diff --git a/.gitea/ISSUE_TEMPLATE/adr.md b/.mokogitea/ISSUE_TEMPLATE/adr.md similarity index 100% rename from .gitea/ISSUE_TEMPLATE/adr.md rename to .mokogitea/ISSUE_TEMPLATE/adr.md diff --git a/.gitea/ISSUE_TEMPLATE/bug_report.md b/.mokogitea/ISSUE_TEMPLATE/bug_report.md similarity index 100% rename from .gitea/ISSUE_TEMPLATE/bug_report.md rename to .mokogitea/ISSUE_TEMPLATE/bug_report.md diff --git a/.gitea/ISSUE_TEMPLATE/config.yml b/.mokogitea/ISSUE_TEMPLATE/config.yml similarity index 100% rename from .gitea/ISSUE_TEMPLATE/config.yml rename to .mokogitea/ISSUE_TEMPLATE/config.yml diff --git a/.gitea/ISSUE_TEMPLATE/documentation.md b/.mokogitea/ISSUE_TEMPLATE/documentation.md similarity index 100% rename from .gitea/ISSUE_TEMPLATE/documentation.md rename to .mokogitea/ISSUE_TEMPLATE/documentation.md diff --git a/.gitea/ISSUE_TEMPLATE/feature_request.md b/.mokogitea/ISSUE_TEMPLATE/feature_request.md similarity index 100% rename from .gitea/ISSUE_TEMPLATE/feature_request.md rename to .mokogitea/ISSUE_TEMPLATE/feature_request.md diff --git a/.gitea/ISSUE_TEMPLATE/joomla_issue.md b/.mokogitea/ISSUE_TEMPLATE/joomla_issue.md similarity index 100% rename from .gitea/ISSUE_TEMPLATE/joomla_issue.md rename to .mokogitea/ISSUE_TEMPLATE/joomla_issue.md diff --git a/.gitea/ISSUE_TEMPLATE/question.md b/.mokogitea/ISSUE_TEMPLATE/question.md similarity index 100% rename from .gitea/ISSUE_TEMPLATE/question.md rename to .mokogitea/ISSUE_TEMPLATE/question.md diff --git a/.gitea/ISSUE_TEMPLATE/rfc.md b/.mokogitea/ISSUE_TEMPLATE/rfc.md similarity index 100% rename from .gitea/ISSUE_TEMPLATE/rfc.md rename to .mokogitea/ISSUE_TEMPLATE/rfc.md diff --git a/.gitea/ISSUE_TEMPLATE/security.md b/.mokogitea/ISSUE_TEMPLATE/security.md similarity index 100% rename from .gitea/ISSUE_TEMPLATE/security.md rename to .mokogitea/ISSUE_TEMPLATE/security.md diff --git a/.gitea/ISSUE_TEMPLATE/version.md b/.mokogitea/ISSUE_TEMPLATE/version.md similarity index 100% rename from .gitea/ISSUE_TEMPLATE/version.md rename to .mokogitea/ISSUE_TEMPLATE/version.md diff --git a/.gitea/manifest.xml b/.mokogitea/manifest.xml similarity index 62% rename from .gitea/manifest.xml rename to .mokogitea/manifest.xml index 23beccb..e0b254e 100644 --- a/.gitea/manifest.xml +++ b/.mokogitea/manifest.xml @@ -1,14 +1,9 @@ - - Template-Joomla + MokoDoliJoomShop MokoConsulting - Template repository for Joomla extensions (plugins, modules, components, templates) + Joomla storefront component backed by Dolibarr products and invoicing GNU General Public License v3 @@ -18,7 +13,7 @@ PHP - joomla-extension + joomla-component src/ diff --git a/.gitea/workflows/auto-release.yml b/.mokogitea/workflows/auto-release.yml similarity index 90% rename from .gitea/workflows/auto-release.yml rename to .mokogitea/workflows/auto-release.yml index 322e946..46ce4b2 100644 --- a/.gitea/workflows/auto-release.yml +++ b/.mokogitea/workflows/auto-release.yml @@ -4,8 +4,8 @@ # # FILE INFORMATION # DEFGROUP: Gitea.Workflow -# INGROUP: MokoStandards.Release -# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API +# 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 @@ -58,7 +58,7 @@ jobs: token: ${{ secrets.GA_TOKEN }} fetch-depth: 0 - - name: Setup MokoStandards tools + - name: Setup moko-platform tools env: MOKO_CLONE_TOKEN: ${{ secrets.GA_TOKEN }} MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting @@ -69,9 +69,9 @@ jobs: 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 - cd /tmp/mokostandards-api + "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 @@ -79,16 +79,16 @@ jobs: - 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) + # Read platform from manifest.xml element; fallback to generic + PLATFORM=$(sed -n 's/.*\([^<]*\)<\/platform>.*//p' .mokogitea/manifest.xml 2>/dev/null | head -1 | tr -d '[:space:]') [ -z "$PLATFORM" ] && PLATFORM="generic" echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT" echo "Platform detected: ${PLATFORM}" - # 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 '/dev/null | head -1) + # For packages: prefer pkg_*.xml in src/; fallback to any manifest + MANIFEST=$(find ./src -maxdepth 1 -name "pkg_*.xml" -exec grep -l '/dev/null | head -1) + [ -z "$MANIFEST" ] && MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" ! -path "*/packages/*" -exec grep -l '/dev/null | head -1) + [ -z "$MANIFEST" ] && MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '/dev/null | head -1) + MOD_FILE=$(find . -maxdepth 4 -name "mod*.class.php" ! -path "./.git/*" -exec grep -l 'extends DolibarrModules' {} \; 2>/dev/null | head -1) echo "manifest=${MANIFEST}" >> "$GITHUB_OUTPUT" echo "mod_file=${MOD_FILE}" >> "$GITHUB_OUTPUT" @@ -96,7 +96,7 @@ jobs: - name: "Step 1: Read version from README.md" id: version run: | - VERSION=$(php /tmp/mokostandards-api/cli/version_read.php --path . 2>/dev/null) + VERSION=$(php /tmp/moko-platform-api/cli/version_read.php --path . 2>/dev/null) if [ -z "$VERSION" ]; then echo "No VERSION in README.md — skipping release" echo "skip=true" >> "$GITHUB_OUTPUT" @@ -161,6 +161,17 @@ jobs: [ -n "$MANIFEST_VER" ] && sed -i "s|${MANIFEST_VER}|${VERSION}|" "$MANIFEST" sed -i "s|[^<]*|${TODAY}|" "$MANIFEST" fi + # For packages: also bump version in all sub-extension manifests + if [ -d "src/packages" ]; then + for SUB_MANIFEST in $(find src/packages -maxdepth 2 -name "*.xml" -exec grep -l '/dev/null); do + SUB_VER=$(sed -n 's/.*\([^<]*\)<\/version>.*/\1/p' "$SUB_MANIFEST" | head -1) + if [ -n "$SUB_VER" ]; then + sed -i "s|${SUB_VER}|${VERSION}|" "$SUB_MANIFEST" + sed -i "s|[^<]*|${TODAY}|" "$SUB_MANIFEST" + echo " Bumped sub-extension: $(basename $SUB_MANIFEST) ${SUB_VER} → ${VERSION}" + fi + done + fi ;; dolibarr) if [ -n "$MOD_FILE" ]; then @@ -187,7 +198,7 @@ jobs: git add -A git diff --cached --quiet || { git commit -m "chore(version): bump ${CURRENT} → ${VERSION} [skip ci]" - git push origin HEAD:dev 2>&1 || git push origin HEAD:main 2>&1 + git push origin HEAD:main 2>&1 } # Override version output for rest of pipeline @@ -337,7 +348,7 @@ jobs: steps.check.outputs.already_released != 'true' run: | VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}" - php /tmp/mokostandards-api/cli/version_set_platform.php \ + php /tmp/moko-platform-api/cli/version_set_platform.php \ --path . --version "$VERSION" --branch main # -- STEP 4: Update version badges ---------------------------------------- @@ -364,7 +375,8 @@ jobs: REPO="${{ github.repository }}" # -- Parse extension metadata from XML manifest ---------------- - MANIFEST=$(find . -maxdepth 2 -name "*.xml" -exec grep -l '/dev/null | head -1) + MANIFEST=$(find ./src -maxdepth 1 -name "pkg_*.xml" -exec grep -l '/dev/null | head -1) + [ -z "$MANIFEST" ] && MANIFEST=$(find . -maxdepth 2 -name "*.xml" ! -path "*/packages/*" -exec grep -l '/dev/null | head -1) if [ -z "$MANIFEST" ]; then echo "Warning: No Joomla XML manifest found — skipping updates.xml" >> $GITHUB_STEP_SUMMARY exit 0 @@ -561,7 +573,7 @@ jobs: fi [ -z "$EXT_NAME" ] && EXT_NAME="${GITEA_REPO}" - NOTES=$(php /tmp/mokostandards-api/cli/release_notes.php --path . --version "$VERSION" 2>/dev/null) + NOTES=$(php /tmp/moko-platform-api/cli/release_notes.php --path . --version "$VERSION" 2>/dev/null) [ -z "$NOTES" ] && NOTES="Release ${VERSION}" # Build release name: "Pretty Name VERSION (type_element-VERSION)" @@ -621,7 +633,8 @@ jobs: fi # Find extension element name from manifest - MANIFEST=$(find . -maxdepth 2 -name "*.xml" -exec grep -l '/dev/null | head -1 || true) + MANIFEST=$(find ./src -maxdepth 1 -name "pkg_*.xml" -exec grep -l '/dev/null | head -1) + [ -z "$MANIFEST" ] && MANIFEST=$(find . -maxdepth 2 -name "*.xml" ! -path "*/packages/*" -exec grep -l '/dev/null | head -1 || true) [ -z "$MANIFEST" ] && exit 0 # Reuse element from Step 5, with same fallback chain @@ -654,15 +667,39 @@ jobs: EXCLUDES=".ftpignore sftp-config* *.ppk *.pem *.key .env*" - # ZIP package - cd "$SOURCE_DIR" - zip -r "/tmp/${ZIP_NAME}" . -x $EXCLUDES - cd .. + if [ "$EXT_TYPE" = "package" ] && [ -d "${SOURCE_DIR}/packages" ]; then + echo "=== Building Joomla PACKAGE (multi-extension) ===" + PKG_STAGE=$(mktemp -d) - # tar.gz package - tar -czf "/tmp/${TAR_NAME}" -C "$SOURCE_DIR" \ - --exclude='.ftpignore' --exclude='sftp-config*' \ - --exclude='*.ppk' --exclude='*.pem' --exclude='*.key' --exclude='.env*' . + # ZIP each sub-extension + for ext_dir in "${SOURCE_DIR}"/packages/*/; do + [ ! -d "$ext_dir" ] && continue + SUB_NAME=$(basename "$ext_dir") + echo " Packaging sub-extension: ${SUB_NAME}" + (cd "$ext_dir" && zip -r "${PKG_STAGE}/${SUB_NAME}.zip" . -x $EXCLUDES) + done + + # Copy package-level files (manifest, script, etc.) + for f in "${SOURCE_DIR}"/*.xml "${SOURCE_DIR}"/*.php; do + [ -f "$f" ] && cp "$f" "${PKG_STAGE}/" + done + + # Create ZIP and tar.gz from staged package + (cd "$PKG_STAGE" && zip -r "/tmp/${ZIP_NAME}" .) + tar -czf "/tmp/${TAR_NAME}" -C "$PKG_STAGE" . + + rm -rf "$PKG_STAGE" + echo "Package contents built with sub-extension ZIPs" + else + # Standard extension: flat ZIP from src/ + cd "$SOURCE_DIR" + zip -r "/tmp/${ZIP_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*' . + fi ZIP_SIZE=$(stat -c%s "/tmp/${ZIP_NAME}" 2>/dev/null || stat -f%z "/tmp/${ZIP_NAME}" 2>/dev/null || echo "unknown") TAR_SIZE=$(stat -c%s "/tmp/${TAR_NAME}" 2>/dev/null || stat -f%z "/tmp/${TAR_NAME}" 2>/dev/null || echo "unknown") @@ -868,7 +905,7 @@ jobs: BRANCH="${{ steps.version.outputs.branch }}" GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}" - NOTES=$(php /tmp/mokostandards-api/cli/release_notes.php --path . --version "$VERSION" 2>/dev/null || true) + NOTES=$(php /tmp/moko-platform-api/cli/release_notes.php --path . --version "$VERSION" 2>/dev/null || true) [ -z "$NOTES" ] && NOTES="Release ${VERSION}" echo "$NOTES" > /tmp/release_notes.md @@ -941,30 +978,25 @@ jobs: done echo "Cleaned up ${DELETED} pre-release channel(s)" >> $GITHUB_STEP_SUMMARY - # -- STEP 11: Sync dev branch with main + version bump ---------------------- - - name: "Step 11: Merge main into dev (version bump lands on dev)" + # -- STEP 11: Reset dev branch from main ------------------------------------ + - name: "Step 11: Delete and recreate dev 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.GA_TOKEN }}" - # Merge main into dev so dev has the release + version bump + # 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}/merges" \ - -d "{\"base\":\"dev\",\"head\":\"main\",\"message\":\"chore: sync main into dev after release [skip ci]\"}" \ - 2>/dev/null && echo "Merged main into dev" + "${API_BASE}/branches" \ + -d '{"new_branch_name":"dev","old_branch_name":"main"}' 2>/dev/null && echo "Recreated dev from main" - # If dev doesn't exist, create it from main - if [ $? -ne 0 ]; then - 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 "Created dev from main" - fi - - echo "Dev branch synced with main (version bump on dev)" >> $GITHUB_STEP_SUMMARY + echo "Dev branch reset from main (keeps dev ahead after release)" >> $GITHUB_STEP_SUMMARY # -- Dolibarr post-release: Reset dev version ----------------------------- diff --git a/.gitea/workflows/cascade-dev.yml b/.mokogitea/workflows/cascade-dev.yml similarity index 100% rename from .gitea/workflows/cascade-dev.yml rename to .mokogitea/workflows/cascade-dev.yml diff --git a/.gitea/workflows/ci-joomla.yml b/.mokogitea/workflows/ci-joomla.yml similarity index 100% rename from .gitea/workflows/ci-joomla.yml rename to .mokogitea/workflows/ci-joomla.yml diff --git a/.gitea/workflows/cleanup.yml b/.mokogitea/workflows/cleanup.yml similarity index 100% rename from .gitea/workflows/cleanup.yml rename to .mokogitea/workflows/cleanup.yml diff --git a/.gitea/workflows/deploy-manual.yml b/.mokogitea/workflows/deploy-manual.yml similarity index 100% rename from .gitea/workflows/deploy-manual.yml rename to .mokogitea/workflows/deploy-manual.yml diff --git a/.gitea/workflows/gitleaks.yml b/.mokogitea/workflows/gitleaks.yml similarity index 100% rename from .gitea/workflows/gitleaks.yml rename to .mokogitea/workflows/gitleaks.yml diff --git a/.gitea/workflows/notify.yml b/.mokogitea/workflows/notify.yml similarity index 100% rename from .gitea/workflows/notify.yml rename to .mokogitea/workflows/notify.yml diff --git a/.gitea/workflows/pr-check.yml b/.mokogitea/workflows/pr-check.yml similarity index 100% rename from .gitea/workflows/pr-check.yml rename to .mokogitea/workflows/pr-check.yml diff --git a/.gitea/workflows/pre-release.yml b/.mokogitea/workflows/pre-release.yml similarity index 82% rename from .gitea/workflows/pre-release.yml rename to .mokogitea/workflows/pre-release.yml index c51bea8..3ddd113 100644 --- a/.gitea/workflows/pre-release.yml +++ b/.mokogitea/workflows/pre-release.yml @@ -4,7 +4,7 @@ # # FILE INFORMATION # DEFGROUP: Gitea.Workflow -# INGROUP: MokoStandards.Release +# INGROUP: moko-platform.Release # REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards # PATH: /templates/workflows/universal/pre-release.yml.template # VERSION: 05.00.00 @@ -55,15 +55,14 @@ jobs: - 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) + tr -d '[:space:]')| tr -d '[:space:]') [ -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 '/dev/null | head -1) + # For packages: prefer pkg_*.xml in src/; fallback to any manifest + MANIFEST=$(find ./src -maxdepth 1 -name "pkg_*.xml" -exec grep -l '/dev/null | head -1) + [ -z "$MANIFEST" ] && MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" ! -path "*/packages/*" -exec grep -l '/dev/null | head -1) + [ -z "$MANIFEST" ] && MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '/dev/null | head -1) + MOD_FILE=$(find . -maxdepth 4 -name "mod*.class.php" ! -path "./.git/*" -exec grep -l 'extends DolibarrModules' {} \; 2>/dev/null | head -1) echo "manifest=${MANIFEST}" >> "$GITHUB_OUTPUT" echo "mod_file=${MOD_FILE}" >> "$GITHUB_OUTPUT" @@ -120,6 +119,17 @@ jobs: sed -i "s|${MANIFEST_VER}|${VERSION}|" "$MANIFEST" sed -i "s|[^<]*|${TODAY}|" "$MANIFEST" fi + # For packages: also bump version in all sub-extension manifests + if [ -d "src/packages" ]; then + for SUB_MANIFEST in $(find src/packages -maxdepth 2 -name "*.xml" -exec grep -l '/dev/null); do + SUB_VER=$(sed -n 's/.*\([^<]*\)<\/version>.*/\1/p' "$SUB_MANIFEST" | head -1) + if [ -n "$SUB_VER" ]; then + sed -i "s|${SUB_VER}|${VERSION}|" "$SUB_MANIFEST" + sed -i "s|[^<]*|${TODAY}|" "$SUB_MANIFEST" + echo " Bumped sub-extension: $(basename $SUB_MANIFEST) ${SUB_VER} → ${VERSION}" + fi + done + fi ;; dolibarr) if [ -n "$MOD_FILE" ]; then @@ -191,17 +201,49 @@ jobs: exit 1 fi + MANIFEST="${{ steps.meta.outputs.manifest }}" + EXT_TYPE="" + if [ -n "$MANIFEST" ]; then + EXT_TYPE=$(sed -n 's/.*]*type="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1) + fi + + EXCLUDES="sftp-config* .ftpignore *.ppk *.pem *.key .env* *.local .build-trigger" + 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/ + + if [ "$EXT_TYPE" = "package" ] && [ -d "${SOURCE_DIR}/packages" ]; then + echo "=== Building Joomla PACKAGE (multi-extension) ===" + + # 1) ZIP each sub-extension in src/packages/ + for ext_dir in "${SOURCE_DIR}"/packages/*/; do + [ ! -d "$ext_dir" ] && continue + EXT_NAME=$(basename "$ext_dir") + echo " Packaging sub-extension: ${EXT_NAME}" + cd "$ext_dir" + zip -r "../../build/package/${EXT_NAME}.zip" . -x $EXCLUDES + cd "$OLDPWD" + done + + # 2) Copy package-level files (manifest, script, etc.) + for f in "${SOURCE_DIR}"/*.xml "${SOURCE_DIR}"/*.php; do + [ -f "$f" ] && cp "$f" build/package/ + done + + echo "Package contents:" + ls -la build/package/ + else + echo "=== Building standard Joomla extension ===" + rsync -a \ + --exclude='sftp-config*' \ + --exclude='.ftpignore' \ + --exclude='*.ppk' \ + --exclude='*.pem' \ + --exclude='*.key' \ + --exclude='.env*' \ + --exclude='*.local' \ + --exclude='.build-trigger' \ + "${SOURCE_DIR}/" build/package/ + fi - name: Create ZIP id: zip diff --git a/.gitea/workflows/repo-health.yml b/.mokogitea/workflows/repo-health.yml similarity index 100% rename from .gitea/workflows/repo-health.yml rename to .mokogitea/workflows/repo-health.yml diff --git a/.gitea/workflows/security-audit.yml b/.mokogitea/workflows/security-audit.yml similarity index 100% rename from .gitea/workflows/security-audit.yml rename to .mokogitea/workflows/security-audit.yml diff --git a/.gitea/workflows/update-server.yml b/.mokogitea/workflows/update-server.yml similarity index 100% rename from .gitea/workflows/update-server.yml rename to .mokogitea/workflows/update-server.yml diff --git a/CHANGELOG.md b/CHANGELOG.md index 8de0944..d3a0167 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/CLAUDE.md b/CLAUDE.md index 1230263..083544f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,6 +56,8 @@ make clean # Clean build artifacts ## Rules +- **Workflow directory**: `.mokogitea/` (not `.gitea/` or `.github/`) + - **Never commit** `.claude/`, `.mcp.json`, `TODO.md`, or `*.min.css`/`*.min.js` - **Attribution**: use `Authored-by: Moko Consulting` in commits - **Branch strategy**: develop on `dev/`, merge to `main` for release diff --git a/media/com_mokodolijoomshop/css/storefront.css b/media/com_mokodolijoomshop/css/storefront.css new file mode 100644 index 0000000..507f89a --- /dev/null +++ b/media/com_mokodolijoomshop/css/storefront.css @@ -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; + } +} diff --git a/src/admin/language/en-GB/com_mokodolijoomshop.ini b/src/admin/language/en-GB/com_mokodolijoomshop.ini index 40ed2bb..9661b53 100644 --- a/src/admin/language/en-GB/com_mokodolijoomshop.ini +++ b/src/admin/language/en-GB/com_mokodolijoomshop.ini @@ -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" diff --git a/src/admin/sql/install.mysql.sql b/src/admin/sql/install.mysql.sql index 7057556..dba5a82 100644 --- a/src/admin/sql/install.mysql.sql +++ b/src/admin/sql/install.mysql.sql @@ -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, diff --git a/src/admin/sql/uninstall.mysql.sql b/src/admin/sql/uninstall.mysql.sql index 2ecb736..11b4bb4 100644 --- a/src/admin/sql/uninstall.mysql.sql +++ b/src/admin/sql/uninstall.mysql.sql @@ -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`; diff --git a/src/admin/src/Helper/DolibarrClient.php b/src/admin/src/Helper/DolibarrClient.php index 86a9436..a90165a 100644 --- a/src/admin/src/Helper/DolibarrClient.php +++ b/src/admin/src/Helper/DolibarrClient.php @@ -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)) diff --git a/src/admin/src/Model/DashboardModel.php b/src/admin/src/Model/DashboardModel.php new file mode 100644 index 0000000..1e84b9f --- /dev/null +++ b/src/admin/src/Model/DashboardModel.php @@ -0,0 +1,158 @@ +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; + } +} diff --git a/src/admin/src/Model/OrdersModel.php b/src/admin/src/Model/OrdersModel.php new file mode 100644 index 0000000..597dd83 --- /dev/null +++ b/src/admin/src/Model/OrdersModel.php @@ -0,0 +1,168 @@ +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; + } +} diff --git a/src/admin/src/Service/CacheService.php b/src/admin/src/Service/CacheService.php new file mode 100644 index 0000000..82f38bb --- /dev/null +++ b/src/admin/src/Service/CacheService.php @@ -0,0 +1,143 @@ +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); + } +} diff --git a/src/admin/src/Service/CustomerSyncService.php b/src/admin/src/Service/CustomerSyncService.php new file mode 100644 index 0000000..6dc5ef1 --- /dev/null +++ b/src/admin/src/Service/CustomerSyncService.php @@ -0,0 +1,259 @@ +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(); + } +} diff --git a/src/admin/src/Service/EmailService.php b/src/admin/src/Service/EmailService.php new file mode 100644 index 0000000..2f10da3 --- /dev/null +++ b/src/admin/src/Service/EmailService.php @@ -0,0 +1,244 @@ +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 .= '

' . Text::sprintf('COM_MOKODOLIJOOMSHOP_EMAIL_GREETING', htmlspecialchars($name)) . '

'; + $html .= '

' . Text::_('COM_MOKODOLIJOOMSHOP_EMAIL_ORDER_CONFIRMED') . '

'; + $html .= ''; + $html .= ''; + + if ($invoiceRef) + { + $html .= ''; + } + + $html .= '
' . Text::_('COM_MOKODOLIJOOMSHOP_ORDER_REF') . ':' . $orderRef . '
' . Text::_('COM_MOKODOLIJOOMSHOP_ORDER_INVOICE_REF') . ':' . $invoiceRef . '
'; + + // Items table + $html .= ''; + $html .= ''; + $html .= ''; + $html .= ''; + $html .= ''; + $html .= ''; + + foreach ($items as $item) + { + $lineTotal = (float) $item['unit_price'] * (int) $item['quantity']; + $html .= ''; + $html .= ''; + $html .= ''; + $html .= ''; + $html .= ''; + } + + $html .= '
' . Text::_('COM_MOKODOLIJOOMSHOP_PRODUCT_LABEL') . '' . Text::_('COM_MOKODOLIJOOMSHOP_QUANTITY') . '' . Text::_('COM_MOKODOLIJOOMSHOP_SUBTOTAL') . '
' . htmlspecialchars($item['product_label']) . '' . (int) $item['quantity'] . '' . number_format($lineTotal, 2) . ' ' . $currency . '
'; + + // Totals + $html .= ''; + $html .= ''; + + if ($totals['tax'] > 0) + { + $html .= ''; + } + + $html .= ''; + $html .= '
' . Text::_('COM_MOKODOLIJOOMSHOP_SUBTOTAL') . '' . number_format($totals['subtotal'], 2) . ' ' . $currency . '
' . Text::_('COM_MOKODOLIJOOMSHOP_TAX') . '' . number_format($totals['tax'], 2) . ' ' . $currency . '
' . Text::_('COM_MOKODOLIJOOMSHOP_TOTAL') . '' . number_format($totals['total'], 2) . ' ' . $currency . '
'; + + $html .= '

' . Text::sprintf('COM_MOKODOLIJOOMSHOP_EMAIL_FOOTER', htmlspecialchars($siteName)) . '

'; + $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 .= '

' . Text::_('COM_MOKODOLIJOOMSHOP_EMAIL_NEW_ORDER') . '

'; + $html .= '

' . Text::_('COM_MOKODOLIJOOMSHOP_ORDER_REF') . ': ' . htmlspecialchars($order['order_ref'] ?? '') . '

'; + $html .= '

' . Text::_('COM_MOKODOLIJOOMSHOP_CUSTOMER_NAME') . ': ' . htmlspecialchars($customerName) . ' (' . htmlspecialchars($customerEmail) . ')

'; + $html .= '

' . Text::_('COM_MOKODOLIJOOMSHOP_TOTAL') . ': ' . number_format($totals['total'], 2) . ' ' . $currency . '

'; + $html .= '

' . Text::_('COM_MOKODOLIJOOMSHOP_QUANTITY') . ': ' . \count($items) . ' item(s)

'; + $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; + } + } +} diff --git a/src/admin/src/Service/ImageService.php b/src/admin/src/Service/ImageService.php new file mode 100644 index 0000000..9994d35 --- /dev/null +++ b/src/admin/src/Service/ImageService.php @@ -0,0 +1,239 @@ +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; + } +} diff --git a/src/admin/src/Service/OrderService.php b/src/admin/src/Service/OrderService.php new file mode 100644 index 0000000..dc6773c --- /dev/null +++ b/src/admin/src/Service/OrderService.php @@ -0,0 +1,224 @@ +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(); + } +} diff --git a/src/admin/src/Service/WebhookService.php b/src/admin/src/Service/WebhookService.php new file mode 100644 index 0000000..477fdf7 --- /dev/null +++ b/src/admin/src/Service/WebhookService.php @@ -0,0 +1,243 @@ +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(); + } +} diff --git a/src/admin/src/Table/CartTable.php b/src/admin/src/Table/CartTable.php new file mode 100644 index 0000000..1e6ca4d --- /dev/null +++ b/src/admin/src/Table/CartTable.php @@ -0,0 +1,65 @@ +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; + } +} diff --git a/src/admin/src/Table/CustomerTable.php b/src/admin/src/Table/CustomerTable.php new file mode 100644 index 0000000..e0927a7 --- /dev/null +++ b/src/admin/src/Table/CustomerTable.php @@ -0,0 +1,60 @@ +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; + } +} diff --git a/src/admin/src/Table/OrderTable.php b/src/admin/src/Table/OrderTable.php new file mode 100644 index 0000000..49a8f47 --- /dev/null +++ b/src/admin/src/Table/OrderTable.php @@ -0,0 +1,53 @@ +dolibarr_order_id)) + { + $this->setError('Order mapping must have a Dolibarr order ID.'); + + return false; + } + + return true; + } +} diff --git a/src/admin/src/View/Dashboard/HtmlView.php b/src/admin/src/View/Dashboard/HtmlView.php index d8d6275..eaa49ba 100644 --- a/src/admin/src/View/Dashboard/HtmlView.php +++ b/src/admin/src/View/Dashboard/HtmlView.php @@ -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'); diff --git a/src/admin/src/View/Orders/HtmlView.php b/src/admin/src/View/Orders/HtmlView.php new file mode 100644 index 0000000..e04e2a7 --- /dev/null +++ b/src/admin/src/View/Orders/HtmlView.php @@ -0,0 +1,56 @@ +getModel(); + $params = ComponentHelper::getParams('com_mokodolijoomshop'); + $this->items = $model->getItems(); + $this->currency = $params->get('currency', 'USD'); + + ToolbarHelper::title('DoliJoom Shop: Orders'); + + parent::display($tpl); + } +} diff --git a/src/admin/tmpl/dashboard/default.php b/src/admin/tmpl/dashboard/default.php index a343d94..e2c64cb 100644 --- a/src/admin/tmpl/dashboard/default.php +++ b/src/admin/tmpl/dashboard/default.php @@ -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); ?>
-
+ +
-
+

connectionOk) : ?> -
+
+ +

+ : + +

+ +

+ : + + +   + + +

- +
+ +
+ + : + +
+
-
+
-

Quick Actions

+

+ + connectionOk) : ?> + +
+
+
+
+
+

productCount; ?>

+
+
+
+
+
+
+
+

orderCount; ?>

+
+
+
+
+
+
+
+

customerCount; ?>

+
+
+
+
+
+
+
+

revenue['month'] ?? 0, 2); ?>

+
+
+
+
+ + +
+
+
+
+

+
+
+ + + + + + + + + + + + + +
revenue['today'] ?? 0, 2); ?>
revenue['week'] ?? 0, 2); ?>
revenue['month'] ?? 0, 2); ?>
+
+
+
+ +
+
+
+

+
+
+ recentOrders)) : ?> +

+ + + + + + + + + + + + recentOrders as $order) : ?> + + + + + + + + +
+ +
+
+
+
+
diff --git a/src/admin/tmpl/orders/default.php b/src/admin/tmpl/orders/default.php new file mode 100644 index 0000000..d85d507 --- /dev/null +++ b/src/admin/tmpl/orders/default.php @@ -0,0 +1,93 @@ +currency); +?> +
+ + + + +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ + items)) : ?> +
+ + + + + + + + + + + + + + + items as $order) : ?> + 'bg-success', + 'shipped' => 'bg-info', + 'delivered' => 'bg-primary', + 'cancelled' => 'bg-danger', + default => 'bg-secondary', + }; + ?> + + + + + + + + + + + +
+ +
diff --git a/src/mokodolijoomshop.xml b/src/mokodolijoomshop.xml index 6a2c626..6ce4583 100644 --- a/src/mokodolijoomshop.xml +++ b/src/mokodolijoomshop.xml @@ -37,8 +37,14 @@ + + css + images + + language + services src tmpl @@ -127,10 +133,77 @@ + + + + + + + + + + + + +
+ + + + + +
+ +
+
+ + + + + + + + + https://git.mokoconsulting.tech/MokoConsulting/MokoDoliJoomShop/raw/branch/main/updates.xml diff --git a/src/site/language/en-GB/com_mokodolijoomshop.ini b/src/site/language/en-GB/com_mokodolijoomshop.ini index 19d3a91..7377dd2 100644 --- a/src/site/language/en-GB/com_mokodolijoomshop.ini +++ b/src/site/language/en-GB/com_mokodolijoomshop.ini @@ -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" diff --git a/src/site/services/provider.php b/src/site/services/provider.php index c168b90..59e3054 100644 --- a/src/site/services/provider.php +++ b/src/site/services/provider.php @@ -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. diff --git a/src/site/src/Controller/CartController.php b/src/site/src/Controller/CartController.php new file mode 100644 index 0000000..2b2eb21 --- /dev/null +++ b/src/site/src/Controller/CartController.php @@ -0,0 +1,104 @@ +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)); + } +} diff --git a/src/site/src/Controller/CheckoutController.php b/src/site/src/Controller/CheckoutController.php new file mode 100644 index 0000000..204b680 --- /dev/null +++ b/src/site/src/Controller/CheckoutController.php @@ -0,0 +1,102 @@ +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)); + } +} diff --git a/src/site/src/Controller/InvoiceController.php b/src/site/src/Controller/InvoiceController.php new file mode 100644 index 0000000..237e926 --- /dev/null +++ b/src/site/src/Controller/InvoiceController.php @@ -0,0 +1,121 @@ +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(); + } +} diff --git a/src/site/src/Controller/SearchController.php b/src/site/src/Controller/SearchController.php new file mode 100644 index 0000000..ec3dd9d --- /dev/null +++ b/src/site/src/Controller/SearchController.php @@ -0,0 +1,111 @@ +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(); + } +} diff --git a/src/site/src/Helper/CouponHelper.php b/src/site/src/Helper/CouponHelper.php new file mode 100644 index 0000000..07a36a7 --- /dev/null +++ b/src/site/src/Helper/CouponHelper.php @@ -0,0 +1,103 @@ +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); + } +} diff --git a/src/site/src/Helper/StockHelper.php b/src/site/src/Helper/StockHelper.php new file mode 100644 index 0000000..c8de71d --- /dev/null +++ b/src/site/src/Helper/StockHelper.php @@ -0,0 +1,116 @@ +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 '' . $text . ''; + } + + /** + * 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); + } +} diff --git a/src/site/src/Helper/TaxHelper.php b/src/site/src/Helper/TaxHelper.php new file mode 100644 index 0000000..daf538e --- /dev/null +++ b/src/site/src/Helper/TaxHelper.php @@ -0,0 +1,159 @@ +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 + . ' (' . number_format($priceHT, 2) . ' HT)'; + + 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, + ]; + } +} diff --git a/src/site/src/Helper/VariantHelper.php b/src/site/src/Helper/VariantHelper.php new file mode 100644 index 0000000..8a262c0 --- /dev/null +++ b/src/site/src/Helper/VariantHelper.php @@ -0,0 +1,171 @@ +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; + } +} diff --git a/src/site/src/Model/AddressModel.php b/src/site/src/Model/AddressModel.php new file mode 100644 index 0000000..f95bec4 --- /dev/null +++ b/src/site/src/Model/AddressModel.php @@ -0,0 +1,176 @@ +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; + } +} diff --git a/src/site/src/Model/CartModel.php b/src/site/src/Model/CartModel.php new file mode 100644 index 0000000..8986458 --- /dev/null +++ b/src/site/src/Model/CartModel.php @@ -0,0 +1,385 @@ +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())); + } + } +} diff --git a/src/site/src/Model/CategoryModel.php b/src/site/src/Model/CategoryModel.php new file mode 100644 index 0000000..fe221d4 --- /dev/null +++ b/src/site/src/Model/CategoryModel.php @@ -0,0 +1,190 @@ +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; + } +} diff --git a/src/site/src/Model/CheckoutModel.php b/src/site/src/Model/CheckoutModel.php new file mode 100644 index 0000000..545f393 --- /dev/null +++ b/src/site/src/Model/CheckoutModel.php @@ -0,0 +1,137 @@ +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, + ]; + } +} diff --git a/src/site/src/Model/OrdersModel.php b/src/site/src/Model/OrdersModel.php new file mode 100644 index 0000000..0c1b765 --- /dev/null +++ b/src/site/src/Model/OrdersModel.php @@ -0,0 +1,194 @@ +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'; + } +} diff --git a/src/site/src/Model/ProductModel.php b/src/site/src/Model/ProductModel.php new file mode 100644 index 0000000..6203f23 --- /dev/null +++ b/src/site/src/Model/ProductModel.php @@ -0,0 +1,179 @@ +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; + })); + } +} diff --git a/src/site/src/Model/ProductsModel.php b/src/site/src/Model/ProductsModel.php new file mode 100644 index 0000000..c2a43be --- /dev/null +++ b/src/site/src/Model/ProductsModel.php @@ -0,0 +1,137 @@ +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); + } +} diff --git a/src/site/src/Model/WishlistModel.php b/src/site/src/Model/WishlistModel.php new file mode 100644 index 0000000..f5ffe05 --- /dev/null +++ b/src/site/src/Model/WishlistModel.php @@ -0,0 +1,172 @@ +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(); + } +} diff --git a/src/site/src/Service/Router.php b/src/site/src/Service/Router.php new file mode 100644 index 0000000..ad16bea --- /dev/null +++ b/src/site/src/Service/Router.php @@ -0,0 +1,187 @@ + 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; + } +} diff --git a/src/site/src/View/Cart/HtmlView.php b/src/site/src/View/Cart/HtmlView.php new file mode 100644 index 0000000..b22848b --- /dev/null +++ b/src/site/src/View/Cart/HtmlView.php @@ -0,0 +1,61 @@ +getModel(); + $params = ComponentHelper::getParams('com_mokodolijoomshop'); + + $this->items = $model->getItems(); + $this->totals = $model->getTotals(); + $this->currency = $params->get('currency', 'USD'); + + parent::display($tpl); + } +} diff --git a/src/site/src/View/Category/HtmlView.php b/src/site/src/View/Category/HtmlView.php new file mode 100644 index 0000000..4c6f465 --- /dev/null +++ b/src/site/src/View/Category/HtmlView.php @@ -0,0 +1,99 @@ +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); + } +} diff --git a/src/site/src/View/Checkout/HtmlView.php b/src/site/src/View/Checkout/HtmlView.php new file mode 100644 index 0000000..2d85495 --- /dev/null +++ b/src/site/src/View/Checkout/HtmlView.php @@ -0,0 +1,94 @@ +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); + } +} diff --git a/src/site/src/View/Orders/HtmlView.php b/src/site/src/View/Orders/HtmlView.php new file mode 100644 index 0000000..125d59b --- /dev/null +++ b/src/site/src/View/Orders/HtmlView.php @@ -0,0 +1,82 @@ +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); + } +} diff --git a/src/site/src/View/Product/HtmlView.php b/src/site/src/View/Product/HtmlView.php new file mode 100644 index 0000000..1e89fef --- /dev/null +++ b/src/site/src/View/Product/HtmlView.php @@ -0,0 +1,85 @@ +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); + } +} diff --git a/src/site/src/View/Products/HtmlView.php b/src/site/src/View/Products/HtmlView.php new file mode 100644 index 0000000..8ddf969 --- /dev/null +++ b/src/site/src/View/Products/HtmlView.php @@ -0,0 +1,84 @@ +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); + } +} diff --git a/src/site/tmpl/cart/default.php b/src/site/tmpl/cart/default.php new file mode 100644 index 0000000..5442219 --- /dev/null +++ b/src/site/tmpl/cart/default.php @@ -0,0 +1,106 @@ +currency); +?> +
+

+ + items)) : ?> +
+ +
+ + + + +
+ + + + + + + + + + + + + items as $item) : ?> + + + + + + + + + + + +
+ + + + +
+ + + +
+
+
+ + + +
+
+
+ +
+
+ + + + + + totals['tax'] > 0) : ?> + + + + + + + + + +
totals['subtotal'], 2); ?>
totals['tax'], 2); ?>
totals['total'], 2); ?>
+ + +
+
+ +
diff --git a/src/site/tmpl/cart/default.xml b/src/site/tmpl/cart/default.xml new file mode 100644 index 0000000..39ca314 --- /dev/null +++ b/src/site/tmpl/cart/default.xml @@ -0,0 +1,6 @@ + + + + COM_MOKODOLIJOOMSHOP_CART_DESC + + diff --git a/src/site/tmpl/category/default.php b/src/site/tmpl/category/default.php new file mode 100644 index 0000000..9a8b866 --- /dev/null +++ b/src/site/tmpl/category/default.php @@ -0,0 +1,159 @@ +category === null) : +?> +
+ currency); +$catLabel = htmlspecialchars($this->category['label'] ?? ''); +$catDesc = $this->category['description'] ?? ''; +$categoryId = (int) $this->category['id']; +?> +
+ + +
+ +
+
+
+
+
+
+ categoryTree, $categoryId); ?> +
+
+
+ + +
+

+ +

+ + + items)) : ?> +
+ +
+ items as $product) : ?> + 0; + ?> +
+
+
+
+ +
+
+ +
+
+ +
+ + + + +
+
+
+ +'; + + 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 .= '
  • '; + $html .= '' . $label . ''; + + if (!empty($cat['children'])) + { + $html .= mokoshop_render_category_tree($cat['children'], $activeId); + } + + $html .= '
  • '; + } + + $html .= ''; + + return $html; + } +} +?> diff --git a/src/site/tmpl/category/default.xml b/src/site/tmpl/category/default.xml new file mode 100644 index 0000000..1792b69 --- /dev/null +++ b/src/site/tmpl/category/default.xml @@ -0,0 +1,17 @@ + + + + COM_MOKODOLIJOOMSHOP_CATEGORY_DESC + + +
    + +
    +
    +
    diff --git a/src/site/tmpl/checkout/confirmation.php b/src/site/tmpl/checkout/confirmation.php new file mode 100644 index 0000000..0f129d6 --- /dev/null +++ b/src/site/tmpl/checkout/confirmation.php @@ -0,0 +1,47 @@ +orderResult; +?> +
    + +
    + +
    + +
    + +

    + +
    +
    +
    +
    +
    + + +
    +
    + +
    +
    +
    + + + + +
    + +
    diff --git a/src/site/tmpl/checkout/default.php b/src/site/tmpl/checkout/default.php new file mode 100644 index 0000000..724a721 --- /dev/null +++ b/src/site/tmpl/checkout/default.php @@ -0,0 +1,129 @@ +currency); +$isGuest = empty($this->user->id); +$userName = $isGuest ? '' : htmlspecialchars($this->user->name); +$userEmail = $isGuest ? '' : htmlspecialchars($this->user->email); +?> +
    +

    + + cartItems)) : ?> +
    + + + + checkoutMode === 'registered' && $isGuest) : ?> +
    + + + + +
    + + + +
    +
    +
    +
    +
    +

    +
    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    + + +
    +
    + + +
    +
    +
    + + +
    +
    +
    + +
    +
    +

    +
    +
    + +
    +
    + + + + +
    +
    + +
    +
    +
    +

    +
    +
    + cartItems as $item) : ?> +
    + + + × + + +
    + +
    +
    + + totals['subtotal'], 2); ?> +
    + totals['tax'] > 0) : ?> +
    + + totals['tax'], 2); ?> +
    + +
    + + totals['total'], 2); ?> +
    +
    +
    +
    +
    +
    diff --git a/src/site/tmpl/checkout/default.xml b/src/site/tmpl/checkout/default.xml new file mode 100644 index 0000000..1bcbc01 --- /dev/null +++ b/src/site/tmpl/checkout/default.xml @@ -0,0 +1,6 @@ + + + + COM_MOKODOLIJOOMSHOP_CHECKOUT_DESC + + diff --git a/src/site/tmpl/orders/default.php b/src/site/tmpl/orders/default.php new file mode 100644 index 0000000..6ff68e6 --- /dev/null +++ b/src/site/tmpl/orders/default.php @@ -0,0 +1,126 @@ +currency); +?> +
    +

    + + isGuest) : ?> +
    + + + + +
    + + + + orderDetail !== null) : ?> + + orderDetail; + $ref = htmlspecialchars($order['ref'] ?? ''); + ?> + + +
    +
    +

    :

    + +
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + +
    +
    +
    + + orders)) : ?> +
    + + + + +
    + + + + + + + + + + + + + orders as $order) : ?> + + + + + + + + + + +
    + + + + + + + + + +
    +
    + +
    diff --git a/src/site/tmpl/orders/default.xml b/src/site/tmpl/orders/default.xml new file mode 100644 index 0000000..a23fdf8 --- /dev/null +++ b/src/site/tmpl/orders/default.xml @@ -0,0 +1,6 @@ + + + + COM_MOKODOLIJOOMSHOP_MY_ORDERS_DESC + + diff --git a/src/site/tmpl/product/default.php b/src/site/tmpl/product/default.php new file mode 100644 index 0000000..eee6ccf --- /dev/null +++ b/src/site/tmpl/product/default.php @@ -0,0 +1,159 @@ +item === null) : +?> +
    + 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); +?> + + + + +
    + + +
    +
    + images)) : ?> + + +
    + +
    + +
    + +
    +

    +

    :

    + + +

    Barcode:

    + + +
    + + currency); ?> + + +
    + + : currency); ?> + + +
    + +
    + + + (stock; ?> ) + + + +
    + + +
    +
    + + + +
    + +
    + + + +
    +

    +
    +
    + +
    +
    + + related)) : ?> +
    +

    +
    + related, 0, 4) as $rel) : ?> +
    +
    +
    +
    + + + +
    + + + currency); ?> + +
    +
    +
    + +
    +
    + +
    diff --git a/src/site/tmpl/product/default.xml b/src/site/tmpl/product/default.xml new file mode 100644 index 0000000..efea2b6 --- /dev/null +++ b/src/site/tmpl/product/default.xml @@ -0,0 +1,17 @@ + + + + COM_MOKODOLIJOOMSHOP_PRODUCT_DETAIL_DESC + + +
    + +
    +
    +
    diff --git a/src/site/tmpl/products/default.php b/src/site/tmpl/products/default.php new file mode 100644 index 0000000..50942a9 --- /dev/null +++ b/src/site/tmpl/products/default.php @@ -0,0 +1,103 @@ + +
    +

    + + categories)) : ?> + + + + items)) : ?> +
    + +
    + +
    + items as $product) : ?> + 0; + ?> +
    +
    +
    +
    + +
    +

    + +

    + +
    + +
    +
    + +
    + + + categoryId ? '&category_id=' . $this->categoryId : ''); ?> + + +
    diff --git a/src/site/tmpl/products/default.xml b/src/site/tmpl/products/default.xml new file mode 100644 index 0000000..50fa5e9 --- /dev/null +++ b/src/site/tmpl/products/default.xml @@ -0,0 +1,31 @@ + + + + COM_MOKODOLIJOOMSHOP_PRODUCTS_DESC + + +
    + + + + + + + + +
    +
    +