generated from MokoConsulting/Template-Joomla
Compare commits
65 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1fcef28b97 | |||
| 05e0428bc7 | |||
| 915663694c | |||
| f69fd34e0b | |||
| 8f6ff4be16 | |||
| 75ff48005a | |||
| 5ab47f9dd3 | |||
| 6ff1d5c478 | |||
| eea11a2da6 | |||
| 1a55c7f9ab | |||
| 7ec525803a | |||
| f5ce2498d7 | |||
| 235d8aaec3 | |||
| bd7fbb55e5 | |||
| 8041b1def2 | |||
| 42bdd4de33 | |||
| 1b04615ec2 | |||
| 2d5195603f | |||
| db51aec0c4 | |||
| 71b649849d | |||
| d8b467dd18 | |||
| a7d5d801fd | |||
| 1180bd9047 | |||
| 362480a7ed | |||
| 66f7ebd369 | |||
| e11f177215 | |||
| df8b2a90d9 | |||
| 7d10b89865 | |||
| 4921d8d7c4 | |||
| ce729cc072 | |||
| 2836360f73 | |||
| afb711bc1d | |||
| a305d423c3 | |||
| 125e505492 | |||
| f9a3fe3639 | |||
| b34c323d81 | |||
| 80c94bbc27 | |||
| daa6d91fd5 | |||
| bb3e0636ef | |||
| 2c4227656e | |||
| d154d2d309 | |||
| a15ee9c8bd | |||
| 3ff1a3464b | |||
| fccac1a510 | |||
| 7e175d8af2 | |||
| 8d87f3920a | |||
| ea0fe519b2 | |||
| 9b0de50cae | |||
| 8b496dc26b | |||
| b32aa8b573 | |||
| dcc652157c | |||
| df59df6ea9 | |||
| 9e30eb787b | |||
| 32539543df | |||
| b4b8b026e7 | |||
| c1c0aef952 | |||
| 652d27fa40 | |||
| bd2340da9b | |||
| 0ba983086a | |||
| a44e51ce5c | |||
| 300f6cad1a | |||
| 7396dc3b86 | |||
| 0dbf8c6891 | |||
| 4eaa742c8c | |||
| 41a1efdd2c |
File diff suppressed because it is too large
Load Diff
@@ -1,213 +0,0 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: MokoStandards.Maintenance
|
||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API
|
||||
# PATH: /templates/workflows/cascade-dev.yml.template
|
||||
# VERSION: 02.00.00
|
||||
# BRIEF: Forward-merge main → all open branches after every push to main
|
||||
#
|
||||
# +========================================================================+
|
||||
# | CASCADE MAIN → ALL BRANCHES |
|
||||
# +========================================================================+
|
||||
# | |
|
||||
# | Triggers on every push to main (PR merges, bot commits, etc.) |
|
||||
# | |
|
||||
# | 1. List all branches matching: dev, rc/*, beta/*, alpha/* |
|
||||
# | 2. For each: create PR (main → branch), auto-merge if clean |
|
||||
# | 3. On conflict: leave PR open for manual resolution |
|
||||
# | |
|
||||
# +========================================================================+
|
||||
|
||||
name: "Universal: Cascade Main → Dev"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }}
|
||||
GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
cascade:
|
||||
name: Cascade main → branches
|
||||
runs-on: ubuntu-latest
|
||||
if: >-
|
||||
!contains(github.event.head_commit.message, '[skip ci]') &&
|
||||
!contains(github.event.head_commit.message, '[skip cascade]')
|
||||
|
||||
steps:
|
||||
- name: Discover target branches
|
||||
id: branches
|
||||
env:
|
||||
GA_TOKEN: ${{ secrets.GA_TOKEN }}
|
||||
run: |
|
||||
API="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
|
||||
# Fetch all branches (paginated)
|
||||
PAGE=1
|
||||
ALL_BRANCHES=""
|
||||
while true; do
|
||||
BATCH=$(curl -sS \
|
||||
-H "Authorization: token ${GA_TOKEN}" \
|
||||
"${API}/branches?page=${PAGE}&limit=50" \
|
||||
| jq -r '.[].name // empty')
|
||||
[ -z "$BATCH" ] && break
|
||||
ALL_BRANCHES="$ALL_BRANCHES $BATCH"
|
||||
PAGE=$((PAGE + 1))
|
||||
done
|
||||
|
||||
# Filter to cascade targets: dev, dev/*, rc/*, beta/*, alpha/*
|
||||
TARGETS=""
|
||||
for BRANCH in $ALL_BRANCHES; do
|
||||
case "$BRANCH" in
|
||||
dev|dev/*|rc/*|beta/*|alpha/*)
|
||||
TARGETS="$TARGETS $BRANCH"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
TARGETS=$(echo "$TARGETS" | xargs) # trim whitespace
|
||||
|
||||
if [ -z "$TARGETS" ]; then
|
||||
echo "targets=" >> "$GITHUB_OUTPUT"
|
||||
echo "ℹ️ No cascade target branches found"
|
||||
else
|
||||
echo "targets=$TARGETS" >> "$GITHUB_OUTPUT"
|
||||
COUNT=$(echo "$TARGETS" | wc -w)
|
||||
echo "📋 Found ${COUNT} target branch(es): ${TARGETS}"
|
||||
fi
|
||||
|
||||
- name: Cascade to all target branches
|
||||
if: steps.branches.outputs.targets != ''
|
||||
env:
|
||||
GA_TOKEN: ${{ secrets.GA_TOKEN }}
|
||||
run: |
|
||||
API="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
SHORT_SHA="${GITHUB_SHA:0:7}"
|
||||
TARGETS="${{ steps.branches.outputs.targets }}"
|
||||
|
||||
SUCCESS=0
|
||||
CONFLICTS=0
|
||||
SKIPPED=0
|
||||
FAILED=0
|
||||
|
||||
for BRANCH in $TARGETS; do
|
||||
echo ""
|
||||
echo "═══ main → ${BRANCH} ═══"
|
||||
|
||||
# Check if branch is already up to date
|
||||
ENCODED_BRANCH=$(echo "$BRANCH" | sed 's|/|%2F|g')
|
||||
RESPONSE=$(curl -sS \
|
||||
-H "Authorization: token ${GA_TOKEN}" \
|
||||
"${API}/compare/${ENCODED_BRANCH}...main")
|
||||
|
||||
AHEAD=$(echo "$RESPONSE" | jq '.total_commits // 0')
|
||||
|
||||
if [ "$AHEAD" -eq 0 ]; then
|
||||
echo " ✅ Already up to date"
|
||||
SKIPPED=$((SKIPPED + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
echo " ℹ️ main is ${AHEAD} commit(s) ahead"
|
||||
|
||||
# Check for existing cascade PR
|
||||
EXISTING=$(curl -sS \
|
||||
-H "Authorization: token ${GA_TOKEN}" \
|
||||
"${API}/pulls?state=open&head=${GITEA_ORG}:main&base=${ENCODED_BRANCH}&limit=1")
|
||||
|
||||
EXISTING_COUNT=$(echo "$EXISTING" | jq 'length')
|
||||
PR_NUMBER=""
|
||||
|
||||
if [ "$EXISTING_COUNT" -gt 0 ]; then
|
||||
PR_NUMBER=$(echo "$EXISTING" | jq -r '.[0].number')
|
||||
echo " ℹ️ Reusing existing PR #${PR_NUMBER}"
|
||||
else
|
||||
# Create cascade PR
|
||||
PR_RESPONSE=$(curl -sS -w "\n%{http_code}" \
|
||||
-X POST \
|
||||
-H "Authorization: token ${GA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"title\": \"chore: cascade main → ${BRANCH} (${SHORT_SHA}) [skip ci]\",
|
||||
\"body\": \"## Automatic cascade\\n\\nForward-merging \`main\` (${SHORT_SHA}) into \`${BRANCH}\`.\\n\\nIf conflicts exist, resolve manually and merge.\\n\\n> Auto-created by **Cascade Main → Dev**.\",
|
||||
\"head\": \"main\",
|
||||
\"base\": \"${BRANCH}\"
|
||||
}" \
|
||||
"${API}/pulls")
|
||||
|
||||
HTTP_CODE=$(echo "$PR_RESPONSE" | tail -1)
|
||||
BODY=$(echo "$PR_RESPONSE" | sed '$d')
|
||||
PR_NUMBER=$(echo "$BODY" | jq -r '.number // empty')
|
||||
|
||||
if [ "$HTTP_CODE" != "201" ] || [ -z "$PR_NUMBER" ]; then
|
||||
MSG=$(echo "$BODY" | jq -r '.message // .' 2>/dev/null | head -1)
|
||||
echo " ❌ Failed to create PR (HTTP ${HTTP_CODE}): ${MSG}"
|
||||
FAILED=$((FAILED + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
echo " ✅ Created PR #${PR_NUMBER}"
|
||||
fi
|
||||
|
||||
# Try auto-merge
|
||||
PR_DATA=$(curl -sS \
|
||||
-H "Authorization: token ${GA_TOKEN}" \
|
||||
"${API}/pulls/${PR_NUMBER}")
|
||||
|
||||
MERGEABLE=$(echo "$PR_DATA" | jq -r '.mergeable // false')
|
||||
|
||||
if [ "$MERGEABLE" != "true" ]; then
|
||||
echo " ⚠️ Conflicts — PR #${PR_NUMBER} left open"
|
||||
CONFLICTS=$((CONFLICTS + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
MERGE_RESPONSE=$(curl -sS -w "\n%{http_code}" \
|
||||
-X POST \
|
||||
-H "Authorization: token ${GA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"Do\": \"merge\",
|
||||
\"merge_message_field\": \"chore: cascade main → ${BRANCH} [skip ci]\",
|
||||
\"delete_branch_after_merge\": false
|
||||
}" \
|
||||
"${API}/pulls/${PR_NUMBER}/merge")
|
||||
|
||||
MERGE_HTTP=$(echo "$MERGE_RESPONSE" | tail -1)
|
||||
|
||||
if [ "$MERGE_HTTP" = "200" ] || [ "$MERGE_HTTP" = "204" ]; then
|
||||
echo " ✅ Merged — ${BRANCH} is in sync"
|
||||
SUCCESS=$((SUCCESS + 1))
|
||||
else
|
||||
MERGE_BODY=$(echo "$MERGE_RESPONSE" | sed '$d')
|
||||
echo " ⚠️ Merge failed (HTTP ${MERGE_HTTP}) — PR #${PR_NUMBER} left open"
|
||||
CONFLICTS=$((CONFLICTS + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
# Summary
|
||||
echo ""
|
||||
echo "════════════════════════════════════════"
|
||||
echo " ✅ Merged: ${SUCCESS}"
|
||||
echo " ⚠️ Conflicts: ${CONFLICTS}"
|
||||
echo " ⏭️ Up to date: ${SKIPPED}"
|
||||
echo " ❌ Failed: ${FAILED}"
|
||||
echo "════════════════════════════════════════"
|
||||
|
||||
if [ "$FAILED" -gt 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
@@ -1,386 +0,0 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: MokoStandards.Release
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards
|
||||
# PATH: /templates/workflows/universal/pre-release.yml.template
|
||||
# VERSION: 05.00.00
|
||||
# BRIEF: Manual pre-release — builds dev/alpha/beta/rc packages from any branch
|
||||
|
||||
name: "Universal: Pre-Release"
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
stability:
|
||||
description: 'Pre-release channel'
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- development
|
||||
- alpha
|
||||
- beta
|
||||
- release-candidate
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
env:
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }}
|
||||
GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: "Build Pre-Release (${{ inputs.stability }})"
|
||||
runs-on: release
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GA_TOKEN }}
|
||||
|
||||
- name: Setup PHP
|
||||
run: |
|
||||
if ! command -v php &> /dev/null; then
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip >/dev/null 2>&1
|
||||
fi
|
||||
|
||||
- name: Detect platform
|
||||
id: platform
|
||||
run: |
|
||||
# Parse .manifest.xml via manifest_read.php — outputs all fields to GITHUB_OUTPUT
|
||||
php /tmp/mokostandards-api/cli/manifest_read.php --path . --github-output 2>/dev/null || true
|
||||
PLATFORM=$(php /tmp/mokostandards-api/cli/manifest_read.php --path . --field platform 2>/dev/null)
|
||||
[ -z "$PLATFORM" ] && PLATFORM="generic"
|
||||
echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT"
|
||||
# entry-point from manifest, find as fallback
|
||||
MOD_FILE=$(php /tmp/mokostandards-api/cli/manifest_read.php --path . --field entry-point 2>/dev/null)
|
||||
[ -z "$MOD_FILE" ] && MOD_FILE=$(find . -maxdepth 4 -name "mod*.class.php" ! -path "./.git/*" -exec grep -l 'extends DolibarrModules' {} \; 2>/dev/null | head -1)
|
||||
MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
|
||||
echo "manifest=${MANIFEST}" >> "$GITHUB_OUTPUT"
|
||||
echo "mod_file=${MOD_FILE}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Resolve metadata
|
||||
id: meta
|
||||
run: |
|
||||
STABILITY="${{ inputs.stability }}"
|
||||
|
||||
case "$STABILITY" in
|
||||
development) SUFFIX="-dev"; TAG="development" ;;
|
||||
alpha) SUFFIX="-alpha"; TAG="alpha" ;;
|
||||
beta) SUFFIX="-beta"; TAG="beta" ;;
|
||||
release-candidate) SUFFIX="-rc"; TAG="release-candidate" ;;
|
||||
esac
|
||||
|
||||
# Read and bump patch version (with rollover)
|
||||
CURRENT=$(sed -n 's/.*VERSION:[[:space:]]*\([0-9][0-9]\.[0-9][0-9]\.[0-9][0-9]\).*/\1/p' README.md 2>/dev/null | head -1)
|
||||
[ -z "$CURRENT" ] && CURRENT="00.00.00"
|
||||
|
||||
MAJOR=$(echo "$CURRENT" | cut -d. -f1)
|
||||
MINOR=$(echo "$CURRENT" | cut -d. -f2)
|
||||
PATCH=$(echo "$CURRENT" | cut -d. -f3)
|
||||
|
||||
# Patch bump with rollover: ZZ=99 → bump minor, YY=99 → bump major
|
||||
NEW_PATCH=$((10#$PATCH + 1))
|
||||
NEW_MINOR=$((10#$MINOR))
|
||||
NEW_MAJOR=$((10#$MAJOR))
|
||||
|
||||
if [ $NEW_PATCH -gt 99 ]; then
|
||||
NEW_PATCH=0
|
||||
NEW_MINOR=$((NEW_MINOR + 1))
|
||||
fi
|
||||
if [ $NEW_MINOR -gt 99 ]; then
|
||||
NEW_MINOR=0
|
||||
NEW_MAJOR=$((NEW_MAJOR + 1))
|
||||
fi
|
||||
|
||||
VERSION=$(printf "%02d.%02d.%02d" $NEW_MAJOR $NEW_MINOR $NEW_PATCH)
|
||||
TODAY=$(date +%Y-%m-%d)
|
||||
|
||||
echo "Bumping: ${CURRENT} → ${VERSION} (patch)"
|
||||
|
||||
# Update README.md
|
||||
sed -i "s/VERSION:[[:space:]]*${CURRENT}/VERSION: ${VERSION}/" README.md
|
||||
|
||||
# Update platform-specific manifest
|
||||
PLATFORM="${{ steps.platform.outputs.platform }}"
|
||||
MANIFEST="${{ steps.platform.outputs.manifest }}"
|
||||
MOD_FILE="${{ steps.platform.outputs.mod_file }}"
|
||||
case "$PLATFORM" in
|
||||
joomla)
|
||||
if [ -n "$MANIFEST" ]; then
|
||||
MANIFEST_VER=$(sed -n 's/.*<version>\([^<]*\)<\/version>.*/\1/p' "$MANIFEST" | head -1)
|
||||
sed -i "s|<version>${MANIFEST_VER}</version>|<version>${VERSION}</version>|" "$MANIFEST"
|
||||
sed -i "s|<creationDate>[^<]*</creationDate>|<creationDate>${TODAY}</creationDate>|" "$MANIFEST"
|
||||
fi
|
||||
;;
|
||||
dolibarr)
|
||||
if [ -n "$MOD_FILE" ]; then
|
||||
sed -i "s/\$this->version = '[^']*'/\$this->version = '${VERSION}'/" "$MOD_FILE"
|
||||
fi
|
||||
;;
|
||||
*) ;;
|
||||
esac
|
||||
|
||||
# Commit version bump
|
||||
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
|
||||
git config --local user.name "gitea-actions[bot]"
|
||||
git remote set-url origin "https://jmiller:${{ secrets.GA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
|
||||
git add -A
|
||||
git diff --cached --quiet || {
|
||||
git commit -m "chore(version): bump ${CURRENT} → ${VERSION} [skip ci]"
|
||||
git push origin HEAD 2>&1
|
||||
}
|
||||
|
||||
# Auto-detect element (platform-aware)
|
||||
case "$PLATFORM" in
|
||||
joomla)
|
||||
MANIFEST="${{ steps.platform.outputs.manifest }}"
|
||||
EXT_ELEMENT=""
|
||||
if [ -n "$MANIFEST" ]; then
|
||||
EXT_ELEMENT=$(sed -n 's/.*<element>\([^<]*\)<\/element>.*/\1/p' "$MANIFEST" 2>/dev/null | head -1)
|
||||
if [ -z "$EXT_ELEMENT" ]; then
|
||||
EXT_ELEMENT=$(basename "$MANIFEST" .xml | tr '[:upper:]' '[:lower:]')
|
||||
case "$EXT_ELEMENT" in
|
||||
templatedetails|manifest) EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -') ;;
|
||||
esac
|
||||
fi
|
||||
else
|
||||
EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -')
|
||||
fi
|
||||
;;
|
||||
dolibarr)
|
||||
MOD_FILE="${{ steps.platform.outputs.mod_file }}"
|
||||
if [ -n "$MOD_FILE" ]; then
|
||||
MOD_BASENAME=$(basename "$MOD_FILE" .class.php)
|
||||
EXT_ELEMENT=$(echo "$MOD_BASENAME" | sed 's/^mod//' | tr '[:upper:]' '[:lower:]')
|
||||
else
|
||||
EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -')
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -')
|
||||
;;
|
||||
esac
|
||||
|
||||
ZIP_NAME="${EXT_ELEMENT}-${VERSION}${SUFFIX}.zip"
|
||||
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "stability=${STABILITY}" >> "$GITHUB_OUTPUT"
|
||||
echo "suffix=${SUFFIX}" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
||||
echo "zip_name=${ZIP_NAME}" >> "$GITHUB_OUTPUT"
|
||||
echo "ext_element=${EXT_ELEMENT}" >> "$GITHUB_OUTPUT"
|
||||
echo "manifest=${MANIFEST}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
echo "=== Pre-Release: ${EXT_ELEMENT} ${VERSION}${SUFFIX} ==="
|
||||
|
||||
- name: Build package
|
||||
run: |
|
||||
SOURCE_DIR="src"
|
||||
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
|
||||
if [ ! -d "$SOURCE_DIR" ]; then
|
||||
echo "::error::No src/ or htdocs/ directory"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p build/package
|
||||
rsync -a \
|
||||
--exclude='sftp-config*' \
|
||||
--exclude='.ftpignore' \
|
||||
--exclude='*.ppk' \
|
||||
--exclude='*.pem' \
|
||||
--exclude='*.key' \
|
||||
--exclude='.env*' \
|
||||
--exclude='*.local' \
|
||||
--exclude='.build-trigger' \
|
||||
"${SOURCE_DIR}/" build/package/
|
||||
|
||||
- name: Create ZIP
|
||||
id: zip
|
||||
run: |
|
||||
ZIP_NAME="${{ steps.meta.outputs.zip_name }}"
|
||||
cd build/package
|
||||
zip -r "../${ZIP_NAME}" .
|
||||
cd ..
|
||||
|
||||
SHA256=$(sha256sum "${ZIP_NAME}" | cut -d' ' -f1)
|
||||
echo "sha256=${SHA256}" >> "$GITHUB_OUTPUT"
|
||||
echo "ZIP: ${ZIP_NAME} (SHA: ${SHA256:0:16}...)"
|
||||
|
||||
- name: Create or replace Gitea release
|
||||
id: release
|
||||
run: |
|
||||
TAG="${{ steps.meta.outputs.tag }}"
|
||||
VERSION="${{ steps.meta.outputs.version }}"
|
||||
STABILITY="${{ steps.meta.outputs.stability }}"
|
||||
SHA256="${{ steps.zip.outputs.sha256 }}"
|
||||
ZIP_NAME="${{ steps.meta.outputs.zip_name }}"
|
||||
EXT_ELEMENT="${{ steps.meta.outputs.ext_element }}"
|
||||
TOKEN="${{ secrets.GA_TOKEN }}"
|
||||
API="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
BRANCH=$(git branch --show-current)
|
||||
|
||||
BODY="## ${VERSION} ($(date +%Y-%m-%d))
|
||||
**Channel:** ${STABILITY}
|
||||
**SHA-256:** \`${SHA256}\`"
|
||||
|
||||
# Delete existing release
|
||||
EXISTING_ID=$(curl -sS -H "Authorization: token ${TOKEN}" \
|
||||
"${API}/releases/tags/${TAG}" | jq -r '.id // empty' 2>/dev/null)
|
||||
if [ -n "$EXISTING_ID" ]; then
|
||||
curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
|
||||
"${API}/releases/${EXISTING_ID}" 2>/dev/null || true
|
||||
curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
|
||||
"${API}/tags/${TAG}" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Create release
|
||||
RELEASE_ID=$(curl -sS -X POST -H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API}/releases" \
|
||||
-d "$(jq -n \
|
||||
--arg tag "$TAG" \
|
||||
--arg target "$BRANCH" \
|
||||
--arg name "${EXT_ELEMENT} ${VERSION} (${STABILITY})" \
|
||||
--arg body "$BODY" \
|
||||
'{tag_name: $tag, target_commitish: $target, name: $name, body: $body, prerelease: true}'
|
||||
)" | jq -r '.id')
|
||||
|
||||
echo "release_id=${RELEASE_ID}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Upload ZIP
|
||||
curl -sS -X POST -H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
"${API}/releases/${RELEASE_ID}/assets?name=${ZIP_NAME}" \
|
||||
--data-binary "@build/${ZIP_NAME}"
|
||||
|
||||
echo "Released: ${EXT_ELEMENT} ${VERSION} (${STABILITY})"
|
||||
|
||||
- name: Update updates.xml
|
||||
if: steps.platform.outputs.platform == 'joomla'
|
||||
run: |
|
||||
STABILITY="${{ steps.meta.outputs.stability }}"
|
||||
VERSION="${{ steps.meta.outputs.version }}"
|
||||
SHA256="${{ steps.zip.outputs.sha256 }}"
|
||||
ZIP_NAME="${{ steps.meta.outputs.zip_name }}"
|
||||
TAG="${{ steps.meta.outputs.tag }}"
|
||||
DATE=$(date +%Y-%m-%d)
|
||||
|
||||
if [ ! -f "updates.xml" ]; then
|
||||
echo "No updates.xml — skipping"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
export PY_STABILITY="$STABILITY" PY_VERSION="$VERSION" PY_SHA256="$SHA256" \
|
||||
PY_ZIP_NAME="$ZIP_NAME" PY_TAG="$TAG" PY_DATE="$DATE" \
|
||||
PY_GITEA_ORG="$GITEA_ORG" PY_GITEA_REPO="$GITEA_REPO"
|
||||
python3 << 'PYEOF'
|
||||
import re, os
|
||||
|
||||
stability = os.environ["PY_STABILITY"]
|
||||
version = os.environ["PY_VERSION"]
|
||||
sha256 = os.environ["PY_SHA256"]
|
||||
zip_name = os.environ["PY_ZIP_NAME"]
|
||||
tag = os.environ["PY_TAG"]
|
||||
date = os.environ["PY_DATE"]
|
||||
gitea_org = os.environ["PY_GITEA_ORG"]
|
||||
gitea_repo = os.environ["PY_GITEA_REPO"]
|
||||
download_url = f"https://git.mokoconsulting.tech/{gitea_org}/{gitea_repo}/releases/download/{tag}/{zip_name}"
|
||||
|
||||
with open("updates.xml", "r") as f:
|
||||
content = f.read()
|
||||
|
||||
# Map stability to XML tag name
|
||||
tag_map = {"development": "development", "alpha": "alpha", "beta": "beta", "release-candidate": "rc"}
|
||||
xml_tag = tag_map.get(stability, stability)
|
||||
|
||||
pattern = r"(<update>(?:(?!</update>).)*?<tag>" + re.escape(xml_tag) + r"</tag>.*?</update>)"
|
||||
match = re.search(pattern, content, re.DOTALL)
|
||||
if match:
|
||||
block = match.group(1)
|
||||
updated = re.sub(r"<version>[^<]*</version>", f"<version>{version}</version>", block)
|
||||
updated = re.sub(r"<creationDate>[^<]*</creationDate>", f"<creationDate>{date}</creationDate>", updated)
|
||||
if "<sha256>" in updated:
|
||||
updated = re.sub(r"<sha256>[^<]*</sha256>", f"<sha256>{sha256}</sha256>", updated)
|
||||
else:
|
||||
updated = updated.replace("</downloads>", f"</downloads>\n <sha256>{sha256}</sha256>")
|
||||
updated = re.sub(r"(<downloadurl[^>]*>)[^<]*(</downloadurl>)", rf"\g<1>{download_url}\g<2>", updated)
|
||||
content = content.replace(block, updated)
|
||||
print(f"Updated {xml_tag} channel: version={version}")
|
||||
else:
|
||||
print(f"WARNING: No <tag>{xml_tag}</tag> block in updates.xml")
|
||||
|
||||
with open("updates.xml", "w") as f:
|
||||
f.write(content)
|
||||
PYEOF
|
||||
|
||||
# Commit and push to current branch
|
||||
if ! git diff --quiet updates.xml 2>/dev/null; then
|
||||
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
|
||||
git config --local user.name "gitea-actions[bot]"
|
||||
git add updates.xml
|
||||
git commit -m "chore: update ${STABILITY} channel ${VERSION} [skip ci]"
|
||||
git push origin HEAD 2>&1 || echo "WARNING: push failed"
|
||||
fi
|
||||
|
||||
- name: "Sync updates.xml to all branches"
|
||||
if: steps.platform.outputs.platform == 'joomla'
|
||||
run: |
|
||||
CURRENT_BRANCH="${{ github.ref_name }}"
|
||||
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
|
||||
git config --local user.name "gitea-actions[bot]"
|
||||
|
||||
# Sync updates.xml to main and dev (whichever isn't current)
|
||||
for BRANCH in main dev; do
|
||||
[ "$BRANCH" = "$CURRENT_BRANCH" ] && continue
|
||||
|
||||
echo "Syncing updates.xml → ${BRANCH}"
|
||||
git fetch origin "${BRANCH}" 2>/dev/null || continue
|
||||
git checkout "origin/${BRANCH}" -- . 2>/dev/null || continue
|
||||
git checkout "${CURRENT_BRANCH}" -- updates.xml
|
||||
if ! git diff --quiet updates.xml 2>/dev/null; then
|
||||
git add updates.xml
|
||||
git commit -m "chore: sync updates.xml from ${CURRENT_BRANCH} [skip ci]"
|
||||
git push origin HEAD:refs/heads/${BRANCH} 2>&1 || echo "WARNING: push to ${BRANCH} failed"
|
||||
fi
|
||||
git checkout "${CURRENT_BRANCH}" 2>/dev/null
|
||||
done
|
||||
|
||||
- name: "Delete lesser pre-release channels (cascade)"
|
||||
continue-on-error: true
|
||||
run: |
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
TOKEN="${{ secrets.GA_TOKEN }}"
|
||||
STABILITY="${{ steps.meta.outputs.stability }}"
|
||||
|
||||
# Cascade: rc → beta,alpha,dev | beta → alpha,dev | alpha → dev | dev → nothing
|
||||
case "$STABILITY" in
|
||||
release-candidate) TAGS_TO_DELETE="beta alpha development" ;;
|
||||
beta) TAGS_TO_DELETE="alpha development" ;;
|
||||
alpha) TAGS_TO_DELETE="development" ;;
|
||||
*) TAGS_TO_DELETE="" ;;
|
||||
esac
|
||||
|
||||
[ -z "$TAGS_TO_DELETE" ] && exit 0
|
||||
|
||||
for TAG in $TAGS_TO_DELETE; do
|
||||
RELEASE_ID=$(curl -sS -H "Authorization: token ${TOKEN}" \
|
||||
"${API_BASE}/releases/tags/${TAG}" 2>/dev/null | \
|
||||
python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
|
||||
|
||||
if [ -n "$RELEASE_ID" ] && [ "$RELEASE_ID" != "None" ]; then
|
||||
curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
|
||||
"${API_BASE}/releases/${RELEASE_ID}" 2>/dev/null || true
|
||||
curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
|
||||
"${API_BASE}/tags/${TAG}" 2>/dev/null || true
|
||||
echo "Deleted: ${TAG} (id: ${RELEASE_ID})"
|
||||
fi
|
||||
done
|
||||
@@ -1,464 +0,0 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: MokoStandards.Joomla
|
||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API
|
||||
# PATH: /templates/workflows/joomla/update-server.yml.template
|
||||
# VERSION: 04.06.00
|
||||
# BRIEF: Update Joomla update server XML feed with stable/rc/dev entries
|
||||
#
|
||||
# Writes updates.xml with multiple <update> entries:
|
||||
# - <tag>stable</tag> on push to main (from auto-release)
|
||||
# - <tag>rc</tag> on push to rc/**
|
||||
# - <tag>development</tag> on push to dev or dev/**
|
||||
#
|
||||
# Joomla filters by user's "Minimum Stability" setting.
|
||||
|
||||
name: "Joomla: Update Server"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'dev'
|
||||
- 'dev/**'
|
||||
- 'alpha/**'
|
||||
- 'beta/**'
|
||||
- 'rc/**'
|
||||
paths:
|
||||
- 'src/**'
|
||||
- 'htdocs/**'
|
||||
pull_request:
|
||||
types: [closed]
|
||||
branches:
|
||||
- 'dev'
|
||||
- 'dev/**'
|
||||
- 'alpha/**'
|
||||
- 'beta/**'
|
||||
- 'rc/**'
|
||||
paths:
|
||||
- 'src/**'
|
||||
- 'htdocs/**'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
stability:
|
||||
description: 'Stability tag'
|
||||
required: true
|
||||
default: 'development'
|
||||
type: choice
|
||||
options:
|
||||
- development
|
||||
- alpha
|
||||
- beta
|
||||
- rc
|
||||
- stable
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }}
|
||||
GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
update-xml:
|
||||
name: Update updates.xml
|
||||
runs-on: release
|
||||
if: >-
|
||||
github.event.pull_request.merged == true || github.event_name == 'workflow_dispatch' || github.event_name == 'push'
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
token: ${{ secrets.GA_TOKEN }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup MokoStandards tools
|
||||
env:
|
||||
MOKO_CLONE_TOKEN: ${{ secrets.GA_TOKEN }}
|
||||
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
|
||||
COMPOSER_AUTH: '{"http-basic":{"git.mokoconsulting.tech":{"username":"token","password":"${{ secrets.GA_TOKEN }}"}}}'
|
||||
run: |
|
||||
if ! command -v composer &> /dev/null; then
|
||||
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
|
||||
fi
|
||||
git clone --depth 1 --branch main --quiet \
|
||||
"https://${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \
|
||||
/tmp/mokostandards-api 2>/dev/null || true
|
||||
if [ -d "/tmp/mokostandards-api" ] && [ -f "/tmp/mokostandards-api/composer.json" ]; then
|
||||
cd /tmp/mokostandards-api && composer install --no-dev --no-interaction --quiet 2>/dev/null || true
|
||||
fi
|
||||
|
||||
- name: Generate updates.xml entry
|
||||
id: update
|
||||
run: |
|
||||
BRANCH="${{ github.ref_name }}"
|
||||
REPO="${{ github.repository }}"
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
VERSION=$(php /tmp/mokostandards-api/cli/version_read.php --path . 2>/dev/null || echo "0.0.0")
|
||||
|
||||
# Auto-bump patch on all branches (dev, alpha, beta, rc)
|
||||
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
|
||||
git config --local user.name "gitea-actions[bot]"
|
||||
BUMPED=$(php /tmp/mokostandards-api/cli/version_bump.php --path . 2>/dev/null || true)
|
||||
if [ -n "$BUMPED" ]; then
|
||||
VERSION=$(php /tmp/mokostandards-api/cli/version_read.php --path . 2>/dev/null || echo "$VERSION")
|
||||
git add -A
|
||||
git commit -m "chore(version): auto-bump patch ${VERSION} [skip ci]" \
|
||||
--author="gitea-actions[bot] <gitea-actions[bot]@mokoconsulting.tech>" 2>/dev/null || true
|
||||
git push 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Determine stability from branch or input
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
STABILITY="${{ inputs.stability }}"
|
||||
elif [[ "$BRANCH" == rc/* ]]; then
|
||||
STABILITY="rc"
|
||||
elif [[ "$BRANCH" == beta/* ]]; then
|
||||
STABILITY="beta"
|
||||
elif [[ "$BRANCH" == alpha/* ]]; then
|
||||
STABILITY="alpha"
|
||||
elif [[ "$BRANCH" == dev/* ]] || [[ "$BRANCH" == "dev" ]]; then
|
||||
STABILITY="development"
|
||||
else
|
||||
STABILITY="stable"
|
||||
fi
|
||||
|
||||
echo "stability=${STABILITY}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Parse manifest (portable — no grep -P)
|
||||
MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" ! -path "./build/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
|
||||
if [ -z "$MANIFEST" ]; then
|
||||
echo "No Joomla manifest found — skipping"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Extract fields using sed (works on all runners)
|
||||
EXT_NAME=$(sed -n 's/.*<name>\([^<]*\)<\/name>.*/\1/p' "$MANIFEST" | head -1)
|
||||
EXT_TYPE=$(sed -n 's/.*<extension[^>]*type="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
|
||||
EXT_ELEMENT=$(sed -n 's/.*<element>\([^<]*\)<\/element>.*/\1/p' "$MANIFEST" | head -1)
|
||||
EXT_CLIENT=$(sed -n 's/.*<extension[^>]*client="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
|
||||
EXT_FOLDER=$(sed -n 's/.*<extension[^>]*group="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
|
||||
EXT_VERSION=$(sed -n 's/.*<version>\([^<]*\)<\/version>.*/\1/p' "$MANIFEST" | head -1)
|
||||
TARGET_PLATFORM=$(sed -n 's/.*\(<targetplatform[^/]*\/>\).*/\1/p' "$MANIFEST" | head -1)
|
||||
PHP_MINIMUM=$(sed -n 's/.*<php_minimum>\([^<]*\)<\/php_minimum>.*/\1/p' "$MANIFEST" | head -1)
|
||||
|
||||
# Fallbacks
|
||||
[ -z "$EXT_NAME" ] && EXT_NAME="${{ github.event.repository.name }}"
|
||||
[ -z "$EXT_TYPE" ] && EXT_TYPE="component"
|
||||
|
||||
# Derive element if not in manifest: try XML filename, then repo name
|
||||
if [ -z "$EXT_ELEMENT" ]; then
|
||||
EXT_ELEMENT=$(basename "$MANIFEST" .xml | tr '[:upper:]' '[:lower:]')
|
||||
case "$EXT_ELEMENT" in
|
||||
templatedetails|manifest|*.xml) EXT_ELEMENT=$(echo "${{ github.event.repository.name }}" | tr '[:upper:]' '[:lower:]' | tr -d ' -') ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# Use manifest version if README version is empty
|
||||
[ "$VERSION" = "0.0.0" ] && [ -n "$EXT_VERSION" ] && VERSION="$EXT_VERSION"
|
||||
|
||||
[ -z "$TARGET_PLATFORM" ] && TARGET_PLATFORM=$(printf '<targetplatform name="joomla" version="((5.[0-9])|(6.[0-9]))" %s>' "/")
|
||||
|
||||
CLIENT_TAG=""
|
||||
[ -n "$EXT_CLIENT" ] && CLIENT_TAG="<client>${EXT_CLIENT}</client>"
|
||||
[ -z "$CLIENT_TAG" ] && ([ "$EXT_TYPE" = "module" ] || [ "$EXT_TYPE" = "plugin" ]) && CLIENT_TAG="<client>site</client>"
|
||||
|
||||
FOLDER_TAG=""
|
||||
[ -n "$EXT_FOLDER" ] && [ "$EXT_TYPE" = "plugin" ] && FOLDER_TAG="<folder>${EXT_FOLDER}</folder>"
|
||||
|
||||
PHP_TAG=""
|
||||
[ -n "$PHP_MINIMUM" ] && PHP_TAG="<php_minimum>${PHP_MINIMUM}</php_minimum>"
|
||||
|
||||
# Version suffix for non-stable
|
||||
DISPLAY_VERSION="$VERSION"
|
||||
case "$STABILITY" in
|
||||
development) DISPLAY_VERSION="${VERSION}-dev" ;;
|
||||
alpha) DISPLAY_VERSION="${VERSION}-alpha" ;;
|
||||
beta) DISPLAY_VERSION="${VERSION}-beta" ;;
|
||||
rc) DISPLAY_VERSION="${VERSION}-rc" ;;
|
||||
esac
|
||||
|
||||
MAJOR=$(echo "$VERSION" | awk -F. '{print $1}')
|
||||
|
||||
# Each stability level has its own release tag
|
||||
case "$STABILITY" in
|
||||
development) RELEASE_TAG="development" ;;
|
||||
alpha) RELEASE_TAG="alpha" ;;
|
||||
beta) RELEASE_TAG="beta" ;;
|
||||
rc) RELEASE_TAG="release-candidate" ;;
|
||||
*) RELEASE_TAG="v${MAJOR}" ;;
|
||||
esac
|
||||
|
||||
PACKAGE_NAME="${EXT_ELEMENT}-${DISPLAY_VERSION}.zip"
|
||||
DOWNLOAD_URL="${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/download/${RELEASE_TAG}/${PACKAGE_NAME}"
|
||||
INFO_URL="${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}"
|
||||
|
||||
# -- Build install packages (ZIP + tar.gz) --------------------
|
||||
SOURCE_DIR="src"
|
||||
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
|
||||
if [ -d "$SOURCE_DIR" ]; then
|
||||
EXCLUDES=".ftpignore sftp-config* *.ppk *.pem *.key .env*"
|
||||
TAR_NAME="${EXT_ELEMENT}-${DISPLAY_VERSION}.tar.gz"
|
||||
|
||||
cd "$SOURCE_DIR"
|
||||
zip -r "/tmp/${PACKAGE_NAME}" . -x $EXCLUDES
|
||||
cd ..
|
||||
tar -czf "/tmp/${TAR_NAME}" -C "$SOURCE_DIR" \
|
||||
--exclude='.ftpignore' --exclude='sftp-config*' \
|
||||
--exclude='*.ppk' --exclude='*.pem' --exclude='*.key' --exclude='.env*' .
|
||||
|
||||
SHA256=$(sha256sum "/tmp/${PACKAGE_NAME}" | cut -d' ' -f1)
|
||||
|
||||
# Ensure release exists on Gitea
|
||||
RELEASE_JSON=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
|
||||
"${API_BASE}/releases/tags/${RELEASE_TAG}" 2>/dev/null || true)
|
||||
RELEASE_ID=$(echo "$RELEASE_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
|
||||
|
||||
if [ -z "$RELEASE_ID" ]; then
|
||||
# Create release
|
||||
RELEASE_JSON=$(curl -sf -X POST -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API_BASE}/releases" \
|
||||
-d "$(python3 -c "import json; print(json.dumps({
|
||||
'tag_name': '${RELEASE_TAG}',
|
||||
'name': '${RELEASE_TAG} (${DISPLAY_VERSION})',
|
||||
'body': '${STABILITY} release',
|
||||
'prerelease': True,
|
||||
'target_commitish': 'main'
|
||||
}))")" 2>/dev/null || true)
|
||||
RELEASE_ID=$(echo "$RELEASE_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
|
||||
fi
|
||||
|
||||
if [ -n "$RELEASE_ID" ]; then
|
||||
# Delete existing assets with same name before uploading
|
||||
ASSETS=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
|
||||
"${API_BASE}/releases/${RELEASE_ID}/assets" 2>/dev/null || echo "[]")
|
||||
for ASSET_FILE in "$PACKAGE_NAME" "$TAR_NAME"; do
|
||||
ASSET_ID=$(echo "$ASSETS" | python3 -c "
|
||||
import sys,json
|
||||
assets = json.load(sys.stdin)
|
||||
for a in assets:
|
||||
if a['name'] == '${ASSET_FILE}':
|
||||
print(a['id']); break
|
||||
" 2>/dev/null || true)
|
||||
if [ -n "$ASSET_ID" ]; then
|
||||
curl -sf -X DELETE -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
|
||||
"${API_BASE}/releases/${RELEASE_ID}/assets/${ASSET_ID}" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
|
||||
# Upload both formats
|
||||
curl -sf -X POST -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary @"/tmp/${PACKAGE_NAME}" \
|
||||
"${API_BASE}/releases/${RELEASE_ID}/assets?name=${PACKAGE_NAME}" > /dev/null 2>&1 || true
|
||||
|
||||
curl -sf -X POST -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary @"/tmp/${TAR_NAME}" \
|
||||
"${API_BASE}/releases/${RELEASE_ID}/assets?name=${TAR_NAME}" > /dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
echo "Packages: ${PACKAGE_NAME} + ${TAR_NAME} (SHA: ${SHA256})" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
SHA256=""
|
||||
fi
|
||||
|
||||
# -- Build the new entry (canonical format matching release.yml) --
|
||||
NEW_ENTRY=""
|
||||
NEW_ENTRY="${NEW_ENTRY} <update>\n"
|
||||
NEW_ENTRY="${NEW_ENTRY} <name>${EXT_NAME}</name>\n"
|
||||
NEW_ENTRY="${NEW_ENTRY} <description>${EXT_NAME} ${STABILITY} build.</description>\n"
|
||||
NEW_ENTRY="${NEW_ENTRY} <element>${EXT_ELEMENT}</element>\n"
|
||||
NEW_ENTRY="${NEW_ENTRY} <type>${EXT_TYPE}</type>\n"
|
||||
[ -n "$CLIENT_TAG" ] && NEW_ENTRY="${NEW_ENTRY} ${CLIENT_TAG}\n"
|
||||
[ -n "$FOLDER_TAG" ] && NEW_ENTRY="${NEW_ENTRY} ${FOLDER_TAG}\n"
|
||||
NEW_ENTRY="${NEW_ENTRY} <version>${VERSION}</version>\n"
|
||||
NEW_ENTRY="${NEW_ENTRY} <creationDate>$(date +%Y-%m-%d)</creationDate>\n"
|
||||
NEW_ENTRY="${NEW_ENTRY} <infourl title='${EXT_NAME}'>https://git.mokoconsulting.tech/${GITEA_ORG}/${GITEA_REPO}/releases/tag/${RELEASE_TAG}</infourl>\n"
|
||||
NEW_ENTRY="${NEW_ENTRY} <downloads>\n"
|
||||
NEW_ENTRY="${NEW_ENTRY} <downloadurl type='full' format='zip'>${DOWNLOAD_URL}</downloadurl>\n"
|
||||
NEW_ENTRY="${NEW_ENTRY} </downloads>\n"
|
||||
[ -n "$SHA256" ] && NEW_ENTRY="${NEW_ENTRY} <sha256>${SHA256}</sha256>\n"
|
||||
NEW_ENTRY="${NEW_ENTRY} <tags><tag>${STABILITY}</tag></tags>\n"
|
||||
NEW_ENTRY="${NEW_ENTRY} <maintainer>Moko Consulting</maintainer>\n"
|
||||
NEW_ENTRY="${NEW_ENTRY} <maintainerurl>https://mokoconsulting.tech</maintainerurl>\n"
|
||||
NEW_ENTRY="${NEW_ENTRY} <targetplatform name='joomla' version='(5|6).*'/>\n"
|
||||
[ -n "$PHP_MINIMUM" ] && NEW_ENTRY="${NEW_ENTRY} <php_minimum>${PHP_MINIMUM}</php_minimum>\n"
|
||||
NEW_ENTRY="${NEW_ENTRY} </update>"
|
||||
|
||||
# -- Write new entry to temp file --------------------------------
|
||||
printf '%b' "$NEW_ENTRY" > /tmp/new_entry.xml
|
||||
|
||||
# -- Merge into updates.xml ----------------------------------------
|
||||
# Cascade: stable→all | rc→rc+lower | beta→beta+lower | alpha→alpha+dev | dev→dev
|
||||
CASCADE_MAP="stable:development,alpha,beta,rc,stable rc:development,alpha,beta,rc beta:development,alpha,beta alpha:development,alpha development:development"
|
||||
TARGETS=""
|
||||
for entry in $CASCADE_MAP; do
|
||||
key="${entry%%:*}"
|
||||
vals="${entry#*:}"
|
||||
if [ "$key" = "${STABILITY}" ]; then
|
||||
TARGETS="$vals"
|
||||
break
|
||||
fi
|
||||
done
|
||||
[ -z "$TARGETS" ] && TARGETS="${STABILITY}"
|
||||
|
||||
echo "Cascade: ${STABILITY} → ${TARGETS}"
|
||||
|
||||
# Create updates.xml if missing
|
||||
if [ ! -f "updates.xml" ]; then
|
||||
printf '%s\n' "<?xml version='1.0' encoding='UTF-8'?>" > updates.xml
|
||||
printf '%s\n' "<!-- Copyright (C) $(date +%Y) Moko Consulting -->" >> updates.xml
|
||||
printf '%s\n' "<updates>" >> updates.xml
|
||||
printf '%s\n' "</updates>" >> updates.xml
|
||||
fi
|
||||
|
||||
# Update existing blocks or create missing ones
|
||||
export PY_TARGETS="$TARGETS" PY_VERSION="$VERSION" PY_DATE="$(date +%Y-%m-%d)"
|
||||
python3 << 'PYEOF'
|
||||
import re, os
|
||||
|
||||
targets = os.environ["PY_TARGETS"].split(",")
|
||||
version = os.environ["PY_VERSION"]
|
||||
date = os.environ["PY_DATE"]
|
||||
|
||||
with open("updates.xml") as f:
|
||||
content = f.read()
|
||||
with open("/tmp/new_entry.xml") as f:
|
||||
new_entry_template = f.read()
|
||||
|
||||
for tag in targets:
|
||||
tag = tag.strip()
|
||||
# Build entry with this tag's name
|
||||
new_entry = re.sub(r"<tag>[^<]*</tag>", f"<tag>{tag}</tag>", new_entry_template)
|
||||
|
||||
# Try to find existing block (handles both single-line and multi-line <tags>)
|
||||
block_pattern = r"(<update>(?:(?!</update>).)*?<tag>" + re.escape(tag) + r"</tag>.*?</update>)"
|
||||
match = re.search(block_pattern, content, re.DOTALL)
|
||||
|
||||
if match:
|
||||
# Update in place — replace entire block
|
||||
content = content.replace(match.group(1), new_entry.strip())
|
||||
print(f" UPDATED: <tag>{tag}</tag> → {version}")
|
||||
else:
|
||||
# Create — insert before </updates>
|
||||
content = content.replace("</updates>", "\n" + new_entry.strip() + "\n\n</updates>")
|
||||
print(f" CREATED: <tag>{tag}</tag> → {version}")
|
||||
|
||||
# Clean up excessive blank lines
|
||||
content = re.sub(r"\n{3,}", "\n\n", content)
|
||||
|
||||
with open("updates.xml", "w") as f:
|
||||
f.write(content)
|
||||
PYEOF
|
||||
|
||||
# Commit
|
||||
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
|
||||
git config --local user.name "gitea-actions[bot]"
|
||||
git add updates.xml
|
||||
git diff --cached --quiet || {
|
||||
git commit -m "chore: update updates.xml (${STABILITY}: ${DISPLAY_VERSION}) [skip ci]" \
|
||||
--author="gitea-actions[bot] <gitea-actions[bot]@mokoconsulting.tech>"
|
||||
git push
|
||||
}
|
||||
|
||||
# -- Sync updates.xml to main (for non-main branches) ----------------------
|
||||
- name: Sync updates.xml to main
|
||||
if: github.ref_name != 'main'
|
||||
run: |
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
GA_TOKEN="${{ secrets.GA_TOKEN }}"
|
||||
|
||||
FILE_SHA=$(curl -sf -H "Authorization: token ${GA_TOKEN}" \
|
||||
"${API_BASE}/contents/updates.xml?ref=main" | python3 -c "import sys,json; print(json.load(sys.stdin).get('sha',''))" 2>/dev/null || true)
|
||||
|
||||
if [ -n "$FILE_SHA" ] && [ -f "updates.xml" ]; then
|
||||
CONTENT=$(base64 -w0 updates.xml)
|
||||
curl -sf -X PUT -H "Authorization: token ${GA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API_BASE}/contents/updates.xml" \
|
||||
-d "$(python3 -c "import json; print(json.dumps({
|
||||
'content': '${CONTENT}',
|
||||
'sha': '${FILE_SHA}',
|
||||
'message': 'chore: sync updates.xml from ${STABILITY} [skip ci]',
|
||||
'branch': 'main'
|
||||
}))")" > /dev/null 2>&1 \
|
||||
&& echo "updates.xml synced to main (${STABILITY})" >> $GITHUB_STEP_SUMMARY \
|
||||
|| echo "WARNING: failed to sync updates.xml to main" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "WARNING: could not get updates.xml SHA from main" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
- name: SFTP deploy to dev server
|
||||
if: contains(github.ref, 'dev/') || github.ref == 'refs/heads/dev'
|
||||
env:
|
||||
DEV_HOST: ${{ vars.DEV_FTP_HOST }}
|
||||
DEV_PATH: ${{ vars.DEV_FTP_PATH }}
|
||||
DEV_SUFFIX: ${{ vars.DEV_FTP_SUFFIX }}
|
||||
DEV_USER: ${{ vars.DEV_FTP_USERNAME }}
|
||||
DEV_PORT: ${{ vars.DEV_FTP_PORT }}
|
||||
DEV_KEY: ${{ secrets.DEV_FTP_KEY }}
|
||||
DEV_PASS: ${{ secrets.DEV_FTP_PASSWORD }}
|
||||
run: |
|
||||
# -- Permission check: admin or maintain role required --------
|
||||
ACTOR="${{ github.actor }}"
|
||||
REPO="${{ github.repository }}"
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
|
||||
PERMISSION=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
|
||||
"${API_BASE}/collaborators/${ACTOR}/permission" 2>/dev/null | \
|
||||
python3 -c "import sys,json; print(json.load(sys.stdin).get('permission','read'))" 2>/dev/null || echo "read")
|
||||
case "$PERMISSION" in
|
||||
admin|maintain|write) ;;
|
||||
*)
|
||||
echo "Deploy denied: ${ACTOR} has '${PERMISSION}' — requires admin, maintain, or write"
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
|
||||
[ -z "$DEV_HOST" ] || [ -z "$DEV_PATH" ] && { echo "DEV FTP not configured — skipping SFTP"; exit 0; }
|
||||
|
||||
SOURCE_DIR="src"
|
||||
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
|
||||
[ ! -d "$SOURCE_DIR" ] && exit 0
|
||||
|
||||
PORT="${DEV_PORT:-22}"
|
||||
REMOTE="${DEV_PATH%/}"
|
||||
[ -n "$DEV_SUFFIX" ] && REMOTE="${REMOTE}/${DEV_SUFFIX#/}"
|
||||
|
||||
printf '{"host":"%s","port":%s,"username":"%s","remotePath":"%s"' \
|
||||
"$DEV_HOST" "$PORT" "$DEV_USER" "$REMOTE" > /tmp/sftp-config.json
|
||||
if [ -n "$DEV_KEY" ]; then
|
||||
echo "$DEV_KEY" > /tmp/deploy_key && chmod 600 /tmp/deploy_key
|
||||
printf ',"privateKeyPath":"/tmp/deploy_key"}' >> /tmp/sftp-config.json
|
||||
else
|
||||
printf ',"password":"%s"}' "$DEV_PASS" >> /tmp/sftp-config.json
|
||||
fi
|
||||
|
||||
PLATFORM=$(php /tmp/mokostandards-api/cli/platform_detect.php --path . 2>/dev/null || true)
|
||||
if [ "$PLATFORM" = "waas-component" ] && [ -f "/tmp/mokostandards-api/deploy/deploy-joomla.php" ]; then
|
||||
php /tmp/mokostandards-api/deploy/deploy-joomla.php --path . --src-dir "$SOURCE_DIR" --config /tmp/sftp-config.json
|
||||
elif [ -f "/tmp/mokostandards-api/deploy/deploy-sftp.php" ]; then
|
||||
php /tmp/mokostandards-api/deploy/deploy-sftp.php --path . --src-dir "$SOURCE_DIR" --config /tmp/sftp-config.json
|
||||
fi
|
||||
rm -f /tmp/deploy_key /tmp/sftp-config.json
|
||||
echo "SFTP deploy to dev complete" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
- name: Summary
|
||||
if: always()
|
||||
run: |
|
||||
echo "## Joomla Update Server" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Stability | \`${STABILITY}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Version | \`${DISPLAY_VERSION}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Element | \`${EXT_ELEMENT}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Download | [ZIP](${DOWNLOAD_URL}) |" >> $GITHUB_STEP_SUMMARY
|
||||
@@ -1,14 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
MokoStandards Repository Manifest
|
||||
Template: Joomla Extension
|
||||
See: https://git.mokoconsulting.tech/MokoConsulting/moko-platform/wiki/Home
|
||||
-->
|
||||
<moko-platform xmlns="https://standards.mokoconsulting.tech/moko-platform/1.0" schema-version="1.0">
|
||||
<identity>
|
||||
<name>Template-Joomla</name>
|
||||
<name>MokoDoliJoomShop</name>
|
||||
<org>MokoConsulting</org>
|
||||
<description>Template repository for Joomla extensions (plugins, modules, components, templates)</description>
|
||||
<description>Joomla storefront component backed by Dolibarr products and invoicing</description>
|
||||
<license spdx="GPL-3.0-or-later">GNU General Public License v3</license>
|
||||
</identity>
|
||||
<governance>
|
||||
@@ -18,7 +13,7 @@
|
||||
</governance>
|
||||
<build>
|
||||
<language>PHP</language>
|
||||
<package-type>joomla-extension</package-type>
|
||||
<package-type>joomla-component</package-type>
|
||||
<entry-point>src/</entry-point>
|
||||
</build>
|
||||
</moko-platform>
|
||||
@@ -0,0 +1,283 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: moko-platform.Release
|
||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform
|
||||
# PATH: /templates/workflows/universal/auto-release.yml.template
|
||||
# VERSION: 05.00.00
|
||||
# BRIEF: Universal build & release � detects platform from manifest.xml
|
||||
#
|
||||
# +========================================================================+
|
||||
# | UNIVERSAL BUILD & RELEASE PIPELINE |
|
||||
# +========================================================================+
|
||||
# | |
|
||||
# | Reads manifest.xml (joomla|dolibarr|generic) to branch logic. |
|
||||
# | |
|
||||
# | Platform-specific: |
|
||||
# | joomla: XML manifest, updates.xml, type-prefixed packages |
|
||||
# | dolibarr: mod*.class.php, update.txt, dev version reset |
|
||||
# | generic: README-only, no update stream |
|
||||
# | |
|
||||
# +========================================================================+
|
||||
|
||||
name: "Universal: Build & Release"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, closed]
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
action:
|
||||
description: 'Action to perform'
|
||||
required: false
|
||||
type: choice
|
||||
default: release
|
||||
options:
|
||||
- release
|
||||
- promote-rc
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }}
|
||||
GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
# ── PR Opened → Rename branch to RC and build RC release ─────────────────────
|
||||
promote-rc:
|
||||
name: Promote to RC
|
||||
runs-on: release
|
||||
if: >-
|
||||
(github.event.action == 'opened' && github.event.pull_request.merged != true) ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs.action == 'promote-rc')
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup moko-platform tools
|
||||
env:
|
||||
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
|
||||
run: |
|
||||
if ! command -v composer &> /dev/null; then
|
||||
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
|
||||
fi
|
||||
# Always fetch latest CLI tools — never use stale cache from previous runs
|
||||
rm -rf /tmp/moko-platform-api
|
||||
git clone --depth 1 --branch main --quiet \
|
||||
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \
|
||||
/tmp/moko-platform-api
|
||||
cd /tmp/moko-platform-api
|
||||
composer install --no-dev --no-interaction --quiet
|
||||
|
||||
- name: Rename branch to rc
|
||||
run: |
|
||||
php /tmp/moko-platform-api/cli/branch_rename.php \
|
||||
--from "${{ github.event.pull_request.head.ref || 'dev' }}" --to rc \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
|
||||
--api-base "${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" \
|
||||
--pr "${{ github.event.pull_request.number }}"
|
||||
|
||||
- name: Checkout rc and configure git
|
||||
run: |
|
||||
git fetch origin rc
|
||||
git checkout rc
|
||||
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
|
||||
git config --local user.name "gitea-actions[bot]"
|
||||
git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
|
||||
|
||||
- name: Publish RC release
|
||||
run: |
|
||||
php /tmp/moko-platform-api/cli/release_publish.php \
|
||||
--path . --stability rc --bump minor --branch rc \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
|
||||
- name: Summary
|
||||
if: always()
|
||||
run: |
|
||||
echo "## Promoted to Release Candidate" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Branch renamed to rc, minor bump, RC + lesser stream releases built, updates.xml synced" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# ── Merged PR → Build & Release (or promote RC to stable) ────────────────────
|
||||
release:
|
||||
name: Build & Release Pipeline
|
||||
runs-on: release
|
||||
if: >-
|
||||
github.event.pull_request.merged == true ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs.action != 'promote-rc')
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Configure git for bot pushes
|
||||
run: |
|
||||
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
|
||||
git config --local user.name "gitea-actions[bot]"
|
||||
git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
|
||||
|
||||
- name: Check for merge conflict markers
|
||||
run: |
|
||||
CONFLICTS=$(grep -rn '<<<<<<< \|>>>>>>> \|^=======$' --include='*.php' --include='*.xml' --include='*.css' --include='*.js' --include='*.json' --include='*.md' --include='*.yml' --include='*.yaml' --include='*.ini' --include='*.txt' . 2>/dev/null | grep -v '.git/' || true)
|
||||
if [ -n "$CONFLICTS" ]; then
|
||||
echo "::error::Merge conflict markers found — aborting release"
|
||||
echo "## Release Blocked: Conflict Markers" >> $GITHUB_STEP_SUMMARY
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
echo "$CONFLICTS" >> $GITHUB_STEP_SUMMARY
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
fi
|
||||
echo "No conflict markers found"
|
||||
|
||||
- name: Setup moko-platform tools
|
||||
env:
|
||||
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
|
||||
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_MIRROR_TOKEN }}"}}'
|
||||
run: |
|
||||
# Ensure PHP + Composer are available
|
||||
if ! command -v composer &> /dev/null; then
|
||||
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
|
||||
fi
|
||||
# Always fetch latest CLI tools — never use stale cache from previous runs
|
||||
rm -rf /tmp/moko-platform-api
|
||||
git clone --depth 1 --branch main --quiet \
|
||||
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \
|
||||
/tmp/moko-platform-api
|
||||
cd /tmp/moko-platform-api
|
||||
composer install --no-dev --no-interaction --quiet
|
||||
|
||||
|
||||
- name: "Publish stable release"
|
||||
run: |
|
||||
php /tmp/moko-platform-api/cli/release_publish.php \
|
||||
--path . --stability stable --bump minor --branch main \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
|
||||
# -- STEP 9: Mirror to GitHub (stable only) --------------------------------
|
||||
- name: "Step 9: Mirror release to GitHub"
|
||||
if: >-
|
||||
steps.version.outputs.skip != 'true' &&
|
||||
secrets.GH_MIRROR_TOKEN != ''
|
||||
continue-on-error: true
|
||||
run: |
|
||||
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
||||
RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
|
||||
GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}"
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
php /tmp/moko-platform-api/cli/release_mirror.php \
|
||||
--version "$VERSION" --tag "$RELEASE_TAG" \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
|
||||
--gh-token "${{ secrets.GH_MIRROR_TOKEN }}" --gh-repo "$GH_REPO" \
|
||||
--branch main 2>&1 || true
|
||||
echo "GitHub mirror updated" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# -- STEP 10: Sync main branch to GitHub mirror ----------------------------
|
||||
- name: "Step 10: Push main to GitHub mirror"
|
||||
if: >-
|
||||
steps.version.outputs.skip != 'true' &&
|
||||
secrets.GH_MIRROR_TOKEN != ''
|
||||
continue-on-error: true
|
||||
run: |
|
||||
GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}"
|
||||
GH_ORG=$(echo "$GH_REPO" | cut -d/ -f1)
|
||||
GH_NAME=$(echo "$GH_REPO" | cut -d/ -f2)
|
||||
git remote add github "https://x-access-token:${{ secrets.GH_MIRROR_TOKEN }}@github.com/${GH_ORG}/${GH_NAME}.git" 2>/dev/null || \
|
||||
git remote set-url github "https://x-access-token:${{ secrets.GH_MIRROR_TOKEN }}@github.com/${GH_ORG}/${GH_NAME}.git"
|
||||
git fetch origin main --depth=1
|
||||
git push github origin/main:refs/heads/main --force 2>/dev/null \
|
||||
&& echo "main branch pushed to GitHub mirror" \
|
||||
|| echo "WARNING: GitHub mirror push failed"
|
||||
|
||||
- name: "Step 11: Delete rc branch and recreate dev from main"
|
||||
if: steps.version.outputs.skip != 'true'
|
||||
continue-on-error: true
|
||||
run: |
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
|
||||
# Delete rc branch (ephemeral — created by promote-rc)
|
||||
curl -sf -X DELETE -H "Authorization: token ${TOKEN}" \
|
||||
"${API_BASE}/branches/rc" 2>/dev/null \
|
||||
&& echo "Deleted rc branch" || echo "rc branch not found"
|
||||
|
||||
# Delete dev branch
|
||||
curl -sf -X DELETE -H "Authorization: token ${TOKEN}" \
|
||||
"${API_BASE}/branches/dev" 2>/dev/null && echo "Deleted dev branch"
|
||||
|
||||
# Recreate dev from main (now includes version bump + changelog promotion)
|
||||
curl -sf -X POST -H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API_BASE}/branches" \
|
||||
-d '{"new_branch_name":"dev","old_branch_name":"main"}' 2>/dev/null && echo "Recreated dev from main"
|
||||
|
||||
echo "Pre-release branches cleaned, dev reset from main" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
- name: "Step 12: Create version branch from main"
|
||||
if: steps.version.outputs.skip != 'true'
|
||||
continue-on-error: true
|
||||
run: |
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
||||
BRANCH_NAME="version/${VERSION}"
|
||||
MAIN_SHA=$(git rev-parse HEAD)
|
||||
|
||||
# Delete old version branch if it exists (same version re-release)
|
||||
curl -sf -X DELETE -H "Authorization: token ${TOKEN}" "${API_BASE}/branches/${BRANCH_NAME}" 2>/dev/null && echo "Deleted old ${BRANCH_NAME}"
|
||||
|
||||
# Create version/XX.YY.ZZ from main
|
||||
curl -sf -X POST -H "Authorization: token ${TOKEN}" -H "Content-Type: application/json" "${API_BASE}/branches" -d "{\"new_branch_name\":\"${BRANCH_NAME}\",\"old_branch_name\":\"main\"}" 2>/dev/null && echo "Created ${BRANCH_NAME} from main (${MAIN_SHA})" || echo "WARNING: ${BRANCH_NAME} creation failed"
|
||||
|
||||
echo "Version branch created: ${BRANCH_NAME} (${MAIN_SHA})" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
|
||||
|
||||
# -- Dolibarr post-release: Reset dev version -----------------------------
|
||||
- name: "Post-release: Reset dev version"
|
||||
if: steps.version.outputs.skip != 'true'
|
||||
continue-on-error: true
|
||||
run: |
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
php /tmp/moko-platform-api/cli/version_reset_dev.php \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "${API_BASE}" \
|
||||
--branch dev --path . 2>&1 || true
|
||||
|
||||
# -- Summary --------------------------------------------------------------
|
||||
- name: Pipeline Summary
|
||||
if: always()
|
||||
run: |
|
||||
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
||||
PLATFORM="${{ steps.platform.outputs.platform }}"
|
||||
if [ "${{ steps.version.outputs.skip }}" = "true" ]; then
|
||||
echo "## Release Skipped" >> $GITHUB_STEP_SUMMARY
|
||||
echo "No VERSION in README.md" >> $GITHUB_STEP_SUMMARY
|
||||
elif [ "${{ steps.check.outputs.already_released }}" = "true" ]; then
|
||||
echo "## Already Released — ${VERSION}" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "## Build & Release Complete (${PLATFORM})" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Step | Result |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "|------|--------|" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Platform | \`${PLATFORM}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Version | \`${VERSION}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Branch | \`${{ steps.version.outputs.branch }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Tag | \`${{ steps.version.outputs.tag }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Release | [View](${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/tag/${{ steps.version.outputs.tag }}) |" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
@@ -43,9 +43,9 @@ jobs:
|
||||
|
||||
- name: Clone MokoStandards
|
||||
env:
|
||||
GA_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
|
||||
MOKO_CLONE_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
|
||||
MOKO_CLONE_HOST: ${{ secrets.GA_TOKEN && 'git.mokoconsulting.tech/MokoConsulting' || 'github.com/mokoconsulting-tech' }}
|
||||
GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN || secrets.MOKOGITEA_TOKEN || github.token }}
|
||||
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN || secrets.MOKOGITEA_TOKEN || github.token }}
|
||||
MOKO_CLONE_HOST: ${{ secrets.MOKOGITEA_TOKEN && 'git.mokoconsulting.tech/MokoConsulting' || 'github.com/mokoconsulting-tech' }}
|
||||
run: |
|
||||
git clone --depth 1 --branch main --quiet \
|
||||
"https://${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \
|
||||
@@ -53,7 +53,7 @@ jobs:
|
||||
|
||||
- name: Install dependencies
|
||||
env:
|
||||
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GA_TOKEN || github.token }}"}}'
|
||||
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.MOKOGITEA_TOKEN || github.token }}"}}'
|
||||
run: |
|
||||
if [ -f "composer.json" ]; then
|
||||
composer install \
|
||||
@@ -346,7 +346,7 @@ jobs:
|
||||
|
||||
- name: Install dependencies
|
||||
env:
|
||||
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GA_TOKEN || github.token }}"}}'
|
||||
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.MOKOGITEA_TOKEN || github.token }}"}}'
|
||||
run: |
|
||||
if [ -f "composer.json" ]; then
|
||||
composer install \
|
||||
@@ -391,7 +391,7 @@ jobs:
|
||||
|
||||
- name: Install dependencies
|
||||
env:
|
||||
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GA_TOKEN || github.token }}"}}'
|
||||
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.MOKOGITEA_TOKEN || github.token }}"}}'
|
||||
run: |
|
||||
if [ -f "composer.json" ]; then
|
||||
composer install --no-interaction --prefer-dist --optimize-autoloader
|
||||
@@ -4,8 +4,8 @@
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: MokoStandards.Maintenance
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards
|
||||
# INGROUP: moko-platform.Maintenance
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
# PATH: /.gitea/workflows/cleanup.yml
|
||||
# VERSION: 01.00.00
|
||||
# BRIEF: Scheduled cleanup — delete merged branches and old workflow runs
|
||||
@@ -33,17 +33,17 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GA_TOKEN }}
|
||||
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
|
||||
- name: Delete merged branches
|
||||
env:
|
||||
GA_TOKEN: ${{ secrets.GA_TOKEN }}
|
||||
GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
run: |
|
||||
echo "=== Merged Branch Cleanup ==="
|
||||
API="${GITEA_URL}/api/v1/repos/${{ github.repository }}"
|
||||
|
||||
# List branches via API
|
||||
BRANCHES=$(curl -sS -H "Authorization: token ${GA_TOKEN}" \
|
||||
BRANCHES=$(curl -sS -H "Authorization: token ${GITEA_TOKEN}" \
|
||||
"${API}/branches?limit=50" | jq -r '.[].name')
|
||||
|
||||
DELETED=0
|
||||
@@ -56,7 +56,7 @@ jobs:
|
||||
# Check if branch is merged into main
|
||||
if git merge-base --is-ancestor "origin/${BRANCH}" origin/main 2>/dev/null; then
|
||||
echo " Deleting merged branch: ${BRANCH}"
|
||||
curl -sS -X DELETE -H "Authorization: token ${GA_TOKEN}" \
|
||||
curl -sS -X DELETE -H "Authorization: token ${GITEA_TOKEN}" \
|
||||
"${API}/branches/${BRANCH}" 2>/dev/null || true
|
||||
DELETED=$((DELETED + 1))
|
||||
fi
|
||||
@@ -66,20 +66,20 @@ jobs:
|
||||
|
||||
- name: Clean old workflow runs
|
||||
env:
|
||||
GA_TOKEN: ${{ secrets.GA_TOKEN }}
|
||||
GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
run: |
|
||||
echo "=== Workflow Run Cleanup ==="
|
||||
API="${GITEA_URL}/api/v1/repos/${{ github.repository }}"
|
||||
CUTOFF=$(date -d "30 days ago" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -v-30d +%Y-%m-%dT%H:%M:%SZ)
|
||||
|
||||
# Get old completed runs
|
||||
RUNS=$(curl -sS -H "Authorization: token ${GA_TOKEN}" \
|
||||
RUNS=$(curl -sS -H "Authorization: token ${GITEA_TOKEN}" \
|
||||
"${API}/actions/runs?status=completed&limit=50" | \
|
||||
jq -r ".workflow_runs[] | select(.created_at < \"${CUTOFF}\") | .id" 2>/dev/null)
|
||||
|
||||
DELETED=0
|
||||
for RUN_ID in $RUNS; do
|
||||
curl -sS -X DELETE -H "Authorization: token ${GA_TOKEN}" \
|
||||
curl -sS -X DELETE -H "Authorization: token ${GITEA_TOKEN}" \
|
||||
"${API}/actions/runs/${RUN_ID}" 2>/dev/null || true
|
||||
DELETED=$((DELETED + 1))
|
||||
done
|
||||
@@ -42,10 +42,10 @@ jobs:
|
||||
|
||||
- name: Setup MokoStandards tools
|
||||
env:
|
||||
GA_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
|
||||
MOKO_CLONE_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
|
||||
MOKO_CLONE_HOST: ${{ secrets.GA_TOKEN && 'git.mokoconsulting.tech/MokoConsulting' || 'github.com/mokoconsulting-tech' }}
|
||||
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GA_TOKEN || github.token }}"}}'
|
||||
GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN || secrets.MOKOGITEA_TOKEN || github.token }}
|
||||
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN || secrets.MOKOGITEA_TOKEN || github.token }}
|
||||
MOKO_CLONE_HOST: ${{ secrets.MOKOGITEA_TOKEN && 'git.mokoconsulting.tech/MokoConsulting' || 'github.com/mokoconsulting-tech' }}
|
||||
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.MOKOGITEA_TOKEN || github.token }}"}}'
|
||||
run: |
|
||||
git clone --depth 1 --branch main --quiet \
|
||||
"https://${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \
|
||||
@@ -4,8 +4,8 @@
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: MokoStandards.Security
|
||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API
|
||||
# INGROUP: moko-platform.Security
|
||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform
|
||||
# PATH: /templates/workflows/gitleaks.yml.template
|
||||
# VERSION: 01.00.00
|
||||
# BRIEF: Secret scanning — detect leaked credentials, API keys, and tokens
|
||||
@@ -4,8 +4,8 @@
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: MokoStandards.Notifications
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards
|
||||
# INGROUP: moko-platform.Notifications
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
# PATH: /.gitea/workflows/notify.yml
|
||||
# VERSION: 01.00.00
|
||||
# BRIEF: Push notifications via ntfy on release success or workflow failure
|
||||
@@ -18,7 +18,6 @@ on:
|
||||
- "Joomla Build & Release"
|
||||
- "Joomla Extension CI"
|
||||
- "Deploy"
|
||||
- "Cascade Main → Dev"
|
||||
types:
|
||||
- completed
|
||||
|
||||
@@ -1,224 +1,277 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: MokoStandards.CI
|
||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API
|
||||
# PATH: /templates/workflows/universal/pr-check.yml.template
|
||||
# VERSION: 05.00.00
|
||||
# BRIEF: PR gate — branch policy + code validation before merge
|
||||
|
||||
name: "Universal: PR Check"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, edited]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
# ── Branch Policy ──────────────────────────────────────────────────────
|
||||
branch-policy:
|
||||
name: Branch Policy
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check branch merge target
|
||||
run: |
|
||||
HEAD="${{ github.head_ref }}"
|
||||
BASE="${{ github.base_ref }}"
|
||||
|
||||
echo "PR: ${HEAD} → ${BASE}"
|
||||
|
||||
ALLOWED=true
|
||||
REASON=""
|
||||
|
||||
case "$HEAD" in
|
||||
feature/*|feat/*)
|
||||
if [ "$BASE" != "dev" ]; then
|
||||
ALLOWED=false
|
||||
REASON="Feature branches must target 'dev', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
fix/*|bugfix/*)
|
||||
if [ "$BASE" != "dev" ]; then
|
||||
ALLOWED=false
|
||||
REASON="Fix branches must target 'dev', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
hotfix/*)
|
||||
if [ "$BASE" != "dev" ] && [ "$BASE" != "main" ]; then
|
||||
ALLOWED=false
|
||||
REASON="Hotfix branches can only target 'dev' or 'main', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
alpha/*|beta/*)
|
||||
if [ "$BASE" != "dev" ]; then
|
||||
ALLOWED=false
|
||||
REASON="Pre-release branches must target 'dev', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
rc/*)
|
||||
if [ "$BASE" != "main" ]; then
|
||||
ALLOWED=false
|
||||
REASON="Release candidate branches must target 'main', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
dev)
|
||||
if [ "$BASE" != "main" ]; then
|
||||
ALLOWED=false
|
||||
REASON="Dev branch can only merge into 'main', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "$ALLOWED" = false ]; then
|
||||
echo "::error::${REASON}"
|
||||
echo "## Branch Policy Violation" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "${REASON}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### Allowed merge paths:" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`feature/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`fix/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`hotfix/*\` → \`dev\` or \`main\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`dev\` → \`main\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`rc/*\` → \`main\`" >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Branch policy: OK (${HEAD} → ${BASE})"
|
||||
echo "## Branch Policy: Passed" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# ── Code Validation ────────────────────────────────────────────────────
|
||||
validate:
|
||||
name: Validate PR
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Detect platform
|
||||
id: platform
|
||||
run: |
|
||||
# Parse manifest for platform detection
|
||||
PLATFORM=$(php /tmp/mokostandards-api/cli/manifest_read.php --path . --field platform 2>/dev/null)
|
||||
[ -z "$PLATFORM" ] && PLATFORM="generic"
|
||||
echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Setup PHP
|
||||
if: steps.platform.outputs.platform == 'joomla' || steps.platform.outputs.platform == 'dolibarr'
|
||||
run: |
|
||||
if ! command -v php &> /dev/null; then
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq php-cli php-mbstring php-xml >/dev/null 2>&1
|
||||
fi
|
||||
|
||||
- name: PHP syntax check
|
||||
if: steps.platform.outputs.platform == 'joomla' || steps.platform.outputs.platform == 'dolibarr'
|
||||
run: |
|
||||
ERRORS=0
|
||||
while IFS= read -r -d '' file; do
|
||||
if ! php -l "$file" 2>&1 | grep -q "No syntax errors"; then
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
done < <(find . -name "*.php" -not -path "./.git/*" -not -path "./vendor/*" -print0)
|
||||
echo "PHP lint: ${ERRORS} error(s)"
|
||||
[ "$ERRORS" -eq 0 ] || { echo "::error::PHP syntax errors found"; exit 1; }
|
||||
|
||||
- name: Validate platform manifest
|
||||
run: |
|
||||
PLATFORM="${{ steps.platform.outputs.platform }}"
|
||||
case "$PLATFORM" in
|
||||
joomla)
|
||||
MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
|
||||
if [ -z "$MANIFEST" ]; then
|
||||
echo "::warning::No Joomla manifest found (WaaS site)"
|
||||
exit 0
|
||||
fi
|
||||
echo "Manifest: ${MANIFEST}"
|
||||
if command -v php &> /dev/null; then
|
||||
php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('$MANIFEST'); if(!\$x){foreach(libxml_get_errors() as \$e) echo \$e->message; exit(1);}" || { echo "::error::Manifest XML is malformed"; exit 1; }
|
||||
fi
|
||||
for ELEMENT in name version description; do
|
||||
grep -q "<${ELEMENT}>" "$MANIFEST" || { echo "::error::Missing <${ELEMENT}> in manifest"; exit 1; }
|
||||
done
|
||||
echo "Joomla manifest valid"
|
||||
;;
|
||||
dolibarr)
|
||||
MOD_FILE=$(find . -maxdepth 4 -name "mod*.class.php" ! -path "./.git/*" -exec grep -l 'extends DolibarrModules' {} \; 2>/dev/null | head -1)
|
||||
if [ -z "$MOD_FILE" ]; then
|
||||
echo "::error::No mod*.class.php found"
|
||||
exit 1
|
||||
fi
|
||||
echo "Dolibarr module: ${MOD_FILE}"
|
||||
;;
|
||||
*)
|
||||
echo "Generic platform — no manifest validation"
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Check update stream format
|
||||
run: |
|
||||
PLATFORM="${{ steps.platform.outputs.platform }}"
|
||||
case "$PLATFORM" in
|
||||
joomla)
|
||||
if [ -f "updates.xml" ]; then
|
||||
if command -v php &> /dev/null; then
|
||||
php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('updates.xml'); if(!\$x){foreach(libxml_get_errors() as \$e) echo \$e->message; exit(1);}" || { echo "::error::updates.xml is malformed"; exit 1; }
|
||||
fi
|
||||
echo "updates.xml valid"
|
||||
fi
|
||||
;;
|
||||
dolibarr)
|
||||
[ -f "update.txt" ] && echo "update.txt present" || echo "::warning::No update.txt"
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Verify package source
|
||||
run: |
|
||||
SOURCE_DIR="src"
|
||||
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
|
||||
if [ ! -d "$SOURCE_DIR" ]; then
|
||||
echo "::warning::No src/ or htdocs/ directory"
|
||||
exit 0
|
||||
fi
|
||||
FILE_COUNT=$(find "$SOURCE_DIR" -type f | wc -l)
|
||||
echo "Source: ${FILE_COUNT} files"
|
||||
[ "$FILE_COUNT" -gt 0 ] || { echo "::error::Source directory is empty"; exit 1; }
|
||||
|
||||
# ── Changelog Gate ────────────────────────────────────────────────────
|
||||
changelog:
|
||||
name: Changelog Updated
|
||||
runs-on: ubuntu-latest
|
||||
if: github.base_ref == 'main'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Check CHANGELOG.md was updated
|
||||
run: |
|
||||
BASE="${{ github.event.pull_request.base.sha }}"
|
||||
HEAD="${{ github.event.pull_request.head.sha }}"
|
||||
|
||||
if git diff --name-only "$BASE" "$HEAD" | grep -q "^CHANGELOG.md$"; then
|
||||
echo "CHANGELOG.md updated"
|
||||
else
|
||||
# Allow [skip changelog] in PR title or body
|
||||
PR_TITLE="${{ github.event.pull_request.title }}"
|
||||
PR_BODY="${{ github.event.pull_request.body }}"
|
||||
if echo "$PR_TITLE $PR_BODY" | grep -qi "\[skip changelog\]"; then
|
||||
echo "::warning::Changelog skip requested via [skip changelog]"
|
||||
exit 0
|
||||
fi
|
||||
echo "::error::CHANGELOG.md must be updated before merging to main. Add [skip changelog] to the PR title to bypass."
|
||||
exit 1
|
||||
fi
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: moko-platform.CI
|
||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform
|
||||
# PATH: /templates/workflows/universal/pr-check.yml.template
|
||||
# VERSION: 09.23.00
|
||||
# BRIEF: PR gate — branch policy + code validation before merge
|
||||
|
||||
name: "Universal: PR Check"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, edited]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
# ── Branch Policy ──────────────────────────────────────────────────────
|
||||
branch-policy:
|
||||
name: Branch Policy
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check branch merge target
|
||||
run: |
|
||||
HEAD="${{ github.head_ref }}"
|
||||
BASE="${{ github.base_ref }}"
|
||||
|
||||
echo "PR: ${HEAD} → ${BASE}"
|
||||
|
||||
ALLOWED=true
|
||||
REASON=""
|
||||
|
||||
case "$HEAD" in
|
||||
feature/*|feat/*)
|
||||
if [ "$BASE" != "dev" ]; then
|
||||
ALLOWED=false
|
||||
REASON="Feature branches must target 'dev', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
fix/*|bugfix/*)
|
||||
if [ "$BASE" != "dev" ]; then
|
||||
ALLOWED=false
|
||||
REASON="Fix branches must target 'dev', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
patch/*)
|
||||
if [ "$BASE" != "dev" ] && [ "$BASE" != "rc" ]; then
|
||||
ALLOWED=false
|
||||
REASON="Patch branches must target 'dev' or 'rc', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
hotfix/*)
|
||||
if [ "$BASE" != "dev" ] && [ "$BASE" != "main" ]; then
|
||||
ALLOWED=false
|
||||
REASON="Hotfix branches can only target 'dev' or 'main', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
rc)
|
||||
if [ "$BASE" != "main" ]; then
|
||||
ALLOWED=false
|
||||
REASON="RC branch can only merge into 'main', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
dev)
|
||||
if [ "$BASE" != "main" ]; then
|
||||
ALLOWED=false
|
||||
REASON="Dev branch can only merge into 'main', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "$ALLOWED" = false ]; then
|
||||
echo "::error::${REASON}"
|
||||
echo "## Branch Policy Violation" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "${REASON}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### Allowed merge paths:" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`feature/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`fix/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`hotfix/*\` → \`dev\` or \`main\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`dev\` → \`main\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`rc/*\` → \`main\`" >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Branch policy: OK (${HEAD} → ${BASE})"
|
||||
echo "## Branch Policy: Passed" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# ── Code Validation ────────────────────────────────────────────────────
|
||||
validate:
|
||||
name: Validate PR
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Check for merge conflict markers
|
||||
run: |
|
||||
CONFLICTS=$(grep -rn '<<<<<<< \|>>>>>>> \|^=======$' --include='*.php' --include='*.xml' --include='*.css' --include='*.js' --include='*.json' --include='*.md' --include='*.yml' --include='*.yaml' --include='*.ini' --include='*.txt' . 2>/dev/null | grep -v '.git/' || true)
|
||||
if [ -n "$CONFLICTS" ]; then
|
||||
echo "::error::Merge conflict markers found in source files"
|
||||
echo "## Conflict Markers Found" >> $GITHUB_STEP_SUMMARY
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
echo "$CONFLICTS" >> $GITHUB_STEP_SUMMARY
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
fi
|
||||
echo "No conflict markers found"
|
||||
|
||||
- name: Detect platform
|
||||
id: platform
|
||||
run: |
|
||||
# Read platform from XML manifest (<platform> tag) or plain text fallback
|
||||
PLATFORM=$(sed -n 's/.*<platform>\([^<]*\)<\/platform>.*/\1/p' .mokogitea/manifest.xml 2>/dev/null | head -1)
|
||||
[ -z "$PLATFORM" ] && PLATFORM=$(cat .mokogitea/manifest.xml 2>/dev/null | tr -d '[:space:]')
|
||||
[ -z "$PLATFORM" ] && PLATFORM="generic"
|
||||
echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Setup PHP
|
||||
if: steps.platform.outputs.platform == 'joomla' || steps.platform.outputs.platform == 'dolibarr'
|
||||
run: |
|
||||
if ! command -v php &> /dev/null; then
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq php-cli php-mbstring php-xml >/dev/null 2>&1
|
||||
fi
|
||||
|
||||
- name: PHP syntax check
|
||||
if: steps.platform.outputs.platform == 'joomla' || steps.platform.outputs.platform == 'dolibarr'
|
||||
run: |
|
||||
ERRORS=0
|
||||
while IFS= read -r -d '' file; do
|
||||
if ! php -l "$file" 2>&1 | grep -q "No syntax errors"; then
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
done < <(find . -name "*.php" -not -path "./.git/*" -not -path "./vendor/*" -print0)
|
||||
echo "PHP lint: ${ERRORS} error(s)"
|
||||
[ "$ERRORS" -eq 0 ] || { echo "::error::PHP syntax errors found"; exit 1; }
|
||||
|
||||
- name: Validate platform manifest
|
||||
run: |
|
||||
PLATFORM="${{ steps.platform.outputs.platform }}"
|
||||
case "$PLATFORM" in
|
||||
joomla)
|
||||
MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
|
||||
if [ -z "$MANIFEST" ]; then
|
||||
echo "::warning::No Joomla manifest found (WaaS site)"
|
||||
exit 0
|
||||
fi
|
||||
echo "Manifest: ${MANIFEST}"
|
||||
if command -v php &> /dev/null; then
|
||||
php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('$MANIFEST'); if(!\$x){foreach(libxml_get_errors() as \$e) echo \$e->message; exit(1);}" || { echo "::error::Manifest XML is malformed"; exit 1; }
|
||||
fi
|
||||
for ELEMENT in name version description; do
|
||||
grep -q "<${ELEMENT}>" "$MANIFEST" || { echo "::error::Missing <${ELEMENT}> in manifest"; exit 1; }
|
||||
done
|
||||
echo "Joomla manifest valid"
|
||||
;;
|
||||
dolibarr)
|
||||
MOD_FILE=$(find . -maxdepth 4 -name "mod*.class.php" ! -path "./.git/*" -exec grep -l 'extends DolibarrModules' {} \; 2>/dev/null | head -1)
|
||||
if [ -z "$MOD_FILE" ]; then
|
||||
echo "::error::No mod*.class.php found"
|
||||
exit 1
|
||||
fi
|
||||
echo "Dolibarr module: ${MOD_FILE}"
|
||||
;;
|
||||
*)
|
||||
echo "Generic platform — no manifest validation"
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Check update stream format
|
||||
run: |
|
||||
PLATFORM="${{ steps.platform.outputs.platform }}"
|
||||
case "$PLATFORM" in
|
||||
joomla)
|
||||
if [ -f "updates.xml" ]; then
|
||||
if command -v php &> /dev/null; then
|
||||
php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('updates.xml'); if(!\$x){foreach(libxml_get_errors() as \$e) echo \$e->message; exit(1);}" || { echo "::error::updates.xml is malformed"; exit 1; }
|
||||
fi
|
||||
echo "updates.xml valid"
|
||||
fi
|
||||
;;
|
||||
dolibarr)
|
||||
[ -f "update.txt" ] && echo "update.txt present" || echo "::warning::No update.txt"
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Check changelog has unreleased entry
|
||||
run: |
|
||||
if [ ! -f "CHANGELOG.md" ]; then
|
||||
echo "::warning::No CHANGELOG.md found"
|
||||
exit 0
|
||||
fi
|
||||
# Check for content under [Unreleased] section
|
||||
if ! grep -q "## \[Unreleased\]" CHANGELOG.md; then
|
||||
echo "::error::CHANGELOG.md missing [Unreleased] section"
|
||||
exit 1
|
||||
fi
|
||||
# Check there's at least one entry (Added/Changed/Fixed/Removed) under Unreleased
|
||||
UNRELEASED_CONTENT=$(sed -n '/## \[Unreleased\]/,/## \[/p' CHANGELOG.md | grep -cE '^\s*-\s' || true)
|
||||
if [ "$UNRELEASED_CONTENT" -eq 0 ]; then
|
||||
echo "::error::CHANGELOG.md [Unreleased] section has no entries. Add a changelog entry describing your changes."
|
||||
echo "## Changelog Check: Failed" >> $GITHUB_STEP_SUMMARY
|
||||
echo "The \`[Unreleased]\` section in CHANGELOG.md has no entries." >> $GITHUB_STEP_SUMMARY
|
||||
echo "Add a line like \`- Description of your change\` under a heading (\`### Added\`, \`### Changed\`, \`### Fixed\`, etc.)" >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
fi
|
||||
echo "Changelog: ${UNRELEASED_CONTENT} entry/entries in [Unreleased]"
|
||||
|
||||
- name: Verify package source
|
||||
run: |
|
||||
SOURCE_DIR="src"
|
||||
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
|
||||
if [ ! -d "$SOURCE_DIR" ]; then
|
||||
echo "::warning::No src/ or htdocs/ directory"
|
||||
exit 0
|
||||
fi
|
||||
FILE_COUNT=$(find "$SOURCE_DIR" -type f | wc -l)
|
||||
echo "Source: ${FILE_COUNT} files"
|
||||
[ "$FILE_COUNT" -gt 0 ] || { echo "::error::Source directory is empty"; exit 1; }
|
||||
|
||||
# ── Pre-Release RC Build ─────────────────────────────────────────────────
|
||||
pre-release:
|
||||
name: Build RC Package
|
||||
runs-on: ubuntu-latest
|
||||
needs: [branch-policy, validate]
|
||||
|
||||
steps:
|
||||
- name: Trigger RC pre-release
|
||||
env:
|
||||
GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
BRANCH: ${{ github.head_ref }}
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
run: |
|
||||
curl -s -X POST "${GITEA_URL}/api/v1/repos/${REPO}/actions/workflows/pre-release.yml/dispatches" -H "Authorization: token ${GITEA_TOKEN}" -H "Content-Type: application/json" -d "{\"ref\":\"${BRANCH}\",\"inputs\":{\"stability\":\"release-candidate\"}}"
|
||||
echo "### Pre-Release" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Triggered RC build on branch \`${BRANCH}\`" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# ── Issue Reporter ──────────────────────────────────────────────────────
|
||||
report-issues:
|
||||
name: Report Issues
|
||||
runs-on: ubuntu-latest
|
||||
needs: [branch-policy, validate]
|
||||
if: >-
|
||||
always() &&
|
||||
needs.validate.result == 'failure'
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
sparse-checkout: automation/ci-issue-reporter.sh
|
||||
sparse-checkout-cone-mode: false
|
||||
|
||||
- name: "File issue for PR validation failure"
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
run: |
|
||||
chmod +x automation/ci-issue-reporter.sh
|
||||
./automation/ci-issue-reporter.sh \
|
||||
--gate "PR Validation" \
|
||||
--workflow "PR Check" \
|
||||
--severity error \
|
||||
--details "PR validation failed (syntax, manifest, changelog, or source checks). See the CI run for the specific check that failed."
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,8 +4,8 @@
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: MokoStandards.Security
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards
|
||||
# INGROUP: moko-platform.Security
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
# PATH: /.gitea/workflows/security-audit.yml
|
||||
# VERSION: 01.00.00
|
||||
# BRIEF: Dependency vulnerability scanning for composer and npm packages
|
||||
@@ -80,3 +80,19 @@ jobs:
|
||||
-H "Priority: high" \
|
||||
-d "Security audit found vulnerabilities. Review dependency updates." \
|
||||
"${NTFY_URL}/${NTFY_TOPIC}" || true
|
||||
|
||||
|
||||
- name: Joomla version audit
|
||||
if: always()
|
||||
run: |
|
||||
if [ -f "monitoring/joomla-version-audit.php" ] && [ -n "$JOOMLA_SITES" ]; then
|
||||
echo "$JOOMLA_SITES" > /tmp/sites.json
|
||||
php monitoring/joomla-version-audit.php --sites /tmp/sites.json || true
|
||||
echo "### Joomla Version Audit" >> $GITHUB_STEP_SUMMARY
|
||||
rm -f /tmp/sites.json
|
||||
else
|
||||
echo "Joomla audit skipped (no script or JOOMLA_SITES_JSON not configured)"
|
||||
fi
|
||||
env:
|
||||
JOOMLA_SITES: ${{ vars.JOOMLA_SITES_JSON }}
|
||||
|
||||
+51
-6
@@ -5,13 +5,58 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [1.0.0-dev.1] - 2026-05-21
|
||||
|
||||
### Added
|
||||
|
||||
#### Core Storefront (Critical — Issues #1-6)
|
||||
- Product catalog view with Dolibarr API integration, pagination, category filtering
|
||||
- Product detail view with stock status, image gallery, Schema.org JSON-LD, add-to-cart
|
||||
- Session-based shopping cart with DB persistence, quantity update, stock validation
|
||||
- Cart merge on user login (guest → registered)
|
||||
- Checkout flow with billing form, guest and registered modes
|
||||
- Dolibarr order and invoice creation from cart data
|
||||
- Customer sync service: Joomla user ↔ Dolibarr thirdparty mapping with email dedup
|
||||
- Enhanced DolibarrClient with SSL verify, detailed connection test, permission checks
|
||||
- Dashboard connection status with version display, permission indicators, troubleshooting hints
|
||||
|
||||
#### High Priority (Issues #7-12)
|
||||
- Hierarchical product category navigation with sidebar tree and breadcrumbs
|
||||
- Category landing pages with filtered product grid
|
||||
- Stock/inventory display: In Stock / Low Stock / Out of Stock badges
|
||||
- Configurable low-stock threshold and backorder support
|
||||
- Tax calculation from Dolibarr `tva_tx` with grouped tax breakdown
|
||||
- Configurable tax display mode (TTC, HT, or both)
|
||||
- Product search controller with AJAX endpoint, text search, price range, sorting
|
||||
- Order history (My Orders) for registered users with detail view
|
||||
- Admin dashboard with product/order/customer counts, revenue metrics, recent orders
|
||||
|
||||
#### Medium Priority (Issues #13-19)
|
||||
- SEF URL router for all views (clean URLs)
|
||||
- Product image service with local caching, thumbnail support, placeholder fallback
|
||||
- Email notification service: customer confirmation and admin notification on order
|
||||
- Joomla menu item types for Products, Category, Product, Cart, Checkout, My Orders
|
||||
- Responsive storefront CSS (mobile-first, sticky add-to-cart, touch-friendly cart)
|
||||
- Product variant/attribute helper (Dolibarr combinations support)
|
||||
- Admin orders management view with filters (status, date, search) and Dolibarr sync
|
||||
|
||||
#### Low Priority (Issues #20-27)
|
||||
- Wishlist / Save for Later with DB persistence and guest merge on login
|
||||
- Coupon/discount code validation against Dolibarr discount rules
|
||||
- API response caching via Joomla cache framework with configurable TTL
|
||||
- Shipping address management (address book, default address, CRUD)
|
||||
- Joomla ACL integration (component-level permissions for products, orders, settings)
|
||||
- Dolibarr webhook endpoint with event processing and log table
|
||||
- Frontend invoice PDF download (streamed from Dolibarr with ownership check)
|
||||
- Multi-language readiness (all strings via Joomla Text class)
|
||||
|
||||
#### Infrastructure
|
||||
- Database schema: 6 tables (cart, orders, customers, wishlist, addresses, webhook_log)
|
||||
- Component manifest with config fieldsets: Dolibarr, Shop, Performance, Webhooks
|
||||
- Media folder with responsive CSS
|
||||
- Full en-GB language files for admin and site
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- Initial component scaffold with Dolibarr REST API client
|
||||
- Admin dashboard with connection status
|
||||
- Admin views: products, orders, customers (placeholders)
|
||||
- Site views: products catalog, product detail, cart, checkout (placeholders)
|
||||
- Database schema for cart, orders, and customer mapping
|
||||
- Component parameters for Dolibarr connection and shop settings
|
||||
- Language files (en-GB)
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
#!/usr/bin/env bash
|
||||
# ============================================================================
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Automation.CI
|
||||
# INGROUP: moko-platform.Automation
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
# PATH: /automation/ci-issue-reporter.sh
|
||||
# VERSION: 09.23.00
|
||||
# BRIEF: Creates or updates a Gitea issue when a CI gate fails.
|
||||
# Deduplicates by searching open issues with the "ci-auto" label
|
||||
# whose title matches the gate. If a matching issue exists, a comment
|
||||
# is appended instead of opening a duplicate.
|
||||
# ============================================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ── Defaults ────────────────────────────────────────────────────────────────
|
||||
GITEA_URL="${GITEA_URL:-https://git.mokoconsulting.tech}"
|
||||
GITEA_TOKEN="${GITEA_TOKEN:-}"
|
||||
REPO="${GITHUB_REPOSITORY:-}"
|
||||
RUN_URL="${GITHUB_SERVER_URL:-${GITEA_URL}}/${REPO}/actions/runs/${GITHUB_RUN_ID:-0}"
|
||||
LABEL_NAME="ci-auto"
|
||||
LABEL_COLOR="#e11d48"
|
||||
|
||||
GATE=""
|
||||
DETAILS=""
|
||||
SEVERITY="error"
|
||||
WORKFLOW=""
|
||||
|
||||
# ── Parse arguments ─────────────────────────────────────────────────────────
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: ci-issue-reporter.sh --gate NAME --details TEXT [OPTIONS]
|
||||
|
||||
Required:
|
||||
--gate CI gate name (e.g. "Code Quality", "Self-Health")
|
||||
--details Human-readable failure description
|
||||
|
||||
Optional:
|
||||
--severity "error" (default) or "warning"
|
||||
--workflow Workflow name for the issue title
|
||||
--repo owner/repo (default: \$GITHUB_REPOSITORY)
|
||||
--run-url URL to the CI run (auto-detected from env)
|
||||
--token Gitea API token (default: \$GITEA_TOKEN)
|
||||
--url Gitea base URL (default: \$GITEA_URL)
|
||||
EOF
|
||||
exit 1
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--gate) GATE="$2"; shift 2 ;;
|
||||
--details) DETAILS="$2"; shift 2 ;;
|
||||
--severity) SEVERITY="$2"; shift 2 ;;
|
||||
--workflow) WORKFLOW="$2"; shift 2 ;;
|
||||
--repo) REPO="$2"; shift 2 ;;
|
||||
--run-url) RUN_URL="$2"; shift 2 ;;
|
||||
--token) GITEA_TOKEN="$2"; shift 2 ;;
|
||||
--url) GITEA_URL="$2"; shift 2 ;;
|
||||
-h|--help) usage ;;
|
||||
*) echo "Unknown option: $1"; usage ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ -z "$GATE" ]] && { echo "ERROR: --gate is required"; usage; }
|
||||
[[ -z "$DETAILS" ]] && { echo "ERROR: --details is required"; usage; }
|
||||
[[ -z "$GITEA_TOKEN" ]] && { echo "ERROR: GITEA_TOKEN not set"; exit 1; }
|
||||
[[ -z "$REPO" ]] && { echo "ERROR: GITHUB_REPOSITORY not set"; exit 1; }
|
||||
|
||||
API="${GITEA_URL}/api/v1/repos/${REPO}"
|
||||
|
||||
# ── Build title ─────────────────────────────────────────────────────────────
|
||||
if [[ -n "$WORKFLOW" ]]; then
|
||||
TITLE="[CI] ${WORKFLOW}: ${GATE} failed"
|
||||
else
|
||||
TITLE="[CI] ${GATE} failed"
|
||||
fi
|
||||
|
||||
# ── Ensure label exists ─────────────────────────────────────────────────────
|
||||
ensure_label() {
|
||||
local exists
|
||||
exists=$(curl -sf -o /dev/null -w '%{http_code}' \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
"${API}/labels" 2>/dev/null || echo "000")
|
||||
|
||||
if [[ "$exists" == "200" ]]; then
|
||||
# Check if label already exists
|
||||
local found
|
||||
found=$(curl -sf \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
"${API}/labels" 2>/dev/null \
|
||||
| grep -o "\"name\":\"${LABEL_NAME}\"" || true)
|
||||
|
||||
if [[ -z "$found" ]]; then
|
||||
curl -sf -X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API}/labels" \
|
||||
-d "{\"name\":\"${LABEL_NAME}\",\"color\":\"${LABEL_COLOR}\",\"description\":\"Auto-created by CI issue reporter\"}" \
|
||||
> /dev/null 2>&1 || true
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Search for existing open issue ──────────────────────────────────────────
|
||||
find_existing_issue() {
|
||||
# URL-encode the gate name for the query
|
||||
local query
|
||||
query=$(printf '%s' "[CI] ${GATE}" | sed 's/ /%20/g; s/\[/%5B/g; s/\]/%5D/g')
|
||||
|
||||
local response
|
||||
response=$(curl -sf \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
"${API}/issues?type=issues&state=open&labels=${LABEL_NAME}&q=${query}&limit=5" \
|
||||
2>/dev/null || echo "[]")
|
||||
|
||||
# Extract the first matching issue number
|
||||
echo "$response" \
|
||||
| grep -oP '"number":\s*\K[0-9]+' \
|
||||
| head -1
|
||||
}
|
||||
|
||||
# ── Build issue body ────────────────────────────────────────────────────────
|
||||
build_body() {
|
||||
local severity_badge
|
||||
if [[ "$SEVERITY" == "error" ]]; then
|
||||
severity_badge="**Severity:** Error"
|
||||
else
|
||||
severity_badge="**Severity:** Warning"
|
||||
fi
|
||||
|
||||
cat <<BODY
|
||||
## CI Gate Failure: ${GATE}
|
||||
|
||||
${severity_badge}
|
||||
**Workflow:** ${WORKFLOW:-unknown}
|
||||
**Branch:** ${GITHUB_REF_NAME:-unknown}
|
||||
**Commit:** \`${GITHUB_SHA:0:8}\`
|
||||
**Run:** [View CI run](${RUN_URL})
|
||||
|
||||
### Details
|
||||
|
||||
${DETAILS}
|
||||
|
||||
### Resolution
|
||||
|
||||
Fix the issue described above and push a new commit. This issue will be closed automatically when the gate passes, or can be closed manually.
|
||||
|
||||
---
|
||||
*Auto-created by [ci-issue-reporter](${GITEA_URL}/${REPO}/src/branch/main/automation/ci-issue-reporter.sh)*
|
||||
BODY
|
||||
}
|
||||
|
||||
# ── Build comment body (for existing issues) ────────────────────────────────
|
||||
build_comment() {
|
||||
cat <<COMMENT
|
||||
### CI failure recurrence
|
||||
|
||||
**Branch:** ${GITHUB_REF_NAME:-unknown}
|
||||
**Commit:** \`${GITHUB_SHA:0:8}\`
|
||||
**Run:** [View CI run](${RUN_URL})
|
||||
|
||||
${DETAILS}
|
||||
COMMENT
|
||||
}
|
||||
|
||||
# ── Main ────────────────────────────────────────────────────────────────────
|
||||
ensure_label
|
||||
|
||||
EXISTING=$(find_existing_issue)
|
||||
|
||||
if [[ -n "$EXISTING" ]]; then
|
||||
# Append comment to existing issue
|
||||
COMMENT_BODY=$(build_comment)
|
||||
COMMENT_JSON=$(printf '%s' "$COMMENT_BODY" | python3 -c "
|
||||
import sys, json
|
||||
print(json.dumps({'body': sys.stdin.read()}))" 2>/dev/null)
|
||||
|
||||
HTTP=$(curl -sf -o /dev/null -w '%{http_code}' -X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API}/issues/${EXISTING}/comments" \
|
||||
-d "${COMMENT_JSON}" 2>/dev/null || echo "000")
|
||||
|
||||
if [[ "$HTTP" == "201" ]]; then
|
||||
echo "Commented on existing issue #${EXISTING}"
|
||||
else
|
||||
echo "WARNING: Failed to comment on issue #${EXISTING} (HTTP ${HTTP})"
|
||||
fi
|
||||
else
|
||||
# Create new issue
|
||||
ISSUE_BODY=$(build_body)
|
||||
ISSUE_JSON=$(python3 -c "
|
||||
import sys, json
|
||||
body = sys.stdin.read()
|
||||
print(json.dumps({
|
||||
'title': sys.argv[1],
|
||||
'body': body,
|
||||
'labels': []
|
||||
}))" "$TITLE" <<< "$ISSUE_BODY" 2>/dev/null)
|
||||
|
||||
# Create the issue
|
||||
RESPONSE=$(curl -sf -X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API}/issues" \
|
||||
-d "${ISSUE_JSON}" 2>/dev/null || echo "{}")
|
||||
|
||||
ISSUE_NUM=$(echo "$RESPONSE" | grep -oP '"number":\s*\K[0-9]+' | head -1)
|
||||
|
||||
if [[ -n "$ISSUE_NUM" ]]; then
|
||||
# Apply label (separate call — more reliable across Gitea versions)
|
||||
LABEL_ID=$(curl -sf \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
"${API}/labels" 2>/dev/null \
|
||||
| grep -oP "\"id\":\s*\K[0-9]+(?=[^}]*\"name\":\s*\"${LABEL_NAME}\")" \
|
||||
| head -1 || true)
|
||||
|
||||
if [[ -n "$LABEL_ID" ]]; then
|
||||
curl -sf -X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API}/issues/${ISSUE_NUM}/labels" \
|
||||
-d "{\"labels\":[${LABEL_ID}]}" \
|
||||
> /dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
echo "Created issue #${ISSUE_NUM}: ${TITLE}"
|
||||
else
|
||||
echo "WARNING: Failed to create issue"
|
||||
echo "Response: ${RESPONSE}"
|
||||
fi
|
||||
fi
|
||||
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* MokoDoliJoomShop - Responsive Storefront Styles
|
||||
* Mobile-first responsive layout for all storefront views.
|
||||
*
|
||||
* @package MokoDoliJoomShop
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
/* ==========================================================================
|
||||
Base / Mobile-first (320px+)
|
||||
========================================================================== */
|
||||
|
||||
.com-mokodolijoomshop-products,
|
||||
.com-mokodolijoomshop-product,
|
||||
.com-mokodolijoomshop-cart,
|
||||
.com-mokodolijoomshop-checkout,
|
||||
.com-mokodolijoomshop-category,
|
||||
.com-mokodolijoomshop-orders {
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
/* Product cards */
|
||||
.product-card {
|
||||
transition: box-shadow 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.product-card:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.product-card .card-title a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.product-card .card-title a:hover {
|
||||
color: var(--bs-primary);
|
||||
}
|
||||
|
||||
/* Product gallery */
|
||||
.product-gallery img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
/* Cart table mobile */
|
||||
@media (max-width: 575.98px) {
|
||||
.com-mokodolijoomshop-cart .table-responsive table {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.com-mokodolijoomshop-cart .table th,
|
||||
.com-mokodolijoomshop-cart .table td {
|
||||
padding: 0.5rem 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Tablet (576px+)
|
||||
========================================================================== */
|
||||
|
||||
@media (min-width: 576px) {
|
||||
.product-card .card-body {
|
||||
min-height: 120px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Desktop (992px+)
|
||||
========================================================================== */
|
||||
|
||||
@media (min-width: 992px) {
|
||||
.product-card .card-body {
|
||||
min-height: 140px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Mobile Product Detail - Sticky Add to Cart
|
||||
========================================================================== */
|
||||
|
||||
@media (max-width: 767.98px) {
|
||||
.com-mokodolijoomshop-product .input-group {
|
||||
max-width: 100% !important;
|
||||
}
|
||||
|
||||
.com-mokodolijoomshop-product form[action*="cart.add"] {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
background: var(--bs-body-bg, #fff);
|
||||
padding: 0.75rem 0;
|
||||
border-top: 1px solid var(--bs-border-color, #dee2e6);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
/* Checkout form columns stack on mobile */
|
||||
.com-mokodolijoomshop-checkout .row > .col-md-7,
|
||||
.com-mokodolijoomshop-checkout .row > .col-md-5 {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Category Sidebar
|
||||
========================================================================== */
|
||||
|
||||
.com-mokodolijoomshop-category .list-group-item a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.com-mokodolijoomshop-category .list-group-item a:hover {
|
||||
color: var(--bs-primary);
|
||||
}
|
||||
|
||||
.com-mokodolijoomshop-category .list-group-item.active a {
|
||||
color: var(--bs-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Nested category lists */
|
||||
.com-mokodolijoomshop-category .list-group .list-group {
|
||||
margin-left: 1rem;
|
||||
border: none;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Shop Categories Navigation (products view)
|
||||
========================================================================== */
|
||||
|
||||
.shop-categories {
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
padding-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Touch-friendly Cart Controls
|
||||
========================================================================== */
|
||||
|
||||
.com-mokodolijoomshop-cart input[type="number"] {
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: textfield;
|
||||
text-align: center;
|
||||
min-width: 50px;
|
||||
}
|
||||
|
||||
.com-mokodolijoomshop-cart .btn-outline-danger {
|
||||
min-width: 36px;
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Order Status Badges
|
||||
========================================================================== */
|
||||
|
||||
.com-mokodolijoomshop-orders .badge {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Print Styles
|
||||
========================================================================== */
|
||||
|
||||
@media print {
|
||||
.shop-categories,
|
||||
.com-mokodolijoomshop-product form,
|
||||
.com-mokodolijoomshop-cart .btn,
|
||||
.pagination {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
@@ -27,5 +27,66 @@ COM_MOKODOLIJOOMSHOP_FIELD_TAX_ENABLED="Enable Tax"
|
||||
|
||||
COM_MOKODOLIJOOMSHOP_CONNECTION_OK="Dolibarr connection successful"
|
||||
COM_MOKODOLIJOOMSHOP_CONNECTION_FAILED="Dolibarr connection failed. Check URL and API key."
|
||||
COM_MOKODOLIJOOMSHOP_DOLIBARR_VERSION="Dolibarr Version"
|
||||
COM_MOKODOLIJOOMSHOP_PERMISSIONS="API Permissions"
|
||||
COM_MOKODOLIJOOMSHOP_PERMISSION_READ="Products (read)"
|
||||
COM_MOKODOLIJOOMSHOP_PERMISSION_WRITE="Third-parties (read/write)"
|
||||
COM_MOKODOLIJOOMSHOP_TROUBLESHOOTING="Troubleshooting"
|
||||
COM_MOKODOLIJOOMSHOP_QUICK_ACTIONS="Quick Actions"
|
||||
|
||||
COM_MOKODOLIJOOMSHOP_SYNC_PRODUCTS="Sync Products"
|
||||
COM_MOKODOLIJOOMSHOP_SYNC_COMPLETE="Product sync complete: %d products updated"
|
||||
COM_MOKODOLIJOOMSHOP_NO_PRODUCTS="No products found."
|
||||
COM_MOKODOLIJOOMSHOP_PRODUCT_REF="Reference"
|
||||
COM_MOKODOLIJOOMSHOP_PRODUCT_LABEL="Label"
|
||||
COM_MOKODOLIJOOMSHOP_PRODUCT_PRICE="Price"
|
||||
COM_MOKODOLIJOOMSHOP_PRODUCT_STOCK="Stock"
|
||||
COM_MOKODOLIJOOMSHOP_PRODUCT_STATUS="Status"
|
||||
COM_MOKODOLIJOOMSHOP_PRODUCT_TOSELL="For Sale"
|
||||
COM_MOKODOLIJOOMSHOP_PRODUCT_TOBUY="For Purchase"
|
||||
|
||||
COM_MOKODOLIJOOMSHOP_ORDER_REF="Order Ref"
|
||||
COM_MOKODOLIJOOMSHOP_ORDER_INVOICE_REF="Invoice Ref"
|
||||
COM_MOKODOLIJOOMSHOP_ORDER_TOTAL_HT="Total (excl. tax)"
|
||||
COM_MOKODOLIJOOMSHOP_ORDER_TOTAL_TTC="Total (incl. tax)"
|
||||
COM_MOKODOLIJOOMSHOP_ORDER_STATUS="Status"
|
||||
COM_MOKODOLIJOOMSHOP_ORDER_DATE="Date"
|
||||
COM_MOKODOLIJOOMSHOP_NO_ORDERS="No orders found."
|
||||
|
||||
COM_MOKODOLIJOOMSHOP_CUSTOMER_NAME="Customer Name"
|
||||
COM_MOKODOLIJOOMSHOP_CUSTOMER_EMAIL="Email"
|
||||
COM_MOKODOLIJOOMSHOP_CUSTOMER_DOLIBARR_ID="Dolibarr ID"
|
||||
COM_MOKODOLIJOOMSHOP_CUSTOMER_SYNCED="Last Synced"
|
||||
COM_MOKODOLIJOOMSHOP_NO_CUSTOMERS="No customer mappings found."
|
||||
|
||||
COM_MOKODOLIJOOMSHOP_REVENUE="Revenue"
|
||||
COM_MOKODOLIJOOMSHOP_REVENUE_TODAY="Today"
|
||||
COM_MOKODOLIJOOMSHOP_REVENUE_WEEK="This Week"
|
||||
COM_MOKODOLIJOOMSHOP_REVENUE_MONTH="This Month"
|
||||
COM_MOKODOLIJOOMSHOP_RECENT_ORDERS="Recent Orders"
|
||||
|
||||
COM_MOKODOLIJOOMSHOP_FIELD_TAX_DISPLAY="Tax Display Mode"
|
||||
COM_MOKODOLIJOOMSHOP_TAX_DISPLAY_TTC="Prices Include Tax (TTC)"
|
||||
COM_MOKODOLIJOOMSHOP_TAX_DISPLAY_HT="Prices Exclude Tax (HT)"
|
||||
COM_MOKODOLIJOOMSHOP_TAX_DISPLAY_BOTH="Show Both (TTC and HT)"
|
||||
COM_MOKODOLIJOOMSHOP_FIELD_LOW_STOCK_THRESHOLD="Low Stock Threshold"
|
||||
COM_MOKODOLIJOOMSHOP_FIELD_ALLOW_BACKORDER="Allow Backorders"
|
||||
|
||||
COM_MOKODOLIJOOMSHOP_EMAIL_ORDER_SUBJECT="Order Confirmation — %s"
|
||||
COM_MOKODOLIJOOMSHOP_EMAIL_ADMIN_ORDER_SUBJECT="New Order Received — %s"
|
||||
COM_MOKODOLIJOOMSHOP_EMAIL_GREETING="Hello %s,"
|
||||
COM_MOKODOLIJOOMSHOP_EMAIL_ORDER_CONFIRMED="Thank you for your order! Here is your order summary:"
|
||||
COM_MOKODOLIJOOMSHOP_EMAIL_NEW_ORDER="A new order has been placed."
|
||||
COM_MOKODOLIJOOMSHOP_EMAIL_FOOTER="Thank you for shopping with %s."
|
||||
|
||||
COM_MOKODOLIJOOMSHOP_FIELDSET_PERFORMANCE="Performance"
|
||||
COM_MOKODOLIJOOMSHOP_FIELD_CACHE_ENABLED="Enable API Caching"
|
||||
COM_MOKODOLIJOOMSHOP_FIELD_CACHE_TTL="Cache Lifetime (seconds)"
|
||||
COM_MOKODOLIJOOMSHOP_FIELDSET_WEBHOOKS="Webhooks"
|
||||
COM_MOKODOLIJOOMSHOP_FIELD_WEBHOOK_SECRET="Webhook Secret"
|
||||
COM_MOKODOLIJOOMSHOP_FIELD_WEBHOOK_SECRET_DESC="Shared secret for validating Dolibarr webhook requests."
|
||||
|
||||
COM_MOKODOLIJOOMSHOP_ACL_PRODUCTS_MANAGE="Manage Products"
|
||||
COM_MOKODOLIJOOMSHOP_ACL_ORDERS_VIEW="View Orders"
|
||||
COM_MOKODOLIJOOMSHOP_ACL_CUSTOMERS_MANAGE="Manage Customers"
|
||||
COM_MOKODOLIJOOMSHOP_ACL_SETTINGS_MANAGE="Manage Settings"
|
||||
|
||||
@@ -45,6 +45,52 @@ CREATE TABLE IF NOT EXISTS `#__mokodolijoomshop_orders` (
|
||||
KEY `idx_status` (`status`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Wishlist items (save for later)
|
||||
CREATE TABLE IF NOT EXISTS `#__mokodolijoomshop_wishlist` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`user_id` int(10) unsigned NOT NULL,
|
||||
`session_id` varchar(255) NOT NULL DEFAULT '',
|
||||
`dolibarr_product_id` int(11) NOT NULL,
|
||||
`product_ref` varchar(128) NOT NULL DEFAULT '',
|
||||
`product_label` varchar(255) NOT NULL DEFAULT '',
|
||||
`created` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_user` (`user_id`),
|
||||
KEY `idx_session` (`session_id`),
|
||||
UNIQUE KEY `idx_user_product` (`user_id`, `dolibarr_product_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Shipping addresses (user address book)
|
||||
CREATE TABLE IF NOT EXISTS `#__mokodolijoomshop_addresses` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`user_id` int(10) unsigned NOT NULL,
|
||||
`label` varchar(100) NOT NULL DEFAULT '',
|
||||
`name` varchar(255) NOT NULL DEFAULT '',
|
||||
`address` text NOT NULL,
|
||||
`town` varchar(255) NOT NULL DEFAULT '',
|
||||
`zip` varchar(20) NOT NULL DEFAULT '',
|
||||
`country_code` varchar(5) NOT NULL DEFAULT '',
|
||||
`phone` varchar(50) NOT NULL DEFAULT '',
|
||||
`is_default` tinyint(1) NOT NULL DEFAULT 0,
|
||||
`created` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`modified` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_user` (`user_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Webhook event log
|
||||
CREATE TABLE IF NOT EXISTS `#__mokodolijoomshop_webhook_log` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`event_type` varchar(100) NOT NULL,
|
||||
`payload` text NOT NULL,
|
||||
`status` varchar(20) NOT NULL DEFAULT 'received',
|
||||
`message` varchar(500) NOT NULL DEFAULT '',
|
||||
`created` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_event_type` (`event_type`),
|
||||
KEY `idx_created` (`created`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Customer mapping (links Joomla users to Dolibarr thirdparties)
|
||||
CREATE TABLE IF NOT EXISTS `#__mokodolijoomshop_customers` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
|
||||
@@ -6,3 +6,6 @@
|
||||
DROP TABLE IF EXISTS `#__mokodolijoomshop_cart`;
|
||||
DROP TABLE IF EXISTS `#__mokodolijoomshop_orders`;
|
||||
DROP TABLE IF EXISTS `#__mokodolijoomshop_customers`;
|
||||
DROP TABLE IF EXISTS `#__mokodolijoomshop_wishlist`;
|
||||
DROP TABLE IF EXISTS `#__mokodolijoomshop_addresses`;
|
||||
DROP TABLE IF EXISTS `#__mokodolijoomshop_webhook_log`;
|
||||
|
||||
@@ -13,6 +13,7 @@ defined('_JEXEC') or die;
|
||||
use Joomla\CMS\Component\ComponentHelper;
|
||||
use Joomla\CMS\Http\HttpFactory;
|
||||
use Joomla\CMS\Log\Log;
|
||||
use Joomla\Registry\Registry;
|
||||
|
||||
/**
|
||||
* HTTP client for the Dolibarr REST API.
|
||||
@@ -36,20 +37,28 @@ class DolibarrClient
|
||||
*/
|
||||
private string $apiKey;
|
||||
|
||||
/**
|
||||
* @var bool Whether to verify SSL certificates.
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private bool $verifySSL;
|
||||
|
||||
/**
|
||||
* Constructor. Reads connection settings from component params.
|
||||
*
|
||||
* @param string|null $baseUrl Override base URL.
|
||||
* @param string|null $apiKey Override API key.
|
||||
* @param string|null $baseUrl Override base URL.
|
||||
* @param string|null $apiKey Override API key.
|
||||
* @param bool|null $verifySSL Override SSL verification.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function __construct(?string $baseUrl = null, ?string $apiKey = null)
|
||||
public function __construct(?string $baseUrl = null, ?string $apiKey = null, ?bool $verifySSL = null)
|
||||
{
|
||||
$params = ComponentHelper::getParams('com_mokodolijoomshop');
|
||||
|
||||
$this->baseUrl = rtrim($baseUrl ?? $params->get('dolibarr_url', ''), '/');
|
||||
$this->apiKey = $apiKey ?? $params->get('dolibarr_api_key', '');
|
||||
$this->baseUrl = rtrim($baseUrl ?? $params->get('dolibarr_url', ''), '/');
|
||||
$this->apiKey = $apiKey ?? $params->get('dolibarr_api_key', '');
|
||||
$this->verifySSL = $verifySSL ?? (bool) $params->get('dolibarr_verify_ssl', true);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -97,6 +106,20 @@ class DolibarrClient
|
||||
return $this->request('PUT', $endpoint, [], $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a DELETE request to the Dolibarr API.
|
||||
*
|
||||
* @param string $endpoint API endpoint.
|
||||
*
|
||||
* @return array|null Decoded JSON response, or null on failure.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function delete(string $endpoint): ?array
|
||||
{
|
||||
return $this->request('DELETE', $endpoint);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test the connection to the Dolibarr API.
|
||||
*
|
||||
@@ -111,6 +134,82 @@ class DolibarrClient
|
||||
return $result !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detailed connection test returning status information.
|
||||
*
|
||||
* Checks connectivity, API version, and read/write permissions.
|
||||
*
|
||||
* @return array{ok: bool, version: string, permissions: array, error: string, hint: string}
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function testConnectionDetailed(): array
|
||||
{
|
||||
$result = [
|
||||
'ok' => false,
|
||||
'version' => '',
|
||||
'permissions' => ['read' => false, 'write' => false],
|
||||
'error' => '',
|
||||
'hint' => '',
|
||||
];
|
||||
|
||||
if (empty($this->baseUrl)) {
|
||||
$result['error'] = 'Dolibarr URL is not configured.';
|
||||
$result['hint'] = 'Set the Dolibarr URL in component options (e.g., https://erp.example.com).';
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
if (empty($this->apiKey)) {
|
||||
$result['error'] = 'API key is not configured.';
|
||||
$result['hint'] = 'Generate an API key in Dolibarr: Setup > Security > API and set it in component options.';
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
// Test basic connectivity
|
||||
$status = $this->get('/status');
|
||||
|
||||
if ($status === null) {
|
||||
$result['error'] = 'Cannot reach Dolibarr API.';
|
||||
$result['hint'] = 'Verify the URL is correct and the Dolibarr API module is enabled. '
|
||||
. 'Check: Home > Setup > Modules > Web Services API REST.';
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
$result['ok'] = true;
|
||||
$result['version'] = $status['success']['dolibarr_version'] ?? ($status['dolibarr_version'] ?? 'unknown');
|
||||
|
||||
// Test read access (list products, limit 1)
|
||||
$readTest = $this->get('/products', ['limit' => 1]);
|
||||
$result['permissions']['read'] = ($readTest !== null);
|
||||
|
||||
// Test write access by checking thirdparties access (non-destructive)
|
||||
$writeTest = $this->get('/thirdparties', ['limit' => 1]);
|
||||
$result['permissions']['write'] = ($writeTest !== null);
|
||||
|
||||
if (!$result['permissions']['read']) {
|
||||
$result['ok'] = false;
|
||||
$result['error'] = 'API key lacks read permission for products.';
|
||||
$result['hint'] = 'Ensure the API user has permissions: Products (read) and Third-parties (read/write).';
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the client is configured (has URL and key set).
|
||||
*
|
||||
* @return bool
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function isConfigured(): bool
|
||||
{
|
||||
return !empty($this->baseUrl) && !empty($this->apiKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute an HTTP request against the Dolibarr REST API.
|
||||
*
|
||||
@@ -147,7 +246,13 @@ class DolibarrClient
|
||||
|
||||
try
|
||||
{
|
||||
$http = HttpFactory::getHttp();
|
||||
$options = new Registry();
|
||||
$options->set('transport.curl', [
|
||||
CURLOPT_SSL_VERIFYPEER => $this->verifySSL,
|
||||
CURLOPT_SSL_VERIFYHOST => $this->verifySSL ? 2 : 0,
|
||||
]);
|
||||
|
||||
$http = HttpFactory::getHttp($options);
|
||||
$jsonBody = !empty($body) ? json_encode($body) : null;
|
||||
|
||||
switch (strtoupper($method))
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoDoliJoomShop
|
||||
* @subpackage com_mokodolijoomshop
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoDoliJoomShop\Administrator\Model;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
|
||||
use Moko\Component\MokoDoliJoomShop\Administrator\Helper\DolibarrClient;
|
||||
|
||||
/**
|
||||
* Dashboard model — provides metrics and sync data for the admin dashboard.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class DashboardModel extends BaseDatabaseModel
|
||||
{
|
||||
/**
|
||||
* @var DolibarrClient
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private DolibarrClient $client;
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @param array $config Configuration array.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function __construct($config = [])
|
||||
{
|
||||
parent::__construct($config);
|
||||
|
||||
$this->client = new DolibarrClient();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get product count from Dolibarr.
|
||||
*
|
||||
* @return int
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function getProductCount(): int
|
||||
{
|
||||
$products = $this->client->get('/products', ['limit' => 0]);
|
||||
|
||||
return $products !== null ? \count($products) : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get local order count.
|
||||
*
|
||||
* @return int
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function getOrderCount(): int
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$query = $db->getQuery(true);
|
||||
$query->select('COUNT(*)')
|
||||
->from($db->quoteName('#__mokodolijoomshop_orders'));
|
||||
$db->setQuery($query);
|
||||
|
||||
return (int) $db->loadResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get customer mapping count.
|
||||
*
|
||||
* @return int
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function getCustomerCount(): int
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$query = $db->getQuery(true);
|
||||
$query->select('COUNT(*)')
|
||||
->from($db->quoteName('#__mokodolijoomshop_customers'));
|
||||
$db->setQuery($query);
|
||||
|
||||
return (int) $db->loadResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent orders from local table.
|
||||
*
|
||||
* @param int $limit Number of recent orders.
|
||||
*
|
||||
* @return array
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function getRecentOrders(int $limit = 10): array
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$query = $db->getQuery(true);
|
||||
$query->select('*')
|
||||
->from($db->quoteName('#__mokodolijoomshop_orders'))
|
||||
->order($db->quoteName('created') . ' DESC');
|
||||
$db->setQuery($query, 0, $limit);
|
||||
|
||||
return $db->loadAssocList() ?: [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get revenue metrics.
|
||||
*
|
||||
* @return array{today: float, week: float, month: float}
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function getRevenue(): array
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$now = Factory::getDate();
|
||||
$today = $now->format('Y-m-d');
|
||||
$week = Factory::getDate('-7 days')->format('Y-m-d');
|
||||
$month = Factory::getDate('-30 days')->format('Y-m-d');
|
||||
|
||||
$revenue = ['today' => 0.0, 'week' => 0.0, 'month' => 0.0];
|
||||
|
||||
// Today
|
||||
$query = $db->getQuery(true);
|
||||
$query->select('COALESCE(SUM(' . $db->quoteName('total_ttc') . '), 0)')
|
||||
->from($db->quoteName('#__mokodolijoomshop_orders'))
|
||||
->where($db->quoteName('created') . ' >= ' . $db->quote($today . ' 00:00:00'));
|
||||
$db->setQuery($query);
|
||||
$revenue['today'] = (float) $db->loadResult();
|
||||
|
||||
// Week
|
||||
$query = $db->getQuery(true);
|
||||
$query->select('COALESCE(SUM(' . $db->quoteName('total_ttc') . '), 0)')
|
||||
->from($db->quoteName('#__mokodolijoomshop_orders'))
|
||||
->where($db->quoteName('created') . ' >= ' . $db->quote($week . ' 00:00:00'));
|
||||
$db->setQuery($query);
|
||||
$revenue['week'] = (float) $db->loadResult();
|
||||
|
||||
// Month
|
||||
$query = $db->getQuery(true);
|
||||
$query->select('COALESCE(SUM(' . $db->quoteName('total_ttc') . '), 0)')
|
||||
->from($db->quoteName('#__mokodolijoomshop_orders'))
|
||||
->where($db->quoteName('created') . ' >= ' . $db->quote($month . ' 00:00:00'));
|
||||
$db->setQuery($query);
|
||||
$revenue['month'] = (float) $db->loadResult();
|
||||
|
||||
return $revenue;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoDoliJoomShop
|
||||
* @subpackage com_mokodolijoomshop
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoDoliJoomShop\Administrator\Model;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
|
||||
use Moko\Component\MokoDoliJoomShop\Administrator\Helper\DolibarrClient;
|
||||
|
||||
/**
|
||||
* Admin orders model — lists and manages orders from local mapping table.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class OrdersModel extends BaseDatabaseModel
|
||||
{
|
||||
/**
|
||||
* @var DolibarrClient
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private DolibarrClient $client;
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @param array $config Configuration array.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function __construct($config = [])
|
||||
{
|
||||
parent::__construct($config);
|
||||
|
||||
$this->client = new DolibarrClient();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all orders with optional filters.
|
||||
*
|
||||
* @return array
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function getItems(): array
|
||||
{
|
||||
$app = Factory::getApplication();
|
||||
$db = $this->getDatabase();
|
||||
$query = $db->getQuery(true);
|
||||
$status = $app->input->getString('filter_status', '');
|
||||
$dateFrom = $app->input->getString('filter_date_from', '');
|
||||
$dateTo = $app->input->getString('filter_date_to', '');
|
||||
$search = $app->input->getString('filter_search', '');
|
||||
|
||||
$query->select('o.*')
|
||||
->from($db->quoteName('#__mokodolijoomshop_orders', 'o'))
|
||||
->order($db->quoteName('o.created') . ' DESC');
|
||||
|
||||
if (!empty($status))
|
||||
{
|
||||
$query->where($db->quoteName('o.status') . ' = ' . $db->quote($status));
|
||||
}
|
||||
|
||||
if (!empty($dateFrom))
|
||||
{
|
||||
$query->where($db->quoteName('o.created') . ' >= ' . $db->quote($dateFrom . ' 00:00:00'));
|
||||
}
|
||||
|
||||
if (!empty($dateTo))
|
||||
{
|
||||
$query->where($db->quoteName('o.created') . ' <= ' . $db->quote($dateTo . ' 23:59:59'));
|
||||
}
|
||||
|
||||
if (!empty($search))
|
||||
{
|
||||
$searchQuoted = $db->quote('%' . $search . '%');
|
||||
$query->where(
|
||||
'(' . $db->quoteName('o.order_ref') . ' LIKE ' . $searchQuoted
|
||||
. ' OR ' . $db->quoteName('o.invoice_ref') . ' LIKE ' . $searchQuoted . ')'
|
||||
);
|
||||
}
|
||||
|
||||
$db->setQuery($query);
|
||||
|
||||
$orders = $db->loadAssocList() ?: [];
|
||||
|
||||
// Enrich with user names
|
||||
foreach ($orders as &$order)
|
||||
{
|
||||
if ((int) $order['user_id'] > 0)
|
||||
{
|
||||
$userQuery = $db->getQuery(true);
|
||||
$userQuery->select($db->quoteName('name'))
|
||||
->from($db->quoteName('#__users'))
|
||||
->where($db->quoteName('id') . ' = ' . (int) $order['user_id']);
|
||||
$db->setQuery($userQuery);
|
||||
$order['customer_name'] = $db->loadResult() ?: 'User #' . $order['user_id'];
|
||||
}
|
||||
else
|
||||
{
|
||||
$order['customer_name'] = 'Guest';
|
||||
}
|
||||
}
|
||||
|
||||
unset($order);
|
||||
|
||||
return $orders;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync order status from Dolibarr for a specific order.
|
||||
*
|
||||
* @param int $localOrderId Local order table ID.
|
||||
*
|
||||
* @return string|null Updated status, or null on failure.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function syncOrderStatus(int $localOrderId): ?string
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$query = $db->getQuery(true);
|
||||
$query->select('*')
|
||||
->from($db->quoteName('#__mokodolijoomshop_orders'))
|
||||
->where($db->quoteName('id') . ' = ' . $localOrderId);
|
||||
$db->setQuery($query);
|
||||
$local = $db->loadAssoc();
|
||||
|
||||
if (empty($local) || empty($local['dolibarr_order_id']))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
$order = $this->client->get('/orders/' . (int) $local['dolibarr_order_id']);
|
||||
|
||||
if ($order === null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
$statusMap = [
|
||||
-1 => 'cancelled',
|
||||
0 => 'draft',
|
||||
1 => 'validated',
|
||||
2 => 'shipped',
|
||||
3 => 'delivered',
|
||||
];
|
||||
|
||||
$statusCode = (int) ($order['statut'] ?? $order['status'] ?? 0);
|
||||
$newStatus = $statusMap[$statusCode] ?? 'unknown';
|
||||
|
||||
// Update local status
|
||||
$update = $db->getQuery(true);
|
||||
$update->update($db->quoteName('#__mokodolijoomshop_orders'))
|
||||
->set($db->quoteName('status') . ' = ' . $db->quote($newStatus))
|
||||
->where($db->quoteName('id') . ' = ' . $localOrderId);
|
||||
$db->setQuery($update);
|
||||
$db->execute();
|
||||
|
||||
return $newStatus;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoDoliJoomShop
|
||||
* @subpackage com_mokodolijoomshop
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoDoliJoomShop\Administrator\Service;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Cache\CacheControllerFactoryInterface;
|
||||
use Joomla\CMS\Component\ComponentHelper;
|
||||
use Joomla\CMS\Factory;
|
||||
|
||||
/**
|
||||
* API response cache service using Joomla's cache framework.
|
||||
*
|
||||
* Caches Dolibarr API responses to reduce load and improve performance.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class CacheService
|
||||
{
|
||||
/**
|
||||
* @var string Cache group name.
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private const GROUP = 'com_mokodolijoomshop';
|
||||
|
||||
/**
|
||||
* Get a cached value or execute the callback and cache the result.
|
||||
*
|
||||
* @param string $key Cache key.
|
||||
* @param callable $callback Function to call if cache miss.
|
||||
* @param int|null $ttl Time-to-live in seconds (null = use default).
|
||||
*
|
||||
* @return mixed
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function remember(string $key, callable $callback, ?int $ttl = null)
|
||||
{
|
||||
if (!self::isEnabled())
|
||||
{
|
||||
return $callback();
|
||||
}
|
||||
|
||||
$cache = self::getCache($ttl);
|
||||
$id = md5($key);
|
||||
|
||||
$result = $cache->get($id, self::GROUP);
|
||||
|
||||
if ($result !== false)
|
||||
{
|
||||
return $result;
|
||||
}
|
||||
|
||||
$result = $callback();
|
||||
$cache->store($result, $id, self::GROUP);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate a specific cache key.
|
||||
*
|
||||
* @param string $key Cache key to invalidate.
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function forget(string $key): void
|
||||
{
|
||||
$cache = self::getCache();
|
||||
$cache->remove(md5($key), self::GROUP);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all component cache (used during manual sync).
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function flush(): void
|
||||
{
|
||||
$cache = self::getCache();
|
||||
$cache->clean(self::GROUP);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if caching is enabled.
|
||||
*
|
||||
* @return bool
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function isEnabled(): bool
|
||||
{
|
||||
$params = ComponentHelper::getParams('com_mokodolijoomshop');
|
||||
|
||||
return (bool) $params->get('cache_enabled', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default TTL in seconds.
|
||||
*
|
||||
* @return int
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function getDefaultTtl(): int
|
||||
{
|
||||
$params = ComponentHelper::getParams('com_mokodolijoomshop');
|
||||
|
||||
return (int) $params->get('cache_ttl', 900); // 15 minutes default
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a Joomla cache controller.
|
||||
*
|
||||
* @param int|null $ttl TTL override in seconds.
|
||||
*
|
||||
* @return \Joomla\CMS\Cache\CacheController
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function getCache(?int $ttl = null)
|
||||
{
|
||||
$options = [
|
||||
'defaultgroup' => self::GROUP,
|
||||
'caching' => true,
|
||||
'lifetime' => ($ttl ?? self::getDefaultTtl()) / 60,
|
||||
];
|
||||
|
||||
return Factory::getContainer()
|
||||
->get(CacheControllerFactoryInterface::class)
|
||||
->createCacheController('output', $options);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoDoliJoomShop
|
||||
* @subpackage com_mokodolijoomshop
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoDoliJoomShop\Administrator\Service;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Log\Log;
|
||||
use Joomla\CMS\User\User;
|
||||
use Moko\Component\MokoDoliJoomShop\Administrator\Helper\DolibarrClient;
|
||||
|
||||
/**
|
||||
* Syncs Joomla users to Dolibarr thirdparties (customers).
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class CustomerSyncService
|
||||
{
|
||||
/**
|
||||
* @var DolibarrClient
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private DolibarrClient $client;
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @param DolibarrClient|null $client Optional client override.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function __construct(?DolibarrClient $client = null)
|
||||
{
|
||||
$this->client = $client ?? new DolibarrClient();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create a Dolibarr thirdparty for the given Joomla user.
|
||||
*
|
||||
* Checks the local mapping table first, then searches Dolibarr by email,
|
||||
* and finally creates a new thirdparty if none exists.
|
||||
*
|
||||
* @param int $userId Joomla user ID.
|
||||
*
|
||||
* @return int|null Dolibarr thirdparty ID, or null on failure.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function getOrCreateThirdparty(int $userId): ?int
|
||||
{
|
||||
// Check local mapping first
|
||||
$existingId = $this->getLocalMapping($userId);
|
||||
|
||||
if ($existingId !== null)
|
||||
{
|
||||
return $existingId;
|
||||
}
|
||||
|
||||
$user = Factory::getContainer()->get(\Joomla\CMS\User\UserFactoryInterface::class)->loadUserById($userId);
|
||||
|
||||
if ($user->guest || empty($user->email))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Search Dolibarr by email to avoid duplicates
|
||||
$existing = $this->findThirdpartyByEmail($user->email);
|
||||
|
||||
if ($existing !== null)
|
||||
{
|
||||
$this->saveMapping($userId, $existing);
|
||||
|
||||
return $existing;
|
||||
}
|
||||
|
||||
// Create new thirdparty in Dolibarr
|
||||
$thirdpartyId = $this->createThirdparty($user);
|
||||
|
||||
if ($thirdpartyId !== null)
|
||||
{
|
||||
$this->saveMapping($userId, $thirdpartyId);
|
||||
}
|
||||
|
||||
return $thirdpartyId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a guest customer in Dolibarr (no Joomla user mapping).
|
||||
*
|
||||
* @param string $name Customer name.
|
||||
* @param string $email Customer email.
|
||||
* @param string $address Billing address.
|
||||
* @param string $town City.
|
||||
* @param string $zip Postal code.
|
||||
* @param string $phone Phone number.
|
||||
*
|
||||
* @return int|null Dolibarr thirdparty ID.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function createGuestCustomer(
|
||||
string $name,
|
||||
string $email,
|
||||
string $address = '',
|
||||
string $town = '',
|
||||
string $zip = '',
|
||||
string $phone = ''
|
||||
): ?int {
|
||||
// Check if already exists by email
|
||||
$existing = $this->findThirdpartyByEmail($email);
|
||||
|
||||
if ($existing !== null)
|
||||
{
|
||||
return $existing;
|
||||
}
|
||||
|
||||
$data = [
|
||||
'name' => $name,
|
||||
'email' => $email,
|
||||
'client' => 1,
|
||||
'code_client' => '-1',
|
||||
'address' => $address,
|
||||
'town' => $town,
|
||||
'zip' => $zip,
|
||||
'phone' => $phone,
|
||||
];
|
||||
|
||||
$result = $this->client->post('/thirdparties', $data);
|
||||
|
||||
if ($result === null)
|
||||
{
|
||||
Log::add('CustomerSyncService: Failed to create guest thirdparty for ' . $email, Log::ERROR, 'com_mokodolijoomshop');
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return (int) $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the local mapping for a Joomla user.
|
||||
*
|
||||
* @param int $userId Joomla user ID.
|
||||
*
|
||||
* @return int|null Dolibarr thirdparty ID, or null if not mapped.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function getLocalMapping(int $userId): ?int
|
||||
{
|
||||
$db = Factory::getContainer()->get('DatabaseDriver');
|
||||
$query = $db->getQuery(true);
|
||||
$query->select($db->quoteName('dolibarr_thirdparty_id'))
|
||||
->from($db->quoteName('#__mokodolijoomshop_customers'))
|
||||
->where($db->quoteName('user_id') . ' = ' . $userId);
|
||||
|
||||
$db->setQuery($query);
|
||||
$result = $db->loadResult();
|
||||
|
||||
return $result !== null ? (int) $result : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search Dolibarr for a thirdparty matching the given email.
|
||||
*
|
||||
* @param string $email Email address to search.
|
||||
*
|
||||
* @return int|null Thirdparty ID or null.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private function findThirdpartyByEmail(string $email): ?int
|
||||
{
|
||||
$results = $this->client->get('/thirdparties', [
|
||||
'sortfield' => 't.rowid',
|
||||
'sortorder' => 'ASC',
|
||||
'limit' => 1,
|
||||
'sqlfilters' => "(t.email:=:'" . addslashes($email) . "')",
|
||||
]);
|
||||
|
||||
if (!empty($results) && isset($results[0]['id']))
|
||||
{
|
||||
return (int) $results[0]['id'];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Dolibarr thirdparty from a Joomla user.
|
||||
*
|
||||
* @param User $user Joomla user object.
|
||||
*
|
||||
* @return int|null Created thirdparty ID.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private function createThirdparty(User $user): ?int
|
||||
{
|
||||
$data = [
|
||||
'name' => $user->name,
|
||||
'email' => $user->email,
|
||||
'client' => 1,
|
||||
'code_client' => '-1',
|
||||
];
|
||||
|
||||
$result = $this->client->post('/thirdparties', $data);
|
||||
|
||||
if ($result === null)
|
||||
{
|
||||
Log::add(
|
||||
'CustomerSyncService: Failed to create thirdparty for user ' . $user->id,
|
||||
Log::ERROR,
|
||||
'com_mokodolijoomshop'
|
||||
);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return (int) $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a user ↔ thirdparty mapping in the local database.
|
||||
*
|
||||
* @param int $userId Joomla user ID.
|
||||
* @param int $thirdpartyId Dolibarr thirdparty ID.
|
||||
*
|
||||
* @return bool
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private function saveMapping(int $userId, int $thirdpartyId): bool
|
||||
{
|
||||
$db = Factory::getContainer()->get('DatabaseDriver');
|
||||
$table = new \Moko\Component\MokoDoliJoomShop\Administrator\Table\CustomerTable($db);
|
||||
|
||||
$data = [
|
||||
'user_id' => $userId,
|
||||
'dolibarr_thirdparty_id' => $thirdpartyId,
|
||||
'synced_at' => Factory::getDate()->toSql(),
|
||||
];
|
||||
|
||||
$table->bind($data);
|
||||
|
||||
if (!$table->check())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return $table->store();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoDoliJoomShop
|
||||
* @subpackage com_mokodolijoomshop
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoDoliJoomShop\Administrator\Service;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Component\ComponentHelper;
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\Log\Log;
|
||||
use Joomla\CMS\Mail\MailerFactoryInterface;
|
||||
|
||||
/**
|
||||
* Email notification service for order events.
|
||||
*
|
||||
* Sends customer confirmation and admin notification emails
|
||||
* using Joomla's mail transport system.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class EmailService
|
||||
{
|
||||
/**
|
||||
* Send order confirmation email to the customer.
|
||||
*
|
||||
* @param string $customerEmail Customer email address.
|
||||
* @param string $customerName Customer name.
|
||||
* @param array $orderData Order result data (order_ref, invoice_ref).
|
||||
* @param array $cartItems Cart items at time of order.
|
||||
* @param array $totals Cart totals (subtotal, tax, total).
|
||||
*
|
||||
* @return bool True if sent successfully.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function sendCustomerConfirmation(
|
||||
string $customerEmail,
|
||||
string $customerName,
|
||||
array $orderData,
|
||||
array $cartItems,
|
||||
array $totals
|
||||
): bool {
|
||||
$params = ComponentHelper::getParams('com_mokodolijoomshop');
|
||||
$currency = $params->get('currency', 'USD');
|
||||
$siteName = Factory::getApplication()->get('sitename');
|
||||
|
||||
$subject = Text::sprintf('COM_MOKODOLIJOOMSHOP_EMAIL_ORDER_SUBJECT', $orderData['order_ref'] ?? '');
|
||||
|
||||
$body = $this->buildCustomerEmailBody(
|
||||
$customerName,
|
||||
$orderData,
|
||||
$cartItems,
|
||||
$totals,
|
||||
$currency,
|
||||
$siteName
|
||||
);
|
||||
|
||||
return $this->sendMail($customerEmail, $subject, $body);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send order notification email to the admin.
|
||||
*
|
||||
* @param array $orderData Order result data.
|
||||
* @param array $cartItems Cart items.
|
||||
* @param array $totals Cart totals.
|
||||
* @param string $customerName Customer name.
|
||||
* @param string $customerEmail Customer email.
|
||||
*
|
||||
* @return bool True if sent successfully.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function sendAdminNotification(
|
||||
array $orderData,
|
||||
array $cartItems,
|
||||
array $totals,
|
||||
string $customerName,
|
||||
string $customerEmail
|
||||
): bool {
|
||||
$params = ComponentHelper::getParams('com_mokodolijoomshop');
|
||||
$currency = $params->get('currency', 'USD');
|
||||
$adminMail = Factory::getApplication()->get('mailfrom');
|
||||
|
||||
$subject = Text::sprintf('COM_MOKODOLIJOOMSHOP_EMAIL_ADMIN_ORDER_SUBJECT', $orderData['order_ref'] ?? '');
|
||||
|
||||
$body = $this->buildAdminEmailBody(
|
||||
$orderData,
|
||||
$cartItems,
|
||||
$totals,
|
||||
$currency,
|
||||
$customerName,
|
||||
$customerEmail
|
||||
);
|
||||
|
||||
return $this->sendMail($adminMail, $subject, $body);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the customer confirmation email HTML body.
|
||||
*
|
||||
* @param string $name Customer name.
|
||||
* @param array $order Order data.
|
||||
* @param array $items Cart items.
|
||||
* @param array $totals Totals.
|
||||
* @param string $currency Currency code.
|
||||
* @param string $siteName Site name.
|
||||
*
|
||||
* @return string HTML email body.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private function buildCustomerEmailBody(
|
||||
string $name,
|
||||
array $order,
|
||||
array $items,
|
||||
array $totals,
|
||||
string $currency,
|
||||
string $siteName
|
||||
): string {
|
||||
$orderRef = htmlspecialchars($order['order_ref'] ?? '');
|
||||
$invoiceRef = htmlspecialchars($order['invoice_ref'] ?? '');
|
||||
|
||||
$html = '<html><body style="font-family: Arial, sans-serif; line-height: 1.6;">';
|
||||
$html .= '<h2>' . Text::sprintf('COM_MOKODOLIJOOMSHOP_EMAIL_GREETING', htmlspecialchars($name)) . '</h2>';
|
||||
$html .= '<p>' . Text::_('COM_MOKODOLIJOOMSHOP_EMAIL_ORDER_CONFIRMED') . '</p>';
|
||||
$html .= '<table style="border-collapse:collapse; width:100%; margin:20px 0;">';
|
||||
$html .= '<tr><td><strong>' . Text::_('COM_MOKODOLIJOOMSHOP_ORDER_REF') . ':</strong></td><td>' . $orderRef . '</td></tr>';
|
||||
|
||||
if ($invoiceRef)
|
||||
{
|
||||
$html .= '<tr><td><strong>' . Text::_('COM_MOKODOLIJOOMSHOP_ORDER_INVOICE_REF') . ':</strong></td><td>' . $invoiceRef . '</td></tr>';
|
||||
}
|
||||
|
||||
$html .= '</table>';
|
||||
|
||||
// Items table
|
||||
$html .= '<table style="border-collapse:collapse; width:100%; margin:20px 0; border:1px solid #ddd;">';
|
||||
$html .= '<thead><tr style="background:#f5f5f5;">';
|
||||
$html .= '<th style="padding:8px; text-align:left; border:1px solid #ddd;">' . Text::_('COM_MOKODOLIJOOMSHOP_PRODUCT_LABEL') . '</th>';
|
||||
$html .= '<th style="padding:8px; text-align:center; border:1px solid #ddd;">' . Text::_('COM_MOKODOLIJOOMSHOP_QUANTITY') . '</th>';
|
||||
$html .= '<th style="padding:8px; text-align:right; border:1px solid #ddd;">' . Text::_('COM_MOKODOLIJOOMSHOP_SUBTOTAL') . '</th>';
|
||||
$html .= '</tr></thead><tbody>';
|
||||
|
||||
foreach ($items as $item)
|
||||
{
|
||||
$lineTotal = (float) $item['unit_price'] * (int) $item['quantity'];
|
||||
$html .= '<tr>';
|
||||
$html .= '<td style="padding:8px; border:1px solid #ddd;">' . htmlspecialchars($item['product_label']) . '</td>';
|
||||
$html .= '<td style="padding:8px; text-align:center; border:1px solid #ddd;">' . (int) $item['quantity'] . '</td>';
|
||||
$html .= '<td style="padding:8px; text-align:right; border:1px solid #ddd;">' . number_format($lineTotal, 2) . ' ' . $currency . '</td>';
|
||||
$html .= '</tr>';
|
||||
}
|
||||
|
||||
$html .= '</tbody></table>';
|
||||
|
||||
// Totals
|
||||
$html .= '<table style="width:300px; margin-left:auto;">';
|
||||
$html .= '<tr><td>' . Text::_('COM_MOKODOLIJOOMSHOP_SUBTOTAL') . '</td><td style="text-align:right;">' . number_format($totals['subtotal'], 2) . ' ' . $currency . '</td></tr>';
|
||||
|
||||
if ($totals['tax'] > 0)
|
||||
{
|
||||
$html .= '<tr><td>' . Text::_('COM_MOKODOLIJOOMSHOP_TAX') . '</td><td style="text-align:right;">' . number_format($totals['tax'], 2) . ' ' . $currency . '</td></tr>';
|
||||
}
|
||||
|
||||
$html .= '<tr style="font-weight:bold; font-size:1.2em;"><td>' . Text::_('COM_MOKODOLIJOOMSHOP_TOTAL') . '</td><td style="text-align:right;">' . number_format($totals['total'], 2) . ' ' . $currency . '</td></tr>';
|
||||
$html .= '</table>';
|
||||
|
||||
$html .= '<p style="margin-top:30px; color:#666;">' . Text::sprintf('COM_MOKODOLIJOOMSHOP_EMAIL_FOOTER', htmlspecialchars($siteName)) . '</p>';
|
||||
$html .= '</body></html>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the admin notification email body.
|
||||
*
|
||||
* @param array $order Order data.
|
||||
* @param array $items Cart items.
|
||||
* @param array $totals Totals.
|
||||
* @param string $currency Currency.
|
||||
* @param string $customerName Customer name.
|
||||
* @param string $customerEmail Customer email.
|
||||
*
|
||||
* @return string HTML body.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private function buildAdminEmailBody(
|
||||
array $order,
|
||||
array $items,
|
||||
array $totals,
|
||||
string $currency,
|
||||
string $customerName,
|
||||
string $customerEmail
|
||||
): string {
|
||||
$html = '<html><body style="font-family: Arial, sans-serif;">';
|
||||
$html .= '<h2>' . Text::_('COM_MOKODOLIJOOMSHOP_EMAIL_NEW_ORDER') . '</h2>';
|
||||
$html .= '<p><strong>' . Text::_('COM_MOKODOLIJOOMSHOP_ORDER_REF') . ':</strong> ' . htmlspecialchars($order['order_ref'] ?? '') . '</p>';
|
||||
$html .= '<p><strong>' . Text::_('COM_MOKODOLIJOOMSHOP_CUSTOMER_NAME') . ':</strong> ' . htmlspecialchars($customerName) . ' (' . htmlspecialchars($customerEmail) . ')</p>';
|
||||
$html .= '<p><strong>' . Text::_('COM_MOKODOLIJOOMSHOP_TOTAL') . ':</strong> ' . number_format($totals['total'], 2) . ' ' . $currency . '</p>';
|
||||
$html .= '<p><strong>' . Text::_('COM_MOKODOLIJOOMSHOP_QUANTITY') . ':</strong> ' . \count($items) . ' item(s)</p>';
|
||||
$html .= '</body></html>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an HTML email using Joomla's mail system.
|
||||
*
|
||||
* @param string $to Recipient email.
|
||||
* @param string $subject Email subject.
|
||||
* @param string $body HTML body.
|
||||
*
|
||||
* @return bool
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private function sendMail(string $to, string $subject, string $body): bool
|
||||
{
|
||||
try
|
||||
{
|
||||
$mailer = Factory::getContainer()->get(MailerFactoryInterface::class)->createMailer();
|
||||
$mailer->addRecipient($to);
|
||||
$mailer->setSubject($subject);
|
||||
$mailer->setBody($body);
|
||||
$mailer->isHtml(true);
|
||||
|
||||
return $mailer->Send();
|
||||
}
|
||||
catch (\Exception $e)
|
||||
{
|
||||
Log::add('EmailService: ' . $e->getMessage(), Log::ERROR, 'com_mokodolijoomshop');
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoDoliJoomShop
|
||||
* @subpackage com_mokodolijoomshop
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoDoliJoomShop\Administrator\Service;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Filesystem\File;
|
||||
use Joomla\CMS\Filesystem\Folder;
|
||||
use Joomla\CMS\Log\Log;
|
||||
use Joomla\CMS\Uri\Uri;
|
||||
use Moko\Component\MokoDoliJoomShop\Administrator\Helper\DolibarrClient;
|
||||
|
||||
/**
|
||||
* Product image service — fetches, caches, and serves product images.
|
||||
*
|
||||
* Images are stored in: /media/com_mokodolijoomshop/images/products/{product_id}/
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class ImageService
|
||||
{
|
||||
/**
|
||||
* @var string Base path for cached images.
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private string $basePath;
|
||||
|
||||
/**
|
||||
* @var string Base URL for cached images.
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private string $baseUrl;
|
||||
|
||||
/**
|
||||
* @var DolibarrClient
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private DolibarrClient $client;
|
||||
|
||||
/**
|
||||
* @var string Placeholder image path.
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private const PLACEHOLDER = 'media/com_mokodolijoomshop/images/placeholder.png';
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @param DolibarrClient|null $client Optional client override.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function __construct(?DolibarrClient $client = null)
|
||||
{
|
||||
$this->client = $client ?? new DolibarrClient();
|
||||
$this->basePath = JPATH_ROOT . '/media/com_mokodolijoomshop/images/products';
|
||||
$this->baseUrl = Uri::root() . 'media/com_mokodolijoomshop/images/products';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get image URLs for a product, fetching from Dolibarr if not cached.
|
||||
*
|
||||
* @param int $productId Dolibarr product ID.
|
||||
*
|
||||
* @return array Array of image URLs (local cached paths).
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function getProductImages(int $productId): array
|
||||
{
|
||||
$productDir = $this->basePath . '/' . $productId;
|
||||
|
||||
// Check cache first
|
||||
if (is_dir($productDir))
|
||||
{
|
||||
$files = Folder::files($productDir, '\.(jpe?g|png|gif|webp)$', false, true);
|
||||
|
||||
if (!empty($files))
|
||||
{
|
||||
return array_map(function ($file) use ($productId) {
|
||||
return $this->baseUrl . '/' . $productId . '/' . basename($file);
|
||||
}, $files);
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch from Dolibarr
|
||||
return $this->fetchAndCache($productId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single thumbnail URL for list views.
|
||||
*
|
||||
* @param int $productId Dolibarr product ID.
|
||||
*
|
||||
* @return string Image URL or placeholder.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function getThumbnail(int $productId): string
|
||||
{
|
||||
$images = $this->getProductImages($productId);
|
||||
|
||||
if (empty($images))
|
||||
{
|
||||
return Uri::root() . self::PLACEHOLDER;
|
||||
}
|
||||
|
||||
// Return first image as thumbnail
|
||||
return $images[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the placeholder image URL.
|
||||
*
|
||||
* @return string
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function getPlaceholder(): string
|
||||
{
|
||||
return Uri::root() . self::PLACEHOLDER;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate the image cache for a product (used during sync).
|
||||
*
|
||||
* @param int $productId Dolibarr product ID.
|
||||
*
|
||||
* @return bool
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function invalidateCache(int $productId): bool
|
||||
{
|
||||
$productDir = $this->basePath . '/' . $productId;
|
||||
|
||||
if (is_dir($productDir))
|
||||
{
|
||||
return Folder::delete($productDir);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate all cached images.
|
||||
*
|
||||
* @return bool
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function invalidateAll(): bool
|
||||
{
|
||||
if (is_dir($this->basePath))
|
||||
{
|
||||
return Folder::delete($this->basePath) && Folder::create($this->basePath);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch product images from Dolibarr and cache them locally.
|
||||
*
|
||||
* @param int $productId Dolibarr product ID.
|
||||
*
|
||||
* @return array Array of cached image URLs.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private function fetchAndCache(int $productId): array
|
||||
{
|
||||
$docs = $this->client->get('/documents', [
|
||||
'modulepart' => 'product',
|
||||
'id' => $productId,
|
||||
]);
|
||||
|
||||
if (empty($docs) || !\is_array($docs))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
$productDir = $this->basePath . '/' . $productId;
|
||||
|
||||
if (!is_dir($productDir))
|
||||
{
|
||||
Folder::create($productDir);
|
||||
}
|
||||
|
||||
$urls = [];
|
||||
|
||||
foreach ($docs as $doc)
|
||||
{
|
||||
$filename = $doc['name'] ?? basename($doc['relativename'] ?? '');
|
||||
|
||||
if (!preg_match('/\.(jpe?g|png|gif|webp)$/i', $filename))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Download the file content
|
||||
$content = null;
|
||||
|
||||
if (!empty($doc['content']))
|
||||
{
|
||||
// Base64 encoded content
|
||||
$content = base64_decode($doc['content']);
|
||||
}
|
||||
elseif (!empty($doc['fullname']))
|
||||
{
|
||||
// Fetch via documents/download endpoint
|
||||
$download = $this->client->get('/documents/download', [
|
||||
'modulepart' => 'product',
|
||||
'original_file' => $doc['relativename'] ?? $filename,
|
||||
]);
|
||||
|
||||
if (!empty($download['content']))
|
||||
{
|
||||
$content = base64_decode($download['content']);
|
||||
}
|
||||
}
|
||||
|
||||
if ($content !== null)
|
||||
{
|
||||
$localPath = $productDir . '/' . $filename;
|
||||
File::write($localPath, $content);
|
||||
$urls[] = $this->baseUrl . '/' . $productId . '/' . $filename;
|
||||
}
|
||||
}
|
||||
|
||||
return $urls;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoDoliJoomShop
|
||||
* @subpackage com_mokodolijoomshop
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoDoliJoomShop\Administrator\Service;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Log\Log;
|
||||
use Moko\Component\MokoDoliJoomShop\Administrator\Helper\DolibarrClient;
|
||||
use Moko\Component\MokoDoliJoomShop\Administrator\Table\OrderTable;
|
||||
|
||||
/**
|
||||
* Creates orders and invoices in Dolibarr from cart data.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class OrderService
|
||||
{
|
||||
/**
|
||||
* @var DolibarrClient
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private DolibarrClient $client;
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @param DolibarrClient|null $client Optional client override.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function __construct(?DolibarrClient $client = null)
|
||||
{
|
||||
$this->client = $client ?? new DolibarrClient();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an order in Dolibarr from cart items.
|
||||
*
|
||||
* @param int $thirdpartyId Dolibarr thirdparty (customer) ID.
|
||||
* @param array $cartItems Cart items array from CartModel::getItems().
|
||||
* @param array $metadata Additional order metadata (note_public, note_private, etc.).
|
||||
*
|
||||
* @return array|null Array with order data, or null on failure.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function createOrder(int $thirdpartyId, array $cartItems, array $metadata = []): ?array
|
||||
{
|
||||
if (empty($cartItems))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Build line items
|
||||
$lines = [];
|
||||
|
||||
foreach ($cartItems as $item)
|
||||
{
|
||||
$lines[] = [
|
||||
'fk_product' => (int) $item['dolibarr_product_id'],
|
||||
'qty' => (int) $item['quantity'],
|
||||
'subprice' => (float) $item['unit_price'],
|
||||
'tva_tx' => (float) $item['tax_rate'],
|
||||
'product_type' => 0,
|
||||
'desc' => $item['product_label'] ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
$orderData = [
|
||||
'socid' => $thirdpartyId,
|
||||
'date' => date('Y-m-d'),
|
||||
'lines' => $lines,
|
||||
'note_public' => $metadata['note_public'] ?? '',
|
||||
'note_private' => $metadata['note_private'] ?? '',
|
||||
];
|
||||
|
||||
$result = $this->client->post('/orders', $orderData);
|
||||
|
||||
if ($result === null)
|
||||
{
|
||||
Log::add('OrderService: Failed to create order for thirdparty ' . $thirdpartyId, Log::ERROR, 'com_mokodolijoomshop');
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$orderId = (int) $result;
|
||||
|
||||
// Fetch created order details
|
||||
$order = $this->client->get('/orders/' . $orderId);
|
||||
|
||||
if ($order === null)
|
||||
{
|
||||
return ['id' => $orderId, 'ref' => ''];
|
||||
}
|
||||
|
||||
// Validate (set to status 1 = validated)
|
||||
$this->client->post('/orders/' . $orderId . '/validate', []);
|
||||
|
||||
return $order;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an invoice from a Dolibarr order.
|
||||
*
|
||||
* @param int $orderId Dolibarr order ID.
|
||||
*
|
||||
* @return array|null Invoice data, or null on failure.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function createInvoiceFromOrder(int $orderId): ?array
|
||||
{
|
||||
$invoiceData = [
|
||||
'socid' => 0,
|
||||
];
|
||||
|
||||
// Use createfromorder endpoint
|
||||
$result = $this->client->post('/invoices/createfromorder/' . $orderId, $invoiceData);
|
||||
|
||||
if ($result === null)
|
||||
{
|
||||
// Fallback: create invoice manually from order data
|
||||
$order = $this->client->get('/orders/' . $orderId);
|
||||
|
||||
if ($order === null)
|
||||
{
|
||||
Log::add('OrderService: Failed to create invoice from order ' . $orderId, Log::ERROR, 'com_mokodolijoomshop');
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$lines = [];
|
||||
|
||||
foreach ($order['lines'] ?? [] as $line)
|
||||
{
|
||||
$lines[] = [
|
||||
'fk_product' => (int) ($line['fk_product'] ?? 0),
|
||||
'qty' => (float) ($line['qty'] ?? 1),
|
||||
'subprice' => (float) ($line['subprice'] ?? 0),
|
||||
'tva_tx' => (float) ($line['tva_tx'] ?? 0),
|
||||
'product_type' => (int) ($line['product_type'] ?? 0),
|
||||
'desc' => $line['desc'] ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
$invoicePayload = [
|
||||
'socid' => (int) ($order['socid'] ?? 0),
|
||||
'date' => date('Y-m-d'),
|
||||
'lines' => $lines,
|
||||
'linked_objects' => ['commande' => $orderId],
|
||||
];
|
||||
|
||||
$result = $this->client->post('/invoices', $invoicePayload);
|
||||
|
||||
if ($result === null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
$invoiceId = (int) $result;
|
||||
$this->client->post('/invoices/' . $invoiceId . '/validate', []);
|
||||
|
||||
return $this->client->get('/invoices/' . $invoiceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save order mapping to local database.
|
||||
*
|
||||
* @param int $userId Joomla user ID (0 for guest).
|
||||
* @param int $orderId Dolibarr order ID.
|
||||
* @param int $invoiceId Dolibarr invoice ID.
|
||||
* @param int $thirdpartyId Dolibarr thirdparty ID.
|
||||
* @param string $orderRef Order reference string.
|
||||
* @param string $invoiceRef Invoice reference string.
|
||||
* @param float $totalHT Total excl. tax.
|
||||
* @param float $totalTTC Total incl. tax.
|
||||
*
|
||||
* @return bool
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function saveOrderMapping(
|
||||
int $userId,
|
||||
int $orderId,
|
||||
int $invoiceId,
|
||||
int $thirdpartyId,
|
||||
string $orderRef,
|
||||
string $invoiceRef,
|
||||
float $totalHT,
|
||||
float $totalTTC
|
||||
): bool {
|
||||
$db = Factory::getContainer()->get('DatabaseDriver');
|
||||
$table = new OrderTable($db);
|
||||
|
||||
$data = [
|
||||
'user_id' => $userId,
|
||||
'dolibarr_order_id' => $orderId,
|
||||
'dolibarr_invoice_id' => $invoiceId,
|
||||
'dolibarr_thirdparty_id' => $thirdpartyId,
|
||||
'order_ref' => $orderRef,
|
||||
'invoice_ref' => $invoiceRef,
|
||||
'total_ht' => $totalHT,
|
||||
'total_ttc' => $totalTTC,
|
||||
'status' => 'confirmed',
|
||||
];
|
||||
|
||||
$table->bind($data);
|
||||
|
||||
if (!$table->check())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return $table->store();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoDoliJoomShop
|
||||
* @subpackage com_mokodolijoomshop
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoDoliJoomShop\Administrator\Service;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Component\ComponentHelper;
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Log\Log;
|
||||
|
||||
/**
|
||||
* Webhook service — receives and processes Dolibarr webhook events.
|
||||
*
|
||||
* Endpoint: /api/mokodolijoomshop/webhook
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class WebhookService
|
||||
{
|
||||
/**
|
||||
* Validate the webhook secret.
|
||||
*
|
||||
* @param string $providedSecret Secret from request header.
|
||||
*
|
||||
* @return bool
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function validateSecret(string $providedSecret): bool
|
||||
{
|
||||
$params = ComponentHelper::getParams('com_mokodolijoomshop');
|
||||
$expectedSecret = $params->get('webhook_secret', '');
|
||||
|
||||
if (empty($expectedSecret))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return hash_equals($expectedSecret, $providedSecret);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process an incoming webhook event.
|
||||
*
|
||||
* @param string $eventType Event type (e.g., 'PRODUCT_CREATE', 'ORDER_UPDATE').
|
||||
* @param array $payload Event payload data.
|
||||
*
|
||||
* @return bool True if processed successfully.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function processEvent(string $eventType, array $payload): bool
|
||||
{
|
||||
$this->logEvent($eventType, $payload, 'processing');
|
||||
|
||||
try
|
||||
{
|
||||
switch ($eventType)
|
||||
{
|
||||
case 'PRODUCT_CREATE':
|
||||
case 'PRODUCT_MODIFY':
|
||||
$this->handleProductChange($payload);
|
||||
break;
|
||||
|
||||
case 'PRODUCT_DELETE':
|
||||
$this->handleProductDelete($payload);
|
||||
break;
|
||||
|
||||
case 'ORDER_VALIDATE':
|
||||
case 'ORDER_MODIFY':
|
||||
case 'ORDER_CLOSE':
|
||||
case 'ORDER_CANCEL':
|
||||
$this->handleOrderStatusChange($payload);
|
||||
break;
|
||||
|
||||
case 'PAYMENT_CUSTOMER_CREATE':
|
||||
$this->handlePaymentReceived($payload);
|
||||
break;
|
||||
|
||||
default:
|
||||
$this->logEvent($eventType, $payload, 'ignored', 'Unknown event type');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
$this->logEvent($eventType, $payload, 'success');
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (\Exception $e)
|
||||
{
|
||||
$this->logEvent($eventType, $payload, 'error', $e->getMessage());
|
||||
Log::add('WebhookService: ' . $e->getMessage(), Log::ERROR, 'com_mokodolijoomshop');
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle product create/modify — invalidate image cache.
|
||||
*
|
||||
* @param array $payload Event payload.
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private function handleProductChange(array $payload): void
|
||||
{
|
||||
$productId = (int) ($payload['object_id'] ?? $payload['id'] ?? 0);
|
||||
|
||||
if ($productId > 0)
|
||||
{
|
||||
// Invalidate cached images for this product
|
||||
$imageService = new ImageService();
|
||||
$imageService->invalidateCache($productId);
|
||||
|
||||
// Clear API cache
|
||||
CacheService::forget('products_list');
|
||||
CacheService::forget('product_' . $productId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle product deletion.
|
||||
*
|
||||
* @param array $payload Event payload.
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private function handleProductDelete(array $payload): void
|
||||
{
|
||||
$productId = (int) ($payload['object_id'] ?? $payload['id'] ?? 0);
|
||||
|
||||
if ($productId > 0)
|
||||
{
|
||||
$imageService = new ImageService();
|
||||
$imageService->invalidateCache($productId);
|
||||
CacheService::flush();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle order status changes — update local mapping.
|
||||
*
|
||||
* @param array $payload Event payload.
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private function handleOrderStatusChange(array $payload): void
|
||||
{
|
||||
$orderId = (int) ($payload['object_id'] ?? $payload['id'] ?? 0);
|
||||
|
||||
if ($orderId <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
$statusMap = [
|
||||
-1 => 'cancelled',
|
||||
0 => 'draft',
|
||||
1 => 'validated',
|
||||
2 => 'shipped',
|
||||
3 => 'delivered',
|
||||
];
|
||||
|
||||
$statusCode = (int) ($payload['object_status'] ?? $payload['status'] ?? 0);
|
||||
$newStatus = $statusMap[$statusCode] ?? 'unknown';
|
||||
|
||||
$db = Factory::getContainer()->get('DatabaseDriver');
|
||||
$query = $db->getQuery(true);
|
||||
$query->update($db->quoteName('#__mokodolijoomshop_orders'))
|
||||
->set($db->quoteName('status') . ' = ' . $db->quote($newStatus))
|
||||
->where($db->quoteName('dolibarr_order_id') . ' = ' . $orderId);
|
||||
$db->setQuery($query);
|
||||
$db->execute();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle payment received — update order status to paid.
|
||||
*
|
||||
* @param array $payload Event payload.
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private function handlePaymentReceived(array $payload): void
|
||||
{
|
||||
$invoiceId = (int) ($payload['object_id'] ?? 0);
|
||||
|
||||
if ($invoiceId <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
$db = Factory::getContainer()->get('DatabaseDriver');
|
||||
$query = $db->getQuery(true);
|
||||
$query->update($db->quoteName('#__mokodolijoomshop_orders'))
|
||||
->set($db->quoteName('status') . ' = ' . $db->quote('paid'))
|
||||
->where($db->quoteName('dolibarr_invoice_id') . ' = ' . $invoiceId);
|
||||
$db->setQuery($query);
|
||||
$db->execute();
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a webhook event to the database.
|
||||
*
|
||||
* @param string $eventType Event type.
|
||||
* @param array $payload Payload data.
|
||||
* @param string $status Processing status.
|
||||
* @param string $message Optional message.
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private function logEvent(string $eventType, array $payload, string $status, string $message = ''): void
|
||||
{
|
||||
$db = Factory::getContainer()->get('DatabaseDriver');
|
||||
$query = $db->getQuery(true);
|
||||
$query->insert($db->quoteName('#__mokodolijoomshop_webhook_log'))
|
||||
->columns(['event_type', 'payload', 'status', 'message'])
|
||||
->values(implode(',', [
|
||||
$db->quote($eventType),
|
||||
$db->quote(json_encode($payload)),
|
||||
$db->quote($status),
|
||||
$db->quote(mb_substr($message, 0, 500)),
|
||||
]));
|
||||
$db->setQuery($query);
|
||||
$db->execute();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoDoliJoomShop
|
||||
* @subpackage com_mokodolijoomshop
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoDoliJoomShop\Administrator\Table;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Table\Table;
|
||||
use Joomla\Database\DatabaseDriver;
|
||||
|
||||
/**
|
||||
* Cart item table class.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class CartTable extends Table
|
||||
{
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @param DatabaseDriver $db Database connector.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function __construct(DatabaseDriver $db)
|
||||
{
|
||||
parent::__construct('#__mokodolijoomshop_cart', 'id', $db);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validation before store.
|
||||
*
|
||||
* @return bool
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function check(): bool
|
||||
{
|
||||
if (empty($this->session_id) && empty($this->user_id))
|
||||
{
|
||||
$this->setError('Cart item must have a session_id or user_id.');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (empty($this->dolibarr_product_id))
|
||||
{
|
||||
$this->setError('Cart item must have a product ID.');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->quantity < 1)
|
||||
{
|
||||
$this->quantity = 1;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoDoliJoomShop
|
||||
* @subpackage com_mokodolijoomshop
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoDoliJoomShop\Administrator\Table;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Table\Table;
|
||||
use Joomla\Database\DatabaseDriver;
|
||||
|
||||
/**
|
||||
* Customer mapping table class.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class CustomerTable extends Table
|
||||
{
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @param DatabaseDriver $db Database connector.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function __construct(DatabaseDriver $db)
|
||||
{
|
||||
parent::__construct('#__mokodolijoomshop_customers', 'id', $db);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validation before store.
|
||||
*
|
||||
* @return bool
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function check(): bool
|
||||
{
|
||||
if (empty($this->user_id))
|
||||
{
|
||||
$this->setError('Customer mapping must have a Joomla user_id.');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (empty($this->dolibarr_thirdparty_id))
|
||||
{
|
||||
$this->setError('Customer mapping must have a Dolibarr thirdparty_id.');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoDoliJoomShop
|
||||
* @subpackage com_mokodolijoomshop
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoDoliJoomShop\Administrator\Table;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Table\Table;
|
||||
use Joomla\Database\DatabaseDriver;
|
||||
|
||||
/**
|
||||
* Order mapping table class.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class OrderTable extends Table
|
||||
{
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @param DatabaseDriver $db Database connector.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function __construct(DatabaseDriver $db)
|
||||
{
|
||||
parent::__construct('#__mokodolijoomshop_orders', 'id', $db);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validation before store.
|
||||
*
|
||||
* @return bool
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function check(): bool
|
||||
{
|
||||
if (empty($this->dolibarr_order_id))
|
||||
{
|
||||
$this->setError('Order mapping must have a Dolibarr order ID.');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -10,9 +10,11 @@ namespace Moko\Component\MokoDoliJoomShop\Administrator\View\Dashboard;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Component\ComponentHelper;
|
||||
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
||||
use Joomla\CMS\Toolbar\ToolbarHelper;
|
||||
use Moko\Component\MokoDoliJoomShop\Administrator\Helper\DolibarrClient;
|
||||
use Moko\Component\MokoDoliJoomShop\Administrator\Model\DashboardModel;
|
||||
|
||||
/**
|
||||
* Dashboard view for the admin.
|
||||
@@ -27,6 +29,48 @@ class HtmlView extends BaseHtmlView
|
||||
*/
|
||||
protected bool $connectionOk = false;
|
||||
|
||||
/**
|
||||
* @var array Detailed connection status from DolibarrClient.
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected array $connectionStatus = [];
|
||||
|
||||
/**
|
||||
* @var int Product count.
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected int $productCount = 0;
|
||||
|
||||
/**
|
||||
* @var int Order count.
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected int $orderCount = 0;
|
||||
|
||||
/**
|
||||
* @var int Customer count.
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected int $customerCount = 0;
|
||||
|
||||
/**
|
||||
* @var array Recent orders.
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected array $recentOrders = [];
|
||||
|
||||
/**
|
||||
* @var array Revenue metrics.
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected array $revenue = [];
|
||||
|
||||
/**
|
||||
* @var string Currency.
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected string $currency = 'USD';
|
||||
|
||||
/**
|
||||
* Display the dashboard.
|
||||
*
|
||||
@@ -39,7 +83,21 @@ class HtmlView extends BaseHtmlView
|
||||
public function display($tpl = null): void
|
||||
{
|
||||
$client = new DolibarrClient();
|
||||
$this->connectionOk = $client->testConnection();
|
||||
$this->connectionStatus = $client->testConnectionDetailed();
|
||||
$this->connectionOk = $this->connectionStatus['ok'];
|
||||
|
||||
$params = ComponentHelper::getParams('com_mokodolijoomshop');
|
||||
$this->currency = $params->get('currency', 'USD');
|
||||
|
||||
if ($this->connectionOk)
|
||||
{
|
||||
$dashModel = new DashboardModel();
|
||||
$this->productCount = $dashModel->getProductCount();
|
||||
$this->orderCount = $dashModel->getOrderCount();
|
||||
$this->customerCount = $dashModel->getCustomerCount();
|
||||
$this->recentOrders = $dashModel->getRecentOrders(5);
|
||||
$this->revenue = $dashModel->getRevenue();
|
||||
}
|
||||
|
||||
ToolbarHelper::title('DoliJoom Shop: Dashboard');
|
||||
ToolbarHelper::preferences('com_mokodolijoomshop');
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoDoliJoomShop
|
||||
* @subpackage com_mokodolijoomshop
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoDoliJoomShop\Administrator\View\Orders;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Component\ComponentHelper;
|
||||
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
||||
use Joomla\CMS\Toolbar\ToolbarHelper;
|
||||
|
||||
/**
|
||||
* Admin orders list view.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class HtmlView extends BaseHtmlView
|
||||
{
|
||||
/**
|
||||
* @var array Order items.
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected array $items = [];
|
||||
|
||||
/**
|
||||
* @var string Currency.
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected string $currency = 'USD';
|
||||
|
||||
/**
|
||||
* Display the orders list.
|
||||
*
|
||||
* @param string $tpl Template name.
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function display($tpl = null): void
|
||||
{
|
||||
$model = $this->getModel();
|
||||
$params = ComponentHelper::getParams('com_mokodolijoomshop');
|
||||
$this->items = $model->getItems();
|
||||
$this->currency = $params->get('currency', 'USD');
|
||||
|
||||
ToolbarHelper::title('DoliJoom Shop: Orders');
|
||||
|
||||
parent::display($tpl);
|
||||
}
|
||||
}
|
||||
@@ -9,49 +9,177 @@
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\Router\Route;
|
||||
|
||||
/** @var \Moko\Component\MokoDoliJoomShop\Administrator\View\Dashboard\HtmlView $this */
|
||||
|
||||
$status = $this->connectionStatus;
|
||||
$currency = htmlspecialchars($this->currency);
|
||||
?>
|
||||
<div class="com-mokodolijoomshop-dashboard">
|
||||
<div class="row">
|
||||
<!-- Connection Status -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-lg-6">
|
||||
<div class="card mb-3">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_FIELDSET_DOLIBARR'); ?></h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<?php if ($this->connectionOk) : ?>
|
||||
<div class="alert alert-success">
|
||||
<div class="alert alert-success mb-2">
|
||||
<span class="icon-check" aria-hidden="true"></span>
|
||||
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_CONNECTION_OK'); ?>
|
||||
</div>
|
||||
<?php if (!empty($status['version'])) : ?>
|
||||
<p class="mb-1">
|
||||
<strong><?php echo Text::_('COM_MOKODOLIJOOMSHOP_DOLIBARR_VERSION'); ?>:</strong>
|
||||
<?php echo htmlspecialchars($status['version']); ?>
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
<p class="mb-0">
|
||||
<strong><?php echo Text::_('COM_MOKODOLIJOOMSHOP_PERMISSIONS'); ?>:</strong>
|
||||
<span class="icon-<?php echo $status['permissions']['read'] ? 'check text-success' : 'times text-danger'; ?>" aria-hidden="true"></span>
|
||||
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_PERMISSION_READ'); ?>
|
||||
|
||||
<span class="icon-<?php echo $status['permissions']['write'] ? 'check text-success' : 'times text-danger'; ?>" aria-hidden="true"></span>
|
||||
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_PERMISSION_WRITE'); ?>
|
||||
</p>
|
||||
<?php else : ?>
|
||||
<div class="alert alert-danger">
|
||||
<span class="icon-warning" aria-hidden="true"></span>
|
||||
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_CONNECTION_FAILED'); ?>
|
||||
<?php echo htmlspecialchars($status['error'] ?: Text::_('COM_MOKODOLIJOOMSHOP_CONNECTION_FAILED')); ?>
|
||||
</div>
|
||||
<?php if (!empty($status['hint'])) : ?>
|
||||
<div class="alert alert-info mb-0">
|
||||
<span class="icon-info-circle" aria-hidden="true"></span>
|
||||
<strong><?php echo Text::_('COM_MOKODOLIJOOMSHOP_TROUBLESHOOTING'); ?>:</strong>
|
||||
<?php echo htmlspecialchars($status['hint']); ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-6">
|
||||
<div class="card mb-3">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Quick Actions</h3>
|
||||
<h3 class="card-title"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_QUICK_ACTIONS'); ?></h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<a href="index.php?option=com_mokodolijoomshop&view=products" class="btn btn-primary mb-2 d-block">
|
||||
<a href="<?php echo Route::_('index.php?option=com_mokodolijoomshop&view=products'); ?>" class="btn btn-primary mb-2 d-block">
|
||||
<span class="icon-cube" aria-hidden="true"></span>
|
||||
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_PRODUCTS'); ?>
|
||||
</a>
|
||||
<a href="index.php?option=com_mokodolijoomshop&view=orders" class="btn btn-outline-primary mb-2 d-block">
|
||||
<a href="<?php echo Route::_('index.php?option=com_mokodolijoomshop&view=orders'); ?>" class="btn btn-outline-primary mb-2 d-block">
|
||||
<span class="icon-cart" aria-hidden="true"></span>
|
||||
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_ORDERS'); ?>
|
||||
</a>
|
||||
<a href="index.php?option=com_mokodolijoomshop&view=customers" class="btn btn-outline-primary mb-2 d-block">
|
||||
<a href="<?php echo Route::_('index.php?option=com_mokodolijoomshop&view=customers'); ?>" class="btn btn-outline-primary mb-2 d-block">
|
||||
<span class="icon-users" aria-hidden="true"></span>
|
||||
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_CUSTOMERS'); ?>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if ($this->connectionOk) : ?>
|
||||
<!-- Metrics Cards -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-3">
|
||||
<div class="card text-center">
|
||||
<div class="card-body">
|
||||
<h5 class="text-muted"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_PRODUCTS'); ?></h5>
|
||||
<p class="display-6 fw-bold mb-0"><?php echo $this->productCount; ?></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card text-center">
|
||||
<div class="card-body">
|
||||
<h5 class="text-muted"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_ORDERS'); ?></h5>
|
||||
<p class="display-6 fw-bold mb-0"><?php echo $this->orderCount; ?></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card text-center">
|
||||
<div class="card-body">
|
||||
<h5 class="text-muted"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_CUSTOMERS'); ?></h5>
|
||||
<p class="display-6 fw-bold mb-0"><?php echo $this->customerCount; ?></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card text-center">
|
||||
<div class="card-body">
|
||||
<h5 class="text-muted"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_REVENUE_MONTH'); ?></h5>
|
||||
<p class="display-6 fw-bold mb-0"><?php echo number_format($this->revenue['month'] ?? 0, 2); ?> <?php echo $currency; ?></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Revenue Breakdown & Recent Orders -->
|
||||
<div class="row">
|
||||
<div class="col-lg-4">
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
<h4 class="card-title"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_REVENUE'); ?></h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table class="table table-sm">
|
||||
<tr>
|
||||
<td><?php echo Text::_('COM_MOKODOLIJOOMSHOP_REVENUE_TODAY'); ?></td>
|
||||
<td class="text-end fw-bold"><?php echo number_format($this->revenue['today'] ?? 0, 2); ?> <?php echo $currency; ?></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><?php echo Text::_('COM_MOKODOLIJOOMSHOP_REVENUE_WEEK'); ?></td>
|
||||
<td class="text-end fw-bold"><?php echo number_format($this->revenue['week'] ?? 0, 2); ?> <?php echo $currency; ?></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><?php echo Text::_('COM_MOKODOLIJOOMSHOP_REVENUE_MONTH'); ?></td>
|
||||
<td class="text-end fw-bold"><?php echo number_format($this->revenue['month'] ?? 0, 2); ?> <?php echo $currency; ?></td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-8">
|
||||
<div class="card mb-3">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h4 class="card-title mb-0"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_RECENT_ORDERS'); ?></h4>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<?php if (empty($this->recentOrders)) : ?>
|
||||
<p class="p-3 text-muted"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_NO_ORDERS'); ?></p>
|
||||
<?php else : ?>
|
||||
<table class="table table-sm table-striped mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><?php echo Text::_('COM_MOKODOLIJOOMSHOP_ORDER_REF'); ?></th>
|
||||
<th class="text-end"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_ORDER_TOTAL_TTC'); ?></th>
|
||||
<th><?php echo Text::_('COM_MOKODOLIJOOMSHOP_ORDER_STATUS'); ?></th>
|
||||
<th><?php echo Text::_('COM_MOKODOLIJOOMSHOP_ORDER_DATE'); ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($this->recentOrders as $order) : ?>
|
||||
<tr>
|
||||
<td><?php echo htmlspecialchars($order['order_ref']); ?></td>
|
||||
<td class="text-end"><?php echo number_format((float) $order['total_ttc'], 2); ?> <?php echo $currency; ?></td>
|
||||
<td><span class="badge bg-secondary"><?php echo htmlspecialchars($order['status']); ?></span></td>
|
||||
<td><?php echo htmlspecialchars($order['created']); ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoDoliJoomShop
|
||||
* @subpackage com_mokodolijoomshop
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\HTML\HTMLHelper;
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\Router\Route;
|
||||
|
||||
/** @var \Moko\Component\MokoDoliJoomShop\Administrator\View\Orders\HtmlView $this */
|
||||
|
||||
$currency = htmlspecialchars($this->currency);
|
||||
?>
|
||||
<form action="<?php echo Route::_('index.php?option=com_mokodolijoomshop&view=orders'); ?>" method="get" id="adminForm" name="adminForm">
|
||||
<input type="hidden" name="option" value="com_mokodolijoomshop" />
|
||||
<input type="hidden" name="view" value="orders" />
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-3">
|
||||
<input type="text" name="filter_search" class="form-control" placeholder="<?php echo Text::_('COM_MOKODOLIJOOMSHOP_SEARCH'); ?>"
|
||||
value="<?php echo htmlspecialchars(\Joomla\CMS\Factory::getApplication()->input->getString('filter_search', '')); ?>" />
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<select name="filter_status" class="form-select">
|
||||
<option value=""><?php echo Text::_('COM_MOKODOLIJOOMSHOP_ORDER_STATUS'); ?></option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="confirmed">Confirmed</option>
|
||||
<option value="validated">Validated</option>
|
||||
<option value="shipped">Shipped</option>
|
||||
<option value="delivered">Delivered</option>
|
||||
<option value="cancelled">Cancelled</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<input type="date" name="filter_date_from" class="form-control" placeholder="From" />
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<input type="date" name="filter_date_to" class="form-control" placeholder="To" />
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<span class="icon-search" aria-hidden="true"></span>
|
||||
<?php echo Text::_('JSEARCH_FILTER_SUBMIT'); ?>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if (empty($this->items)) : ?>
|
||||
<div class="alert alert-info"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_NO_ORDERS'); ?></div>
|
||||
<?php else : ?>
|
||||
<table class="table table-striped" id="orderList">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><?php echo Text::_('COM_MOKODOLIJOOMSHOP_ORDER_DATE'); ?></th>
|
||||
<th><?php echo Text::_('COM_MOKODOLIJOOMSHOP_CUSTOMER_NAME'); ?></th>
|
||||
<th><?php echo Text::_('COM_MOKODOLIJOOMSHOP_ORDER_REF'); ?></th>
|
||||
<th><?php echo Text::_('COM_MOKODOLIJOOMSHOP_ORDER_INVOICE_REF'); ?></th>
|
||||
<th class="text-end"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_ORDER_TOTAL_HT'); ?></th>
|
||||
<th class="text-end"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_ORDER_TOTAL_TTC'); ?></th>
|
||||
<th><?php echo Text::_('COM_MOKODOLIJOOMSHOP_ORDER_STATUS'); ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($this->items as $order) : ?>
|
||||
<?php
|
||||
$statusClass = match ($order['status'] ?? '') {
|
||||
'confirmed', 'validated' => 'bg-success',
|
||||
'shipped' => 'bg-info',
|
||||
'delivered' => 'bg-primary',
|
||||
'cancelled' => 'bg-danger',
|
||||
default => 'bg-secondary',
|
||||
};
|
||||
?>
|
||||
<tr>
|
||||
<td><?php echo htmlspecialchars($order['created']); ?></td>
|
||||
<td><?php echo htmlspecialchars($order['customer_name'] ?? ''); ?></td>
|
||||
<td><?php echo htmlspecialchars($order['order_ref']); ?></td>
|
||||
<td><?php echo htmlspecialchars($order['invoice_ref'] ?? ''); ?></td>
|
||||
<td class="text-end"><?php echo number_format((float) $order['total_ht'], 2); ?> <?php echo $currency; ?></td>
|
||||
<td class="text-end"><?php echo number_format((float) $order['total_ttc'], 2); ?> <?php echo $currency; ?></td>
|
||||
<td><span class="badge <?php echo $statusClass; ?>"><?php echo htmlspecialchars($order['status']); ?></span></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
</form>
|
||||
@@ -37,8 +37,14 @@
|
||||
</sql>
|
||||
</uninstall>
|
||||
|
||||
<media destination="com_mokodolijoomshop" folder="../media/com_mokodolijoomshop">
|
||||
<folder>css</folder>
|
||||
<folder>images</folder>
|
||||
</media>
|
||||
|
||||
<files folder="site">
|
||||
<folder>language</folder>
|
||||
<folder>services</folder>
|
||||
<folder>src</folder>
|
||||
<folder>tmpl</folder>
|
||||
</files>
|
||||
@@ -127,11 +133,79 @@
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field
|
||||
name="tax_display"
|
||||
type="list"
|
||||
label="COM_MOKODOLIJOOMSHOP_FIELD_TAX_DISPLAY"
|
||||
default="ttc"
|
||||
>
|
||||
<option value="ttc">COM_MOKODOLIJOOMSHOP_TAX_DISPLAY_TTC</option>
|
||||
<option value="ht">COM_MOKODOLIJOOMSHOP_TAX_DISPLAY_HT</option>
|
||||
<option value="both">COM_MOKODOLIJOOMSHOP_TAX_DISPLAY_BOTH</option>
|
||||
</field>
|
||||
<field
|
||||
name="low_stock_threshold"
|
||||
type="number"
|
||||
label="COM_MOKODOLIJOOMSHOP_FIELD_LOW_STOCK_THRESHOLD"
|
||||
default="5"
|
||||
min="0"
|
||||
max="999"
|
||||
/>
|
||||
<field
|
||||
name="allow_backorder"
|
||||
type="radio"
|
||||
label="COM_MOKODOLIJOOMSHOP_FIELD_ALLOW_BACKORDER"
|
||||
default="0"
|
||||
class="btn-group"
|
||||
>
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
</fieldset>
|
||||
|
||||
<fieldset name="performance" label="COM_MOKODOLIJOOMSHOP_FIELDSET_PERFORMANCE">
|
||||
<field
|
||||
name="cache_enabled"
|
||||
type="radio"
|
||||
label="COM_MOKODOLIJOOMSHOP_FIELD_CACHE_ENABLED"
|
||||
default="1"
|
||||
class="btn-group"
|
||||
>
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field
|
||||
name="cache_ttl"
|
||||
type="number"
|
||||
label="COM_MOKODOLIJOOMSHOP_FIELD_CACHE_TTL"
|
||||
default="900"
|
||||
min="60"
|
||||
max="86400"
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<fieldset name="webhooks" label="COM_MOKODOLIJOOMSHOP_FIELDSET_WEBHOOKS">
|
||||
<field
|
||||
name="webhook_secret"
|
||||
type="password"
|
||||
label="COM_MOKODOLIJOOMSHOP_FIELD_WEBHOOK_SECRET"
|
||||
description="COM_MOKODOLIJOOMSHOP_FIELD_WEBHOOK_SECRET_DESC"
|
||||
/>
|
||||
</fieldset>
|
||||
</fields>
|
||||
</config>
|
||||
|
||||
<access section="component">
|
||||
<action name="core.admin" title="JACTION_ADMIN" />
|
||||
<action name="core.manage" title="JACTION_MANAGE" />
|
||||
<action name="mokodolijoomshop.products.manage" title="COM_MOKODOLIJOOMSHOP_ACL_PRODUCTS_MANAGE" />
|
||||
<action name="mokodolijoomshop.orders.view" title="COM_MOKODOLIJOOMSHOP_ACL_ORDERS_VIEW" />
|
||||
<action name="mokodolijoomshop.customers.manage" title="COM_MOKODOLIJOOMSHOP_ACL_CUSTOMERS_MANAGE" />
|
||||
<action name="mokodolijoomshop.settings.manage" title="COM_MOKODOLIJOOMSHOP_ACL_SETTINGS_MANAGE" />
|
||||
</access>
|
||||
|
||||
<updateservers>
|
||||
<server type="extension" name="MokoDoliJoomShop Updates">https://git.mokoconsulting.tech/MokoConsulting/MokoDoliJoomShop/raw/branch/main/updates.xml</server>
|
||||
</updateservers>
|
||||
</extension>
|
||||
|
||||
|
||||
@@ -10,12 +10,106 @@ COM_MOKODOLIJOOMSHOP_CHECKOUT="Checkout"
|
||||
COM_MOKODOLIJOOMSHOP_ADD_TO_CART="Add to Cart"
|
||||
COM_MOKODOLIJOOMSHOP_VIEW_CART="View Cart"
|
||||
COM_MOKODOLIJOOMSHOP_PROCEED_CHECKOUT="Proceed to Checkout"
|
||||
COM_MOKODOLIJOOMSHOP_CONTINUE_SHOPPING="Continue Shopping"
|
||||
COM_MOKODOLIJOOMSHOP_CART_EMPTY="Your cart is empty."
|
||||
COM_MOKODOLIJOOMSHOP_CART_ITEM_ADDED="Item added to cart."
|
||||
COM_MOKODOLIJOOMSHOP_CART_ITEM_REMOVED="Item removed from cart."
|
||||
COM_MOKODOLIJOOMSHOP_CART_ADD_FAILED="Unable to add item to cart. Product may be out of stock."
|
||||
COM_MOKODOLIJOOMSHOP_ORDER_PLACED="Your order has been placed successfully."
|
||||
COM_MOKODOLIJOOMSHOP_PRICE="Price"
|
||||
COM_MOKODOLIJOOMSHOP_PRICE_HT="Price (excl. tax)"
|
||||
COM_MOKODOLIJOOMSHOP_QUANTITY="Quantity"
|
||||
COM_MOKODOLIJOOMSHOP_SUBTOTAL="Subtotal"
|
||||
COM_MOKODOLIJOOMSHOP_TAX="Tax"
|
||||
COM_MOKODOLIJOOMSHOP_TOTAL="Total"
|
||||
COM_MOKODOLIJOOMSHOP_IN_STOCK="In Stock"
|
||||
COM_MOKODOLIJOOMSHOP_OUT_OF_STOCK="Out of Stock"
|
||||
COM_MOKODOLIJOOMSHOP_AVAILABLE="available"
|
||||
COM_MOKODOLIJOOMSHOP_NO_PRODUCTS="No products found."
|
||||
COM_MOKODOLIJOOMSHOP_NO_IMAGE="No image available"
|
||||
COM_MOKODOLIJOOMSHOP_DESCRIPTION="Description"
|
||||
COM_MOKODOLIJOOMSHOP_RELATED_PRODUCTS="Related Products"
|
||||
COM_MOKODOLIJOOMSHOP_PRODUCT_REF="Reference"
|
||||
COM_MOKODOLIJOOMSHOP_PRODUCT_LABEL="Product"
|
||||
|
||||
COM_MOKODOLIJOOMSHOP_BILLING_DETAILS="Billing Details"
|
||||
COM_MOKODOLIJOOMSHOP_BILLING_NAME="Full Name"
|
||||
COM_MOKODOLIJOOMSHOP_BILLING_EMAIL="Email Address"
|
||||
COM_MOKODOLIJOOMSHOP_BILLING_ADDRESS="Address"
|
||||
COM_MOKODOLIJOOMSHOP_BILLING_TOWN="City"
|
||||
COM_MOKODOLIJOOMSHOP_BILLING_ZIP="Postal Code"
|
||||
COM_MOKODOLIJOOMSHOP_BILLING_PHONE="Phone"
|
||||
COM_MOKODOLIJOOMSHOP_ORDER_NOTES="Order Notes"
|
||||
COM_MOKODOLIJOOMSHOP_ORDER_NOTES_PLACEHOLDER="Any special instructions for your order..."
|
||||
COM_MOKODOLIJOOMSHOP_ORDER_SUMMARY="Order Summary"
|
||||
COM_MOKODOLIJOOMSHOP_PLACE_ORDER="Place Order"
|
||||
COM_MOKODOLIJOOMSHOP_ORDER_REF="Order Reference"
|
||||
COM_MOKODOLIJOOMSHOP_ORDER_INVOICE_REF="Invoice Reference"
|
||||
COM_MOKODOLIJOOMSHOP_NO_ORDER_DATA="No order information available."
|
||||
|
||||
COM_MOKODOLIJOOMSHOP_CHECKOUT_LOGIN_REQUIRED="You must be logged in to checkout."
|
||||
COM_MOKODOLIJOOMSHOP_CHECKOUT_STOCK_ERROR="Some items in your cart are no longer available in the requested quantity."
|
||||
COM_MOKODOLIJOOMSHOP_CHECKOUT_FAILED="Unable to process your order. Please try again."
|
||||
|
||||
COM_MOKODOLIJOOMSHOP_CATEGORIES="Categories"
|
||||
COM_MOKODOLIJOOMSHOP_CATEGORY="Products by Category"
|
||||
COM_MOKODOLIJOOMSHOP_CATEGORY_DESC="Display products from a specific Dolibarr category."
|
||||
COM_MOKODOLIJOOMSHOP_CATEGORY_OPTIONS="Category Options"
|
||||
COM_MOKODOLIJOOMSHOP_CATEGORY_ID="Category ID"
|
||||
COM_MOKODOLIJOOMSHOP_CATEGORY_ID_DESC="The Dolibarr product category ID to display."
|
||||
COM_MOKODOLIJOOMSHOP_PRODUCTS_DESC="Display the full product catalog."
|
||||
COM_MOKODOLIJOOMSHOP_CART_DESC="Display the shopping cart."
|
||||
COM_MOKODOLIJOOMSHOP_CHECKOUT_DESC="Display the checkout form."
|
||||
|
||||
COM_MOKODOLIJOOMSHOP_LOW_STOCK="Low Stock"
|
||||
COM_MOKODOLIJOOMSHOP_BACKORDER="Available on Backorder"
|
||||
|
||||
COM_MOKODOLIJOOMSHOP_MY_ORDERS="My Orders"
|
||||
COM_MOKODOLIJOOMSHOP_MY_ORDERS_DESC="Display order history for the logged-in user."
|
||||
COM_MOKODOLIJOOMSHOP_ORDERS_LOGIN_REQUIRED="Please log in to view your order history."
|
||||
COM_MOKODOLIJOOMSHOP_VIEW_DETAIL="View"
|
||||
COM_MOKODOLIJOOMSHOP_ORDER_DATE="Date"
|
||||
COM_MOKODOLIJOOMSHOP_ORDER_STATUS="Status"
|
||||
COM_MOKODOLIJOOMSHOP_NO_ORDERS="You have no orders yet."
|
||||
|
||||
COM_MOKODOLIJOOMSHOP_SEARCH="Search"
|
||||
COM_MOKODOLIJOOMSHOP_SEARCH_PLACEHOLDER="Search products..."
|
||||
COM_MOKODOLIJOOMSHOP_SORT_BY="Sort by"
|
||||
COM_MOKODOLIJOOMSHOP_SORT_REF_ASC="Reference (A-Z)"
|
||||
COM_MOKODOLIJOOMSHOP_SORT_REF_DESC="Reference (Z-A)"
|
||||
COM_MOKODOLIJOOMSHOP_SORT_PRICE_ASC="Price (Low to High)"
|
||||
COM_MOKODOLIJOOMSHOP_SORT_PRICE_DESC="Price (High to Low)"
|
||||
COM_MOKODOLIJOOMSHOP_SORT_NEWEST="Newest"
|
||||
COM_MOKODOLIJOOMSHOP_FILTER_PRICE="Price Range"
|
||||
COM_MOKODOLIJOOMSHOP_CONTINUE_SHOPPING="Continue Shopping"
|
||||
COM_MOKODOLIJOOMSHOP_USE_GLOBAL="Use Global"
|
||||
|
||||
COM_MOKODOLIJOOMSHOP_PRODUCT_DETAIL_DESC="Display a single product detail page."
|
||||
COM_MOKODOLIJOOMSHOP_PRODUCT_OPTIONS="Product Options"
|
||||
COM_MOKODOLIJOOMSHOP_PRODUCT_ID="Product ID"
|
||||
COM_MOKODOLIJOOMSHOP_PRODUCT_ID_DESC="The Dolibarr product ID to display."
|
||||
|
||||
COM_MOKODOLIJOOMSHOP_SELECT_VARIANT="Select %s"
|
||||
COM_MOKODOLIJOOMSHOP_VARIANT_UNAVAILABLE="Variant unavailable"
|
||||
|
||||
COM_MOKODOLIJOOMSHOP_WISHLIST="Wishlist"
|
||||
COM_MOKODOLIJOOMSHOP_ADD_TO_WISHLIST="Add to Wishlist"
|
||||
COM_MOKODOLIJOOMSHOP_REMOVE_FROM_WISHLIST="Remove from Wishlist"
|
||||
COM_MOKODOLIJOOMSHOP_WISHLIST_EMPTY="Your wishlist is empty."
|
||||
COM_MOKODOLIJOOMSHOP_WISHLIST_ADDED="Item added to wishlist."
|
||||
COM_MOKODOLIJOOMSHOP_MOVE_TO_CART="Move to Cart"
|
||||
|
||||
COM_MOKODOLIJOOMSHOP_COUPON_CODE="Coupon Code"
|
||||
COM_MOKODOLIJOOMSHOP_APPLY_COUPON="Apply"
|
||||
COM_MOKODOLIJOOMSHOP_COUPON_APPLIED="Discount applied: %s"
|
||||
COM_MOKODOLIJOOMSHOP_COUPON_INVALID="Invalid coupon code."
|
||||
COM_MOKODOLIJOOMSHOP_DISCOUNT="Discount"
|
||||
|
||||
COM_MOKODOLIJOOMSHOP_MY_ADDRESSES="My Addresses"
|
||||
COM_MOKODOLIJOOMSHOP_ADD_ADDRESS="Add Address"
|
||||
COM_MOKODOLIJOOMSHOP_EDIT_ADDRESS="Edit Address"
|
||||
COM_MOKODOLIJOOMSHOP_DEFAULT_ADDRESS="Default"
|
||||
COM_MOKODOLIJOOMSHOP_ADDRESS_LABEL="Label (e.g., Home, Office)"
|
||||
|
||||
COM_MOKODOLIJOOMSHOP_INVOICE_NOT_FOUND="Invoice PDF not available."
|
||||
COM_MOKODOLIJOOMSHOP_DOWNLOAD_INVOICE="Download Invoice"
|
||||
|
||||
@@ -8,4 +8,5 @@
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
// Site service provider — component registration handled by admin provider
|
||||
// Site service provider — component registration is handled by the admin provider.
|
||||
// This file must exist but no additional services are needed for the site side.
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoDoliJoomShop
|
||||
* @subpackage com_mokodolijoomshop
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoDoliJoomShop\Site\Controller;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\MVC\Controller\BaseController;
|
||||
use Joomla\CMS\Router\Route;
|
||||
use Joomla\CMS\Session\Session;
|
||||
use Joomla\CMS\Language\Text;
|
||||
|
||||
/**
|
||||
* Cart controller — handles add, update, and remove actions.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class CartController extends BaseController
|
||||
{
|
||||
/**
|
||||
* Add a product to the cart.
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function add(): void
|
||||
{
|
||||
Session::checkToken('request') or die(Text::_('JINVALID_TOKEN'));
|
||||
|
||||
$productId = $this->input->getInt('product_id', 0);
|
||||
$quantity = $this->input->getInt('quantity', 1);
|
||||
|
||||
/** @var \Moko\Component\MokoDoliJoomShop\Site\Model\CartModel $model */
|
||||
$model = $this->getModel('Cart');
|
||||
|
||||
if ($model->addItem($productId, $quantity))
|
||||
{
|
||||
$this->app->enqueueMessage(Text::_('COM_MOKODOLIJOOMSHOP_CART_ITEM_ADDED'), 'success');
|
||||
}
|
||||
else
|
||||
{
|
||||
$this->app->enqueueMessage(Text::_('COM_MOKODOLIJOOMSHOP_CART_ADD_FAILED'), 'error');
|
||||
}
|
||||
|
||||
$return = $this->input->getBase64('return', '');
|
||||
|
||||
if ($return)
|
||||
{
|
||||
$this->setRedirect(base64_decode($return));
|
||||
}
|
||||
else
|
||||
{
|
||||
$this->setRedirect(Route::_('index.php?option=com_mokodolijoomshop&view=cart', false));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update cart item quantity.
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function update(): void
|
||||
{
|
||||
Session::checkToken('request') or die(Text::_('JINVALID_TOKEN'));
|
||||
|
||||
$cartItemId = $this->input->getInt('cart_item_id', 0);
|
||||
$quantity = $this->input->getInt('quantity', 1);
|
||||
|
||||
/** @var \Moko\Component\MokoDoliJoomShop\Site\Model\CartModel $model */
|
||||
$model = $this->getModel('Cart');
|
||||
$model->updateItemQuantity($cartItemId, $quantity);
|
||||
|
||||
$this->setRedirect(Route::_('index.php?option=com_mokodolijoomshop&view=cart', false));
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a cart item.
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function remove(): void
|
||||
{
|
||||
Session::checkToken('request') or die(Text::_('JINVALID_TOKEN'));
|
||||
|
||||
$cartItemId = $this->input->getInt('cart_item_id', 0);
|
||||
|
||||
/** @var \Moko\Component\MokoDoliJoomShop\Site\Model\CartModel $model */
|
||||
$model = $this->getModel('Cart');
|
||||
$model->removeItem($cartItemId);
|
||||
|
||||
$this->app->enqueueMessage(Text::_('COM_MOKODOLIJOOMSHOP_CART_ITEM_REMOVED'), 'success');
|
||||
$this->setRedirect(Route::_('index.php?option=com_mokodolijoomshop&view=cart', false));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoDoliJoomShop
|
||||
* @subpackage com_mokodolijoomshop
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoDoliJoomShop\Site\Controller;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\MVC\Controller\BaseController;
|
||||
use Joomla\CMS\Router\Route;
|
||||
use Joomla\CMS\Session\Session;
|
||||
|
||||
/**
|
||||
* Checkout controller — processes the checkout form submission.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class CheckoutController extends BaseController
|
||||
{
|
||||
/**
|
||||
* Process the checkout form.
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function process(): void
|
||||
{
|
||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||
|
||||
/** @var \Moko\Component\MokoDoliJoomShop\Site\Model\CheckoutModel $checkoutModel */
|
||||
$checkoutModel = $this->getModel('Checkout');
|
||||
|
||||
if (!$checkoutModel->canCheckout())
|
||||
{
|
||||
$this->app->enqueueMessage(Text::_('COM_MOKODOLIJOOMSHOP_CHECKOUT_LOGIN_REQUIRED'), 'warning');
|
||||
$this->setRedirect(Route::_('index.php?option=com_users&view=login', false));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
/** @var \Moko\Component\MokoDoliJoomShop\Site\Model\CartModel $cartModel */
|
||||
$cartModel = $this->getModel('Cart');
|
||||
$cartItems = $cartModel->getItems();
|
||||
|
||||
if (empty($cartItems))
|
||||
{
|
||||
$this->app->enqueueMessage(Text::_('COM_MOKODOLIJOOMSHOP_CART_EMPTY'), 'warning');
|
||||
$this->setRedirect(Route::_('index.php?option=com_mokodolijoomshop&view=cart', false));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate stock before proceeding
|
||||
$stockProblems = $cartModel->validateStock();
|
||||
|
||||
if (!empty($stockProblems))
|
||||
{
|
||||
$this->app->enqueueMessage(Text::_('COM_MOKODOLIJOOMSHOP_CHECKOUT_STOCK_ERROR'), 'error');
|
||||
$this->setRedirect(Route::_('index.php?option=com_mokodolijoomshop&view=cart', false));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Collect billing data from form
|
||||
$billingData = [
|
||||
'name' => $this->input->getString('billing_name', ''),
|
||||
'email' => $this->input->getString('billing_email', ''),
|
||||
'address' => $this->input->getString('billing_address', ''),
|
||||
'town' => $this->input->getString('billing_town', ''),
|
||||
'zip' => $this->input->getString('billing_zip', ''),
|
||||
'phone' => $this->input->getString('billing_phone', ''),
|
||||
'notes' => $this->input->getString('order_notes', ''),
|
||||
];
|
||||
|
||||
$totals = $cartModel->getTotals();
|
||||
$result = $checkoutModel->processCheckout($billingData, $cartItems, $totals);
|
||||
|
||||
if ($result === null)
|
||||
{
|
||||
$this->app->enqueueMessage(Text::_('COM_MOKODOLIJOOMSHOP_CHECKOUT_FAILED'), 'error');
|
||||
$this->setRedirect(Route::_('index.php?option=com_mokodolijoomshop&view=checkout', false));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear the cart on success
|
||||
$cartModel->clearCart();
|
||||
|
||||
// Store result in session for confirmation page
|
||||
$session = $this->app->getSession();
|
||||
$session->set('mokodolijoomshop.order_result', $result);
|
||||
|
||||
$this->app->enqueueMessage(Text::_('COM_MOKODOLIJOOMSHOP_ORDER_PLACED'), 'success');
|
||||
$this->setRedirect(Route::_('index.php?option=com_mokodolijoomshop&view=checkout&layout=confirmation', false));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoDoliJoomShop
|
||||
* @subpackage com_mokodolijoomshop
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoDoliJoomShop\Site\Controller;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\MVC\Controller\BaseController;
|
||||
use Moko\Component\MokoDoliJoomShop\Administrator\Helper\DolibarrClient;
|
||||
|
||||
/**
|
||||
* Invoice controller — handles PDF download for frontend users.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class InvoiceController extends BaseController
|
||||
{
|
||||
/**
|
||||
* Download an invoice PDF.
|
||||
*
|
||||
* Streams the PDF directly from Dolibarr to the browser.
|
||||
* Access is restricted to the order owner.
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function download(): void
|
||||
{
|
||||
$userId = (int) Factory::getApplication()->getIdentity()->id;
|
||||
$invoiceId = $this->input->getInt('invoice_id', 0);
|
||||
|
||||
if ($userId === 0 || $invoiceId === 0)
|
||||
{
|
||||
$this->app->enqueueMessage(Text::_('JERROR_ALERTNOAUTHOR'), 'error');
|
||||
$this->setRedirect('index.php?option=com_mokodolijoomshop&view=orders');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify ownership
|
||||
$db = Factory::getContainer()->get('DatabaseDriver');
|
||||
$query = $db->getQuery(true);
|
||||
$query->select($db->quoteName('invoice_ref'))
|
||||
->from($db->quoteName('#__mokodolijoomshop_orders'))
|
||||
->where($db->quoteName('user_id') . ' = ' . $userId)
|
||||
->where($db->quoteName('dolibarr_invoice_id') . ' = ' . $invoiceId);
|
||||
$db->setQuery($query);
|
||||
$invoiceRef = $db->loadResult();
|
||||
|
||||
if ($invoiceRef === null)
|
||||
{
|
||||
$this->app->enqueueMessage(Text::_('JERROR_ALERTNOAUTHOR'), 'error');
|
||||
$this->setRedirect('index.php?option=com_mokodolijoomshop&view=orders');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch PDF from Dolibarr
|
||||
$client = new DolibarrClient();
|
||||
$docs = $client->get('/documents', [
|
||||
'modulepart' => 'invoice',
|
||||
'id' => $invoiceId,
|
||||
]);
|
||||
|
||||
$pdfDoc = null;
|
||||
|
||||
if (!empty($docs))
|
||||
{
|
||||
foreach ($docs as $doc)
|
||||
{
|
||||
if (isset($doc['relativename']) && str_ends_with($doc['relativename'], '.pdf'))
|
||||
{
|
||||
$pdfDoc = $doc;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($pdfDoc === null)
|
||||
{
|
||||
$this->app->enqueueMessage(Text::_('COM_MOKODOLIJOOMSHOP_INVOICE_NOT_FOUND'), 'warning');
|
||||
$this->setRedirect('index.php?option=com_mokodolijoomshop&view=orders');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Download content
|
||||
$download = $client->get('/documents/download', [
|
||||
'modulepart' => 'invoice',
|
||||
'original_file' => $pdfDoc['relativename'],
|
||||
]);
|
||||
|
||||
if (empty($download['content']))
|
||||
{
|
||||
$this->app->enqueueMessage(Text::_('COM_MOKODOLIJOOMSHOP_INVOICE_NOT_FOUND'), 'warning');
|
||||
$this->setRedirect('index.php?option=com_mokodolijoomshop&view=orders');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$pdfContent = base64_decode($download['content']);
|
||||
$filename = $invoiceRef . '.pdf';
|
||||
|
||||
// Stream PDF to browser
|
||||
$this->app->setHeader('Content-Type', 'application/pdf');
|
||||
$this->app->setHeader('Content-Disposition', 'attachment; filename="' . $filename . '"');
|
||||
$this->app->setHeader('Content-Length', (string) \strlen($pdfContent));
|
||||
$this->app->sendHeaders();
|
||||
|
||||
echo $pdfContent;
|
||||
$this->app->close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoDoliJoomShop
|
||||
* @subpackage com_mokodolijoomshop
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoDoliJoomShop\Site\Controller;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Component\ComponentHelper;
|
||||
use Joomla\CMS\MVC\Controller\BaseController;
|
||||
use Moko\Component\MokoDoliJoomShop\Administrator\Helper\DolibarrClient;
|
||||
|
||||
/**
|
||||
* Search controller — provides AJAX product search and filtering.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class SearchController extends BaseController
|
||||
{
|
||||
/**
|
||||
* AJAX search endpoint.
|
||||
*
|
||||
* Accepts: q (text), category_id, price_min, price_max, sort, page.
|
||||
* Returns JSON array of products.
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function search(): void
|
||||
{
|
||||
$client = new DolibarrClient();
|
||||
$params = ComponentHelper::getParams('com_mokodolijoomshop');
|
||||
$perPage = (int) $params->get('products_per_page', 12);
|
||||
|
||||
$q = $this->input->getString('q', '');
|
||||
$categoryId = $this->input->getInt('category_id', 0);
|
||||
$priceMin = $this->input->getFloat('price_min', 0);
|
||||
$priceMax = $this->input->getFloat('price_max', 0);
|
||||
$sort = $this->input->getString('sort', 'ref_asc');
|
||||
$page = $this->input->getInt('page', 0);
|
||||
|
||||
// Build sort parameters
|
||||
$sortMap = [
|
||||
'ref_asc' => ['t.ref', 'ASC'],
|
||||
'ref_desc' => ['t.ref', 'DESC'],
|
||||
'label_asc' => ['t.label', 'ASC'],
|
||||
'label_desc' => ['t.label', 'DESC'],
|
||||
'price_asc' => ['t.price', 'ASC'],
|
||||
'price_desc' => ['t.price', 'DESC'],
|
||||
'newest' => ['t.datec', 'DESC'],
|
||||
];
|
||||
|
||||
$sortField = $sortMap[$sort][0] ?? 't.ref';
|
||||
$sortOrder = $sortMap[$sort][1] ?? 'ASC';
|
||||
|
||||
$query = [
|
||||
'sortfield' => $sortField,
|
||||
'sortorder' => $sortOrder,
|
||||
'limit' => $perPage,
|
||||
'page' => $page,
|
||||
];
|
||||
|
||||
if ($categoryId > 0)
|
||||
{
|
||||
$query['category'] = $categoryId;
|
||||
}
|
||||
|
||||
// Build sqlfilters for text search and price range
|
||||
$filters = [];
|
||||
|
||||
if (!empty($q))
|
||||
{
|
||||
$escaped = addslashes($q);
|
||||
$filters[] = "(t.label:like:'%{$escaped}%') or (t.ref:like:'%{$escaped}%') or (t.description:like:'%{$escaped}%')";
|
||||
}
|
||||
|
||||
if ($priceMin > 0)
|
||||
{
|
||||
$filters[] = "(t.price:>=:{$priceMin})";
|
||||
}
|
||||
|
||||
if ($priceMax > 0)
|
||||
{
|
||||
$filters[] = "(t.price:<=:{$priceMax})";
|
||||
}
|
||||
|
||||
if (!empty($filters))
|
||||
{
|
||||
$query['sqlfilters'] = implode(' and ', $filters);
|
||||
}
|
||||
|
||||
$products = $client->get('/products', $query);
|
||||
|
||||
// Return JSON response
|
||||
$this->app->setHeader('Content-Type', 'application/json');
|
||||
$this->app->sendHeaders();
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'products' => $products ?? [],
|
||||
'page' => $page,
|
||||
'per_page' => $perPage,
|
||||
]);
|
||||
|
||||
$this->app->close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoDoliJoomShop
|
||||
* @subpackage com_mokodolijoomshop
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoDoliJoomShop\Site\Helper;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Moko\Component\MokoDoliJoomShop\Administrator\Helper\DolibarrClient;
|
||||
|
||||
/**
|
||||
* Coupon/discount code helper — validates codes against Dolibarr discount rules.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class CouponHelper
|
||||
{
|
||||
/**
|
||||
* @var DolibarrClient
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private DolibarrClient $client;
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
$this->client = new DolibarrClient();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a coupon code and return the discount details.
|
||||
*
|
||||
* @param string $code Coupon code entered by user.
|
||||
* @param int $thirdpartyId Customer thirdparty ID (for customer-specific discounts).
|
||||
*
|
||||
* @return array|null Discount data or null if invalid.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function validate(string $code, int $thirdpartyId = 0): ?array
|
||||
{
|
||||
if (empty(trim($code)))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Search for discount rules matching this code in Dolibarr
|
||||
// Dolibarr stores available discounts per thirdparty
|
||||
if ($thirdpartyId > 0)
|
||||
{
|
||||
$discounts = $this->client->get('/thirdparties/' . $thirdpartyId . '/availablediscounts');
|
||||
|
||||
if (!empty($discounts))
|
||||
{
|
||||
foreach ($discounts as $discount)
|
||||
{
|
||||
if (($discount['description'] ?? '') === $code || ($discount['ref'] ?? '') === $code)
|
||||
{
|
||||
return [
|
||||
'id' => (int) ($discount['id'] ?? 0),
|
||||
'type' => !empty($discount['percent']) ? 'percent' : 'fixed',
|
||||
'value' => (float) ($discount['percent'] ?? $discount['amount_ttc'] ?? 0),
|
||||
'amount_ht' => (float) ($discount['amount_ht'] ?? 0),
|
||||
'amount_ttc' => (float) ($discount['amount_ttc'] ?? 0),
|
||||
'description' => $discount['description'] ?? $code,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a discount to a cart total.
|
||||
*
|
||||
* @param array $discount Discount data from validate().
|
||||
* @param float $subtotal Cart subtotal before discount.
|
||||
*
|
||||
* @return float Discount amount to subtract.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function calculateDiscount(array $discount, float $subtotal): float
|
||||
{
|
||||
if ($discount['type'] === 'percent')
|
||||
{
|
||||
return round($subtotal * ($discount['value'] / 100), 4);
|
||||
}
|
||||
|
||||
// Fixed amount — don't exceed subtotal
|
||||
return min($discount['amount_ttc'], $subtotal);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoDoliJoomShop
|
||||
* @subpackage com_mokodolijoomshop
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoDoliJoomShop\Site\Helper;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Component\ComponentHelper;
|
||||
use Joomla\CMS\Language\Text;
|
||||
|
||||
/**
|
||||
* Stock display helper — determines stock status and badge rendering.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class StockHelper
|
||||
{
|
||||
public const STATUS_IN_STOCK = 'in_stock';
|
||||
public const STATUS_LOW_STOCK = 'low_stock';
|
||||
public const STATUS_OUT = 'out_of_stock';
|
||||
|
||||
/**
|
||||
* Determine stock status for a given quantity.
|
||||
*
|
||||
* @param float $stockQty Stock quantity.
|
||||
*
|
||||
* @return string One of the STATUS_ constants.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function getStatus(float $stockQty): string
|
||||
{
|
||||
if ($stockQty <= 0)
|
||||
{
|
||||
return self::STATUS_OUT;
|
||||
}
|
||||
|
||||
$params = ComponentHelper::getParams('com_mokodolijoomshop');
|
||||
$threshold = (int) $params->get('low_stock_threshold', 5);
|
||||
|
||||
if ($stockQty <= $threshold)
|
||||
{
|
||||
return self::STATUS_LOW_STOCK;
|
||||
}
|
||||
|
||||
return self::STATUS_IN_STOCK;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a Bootstrap badge for stock status.
|
||||
*
|
||||
* @param float $stockQty Stock quantity.
|
||||
* @param bool $showQty Whether to show the numeric quantity.
|
||||
*
|
||||
* @return string HTML badge markup.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function renderBadge(float $stockQty, bool $showQty = false): string
|
||||
{
|
||||
$status = self::getStatus($stockQty);
|
||||
|
||||
switch ($status)
|
||||
{
|
||||
case self::STATUS_IN_STOCK:
|
||||
$class = 'bg-success';
|
||||
$text = Text::_('COM_MOKODOLIJOOMSHOP_IN_STOCK');
|
||||
break;
|
||||
|
||||
case self::STATUS_LOW_STOCK:
|
||||
$class = 'bg-warning text-dark';
|
||||
$text = Text::_('COM_MOKODOLIJOOMSHOP_LOW_STOCK');
|
||||
break;
|
||||
|
||||
case self::STATUS_OUT:
|
||||
default:
|
||||
$class = 'bg-danger';
|
||||
$text = Text::_('COM_MOKODOLIJOOMSHOP_OUT_OF_STOCK');
|
||||
break;
|
||||
}
|
||||
|
||||
if ($showQty && $stockQty > 0)
|
||||
{
|
||||
$text .= ' (' . (int) $stockQty . ')';
|
||||
}
|
||||
|
||||
return '<span class="badge ' . $class . '">' . $text . '</span>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if add-to-cart should be enabled.
|
||||
*
|
||||
* @param float $stockQty Stock quantity.
|
||||
*
|
||||
* @return bool
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function canAddToCart(float $stockQty): bool
|
||||
{
|
||||
if ($stockQty > 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if backorders are allowed
|
||||
$params = ComponentHelper::getParams('com_mokodolijoomshop');
|
||||
|
||||
return (bool) $params->get('allow_backorder', false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoDoliJoomShop
|
||||
* @subpackage com_mokodolijoomshop
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoDoliJoomShop\Site\Helper;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Component\ComponentHelper;
|
||||
|
||||
/**
|
||||
* Tax calculation and display helper.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class TaxHelper
|
||||
{
|
||||
/**
|
||||
* Get the configured tax display mode.
|
||||
*
|
||||
* @return string 'ttc' (incl. tax), 'ht' (excl. tax), or 'both'.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function getDisplayMode(): string
|
||||
{
|
||||
$params = ComponentHelper::getParams('com_mokodolijoomshop');
|
||||
|
||||
return $params->get('tax_display', 'ttc');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if tax is enabled.
|
||||
*
|
||||
* @return bool
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function isEnabled(): bool
|
||||
{
|
||||
$params = ComponentHelper::getParams('com_mokodolijoomshop');
|
||||
|
||||
return (bool) $params->get('tax_enabled', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate tax breakdown grouped by rate from cart items.
|
||||
*
|
||||
* @param array $cartItems Cart items with 'unit_price', 'quantity', 'tax_rate'.
|
||||
*
|
||||
* @return array Array of [rate => amount], e.g., [20.0 => 40.00, 5.0 => 2.50].
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function getGroupedTax(array $cartItems): array
|
||||
{
|
||||
$grouped = [];
|
||||
|
||||
foreach ($cartItems as $item)
|
||||
{
|
||||
$rate = (float) ($item['tax_rate'] ?? 0);
|
||||
$lineTotal = (float) $item['unit_price'] * (int) $item['quantity'];
|
||||
$taxAmount = $lineTotal * ($rate / 100);
|
||||
|
||||
if ($rate > 0)
|
||||
{
|
||||
if (!isset($grouped[$rate]))
|
||||
{
|
||||
$grouped[$rate] = 0.0;
|
||||
}
|
||||
|
||||
$grouped[$rate] += $taxAmount;
|
||||
}
|
||||
}
|
||||
|
||||
ksort($grouped);
|
||||
|
||||
return $grouped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a price for display based on the tax display mode.
|
||||
*
|
||||
* @param float $priceHT Price excluding tax.
|
||||
* @param float $priceTTC Price including tax.
|
||||
* @param string $currency Currency code.
|
||||
*
|
||||
* @return string Formatted price string.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function formatPrice(float $priceHT, float $priceTTC, string $currency): string
|
||||
{
|
||||
$mode = self::getDisplayMode();
|
||||
|
||||
switch ($mode)
|
||||
{
|
||||
case 'ht':
|
||||
return number_format($priceHT, 2) . ' ' . $currency . ' HT';
|
||||
|
||||
case 'both':
|
||||
return number_format($priceTTC, 2) . ' ' . $currency
|
||||
. ' <small class="text-muted">(' . number_format($priceHT, 2) . ' HT)</small>';
|
||||
|
||||
case 'ttc':
|
||||
default:
|
||||
return number_format($priceTTC, 2) . ' ' . $currency;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate totals from cart items including tax breakdown.
|
||||
*
|
||||
* @param array $cartItems Cart items.
|
||||
*
|
||||
* @return array{subtotal_ht: float, tax_total: float, total_ttc: float, tax_grouped: array}
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function calculateTotals(array $cartItems): array
|
||||
{
|
||||
$subtotalHT = 0.0;
|
||||
$taxTotal = 0.0;
|
||||
$grouped = [];
|
||||
|
||||
foreach ($cartItems as $item)
|
||||
{
|
||||
$rate = (float) ($item['tax_rate'] ?? 0);
|
||||
$lineTotal = (float) $item['unit_price'] * (int) $item['quantity'];
|
||||
$lineTax = $lineTotal * ($rate / 100);
|
||||
|
||||
$subtotalHT += $lineTotal;
|
||||
$taxTotal += $lineTax;
|
||||
|
||||
if ($rate > 0)
|
||||
{
|
||||
if (!isset($grouped[$rate]))
|
||||
{
|
||||
$grouped[$rate] = 0.0;
|
||||
}
|
||||
|
||||
$grouped[$rate] += $lineTax;
|
||||
}
|
||||
}
|
||||
|
||||
ksort($grouped);
|
||||
|
||||
return [
|
||||
'subtotal_ht' => $subtotalHT,
|
||||
'tax_total' => $taxTotal,
|
||||
'total_ttc' => $subtotalHT + $taxTotal,
|
||||
'tax_grouped' => $grouped,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoDoliJoomShop
|
||||
* @subpackage com_mokodolijoomshop
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoDoliJoomShop\Site\Helper;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Moko\Component\MokoDoliJoomShop\Administrator\Helper\DolibarrClient;
|
||||
|
||||
/**
|
||||
* Product variant helper — handles Dolibarr product variants/combinations.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class VariantHelper
|
||||
{
|
||||
/**
|
||||
* @var DolibarrClient
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private DolibarrClient $client;
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
$this->client = new DolibarrClient();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get variants for a product.
|
||||
*
|
||||
* @param int $productId Parent product ID.
|
||||
*
|
||||
* @return array Array of variant data.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function getVariants(int $productId): array
|
||||
{
|
||||
$variants = $this->client->get('/products/' . $productId . '/variants');
|
||||
|
||||
if ($variants === null || !\is_array($variants))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return $variants;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a product has variants.
|
||||
*
|
||||
* @param int $productId Product ID.
|
||||
*
|
||||
* @return bool
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function hasVariants(int $productId): bool
|
||||
{
|
||||
$variants = $this->getVariants($productId);
|
||||
|
||||
return !empty($variants);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse variants into grouped attribute selectors.
|
||||
*
|
||||
* Returns a structure like:
|
||||
* [
|
||||
* 'Color' => ['Red' => [...], 'Blue' => [...]],
|
||||
* 'Size' => ['S' => [...], 'M' => [...], 'L' => [...]],
|
||||
* ]
|
||||
*
|
||||
* @param array $variants Raw variants from Dolibarr.
|
||||
*
|
||||
* @return array Grouped attributes.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function groupByAttribute(array $variants): array
|
||||
{
|
||||
$grouped = [];
|
||||
|
||||
foreach ($variants as $variant)
|
||||
{
|
||||
$attributes = $variant['attributes'] ?? [];
|
||||
|
||||
foreach ($attributes as $attr)
|
||||
{
|
||||
$attrName = $attr['attribute'] ?? $attr['ref'] ?? 'Option';
|
||||
$attrValue = $attr['value'] ?? $attr['ref_ext'] ?? '';
|
||||
|
||||
if (!isset($grouped[$attrName]))
|
||||
{
|
||||
$grouped[$attrName] = [];
|
||||
}
|
||||
|
||||
if (!isset($grouped[$attrName][$attrValue]))
|
||||
{
|
||||
$grouped[$attrName][$attrValue] = [];
|
||||
}
|
||||
|
||||
$grouped[$attrName][$attrValue][] = [
|
||||
'variant_id' => (int) ($variant['fk_product_child'] ?? $variant['id'] ?? 0),
|
||||
'ref' => $variant['ref'] ?? '',
|
||||
'price_diff' => (float) ($variant['variation_price'] ?? 0),
|
||||
'price_type' => $variant['variation_price_percentage'] ?? false,
|
||||
'stock' => (float) ($variant['stock_reel'] ?? 0),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $grouped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build JSON data for variant selectors (consumed by frontend JS).
|
||||
*
|
||||
* @param int $productId Parent product ID.
|
||||
* @param float $basePrice Base product price.
|
||||
*
|
||||
* @return array Variant config for JSON encoding.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function getVariantConfig(int $productId, float $basePrice): array
|
||||
{
|
||||
$variants = $this->getVariants($productId);
|
||||
|
||||
if (empty($variants))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
$config = [
|
||||
'base_price' => $basePrice,
|
||||
'variants' => [],
|
||||
];
|
||||
|
||||
foreach ($variants as $variant)
|
||||
{
|
||||
$childId = (int) ($variant['fk_product_child'] ?? $variant['id'] ?? 0);
|
||||
$priceDiff = (float) ($variant['variation_price'] ?? 0);
|
||||
$isPercent = !empty($variant['variation_price_percentage']);
|
||||
$finalPrice = $isPercent
|
||||
? $basePrice * (1 + $priceDiff / 100)
|
||||
: $basePrice + $priceDiff;
|
||||
|
||||
$config['variants'][] = [
|
||||
'id' => $childId,
|
||||
'ref' => $variant['ref'] ?? '',
|
||||
'attributes' => $variant['attributes'] ?? [],
|
||||
'price' => round($finalPrice, 4),
|
||||
'price_diff' => $priceDiff,
|
||||
'stock' => (float) ($variant['stock_reel'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
return $config;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoDoliJoomShop
|
||||
* @subpackage com_mokodolijoomshop
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoDoliJoomShop\Site\Model;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
|
||||
|
||||
/**
|
||||
* Shipping address model — manages user address book.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class AddressModel extends BaseDatabaseModel
|
||||
{
|
||||
/**
|
||||
* Get all addresses for the current user.
|
||||
*
|
||||
* @return array
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function getAddresses(): array
|
||||
{
|
||||
$userId = (int) Factory::getApplication()->getIdentity()->id;
|
||||
|
||||
if ($userId === 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
$db = $this->getDatabase();
|
||||
$query = $db->getQuery(true);
|
||||
$query->select('*')
|
||||
->from($db->quoteName('#__mokodolijoomshop_addresses'))
|
||||
->where($db->quoteName('user_id') . ' = ' . $userId)
|
||||
->order($db->quoteName('is_default') . ' DESC, ' . $db->quoteName('label') . ' ASC');
|
||||
|
||||
$db->setQuery($query);
|
||||
|
||||
return $db->loadAssocList() ?: [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default address for the current user.
|
||||
*
|
||||
* @return array|null
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function getDefaultAddress(): ?array
|
||||
{
|
||||
$userId = (int) Factory::getApplication()->getIdentity()->id;
|
||||
|
||||
if ($userId === 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
$db = $this->getDatabase();
|
||||
$query = $db->getQuery(true);
|
||||
$query->select('*')
|
||||
->from($db->quoteName('#__mokodolijoomshop_addresses'))
|
||||
->where($db->quoteName('user_id') . ' = ' . $userId)
|
||||
->where($db->quoteName('is_default') . ' = 1');
|
||||
|
||||
$db->setQuery($query, 0, 1);
|
||||
$result = $db->loadAssoc();
|
||||
|
||||
return $result ?: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save an address.
|
||||
*
|
||||
* @param array $data Address data.
|
||||
*
|
||||
* @return bool
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function saveAddress(array $data): bool
|
||||
{
|
||||
$userId = (int) Factory::getApplication()->getIdentity()->id;
|
||||
|
||||
if ($userId === 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
$db = $this->getDatabase();
|
||||
|
||||
// If setting as default, clear other defaults first
|
||||
if (!empty($data['is_default']))
|
||||
{
|
||||
$clear = $db->getQuery(true);
|
||||
$clear->update($db->quoteName('#__mokodolijoomshop_addresses'))
|
||||
->set($db->quoteName('is_default') . ' = 0')
|
||||
->where($db->quoteName('user_id') . ' = ' . $userId);
|
||||
$db->setQuery($clear);
|
||||
$db->execute();
|
||||
}
|
||||
|
||||
$id = (int) ($data['id'] ?? 0);
|
||||
|
||||
if ($id > 0)
|
||||
{
|
||||
// Update existing
|
||||
$query = $db->getQuery(true);
|
||||
$query->update($db->quoteName('#__mokodolijoomshop_addresses'))
|
||||
->set($db->quoteName('label') . ' = ' . $db->quote($data['label'] ?? ''))
|
||||
->set($db->quoteName('name') . ' = ' . $db->quote($data['name'] ?? ''))
|
||||
->set($db->quoteName('address') . ' = ' . $db->quote($data['address'] ?? ''))
|
||||
->set($db->quoteName('town') . ' = ' . $db->quote($data['town'] ?? ''))
|
||||
->set($db->quoteName('zip') . ' = ' . $db->quote($data['zip'] ?? ''))
|
||||
->set($db->quoteName('country_code') . ' = ' . $db->quote($data['country_code'] ?? ''))
|
||||
->set($db->quoteName('phone') . ' = ' . $db->quote($data['phone'] ?? ''))
|
||||
->set($db->quoteName('is_default') . ' = ' . (int) ($data['is_default'] ?? 0))
|
||||
->where($db->quoteName('id') . ' = ' . $id)
|
||||
->where($db->quoteName('user_id') . ' = ' . $userId);
|
||||
$db->setQuery($query);
|
||||
|
||||
return $db->execute() !== false;
|
||||
}
|
||||
|
||||
// Insert new
|
||||
$query = $db->getQuery(true);
|
||||
$query->insert($db->quoteName('#__mokodolijoomshop_addresses'))
|
||||
->columns(['user_id', 'label', 'name', 'address', 'town', 'zip', 'country_code', 'phone', 'is_default'])
|
||||
->values(implode(',', [
|
||||
$userId,
|
||||
$db->quote($data['label'] ?? ''),
|
||||
$db->quote($data['name'] ?? ''),
|
||||
$db->quote($data['address'] ?? ''),
|
||||
$db->quote($data['town'] ?? ''),
|
||||
$db->quote($data['zip'] ?? ''),
|
||||
$db->quote($data['country_code'] ?? ''),
|
||||
$db->quote($data['phone'] ?? ''),
|
||||
(int) ($data['is_default'] ?? 0),
|
||||
]));
|
||||
$db->setQuery($query);
|
||||
|
||||
return $db->execute() !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an address.
|
||||
*
|
||||
* @param int $addressId Address ID.
|
||||
*
|
||||
* @return bool
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function deleteAddress(int $addressId): bool
|
||||
{
|
||||
$userId = (int) Factory::getApplication()->getIdentity()->id;
|
||||
$db = $this->getDatabase();
|
||||
$query = $db->getQuery(true);
|
||||
|
||||
$query->delete($db->quoteName('#__mokodolijoomshop_addresses'))
|
||||
->where($db->quoteName('id') . ' = ' . $addressId)
|
||||
->where($db->quoteName('user_id') . ' = ' . $userId);
|
||||
|
||||
$db->setQuery($query);
|
||||
|
||||
return $db->execute() !== false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoDoliJoomShop
|
||||
* @subpackage com_mokodolijoomshop
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoDoliJoomShop\Site\Model;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
|
||||
use Moko\Component\MokoDoliJoomShop\Administrator\Helper\DolibarrClient;
|
||||
|
||||
/**
|
||||
* Shopping cart model — session-based with DB persistence.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class CartModel extends BaseDatabaseModel
|
||||
{
|
||||
/**
|
||||
* Get the current session identifier.
|
||||
*
|
||||
* @return string
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function getSessionId(): string
|
||||
{
|
||||
return Factory::getApplication()->getSession()->getId();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current user ID (0 for guests).
|
||||
*
|
||||
* @return int
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function getUserId(): int
|
||||
{
|
||||
return (int) Factory::getApplication()->getIdentity()->id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all cart items for the current user/session.
|
||||
*
|
||||
* @return array
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function getItems(): array
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$query = $db->getQuery(true);
|
||||
$query->select('*')
|
||||
->from($db->quoteName('#__mokodolijoomshop_cart'));
|
||||
|
||||
$userId = $this->getUserId();
|
||||
|
||||
if ($userId > 0)
|
||||
{
|
||||
$query->where($db->quoteName('user_id') . ' = ' . $userId);
|
||||
}
|
||||
else
|
||||
{
|
||||
$query->where($db->quoteName('session_id') . ' = ' . $db->quote($this->getSessionId()));
|
||||
}
|
||||
|
||||
$query->order($db->quoteName('created') . ' ASC');
|
||||
$db->setQuery($query);
|
||||
|
||||
return $db->loadAssocList() ?: [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an item to the cart.
|
||||
*
|
||||
* @param int $productId Dolibarr product ID.
|
||||
* @param int $quantity Quantity to add.
|
||||
*
|
||||
* @return bool True on success.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function addItem(int $productId, int $quantity = 1): bool
|
||||
{
|
||||
// Fetch product info from Dolibarr
|
||||
$client = new DolibarrClient();
|
||||
$product = $client->get('/products/' . $productId);
|
||||
|
||||
if ($product === null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate stock
|
||||
$stockReel = (float) ($product['stock_reel'] ?? 0);
|
||||
|
||||
if ($stockReel <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
$db = $this->getDatabase();
|
||||
$userId = $this->getUserId();
|
||||
$sessionId = $this->getSessionId();
|
||||
|
||||
// Check if this product already exists in cart
|
||||
$existing = $this->findCartItem($productId);
|
||||
|
||||
if ($existing)
|
||||
{
|
||||
$newQty = (int) $existing['quantity'] + $quantity;
|
||||
|
||||
if ($newQty > $stockReel)
|
||||
{
|
||||
$newQty = (int) $stockReel;
|
||||
}
|
||||
|
||||
return $this->updateItemQuantity((int) $existing['id'], $newQty);
|
||||
}
|
||||
|
||||
// Clamp quantity to stock
|
||||
if ($quantity > $stockReel)
|
||||
{
|
||||
$quantity = (int) $stockReel;
|
||||
}
|
||||
|
||||
$table = $this->getTable('Cart', 'Administrator');
|
||||
$data = [
|
||||
'session_id' => $sessionId,
|
||||
'user_id' => $userId,
|
||||
'dolibarr_product_id' => $productId,
|
||||
'product_ref' => $product['ref'] ?? '',
|
||||
'product_label' => $product['label'] ?? '',
|
||||
'quantity' => $quantity,
|
||||
'unit_price' => (float) ($product['price_ttc'] ?? $product['price'] ?? 0),
|
||||
'tax_rate' => (float) ($product['tva_tx'] ?? 0),
|
||||
];
|
||||
|
||||
$table->bind($data);
|
||||
|
||||
if (!$table->check())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return $table->store();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update quantity of a cart item.
|
||||
*
|
||||
* @param int $cartItemId Cart row ID.
|
||||
* @param int $quantity New quantity.
|
||||
*
|
||||
* @return bool
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function updateItemQuantity(int $cartItemId, int $quantity): bool
|
||||
{
|
||||
if ($quantity < 1)
|
||||
{
|
||||
return $this->removeItem($cartItemId);
|
||||
}
|
||||
|
||||
$db = $this->getDatabase();
|
||||
$query = $db->getQuery(true);
|
||||
$query->update($db->quoteName('#__mokodolijoomshop_cart'))
|
||||
->set($db->quoteName('quantity') . ' = ' . $quantity)
|
||||
->where($db->quoteName('id') . ' = ' . $cartItemId);
|
||||
|
||||
$this->addOwnerCondition($query);
|
||||
$db->setQuery($query);
|
||||
|
||||
return $db->execute() !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an item from the cart.
|
||||
*
|
||||
* @param int $cartItemId Cart row ID.
|
||||
*
|
||||
* @return bool
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function removeItem(int $cartItemId): bool
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$query = $db->getQuery(true);
|
||||
$query->delete($db->quoteName('#__mokodolijoomshop_cart'))
|
||||
->where($db->quoteName('id') . ' = ' . $cartItemId);
|
||||
|
||||
$this->addOwnerCondition($query);
|
||||
$db->setQuery($query);
|
||||
|
||||
return $db->execute() !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all items from the current cart.
|
||||
*
|
||||
* @return bool
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function clearCart(): bool
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$query = $db->getQuery(true);
|
||||
$query->delete($db->quoteName('#__mokodolijoomshop_cart'));
|
||||
$this->addOwnerCondition($query);
|
||||
$db->setQuery($query);
|
||||
|
||||
return $db->execute() !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge guest session cart into the logged-in user's cart.
|
||||
*
|
||||
* @param string $sessionId Guest session ID.
|
||||
* @param int $userId Logged-in user ID.
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function mergeGuestCart(string $sessionId, int $userId): void
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$query = $db->getQuery(true);
|
||||
|
||||
// Update guest cart items to belong to the user
|
||||
$query->update($db->quoteName('#__mokodolijoomshop_cart'))
|
||||
->set($db->quoteName('user_id') . ' = ' . $userId)
|
||||
->where($db->quoteName('session_id') . ' = ' . $db->quote($sessionId))
|
||||
->where($db->quoteName('user_id') . ' = 0');
|
||||
|
||||
$db->setQuery($query);
|
||||
$db->execute();
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete cart items older than the specified number of hours.
|
||||
*
|
||||
* @param int $hours Age threshold in hours.
|
||||
*
|
||||
* @return int Number of rows deleted.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function cleanExpired(int $hours = 72): int
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$cutoff = Factory::getDate('-' . $hours . ' hours')->toSql();
|
||||
$query = $db->getQuery(true);
|
||||
$query->delete($db->quoteName('#__mokodolijoomshop_cart'))
|
||||
->where($db->quoteName('modified') . ' < ' . $db->quote($cutoff))
|
||||
->where($db->quoteName('user_id') . ' = 0');
|
||||
|
||||
$db->setQuery($query);
|
||||
$db->execute();
|
||||
|
||||
return $db->getAffectedRows();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cart totals.
|
||||
*
|
||||
* @return array{subtotal: float, tax: float, total: float, count: int}
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function getTotals(): array
|
||||
{
|
||||
$items = $this->getItems();
|
||||
$subtotal = 0.0;
|
||||
$tax = 0.0;
|
||||
$count = 0;
|
||||
|
||||
foreach ($items as $item)
|
||||
{
|
||||
$lineTotal = (float) $item['unit_price'] * (int) $item['quantity'];
|
||||
$lineTax = $lineTotal * ((float) $item['tax_rate'] / 100);
|
||||
$subtotal += $lineTotal;
|
||||
$tax += $lineTax;
|
||||
$count += (int) $item['quantity'];
|
||||
}
|
||||
|
||||
return [
|
||||
'subtotal' => $subtotal,
|
||||
'tax' => $tax,
|
||||
'total' => $subtotal + $tax,
|
||||
'count' => $count,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate stock levels for all cart items against Dolibarr.
|
||||
*
|
||||
* @return array Array of items with insufficient stock: [product_id => available_qty].
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function validateStock(): array
|
||||
{
|
||||
$client = new DolibarrClient();
|
||||
$items = $this->getItems();
|
||||
$problems = [];
|
||||
|
||||
foreach ($items as $item)
|
||||
{
|
||||
$product = $client->get('/products/' . (int) $item['dolibarr_product_id']);
|
||||
|
||||
if ($product === null)
|
||||
{
|
||||
$problems[(int) $item['dolibarr_product_id']] = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
$stockReel = (float) ($product['stock_reel'] ?? 0);
|
||||
|
||||
if ((int) $item['quantity'] > $stockReel)
|
||||
{
|
||||
$problems[(int) $item['dolibarr_product_id']] = $stockReel;
|
||||
}
|
||||
}
|
||||
|
||||
return $problems;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find an existing cart item by product ID for the current user/session.
|
||||
*
|
||||
* @param int $productId Dolibarr product ID.
|
||||
*
|
||||
* @return array|null
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private function findCartItem(int $productId): ?array
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$query = $db->getQuery(true);
|
||||
$query->select('*')
|
||||
->from($db->quoteName('#__mokodolijoomshop_cart'))
|
||||
->where($db->quoteName('dolibarr_product_id') . ' = ' . $productId);
|
||||
|
||||
$this->addOwnerCondition($query);
|
||||
$db->setQuery($query);
|
||||
$result = $db->loadAssoc();
|
||||
|
||||
return $result ?: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add user/session ownership condition to a query.
|
||||
*
|
||||
* @param \Joomla\Database\DatabaseQuery $query Query to modify.
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private function addOwnerCondition($query): void
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$userId = $this->getUserId();
|
||||
|
||||
if ($userId > 0)
|
||||
{
|
||||
$query->where($db->quoteName('user_id') . ' = ' . $userId);
|
||||
}
|
||||
else
|
||||
{
|
||||
$query->where($db->quoteName('session_id') . ' = ' . $db->quote($this->getSessionId()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoDoliJoomShop
|
||||
* @subpackage com_mokodolijoomshop
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoDoliJoomShop\Site\Model;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Component\ComponentHelper;
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
|
||||
use Moko\Component\MokoDoliJoomShop\Administrator\Helper\DolibarrClient;
|
||||
|
||||
/**
|
||||
* Category model — fetches product categories and their products from Dolibarr.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class CategoryModel extends BaseDatabaseModel
|
||||
{
|
||||
/**
|
||||
* @var DolibarrClient
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private DolibarrClient $client;
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @param array $config Configuration array.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function __construct($config = [])
|
||||
{
|
||||
parent::__construct($config);
|
||||
|
||||
$this->client = new DolibarrClient();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single category by ID.
|
||||
*
|
||||
* @param int|null $id Category ID, or null to read from input.
|
||||
*
|
||||
* @return array|null
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function getCategory(?int $id = null): ?array
|
||||
{
|
||||
if ($id === null)
|
||||
{
|
||||
$id = Factory::getApplication()->input->getInt('id', 0);
|
||||
}
|
||||
|
||||
if ($id <= 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->client->get('/categories/' . $id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all product categories as a flat list.
|
||||
*
|
||||
* @return array
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function getAllCategories(): array
|
||||
{
|
||||
$categories = $this->client->get('/categories', [
|
||||
'sortfield' => 't.label',
|
||||
'sortorder' => 'ASC',
|
||||
'type' => 'product',
|
||||
'limit' => 200,
|
||||
]);
|
||||
|
||||
return $categories ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a hierarchical category tree.
|
||||
*
|
||||
* @return array Nested array with 'children' key.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function getCategoryTree(): array
|
||||
{
|
||||
$flat = $this->getAllCategories();
|
||||
$tree = [];
|
||||
$map = [];
|
||||
|
||||
// Index by ID
|
||||
foreach ($flat as $cat)
|
||||
{
|
||||
$cat['children'] = [];
|
||||
$map[(int) $cat['id']] = $cat;
|
||||
}
|
||||
|
||||
// Build tree
|
||||
foreach ($map as $id => &$cat)
|
||||
{
|
||||
$parentId = (int) ($cat['fk_parent'] ?? 0);
|
||||
|
||||
if ($parentId > 0 && isset($map[$parentId]))
|
||||
{
|
||||
$map[$parentId]['children'][] = &$cat;
|
||||
}
|
||||
else
|
||||
{
|
||||
$tree[] = &$cat;
|
||||
}
|
||||
}
|
||||
|
||||
unset($cat);
|
||||
|
||||
return $tree;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get products belonging to a category.
|
||||
*
|
||||
* @param int $categoryId Category ID.
|
||||
*
|
||||
* @return array
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function getCategoryProducts(int $categoryId): array
|
||||
{
|
||||
$params = ComponentHelper::getParams('com_mokodolijoomshop');
|
||||
$perPage = (int) $params->get('products_per_page', 12);
|
||||
$app = Factory::getApplication();
|
||||
$page = $app->input->getInt('page', 0);
|
||||
|
||||
$products = $this->client->get('/products', [
|
||||
'sortfield' => 't.ref',
|
||||
'sortorder' => 'ASC',
|
||||
'limit' => $perPage,
|
||||
'page' => $page,
|
||||
'category' => $categoryId,
|
||||
]);
|
||||
|
||||
return $products ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build breadcrumb trail for a category.
|
||||
*
|
||||
* @param int $categoryId Category ID.
|
||||
*
|
||||
* @return array Array of [id, label] from root to current.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function getBreadcrumbs(int $categoryId): array
|
||||
{
|
||||
$crumbs = [];
|
||||
$visited = [];
|
||||
$current = $categoryId;
|
||||
|
||||
while ($current > 0 && !isset($visited[$current]))
|
||||
{
|
||||
$visited[$current] = true;
|
||||
$cat = $this->client->get('/categories/' . $current);
|
||||
|
||||
if ($cat === null)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
array_unshift($crumbs, [
|
||||
'id' => (int) $cat['id'],
|
||||
'label' => $cat['label'] ?? '',
|
||||
]);
|
||||
|
||||
$current = (int) ($cat['fk_parent'] ?? 0);
|
||||
}
|
||||
|
||||
return $crumbs;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoDoliJoomShop
|
||||
* @subpackage com_mokodolijoomshop
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoDoliJoomShop\Site\Model;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Component\ComponentHelper;
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
|
||||
use Moko\Component\MokoDoliJoomShop\Administrator\Service\CustomerSyncService;
|
||||
use Moko\Component\MokoDoliJoomShop\Administrator\Service\OrderService;
|
||||
|
||||
/**
|
||||
* Checkout model — handles the full checkout process.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class CheckoutModel extends BaseDatabaseModel
|
||||
{
|
||||
/**
|
||||
* Determine if the current user can checkout (based on checkout_mode config).
|
||||
*
|
||||
* @return bool
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function canCheckout(): bool
|
||||
{
|
||||
$params = ComponentHelper::getParams('com_mokodolijoomshop');
|
||||
$mode = $params->get('checkout_mode', 'both');
|
||||
$userId = (int) Factory::getApplication()->getIdentity()->id;
|
||||
|
||||
if ($mode === 'registered' && $userId === 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the configured checkout mode.
|
||||
*
|
||||
* @return string 'guest', 'registered', or 'both'.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function getCheckoutMode(): string
|
||||
{
|
||||
$params = ComponentHelper::getParams('com_mokodolijoomshop');
|
||||
|
||||
return $params->get('checkout_mode', 'both');
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the checkout.
|
||||
*
|
||||
* @param array $billingData Billing form data.
|
||||
* @param array $cartItems Cart items from CartModel.
|
||||
* @param array $totals Cart totals.
|
||||
*
|
||||
* @return array|null Order result with refs, or null on failure.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function processCheckout(array $billingData, array $cartItems, array $totals): ?array
|
||||
{
|
||||
$userId = (int) Factory::getApplication()->getIdentity()->id;
|
||||
$customerService = new CustomerSyncService();
|
||||
$orderService = new OrderService();
|
||||
|
||||
// Resolve or create the Dolibarr thirdparty
|
||||
if ($userId > 0)
|
||||
{
|
||||
$thirdpartyId = $customerService->getOrCreateThirdparty($userId);
|
||||
}
|
||||
else
|
||||
{
|
||||
$thirdpartyId = $customerService->createGuestCustomer(
|
||||
$billingData['name'] ?? 'Guest Customer',
|
||||
$billingData['email'] ?? '',
|
||||
$billingData['address'] ?? '',
|
||||
$billingData['town'] ?? '',
|
||||
$billingData['zip'] ?? '',
|
||||
$billingData['phone'] ?? ''
|
||||
);
|
||||
}
|
||||
|
||||
if ($thirdpartyId === null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Create order in Dolibarr
|
||||
$order = $orderService->createOrder($thirdpartyId, $cartItems, [
|
||||
'note_public' => $billingData['notes'] ?? '',
|
||||
]);
|
||||
|
||||
if ($order === null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
$orderId = (int) ($order['id'] ?? 0);
|
||||
$orderRef = $order['ref'] ?? '';
|
||||
|
||||
// Create invoice from order
|
||||
$invoice = $orderService->createInvoiceFromOrder($orderId);
|
||||
$invoiceId = (int) ($invoice['id'] ?? 0);
|
||||
$invoiceRef = $invoice['ref'] ?? '';
|
||||
|
||||
// Save local mapping
|
||||
$orderService->saveOrderMapping(
|
||||
$userId,
|
||||
$orderId,
|
||||
$invoiceId,
|
||||
$thirdpartyId,
|
||||
$orderRef,
|
||||
$invoiceRef,
|
||||
$totals['subtotal'],
|
||||
$totals['total']
|
||||
);
|
||||
|
||||
return [
|
||||
'order_id' => $orderId,
|
||||
'order_ref' => $orderRef,
|
||||
'invoice_id' => $invoiceId,
|
||||
'invoice_ref' => $invoiceRef,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoDoliJoomShop
|
||||
* @subpackage com_mokodolijoomshop
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoDoliJoomShop\Site\Model;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
|
||||
use Moko\Component\MokoDoliJoomShop\Administrator\Helper\DolibarrClient;
|
||||
|
||||
/**
|
||||
* Order history model — retrieves user's orders from local mapping and Dolibarr.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class OrdersModel extends BaseDatabaseModel
|
||||
{
|
||||
/**
|
||||
* @var DolibarrClient
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private DolibarrClient $client;
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @param array $config Configuration array.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function __construct($config = [])
|
||||
{
|
||||
parent::__construct($config);
|
||||
|
||||
$this->client = new DolibarrClient();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get orders for the currently logged-in user.
|
||||
*
|
||||
* @return array
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function getUserOrders(): array
|
||||
{
|
||||
$userId = (int) Factory::getApplication()->getIdentity()->id;
|
||||
|
||||
if ($userId === 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
$db = $this->getDatabase();
|
||||
$query = $db->getQuery(true);
|
||||
$query->select('*')
|
||||
->from($db->quoteName('#__mokodolijoomshop_orders'))
|
||||
->where($db->quoteName('user_id') . ' = ' . $userId)
|
||||
->order($db->quoteName('created') . ' DESC');
|
||||
|
||||
$db->setQuery($query);
|
||||
|
||||
return $db->loadAssocList() ?: [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single order detail from Dolibarr.
|
||||
*
|
||||
* @param int $orderId Dolibarr order ID.
|
||||
*
|
||||
* @return array|null
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function getOrderDetail(int $orderId): ?array
|
||||
{
|
||||
// Verify the order belongs to the current user
|
||||
$userId = (int) Factory::getApplication()->getIdentity()->id;
|
||||
|
||||
if ($userId === 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
$db = $this->getDatabase();
|
||||
$query = $db->getQuery(true);
|
||||
$query->select($db->quoteName('dolibarr_order_id'))
|
||||
->from($db->quoteName('#__mokodolijoomshop_orders'))
|
||||
->where($db->quoteName('user_id') . ' = ' . $userId)
|
||||
->where($db->quoteName('dolibarr_order_id') . ' = ' . $orderId);
|
||||
|
||||
$db->setQuery($query);
|
||||
|
||||
if ($db->loadResult() === null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->client->get('/orders/' . $orderId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get invoice PDF download URL from Dolibarr.
|
||||
*
|
||||
* @param int $invoiceId Dolibarr invoice ID.
|
||||
*
|
||||
* @return array|null Document info with download data.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function getInvoicePdf(int $invoiceId): ?array
|
||||
{
|
||||
// Verify user has access to this invoice
|
||||
$userId = (int) Factory::getApplication()->getIdentity()->id;
|
||||
|
||||
if ($userId === 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
$db = $this->getDatabase();
|
||||
$query = $db->getQuery(true);
|
||||
$query->select($db->quoteName('dolibarr_invoice_id'))
|
||||
->from($db->quoteName('#__mokodolijoomshop_orders'))
|
||||
->where($db->quoteName('user_id') . ' = ' . $userId)
|
||||
->where($db->quoteName('dolibarr_invoice_id') . ' = ' . $invoiceId);
|
||||
|
||||
$db->setQuery($query);
|
||||
|
||||
if ($db->loadResult() === null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get invoice documents
|
||||
$docs = $this->client->get('/documents', [
|
||||
'modulepart' => 'invoice',
|
||||
'id' => $invoiceId,
|
||||
]);
|
||||
|
||||
if (empty($docs))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Find PDF
|
||||
foreach ($docs as $doc)
|
||||
{
|
||||
if (isset($doc['relativename']) && str_ends_with($doc['relativename'], '.pdf'))
|
||||
{
|
||||
return $doc;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get real-time order status from Dolibarr.
|
||||
*
|
||||
* @param int $orderId Dolibarr order ID.
|
||||
*
|
||||
* @return string Status label.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function getOrderStatus(int $orderId): string
|
||||
{
|
||||
$order = $this->client->get('/orders/' . $orderId);
|
||||
|
||||
if ($order === null)
|
||||
{
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
$statusMap = [
|
||||
-1 => 'cancelled',
|
||||
0 => 'draft',
|
||||
1 => 'validated',
|
||||
2 => 'shipped',
|
||||
3 => 'delivered',
|
||||
];
|
||||
|
||||
$statusCode = (int) ($order['statut'] ?? $order['status'] ?? 0);
|
||||
|
||||
return $statusMap[$statusCode] ?? 'unknown';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoDoliJoomShop
|
||||
* @subpackage com_mokodolijoomshop
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoDoliJoomShop\Site\Model;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
|
||||
use Moko\Component\MokoDoliJoomShop\Administrator\Helper\DolibarrClient;
|
||||
|
||||
/**
|
||||
* Single product model — fetches product detail from Dolibarr API.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class ProductModel extends BaseDatabaseModel
|
||||
{
|
||||
/**
|
||||
* @var DolibarrClient
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private DolibarrClient $client;
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @param array $config Configuration array.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function __construct($config = [])
|
||||
{
|
||||
parent::__construct($config);
|
||||
|
||||
$this->client = new DolibarrClient();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single product by ID.
|
||||
*
|
||||
* @param int|null $id Product ID, or null to read from input.
|
||||
*
|
||||
* @return array|null Product data or null if not found.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function getItem(?int $id = null): ?array
|
||||
{
|
||||
if ($id === null)
|
||||
{
|
||||
$id = Factory::getApplication()->input->getInt('id', 0);
|
||||
}
|
||||
|
||||
if ($id <= 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->client->get('/products/' . $id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get stock level for a product.
|
||||
*
|
||||
* @param int $productId Dolibarr product ID.
|
||||
*
|
||||
* @return float Total stock across all warehouses.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function getStock(int $productId): float
|
||||
{
|
||||
$stockData = $this->client->get('/products/' . $productId . '/stock');
|
||||
|
||||
if ($stockData === null)
|
||||
{
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
// Sum stock across warehouses
|
||||
$total = 0.0;
|
||||
|
||||
if (isset($stockData['stock_warehouses']) && \is_array($stockData['stock_warehouses']))
|
||||
{
|
||||
foreach ($stockData['stock_warehouses'] as $warehouse)
|
||||
{
|
||||
$total += (float) ($warehouse['real'] ?? 0);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
$total = (float) ($stockData['stock_reel'] ?? 0);
|
||||
}
|
||||
|
||||
return $total;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get product images from Dolibarr documents API.
|
||||
*
|
||||
* @param int $productId Dolibarr product ID.
|
||||
* @param string $ref Product reference for path building.
|
||||
*
|
||||
* @return array Array of image URLs.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function getImages(int $productId, string $ref = ''): array
|
||||
{
|
||||
$docs = $this->client->get('/documents', [
|
||||
'modulepart' => 'product',
|
||||
'id' => $productId,
|
||||
]);
|
||||
|
||||
if ($docs === null || !\is_array($docs))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
$images = [];
|
||||
|
||||
foreach ($docs as $doc)
|
||||
{
|
||||
if (isset($doc['relativename']) && preg_match('/\.(jpe?g|png|gif|webp)$/i', $doc['relativename']))
|
||||
{
|
||||
$images[] = [
|
||||
'name' => $doc['name'] ?? basename($doc['relativename']),
|
||||
'url' => $doc['fullname'] ?? $doc['relativename'],
|
||||
'encoded' => $doc['content'] ?? null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $images;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get related products from the same category.
|
||||
*
|
||||
* @param int $productId Current product ID.
|
||||
* @param int $limit Number of related products to return.
|
||||
*
|
||||
* @return array
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function getRelated(int $productId, int $limit = 4): array
|
||||
{
|
||||
// Get categories for this product
|
||||
$categories = $this->client->get('/products/' . $productId . '/categories');
|
||||
|
||||
if (empty($categories))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
$catId = (int) $categories[0]['id'];
|
||||
$products = $this->client->get('/categories/' . $catId . '/objects', [
|
||||
'type' => 'product',
|
||||
'limit' => $limit + 1,
|
||||
]);
|
||||
|
||||
if (empty($products))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
// Remove the current product from related
|
||||
return array_values(array_filter($products, function ($p) use ($productId) {
|
||||
return (int) $p['id'] !== $productId;
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoDoliJoomShop
|
||||
* @subpackage com_mokodolijoomshop
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoDoliJoomShop\Site\Model;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Component\ComponentHelper;
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
|
||||
use Moko\Component\MokoDoliJoomShop\Administrator\Helper\DolibarrClient;
|
||||
|
||||
/**
|
||||
* Products list model — fetches products from Dolibarr API.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class ProductsModel extends BaseDatabaseModel
|
||||
{
|
||||
/**
|
||||
* @var DolibarrClient
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private DolibarrClient $client;
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @param array $config Configuration array.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function __construct($config = [])
|
||||
{
|
||||
parent::__construct($config);
|
||||
|
||||
$this->client = new DolibarrClient();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a list of products from Dolibarr.
|
||||
*
|
||||
* @return array Array of product objects.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function getItems(): array
|
||||
{
|
||||
$params = ComponentHelper::getParams('com_mokodolijoomshop');
|
||||
$perPage = (int) $params->get('products_per_page', 12);
|
||||
$app = Factory::getApplication();
|
||||
$page = $app->input->getInt('page', 0);
|
||||
$categoryId = $app->input->getInt('category_id', 0);
|
||||
|
||||
$query = [
|
||||
'sortfield' => 't.ref',
|
||||
'sortorder' => 'ASC',
|
||||
'limit' => $perPage,
|
||||
'page' => $page,
|
||||
];
|
||||
|
||||
if ($categoryId > 0)
|
||||
{
|
||||
$query['category'] = $categoryId;
|
||||
}
|
||||
|
||||
$products = $this->client->get('/products', $query);
|
||||
|
||||
if ($products === null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
// Filter to only saleable products
|
||||
return array_values(array_filter($products, function ($product) {
|
||||
return !empty($product['status_buy']) || !empty($product['tosell']) || ((int) ($product['status'] ?? 0)) === 1;
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get product categories from Dolibarr.
|
||||
*
|
||||
* @return array Array of category objects.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function getCategories(): array
|
||||
{
|
||||
$categories = $this->client->get('/categories', [
|
||||
'sortfield' => 't.label',
|
||||
'sortorder' => 'ASC',
|
||||
'type' => 'product',
|
||||
]);
|
||||
|
||||
return $categories ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total product count for pagination.
|
||||
*
|
||||
* @return int
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function getTotal(): int
|
||||
{
|
||||
$products = $this->client->get('/products', [
|
||||
'limit' => 0,
|
||||
]);
|
||||
|
||||
if ($products === null)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
return \count($products);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the number of products per page.
|
||||
*
|
||||
* @return int
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function getPerPage(): int
|
||||
{
|
||||
$params = ComponentHelper::getParams('com_mokodolijoomshop');
|
||||
|
||||
return (int) $params->get('products_per_page', 12);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoDoliJoomShop
|
||||
* @subpackage com_mokodolijoomshop
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoDoliJoomShop\Site\Model;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
|
||||
use Moko\Component\MokoDoliJoomShop\Administrator\Helper\DolibarrClient;
|
||||
|
||||
/**
|
||||
* Wishlist model — save for later functionality.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class WishlistModel extends BaseDatabaseModel
|
||||
{
|
||||
/**
|
||||
* Get wishlist items for the current user.
|
||||
*
|
||||
* @return array
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function getItems(): array
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$query = $db->getQuery(true);
|
||||
$userId = (int) Factory::getApplication()->getIdentity()->id;
|
||||
|
||||
$query->select('*')
|
||||
->from($db->quoteName('#__mokodolijoomshop_wishlist'))
|
||||
->order($db->quoteName('created') . ' DESC');
|
||||
|
||||
if ($userId > 0)
|
||||
{
|
||||
$query->where($db->quoteName('user_id') . ' = ' . $userId);
|
||||
}
|
||||
else
|
||||
{
|
||||
$sessionId = Factory::getApplication()->getSession()->getId();
|
||||
$query->where($db->quoteName('session_id') . ' = ' . $db->quote($sessionId));
|
||||
}
|
||||
|
||||
$db->setQuery($query);
|
||||
|
||||
return $db->loadAssocList() ?: [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a product to the wishlist.
|
||||
*
|
||||
* @param int $productId Dolibarr product ID.
|
||||
*
|
||||
* @return bool
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function addItem(int $productId): bool
|
||||
{
|
||||
$client = new DolibarrClient();
|
||||
$product = $client->get('/products/' . $productId);
|
||||
|
||||
if ($product === null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
$db = $this->getDatabase();
|
||||
$userId = (int) Factory::getApplication()->getIdentity()->id;
|
||||
$sessionId = Factory::getApplication()->getSession()->getId();
|
||||
|
||||
// Check if already in wishlist
|
||||
$query = $db->getQuery(true);
|
||||
$query->select('COUNT(*)')
|
||||
->from($db->quoteName('#__mokodolijoomshop_wishlist'))
|
||||
->where($db->quoteName('dolibarr_product_id') . ' = ' . $productId);
|
||||
|
||||
if ($userId > 0)
|
||||
{
|
||||
$query->where($db->quoteName('user_id') . ' = ' . $userId);
|
||||
}
|
||||
else
|
||||
{
|
||||
$query->where($db->quoteName('session_id') . ' = ' . $db->quote($sessionId));
|
||||
}
|
||||
|
||||
$db->setQuery($query);
|
||||
|
||||
if ((int) $db->loadResult() > 0)
|
||||
{
|
||||
return true; // Already in wishlist
|
||||
}
|
||||
|
||||
$insert = $db->getQuery(true);
|
||||
$insert->insert($db->quoteName('#__mokodolijoomshop_wishlist'))
|
||||
->columns(['user_id', 'session_id', 'dolibarr_product_id', 'product_ref', 'product_label'])
|
||||
->values(implode(',', [
|
||||
$userId,
|
||||
$db->quote($sessionId),
|
||||
$productId,
|
||||
$db->quote($product['ref'] ?? ''),
|
||||
$db->quote($product['label'] ?? ''),
|
||||
]));
|
||||
|
||||
$db->setQuery($insert);
|
||||
|
||||
return $db->execute() !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a product from the wishlist.
|
||||
*
|
||||
* @param int $wishlistItemId Wishlist row ID.
|
||||
*
|
||||
* @return bool
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function removeItem(int $wishlistItemId): bool
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$userId = (int) Factory::getApplication()->getIdentity()->id;
|
||||
$query = $db->getQuery(true);
|
||||
|
||||
$query->delete($db->quoteName('#__mokodolijoomshop_wishlist'))
|
||||
->where($db->quoteName('id') . ' = ' . $wishlistItemId);
|
||||
|
||||
if ($userId > 0)
|
||||
{
|
||||
$query->where($db->quoteName('user_id') . ' = ' . $userId);
|
||||
}
|
||||
else
|
||||
{
|
||||
$sessionId = Factory::getApplication()->getSession()->getId();
|
||||
$query->where($db->quoteName('session_id') . ' = ' . $db->quote($sessionId));
|
||||
}
|
||||
|
||||
$db->setQuery($query);
|
||||
|
||||
return $db->execute() !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge guest wishlist into user account on login.
|
||||
*
|
||||
* @param string $sessionId Guest session ID.
|
||||
* @param int $userId User ID.
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function mergeGuestWishlist(string $sessionId, int $userId): void
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$query = $db->getQuery(true);
|
||||
$query->update($db->quoteName('#__mokodolijoomshop_wishlist'))
|
||||
->set($db->quoteName('user_id') . ' = ' . $userId)
|
||||
->where($db->quoteName('session_id') . ' = ' . $db->quote($sessionId))
|
||||
->where($db->quoteName('user_id') . ' = 0');
|
||||
|
||||
$db->setQuery($query);
|
||||
$db->execute();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoDoliJoomShop
|
||||
* @subpackage com_mokodolijoomshop
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoDoliJoomShop\Site\Service;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Application\SiteApplication;
|
||||
use Joomla\CMS\Component\Router\RouterBase;
|
||||
use Joomla\CMS\Menu\AbstractMenu;
|
||||
|
||||
/**
|
||||
* SEF URL router for com_mokodolijoomshop.
|
||||
*
|
||||
* URL patterns:
|
||||
* /shop → products view
|
||||
* /shop/cart → cart view
|
||||
* /shop/checkout → checkout view
|
||||
* /shop/my-orders → orders view
|
||||
* /shop/category/{id} → category view
|
||||
* /shop/product/{id} → product view
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class Router extends RouterBase
|
||||
{
|
||||
/**
|
||||
* Build SEF URL segments from query parameters.
|
||||
*
|
||||
* @param array &$query Query parameters.
|
||||
*
|
||||
* @return array URL segments.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function build(&$query): array
|
||||
{
|
||||
$segments = [];
|
||||
$view = $query['view'] ?? 'products';
|
||||
|
||||
unset($query['view']);
|
||||
|
||||
switch ($view)
|
||||
{
|
||||
case 'products':
|
||||
// No extra segment — the menu item handles it
|
||||
break;
|
||||
|
||||
case 'product':
|
||||
$segments[] = 'product';
|
||||
|
||||
if (isset($query['id']))
|
||||
{
|
||||
$segments[] = $query['id'];
|
||||
unset($query['id']);
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case 'category':
|
||||
$segments[] = 'category';
|
||||
|
||||
if (isset($query['id']))
|
||||
{
|
||||
$segments[] = $query['id'];
|
||||
unset($query['id']);
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case 'cart':
|
||||
$segments[] = 'cart';
|
||||
break;
|
||||
|
||||
case 'checkout':
|
||||
$segments[] = 'checkout';
|
||||
break;
|
||||
|
||||
case 'orders':
|
||||
$segments[] = 'my-orders';
|
||||
|
||||
if (isset($query['order_id']))
|
||||
{
|
||||
$segments[] = $query['order_id'];
|
||||
unset($query['order_id']);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
// Handle task-based URLs (cart.add, cart.remove, etc.)
|
||||
if (isset($query['task']))
|
||||
{
|
||||
// Keep task in query for controller routing
|
||||
}
|
||||
|
||||
return $segments;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse SEF URL segments into query parameters.
|
||||
*
|
||||
* @param array &$segments URL segments.
|
||||
*
|
||||
* @return array Query parameters.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function parse(&$segments): array
|
||||
{
|
||||
$vars = [];
|
||||
$count = \count($segments);
|
||||
|
||||
if ($count === 0)
|
||||
{
|
||||
$vars['view'] = 'products';
|
||||
|
||||
return $vars;
|
||||
}
|
||||
|
||||
$first = $segments[0];
|
||||
|
||||
switch ($first)
|
||||
{
|
||||
case 'product':
|
||||
$vars['view'] = 'product';
|
||||
|
||||
if ($count > 1)
|
||||
{
|
||||
$vars['id'] = (int) $segments[1];
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case 'category':
|
||||
$vars['view'] = 'category';
|
||||
|
||||
if ($count > 1)
|
||||
{
|
||||
$vars['id'] = (int) $segments[1];
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case 'cart':
|
||||
$vars['view'] = 'cart';
|
||||
break;
|
||||
|
||||
case 'checkout':
|
||||
$vars['view'] = 'checkout';
|
||||
break;
|
||||
|
||||
case 'my-orders':
|
||||
$vars['view'] = 'orders';
|
||||
|
||||
if ($count > 1)
|
||||
{
|
||||
$vars['order_id'] = (int) $segments[1];
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
default:
|
||||
// Try to resolve as product ID or fall back
|
||||
if (is_numeric($first))
|
||||
{
|
||||
$vars['view'] = 'product';
|
||||
$vars['id'] = (int) $first;
|
||||
}
|
||||
else
|
||||
{
|
||||
$vars['view'] = 'products';
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
$segments = [];
|
||||
|
||||
return $vars;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoDoliJoomShop
|
||||
* @subpackage com_mokodolijoomshop
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoDoliJoomShop\Site\View\Cart;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Component\ComponentHelper;
|
||||
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
||||
|
||||
/**
|
||||
* Shopping cart view.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class HtmlView extends BaseHtmlView
|
||||
{
|
||||
/**
|
||||
* @var array Cart items.
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected array $items = [];
|
||||
|
||||
/**
|
||||
* @var array Cart totals (subtotal, tax, total, count).
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected array $totals = [];
|
||||
|
||||
/**
|
||||
* @var string Currency code.
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected string $currency = 'USD';
|
||||
|
||||
/**
|
||||
* Display the cart.
|
||||
*
|
||||
* @param string $tpl Template name.
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function display($tpl = null): void
|
||||
{
|
||||
$model = $this->getModel();
|
||||
$params = ComponentHelper::getParams('com_mokodolijoomshop');
|
||||
|
||||
$this->items = $model->getItems();
|
||||
$this->totals = $model->getTotals();
|
||||
$this->currency = $params->get('currency', 'USD');
|
||||
|
||||
parent::display($tpl);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoDoliJoomShop
|
||||
* @subpackage com_mokodolijoomshop
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoDoliJoomShop\Site\View\Category;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Component\ComponentHelper;
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
||||
|
||||
/**
|
||||
* Category landing page view.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class HtmlView extends BaseHtmlView
|
||||
{
|
||||
/**
|
||||
* @var array|null Current category data.
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected ?array $category = null;
|
||||
|
||||
/**
|
||||
* @var array Products in this category.
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected array $items = [];
|
||||
|
||||
/**
|
||||
* @var array Category tree for sidebar.
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected array $categoryTree = [];
|
||||
|
||||
/**
|
||||
* @var array Breadcrumbs path.
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected array $breadcrumbs = [];
|
||||
|
||||
/**
|
||||
* @var string Currency code.
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected string $currency = 'USD';
|
||||
|
||||
/**
|
||||
* @var int Current page.
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected int $page = 0;
|
||||
|
||||
/**
|
||||
* @var int Per page count.
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected int $perPage = 12;
|
||||
|
||||
/**
|
||||
* Display the category page.
|
||||
*
|
||||
* @param string $tpl Template name.
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function display($tpl = null): void
|
||||
{
|
||||
$model = $this->getModel();
|
||||
$app = Factory::getApplication();
|
||||
$params = ComponentHelper::getParams('com_mokodolijoomshop');
|
||||
|
||||
$categoryId = $app->input->getInt('id', 0);
|
||||
|
||||
$this->category = $model->getCategory($categoryId);
|
||||
$this->categoryTree = $model->getCategoryTree();
|
||||
$this->currency = $params->get('currency', 'USD');
|
||||
$this->page = $app->input->getInt('page', 0);
|
||||
$this->perPage = (int) $params->get('products_per_page', 12);
|
||||
|
||||
if ($this->category !== null)
|
||||
{
|
||||
$this->items = $model->getCategoryProducts($categoryId);
|
||||
$this->breadcrumbs = $model->getBreadcrumbs($categoryId);
|
||||
|
||||
$app->getDocument()->setTitle(htmlspecialchars($this->category['label'] ?? 'Category'));
|
||||
}
|
||||
|
||||
parent::display($tpl);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoDoliJoomShop
|
||||
* @subpackage com_mokodolijoomshop
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoDoliJoomShop\Site\View\Checkout;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Component\ComponentHelper;
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
||||
|
||||
/**
|
||||
* Checkout view.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class HtmlView extends BaseHtmlView
|
||||
{
|
||||
/**
|
||||
* @var array Cart items.
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected array $cartItems = [];
|
||||
|
||||
/**
|
||||
* @var array Cart totals.
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected array $totals = [];
|
||||
|
||||
/**
|
||||
* @var string Currency code.
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected string $currency = 'USD';
|
||||
|
||||
/**
|
||||
* @var string Checkout mode.
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected string $checkoutMode = 'both';
|
||||
|
||||
/**
|
||||
* @var \Joomla\CMS\User\User|null Current user.
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected $user = null;
|
||||
|
||||
/**
|
||||
* @var array|null Order result for confirmation page.
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected ?array $orderResult = null;
|
||||
|
||||
/**
|
||||
* Display the checkout view.
|
||||
*
|
||||
* @param string $tpl Template name.
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function display($tpl = null): void
|
||||
{
|
||||
$app = Factory::getApplication();
|
||||
$params = ComponentHelper::getParams('com_mokodolijoomshop');
|
||||
$layout = $app->input->getString('layout', 'default');
|
||||
|
||||
$this->currency = $params->get('currency', 'USD');
|
||||
$this->checkoutMode = $params->get('checkout_mode', 'both');
|
||||
$this->user = $app->getIdentity();
|
||||
|
||||
if ($layout === 'confirmation')
|
||||
{
|
||||
$this->orderResult = $app->getSession()->get('mokodolijoomshop.order_result');
|
||||
$app->getSession()->clear('mokodolijoomshop.order_result');
|
||||
}
|
||||
else
|
||||
{
|
||||
/** @var \Moko\Component\MokoDoliJoomShop\Site\Model\CartModel $cartModel */
|
||||
$cartModel = $this->getModel('Cart');
|
||||
$this->cartItems = $cartModel->getItems();
|
||||
$this->totals = $cartModel->getTotals();
|
||||
}
|
||||
|
||||
parent::display($tpl);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoDoliJoomShop
|
||||
* @subpackage com_mokodolijoomshop
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoDoliJoomShop\Site\View\Orders;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Component\ComponentHelper;
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
||||
|
||||
/**
|
||||
* Order history view (My Orders).
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class HtmlView extends BaseHtmlView
|
||||
{
|
||||
/**
|
||||
* @var array User's orders.
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected array $orders = [];
|
||||
|
||||
/**
|
||||
* @var array|null Single order detail.
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected ?array $orderDetail = null;
|
||||
|
||||
/**
|
||||
* @var string Currency.
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected string $currency = 'USD';
|
||||
|
||||
/**
|
||||
* @var bool Whether user is logged in.
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected bool $isGuest = true;
|
||||
|
||||
/**
|
||||
* Display the orders view.
|
||||
*
|
||||
* @param string $tpl Template name.
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function display($tpl = null): void
|
||||
{
|
||||
$app = Factory::getApplication();
|
||||
$params = ComponentHelper::getParams('com_mokodolijoomshop');
|
||||
$model = $this->getModel();
|
||||
|
||||
$this->currency = $params->get('currency', 'USD');
|
||||
$this->isGuest = empty($app->getIdentity()->id);
|
||||
|
||||
if (!$this->isGuest)
|
||||
{
|
||||
$orderId = $app->input->getInt('order_id', 0);
|
||||
|
||||
if ($orderId > 0)
|
||||
{
|
||||
$this->orderDetail = $model->getOrderDetail($orderId);
|
||||
}
|
||||
else
|
||||
{
|
||||
$this->orders = $model->getUserOrders();
|
||||
}
|
||||
}
|
||||
|
||||
parent::display($tpl);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoDoliJoomShop
|
||||
* @subpackage com_mokodolijoomshop
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoDoliJoomShop\Site\View\Product;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Component\ComponentHelper;
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
||||
|
||||
/**
|
||||
* Single product detail view.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class HtmlView extends BaseHtmlView
|
||||
{
|
||||
/**
|
||||
* @var array|null Product data.
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected ?array $item = null;
|
||||
|
||||
/**
|
||||
* @var float Stock quantity.
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected float $stock = 0.0;
|
||||
|
||||
/**
|
||||
* @var array Product images.
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected array $images = [];
|
||||
|
||||
/**
|
||||
* @var array Related products.
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected array $related = [];
|
||||
|
||||
/**
|
||||
* @var string Currency code.
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected string $currency = 'USD';
|
||||
|
||||
/**
|
||||
* Display the product detail page.
|
||||
*
|
||||
* @param string $tpl Template name.
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function display($tpl = null): void
|
||||
{
|
||||
$model = $this->getModel();
|
||||
$params = ComponentHelper::getParams('com_mokodolijoomshop');
|
||||
|
||||
$this->item = $model->getItem();
|
||||
$this->currency = $params->get('currency', 'USD');
|
||||
|
||||
if ($this->item !== null)
|
||||
{
|
||||
$productId = (int) $this->item['id'];
|
||||
$this->stock = $model->getStock($productId);
|
||||
$this->images = $model->getImages($productId, $this->item['ref'] ?? '');
|
||||
$this->related = $model->getRelated($productId);
|
||||
|
||||
// Set page title
|
||||
$app = Factory::getApplication();
|
||||
$app->getDocument()->setTitle(htmlspecialchars($this->item['label'] ?? 'Product'));
|
||||
}
|
||||
|
||||
parent::display($tpl);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoDoliJoomShop
|
||||
* @subpackage com_mokodolijoomshop
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoDoliJoomShop\Site\View\Products;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
||||
use Joomla\CMS\Component\ComponentHelper;
|
||||
|
||||
/**
|
||||
* Product catalog listing view.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class HtmlView extends BaseHtmlView
|
||||
{
|
||||
/**
|
||||
* @var array Product items from Dolibarr.
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected array $items = [];
|
||||
|
||||
/**
|
||||
* @var array Product categories.
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected array $categories = [];
|
||||
|
||||
/**
|
||||
* @var int Current page number.
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected int $page = 0;
|
||||
|
||||
/**
|
||||
* @var int Active category filter.
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected int $categoryId = 0;
|
||||
|
||||
/**
|
||||
* @var string Currency code.
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected string $currency = 'USD';
|
||||
|
||||
/**
|
||||
* @var int Products per page.
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected int $perPage = 12;
|
||||
|
||||
/**
|
||||
* Display the products catalog.
|
||||
*
|
||||
* @param string $tpl Template name.
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function display($tpl = null): void
|
||||
{
|
||||
$model = $this->getModel();
|
||||
$app = Factory::getApplication();
|
||||
$params = ComponentHelper::getParams('com_mokodolijoomshop');
|
||||
|
||||
$this->items = $model->getItems();
|
||||
$this->categories = $model->getCategories();
|
||||
$this->page = $app->input->getInt('page', 0);
|
||||
$this->categoryId = $app->input->getInt('category_id', 0);
|
||||
$this->currency = $params->get('currency', 'USD');
|
||||
$this->perPage = $model->getPerPage();
|
||||
|
||||
parent::display($tpl);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoDoliJoomShop
|
||||
* @subpackage com_mokodolijoomshop
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\HTML\HTMLHelper;
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\Router\Route;
|
||||
|
||||
/** @var \Moko\Component\MokoDoliJoomShop\Site\View\Cart\HtmlView $this */
|
||||
|
||||
$currency = htmlspecialchars($this->currency);
|
||||
?>
|
||||
<div class="com-mokodolijoomshop-cart">
|
||||
<h2><?php echo Text::_('COM_MOKODOLIJOOMSHOP_CART'); ?></h2>
|
||||
|
||||
<?php if (empty($this->items)) : ?>
|
||||
<div class="alert alert-info">
|
||||
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_CART_EMPTY'); ?>
|
||||
</div>
|
||||
<a href="<?php echo Route::_('index.php?option=com_mokodolijoomshop&view=products'); ?>" class="btn btn-primary">
|
||||
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_CONTINUE_SHOPPING'); ?>
|
||||
</a>
|
||||
<?php else : ?>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><?php echo Text::_('COM_MOKODOLIJOOMSHOP_PRODUCT_LABEL'); ?></th>
|
||||
<th><?php echo Text::_('COM_MOKODOLIJOOMSHOP_PRODUCT_REF'); ?></th>
|
||||
<th class="text-end"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_PRICE'); ?></th>
|
||||
<th class="text-center"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_QUANTITY'); ?></th>
|
||||
<th class="text-end"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_SUBTOTAL'); ?></th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($this->items as $item) : ?>
|
||||
<?php $lineTotal = (float) $item['unit_price'] * (int) $item['quantity']; ?>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="<?php echo Route::_('index.php?option=com_mokodolijoomshop&view=product&id=' . (int) $item['dolibarr_product_id']); ?>">
|
||||
<?php echo htmlspecialchars($item['product_label']); ?>
|
||||
</a>
|
||||
</td>
|
||||
<td class="text-muted"><?php echo htmlspecialchars($item['product_ref']); ?></td>
|
||||
<td class="text-end"><?php echo number_format((float) $item['unit_price'], 2); ?> <?php echo $currency; ?></td>
|
||||
<td class="text-center">
|
||||
<form action="<?php echo Route::_('index.php?option=com_mokodolijoomshop&task=cart.update'); ?>" method="post" class="d-inline">
|
||||
<input type="hidden" name="cart_item_id" value="<?php echo (int) $item['id']; ?>" />
|
||||
<input type="number" name="quantity" value="<?php echo (int) $item['quantity']; ?>" min="1" max="999" class="form-control form-control-sm d-inline-block" style="width: 70px;" onchange="this.form.submit();" />
|
||||
<?php echo HTMLHelper::_('form.token'); ?>
|
||||
</form>
|
||||
</td>
|
||||
<td class="text-end fw-bold"><?php echo number_format($lineTotal, 2); ?> <?php echo $currency; ?></td>
|
||||
<td>
|
||||
<form action="<?php echo Route::_('index.php?option=com_mokodolijoomshop&task=cart.remove'); ?>" method="post" class="d-inline">
|
||||
<input type="hidden" name="cart_item_id" value="<?php echo (int) $item['id']; ?>" />
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger" title="<?php echo Text::_('JACTION_DELETE'); ?>">
|
||||
<span class="icon-trash" aria-hidden="true"></span>
|
||||
</button>
|
||||
<?php echo HTMLHelper::_('form.token'); ?>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="row justify-content-end">
|
||||
<div class="col-md-4">
|
||||
<table class="table table-sm">
|
||||
<tr>
|
||||
<td><?php echo Text::_('COM_MOKODOLIJOOMSHOP_SUBTOTAL'); ?></td>
|
||||
<td class="text-end"><?php echo number_format($this->totals['subtotal'], 2); ?> <?php echo $currency; ?></td>
|
||||
</tr>
|
||||
<?php if ($this->totals['tax'] > 0) : ?>
|
||||
<tr>
|
||||
<td><?php echo Text::_('COM_MOKODOLIJOOMSHOP_TAX'); ?></td>
|
||||
<td class="text-end"><?php echo number_format($this->totals['tax'], 2); ?> <?php echo $currency; ?></td>
|
||||
</tr>
|
||||
<?php endif; ?>
|
||||
<tr class="fw-bold fs-5">
|
||||
<td><?php echo Text::_('COM_MOKODOLIJOOMSHOP_TOTAL'); ?></td>
|
||||
<td class="text-end"><?php echo number_format($this->totals['total'], 2); ?> <?php echo $currency; ?></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<div class="d-grid gap-2">
|
||||
<a href="<?php echo Route::_('index.php?option=com_mokodolijoomshop&view=checkout'); ?>" class="btn btn-primary btn-lg">
|
||||
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_PROCEED_CHECKOUT'); ?>
|
||||
</a>
|
||||
<a href="<?php echo Route::_('index.php?option=com_mokodolijoomshop&view=products'); ?>" class="btn btn-outline-secondary">
|
||||
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_CONTINUE_SHOPPING'); ?>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<metadata>
|
||||
<layout title="COM_MOKODOLIJOOMSHOP_CART">
|
||||
<message>COM_MOKODOLIJOOMSHOP_CART_DESC</message>
|
||||
</layout>
|
||||
</metadata>
|
||||
@@ -0,0 +1,159 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoDoliJoomShop
|
||||
* @subpackage com_mokodolijoomshop
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\Router\Route;
|
||||
|
||||
/** @var \Moko\Component\MokoDoliJoomShop\Site\View\Category\HtmlView $this */
|
||||
|
||||
if ($this->category === null) :
|
||||
?>
|
||||
<div class="alert alert-warning"><?php echo Text::_('JGLOBAL_RESOURCE_NOT_FOUND'); ?></div>
|
||||
<?php return;
|
||||
endif;
|
||||
|
||||
$currency = htmlspecialchars($this->currency);
|
||||
$catLabel = htmlspecialchars($this->category['label'] ?? '');
|
||||
$catDesc = $this->category['description'] ?? '';
|
||||
$categoryId = (int) $this->category['id'];
|
||||
?>
|
||||
<div class="com-mokodolijoomshop-category">
|
||||
<nav aria-label="breadcrumb" class="mb-3">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item">
|
||||
<a href="<?php echo Route::_('index.php?option=com_mokodolijoomshop&view=products'); ?>">
|
||||
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_PRODUCTS'); ?>
|
||||
</a>
|
||||
</li>
|
||||
<?php foreach ($this->breadcrumbs as $i => $crumb) : ?>
|
||||
<?php if ($i === \count($this->breadcrumbs) - 1) : ?>
|
||||
<li class="breadcrumb-item active" aria-current="page"><?php echo htmlspecialchars($crumb['label']); ?></li>
|
||||
<?php else : ?>
|
||||
<li class="breadcrumb-item">
|
||||
<a href="<?php echo Route::_('index.php?option=com_mokodolijoomshop&view=category&id=' . (int) $crumb['id']); ?>">
|
||||
<?php echo htmlspecialchars($crumb['label']); ?>
|
||||
</a>
|
||||
</li>
|
||||
<?php endif; ?>
|
||||
<?php endforeach; ?>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<div class="row">
|
||||
<!-- Category sidebar -->
|
||||
<div class="col-md-3">
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title mb-0"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_CATEGORIES'); ?></h5>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<?php echo mokoshop_render_category_tree($this->categoryTree, $categoryId); ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Products grid -->
|
||||
<div class="col-md-9">
|
||||
<h2><?php echo $catLabel; ?></h2>
|
||||
<?php if ($catDesc) : ?>
|
||||
<p class="text-muted"><?php echo $catDesc; ?></p>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (empty($this->items)) : ?>
|
||||
<div class="alert alert-info"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_NO_PRODUCTS'); ?></div>
|
||||
<?php else : ?>
|
||||
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-4">
|
||||
<?php foreach ($this->items as $product) : ?>
|
||||
<?php
|
||||
$productId = (int) $product['id'];
|
||||
$label = htmlspecialchars($product['label'] ?? $product['ref'] ?? '');
|
||||
$price = (float) ($product['price_ttc'] ?? $product['price'] ?? 0);
|
||||
$detailLink = Route::_('index.php?option=com_mokodolijoomshop&view=product&id=' . $productId);
|
||||
$stockReel = (float) ($product['stock_reel'] ?? 0);
|
||||
$inStock = $stockReel > 0;
|
||||
?>
|
||||
<div class="col">
|
||||
<div class="card h-100 product-card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">
|
||||
<a href="<?php echo $detailLink; ?>"><?php echo $label; ?></a>
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-footer d-flex justify-content-between align-items-center">
|
||||
<span class="fw-bold"><?php echo number_format($price, 2); ?> <?php echo $currency; ?></span>
|
||||
<?php if ($inStock) : ?>
|
||||
<a href="<?php echo Route::_('index.php?option=com_mokodolijoomshop&task=cart.add&product_id=' . $productId); ?>" class="btn btn-sm btn-primary">
|
||||
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_ADD_TO_CART'); ?>
|
||||
</a>
|
||||
<?php else : ?>
|
||||
<span class="badge bg-secondary"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_OUT_OF_STOCK'); ?></span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<?php $baseUrl = 'index.php?option=com_mokodolijoomshop&view=category&id=' . $categoryId; ?>
|
||||
<nav class="mt-4" aria-label="<?php echo Text::_('JLIB_HTML_PAGINATION'); ?>">
|
||||
<ul class="pagination justify-content-center">
|
||||
<?php if ($this->page > 0) : ?>
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="<?php echo Route::_($baseUrl . '&page=' . ($this->page - 1)); ?>">« <?php echo Text::_('JPREV'); ?></a>
|
||||
</li>
|
||||
<?php endif; ?>
|
||||
<?php if (\count($this->items) >= $this->perPage) : ?>
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="<?php echo Route::_($baseUrl . '&page=' . ($this->page + 1)); ?>"><?php echo Text::_('JNEXT'); ?> »</a>
|
||||
</li>
|
||||
<?php endif; ?>
|
||||
</ul>
|
||||
</nav>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php
|
||||
// Render the category tree as nested lists
|
||||
if (!function_exists('mokoshop_render_category_tree'))
|
||||
{
|
||||
function mokoshop_render_category_tree(array $tree, int $activeId): string
|
||||
{
|
||||
if (empty($tree))
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
$html = '<ul class="list-group list-group-flush">';
|
||||
|
||||
foreach ($tree as $cat)
|
||||
{
|
||||
$id = (int) $cat['id'];
|
||||
$label = htmlspecialchars($cat['label'] ?? '');
|
||||
$active = ($id === $activeId) ? ' active' : '';
|
||||
$link = Route::_('index.php?option=com_mokodolijoomshop&view=category&id=' . $id);
|
||||
$html .= '<li class="list-group-item' . $active . '">';
|
||||
$html .= '<a href="' . $link . '">' . $label . '</a>';
|
||||
|
||||
if (!empty($cat['children']))
|
||||
{
|
||||
$html .= mokoshop_render_category_tree($cat['children'], $activeId);
|
||||
}
|
||||
|
||||
$html .= '</li>';
|
||||
}
|
||||
|
||||
$html .= '</ul>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
}
|
||||
?>
|
||||
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<metadata>
|
||||
<layout title="COM_MOKODOLIJOOMSHOP_CATEGORY">
|
||||
<message>COM_MOKODOLIJOOMSHOP_CATEGORY_DESC</message>
|
||||
</layout>
|
||||
<fields name="params">
|
||||
<fieldset name="request" label="COM_MOKODOLIJOOMSHOP_CATEGORY_OPTIONS">
|
||||
<field
|
||||
name="id"
|
||||
type="number"
|
||||
label="COM_MOKODOLIJOOMSHOP_CATEGORY_ID"
|
||||
description="COM_MOKODOLIJOOMSHOP_CATEGORY_ID_DESC"
|
||||
required="true"
|
||||
/>
|
||||
</fieldset>
|
||||
</fields>
|
||||
</metadata>
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoDoliJoomShop
|
||||
* @subpackage com_mokodolijoomshop
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\Router\Route;
|
||||
|
||||
/** @var \Moko\Component\MokoDoliJoomShop\Site\View\Checkout\HtmlView $this */
|
||||
|
||||
$order = $this->orderResult;
|
||||
?>
|
||||
<div class="com-mokodolijoomshop-checkout-confirmation">
|
||||
<?php if ($order === null) : ?>
|
||||
<div class="alert alert-warning">
|
||||
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_NO_ORDER_DATA'); ?>
|
||||
</div>
|
||||
<?php else : ?>
|
||||
<div class="text-center py-5">
|
||||
<span class="icon-check-circle text-success" style="font-size: 4rem;" aria-hidden="true"></span>
|
||||
<h2 class="mt-3"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_ORDER_PLACED'); ?></h2>
|
||||
|
||||
<div class="card mx-auto mt-4" style="max-width: 400px;">
|
||||
<div class="card-body">
|
||||
<dl class="row mb-0">
|
||||
<dt class="col-sm-5"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_ORDER_REF'); ?></dt>
|
||||
<dd class="col-sm-7 fw-bold"><?php echo htmlspecialchars($order['order_ref'] ?? ''); ?></dd>
|
||||
|
||||
<?php if (!empty($order['invoice_ref'])) : ?>
|
||||
<dt class="col-sm-5"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_ORDER_INVOICE_REF'); ?></dt>
|
||||
<dd class="col-sm-7"><?php echo htmlspecialchars($order['invoice_ref']); ?></dd>
|
||||
<?php endif; ?>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a href="<?php echo Route::_('index.php?option=com_mokodolijoomshop&view=products'); ?>" class="btn btn-primary mt-4">
|
||||
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_CONTINUE_SHOPPING'); ?>
|
||||
</a>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoDoliJoomShop
|
||||
* @subpackage com_mokodolijoomshop
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\HTML\HTMLHelper;
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\Router\Route;
|
||||
|
||||
/** @var \Moko\Component\MokoDoliJoomShop\Site\View\Checkout\HtmlView $this */
|
||||
|
||||
$currency = htmlspecialchars($this->currency);
|
||||
$isGuest = empty($this->user->id);
|
||||
$userName = $isGuest ? '' : htmlspecialchars($this->user->name);
|
||||
$userEmail = $isGuest ? '' : htmlspecialchars($this->user->email);
|
||||
?>
|
||||
<div class="com-mokodolijoomshop-checkout">
|
||||
<h2><?php echo Text::_('COM_MOKODOLIJOOMSHOP_CHECKOUT'); ?></h2>
|
||||
|
||||
<?php if (empty($this->cartItems)) : ?>
|
||||
<div class="alert alert-warning"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_CART_EMPTY'); ?></div>
|
||||
<?php return; ?>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($this->checkoutMode === 'registered' && $isGuest) : ?>
|
||||
<div class="alert alert-info">
|
||||
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_CHECKOUT_LOGIN_REQUIRED'); ?>
|
||||
<a href="<?php echo Route::_('index.php?option=com_users&view=login'); ?>" class="btn btn-primary ms-2">
|
||||
<?php echo Text::_('JLOGIN'); ?>
|
||||
</a>
|
||||
</div>
|
||||
<?php return; ?>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-7">
|
||||
<form action="<?php echo Route::_('index.php?option=com_mokodolijoomshop&task=checkout.process'); ?>" method="post" id="checkoutForm">
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
<h4 class="card-title mb-0"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_BILLING_DETAILS'); ?></h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label for="billing_name" class="form-label"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_BILLING_NAME'); ?> *</label>
|
||||
<input type="text" id="billing_name" name="billing_name" class="form-control" required value="<?php echo $userName; ?>" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="billing_email" class="form-label"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_BILLING_EMAIL'); ?> *</label>
|
||||
<input type="email" id="billing_email" name="billing_email" class="form-control" required value="<?php echo $userEmail; ?>" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="billing_address" class="form-label"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_BILLING_ADDRESS'); ?></label>
|
||||
<textarea id="billing_address" name="billing_address" class="form-control" rows="2"></textarea>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="billing_town" class="form-label"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_BILLING_TOWN'); ?></label>
|
||||
<input type="text" id="billing_town" name="billing_town" class="form-control" />
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="billing_zip" class="form-label"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_BILLING_ZIP'); ?></label>
|
||||
<input type="text" id="billing_zip" name="billing_zip" class="form-control" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="billing_phone" class="form-label"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_BILLING_PHONE'); ?></label>
|
||||
<input type="tel" id="billing_phone" name="billing_phone" class="form-control" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
<h4 class="card-title mb-0"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_ORDER_NOTES'); ?></h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<textarea name="order_notes" class="form-control" rows="3" placeholder="<?php echo Text::_('COM_MOKODOLIJOOMSHOP_ORDER_NOTES_PLACEHOLDER'); ?>"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-lg w-100">
|
||||
<span class="icon-cart" aria-hidden="true"></span>
|
||||
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_PLACE_ORDER'); ?>
|
||||
</button>
|
||||
|
||||
<?php echo HTMLHelper::_('form.token'); ?>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="col-md-5">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h4 class="card-title mb-0"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_ORDER_SUMMARY'); ?></h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<?php foreach ($this->cartItems as $item) : ?>
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<span>
|
||||
<?php echo htmlspecialchars($item['product_label']); ?>
|
||||
<small class="text-muted">× <?php echo (int) $item['quantity']; ?></small>
|
||||
</span>
|
||||
<span><?php echo number_format((float) $item['unit_price'] * (int) $item['quantity'], 2); ?> <?php echo $currency; ?></span>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
<hr>
|
||||
<div class="d-flex justify-content-between">
|
||||
<span><?php echo Text::_('COM_MOKODOLIJOOMSHOP_SUBTOTAL'); ?></span>
|
||||
<span><?php echo number_format($this->totals['subtotal'], 2); ?> <?php echo $currency; ?></span>
|
||||
</div>
|
||||
<?php if ($this->totals['tax'] > 0) : ?>
|
||||
<div class="d-flex justify-content-between">
|
||||
<span><?php echo Text::_('COM_MOKODOLIJOOMSHOP_TAX'); ?></span>
|
||||
<span><?php echo number_format($this->totals['tax'], 2); ?> <?php echo $currency; ?></span>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<div class="d-flex justify-content-between fw-bold fs-5 mt-2">
|
||||
<span><?php echo Text::_('COM_MOKODOLIJOOMSHOP_TOTAL'); ?></span>
|
||||
<span><?php echo number_format($this->totals['total'], 2); ?> <?php echo $currency; ?></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<metadata>
|
||||
<layout title="COM_MOKODOLIJOOMSHOP_CHECKOUT">
|
||||
<message>COM_MOKODOLIJOOMSHOP_CHECKOUT_DESC</message>
|
||||
</layout>
|
||||
</metadata>
|
||||
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoDoliJoomShop
|
||||
* @subpackage com_mokodolijoomshop
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\Router\Route;
|
||||
|
||||
/** @var \Moko\Component\MokoDoliJoomShop\Site\View\Orders\HtmlView $this */
|
||||
|
||||
$currency = htmlspecialchars($this->currency);
|
||||
?>
|
||||
<div class="com-mokodolijoomshop-orders">
|
||||
<h2><?php echo Text::_('COM_MOKODOLIJOOMSHOP_MY_ORDERS'); ?></h2>
|
||||
|
||||
<?php if ($this->isGuest) : ?>
|
||||
<div class="alert alert-info">
|
||||
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_ORDERS_LOGIN_REQUIRED'); ?>
|
||||
<a href="<?php echo Route::_('index.php?option=com_users&view=login'); ?>" class="btn btn-primary ms-2">
|
||||
<?php echo Text::_('JLOGIN'); ?>
|
||||
</a>
|
||||
</div>
|
||||
<?php return; ?>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($this->orderDetail !== null) : ?>
|
||||
<?php // Order detail view ?>
|
||||
<?php
|
||||
$order = $this->orderDetail;
|
||||
$ref = htmlspecialchars($order['ref'] ?? '');
|
||||
?>
|
||||
<nav aria-label="breadcrumb" class="mb-3">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item">
|
||||
<a href="<?php echo Route::_('index.php?option=com_mokodolijoomshop&view=orders'); ?>">
|
||||
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_MY_ORDERS'); ?>
|
||||
</a>
|
||||
</li>
|
||||
<li class="breadcrumb-item active"><?php echo $ref; ?></li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-header d-flex justify-content-between">
|
||||
<h4 class="mb-0"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_ORDER_REF'); ?>: <?php echo $ref; ?></h4>
|
||||
<span class="badge bg-info"><?php echo htmlspecialchars($order['statut_label'] ?? $order['status'] ?? ''); ?></span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><?php echo Text::_('COM_MOKODOLIJOOMSHOP_PRODUCT_LABEL'); ?></th>
|
||||
<th class="text-end"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_PRICE'); ?></th>
|
||||
<th class="text-center"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_QUANTITY'); ?></th>
|
||||
<th class="text-end"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_SUBTOTAL'); ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($order['lines'] ?? [] as $line) : ?>
|
||||
<tr>
|
||||
<td><?php echo htmlspecialchars($line['desc'] ?? $line['product_label'] ?? ''); ?></td>
|
||||
<td class="text-end"><?php echo number_format((float) ($line['subprice'] ?? 0), 2); ?> <?php echo $currency; ?></td>
|
||||
<td class="text-center"><?php echo (int) ($line['qty'] ?? 0); ?></td>
|
||||
<td class="text-end"><?php echo number_format((float) ($line['total_ttc'] ?? 0), 2); ?> <?php echo $currency; ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="fw-bold">
|
||||
<td colspan="3" class="text-end"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_TOTAL'); ?></td>
|
||||
<td class="text-end"><?php echo number_format((float) ($order['total_ttc'] ?? 0), 2); ?> <?php echo $currency; ?></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php elseif (empty($this->orders)) : ?>
|
||||
<div class="alert alert-info"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_NO_ORDERS'); ?></div>
|
||||
<a href="<?php echo Route::_('index.php?option=com_mokodolijoomshop&view=products'); ?>" class="btn btn-primary">
|
||||
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_CONTINUE_SHOPPING'); ?>
|
||||
</a>
|
||||
<?php else : ?>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><?php echo Text::_('COM_MOKODOLIJOOMSHOP_ORDER_DATE'); ?></th>
|
||||
<th><?php echo Text::_('COM_MOKODOLIJOOMSHOP_ORDER_REF'); ?></th>
|
||||
<th><?php echo Text::_('COM_MOKODOLIJOOMSHOP_ORDER_INVOICE_REF'); ?></th>
|
||||
<th class="text-end"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_TOTAL'); ?></th>
|
||||
<th><?php echo Text::_('COM_MOKODOLIJOOMSHOP_ORDER_STATUS'); ?></th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($this->orders as $order) : ?>
|
||||
<tr>
|
||||
<td><?php echo htmlspecialchars($order['created'] ?? ''); ?></td>
|
||||
<td>
|
||||
<a href="<?php echo Route::_('index.php?option=com_mokodolijoomshop&view=orders&order_id=' . (int) $order['dolibarr_order_id']); ?>">
|
||||
<?php echo htmlspecialchars($order['order_ref']); ?>
|
||||
</a>
|
||||
</td>
|
||||
<td><?php echo htmlspecialchars($order['invoice_ref'] ?? ''); ?></td>
|
||||
<td class="text-end"><?php echo number_format((float) ($order['total_ttc'] ?? 0), 2); ?> <?php echo $currency; ?></td>
|
||||
<td>
|
||||
<span class="badge bg-secondary"><?php echo htmlspecialchars($order['status'] ?? ''); ?></span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="<?php echo Route::_('index.php?option=com_mokodolijoomshop&view=orders&order_id=' . (int) $order['dolibarr_order_id']); ?>" class="btn btn-sm btn-outline-primary">
|
||||
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_VIEW_DETAIL'); ?>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<metadata>
|
||||
<layout title="COM_MOKODOLIJOOMSHOP_MY_ORDERS">
|
||||
<message>COM_MOKODOLIJOOMSHOP_MY_ORDERS_DESC</message>
|
||||
</layout>
|
||||
</metadata>
|
||||
@@ -0,0 +1,159 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoDoliJoomShop
|
||||
* @subpackage com_mokodolijoomshop
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\Router\Route;
|
||||
|
||||
/** @var \Moko\Component\MokoDoliJoomShop\Site\View\Product\HtmlView $this */
|
||||
|
||||
if ($this->item === null) :
|
||||
?>
|
||||
<div class="alert alert-warning"><?php echo Text::_('JGLOBAL_RESOURCE_NOT_FOUND'); ?></div>
|
||||
<?php return;
|
||||
endif;
|
||||
|
||||
$product = $this->item;
|
||||
$ref = htmlspecialchars($product['ref'] ?? '');
|
||||
$label = htmlspecialchars($product['label'] ?? $ref);
|
||||
$description = $product['description'] ?? '';
|
||||
$priceHT = (float) ($product['price'] ?? 0);
|
||||
$priceTTC = (float) ($product['price_ttc'] ?? $priceHT);
|
||||
$barcode = htmlspecialchars($product['barcode'] ?? '');
|
||||
$inStock = $this->stock > 0;
|
||||
$productId = (int) $product['id'];
|
||||
$addCartLink = Route::_('index.php?option=com_mokodolijoomshop&task=cart.add&product_id=' . $productId);
|
||||
?>
|
||||
|
||||
<?php // Schema.org Product JSON-LD ?>
|
||||
<script type="application/ld+json">
|
||||
<?php echo json_encode([
|
||||
'@context' => 'https://schema.org',
|
||||
'@type' => 'Product',
|
||||
'name' => $label,
|
||||
'description' => strip_tags($description),
|
||||
'sku' => $ref,
|
||||
'offers' => [
|
||||
'@type' => 'Offer',
|
||||
'price' => number_format($priceTTC, 2, '.', ''),
|
||||
'priceCurrency' => $this->currency,
|
||||
'availability' => $inStock ? 'https://schema.org/InStock' : 'https://schema.org/OutOfStock',
|
||||
],
|
||||
], JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT); ?>
|
||||
</script>
|
||||
|
||||
<div class="com-mokodolijoomshop-product">
|
||||
<nav aria-label="breadcrumb" class="mb-3">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item">
|
||||
<a href="<?php echo Route::_('index.php?option=com_mokodolijoomshop&view=products'); ?>">
|
||||
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_PRODUCTS'); ?>
|
||||
</a>
|
||||
</li>
|
||||
<li class="breadcrumb-item active" aria-current="page"><?php echo $label; ?></li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-5">
|
||||
<?php if (!empty($this->images)) : ?>
|
||||
<div class="product-gallery mb-3">
|
||||
<?php foreach ($this->images as $i => $image) : ?>
|
||||
<img
|
||||
src="<?php echo htmlspecialchars($image['url']); ?>"
|
||||
alt="<?php echo $label; ?>"
|
||||
class="img-fluid rounded <?php echo $i > 0 ? 'mt-2' : ''; ?>"
|
||||
loading="<?php echo $i === 0 ? 'eager' : 'lazy'; ?>"
|
||||
/>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php else : ?>
|
||||
<div class="bg-light rounded d-flex align-items-center justify-content-center" style="height: 300px;">
|
||||
<span class="text-muted"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_NO_IMAGE'); ?></span>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="col-md-7">
|
||||
<h1><?php echo $label; ?></h1>
|
||||
<p class="text-muted"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_PRODUCT_REF'); ?>: <?php echo $ref; ?></p>
|
||||
|
||||
<?php if ($barcode) : ?>
|
||||
<p class="small text-muted">Barcode: <?php echo $barcode; ?></p>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="mb-3">
|
||||
<span class="fs-3 fw-bold">
|
||||
<?php echo number_format($priceTTC, 2); ?> <?php echo htmlspecialchars($this->currency); ?>
|
||||
</span>
|
||||
<?php if ($priceHT !== $priceTTC) : ?>
|
||||
<br>
|
||||
<span class="text-muted small">
|
||||
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_PRICE_HT'); ?>: <?php echo number_format($priceHT, 2); ?> <?php echo htmlspecialchars($this->currency); ?>
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<?php if ($inStock) : ?>
|
||||
<span class="badge bg-success"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_IN_STOCK'); ?></span>
|
||||
<span class="text-muted small ms-2">(<?php echo (int) $this->stock; ?> <?php echo Text::_('COM_MOKODOLIJOOMSHOP_AVAILABLE'); ?>)</span>
|
||||
<?php else : ?>
|
||||
<span class="badge bg-danger"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_OUT_OF_STOCK'); ?></span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php if ($inStock) : ?>
|
||||
<form action="<?php echo $addCartLink; ?>" method="post" class="mb-4">
|
||||
<div class="input-group" style="max-width: 250px;">
|
||||
<label for="quantity" class="visually-hidden"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_QUANTITY'); ?></label>
|
||||
<input type="number" id="quantity" name="quantity" value="1" min="1" max="<?php echo (int) $this->stock; ?>" class="form-control" />
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<span class="icon-cart" aria-hidden="true"></span>
|
||||
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_ADD_TO_CART'); ?>
|
||||
</button>
|
||||
</div>
|
||||
<?php echo \Joomla\CMS\HTML\HTMLHelper::_('form.token'); ?>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($description) : ?>
|
||||
<div class="product-description mt-4">
|
||||
<h4><?php echo Text::_('COM_MOKODOLIJOOMSHOP_DESCRIPTION'); ?></h4>
|
||||
<div><?php echo $description; ?></div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if (!empty($this->related)) : ?>
|
||||
<div class="mt-5">
|
||||
<h3><?php echo Text::_('COM_MOKODOLIJOOMSHOP_RELATED_PRODUCTS'); ?></h3>
|
||||
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-4 g-3">
|
||||
<?php foreach (array_slice($this->related, 0, 4) as $rel) : ?>
|
||||
<div class="col">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title">
|
||||
<a href="<?php echo Route::_('index.php?option=com_mokodolijoomshop&view=product&id=' . (int) $rel['id']); ?>">
|
||||
<?php echo htmlspecialchars($rel['label'] ?? $rel['ref'] ?? ''); ?>
|
||||
</a>
|
||||
</h6>
|
||||
<span class="fw-bold">
|
||||
<?php echo number_format((float) ($rel['price_ttc'] ?? $rel['price'] ?? 0), 2); ?>
|
||||
<?php echo htmlspecialchars($this->currency); ?>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<metadata>
|
||||
<layout title="COM_MOKODOLIJOOMSHOP_PRODUCT_DETAIL">
|
||||
<message>COM_MOKODOLIJOOMSHOP_PRODUCT_DETAIL_DESC</message>
|
||||
</layout>
|
||||
<fields name="params">
|
||||
<fieldset name="request" label="COM_MOKODOLIJOOMSHOP_PRODUCT_OPTIONS">
|
||||
<field
|
||||
name="id"
|
||||
type="number"
|
||||
label="COM_MOKODOLIJOOMSHOP_PRODUCT_ID"
|
||||
description="COM_MOKODOLIJOOMSHOP_PRODUCT_ID_DESC"
|
||||
required="true"
|
||||
/>
|
||||
</fieldset>
|
||||
</fields>
|
||||
</metadata>
|
||||
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoDoliJoomShop
|
||||
* @subpackage com_mokodolijoomshop
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\Router\Route;
|
||||
|
||||
/** @var \Moko\Component\MokoDoliJoomShop\Site\View\Products\HtmlView $this */
|
||||
?>
|
||||
<div class="com-mokodolijoomshop-products">
|
||||
<h2><?php echo Text::_('COM_MOKODOLIJOOMSHOP_PRODUCTS'); ?></h2>
|
||||
|
||||
<?php if (!empty($this->categories)) : ?>
|
||||
<nav class="shop-categories mb-4">
|
||||
<a href="<?php echo Route::_('index.php?option=com_mokodolijoomshop&view=products'); ?>"
|
||||
class="btn btn-sm <?php echo $this->categoryId === 0 ? 'btn-primary' : 'btn-outline-secondary'; ?> me-1 mb-1">
|
||||
<?php echo Text::_('JALL'); ?>
|
||||
</a>
|
||||
<?php foreach ($this->categories as $cat) : ?>
|
||||
<a href="<?php echo Route::_('index.php?option=com_mokodolijoomshop&view=products&category_id=' . (int) $cat['id']); ?>"
|
||||
class="btn btn-sm <?php echo $this->categoryId === (int) $cat['id'] ? 'btn-primary' : 'btn-outline-secondary'; ?> me-1 mb-1">
|
||||
<?php echo htmlspecialchars($cat['label']); ?>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</nav>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (empty($this->items)) : ?>
|
||||
<div class="alert alert-info">
|
||||
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_NO_PRODUCTS'); ?>
|
||||
</div>
|
||||
<?php else : ?>
|
||||
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 row-cols-xl-4 g-4">
|
||||
<?php foreach ($this->items as $product) : ?>
|
||||
<?php
|
||||
$productId = (int) $product['id'];
|
||||
$ref = htmlspecialchars($product['ref'] ?? '');
|
||||
$label = htmlspecialchars($product['label'] ?? $ref);
|
||||
$price = (float) ($product['price_ttc'] ?? $product['price'] ?? 0);
|
||||
$description = htmlspecialchars(strip_tags($product['description'] ?? ''));
|
||||
$detailLink = Route::_('index.php?option=com_mokodolijoomshop&view=product&id=' . $productId);
|
||||
$addCartLink = Route::_('index.php?option=com_mokodolijoomshop&task=cart.add&product_id=' . $productId);
|
||||
$stockReel = (float) ($product['stock_reel'] ?? 0);
|
||||
$inStock = $stockReel > 0;
|
||||
?>
|
||||
<div class="col">
|
||||
<div class="card h-100 product-card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">
|
||||
<a href="<?php echo $detailLink; ?>"><?php echo $label; ?></a>
|
||||
</h5>
|
||||
<p class="card-text text-muted small"><?php echo $ref; ?></p>
|
||||
<?php if ($description) : ?>
|
||||
<p class="card-text"><?php echo mb_strimwidth($description, 0, 120, '…'); ?></p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div class="card-footer d-flex justify-content-between align-items-center">
|
||||
<span class="fw-bold fs-5">
|
||||
<?php echo number_format($price, 2); ?>
|
||||
<?php echo htmlspecialchars($this->currency); ?>
|
||||
</span>
|
||||
<?php if ($inStock) : ?>
|
||||
<a href="<?php echo $addCartLink; ?>" class="btn btn-sm btn-primary">
|
||||
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_ADD_TO_CART'); ?>
|
||||
</a>
|
||||
<?php else : ?>
|
||||
<span class="badge bg-secondary"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_OUT_OF_STOCK'); ?></span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<?php // Pagination ?>
|
||||
<?php $baseUrl = 'index.php?option=com_mokodolijoomshop&view=products'
|
||||
. ($this->categoryId ? '&category_id=' . $this->categoryId : ''); ?>
|
||||
<nav class="mt-4" aria-label="<?php echo Text::_('JLIB_HTML_PAGINATION'); ?>">
|
||||
<ul class="pagination justify-content-center">
|
||||
<?php if ($this->page > 0) : ?>
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="<?php echo Route::_($baseUrl . '&page=' . ($this->page - 1)); ?>">
|
||||
« <?php echo Text::_('JPREV'); ?>
|
||||
</a>
|
||||
</li>
|
||||
<?php endif; ?>
|
||||
<?php if (\count($this->items) >= $this->perPage) : ?>
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="<?php echo Route::_($baseUrl . '&page=' . ($this->page + 1)); ?>">
|
||||
<?php echo Text::_('JNEXT'); ?> »
|
||||
</a>
|
||||
</li>
|
||||
<?php endif; ?>
|
||||
</ul>
|
||||
</nav>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
@@ -0,0 +1,31 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<metadata>
|
||||
<layout title="COM_MOKODOLIJOOMSHOP_PRODUCTS">
|
||||
<message>COM_MOKODOLIJOOMSHOP_PRODUCTS_DESC</message>
|
||||
</layout>
|
||||
<fields name="params">
|
||||
<fieldset name="basic" label="COM_MOKODOLIJOOMSHOP_FIELDSET_SHOP">
|
||||
<field
|
||||
name="products_per_page"
|
||||
type="number"
|
||||
label="COM_MOKODOLIJOOMSHOP_FIELD_PRODUCTS_PER_PAGE"
|
||||
default=""
|
||||
min="1"
|
||||
max="100"
|
||||
hint="COM_MOKODOLIJOOMSHOP_USE_GLOBAL"
|
||||
/>
|
||||
<field
|
||||
name="sort_order"
|
||||
type="list"
|
||||
label="COM_MOKODOLIJOOMSHOP_SORT_BY"
|
||||
default=""
|
||||
>
|
||||
<option value="">COM_MOKODOLIJOOMSHOP_USE_GLOBAL</option>
|
||||
<option value="ref_asc">COM_MOKODOLIJOOMSHOP_SORT_REF_ASC</option>
|
||||
<option value="price_asc">COM_MOKODOLIJOOMSHOP_SORT_PRICE_ASC</option>
|
||||
<option value="price_desc">COM_MOKODOLIJOOMSHOP_SORT_PRICE_DESC</option>
|
||||
<option value="newest">COM_MOKODOLIJOOMSHOP_SORT_NEWEST</option>
|
||||
</field>
|
||||
</fieldset>
|
||||
</fields>
|
||||
</metadata>
|
||||
Reference in New Issue
Block a user