From 8a10b8a9e8b351e7b8f41cd9d9e7c4a8dddfe098 Mon Sep 17 00:00:00 2001 From: Jonathan Miller <1+jmiller@noreply.git.mokoconsulting.tech> Date: Thu, 21 May 2026 20:09:06 +0000 Subject: [PATCH 1/2] chore: update CLAUDE.md to reference .mokogitea/ [skip ci] Authored-by: Moko Consulting --- CLAUDE.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index d7ccc34..e65deda 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -59,6 +59,8 @@ This is a Joomla package. Key layout: ## 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 -- 2.52.0 From 72fecbb634702de537a76b90c3a6f7c404a5fa59 Mon Sep 17 00:00:00 2001 From: Jonathan Miller <1+jmiller@noreply.git.mokoconsulting.tech> Date: Sat, 23 May 2026 22:34:48 +0000 Subject: [PATCH 2/2] feat: initial implementation with manifest fixes (#36) feat: initial implementation with manifest fixes Squash merge of dev/initial-implementation into main. Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 1 + .mokogitea/.moko-platform | 1 - .mokogitea/manifest.xml | 19 ++ .mokogitea/workflows/auto-release.yml | 138 ++++---- .mokogitea/workflows/pre-release.yml | 72 ++++- CHANGELOG.md | 65 +++- .../com_mokojoomstorelocator/admin/access.xml | 17 + .../admin/forms/category.xml | 74 +++++ .../admin/forms/filter_categories.xml | 15 + .../admin/forms/filter_locations.xml | 33 ++ .../admin/forms/location.xml | 30 +- .../en-GB/com_mokojoomstorelocator.ini | 57 ++++ .../en-US/com_mokojoomstorelocator.ini | 87 +++++ .../en-US/com_mokojoomstorelocator.sys.ini | 7 + .../admin/sql/install.mysql.sql | 34 +- .../admin/sql/uninstall.mysql.sql | 2 + .../admin/sql/updates/mysql/1.0.0.sql | 1 + .../admin/sql/updates/mysql/1.0.1.sql | 30 ++ .../src/Controller/CategoriesController.php | 14 + .../src/Controller/CategoryController.php | 11 + .../admin/src/Controller/ExportController.php | 103 ++++++ .../admin/src/Controller/ImportController.php | 248 +++++++++++++++ .../src/Controller/LocationController.php | 31 ++ .../src/Controller/LocationsController.php | 39 +++ .../src/Controller/SampledataController.php | 269 ++++++++++++++++ .../MokoJoomStoreLocatorComponent.php | 56 +++- .../src/Field/LocationCategoriesField.php | 50 +++ .../admin/src/Helper/Geocoder.php | 227 +++++++++++++ .../admin/src/Helper/VideoHelper.php | 52 +++ .../admin/src/Model/CategoriesModel.php | 80 +++++ .../admin/src/Model/CategoryModel.php | 60 ++++ .../admin/src/Model/LocationModel.php | 147 ++++++--- .../admin/src/Model/LocationsModel.php | 58 +++- .../admin/src/Table/CategoryTable.php | 58 ++++ .../admin/src/Table/LocationTable.php | 67 +++- .../admin/src/View/Categories/HtmlView.php | 29 ++ .../admin/src/View/Categories/JsonapiView.php | 17 + .../admin/src/View/Category/HtmlView.php | 31 ++ .../admin/src/View/Import/HtmlView.php | 38 +++ .../admin/src/View/Location/HtmlView.php | 83 +++++ .../admin/src/View/Locations/HtmlView.php | 12 + .../admin/src/View/Locations/JsonapiView.php | 25 ++ .../admin/tmpl/categories/default.php | 70 ++++ .../admin/tmpl/category/edit.php | 66 ++++ .../admin/tmpl/import/default.php | 298 ++++++++++++++++++ .../admin/tmpl/location/edit.php | 158 ++++++++++ .../admin/tmpl/locations/default.php | 26 +- .../mokojoomstorelocator.xml | 55 +++- .../site/css/storelocator.css | 198 ++++++++++++ .../site/joomla.asset.json | 39 +++ .../en-GB/com_mokojoomstorelocator.ini | 32 ++ .../en-US/com_mokojoomstorelocator.ini | 32 ++ .../site/services/provider.php | 12 + .../site/src/Controller/ContactController.php | 118 +++++++ .../site/src/Controller/DisplayController.php | 29 ++ .../site/src/Model/CategoryModel.php | 63 ++++ .../site/src/Model/LocationModel.php | 54 ++++ .../site/src/Model/LocationsModel.php | 116 +++++++ .../site/src/Service/Router.php | 111 +++++++ .../site/src/View/Category/HtmlView.php | 32 ++ .../site/src/View/Location/HtmlView.php | 174 ++++++++++ .../site/src/View/Locations/HtmlView.php | 54 ++++ .../site/tmpl/category/default.php | 54 ++++ .../site/tmpl/location/default.php | 228 ++++++++++++++ .../site/tmpl/locations/default.php | 96 ++++++ .../en-US/mod_mokojoomstorelocator_map.ini | 12 + .../mod_mokojoomstorelocator_map.sys.ini | 6 + .../mod_mokojoomstorelocator_map.xml | 8 +- .../src/Dispatcher/Dispatcher.php | 103 +++++- .../tmpl/default.php | 203 +++++++++++- .../en-GB/mod_mokojoomstorelocator_search.ini | 8 + .../en-US/mod_mokojoomstorelocator_search.ini | 21 ++ .../mod_mokojoomstorelocator_search.sys.ini | 6 + .../mod_mokojoomstorelocator_search.xml | 8 +- .../src/Dispatcher/Dispatcher.php | 30 +- .../tmpl/default.php | 131 +++++++- .../plg_webservices_mokojoomstorelocator.ini | 1 + .../plg_webservices_mokojoomstorelocator.ini | 1 + .../mokojoomstorelocator.xml | 12 + .../src/Extension/MokoJoomStoreLocator.php | 60 ++++ src/pkg_mokojoomstorelocator.xml | 9 +- src/script.php | 44 ++- 82 files changed, 5076 insertions(+), 190 deletions(-) delete mode 100644 .mokogitea/.moko-platform create mode 100644 .mokogitea/manifest.xml create mode 100644 src/packages/com_mokojoomstorelocator/admin/access.xml create mode 100644 src/packages/com_mokojoomstorelocator/admin/forms/category.xml create mode 100644 src/packages/com_mokojoomstorelocator/admin/forms/filter_categories.xml create mode 100644 src/packages/com_mokojoomstorelocator/admin/forms/filter_locations.xml create mode 100644 src/packages/com_mokojoomstorelocator/admin/language/en-US/com_mokojoomstorelocator.ini create mode 100644 src/packages/com_mokojoomstorelocator/admin/language/en-US/com_mokojoomstorelocator.sys.ini create mode 100644 src/packages/com_mokojoomstorelocator/admin/sql/updates/mysql/1.0.0.sql create mode 100644 src/packages/com_mokojoomstorelocator/admin/sql/updates/mysql/1.0.1.sql create mode 100644 src/packages/com_mokojoomstorelocator/admin/src/Controller/CategoriesController.php create mode 100644 src/packages/com_mokojoomstorelocator/admin/src/Controller/CategoryController.php create mode 100644 src/packages/com_mokojoomstorelocator/admin/src/Controller/ExportController.php create mode 100644 src/packages/com_mokojoomstorelocator/admin/src/Controller/ImportController.php create mode 100644 src/packages/com_mokojoomstorelocator/admin/src/Controller/LocationController.php create mode 100644 src/packages/com_mokojoomstorelocator/admin/src/Controller/LocationsController.php create mode 100644 src/packages/com_mokojoomstorelocator/admin/src/Controller/SampledataController.php create mode 100644 src/packages/com_mokojoomstorelocator/admin/src/Field/LocationCategoriesField.php create mode 100644 src/packages/com_mokojoomstorelocator/admin/src/Helper/Geocoder.php create mode 100644 src/packages/com_mokojoomstorelocator/admin/src/Helper/VideoHelper.php create mode 100644 src/packages/com_mokojoomstorelocator/admin/src/Model/CategoriesModel.php create mode 100644 src/packages/com_mokojoomstorelocator/admin/src/Model/CategoryModel.php create mode 100644 src/packages/com_mokojoomstorelocator/admin/src/Table/CategoryTable.php create mode 100644 src/packages/com_mokojoomstorelocator/admin/src/View/Categories/HtmlView.php create mode 100644 src/packages/com_mokojoomstorelocator/admin/src/View/Categories/JsonapiView.php create mode 100644 src/packages/com_mokojoomstorelocator/admin/src/View/Category/HtmlView.php create mode 100644 src/packages/com_mokojoomstorelocator/admin/src/View/Import/HtmlView.php create mode 100644 src/packages/com_mokojoomstorelocator/admin/src/View/Location/HtmlView.php create mode 100644 src/packages/com_mokojoomstorelocator/admin/src/View/Locations/JsonapiView.php create mode 100644 src/packages/com_mokojoomstorelocator/admin/tmpl/categories/default.php create mode 100644 src/packages/com_mokojoomstorelocator/admin/tmpl/category/edit.php create mode 100644 src/packages/com_mokojoomstorelocator/admin/tmpl/import/default.php create mode 100644 src/packages/com_mokojoomstorelocator/admin/tmpl/location/edit.php create mode 100644 src/packages/com_mokojoomstorelocator/site/css/storelocator.css create mode 100644 src/packages/com_mokojoomstorelocator/site/joomla.asset.json create mode 100644 src/packages/com_mokojoomstorelocator/site/language/en-GB/com_mokojoomstorelocator.ini create mode 100644 src/packages/com_mokojoomstorelocator/site/language/en-US/com_mokojoomstorelocator.ini create mode 100644 src/packages/com_mokojoomstorelocator/site/services/provider.php create mode 100644 src/packages/com_mokojoomstorelocator/site/src/Controller/ContactController.php create mode 100644 src/packages/com_mokojoomstorelocator/site/src/Controller/DisplayController.php create mode 100644 src/packages/com_mokojoomstorelocator/site/src/Model/CategoryModel.php create mode 100644 src/packages/com_mokojoomstorelocator/site/src/Model/LocationModel.php create mode 100644 src/packages/com_mokojoomstorelocator/site/src/Model/LocationsModel.php create mode 100644 src/packages/com_mokojoomstorelocator/site/src/Service/Router.php create mode 100644 src/packages/com_mokojoomstorelocator/site/src/View/Category/HtmlView.php create mode 100644 src/packages/com_mokojoomstorelocator/site/src/View/Location/HtmlView.php create mode 100644 src/packages/com_mokojoomstorelocator/site/src/View/Locations/HtmlView.php create mode 100644 src/packages/com_mokojoomstorelocator/site/tmpl/category/default.php create mode 100644 src/packages/com_mokojoomstorelocator/site/tmpl/location/default.php create mode 100644 src/packages/com_mokojoomstorelocator/site/tmpl/locations/default.php create mode 100644 src/packages/mod_mokojoomstorelocator_map/language/en-US/mod_mokojoomstorelocator_map.ini create mode 100644 src/packages/mod_mokojoomstorelocator_map/language/en-US/mod_mokojoomstorelocator_map.sys.ini create mode 100644 src/packages/mod_mokojoomstorelocator_search/language/en-US/mod_mokojoomstorelocator_search.ini create mode 100644 src/packages/mod_mokojoomstorelocator_search/language/en-US/mod_mokojoomstorelocator_search.sys.ini create mode 100644 src/packages/plg_webservices_mokojoomstorelocator/language/en-GB/plg_webservices_mokojoomstorelocator.ini create mode 100644 src/packages/plg_webservices_mokojoomstorelocator/language/en-US/plg_webservices_mokojoomstorelocator.ini create mode 100644 src/packages/plg_webservices_mokojoomstorelocator/mokojoomstorelocator.xml create mode 100644 src/packages/plg_webservices_mokojoomstorelocator/src/Extension/MokoJoomStoreLocator.php diff --git a/.gitignore b/.gitignore index 391f47d..8db86be 100644 --- a/.gitignore +++ b/.gitignore @@ -114,6 +114,7 @@ build/ dist/ out/ site/ +!src/**/site/ *.map *.css.map *.js.map diff --git a/.mokogitea/.moko-platform b/.mokogitea/.moko-platform deleted file mode 100644 index ff38175..0000000 --- a/.mokogitea/.moko-platform +++ /dev/null @@ -1 +0,0 @@ -joomla diff --git a/.mokogitea/manifest.xml b/.mokogitea/manifest.xml new file mode 100644 index 0000000..b51e2cc --- /dev/null +++ b/.mokogitea/manifest.xml @@ -0,0 +1,19 @@ + + + + MokoJoomStoreLocator + MokoConsulting + Joomla store locator package with component and coordinating modules + GNU General Public License v3 + + + joomla + 05.00.00 + https://git.mokoconsulting.tech/MokoConsulting/moko-platform + + + PHP + joomla-package + src/ + + diff --git a/.mokogitea/workflows/auto-release.yml b/.mokogitea/workflows/auto-release.yml index ebf87bb..f4a36b8 100644 --- a/.mokogitea/workflows/auto-release.yml +++ b/.mokogitea/workflows/auto-release.yml @@ -4,17 +4,17 @@ # # 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 .moko-platform +# BRIEF: Universal build & release � detects platform from manifest.xml # # +========================================================================+ # | UNIVERSAL BUILD & RELEASE PIPELINE | # +========================================================================+ # | | -# | Reads .moko-platform (joomla|dolibarr|generic) to branch logic. | +# | Reads manifest.xml (joomla|dolibarr|generic) to branch logic. | # | | # | Platform-specific: | # | joomla: XML manifest, updates.xml, type-prefixed packages | @@ -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://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/MokoStandards-API.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,11 +79,15 @@ jobs: - name: Detect platform id: platform run: | - PLATFORM=$(cat .mokogitea/.moko-platform 2>/dev/null | tr -d '[:space:]') + # 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}" - 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" @@ -92,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" @@ -125,56 +129,21 @@ jobs: if: steps.version.outputs.skip != 'true' id: bump run: | - CURRENT=$(sed -n 's/.*VERSION:[[:space:]]*\([0-9][0-9]\.[0-9][0-9]\.[0-9][0-9]\).*/\1/p' README.md 2>/dev/null | head -1) + CLI="/tmp/moko-platform-api/cli" + CURRENT=$(php $CLI/version_read.php --path . 2>/dev/null) [ -z "$CURRENT" ] && { echo "skip=true" >> "$GITHUB_OUTPUT"; exit 0; } - MAJOR=$((10#$(echo "$CURRENT" | cut -d. -f1))) - MINOR=$((10#$(echo "$CURRENT" | cut -d. -f2))) - - # Minor bump, reset patch. Rollover if minor > 99 - MINOR=$((MINOR + 1)) - if [ $MINOR -gt 99 ]; then - MINOR=0 - MAJOR=$((MAJOR + 1)) - fi - - VERSION=$(printf "%02d.%02d.00" $MAJOR $MINOR) + # Minor bump via CLI (updates README.md in-place) + BUMP_OUT=$(php $CLI/version_bump.php --path . --minor) + VERSION=$(php $CLI/version_read.php --path . 2>/dev/null) TODAY=$(date +%Y-%m-%d) + echo "Stable bump: ${BUMP_OUT}" - echo "Stable bump: ${CURRENT} → ${VERSION} (minor)" + # Set platform-specific version (Joomla XML, Dolibarr mod*.class.php) + php $CLI/version_set_platform.php --path . --version "$VERSION" --stability stable --branch main - # Update README.md - sed -i "s/VERSION:[[:space:]]*${CURRENT}/VERSION: ${VERSION}/" README.md - - # Update platform-specific manifest - PLATFORM="${{ steps.platform.outputs.platform }}" - MANIFEST="${{ steps.platform.outputs.manifest }}" - MOD_FILE="${{ steps.platform.outputs.mod_file }}" - case "$PLATFORM" in - joomla) - if [ -n "$MANIFEST" ]; then - MANIFEST_VER=$(sed -n 's/.*\([^<]*\)<\/version>.*/\1/p' "$MANIFEST" | head -1) - [ -n "$MANIFEST_VER" ] && sed -i "s|${MANIFEST_VER}|${VERSION}|" "$MANIFEST" - sed -i "s|[^<]*|${TODAY}|" "$MANIFEST" - fi - ;; - dolibarr) - if [ -n "$MOD_FILE" ]; then - sed -i "s/\$this->version = '[^']*'/\$this->version = '${VERSION}'/" "$MOD_FILE" - fi - echo "${VERSION}" > update.txt - ;; - *) ;; - esac - - # Promote [Unreleased] section in CHANGELOG.md to new version - if [ -f "CHANGELOG.md" ] && grep -qi "Unreleased" CHANGELOG.md; then - sed -i "s|## \[Unreleased\]|## [${VERSION}] --- ${TODAY}|" CHANGELOG.md - sed -i "s|## Unreleased|## [${VERSION}] --- ${TODAY}|" CHANGELOG.md - sed -i "2i ## [Unreleased]" CHANGELOG.md - sed -i "3i \\ " CHANGELOG.md - echo "CHANGELOG promoted to [${VERSION}]" - fi + # Promote [Unreleased] in CHANGELOG.md + php $CLI/changelog_promote.php --path . --version "$VERSION" --date "$TODAY" 2>/dev/null || true # Commit and push git config --local user.email "gitea-actions[bot]@mokoconsulting.tech" @@ -187,8 +156,9 @@ jobs: } # Override version output for rest of pipeline + MAJOR=$(echo "$VERSION" | cut -d. -f1) echo "version=${VERSION}" >> "$GITHUB_OUTPUT" - echo "major=$(printf "%02d" $MAJOR)" >> "$GITHUB_OUTPUT" + echo "major=${MAJOR}" >> "$GITHUB_OUTPUT" - name: Check if already released if: steps.version.outputs.skip != 'true' @@ -296,7 +266,7 @@ jobs: echo "- Missing update.txt" >> $GITHUB_STEP_SUMMARY ERRORS=$((ERRORS+1)) fi ;; - *) echo "- Generic platform — no manifest checks" >> $GITHUB_STEP_SUMMARY ;; + *) echo "- Generic platform � no manifest checks" >> $GITHUB_STEP_SUMMARY ;; esac echo "" >> $GITHUB_STEP_SUMMARY @@ -333,7 +303,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 ---------------------------------------- @@ -360,7 +330,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 @@ -557,7 +528,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)" @@ -617,7 +588,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 @@ -650,15 +622,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") @@ -864,7 +860,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 diff --git a/.mokogitea/workflows/pre-release.yml b/.mokogitea/workflows/pre-release.yml index f98f61e..23c3db9 100644 --- a/.mokogitea/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,10 +55,13 @@ jobs: - name: Detect platform id: platform run: | - PLATFORM=$(cat .mokogitea/.moko-platform 2>/dev/null | tr -d '[:space:]') + PLATFORM=$(sed -n 's/.*\([^<]*\)<\/platform>.*/\1/p' .mokogitea/manifest.xml 2>/dev/null | head -1 | tr -d '[:space:]') [ -z "$PLATFORM" ] && PLATFORM="generic" echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT" - 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" @@ -116,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 @@ -187,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/CHANGELOG.md b/CHANGELOG.md index 425bd88..35f53b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,9 +8,62 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added -- Initial package scaffold with component, map module, and search module -- Database schema for locations table with coordinates -- Admin MVC for location CRUD -- Frontend location listing view with Schema.org markup -- Map module with Leaflet/Google Maps provider support -- Search module with city and radius filter options +- **Package** with component, 2 modules, and web services API plugin +- **Component (com_mokojoomstorelocator)** + - Admin location CRUD with tabbed edit form (details, address, coordinates, contact, media) + - Leaflet coordinate picker — click map to set lat/lng on admin form + - Locations list with publish/unpublish/delete, search, pagination + - Categories admin with parent/child hierarchy, color, and custom marker icon + - Multi-category support — locations assigned to multiple categories via junction table + - CSV import with 3-step wizard (upload, column mapping, preview/validate) + - CSV export with filter support and UTF-8 BOM for Excel + - Sample data injection (8 Tennessee locations with real coordinates) + - Geocoding service — Nominatim (free default) and Google Geocoding API + - Auto-geocode on save when address present but coordinates missing + - Video URL field with YouTube/Vimeo embed support + - Multiple images field (gallery) per location + - Contact form per location with email delivery and captcha + - Custom fields integration via Joomla com_fields (location + category contexts) + - Component config: geocoding provider, Google API key, auto-geocode toggle + - access.xml with component and category-level ACL permissions + - SQL update schema with versioned migration files + - Filter forms: filter_locations.xml, filter_categories.xml + - populateState for persistent admin list filters + - joomla.asset.json for Web Asset Manager (Leaflet, MarkerCluster, CSS) +- **Frontend (site views)** + - Locations list with linked titles, distance display, Get Directions buttons + - Single location detail page with embedded map, Schema.org JSON-LD + - Category view — locations filtered by category with color swatch + - SEF URL router (locations, location by alias, category by alias) + - Contact form embedded on location detail page + - Photo gallery and responsive video embed on detail page + - Print button with print stylesheet and static map image + - Category tags with color badges and links + - Responsive CSS with mobile-first grid, click-to-call phone +- **Map Module (mod_mokojoomstorelocator_map)** + - Leaflet.js with OpenStreetMap tiles (no API key required) + - Google Maps provider (optional, with API key) + - MarkerCluster plugin for both providers + - Category-colored SVG markers with custom icon override + - Category legend below map + - DOM-based popup content (XSS-safe) + - Auto-fit bounds to show all markers + - Get Directions link in popups +- **Search Module (mod_mokojoomstorelocator_search)** + - Text search (title, address, city, postcode) + - City dropdown filter from distinct values + - Radius filter with configurable options (miles/km) + - "Use My Location" geolocation button with permission handling + - Haversine formula for distance calculation in SQL +- **Web Services API (plg_webservices_mokojoomstorelocator)** + - CRUD routes for /v1/storelocator/locations + - CRUD routes for /v1/storelocator/categories + - Custom search route: /v1/storelocator/search + - JsonapiView for Locations and Categories +- **Package installer** + - Auto-enables modules and API plugin on install + - PHP 8.1+ and Joomla 4.4+ version checks + - en-GB and en-US language files for all extensions + +### Fixed +- Hardcode name and description in all XML manifests (language variables don't resolve during install) diff --git a/src/packages/com_mokojoomstorelocator/admin/access.xml b/src/packages/com_mokojoomstorelocator/admin/access.xml new file mode 100644 index 0000000..a5e5da0 --- /dev/null +++ b/src/packages/com_mokojoomstorelocator/admin/access.xml @@ -0,0 +1,17 @@ + + +
+ + + + + + +
+
+ + + + +
+
diff --git a/src/packages/com_mokojoomstorelocator/admin/forms/category.xml b/src/packages/com_mokojoomstorelocator/admin/forms/category.xml new file mode 100644 index 0000000..926188e --- /dev/null +++ b/src/packages/com_mokojoomstorelocator/admin/forms/category.xml @@ -0,0 +1,74 @@ + +
+
+ + + + + + + + + + + + + + +
+ +
+ + + + + +
+
diff --git a/src/packages/com_mokojoomstorelocator/admin/forms/filter_categories.xml b/src/packages/com_mokojoomstorelocator/admin/forms/filter_categories.xml new file mode 100644 index 0000000..c5daf01 --- /dev/null +++ b/src/packages/com_mokojoomstorelocator/admin/forms/filter_categories.xml @@ -0,0 +1,15 @@ + +
+ + + + + + + + + + + + +
diff --git a/src/packages/com_mokojoomstorelocator/admin/forms/filter_locations.xml b/src/packages/com_mokojoomstorelocator/admin/forms/filter_locations.xml new file mode 100644 index 0000000..6959833 --- /dev/null +++ b/src/packages/com_mokojoomstorelocator/admin/forms/filter_locations.xml @@ -0,0 +1,33 @@ + +
+ + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/src/packages/com_mokojoomstorelocator/admin/forms/location.xml b/src/packages/com_mokojoomstorelocator/admin/forms/location.xml index 1a3de75..26e7f84 100644 --- a/src/packages/com_mokojoomstorelocator/admin/forms/location.xml +++ b/src/packages/com_mokojoomstorelocator/admin/forms/location.xml @@ -41,6 +41,15 @@ + +
@@ -130,11 +139,30 @@ />
-
+
+ + + +
diff --git a/src/packages/com_mokojoomstorelocator/admin/language/en-GB/com_mokojoomstorelocator.ini b/src/packages/com_mokojoomstorelocator/admin/language/en-GB/com_mokojoomstorelocator.ini index f58dd4f..0e1fb8b 100644 --- a/src/packages/com_mokojoomstorelocator/admin/language/en-GB/com_mokojoomstorelocator.ini +++ b/src/packages/com_mokojoomstorelocator/admin/language/en-GB/com_mokojoomstorelocator.ini @@ -28,3 +28,60 @@ COM_MOKOJOOMSTORELOCATOR_FIELD_PHONE="Phone" COM_MOKOJOOMSTORELOCATOR_FIELD_WEBSITE="Website" COM_MOKOJOOMSTORELOCATOR_FIELD_HOURS="Business Hours" COM_MOKOJOOMSTORELOCATOR_FIELD_IMAGE="Location Image" + +COM_MOKOJOOMSTORELOCATOR_FIELDSET_GEOCODING="Geocoding" +COM_MOKOJOOMSTORELOCATOR_FIELD_GEOCODER_PROVIDER="Geocoding Provider" +COM_MOKOJOOMSTORELOCATOR_FIELD_GOOGLE_API_KEY="Google API Key" +COM_MOKOJOOMSTORELOCATOR_FIELD_GOOGLE_API_KEY_DESC="Required for Google Geocoding and Google Maps. Get one at console.cloud.google.com" +COM_MOKOJOOMSTORELOCATOR_FIELD_AUTO_GEOCODE="Auto-Geocode on Save" +COM_MOKOJOOMSTORELOCATOR_FIELD_AUTO_GEOCODE_DESC="Automatically convert addresses to coordinates when saving a location." + +COM_MOKOJOOMSTORELOCATOR_IMPORT="Import" +COM_MOKOJOOMSTORELOCATOR_IMPORT_TITLE="Import Locations from CSV" +COM_MOKOJOOMSTORELOCATOR_IMPORT_FILE="CSV File" +COM_MOKOJOOMSTORELOCATOR_IMPORT_FILE_DESC="Upload a CSV file with location data. First row must be column headers." +COM_MOKOJOOMSTORELOCATOR_IMPORT_NO_FILE="No file was uploaded." +COM_MOKOJOOMSTORELOCATOR_IMPORT_INVALID_FORMAT="Invalid file format. Only CSV files are accepted." +COM_MOKOJOOMSTORELOCATOR_IMPORT_GEOCODE="Geocode missing coordinates" +COM_MOKOJOOMSTORELOCATOR_IMPORT_GEOCODE_DESC="Auto-fill latitude/longitude for locations without coordinates. Uses your configured geocoding provider." +COM_MOKOJOOMSTORELOCATOR_IMPORT_UPDATE_EXISTING="Update existing locations" +COM_MOKOJOOMSTORELOCATOR_IMPORT_UPDATE_EXISTING_DESC="Match by title and update existing records instead of creating duplicates." +COM_MOKOJOOMSTORELOCATOR_IMPORT_SUBMIT="Import Locations" +COM_MOKOJOOMSTORELOCATOR_IMPORT_FORMAT="CSV Format" +COM_MOKOJOOMSTORELOCATOR_IMPORT_FORMAT_DESC="The CSV file must have a header row. Supported columns:" +COM_MOKOJOOMSTORELOCATOR_IMPORT_DOWNLOAD_TEMPLATE="Download CSV Template" +COM_MOKOJOOMSTORELOCATOR_IMPORT_RESULT="Import complete: %d imported, %d updated, %d skipped." + +COM_MOKOJOOMSTORELOCATOR_CATEGORIES="Categories" +COM_MOKOJOOMSTORELOCATOR_CATEGORY_NEW="New Category" +COM_MOKOJOOMSTORELOCATOR_CATEGORY_EDIT="Edit Category" +COM_MOKOJOOMSTORELOCATOR_PARENT="Parent" +COM_MOKOJOOMSTORELOCATOR_NO_PARENT="— No Parent —" +COM_MOKOJOOMSTORELOCATOR_FIELD_PARENT_CATEGORY="Parent Category" +COM_MOKOJOOMSTORELOCATOR_FIELD_CATEGORIES="Categories" +COM_MOKOJOOMSTORELOCATOR_FIELD_CATEGORIES_DESC="Assign this location to one or more categories." +COM_MOKOJOOMSTORELOCATOR_FIELDSET_APPEARANCE="Appearance & Marker" +COM_MOKOJOOMSTORELOCATOR_FIELD_COLOR="Color" +COM_MOKOJOOMSTORELOCATOR_FIELD_COLOR_DESC="Used for map markers when no custom icon is set." +COM_MOKOJOOMSTORELOCATOR_FIELD_MARKER_ICON="Custom Marker Icon" +COM_MOKOJOOMSTORELOCATOR_FIELD_MARKER_ICON_DESC="Upload an SVG or PNG image (recommended 32x32px). Overrides the color-based marker." +COM_MOKOJOOMSTORELOCATOR_FIELD_CATEGORY_IMAGE="Category Image" +COM_MOKOJOOMSTORELOCATOR_FIELDS_LOCATION="Location Custom Fields" +COM_MOKOJOOMSTORELOCATOR_FIELDS_CATEGORY="Category Custom Fields" + +COM_MOKOJOOMSTORELOCATOR_EXPORT="Export" +COM_MOKOJOOMSTORELOCATOR_EXPORT_CSV="Export to CSV" + +COM_MOKOJOOMSTORELOCATOR_SAMPLEDATA="Sample Data" +COM_MOKOJOOMSTORELOCATOR_SAMPLEDATA_INJECT="Install Sample Data" +COM_MOKOJOOMSTORELOCATOR_SAMPLEDATA_INJECT_CONFIRM="This will add 8 sample store locations to your database. Continue?" +COM_MOKOJOOMSTORELOCATOR_SAMPLEDATA_INJECTED="%d sample locations installed successfully." + +COM_MOKOJOOMSTORELOCATOR_GET_DIRECTIONS="Get Directions" + +COM_MOKOJOOMSTORELOCATOR_FIELDSET_MEDIA="Media" +COM_MOKOJOOMSTORELOCATOR_FIELD_IMAGE_DESC="Primary location image." +COM_MOKOJOOMSTORELOCATOR_FIELD_IMAGES="Additional Photos" +COM_MOKOJOOMSTORELOCATOR_FIELD_IMAGES_DESC="One image path per line. These display as a photo gallery on the location detail page." +COM_MOKOJOOMSTORELOCATOR_FIELD_VIDEO_URL="Video URL" +COM_MOKOJOOMSTORELOCATOR_FIELD_VIDEO_URL_DESC="YouTube or Vimeo URL. Embeds on the location detail page." diff --git a/src/packages/com_mokojoomstorelocator/admin/language/en-US/com_mokojoomstorelocator.ini b/src/packages/com_mokojoomstorelocator/admin/language/en-US/com_mokojoomstorelocator.ini new file mode 100644 index 0000000..0e1fb8b --- /dev/null +++ b/src/packages/com_mokojoomstorelocator/admin/language/en-US/com_mokojoomstorelocator.ini @@ -0,0 +1,87 @@ +; MokoJoomStoreLocator - Admin language strings +; Copyright (C) 2026 Moko Consulting. All rights reserved. +; License: GNU General Public License version 3 or later; see LICENSE + +COM_MOKOJOOMSTORELOCATOR="Store Locator" +COM_MOKOJOOMSTORELOCATOR_DESC="A store locator component for managing and displaying location listings." +COM_MOKOJOOMSTORELOCATOR_LOCATIONS="Locations" +COM_MOKOJOOMSTORELOCATOR_LOCATION_NEW="New Location" +COM_MOKOJOOMSTORELOCATOR_LOCATION_EDIT="Edit Location" +COM_MOKOJOOMSTORELOCATOR_TABLE_CAPTION="Store Location List" + +COM_MOKOJOOMSTORELOCATOR_CITY="City" +COM_MOKOJOOMSTORELOCATOR_STATE="State" + +COM_MOKOJOOMSTORELOCATOR_FIELDSET_ADDRESS="Address" +COM_MOKOJOOMSTORELOCATOR_FIELDSET_COORDINATES="Coordinates" +COM_MOKOJOOMSTORELOCATOR_FIELDSET_CONTACT="Contact Information" +COM_MOKOJOOMSTORELOCATOR_FIELDSET_IMAGE="Image" + +COM_MOKOJOOMSTORELOCATOR_FIELD_ADDRESS="Street Address" +COM_MOKOJOOMSTORELOCATOR_FIELD_CITY="City" +COM_MOKOJOOMSTORELOCATOR_FIELD_STATE="State / Province" +COM_MOKOJOOMSTORELOCATOR_FIELD_POSTCODE="Postal Code" +COM_MOKOJOOMSTORELOCATOR_FIELD_COUNTRY="Country" +COM_MOKOJOOMSTORELOCATOR_FIELD_LATITUDE="Latitude" +COM_MOKOJOOMSTORELOCATOR_FIELD_LONGITUDE="Longitude" +COM_MOKOJOOMSTORELOCATOR_FIELD_PHONE="Phone" +COM_MOKOJOOMSTORELOCATOR_FIELD_WEBSITE="Website" +COM_MOKOJOOMSTORELOCATOR_FIELD_HOURS="Business Hours" +COM_MOKOJOOMSTORELOCATOR_FIELD_IMAGE="Location Image" + +COM_MOKOJOOMSTORELOCATOR_FIELDSET_GEOCODING="Geocoding" +COM_MOKOJOOMSTORELOCATOR_FIELD_GEOCODER_PROVIDER="Geocoding Provider" +COM_MOKOJOOMSTORELOCATOR_FIELD_GOOGLE_API_KEY="Google API Key" +COM_MOKOJOOMSTORELOCATOR_FIELD_GOOGLE_API_KEY_DESC="Required for Google Geocoding and Google Maps. Get one at console.cloud.google.com" +COM_MOKOJOOMSTORELOCATOR_FIELD_AUTO_GEOCODE="Auto-Geocode on Save" +COM_MOKOJOOMSTORELOCATOR_FIELD_AUTO_GEOCODE_DESC="Automatically convert addresses to coordinates when saving a location." + +COM_MOKOJOOMSTORELOCATOR_IMPORT="Import" +COM_MOKOJOOMSTORELOCATOR_IMPORT_TITLE="Import Locations from CSV" +COM_MOKOJOOMSTORELOCATOR_IMPORT_FILE="CSV File" +COM_MOKOJOOMSTORELOCATOR_IMPORT_FILE_DESC="Upload a CSV file with location data. First row must be column headers." +COM_MOKOJOOMSTORELOCATOR_IMPORT_NO_FILE="No file was uploaded." +COM_MOKOJOOMSTORELOCATOR_IMPORT_INVALID_FORMAT="Invalid file format. Only CSV files are accepted." +COM_MOKOJOOMSTORELOCATOR_IMPORT_GEOCODE="Geocode missing coordinates" +COM_MOKOJOOMSTORELOCATOR_IMPORT_GEOCODE_DESC="Auto-fill latitude/longitude for locations without coordinates. Uses your configured geocoding provider." +COM_MOKOJOOMSTORELOCATOR_IMPORT_UPDATE_EXISTING="Update existing locations" +COM_MOKOJOOMSTORELOCATOR_IMPORT_UPDATE_EXISTING_DESC="Match by title and update existing records instead of creating duplicates." +COM_MOKOJOOMSTORELOCATOR_IMPORT_SUBMIT="Import Locations" +COM_MOKOJOOMSTORELOCATOR_IMPORT_FORMAT="CSV Format" +COM_MOKOJOOMSTORELOCATOR_IMPORT_FORMAT_DESC="The CSV file must have a header row. Supported columns:" +COM_MOKOJOOMSTORELOCATOR_IMPORT_DOWNLOAD_TEMPLATE="Download CSV Template" +COM_MOKOJOOMSTORELOCATOR_IMPORT_RESULT="Import complete: %d imported, %d updated, %d skipped." + +COM_MOKOJOOMSTORELOCATOR_CATEGORIES="Categories" +COM_MOKOJOOMSTORELOCATOR_CATEGORY_NEW="New Category" +COM_MOKOJOOMSTORELOCATOR_CATEGORY_EDIT="Edit Category" +COM_MOKOJOOMSTORELOCATOR_PARENT="Parent" +COM_MOKOJOOMSTORELOCATOR_NO_PARENT="— No Parent —" +COM_MOKOJOOMSTORELOCATOR_FIELD_PARENT_CATEGORY="Parent Category" +COM_MOKOJOOMSTORELOCATOR_FIELD_CATEGORIES="Categories" +COM_MOKOJOOMSTORELOCATOR_FIELD_CATEGORIES_DESC="Assign this location to one or more categories." +COM_MOKOJOOMSTORELOCATOR_FIELDSET_APPEARANCE="Appearance & Marker" +COM_MOKOJOOMSTORELOCATOR_FIELD_COLOR="Color" +COM_MOKOJOOMSTORELOCATOR_FIELD_COLOR_DESC="Used for map markers when no custom icon is set." +COM_MOKOJOOMSTORELOCATOR_FIELD_MARKER_ICON="Custom Marker Icon" +COM_MOKOJOOMSTORELOCATOR_FIELD_MARKER_ICON_DESC="Upload an SVG or PNG image (recommended 32x32px). Overrides the color-based marker." +COM_MOKOJOOMSTORELOCATOR_FIELD_CATEGORY_IMAGE="Category Image" +COM_MOKOJOOMSTORELOCATOR_FIELDS_LOCATION="Location Custom Fields" +COM_MOKOJOOMSTORELOCATOR_FIELDS_CATEGORY="Category Custom Fields" + +COM_MOKOJOOMSTORELOCATOR_EXPORT="Export" +COM_MOKOJOOMSTORELOCATOR_EXPORT_CSV="Export to CSV" + +COM_MOKOJOOMSTORELOCATOR_SAMPLEDATA="Sample Data" +COM_MOKOJOOMSTORELOCATOR_SAMPLEDATA_INJECT="Install Sample Data" +COM_MOKOJOOMSTORELOCATOR_SAMPLEDATA_INJECT_CONFIRM="This will add 8 sample store locations to your database. Continue?" +COM_MOKOJOOMSTORELOCATOR_SAMPLEDATA_INJECTED="%d sample locations installed successfully." + +COM_MOKOJOOMSTORELOCATOR_GET_DIRECTIONS="Get Directions" + +COM_MOKOJOOMSTORELOCATOR_FIELDSET_MEDIA="Media" +COM_MOKOJOOMSTORELOCATOR_FIELD_IMAGE_DESC="Primary location image." +COM_MOKOJOOMSTORELOCATOR_FIELD_IMAGES="Additional Photos" +COM_MOKOJOOMSTORELOCATOR_FIELD_IMAGES_DESC="One image path per line. These display as a photo gallery on the location detail page." +COM_MOKOJOOMSTORELOCATOR_FIELD_VIDEO_URL="Video URL" +COM_MOKOJOOMSTORELOCATOR_FIELD_VIDEO_URL_DESC="YouTube or Vimeo URL. Embeds on the location detail page." diff --git a/src/packages/com_mokojoomstorelocator/admin/language/en-US/com_mokojoomstorelocator.sys.ini b/src/packages/com_mokojoomstorelocator/admin/language/en-US/com_mokojoomstorelocator.sys.ini new file mode 100644 index 0000000..5652b4d --- /dev/null +++ b/src/packages/com_mokojoomstorelocator/admin/language/en-US/com_mokojoomstorelocator.sys.ini @@ -0,0 +1,7 @@ +; MokoJoomStoreLocator - System language strings +; Copyright (C) 2026 Moko Consulting. All rights reserved. +; License: GNU General Public License version 3 or later; see LICENSE + +COM_MOKOJOOMSTORELOCATOR="Store Locator" +COM_MOKOJOOMSTORELOCATOR_DESC="A store locator component for managing and displaying location listings." +COM_MOKOJOOMSTORELOCATOR_LOCATIONS="Locations" diff --git a/src/packages/com_mokojoomstorelocator/admin/sql/install.mysql.sql b/src/packages/com_mokojoomstorelocator/admin/sql/install.mysql.sql index e464c71..2059aa9 100644 --- a/src/packages/com_mokojoomstorelocator/admin/sql/install.mysql.sql +++ b/src/packages/com_mokojoomstorelocator/admin/sql/install.mysql.sql @@ -2,9 +2,10 @@ -- Copyright (C) 2026 Moko Consulting -- SPDX-License-Identifier: GPL-3.0-or-later -- --- MokoJoomStoreLocator - Store locations table +-- MokoJoomStoreLocator - Database schema -- ========================================================================= +-- Store locations CREATE TABLE IF NOT EXISTS `#__mokojoomstorelocator_locations` ( `id` int(11) NOT NULL AUTO_INCREMENT, `title` varchar(255) NOT NULL DEFAULT '', @@ -22,6 +23,8 @@ CREATE TABLE IF NOT EXISTS `#__mokojoomstorelocator_locations` ( `website` varchar(255) NOT NULL DEFAULT '', `hours` text NOT NULL, `image` varchar(255) NOT NULL DEFAULT '', + `images` text NOT NULL, + `video_url` varchar(500) NOT NULL DEFAULT '', `published` tinyint(4) NOT NULL DEFAULT 0, `ordering` int(11) NOT NULL DEFAULT 0, `catid` int(11) NOT NULL DEFAULT 0, @@ -38,3 +41,32 @@ CREATE TABLE IF NOT EXISTS `#__mokojoomstorelocator_locations` ( KEY `idx_alias` (`alias`(191)), KEY `idx_coordinates` (`latitude`, `longitude`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Location categories with hierarchy, color, and custom marker support +CREATE TABLE IF NOT EXISTS `#__mokojoomstorelocator_categories` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `parent_id` int(11) NOT NULL DEFAULT 0, + `title` varchar(255) NOT NULL DEFAULT '', + `alias` varchar(400) NOT NULL DEFAULT '', + `description` text NOT NULL, + `color` varchar(7) NOT NULL DEFAULT '#3b82f6', + `marker_icon` varchar(255) NOT NULL DEFAULT '', + `image` varchar(255) NOT NULL DEFAULT '', + `published` tinyint(4) NOT NULL DEFAULT 0, + `ordering` int(11) NOT NULL DEFAULT 0, + `params` text NOT NULL, + `created` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `modified` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_parent` (`parent_id`), + KEY `idx_published` (`published`), + KEY `idx_alias` (`alias`(191)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Many-to-many: locations <-> categories +CREATE TABLE IF NOT EXISTS `#__mokojoomstorelocator_location_categories` ( + `location_id` int(11) NOT NULL, + `category_id` int(11) NOT NULL, + PRIMARY KEY (`location_id`, `category_id`), + KEY `idx_category` (`category_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/src/packages/com_mokojoomstorelocator/admin/sql/uninstall.mysql.sql b/src/packages/com_mokojoomstorelocator/admin/sql/uninstall.mysql.sql index c5f8ce8..e847b91 100644 --- a/src/packages/com_mokojoomstorelocator/admin/sql/uninstall.mysql.sql +++ b/src/packages/com_mokojoomstorelocator/admin/sql/uninstall.mysql.sql @@ -3,4 +3,6 @@ -- SPDX-License-Identifier: GPL-3.0-or-later -- ========================================================================= +DROP TABLE IF EXISTS `#__mokojoomstorelocator_location_categories`; +DROP TABLE IF EXISTS `#__mokojoomstorelocator_categories`; DROP TABLE IF EXISTS `#__mokojoomstorelocator_locations`; diff --git a/src/packages/com_mokojoomstorelocator/admin/sql/updates/mysql/1.0.0.sql b/src/packages/com_mokojoomstorelocator/admin/sql/updates/mysql/1.0.0.sql new file mode 100644 index 0000000..f697638 --- /dev/null +++ b/src/packages/com_mokojoomstorelocator/admin/sql/updates/mysql/1.0.0.sql @@ -0,0 +1 @@ +-- v1.0.0 initial schema marker diff --git a/src/packages/com_mokojoomstorelocator/admin/sql/updates/mysql/1.0.1.sql b/src/packages/com_mokojoomstorelocator/admin/sql/updates/mysql/1.0.1.sql new file mode 100644 index 0000000..2393230 --- /dev/null +++ b/src/packages/com_mokojoomstorelocator/admin/sql/updates/mysql/1.0.1.sql @@ -0,0 +1,30 @@ +-- v1.0.1: Add images and video_url columns, categories table, junction table +ALTER TABLE `#__mokojoomstorelocator_locations` ADD COLUMN IF NOT EXISTS `images` text NOT NULL AFTER `image`; +ALTER TABLE `#__mokojoomstorelocator_locations` ADD COLUMN IF NOT EXISTS `video_url` varchar(500) NOT NULL DEFAULT '' AFTER `images`; + +CREATE TABLE IF NOT EXISTS `#__mokojoomstorelocator_categories` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `parent_id` int(11) NOT NULL DEFAULT 0, + `title` varchar(255) NOT NULL DEFAULT '', + `alias` varchar(400) NOT NULL DEFAULT '', + `description` text NOT NULL, + `color` varchar(7) NOT NULL DEFAULT '#3b82f6', + `marker_icon` varchar(255) NOT NULL DEFAULT '', + `image` varchar(255) NOT NULL DEFAULT '', + `published` tinyint(4) NOT NULL DEFAULT 0, + `ordering` int(11) NOT NULL DEFAULT 0, + `params` text NOT NULL, + `created` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `modified` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_parent` (`parent_id`), + KEY `idx_published` (`published`), + KEY `idx_alias` (`alias`(191)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `#__mokojoomstorelocator_location_categories` ( + `location_id` int(11) NOT NULL, + `category_id` int(11) NOT NULL, + PRIMARY KEY (`location_id`, `category_id`), + KEY `idx_category` (`category_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/src/packages/com_mokojoomstorelocator/admin/src/Controller/CategoriesController.php b/src/packages/com_mokojoomstorelocator/admin/src/Controller/CategoriesController.php new file mode 100644 index 0000000..19697ef --- /dev/null +++ b/src/packages/com_mokojoomstorelocator/admin/src/Controller/CategoriesController.php @@ -0,0 +1,14 @@ + true]) + { + return parent::getModel($name, $prefix, $config); + } +} diff --git a/src/packages/com_mokojoomstorelocator/admin/src/Controller/CategoryController.php b/src/packages/com_mokojoomstorelocator/admin/src/Controller/CategoryController.php new file mode 100644 index 0000000..f301f1e --- /dev/null +++ b/src/packages/com_mokojoomstorelocator/admin/src/Controller/CategoryController.php @@ -0,0 +1,11 @@ +get(\Joomla\Database\DatabaseInterface::class); + $query = $db->getQuery(true); + + $query->select('a.*') + ->from($db->quoteName('#__mokojoomstorelocator_locations', 'a')) + ->order($db->quoteName('a.title') . ' ASC'); + + // Apply filters from request + $app = Factory::getApplication(); + $published = $app->getInput()->getInt('filter_published', null); + + if ($published !== null) + { + $query->where($db->quoteName('a.published') . ' = :published') + ->bind(':published', $published, \Joomla\Database\ParameterType::INTEGER); + } + + $city = $app->getInput()->getString('filter_city', ''); + + if ($city) + { + $query->where($db->quoteName('a.city') . ' = :city') + ->bind(':city', $city); + } + + $db->setQuery($query); + $locations = $db->loadObjectList(); + + // CSV columns + $columns = [ + 'id', 'title', 'alias', 'description', 'address', 'city', 'state', + 'postcode', 'country', 'latitude', 'longitude', 'phone', 'email', + 'website', 'hours', 'published', + ]; + + // Output CSV + $app = Factory::getApplication(); + $app->setHeader('Content-Type', 'text/csv; charset=utf-8'); + $app->setHeader('Content-Disposition', 'attachment; filename="store-locations-' . date('Y-m-d') . '.csv"'); + $app->setHeader('Cache-Control', 'no-cache, must-revalidate'); + $app->sendHeaders(); + + $output = fopen('php://output', 'w'); + + // BOM for Excel UTF-8 compatibility + fwrite($output, "\xEF\xBB\xBF"); + + // Header row + fputcsv($output, $columns); + + // Data rows + foreach ($locations as $location) + { + $row = []; + + foreach ($columns as $col) + { + $row[] = $location->$col ?? ''; + } + + fputcsv($output, $row); + } + + fclose($output); + + $app->close(); + } +} diff --git a/src/packages/com_mokojoomstorelocator/admin/src/Controller/ImportController.php b/src/packages/com_mokojoomstorelocator/admin/src/Controller/ImportController.php new file mode 100644 index 0000000..6100b45 --- /dev/null +++ b/src/packages/com_mokojoomstorelocator/admin/src/Controller/ImportController.php @@ -0,0 +1,248 @@ +getInput(); + $file = $input->files->get('import_file', [], 'array'); + + if (empty($file['tmp_name']) || $file['error'] !== UPLOAD_ERR_OK) + { + $app->enqueueMessage(Text::_('COM_MOKOJOOMSTORELOCATOR_IMPORT_NO_FILE'), 'error'); + $app->redirect(Route::_('index.php?option=com_mokojoomstorelocator&view=import', false)); + + return; + } + + $ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION)); + + if ($ext !== 'csv') + { + $app->enqueueMessage(Text::_('COM_MOKOJOOMSTORELOCATOR_IMPORT_INVALID_FORMAT'), 'error'); + $app->redirect(Route::_('index.php?option=com_mokojoomstorelocator&view=import', false)); + + return; + } + + $geocodeOnImport = (bool) $input->getInt('geocode', 0); + $updateExisting = (bool) $input->getInt('update_existing', 0); + + // Column mapping from the enhanced import UI (JSON string: {"0":"title","1":"address",...}) + $columnMapJson = $input->getString('column_map', ''); + $columnMap = $columnMapJson ? json_decode($columnMapJson, true) : null; + + $result = $this->processCSV($file['tmp_name'], $geocodeOnImport, $updateExisting, $columnMap); + + $app->enqueueMessage( + Text::sprintf('COM_MOKOJOOMSTORELOCATOR_IMPORT_RESULT', $result['imported'], $result['updated'], $result['skipped']), + 'success' + ); + + $app->redirect(Route::_('index.php?option=com_mokojoomstorelocator&view=locations', false)); + } + + /** + * Parse and import a CSV file. + * + * Expected columns: title, address, city, state, postcode, country, latitude, longitude, + * phone, email, website, hours, description + * + * @param string $filePath Path to the CSV file. + * @param bool $geocodeOnImport Whether to geocode missing coordinates. + * @param bool $updateExisting Whether to update existing records by title match. + * @param array|null $columnMap Column mapping from UI: {"csv_index":"field_name",...} + * + * @return array ['imported' => int, 'updated' => int, 'skipped' => int] + * + * @since 1.0.0 + */ + private function processCSV(string $filePath, bool $geocodeOnImport, bool $updateExisting, ?array $columnMap = null): array + { + $handle = fopen($filePath, 'r'); + + if (!$handle) + { + return ['imported' => 0, 'updated' => 0, 'skipped' => 0]; + } + + // Read header row + $headers = fgetcsv($handle); + + if (!$headers) + { + fclose($handle); + + return ['imported' => 0, 'updated' => 0, 'skipped' => 0]; + } + + // If column map provided from UI, use it; otherwise fall back to header-based mapping + if ($columnMap) + { + // columnMap is {"csv_index": "field_name", ...} + $useColumnMap = true; + } + else + { + $headers = array_map('strtolower', array_map('trim', $headers)); + $useColumnMap = false; + } + + $db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class); + $geocoder = $geocodeOnImport ? new Geocoder() : null; + $imported = 0; + $updated = 0; + $skipped = 0; + + while (($row = fgetcsv($handle)) !== false) + { + if (count($row) < 2) + { + $skipped++; + continue; + } + + if ($useColumnMap) + { + $data = []; + + foreach ($columnMap as $csvIndex => $fieldName) + { + $data[$fieldName] = $row[(int) $csvIndex] ?? ''; + } + } + else + { + $data = @array_combine($headers, $row) ?: []; + } + + if (empty($data['title'])) + { + $skipped++; + continue; + } + + // Check for existing record + $existingId = null; + + if ($updateExisting) + { + $query = $db->getQuery(true) + ->select('id') + ->from($db->quoteName('#__mokojoomstorelocator_locations')) + ->where($db->quoteName('title') . ' = :title') + ->bind(':title', $data['title']); + $db->setQuery($query); + $existingId = $db->loadResult(); + } + + // Geocode if needed + if ($geocoder && empty($data['latitude']) && empty($data['longitude'])) + { + $coords = $geocoder->geocode( + $data['address'] ?? '', + $data['city'] ?? '', + $data['state'] ?? '', + $data['postcode'] ?? '', + $data['country'] ?? '' + ); + + if ($coords) + { + $data['latitude'] = $coords['lat']; + $data['longitude'] = $coords['lng']; + } + } + + // Build table record + $table = $this->getModel('Location')->getTable(); + + if ($existingId) + { + $table->load($existingId); + } + + $locationData = [ + 'title' => $data['title'] ?? '', + 'address' => $data['address'] ?? '', + 'city' => $data['city'] ?? '', + 'state' => $data['state'] ?? '', + 'postcode' => $data['postcode'] ?? '', + 'country' => $data['country'] ?? '', + 'latitude' => !empty($data['latitude']) ? (float) $data['latitude'] : null, + 'longitude' => !empty($data['longitude']) ? (float) $data['longitude'] : null, + 'phone' => $data['phone'] ?? '', + 'email' => $data['email'] ?? '', + 'website' => $data['website'] ?? '', + 'hours' => $data['hours'] ?? '', + 'description' => $data['description'] ?? '', + 'published' => 1, + ]; + + if (!$table->bind($locationData)) + { + $skipped++; + continue; + } + + if (!$table->check()) + { + $skipped++; + continue; + } + + if (!$table->store()) + { + Log::add('Import failed for: ' . $data['title'] . ' — ' . $table->getError(), Log::WARNING, 'com_mokojoomstorelocator'); + $skipped++; + continue; + } + + if ($existingId) + { + $updated++; + } + else + { + $imported++; + } + } + + fclose($handle); + + return ['imported' => $imported, 'updated' => $updated, 'skipped' => $skipped]; + } +} diff --git a/src/packages/com_mokojoomstorelocator/admin/src/Controller/LocationController.php b/src/packages/com_mokojoomstorelocator/admin/src/Controller/LocationController.php new file mode 100644 index 0000000..1edb40b --- /dev/null +++ b/src/packages/com_mokojoomstorelocator/admin/src/Controller/LocationController.php @@ -0,0 +1,31 @@ + true]) + { + return parent::getModel($name, $prefix, $config); + } +} diff --git a/src/packages/com_mokojoomstorelocator/admin/src/Controller/SampledataController.php b/src/packages/com_mokojoomstorelocator/admin/src/Controller/SampledataController.php new file mode 100644 index 0000000..1aedb44 --- /dev/null +++ b/src/packages/com_mokojoomstorelocator/admin/src/Controller/SampledataController.php @@ -0,0 +1,269 @@ +setHeader('Content-Type', 'text/csv; charset=utf-8'); + $app->setHeader('Content-Disposition', 'attachment; filename="store-locations-template.csv"'); + $app->sendHeaders(); + + $output = fopen('php://output', 'w'); + fwrite($output, "\xEF\xBB\xBF"); + + $headers = ['title', 'address', 'city', 'state', 'postcode', 'country', 'latitude', 'longitude', 'phone', 'email', 'website', 'hours', 'description']; + fputcsv($output, $headers); + + fputcsv($output, [ + 'Moko HQ', + '123 Main Street', + 'Nashville', + 'TN', + '37201', + 'US', + '36.1627', + '-86.7816', + '(615) 555-0100', + 'hello@example.com', + 'https://example.com', + 'Mon-Fri 9am-5pm', + 'Our main office location.', + ]); + + fclose($output); + $app->close(); + } + + /** + * Inject sample location data into the database for testing. + * + * @return void + * + * @since 1.0.0 + */ + public function inject(): void + { + Session::checkToken() or die(Text::_('JINVALID_TOKEN')); + + $app = Factory::getApplication(); + $db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class); + $now = Factory::getDate()->toSql(); + $uid = Factory::getApplication()->getIdentity()->id ?? 0; + + $samples = [ + [ + 'title' => 'Downtown Nashville Store', + 'alias' => 'downtown-nashville-store', + 'description' => 'Our flagship location in the heart of downtown Nashville.', + 'address' => '200 Broadway', + 'city' => 'Nashville', + 'state' => 'TN', + 'postcode' => '37201', + 'country' => 'US', + 'latitude' => 36.1622, + 'longitude' => -86.7744, + 'phone' => '(615) 555-0101', + 'email' => 'downtown@example.com', + 'website' => 'https://example.com/downtown', + 'hours' => "Mon-Fri: 9am-7pm\nSat: 10am-6pm\nSun: 12pm-5pm", + ], + [ + 'title' => 'East Nashville Location', + 'alias' => 'east-nashville-location', + 'description' => 'Serving the East Nashville community with friendly service.', + 'address' => '1000 Main St', + 'city' => 'Nashville', + 'state' => 'TN', + 'postcode' => '37206', + 'country' => 'US', + 'latitude' => 36.1781, + 'longitude' => -86.7534, + 'phone' => '(615) 555-0102', + 'email' => 'east@example.com', + 'website' => 'https://example.com/east', + 'hours' => "Mon-Sat: 10am-8pm\nSun: Closed", + ], + [ + 'title' => 'Franklin Square', + 'alias' => 'franklin-square', + 'description' => 'Conveniently located in the Franklin town square.', + 'address' => '400 Main St', + 'city' => 'Franklin', + 'state' => 'TN', + 'postcode' => '37064', + 'country' => 'US', + 'latitude' => 35.9251, + 'longitude' => -86.8689, + 'phone' => '(615) 555-0103', + 'email' => 'franklin@example.com', + 'website' => 'https://example.com/franklin', + 'hours' => "Mon-Fri: 8am-6pm\nSat-Sun: 10am-4pm", + ], + [ + 'title' => 'Murfreesboro Plaza', + 'alias' => 'murfreesboro-plaza', + 'description' => 'Our newest location serving Rutherford County.', + 'address' => '1720 Old Fort Pkwy', + 'city' => 'Murfreesboro', + 'state' => 'TN', + 'postcode' => '37129', + 'country' => 'US', + 'latitude' => 35.8353, + 'longitude' => -86.4160, + 'phone' => '(615) 555-0104', + 'email' => 'murfreesboro@example.com', + 'website' => 'https://example.com/murfreesboro', + 'hours' => "Mon-Sat: 9am-9pm\nSun: 11am-6pm", + ], + [ + 'title' => 'Clarksville Center', + 'alias' => 'clarksville-center', + 'description' => 'Serving the Clarksville-Montgomery County area.', + 'address' => '2801 Wilma Rudolph Blvd', + 'city' => 'Clarksville', + 'state' => 'TN', + 'postcode' => '37040', + 'country' => 'US', + 'latitude' => 36.5843, + 'longitude' => -87.3199, + 'phone' => '(931) 555-0105', + 'email' => 'clarksville@example.com', + 'website' => 'https://example.com/clarksville', + 'hours' => "Mon-Fri: 9am-7pm\nSat: 10am-5pm\nSun: Closed", + ], + [ + 'title' => 'Chattanooga Riverfront', + 'alias' => 'chattanooga-riverfront', + 'description' => 'Located near the Tennessee Aquarium on the riverfront.', + 'address' => '1 Broad St', + 'city' => 'Chattanooga', + 'state' => 'TN', + 'postcode' => '37402', + 'country' => 'US', + 'latitude' => 35.0557, + 'longitude' => -85.3097, + 'phone' => '(423) 555-0106', + 'email' => 'chattanooga@example.com', + 'website' => 'https://example.com/chattanooga', + 'hours' => "Daily: 10am-8pm", + ], + [ + 'title' => 'Knoxville Market Square', + 'alias' => 'knoxville-market-square', + 'description' => 'In the heart of downtown Knoxville at Market Square.', + 'address' => '36 Market Square', + 'city' => 'Knoxville', + 'state' => 'TN', + 'postcode' => '37902', + 'country' => 'US', + 'latitude' => 35.9643, + 'longitude' => -83.9198, + 'phone' => '(865) 555-0107', + 'email' => 'knoxville@example.com', + 'website' => 'https://example.com/knoxville', + 'hours' => "Mon-Sat: 9am-8pm\nSun: 12pm-6pm", + ], + [ + 'title' => 'Memphis Beale Street', + 'alias' => 'memphis-beale-street', + 'description' => 'Right on iconic Beale Street in downtown Memphis.', + 'address' => '152 Beale St', + 'city' => 'Memphis', + 'state' => 'TN', + 'postcode' => '38103', + 'country' => 'US', + 'latitude' => 35.1393, + 'longitude' => -90.0530, + 'phone' => '(901) 555-0108', + 'email' => 'memphis@example.com', + 'website' => 'https://example.com/memphis', + 'hours' => "Mon-Thu: 10am-10pm\nFri-Sat: 10am-12am\nSun: 11am-8pm", + ], + ]; + + $inserted = 0; + + foreach ($samples as $sample) + { + $sample['published'] = 1; + $sample['ordering'] = $inserted + 1; + $sample['params'] = '{}'; + $sample['image'] = ''; + $sample['catid'] = 0; + $sample['created'] = $now; + $sample['created_by'] = $uid; + $sample['modified'] = $now; + $sample['modified_by'] = $uid; + + $query = $db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName('#__mokojoomstorelocator_locations')) + ->where($db->quoteName('alias') . ' = :alias') + ->bind(':alias', $sample['alias']); + $db->setQuery($query); + + if ($db->loadResult() > 0) + { + continue; + } + + $query = $db->getQuery(true) + ->insert($db->quoteName('#__mokojoomstorelocator_locations')) + ->columns($db->quoteName(array_keys($sample))) + ->values(implode(',', array_map(function ($v) use ($db) { + return $db->quote($v); + }, array_values($sample)))); + + $db->setQuery($query); + + try + { + $db->execute(); + $inserted++; + } + catch (\Exception $e) + { + Log::add('Sample data insert failed: ' . $e->getMessage(), Log::WARNING, 'com_mokojoomstorelocator'); + } + } + + $app->enqueueMessage( + Text::sprintf('COM_MOKOJOOMSTORELOCATOR_SAMPLEDATA_INJECTED', $inserted), + 'success' + ); + + $app->redirect(Route::_('index.php?option=com_mokojoomstorelocator&view=locations', false)); + } +} diff --git a/src/packages/com_mokojoomstorelocator/admin/src/Extension/MokoJoomStoreLocatorComponent.php b/src/packages/com_mokojoomstorelocator/admin/src/Extension/MokoJoomStoreLocatorComponent.php index 49f9e3e..1db220f 100644 --- a/src/packages/com_mokojoomstorelocator/admin/src/Extension/MokoJoomStoreLocatorComponent.php +++ b/src/packages/com_mokojoomstorelocator/admin/src/Extension/MokoJoomStoreLocatorComponent.php @@ -11,13 +11,65 @@ namespace Moko\Component\MokoJoomStoreLocator\Administrator\Extension; defined('_JEXEC') or die; use Joomla\CMS\Extension\MVCComponent; +use Joomla\CMS\Component\Router\RouterServiceInterface; +use Joomla\CMS\Component\Router\RouterServiceTrait; +use Joomla\CMS\Fields\FieldsServiceInterface; /** * Component class for com_mokojoomstorelocator. * + * Implements RouterServiceInterface for SEF URLs and FieldsServiceInterface + * for Joomla custom fields (com_fields) integration. + * * @since 1.0.0 */ -class MokoJoomStoreLocatorComponent extends MVCComponent +class MokoJoomStoreLocatorComponent extends MVCComponent implements RouterServiceInterface, FieldsServiceInterface { - // TODO: Add boot(), getRouterRules(), or custom services as needed + use RouterServiceTrait; + + /** + * Returns the contexts available for custom fields. + * + * @return string[] Array of context names. + * + * @since 1.0.0 + */ + public function getContexts(): array + { + return [ + 'com_mokojoomstorelocator.location' => 'COM_MOKOJOOMSTORELOCATOR_FIELDS_LOCATION', + 'com_mokojoomstorelocator.category' => 'COM_MOKOJOOMSTORELOCATOR_FIELDS_CATEGORY', + ]; + } + + /** + * Validates a field context. + * + * @param string $context The context to validate. + * + * @return array|null Validated context parts or null. + * + * @since 1.0.0 + */ + public function validateSection($section, $item = null): ?string + { + if ($section === 'location' || $section === 'category') + { + return $section; + } + + return null; + } + + /** + * Returns valid sections for custom fields. + * + * @return array Section names. + * + * @since 1.0.0 + */ + public function getValidSections(): array + { + return ['location', 'category']; + } } diff --git a/src/packages/com_mokojoomstorelocator/admin/src/Field/LocationCategoriesField.php b/src/packages/com_mokojoomstorelocator/admin/src/Field/LocationCategoriesField.php new file mode 100644 index 0000000..e97a8d9 --- /dev/null +++ b/src/packages/com_mokojoomstorelocator/admin/src/Field/LocationCategoriesField.php @@ -0,0 +1,50 @@ +get(DatabaseInterface::class); + $query = $db->getQuery(true) + ->select([$db->quoteName('id', 'value'), $db->quoteName('title', 'text')]) + ->from($db->quoteName('#__mokojoomstorelocator_categories')) + ->where($db->quoteName('published') . ' = 1') + ->order($db->quoteName('title') . ' ASC'); + + $db->setQuery($query); + $categories = $db->loadObjectList(); + + foreach ($categories as $cat) + { + $options[] = (object) [ + 'value' => $cat->value, + 'text' => $cat->text, + ]; + } + + return $options; + } +} diff --git a/src/packages/com_mokojoomstorelocator/admin/src/Helper/Geocoder.php b/src/packages/com_mokojoomstorelocator/admin/src/Helper/Geocoder.php new file mode 100644 index 0000000..accef5e --- /dev/null +++ b/src/packages/com_mokojoomstorelocator/admin/src/Helper/Geocoder.php @@ -0,0 +1,227 @@ +provider = $params->get('geocoder_provider', 'nominatim'); + $this->apiKey = $params->get('google_api_key', ''); + } + + /** + * Geocode an address string to coordinates. + * + * @param string $address Street address. + * @param string $city City. + * @param string $state State/province. + * @param string $postcode Postal code. + * @param string $country Country. + * + * @return array|null ['lat' => float, 'lng' => float] or null on failure. + * + * @since 1.0.0 + */ + public function geocode(string $address = '', string $city = '', string $state = '', string $postcode = '', string $country = ''): ?array + { + $parts = array_filter([$address, $city, $state, $postcode, $country]); + $query = implode(', ', $parts); + + if (empty($query)) + { + return null; + } + + if ($this->provider === 'google' && $this->apiKey) + { + return $this->geocodeGoogle($query); + } + + return $this->geocodeNominatim($query); + } + + /** + * Batch geocode multiple locations. + * + * @param array $locations Array of arrays with address fields. + * + * @return array Array of results indexed by input key: ['lat' => float, 'lng' => float] or null. + * + * @since 1.0.0 + */ + public function batchGeocode(array $locations): array + { + $results = []; + + foreach ($locations as $key => $loc) + { + $results[$key] = $this->geocode( + $loc['address'] ?? '', + $loc['city'] ?? '', + $loc['state'] ?? '', + $loc['postcode'] ?? '', + $loc['country'] ?? '' + ); + + // Nominatim rate limit: max 1 request per second + if ($this->provider === 'nominatim') + { + usleep(1100000); + } + } + + return $results; + } + + /** + * Geocode using OpenStreetMap Nominatim (free, no API key). + * + * @param string $query The full address string. + * + * @return array|null Coordinates or null. + * + * @since 1.0.0 + */ + private function geocodeNominatim(string $query): ?array + { + $url = 'https://nominatim.openstreetmap.org/search?' . http_build_query([ + 'q' => $query, + 'format' => 'json', + 'limit' => 1, + ]); + + $result = $this->httpGet($url, [ + 'User-Agent' => 'MokoJoomStoreLocator/1.0 (Joomla component)', + 'Accept' => 'application/json', + ]); + + if ($result === null || empty($result)) + { + return null; + } + + $first = $result[0] ?? null; + + if (!$first || !isset($first['lat'], $first['lon'])) + { + return null; + } + + return [ + 'lat' => (float) $first['lat'], + 'lng' => (float) $first['lon'], + ]; + } + + /** + * Geocode using Google Geocoding API. + * + * @param string $query The full address string. + * + * @return array|null Coordinates or null. + * + * @since 1.0.0 + */ + private function geocodeGoogle(string $query): ?array + { + $url = 'https://maps.googleapis.com/maps/api/geocode/json?' . http_build_query([ + 'address' => $query, + 'key' => $this->apiKey, + ]); + + $result = $this->httpGet($url); + + if ($result === null || ($result['status'] ?? '') !== 'OK') + { + $status = $result['status'] ?? 'unknown'; + Log::add("Geocoder: Google returned status $status for: $query", Log::WARNING, 'com_mokojoomstorelocator'); + + return null; + } + + $location = $result['results'][0]['geometry']['location'] ?? null; + + if (!$location) + { + return null; + } + + return [ + 'lat' => (float) $location['lat'], + 'lng' => (float) $location['lng'], + ]; + } + + /** + * Perform an HTTP GET request and return decoded JSON. + * + * @param string $url The URL to fetch. + * @param array $headers Additional headers. + * + * @return array|null Decoded JSON or null on error. + * + * @since 1.0.0 + */ + private function httpGet(string $url, array $headers = []): ?array + { + try + { + $http = HttpFactory::getHttp(); + $response = $http->get($url, $headers, 15); + + if ($response->code !== 200) + { + Log::add("Geocoder: HTTP {$response->code} from $url", Log::WARNING, 'com_mokojoomstorelocator'); + + return null; + } + + return json_decode($response->body, true); + } + catch (\Exception $e) + { + Log::add('Geocoder: ' . $e->getMessage(), Log::ERROR, 'com_mokojoomstorelocator'); + + return null; + } + } +} diff --git a/src/packages/com_mokojoomstorelocator/admin/src/Helper/VideoHelper.php b/src/packages/com_mokojoomstorelocator/admin/src/Helper/VideoHelper.php new file mode 100644 index 0000000..186cfdb --- /dev/null +++ b/src/packages/com_mokojoomstorelocator/admin/src/Helper/VideoHelper.php @@ -0,0 +1,52 @@ +getDatabase(); + $query = $db->getQuery(true); + + $query->select([ + 'a.*', + 'p.title AS parent_title', + '(SELECT COUNT(*) FROM ' . $db->quoteName('#__mokojoomstorelocator_location_categories', 'lc') + . ' WHERE lc.category_id = a.id) AS location_count', + ]) + ->from($db->quoteName('#__mokojoomstorelocator_categories', 'a')) + ->join('LEFT', $db->quoteName('#__mokojoomstorelocator_categories', 'p') . ' ON p.id = a.parent_id'); + + $published = $this->getState('filter.published'); + + if (is_numeric($published)) + { + $query->where($db->quoteName('a.published') . ' = :published') + ->bind(':published', $published, \Joomla\Database\ParameterType::INTEGER); + } + elseif ($published === '') + { + $query->where($db->quoteName('a.published') . ' IN (0, 1)'); + } + + $search = $this->getState('filter.search'); + + if (!empty($search)) + { + $search = '%' . trim($search) . '%'; + $query->where($db->quoteName('a.title') . ' LIKE :search') + ->bind(':search', $search); + } + + $orderCol = $this->state->get('list.ordering', 'a.ordering'); + $orderDir = $this->state->get('list.direction', 'ASC'); + $query->order($db->escape($orderCol) . ' ' . $db->escape($orderDir)); + + return $query; + } +} diff --git a/src/packages/com_mokojoomstorelocator/admin/src/Model/CategoryModel.php b/src/packages/com_mokojoomstorelocator/admin/src/Model/CategoryModel.php new file mode 100644 index 0000000..f588a21 --- /dev/null +++ b/src/packages/com_mokojoomstorelocator/admin/src/Model/CategoryModel.php @@ -0,0 +1,60 @@ +loadForm( + 'com_mokojoomstorelocator.category', + 'category', + ['control' => 'jform', 'load_data' => $loadData] + ); + + return empty($form) ? false : $form; + } + + protected function loadFormData() + { + return $this->getItem(); + } + + public function getTable($name = 'Category', $prefix = 'Administrator', $options = []) + { + return parent::getTable($name, $prefix, $options); + } + + /** + * Save category and update the location-category junction table. + * + * @param array $data The form data. + * + * @return boolean + * + * @since 1.0.0 + */ + public function save($data) + { + return parent::save($data); + } +} diff --git a/src/packages/com_mokojoomstorelocator/admin/src/Model/LocationModel.php b/src/packages/com_mokojoomstorelocator/admin/src/Model/LocationModel.php index 5c44117..1f8c615 100644 --- a/src/packages/com_mokojoomstorelocator/admin/src/Model/LocationModel.php +++ b/src/packages/com_mokojoomstorelocator/admin/src/Model/LocationModel.php @@ -10,35 +10,22 @@ namespace Moko\Component\MokoJoomStoreLocator\Administrator\Model; defined('_JEXEC') or die; +use Joomla\CMS\Factory; use Joomla\CMS\MVC\Model\AdminModel; use Joomla\CMS\Form\Form; use Joomla\CMS\Table\Table; +use Joomla\Database\DatabaseInterface; +use Joomla\Database\ParameterType; /** - * Single location edit model. + * Single location edit model with multi-category support. * * @since 1.0.0 */ class LocationModel extends AdminModel { - /** - * The type alias for this content type. - * - * @var string - * @since 1.0.0 - */ public $typeAlias = 'com_mokojoomstorelocator.location'; - /** - * Get the form for this model. - * - * @param array $data Data for the form. - * @param boolean $loadData True if the form is to load its own data. - * - * @return Form|boolean A Form object on success, false on failure. - * - * @since 1.0.0 - */ public function getForm($data = [], $loadData = true) { $form = $this->loadForm( @@ -47,41 +34,119 @@ class LocationModel extends AdminModel ['control' => 'jform', 'load_data' => $loadData] ); - if (empty($form)) - { - return false; - } - - return $form; + return empty($form) ? false : $form; } - /** - * Load the data for the form. - * - * @return mixed The data for the form. - * - * @since 1.0.0 - */ protected function loadFormData() { $data = $this->getItem(); + // Load assigned category IDs for the multi-select field + if ($data && $data->id) + { + $data->category_ids = $this->getCategoryIds((int) $data->id); + } + return $data; } - /** - * Get the table for this model. - * - * @param string $name The table name. - * @param string $prefix The table prefix. - * @param array $options Configuration array for the table. - * - * @return Table - * - * @since 1.0.0 - */ public function getTable($name = 'Location', $prefix = 'Administrator', $options = []) { return parent::getTable($name, $prefix, $options); } + + /** + * Override save to handle the many-to-many category relationship. + * + * @param array $data The form data. + * + * @return boolean + * + * @since 1.0.0 + */ + public function save($data) + { + $categoryIds = $data['category_ids'] ?? []; + unset($data['category_ids']); + + if (!parent::save($data)) + { + return false; + } + + $locationId = (int) $this->getState($this->getName() . '.id'); + + $this->saveCategoryAssignments($locationId, (array) $categoryIds); + + return true; + } + + /** + * Get category IDs assigned to a location. + * + * @param int $locationId The location ID. + * + * @return array Array of category IDs. + * + * @since 1.0.0 + */ + public function getCategoryIds(int $locationId): array + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + $query = $db->getQuery(true) + ->select($db->quoteName('category_id')) + ->from($db->quoteName('#__mokojoomstorelocator_location_categories')) + ->where($db->quoteName('location_id') . ' = :id') + ->bind(':id', $locationId, ParameterType::INTEGER); + + $db->setQuery($query); + + return $db->loadColumn() ?: []; + } + + /** + * Save category assignments for a location (replace all). + * + * @param int $locationId The location ID. + * @param array $categoryIds Array of category IDs to assign. + * + * @return void + * + * @since 1.0.0 + */ + private function saveCategoryAssignments(int $locationId, array $categoryIds): void + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + // Delete existing assignments + $query = $db->getQuery(true) + ->delete($db->quoteName('#__mokojoomstorelocator_location_categories')) + ->where($db->quoteName('location_id') . ' = :id') + ->bind(':id', $locationId, ParameterType::INTEGER); + $db->setQuery($query); + $db->execute(); + + // Insert new assignments + if (empty($categoryIds)) + { + return; + } + + $query = $db->getQuery(true) + ->insert($db->quoteName('#__mokojoomstorelocator_location_categories')) + ->columns([$db->quoteName('location_id'), $db->quoteName('category_id')]); + + foreach ($categoryIds as $catId) + { + $catId = (int) $catId; + + if ($catId > 0) + { + $query->values($locationId . ',' . $catId); + } + } + + $db->setQuery($query); + $db->execute(); + } } diff --git a/src/packages/com_mokojoomstorelocator/admin/src/Model/LocationsModel.php b/src/packages/com_mokojoomstorelocator/admin/src/Model/LocationsModel.php index ee3312a..32ba268 100644 --- a/src/packages/com_mokojoomstorelocator/admin/src/Model/LocationsModel.php +++ b/src/packages/com_mokojoomstorelocator/admin/src/Model/LocationsModel.php @@ -44,6 +44,20 @@ class LocationsModel extends ListModel parent::__construct($config); } + protected function populateState($ordering = 'a.ordering', $direction = 'ASC') + { + $search = $this->getUserStateFromRequest($this->context . '.filter.search', 'filter_search', '', 'string'); + $this->setState('filter.search', $search); + + $published = $this->getUserStateFromRequest($this->context . '.filter.published', 'filter_published', '', 'string'); + $this->setState('filter.published', $published); + + $categoryId = $this->getUserStateFromRequest($this->context . '.filter.category_id', 'filter_category_id', 0, 'int'); + $this->setState('filter.category_id', $categoryId); + + parent::populateState($ordering, $direction); + } + /** * Build an SQL query to load the list data. * @@ -59,10 +73,46 @@ class LocationsModel extends ListModel $query->select('a.*') ->from($db->quoteName('#__mokojoomstorelocator_locations', 'a')); - // TODO: Add filter by published state - // TODO: Add filter by category - // TODO: Add search filter - // TODO: Add ordering clause + $published = $this->getState('filter.published'); + + if (is_numeric($published)) + { + $query->where($db->quoteName('a.published') . ' = :published') + ->bind(':published', $published, \Joomla\Database\ParameterType::INTEGER); + } + elseif ($published === '') + { + $query->where($db->quoteName('a.published') . ' IN (0, 1)'); + } + + $catId = (int) $this->getState('filter.category_id'); + + if ($catId > 0) + { + $query->where($db->quoteName('a.catid') . ' = :catid') + ->bind(':catid', $catId, \Joomla\Database\ParameterType::INTEGER); + } + + $search = $this->getState('filter.search'); + + if (!empty($search)) + { + $search = '%' . trim($search) . '%'; + $query->where( + '(' . $db->quoteName('a.title') . ' LIKE :search1' + . ' OR ' . $db->quoteName('a.city') . ' LIKE :search2' + . ' OR ' . $db->quoteName('a.address') . ' LIKE :search3' + . ' OR ' . $db->quoteName('a.postcode') . ' LIKE :search4)' + ) + ->bind(':search1', $search) + ->bind(':search2', $search) + ->bind(':search3', $search) + ->bind(':search4', $search); + } + + $orderCol = $this->state->get('list.ordering', 'a.ordering'); + $orderDir = $this->state->get('list.direction', 'ASC'); + $query->order($db->escape($orderCol) . ' ' . $db->escape($orderDir)); return $query; } diff --git a/src/packages/com_mokojoomstorelocator/admin/src/Table/CategoryTable.php b/src/packages/com_mokojoomstorelocator/admin/src/Table/CategoryTable.php new file mode 100644 index 0000000..bbd3013 --- /dev/null +++ b/src/packages/com_mokojoomstorelocator/admin/src/Table/CategoryTable.php @@ -0,0 +1,58 @@ +title)) + { + $this->setError('A category title is required.'); + + return false; + } + + if (empty($this->alias)) + { + $this->alias = $this->title; + } + + $this->alias = OutputFilter::stringURLSafe($this->alias); + + if (empty($this->alias)) + { + $this->alias = \Joomla\CMS\Factory::getDate()->format('Y-m-d-H-i-s'); + } + + // Validate color is a hex value + if (!empty($this->color) && !preg_match('/^#[0-9a-fA-F]{6}$/', $this->color)) + { + $this->color = '#3b82f6'; + } + + return parent::check(); + } +} diff --git a/src/packages/com_mokojoomstorelocator/admin/src/Table/LocationTable.php b/src/packages/com_mokojoomstorelocator/admin/src/Table/LocationTable.php index 10d81b2..a0f5fd6 100644 --- a/src/packages/com_mokojoomstorelocator/admin/src/Table/LocationTable.php +++ b/src/packages/com_mokojoomstorelocator/admin/src/Table/LocationTable.php @@ -43,10 +43,69 @@ class LocationTable extends Table */ public function check(): bool { - // TODO: Validate title is not empty - // TODO: Auto-generate alias from title if empty - // TODO: Validate latitude/longitude ranges - // TODO: Set created/modified timestamps + if (empty($this->title)) + { + $this->setError('A location title is required.'); + + return false; + } + + if (empty($this->alias)) + { + $this->alias = $this->title; + } + + $this->alias = \Joomla\CMS\Filter\OutputFilter::stringURLSafe($this->alias); + + if (empty($this->alias)) + { + $this->alias = \Joomla\CMS\Factory::getDate()->format('Y-m-d-H-i-s'); + } + + if ($this->latitude !== null && ($this->latitude < -90 || $this->latitude > 90)) + { + $this->setError('Latitude must be between -90 and 90.'); + + return false; + } + + if ($this->longitude !== null && ($this->longitude < -180 || $this->longitude > 180)) + { + $this->setError('Longitude must be between -180 and 180.'); + + return false; + } + + $now = \Joomla\CMS\Factory::getDate()->toSql(); + + if (empty($this->created) || $this->created === '0000-00-00 00:00:00') + { + $this->created = $now; + $this->created_by = \Joomla\CMS\Factory::getApplication()->getIdentity()->id ?? 0; + } + + $this->modified = $now; + $this->modified_by = \Joomla\CMS\Factory::getApplication()->getIdentity()->id ?? 0; + + // Auto-geocode if address present but coordinates missing + if ((empty($this->latitude) || empty($this->longitude)) + && (!empty($this->address) || !empty($this->city))) + { + $geocoder = new \Moko\Component\MokoJoomStoreLocator\Administrator\Helper\Geocoder(); + $coords = $geocoder->geocode( + $this->address ?? '', + $this->city ?? '', + $this->state ?? '', + $this->postcode ?? '', + $this->country ?? '' + ); + + if ($coords) + { + $this->latitude = $coords['lat']; + $this->longitude = $coords['lng']; + } + } return parent::check(); } diff --git a/src/packages/com_mokojoomstorelocator/admin/src/View/Categories/HtmlView.php b/src/packages/com_mokojoomstorelocator/admin/src/View/Categories/HtmlView.php new file mode 100644 index 0000000..84a30b3 --- /dev/null +++ b/src/packages/com_mokojoomstorelocator/admin/src/View/Categories/HtmlView.php @@ -0,0 +1,29 @@ +items = $this->get('Items'); + $this->pagination = $this->get('Pagination'); + $this->state = $this->get('State'); + + ToolbarHelper::title('Store Locator: Categories'); + ToolbarHelper::addNew('category.add'); + ToolbarHelper::publish('categories.publish', 'JTOOLBAR_PUBLISH', true); + ToolbarHelper::unpublish('categories.unpublish', 'JTOOLBAR_UNPUBLISH', true); + ToolbarHelper::deleteList('', 'categories.delete', 'JTOOLBAR_DELETE'); + + parent::display($tpl); + } +} diff --git a/src/packages/com_mokojoomstorelocator/admin/src/View/Categories/JsonapiView.php b/src/packages/com_mokojoomstorelocator/admin/src/View/Categories/JsonapiView.php new file mode 100644 index 0000000..2ff5fe2 --- /dev/null +++ b/src/packages/com_mokojoomstorelocator/admin/src/View/Categories/JsonapiView.php @@ -0,0 +1,17 @@ +form = $this->get('Form'); + $this->item = $this->get('Item'); + + Factory::getApplication()->getInput()->set('hidemainmenu', true); + + $isNew = ($this->item->id == 0); + ToolbarHelper::title($isNew ? 'Store Locator: New Category' : 'Store Locator: Edit Category'); + ToolbarHelper::apply('category.apply'); + ToolbarHelper::save('category.save'); + ToolbarHelper::save2new('category.save2new'); + ToolbarHelper::cancel('category.cancel', $isNew ? 'JTOOLBAR_CANCEL' : 'JTOOLBAR_CLOSE'); + + parent::display($tpl); + } +} diff --git a/src/packages/com_mokojoomstorelocator/admin/src/View/Import/HtmlView.php b/src/packages/com_mokojoomstorelocator/admin/src/View/Import/HtmlView.php new file mode 100644 index 0000000..e2c51e7 --- /dev/null +++ b/src/packages/com_mokojoomstorelocator/admin/src/View/Import/HtmlView.php @@ -0,0 +1,38 @@ +form = $this->get('Form'); + $this->item = $this->get('Item'); + + $this->addToolbar(); + + parent::display($tpl); + } + + /** + * Add the page title and toolbar. + * + * @return void + * + * @since 1.0.0 + */ + protected function addToolbar(): void + { + Factory::getApplication()->getInput()->set('hidemainmenu', true); + + $isNew = ($this->item->id == 0); + + ToolbarHelper::title( + $isNew ? 'Store Locator: New Location' : 'Store Locator: Edit Location' + ); + + ToolbarHelper::apply('location.apply'); + ToolbarHelper::save('location.save'); + ToolbarHelper::save2new('location.save2new'); + + if (!$isNew) + { + ToolbarHelper::save2copy('location.save2copy'); + } + + ToolbarHelper::cancel('location.cancel', $isNew ? 'JTOOLBAR_CANCEL' : 'JTOOLBAR_CLOSE'); + } +} diff --git a/src/packages/com_mokojoomstorelocator/admin/src/View/Locations/HtmlView.php b/src/packages/com_mokojoomstorelocator/admin/src/View/Locations/HtmlView.php index 2fd41e5..18db727 100644 --- a/src/packages/com_mokojoomstorelocator/admin/src/View/Locations/HtmlView.php +++ b/src/packages/com_mokojoomstorelocator/admin/src/View/Locations/HtmlView.php @@ -11,6 +11,8 @@ namespace Moko\Component\MokoJoomStoreLocator\Administrator\View\Locations; defined('_JEXEC') or die; use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView; +use Joomla\CMS\Session\Session; +use Joomla\CMS\Toolbar\Toolbar; use Joomla\CMS\Toolbar\ToolbarHelper; /** @@ -78,5 +80,15 @@ class HtmlView extends BaseHtmlView ToolbarHelper::publish('locations.publish', 'JTOOLBAR_PUBLISH', true); ToolbarHelper::unpublish('locations.unpublish', 'JTOOLBAR_UNPUBLISH', true); ToolbarHelper::deleteList('', 'locations.delete', 'JTOOLBAR_DELETE'); + + $toolbar = Toolbar::getInstance('toolbar'); + + $exportUrl = 'index.php?option=com_mokojoomstorelocator&task=export.execute&' . Session::getFormToken() . '=1'; + $toolbar->standardButton('download', 'COM_MOKOJOOMSTORELOCATOR_EXPORT_CSV', '')->icon('icon-download')->url($exportUrl); + + $sampleUrl = 'index.php?option=com_mokojoomstorelocator&task=sampledata.inject&' . Session::getFormToken() . '=1'; + $toolbar->standardButton('lightning', 'COM_MOKOJOOMSTORELOCATOR_SAMPLEDATA_INJECT', '')->icon('icon-lightning')->url($sampleUrl); + + ToolbarHelper::preferences('com_mokojoomstorelocator'); } } diff --git a/src/packages/com_mokojoomstorelocator/admin/src/View/Locations/JsonapiView.php b/src/packages/com_mokojoomstorelocator/admin/src/View/Locations/JsonapiView.php new file mode 100644 index 0000000..609aaf4 --- /dev/null +++ b/src/packages/com_mokojoomstorelocator/admin/src/View/Locations/JsonapiView.php @@ -0,0 +1,25 @@ + +
+
+ items)) : ?> +
+ + +
+ + + + + + + + + + + + + + + items as $i => $item) : ?> + + + + + + + + + + + +
+ id, false, 'cid', 'cb', $item->title); ?> + + + escape($item->title); ?> + + marker_icon) : ?> + + + + + escape($item->color); ?> + + parent_title ? $this->escape($item->parent_title) : '—'; ?> + + location_count; ?> + + published, $i, 'categories.', true, 'cb'); ?> + id; ?>
+ pagination->getListFooter(); ?> + + + + + +
+
diff --git a/src/packages/com_mokojoomstorelocator/admin/tmpl/category/edit.php b/src/packages/com_mokojoomstorelocator/admin/tmpl/category/edit.php new file mode 100644 index 0000000..916a363 --- /dev/null +++ b/src/packages/com_mokojoomstorelocator/admin/tmpl/category/edit.php @@ -0,0 +1,66 @@ + +
+ + 'details', 'recall' => true]); ?> + + +
+
+ form->renderField('title'); ?> + form->renderField('alias'); ?> + form->renderField('parent_id'); ?> + form->renderField('description'); ?> +
+
+ form->renderField('published'); ?> + form->renderField('id'); ?> +
+
+ + + +
+
+ form->renderField('color'); ?> + form->renderField('marker_icon'); ?> + form->renderField('image'); ?> +
+
+
+
+
Marker Preview
+

The color swatch shows how this category's markers will appear on the map. Upload a custom SVG/PNG marker icon to override the default pin.

+
+
+
+
+
+ + + + + + + +
diff --git a/src/packages/com_mokojoomstorelocator/admin/tmpl/import/default.php b/src/packages/com_mokojoomstorelocator/admin/tmpl/import/default.php new file mode 100644 index 0000000..517d656 --- /dev/null +++ b/src/packages/com_mokojoomstorelocator/admin/tmpl/import/default.php @@ -0,0 +1,298 @@ + '— Skip —', + 'title' => 'Title *', + 'address' => 'Address', + 'city' => 'City', + 'state' => 'State', + 'postcode' => 'Postal Code', + 'country' => 'Country', + 'latitude' => 'Latitude', + 'longitude' => 'Longitude', + 'phone' => 'Phone', + 'email' => 'Email', + 'website' => 'Website', + 'hours' => 'Hours', + 'description' => 'Description', + 'video_url' => 'Video URL', +]; +?> +
+
+
+
+
+
+

+
+
+

Step 1: Upload your CSV file. Step 2: Map columns. Step 3: Preview and import.

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

+
+

Supports CSV with any column order. Map your columns in step 2.

+ + + + +
+
+
+
+
+
+ + + + + + diff --git a/src/packages/com_mokojoomstorelocator/admin/tmpl/location/edit.php b/src/packages/com_mokojoomstorelocator/admin/tmpl/location/edit.php new file mode 100644 index 0000000..09d4d25 --- /dev/null +++ b/src/packages/com_mokojoomstorelocator/admin/tmpl/location/edit.php @@ -0,0 +1,158 @@ +getDocument()->getWebAssetManager(); +$wa->useScript('keepalive') + ->useScript('form.validate'); +?> +
+ + 'details', 'recall' => true, 'breakpoint' => 768]); ?> + + +
+
+ form->renderField('title'); ?> + form->renderField('alias'); ?> + form->renderField('description'); ?> +
+
+ form->renderField('published'); ?> + form->renderField('id'); ?> +
+
+ + + +
+
+ form->renderField('address'); ?> + form->renderField('city'); ?> + form->renderField('state'); ?> + form->renderField('postcode'); ?> + form->renderField('country'); ?> +
+
+
+
+

+ Click the map to set coordinates +
+
+
+
+
+ form->renderField('latitude'); ?> +
+
+ form->renderField('longitude'); ?> +
+
+
+
+
+
+ + + +
+
+ form->renderField('phone'); ?> + form->renderField('email'); ?> + form->renderField('website'); ?> +
+
+ form->renderField('hours'); ?> +
+
+ + + + form->renderField('image'); ?> + + + + + + +
+ + + + diff --git a/src/packages/com_mokojoomstorelocator/admin/tmpl/locations/default.php b/src/packages/com_mokojoomstorelocator/admin/tmpl/locations/default.php index ab535a8..bf98c9a 100644 --- a/src/packages/com_mokojoomstorelocator/admin/tmpl/locations/default.php +++ b/src/packages/com_mokojoomstorelocator/admin/tmpl/locations/default.php @@ -54,7 +54,31 @@ use Joomla\CMS\Router\Route; - + items as $i => $item) : ?> + + + id, false, 'cid', 'cb', $item->title); ?> + + + + escape($item->title); ?> + +
escape($item->alias); ?>
+ + + escape($item->city); ?> + + + escape($item->state); ?> + + + published, $i, 'locations.', true, 'cb'); ?> + + + id; ?> + + + diff --git a/src/packages/com_mokojoomstorelocator/mokojoomstorelocator.xml b/src/packages/com_mokojoomstorelocator/mokojoomstorelocator.xml index d1396e4..e91c31e 100644 --- a/src/packages/com_mokojoomstorelocator/mokojoomstorelocator.xml +++ b/src/packages/com_mokojoomstorelocator/mokojoomstorelocator.xml @@ -14,15 +14,15 @@ ========================================================================= --> - com_mokojoomstorelocator - 1.0.0 - 2026-05-21 + Moko Store Locator + 00.00.01 + 2026-05-22 Moko Consulting hello@mokoconsulting.tech https://mokoconsulting.tech Copyright (C) 2026 Moko Consulting. All rights reserved. GNU General Public License version 3 or later; see LICENSE - COM_MOKOJOOMSTORELOCATOR_DESC + A store locator component for managing and displaying location listings. Moko\Component\MokoJoomStoreLocator @@ -37,8 +37,14 @@ sql/uninstall.mysql.sql +n + + sql/updates/mysql + + + css language src tmpl @@ -47,7 +53,8 @@ forms - language + css + language services sql src @@ -57,6 +64,44 @@ COM_MOKOJOOMSTORELOCATOR COM_MOKOJOOMSTORELOCATOR_LOCATIONS + COM_MOKOJOOMSTORELOCATOR_CATEGORIES + COM_MOKOJOOMSTORELOCATOR_IMPORT + + + +
+ + + + + + + + + + + +
+
+
diff --git a/src/packages/com_mokojoomstorelocator/site/css/storelocator.css b/src/packages/com_mokojoomstorelocator/site/css/storelocator.css new file mode 100644 index 0000000..52d611b --- /dev/null +++ b/src/packages/com_mokojoomstorelocator/site/css/storelocator.css @@ -0,0 +1,198 @@ +/* MokoJoomStoreLocator — Responsive site styles + * Copyright (C) 2026 Moko Consulting. All rights reserved. + * License: GPL-3.0-or-later + */ + +/* === Location list === */ +.mokojoomstorelocator-list { + display: grid; + gap: 1.5rem; +} + +.mokojoomstorelocator-location { + border: 1px solid #e5e7eb; + border-radius: 8px; + padding: 1.25rem; + transition: box-shadow 0.2s; +} + +.mokojoomstorelocator-location:hover { + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); +} + +.mokojoomstorelocator-location h3 { + margin: 0 0 0.5rem; + font-size: 1.15rem; +} + +.mokojoomstorelocator-location h3 a { + text-decoration: none; +} + +.mokojoomstorelocator-address, +.mokojoomstorelocator-phone, +.mokojoomstorelocator-website, +.mokojoomstorelocator-hours, +.mokojoomstorelocator-distance, +.mokojoomstorelocator-directions, +.mokojoomstorelocator-categories-tags { + margin-top: 0.35rem; + font-size: 0.9rem; +} + +.mokojoomstorelocator-categories-tags span { + display: inline-block; + padding: 2px 8px; + border-radius: 12px; + font-size: 0.8rem; + color: #fff; + margin-right: 4px; + margin-bottom: 4px; +} + +/* === Image === */ +.mokojoomstorelocator-image img { + max-width: 100%; + height: auto; + border-radius: 6px; +} + +/* === Gallery (multi-image) === */ +.mokojoomstorelocator-gallery { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + gap: 0.5rem; + margin-top: 1rem; +} + +.mokojoomstorelocator-gallery img { + width: 100%; + height: 120px; + object-fit: cover; + border-radius: 4px; + cursor: pointer; +} + +/* === Video embed === */ +.mokojoomstorelocator-video { + position: relative; + padding-bottom: 56.25%; + height: 0; + overflow: hidden; + margin-top: 1rem; + border-radius: 6px; +} + +.mokojoomstorelocator-video iframe { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + border: 0; +} + +/* === Business hours === */ +.mokojoomstorelocator-hours-table { + width: 100%; + font-size: 0.9rem; + border-collapse: collapse; +} + +.mokojoomstorelocator-hours-table td { + padding: 3px 8px; + border-bottom: 1px solid #f3f4f6; +} + +.mokojoomstorelocator-hours-table td:first-child { + font-weight: 600; + width: 100px; +} + +.mokojoomstorelocator-open-badge { + display: inline-block; + padding: 2px 10px; + border-radius: 12px; + font-size: 0.8rem; + font-weight: 600; +} + +.mokojoomstorelocator-open-badge--open { + background: #dcfce7; + color: #166534; +} + +.mokojoomstorelocator-open-badge--closed { + background: #fee2e2; + color: #991b1b; +} + +/* === Responsive === */ +@media (min-width: 768px) { + .mokojoomstorelocator-list { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (max-width: 767px) { + .com-mokojoomstorelocator-location .row { + flex-direction: column; + } + + .com-mokojoomstorelocator-location .col-lg-5, + .com-mokojoomstorelocator-location .col-lg-7 { + width: 100%; + } + + /* Click-to-call on mobile */ + a[href^="tel:"] { + display: inline-block; + padding: 6px 16px; + background: #3b82f6; + color: #fff; + border-radius: 6px; + text-decoration: none; + font-weight: 600; + } +} + +/* === Print === */ +@media print { + .mokojoomstorelocator-directions, + .mod-mokojoomstorelocator-search, + .mod-mokojoomstorelocator-map, + .mokojoomstorelocator-video, + .btn, + nav, + footer { + display: none !important; + } + + .mokojoomstorelocator-location { + break-inside: avoid; + border: 1px solid #ccc; + padding: 0.75rem; + margin-bottom: 0.75rem; + } + + .com-mokojoomstorelocator-location { + font-size: 12pt; + } + + .com-mokojoomstorelocator-location .col-lg-5 { + display: none; + } + + .mokojoomstorelocator-print-map { + display: block !important; + max-width: 300px; + } +} + +.mokojoomstorelocator-print-btn { + cursor: pointer; +} + +.mokojoomstorelocator-print-map { + display: none; +} diff --git a/src/packages/com_mokojoomstorelocator/site/joomla.asset.json b/src/packages/com_mokojoomstorelocator/site/joomla.asset.json new file mode 100644 index 0000000..d45043f --- /dev/null +++ b/src/packages/com_mokojoomstorelocator/site/joomla.asset.json @@ -0,0 +1,39 @@ +{ + "$schema": "https://developer.joomla.org/schemas/json-schema/web_assets.json", + "name": "com_mokojoomstorelocator", + "version": "1.0.0", + "description": "Web assets for MokoJoomStoreLocator", + "license": "GPL-3.0-or-later", + "assets": [ + { + "name": "com_mokojoomstorelocator.site", + "type": "style", + "uri": "components/com_mokojoomstorelocator/css/storelocator.css" + }, + { + "name": "com_mokojoomstorelocator.leaflet", + "type": "script", + "uri": "https://unpkg.com/leaflet@1.9.4/dist/leaflet.js", + "attributes": { "crossorigin": "" } + }, + { + "name": "com_mokojoomstorelocator.leaflet.css", + "type": "style", + "uri": "https://unpkg.com/leaflet@1.9.4/dist/leaflet.css", + "attributes": { "crossorigin": "" } + }, + { + "name": "com_mokojoomstorelocator.markercluster", + "type": "script", + "uri": "https://unpkg.com/leaflet.markercluster@1.5.3/dist/leaflet.markercluster.js", + "dependencies": ["com_mokojoomstorelocator.leaflet"], + "attributes": { "crossorigin": "" } + }, + { + "name": "com_mokojoomstorelocator.markercluster.css", + "type": "style", + "uri": "https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.css", + "attributes": { "crossorigin": "" } + } + ] +} diff --git a/src/packages/com_mokojoomstorelocator/site/language/en-GB/com_mokojoomstorelocator.ini b/src/packages/com_mokojoomstorelocator/site/language/en-GB/com_mokojoomstorelocator.ini new file mode 100644 index 0000000..0b9c432 --- /dev/null +++ b/src/packages/com_mokojoomstorelocator/site/language/en-GB/com_mokojoomstorelocator.ini @@ -0,0 +1,32 @@ +; MokoJoomStoreLocator - Site language strings +; Copyright (C) 2026 Moko Consulting. All rights reserved. +; License: GNU General Public License version 3 or later; see LICENSE + +COM_MOKOJOOMSTORELOCATOR="Store Locator" +COM_MOKOJOOMSTORELOCATOR_LOCATIONS="Store Locations" +COM_MOKOJOOMSTORELOCATOR_NO_LOCATIONS="No locations found." +COM_MOKOJOOMSTORELOCATOR_LOCATION_DETAIL="Location Detail" +COM_MOKOJOOMSTORELOCATOR_GET_DIRECTIONS="Get Directions" +COM_MOKOJOOMSTORELOCATOR_BACK_TO_LOCATIONS="Back to All Locations" +COM_MOKOJOOMSTORELOCATOR_FIELDSET_ADDRESS="Address" +COM_MOKOJOOMSTORELOCATOR_FIELDSET_CONTACT="Contact Information" +COM_MOKOJOOMSTORELOCATOR_FIELD_PHONE="Phone" +COM_MOKOJOOMSTORELOCATOR_FIELD_WEBSITE="Website" +COM_MOKOJOOMSTORELOCATOR_FIELD_HOURS="Business Hours" +COM_MOKOJOOMSTORELOCATOR_PRINT="Print" +COM_MOKOJOOMSTORELOCATOR_ADDITIONAL_INFO="Additional Information" +COM_MOKOJOOMSTORELOCATOR_CATEGORY="Category" +COM_MOKOJOOMSTORELOCATOR_CATEGORIES="Categories" + +COM_MOKOJOOMSTORELOCATOR_CONTACT_TITLE="Contact This Location" +COM_MOKOJOOMSTORELOCATOR_CONTACT_NAME="Your Name" +COM_MOKOJOOMSTORELOCATOR_CONTACT_EMAIL="Your Email" +COM_MOKOJOOMSTORELOCATOR_CONTACT_SUBJECT="Subject" +COM_MOKOJOOMSTORELOCATOR_CONTACT_MESSAGE="Message" +COM_MOKOJOOMSTORELOCATOR_CONTACT_SEND="Send Message" +COM_MOKOJOOMSTORELOCATOR_CONTACT_SENT="Your message has been sent." +COM_MOKOJOOMSTORELOCATOR_CONTACT_SEND_FAILED="Unable to send your message. Please try again later." +COM_MOKOJOOMSTORELOCATOR_CONTACT_FIELDS_REQUIRED="Please fill in all required fields." +COM_MOKOJOOMSTORELOCATOR_CONTACT_INVALID_EMAIL="Please enter a valid email address." +COM_MOKOJOOMSTORELOCATOR_CONTACT_NO_RECIPIENT="This location does not accept messages." +COM_MOKOJOOMSTORELOCATOR_CONTACT_CAPTCHA_FAILED="Captcha verification failed." diff --git a/src/packages/com_mokojoomstorelocator/site/language/en-US/com_mokojoomstorelocator.ini b/src/packages/com_mokojoomstorelocator/site/language/en-US/com_mokojoomstorelocator.ini new file mode 100644 index 0000000..0b9c432 --- /dev/null +++ b/src/packages/com_mokojoomstorelocator/site/language/en-US/com_mokojoomstorelocator.ini @@ -0,0 +1,32 @@ +; MokoJoomStoreLocator - Site language strings +; Copyright (C) 2026 Moko Consulting. All rights reserved. +; License: GNU General Public License version 3 or later; see LICENSE + +COM_MOKOJOOMSTORELOCATOR="Store Locator" +COM_MOKOJOOMSTORELOCATOR_LOCATIONS="Store Locations" +COM_MOKOJOOMSTORELOCATOR_NO_LOCATIONS="No locations found." +COM_MOKOJOOMSTORELOCATOR_LOCATION_DETAIL="Location Detail" +COM_MOKOJOOMSTORELOCATOR_GET_DIRECTIONS="Get Directions" +COM_MOKOJOOMSTORELOCATOR_BACK_TO_LOCATIONS="Back to All Locations" +COM_MOKOJOOMSTORELOCATOR_FIELDSET_ADDRESS="Address" +COM_MOKOJOOMSTORELOCATOR_FIELDSET_CONTACT="Contact Information" +COM_MOKOJOOMSTORELOCATOR_FIELD_PHONE="Phone" +COM_MOKOJOOMSTORELOCATOR_FIELD_WEBSITE="Website" +COM_MOKOJOOMSTORELOCATOR_FIELD_HOURS="Business Hours" +COM_MOKOJOOMSTORELOCATOR_PRINT="Print" +COM_MOKOJOOMSTORELOCATOR_ADDITIONAL_INFO="Additional Information" +COM_MOKOJOOMSTORELOCATOR_CATEGORY="Category" +COM_MOKOJOOMSTORELOCATOR_CATEGORIES="Categories" + +COM_MOKOJOOMSTORELOCATOR_CONTACT_TITLE="Contact This Location" +COM_MOKOJOOMSTORELOCATOR_CONTACT_NAME="Your Name" +COM_MOKOJOOMSTORELOCATOR_CONTACT_EMAIL="Your Email" +COM_MOKOJOOMSTORELOCATOR_CONTACT_SUBJECT="Subject" +COM_MOKOJOOMSTORELOCATOR_CONTACT_MESSAGE="Message" +COM_MOKOJOOMSTORELOCATOR_CONTACT_SEND="Send Message" +COM_MOKOJOOMSTORELOCATOR_CONTACT_SENT="Your message has been sent." +COM_MOKOJOOMSTORELOCATOR_CONTACT_SEND_FAILED="Unable to send your message. Please try again later." +COM_MOKOJOOMSTORELOCATOR_CONTACT_FIELDS_REQUIRED="Please fill in all required fields." +COM_MOKOJOOMSTORELOCATOR_CONTACT_INVALID_EMAIL="Please enter a valid email address." +COM_MOKOJOOMSTORELOCATOR_CONTACT_NO_RECIPIENT="This location does not accept messages." +COM_MOKOJOOMSTORELOCATOR_CONTACT_CAPTCHA_FAILED="Captcha verification failed." diff --git a/src/packages/com_mokojoomstorelocator/site/services/provider.php b/src/packages/com_mokojoomstorelocator/site/services/provider.php new file mode 100644 index 0000000..b6518ab --- /dev/null +++ b/src/packages/com_mokojoomstorelocator/site/services/provider.php @@ -0,0 +1,12 @@ +getInput(); + + $locationId = $input->getInt('location_id', 0); + $senderName = $input->getString('contact_name', ''); + $senderEmail = $input->getString('contact_email', ''); + $subject = $input->getString('contact_subject', ''); + $message = $input->getString('contact_message', ''); + + // Validate captcha if configured + $captchaPlugin = $app->get('captcha', ''); + + if ($captchaPlugin && $captchaPlugin !== '0') + { + $captcha = \Joomla\CMS\Captcha\Captcha::getInstance($captchaPlugin); + + if (!$captcha->checkAnswer('')) + { + $app->enqueueMessage(Text::_('COM_MOKOJOOMSTORELOCATOR_CONTACT_CAPTCHA_FAILED'), 'error'); + $app->redirect(Route::_('index.php?option=com_mokojoomstorelocator&view=location&id=' . $locationId, false)); + + return; + } + } + + // Validate required fields + if (empty($senderName) || empty($senderEmail) || empty($message) || !$locationId) + { + $app->enqueueMessage(Text::_('COM_MOKOJOOMSTORELOCATOR_CONTACT_FIELDS_REQUIRED'), 'error'); + $app->redirect(Route::_('index.php?option=com_mokojoomstorelocator&view=location&id=' . $locationId, false)); + + return; + } + + if (!filter_var($senderEmail, FILTER_VALIDATE_EMAIL)) + { + $app->enqueueMessage(Text::_('COM_MOKOJOOMSTORELOCATOR_CONTACT_INVALID_EMAIL'), 'error'); + $app->redirect(Route::_('index.php?option=com_mokojoomstorelocator&view=location&id=' . $locationId, false)); + + return; + } + + // Load location to get recipient email + $db = Factory::getContainer()->get(DatabaseInterface::class); + $query = $db->getQuery(true) + ->select([$db->quoteName('title'), $db->quoteName('email')]) + ->from($db->quoteName('#__mokojoomstorelocator_locations')) + ->where($db->quoteName('id') . ' = :id') + ->bind(':id', $locationId, ParameterType::INTEGER); + $db->setQuery($query); + $location = $db->loadObject(); + + if (!$location || empty($location->email)) + { + $app->enqueueMessage(Text::_('COM_MOKOJOOMSTORELOCATOR_CONTACT_NO_RECIPIENT'), 'error'); + $app->redirect(Route::_('index.php?option=com_mokojoomstorelocator&view=location&id=' . $locationId, false)); + + return; + } + + // Send email + try + { + $mailer = Factory::getContainer()->get(MailerFactoryInterface::class)->createMailer(); + $mailer->addRecipient($location->email); + $mailer->addReplyTo($senderEmail, $senderName); + $mailer->setSubject('[Store Locator] ' . ($subject ?: 'Contact from ' . $senderName)); + $mailer->setBody( + "Name: $senderName\n" + . "Email: $senderEmail\n" + . "Location: {$location->title}\n\n" + . "Message:\n$message" + ); + + $mailer->Send(); + + $app->enqueueMessage(Text::_('COM_MOKOJOOMSTORELOCATOR_CONTACT_SENT'), 'success'); + } + catch (\Exception $e) + { + $app->enqueueMessage(Text::_('COM_MOKOJOOMSTORELOCATOR_CONTACT_SEND_FAILED'), 'error'); + } + + $app->redirect(Route::_('index.php?option=com_mokojoomstorelocator&view=location&id=' . $locationId, false)); + } +} diff --git a/src/packages/com_mokojoomstorelocator/site/src/Controller/DisplayController.php b/src/packages/com_mokojoomstorelocator/site/src/Controller/DisplayController.php new file mode 100644 index 0000000..e01f97e --- /dev/null +++ b/src/packages/com_mokojoomstorelocator/site/src/Controller/DisplayController.php @@ -0,0 +1,29 @@ +getDatabase(); + $query = $db->getQuery(true); + $catId = (int) $this->getState('category.id'); + + $query->select('a.*') + ->from($db->quoteName('#__mokojoomstorelocator_locations', 'a')) + ->join('INNER', $db->quoteName('#__mokojoomstorelocator_location_categories', 'lc') + . ' ON lc.location_id = a.id') + ->where($db->quoteName('a.published') . ' = 1') + ->where($db->quoteName('lc.category_id') . ' = :catid') + ->bind(':catid', $catId, ParameterType::INTEGER) + ->order($db->quoteName('a.ordering') . ' ASC'); + + return $query; + } + + /** + * Get the category record. + * + * @return object|null + * + * @since 1.0.0 + */ + public function getCategory(): ?object + { + $catId = (int) $this->getState('category.id'); + + if (!$catId) + { + return null; + } + + $db = $this->getDatabase(); + $query = $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__mokojoomstorelocator_categories')) + ->where($db->quoteName('id') . ' = :id') + ->where($db->quoteName('published') . ' = 1') + ->bind(':id', $catId, ParameterType::INTEGER); + + $db->setQuery($query); + + return $db->loadObject(); + } +} diff --git a/src/packages/com_mokojoomstorelocator/site/src/Model/LocationModel.php b/src/packages/com_mokojoomstorelocator/site/src/Model/LocationModel.php new file mode 100644 index 0000000..e721bd7 --- /dev/null +++ b/src/packages/com_mokojoomstorelocator/site/src/Model/LocationModel.php @@ -0,0 +1,54 @@ +getState('location.id'); + + if (!$pk) + { + return false; + } + + $db = $this->getDatabase(); + $query = $db->getQuery(true); + + $query->select('a.*') + ->from($db->quoteName('#__mokojoomstorelocator_locations', 'a')) + ->where($db->quoteName('a.id') . ' = :id') + ->where($db->quoteName('a.published') . ' = 1') + ->bind(':id', $pk, ParameterType::INTEGER); + + $db->setQuery($query); + + return $db->loadObject(); + } +} diff --git a/src/packages/com_mokojoomstorelocator/site/src/Model/LocationsModel.php b/src/packages/com_mokojoomstorelocator/site/src/Model/LocationsModel.php new file mode 100644 index 0000000..f3937bb --- /dev/null +++ b/src/packages/com_mokojoomstorelocator/site/src/Model/LocationsModel.php @@ -0,0 +1,116 @@ +getDatabase(); + $query = $db->getQuery(true); + + $query->select('a.*') + ->from($db->quoteName('#__mokojoomstorelocator_locations', 'a')) + ->where($db->quoteName('a.published') . ' = 1'); + + $catId = (int) $this->getState('filter.category_id'); + + if ($catId > 0) + { + $query->where($db->quoteName('a.catid') . ' = :catid') + ->bind(':catid', $catId, \Joomla\Database\ParameterType::INTEGER); + } + + $search = $this->getState('filter.search'); + + if (!empty($search)) + { + $search = '%' . trim($search) . '%'; + $query->where( + '(' . $db->quoteName('a.title') . ' LIKE :search1' + . ' OR ' . $db->quoteName('a.city') . ' LIKE :search2' + . ' OR ' . $db->quoteName('a.address') . ' LIKE :search3' + . ' OR ' . $db->quoteName('a.postcode') . ' LIKE :search4)' + ) + ->bind(':search1', $search) + ->bind(':search2', $search) + ->bind(':search3', $search) + ->bind(':search4', $search); + } + + $city = $this->getState('filter.city'); + + if (!empty($city)) + { + $query->where($db->quoteName('a.city') . ' = :city') + ->bind(':city', $city); + } + + $state = $this->getState('filter.state'); + + if (!empty($state)) + { + $query->where($db->quoteName('a.state') . ' = :state') + ->bind(':state', $state); + } + + // Radius search using Haversine formula (bounding box pre-filter + SQL distance) + $lat = (float) $this->getState('filter.latitude'); + $lng = (float) $this->getState('filter.longitude'); + $radius = (float) $this->getState('filter.radius'); + $unit = $this->getState('filter.radius_unit', 'miles'); + + if ($lat && $lng && $radius > 0) + { + // Earth radius: 3959 miles or 6371 km + $earthRadius = ($unit === 'km') ? 6371 : 3959; + + $query->where($db->quoteName('a.latitude') . ' IS NOT NULL') + ->where($db->quoteName('a.longitude') . ' IS NOT NULL'); + + // Haversine distance calculation + $haversine = sprintf( + '(%f * ACOS(COS(RADIANS(%f)) * COS(RADIANS(a.latitude))' + . ' * COS(RADIANS(a.longitude) - RADIANS(%f))' + . ' + SIN(RADIANS(%f)) * SIN(RADIANS(a.latitude))))', + $earthRadius, + $lat, + $lng, + $lat + ); + + $query->select($haversine . ' AS distance') + ->having('distance <= ' . (float) $radius) + ->order('distance ASC'); + } + else + { + $query->order($db->quoteName('a.ordering') . ' ASC'); + } + + return $query; + } +} diff --git a/src/packages/com_mokojoomstorelocator/site/src/Service/Router.php b/src/packages/com_mokojoomstorelocator/site/src/Service/Router.php new file mode 100644 index 0000000..7053336 --- /dev/null +++ b/src/packages/com_mokojoomstorelocator/site/src/Service/Router.php @@ -0,0 +1,111 @@ +registerView($locations); + + $category = new RouterViewConfiguration('category'); + $category->setKey('id')->setParent($locations); + $this->registerView($category); + + $location = new RouterViewConfiguration('location'); + $location->setKey('id')->setParent($locations); + $this->registerView($location); + + parent::__construct($app, $menu); + + $this->attachRule(new MenuRules($this)); + $this->attachRule(new StandardRules($this)); + $this->attachRule(new NomenuRules($this)); + } + + public function getLocationSegment($id, $query): array + { + return $this->getSegmentFromAlias($id, '#__mokojoomstorelocator_locations'); + } + + public function getLocationId($segment, $query): int|false + { + return $this->getIdFromAlias($segment, '#__mokojoomstorelocator_locations'); + } + + public function getCategorySegment($id, $query): array + { + return $this->getSegmentFromAlias($id, '#__mokojoomstorelocator_categories'); + } + + public function getCategoryId($segment, $query): int|false + { + return $this->getIdFromAlias($segment, '#__mokojoomstorelocator_categories'); + } + + private function getSegmentFromAlias($id, string $table): array + { + if (strpos($id, ':') === false) + { + $db = \Joomla\CMS\Factory::getContainer()->get(DatabaseInterface::class); + $query = $db->getQuery(true) + ->select($db->quoteName('alias')) + ->from($db->quoteName($table)) + ->where($db->quoteName('id') . ' = :id') + ->bind(':id', $id, \Joomla\Database\ParameterType::INTEGER); + $db->setQuery($query); + $alias = $db->loadResult(); + + if ($alias) + { + $id = $id . ':' . $alias; + } + } + + [$numericId, $alias] = explode(':', $id, 2) + [1 => '']; + + return [$numericId => $alias ?: $numericId]; + } + + private function getIdFromAlias($segment, string $table): int|false + { + $db = \Joomla\CMS\Factory::getContainer()->get(DatabaseInterface::class); + $query = $db->getQuery(true) + ->select($db->quoteName('id')) + ->from($db->quoteName($table)) + ->where($db->quoteName('alias') . ' = :alias') + ->bind(':alias', $segment); + $db->setQuery($query); + $id = $db->loadResult(); + + return $id ? (int) $id : (int) $segment; + } +} diff --git a/src/packages/com_mokojoomstorelocator/site/src/View/Category/HtmlView.php b/src/packages/com_mokojoomstorelocator/site/src/View/Category/HtmlView.php new file mode 100644 index 0000000..ea6897b --- /dev/null +++ b/src/packages/com_mokojoomstorelocator/site/src/View/Category/HtmlView.php @@ -0,0 +1,32 @@ +items = $this->get('Items'); + $this->category = $this->get('Category'); + $this->pagination = $this->get('Pagination'); + + if ($this->category) + { + $this->getDocument()->setTitle($this->category->title . ' — Store Locator'); + } + + parent::display($tpl); + } +} diff --git a/src/packages/com_mokojoomstorelocator/site/src/View/Location/HtmlView.php b/src/packages/com_mokojoomstorelocator/site/src/View/Location/HtmlView.php new file mode 100644 index 0000000..100d2a7 --- /dev/null +++ b/src/packages/com_mokojoomstorelocator/site/src/View/Location/HtmlView.php @@ -0,0 +1,174 @@ +item = $this->get('Item'); + + if (!$this->item) + { + throw new \Exception('Location not found', 404); + } + + // Load categories for this location + $this->categories = $this->loadLocationCategories((int) $this->item->id); + + // Set page title and meta + $doc = $this->getDocument(); + $doc->setTitle($this->item->title . ' — Store Locator'); + + if ($this->item->description) + { + $doc->setDescription(substr(strip_tags($this->item->description), 0, 160)); + } + + $this->addStructuredData(); + + parent::display($tpl); + } + + /** + * Load categories for a location from the junction table. + * + * @param int $locationId Location ID. + * + * @return array Category objects. + * + * @since 1.0.0 + */ + private function loadLocationCategories(int $locationId): array + { + $db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class); + $query = $db->getQuery(true) + ->select(['c.id', 'c.title', 'c.alias', 'c.color']) + ->from($db->quoteName('#__mokojoomstorelocator_location_categories', 'lc')) + ->join('INNER', $db->quoteName('#__mokojoomstorelocator_categories', 'c') . ' ON c.id = lc.category_id AND c.published = 1') + ->where($db->quoteName('lc.location_id') . ' = :id') + ->bind(':id', $locationId, \Joomla\Database\ParameterType::INTEGER); + + $db->setQuery($query); + + return $db->loadObjectList() ?: []; + } + + /** + * Add Schema.org LocalBusiness JSON-LD to the document. + * + * @return void + * + * @since 1.0.0 + */ + protected function addStructuredData(): void + { + $item = $this->item; + + $schema = [ + '@context' => 'https://schema.org', + '@type' => 'LocalBusiness', + 'name' => $item->title, + ]; + + if ($item->description) + { + $schema['description'] = strip_tags($item->description); + } + + $address = []; + + if ($item->address) + { + $address['streetAddress'] = $item->address; + } + + if ($item->city) + { + $address['addressLocality'] = $item->city; + } + + if ($item->state) + { + $address['addressRegion'] = $item->state; + } + + if ($item->postcode) + { + $address['postalCode'] = $item->postcode; + } + + if ($item->country) + { + $address['addressCountry'] = $item->country; + } + + if (!empty($address)) + { + $address['@type'] = 'PostalAddress'; + $schema['address'] = $address; + } + + if ($item->latitude && $item->longitude) + { + $schema['geo'] = [ + '@type' => 'GeoCoordinates', + 'latitude' => (float) $item->latitude, + 'longitude' => (float) $item->longitude, + ]; + } + + if ($item->phone) + { + $schema['telephone'] = $item->phone; + } + + if ($item->email) + { + $schema['email'] = $item->email; + } + + if ($item->website) + { + $schema['url'] = $item->website; + } + + if ($item->image) + { + $schema['image'] = $item->image; + } + + $this->getDocument()->addScriptOptions('com_mokojoomstorelocator.schema', $schema); + $this->getDocument()->addCustomTag( + '' + ); + } +} diff --git a/src/packages/com_mokojoomstorelocator/site/src/View/Locations/HtmlView.php b/src/packages/com_mokojoomstorelocator/site/src/View/Locations/HtmlView.php new file mode 100644 index 0000000..5c3bc71 --- /dev/null +++ b/src/packages/com_mokojoomstorelocator/site/src/View/Locations/HtmlView.php @@ -0,0 +1,54 @@ +items = $this->get('Items'); + $this->pagination = $this->get('Pagination'); + + parent::display($tpl); + } +} diff --git a/src/packages/com_mokojoomstorelocator/site/tmpl/category/default.php b/src/packages/com_mokojoomstorelocator/site/tmpl/category/default.php new file mode 100644 index 0000000..f5174bc --- /dev/null +++ b/src/packages/com_mokojoomstorelocator/site/tmpl/category/default.php @@ -0,0 +1,54 @@ +category; +?> +
+ +

+ color) : ?> + + + escape($cat->title); ?> +

+ + description) : ?> +
description; ?>
+ + + + items)) : ?> +

+ +
+ items as $item) : ?> +
+

+ + escape($item->title); ?> + +

+ address || $item->city) : ?> +
+ escape(trim($item->address . ', ' . $item->city . ', ' . $item->state . ' ' . $item->postcode, ', ')); ?> +
+ + phone) : ?> + + +
+ +
+ pagination->getListFooter(); ?> + + + +
diff --git a/src/packages/com_mokojoomstorelocator/site/tmpl/location/default.php b/src/packages/com_mokojoomstorelocator/site/tmpl/location/default.php new file mode 100644 index 0000000..537493d --- /dev/null +++ b/src/packages/com_mokojoomstorelocator/site/tmpl/location/default.php @@ -0,0 +1,228 @@ +item; +$embedUrl = !empty($item->video_url) ? VideoHelper::getEmbedUrl($item->video_url) : null; +$gallery = !empty($item->images) ? array_filter(array_map('trim', explode("\n", $item->images))) : []; + +/** @var \Joomla\CMS\WebAsset\WebAssetManager $wa */ +$wa = $this->getDocument()->getWebAssetManager(); +$wa->registerAndUseStyle('com_mokojoomstorelocator.site', 'components/com_mokojoomstorelocator/css/storelocator.css'); +?> +
+
+

escape($item->title); ?>

+ +
+ + categories)) : ?> +
+ categories as $cat) : ?> + + escape($cat->title); ?> + + +
+ + +
+
+ image) : ?> +
+ <?php echo $this->escape($item->title); ?> +
+ + + + + + + description) : ?> +
+ description; ?> +
+ + + +
+ +
+ + +
+
+

+ + address || $item->city || $item->state) : ?> +
+ :
+ address) : ?> + escape($item->address); ?>
+ + city) : ?> + escape($item->city); ?>, + + state) : ?> + escape($item->state); ?> + + postcode) : ?> + escape($item->postcode); ?> + + country) : ?> +
escape($item->country); ?> + +
+ + + phone) : ?> + + + + email) : ?> + + + + website) : ?> + + + + hours) : ?> +
+ :
+ escape($item->hours)); ?> +
+ +
+
+ + latitude && $item->longitude) : ?> +
+ + + +
+ Map + + + + + +
+
+

+ + value) : ?> +
+ escape($field->label); ?>: + value; ?> +
+ + +
+
+ + + email) : ?> +
+
+

+
+
+ +
+
+ +
+
+ +
+
+ +
+ + + +
+
+
+ +
+ +
+ latitude && $item->longitude) : ?> +
+ + + + + +
+
+ + +
diff --git a/src/packages/com_mokojoomstorelocator/site/tmpl/locations/default.php b/src/packages/com_mokojoomstorelocator/site/tmpl/locations/default.php new file mode 100644 index 0000000..0d447c9 --- /dev/null +++ b/src/packages/com_mokojoomstorelocator/site/tmpl/locations/default.php @@ -0,0 +1,96 @@ + +
+

+ + items)) : ?> +

+ +
+ items as $item) : ?> +
+

+ + escape($item->title); ?> + +

+ +
+ address) : ?> + escape($item->address); ?>
+ + city) : ?> + escape($item->city); ?>, + + state) : ?> + escape($item->state); ?> + + postcode) : ?> + escape($item->postcode); ?> + +
+ + phone) : ?> + + + + website) : ?> + + + + hours) : ?> +
+ escape($item->hours)); ?> +
+ + + image) : ?> +
+ <?php echo $this->escape($item->title); ?> +
+ + + distance)) : ?> +
+ distance, 1); ?> away +
+ + + latitude && $item->longitude) : ?> +
+ + + +
+ +
+ +
+ + pagination->getListFooter(); ?> + +
diff --git a/src/packages/mod_mokojoomstorelocator_map/language/en-US/mod_mokojoomstorelocator_map.ini b/src/packages/mod_mokojoomstorelocator_map/language/en-US/mod_mokojoomstorelocator_map.ini new file mode 100644 index 0000000..c119db6 --- /dev/null +++ b/src/packages/mod_mokojoomstorelocator_map/language/en-US/mod_mokojoomstorelocator_map.ini @@ -0,0 +1,12 @@ +; MokoJoomStoreLocator Map Module - Language strings +; Copyright (C) 2026 Moko Consulting. All rights reserved. +; License: GNU General Public License version 3 or later; see LICENSE + +MOD_MOKOJOOMSTORELOCATOR_MAP="Store Locator Map" +MOD_MOKOJOOMSTORELOCATOR_MAP_DESC="Displays an interactive map with store location markers." +MOD_MOKOJOOMSTORELOCATOR_MAP_HEIGHT="Map Height" +MOD_MOKOJOOMSTORELOCATOR_MAP_ZOOM="Default Zoom Level" +MOD_MOKOJOOMSTORELOCATOR_MAP_PROVIDER="Map Provider" +MOD_MOKOJOOMSTORELOCATOR_MAP_API_KEY="API Key" +MOD_MOKOJOOMSTORELOCATOR_MAP_API_KEY_DESC="Required for Google Maps. Not needed for OpenStreetMap." +MOD_MOKOJOOMSTORELOCATOR_MAP_NOSCRIPT="JavaScript is required to display the map." diff --git a/src/packages/mod_mokojoomstorelocator_map/language/en-US/mod_mokojoomstorelocator_map.sys.ini b/src/packages/mod_mokojoomstorelocator_map/language/en-US/mod_mokojoomstorelocator_map.sys.ini new file mode 100644 index 0000000..34ee63b --- /dev/null +++ b/src/packages/mod_mokojoomstorelocator_map/language/en-US/mod_mokojoomstorelocator_map.sys.ini @@ -0,0 +1,6 @@ +; MokoJoomStoreLocator Map Module - System language strings +; Copyright (C) 2026 Moko Consulting. All rights reserved. +; License: GNU General Public License version 3 or later; see LICENSE + +MOD_MOKOJOOMSTORELOCATOR_MAP="Store Locator Map" +MOD_MOKOJOOMSTORELOCATOR_MAP_DESC="Displays an interactive map with store location markers." diff --git a/src/packages/mod_mokojoomstorelocator_map/mod_mokojoomstorelocator_map.xml b/src/packages/mod_mokojoomstorelocator_map/mod_mokojoomstorelocator_map.xml index 156b22f..aff17f7 100644 --- a/src/packages/mod_mokojoomstorelocator_map/mod_mokojoomstorelocator_map.xml +++ b/src/packages/mod_mokojoomstorelocator_map/mod_mokojoomstorelocator_map.xml @@ -13,15 +13,15 @@ ========================================================================= --> - mod_mokojoomstorelocator_map - 1.0.0 - 2026-05-21 + Moko Store Locator - Map + 00.00.01 + 2026-05-22 Moko Consulting hello@mokoconsulting.tech https://mokoconsulting.tech Copyright (C) 2026 Moko Consulting. All rights reserved. GNU General Public License version 3 or later; see LICENSE - MOD_MOKOJOOMSTORELOCATOR_MAP_DESC + Displays an interactive map with store location markers. Moko\Module\MokoJoomStoreLocatorMap diff --git a/src/packages/mod_mokojoomstorelocator_map/src/Dispatcher/Dispatcher.php b/src/packages/mod_mokojoomstorelocator_map/src/Dispatcher/Dispatcher.php index 910ef89..f34b9fa 100644 --- a/src/packages/mod_mokojoomstorelocator_map/src/Dispatcher/Dispatcher.php +++ b/src/packages/mod_mokojoomstorelocator_map/src/Dispatcher/Dispatcher.php @@ -13,30 +13,113 @@ defined('_JEXEC') or die; use Joomla\CMS\Dispatcher\AbstractModuleDispatcher; use Joomla\CMS\Helper\HelperFactoryAwareInterface; use Joomla\CMS\Helper\HelperFactoryAwareTrait; +use Joomla\Database\DatabaseInterface; /** * Dispatcher for mod_mokojoomstorelocator_map. * + * Loads locations with category data (color, marker icon) for map rendering. + * * @since 1.0.0 */ class Dispatcher extends AbstractModuleDispatcher implements HelperFactoryAwareInterface { use HelperFactoryAwareTrait; - /** - * Returns the layout data. - * - * @return array - * - * @since 1.0.0 - */ protected function getLayoutData(): array { $data = parent::getLayoutData(); - // TODO: Load published locations with coordinates from the component table - // TODO: Build marker data array for the map JS - $data['locations'] = []; + $db = \Joomla\CMS\Factory::getContainer()->get(DatabaseInterface::class); + $query = $db->getQuery(true); + + $query->select([ + $db->quoteName('l.id'), + $db->quoteName('l.title'), + $db->quoteName('l.address'), + $db->quoteName('l.city'), + $db->quoteName('l.state'), + $db->quoteName('l.postcode'), + $db->quoteName('l.phone'), + $db->quoteName('l.latitude'), + $db->quoteName('l.longitude'), + ]) + ->from($db->quoteName('#__mokojoomstorelocator_locations', 'l')) + ->where($db->quoteName('l.published') . ' = 1') + ->where($db->quoteName('l.latitude') . ' IS NOT NULL') + ->where($db->quoteName('l.longitude') . ' IS NOT NULL'); + + $db->setQuery($query); + $locations = $db->loadObjectList(); + + // Load categories for all locations in one query + $locationIds = array_column((array) $locations, 'id'); + $categoryMap = []; + + if (!empty($locationIds)) + { + $query = $db->getQuery(true) + ->select([ + $db->quoteName('lc.location_id'), + $db->quoteName('c.id', 'cat_id'), + $db->quoteName('c.title', 'cat_title'), + $db->quoteName('c.color', 'cat_color'), + $db->quoteName('c.marker_icon', 'cat_marker'), + ]) + ->from($db->quoteName('#__mokojoomstorelocator_location_categories', 'lc')) + ->join('INNER', $db->quoteName('#__mokojoomstorelocator_categories', 'c') . ' ON c.id = lc.category_id AND c.published = 1') + ->where($db->quoteName('lc.location_id') . ' IN (' . implode(',', array_map('intval', $locationIds)) . ')'); + + $db->setQuery($query); + $catRows = $db->loadObjectList(); + + foreach ($catRows as $row) + { + $categoryMap[(int) $row->location_id][] = [ + 'id' => (int) $row->cat_id, + 'title' => $row->cat_title, + 'color' => $row->cat_color, + 'marker' => $row->cat_marker, + ]; + } + } + + // Build marker data with category info + $markers = []; + $categories = []; + + foreach ($locations as $loc) + { + $locCats = $categoryMap[(int) $loc->id] ?? []; + $primaryCat = $locCats[0] ?? null; + + $markers[] = [ + 'id' => (int) $loc->id, + 'title' => $loc->title, + 'address' => trim($loc->address . ', ' . $loc->city . ', ' . $loc->state . ' ' . $loc->postcode, ', '), + 'phone' => $loc->phone, + 'lat' => (float) $loc->latitude, + 'lng' => (float) $loc->longitude, + 'color' => $primaryCat['color'] ?? '#3b82f6', + 'marker' => $primaryCat['marker'] ?? '', + 'categories' => array_column($locCats, 'id'), + 'cat_names' => array_column($locCats, 'title'), + ]; + + // Collect unique categories for legend + foreach ($locCats as $cat) + { + $categories[$cat['id']] = [ + 'id' => $cat['id'], + 'title' => $cat['title'], + 'color' => $cat['color'], + 'marker' => $cat['marker'], + ]; + } + } + + $data['locations'] = $markers; + $data['categories'] = array_values($categories); return $data; } diff --git a/src/packages/mod_mokojoomstorelocator_map/tmpl/default.php b/src/packages/mod_mokojoomstorelocator_map/tmpl/default.php index 4137389..5fc4b78 100644 --- a/src/packages/mod_mokojoomstorelocator_map/tmpl/default.php +++ b/src/packages/mod_mokojoomstorelocator_map/tmpl/default.php @@ -16,15 +16,210 @@ $locations = $displayData['locations'] ?? []; $mapHeight = $params->get('map_height', '400px'); $mapZoom = (int) $params->get('map_zoom', 10); $provider = $params->get('map_provider', 'leaflet'); +$apiKey = $params->get('api_key', ''); +$moduleId = $displayData['module']->id; ?>
-
+ + + + + + + + + + + + + + + + + diff --git a/src/packages/mod_mokojoomstorelocator_search/language/en-GB/mod_mokojoomstorelocator_search.ini b/src/packages/mod_mokojoomstorelocator_search/language/en-GB/mod_mokojoomstorelocator_search.ini index 7d0a8c3..5c551b1 100644 --- a/src/packages/mod_mokojoomstorelocator_search/language/en-GB/mod_mokojoomstorelocator_search.ini +++ b/src/packages/mod_mokojoomstorelocator_search/language/en-GB/mod_mokojoomstorelocator_search.ini @@ -11,3 +11,11 @@ MOD_MOKOJOOMSTORELOCATOR_SEARCH_SHOW_RADIUS="Show Radius Filter" MOD_MOKOJOOMSTORELOCATOR_SEARCH_RADIUS_UNIT="Distance Unit" MOD_MOKOJOOMSTORELOCATOR_SEARCH_RADIUS_OPTIONS="Radius Options" MOD_MOKOJOOMSTORELOCATOR_SEARCH_RADIUS_OPTIONS_DESC="Comma-separated list of radius values (e.g., 5,10,25,50,100)" + +MOD_MOKOJOOMSTORELOCATOR_SEARCH_USE_LOCATION="Use My Location" +MOD_MOKOJOOMSTORELOCATOR_SEARCH_LOCATING="Finding your location..." +MOD_MOKOJOOMSTORELOCATOR_SEARCH_LOCATION_FOUND="Location found! Select a radius and search." +MOD_MOKOJOOMSTORELOCATOR_SEARCH_GEO_DENIED="Location access denied. Please enable location services." +MOD_MOKOJOOMSTORELOCATOR_SEARCH_GEO_UNAVAILABLE="Location information unavailable." +MOD_MOKOJOOMSTORELOCATOR_SEARCH_GEO_TIMEOUT="Location request timed out." +MOD_MOKOJOOMSTORELOCATOR_SEARCH_GEO_ERROR="Unable to determine your location." diff --git a/src/packages/mod_mokojoomstorelocator_search/language/en-US/mod_mokojoomstorelocator_search.ini b/src/packages/mod_mokojoomstorelocator_search/language/en-US/mod_mokojoomstorelocator_search.ini new file mode 100644 index 0000000..5c551b1 --- /dev/null +++ b/src/packages/mod_mokojoomstorelocator_search/language/en-US/mod_mokojoomstorelocator_search.ini @@ -0,0 +1,21 @@ +; MokoJoomStoreLocator Search Module - Language strings +; Copyright (C) 2026 Moko Consulting. All rights reserved. +; License: GNU General Public License version 3 or later; see LICENSE + +MOD_MOKOJOOMSTORELOCATOR_SEARCH="Store Locator Search" +MOD_MOKOJOOMSTORELOCATOR_SEARCH_DESC="Provides a search/filter form for finding store locations." +MOD_MOKOJOOMSTORELOCATOR_SEARCH_LABEL="Find a Store" +MOD_MOKOJOOMSTORELOCATOR_SEARCH_PLACEHOLDER="Enter city, postcode, or address..." +MOD_MOKOJOOMSTORELOCATOR_SEARCH_SHOW_CITY="Show City Filter" +MOD_MOKOJOOMSTORELOCATOR_SEARCH_SHOW_RADIUS="Show Radius Filter" +MOD_MOKOJOOMSTORELOCATOR_SEARCH_RADIUS_UNIT="Distance Unit" +MOD_MOKOJOOMSTORELOCATOR_SEARCH_RADIUS_OPTIONS="Radius Options" +MOD_MOKOJOOMSTORELOCATOR_SEARCH_RADIUS_OPTIONS_DESC="Comma-separated list of radius values (e.g., 5,10,25,50,100)" + +MOD_MOKOJOOMSTORELOCATOR_SEARCH_USE_LOCATION="Use My Location" +MOD_MOKOJOOMSTORELOCATOR_SEARCH_LOCATING="Finding your location..." +MOD_MOKOJOOMSTORELOCATOR_SEARCH_LOCATION_FOUND="Location found! Select a radius and search." +MOD_MOKOJOOMSTORELOCATOR_SEARCH_GEO_DENIED="Location access denied. Please enable location services." +MOD_MOKOJOOMSTORELOCATOR_SEARCH_GEO_UNAVAILABLE="Location information unavailable." +MOD_MOKOJOOMSTORELOCATOR_SEARCH_GEO_TIMEOUT="Location request timed out." +MOD_MOKOJOOMSTORELOCATOR_SEARCH_GEO_ERROR="Unable to determine your location." diff --git a/src/packages/mod_mokojoomstorelocator_search/language/en-US/mod_mokojoomstorelocator_search.sys.ini b/src/packages/mod_mokojoomstorelocator_search/language/en-US/mod_mokojoomstorelocator_search.sys.ini new file mode 100644 index 0000000..fd80cc1 --- /dev/null +++ b/src/packages/mod_mokojoomstorelocator_search/language/en-US/mod_mokojoomstorelocator_search.sys.ini @@ -0,0 +1,6 @@ +; MokoJoomStoreLocator Search Module - System language strings +; Copyright (C) 2026 Moko Consulting. All rights reserved. +; License: GNU General Public License version 3 or later; see LICENSE + +MOD_MOKOJOOMSTORELOCATOR_SEARCH="Store Locator Search" +MOD_MOKOJOOMSTORELOCATOR_SEARCH_DESC="Provides a search/filter form for finding store locations." diff --git a/src/packages/mod_mokojoomstorelocator_search/mod_mokojoomstorelocator_search.xml b/src/packages/mod_mokojoomstorelocator_search/mod_mokojoomstorelocator_search.xml index a52ac79..21c1d52 100644 --- a/src/packages/mod_mokojoomstorelocator_search/mod_mokojoomstorelocator_search.xml +++ b/src/packages/mod_mokojoomstorelocator_search/mod_mokojoomstorelocator_search.xml @@ -13,15 +13,15 @@ ========================================================================= --> - mod_mokojoomstorelocator_search - 1.0.0 - 2026-05-21 + Moko Store Locator - Search + 00.00.01 + 2026-05-22 Moko Consulting hello@mokoconsulting.tech https://mokoconsulting.tech Copyright (C) 2026 Moko Consulting. All rights reserved. GNU General Public License version 3 or later; see LICENSE - MOD_MOKOJOOMSTORELOCATOR_SEARCH_DESC + Provides a search and filter form for finding store locations. Moko\Module\MokoJoomStoreLocatorSearch diff --git a/src/packages/mod_mokojoomstorelocator_search/src/Dispatcher/Dispatcher.php b/src/packages/mod_mokojoomstorelocator_search/src/Dispatcher/Dispatcher.php index 9c4a39e..6cf2095 100644 --- a/src/packages/mod_mokojoomstorelocator_search/src/Dispatcher/Dispatcher.php +++ b/src/packages/mod_mokojoomstorelocator_search/src/Dispatcher/Dispatcher.php @@ -34,8 +34,34 @@ class Dispatcher extends AbstractModuleDispatcher implements HelperFactoryAwareI { $data = parent::getLayoutData(); - // TODO: Load distinct cities/states for filter dropdowns - // TODO: Build radius options array from params + $db = \Joomla\CMS\Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class); + $table = $db->quoteName('#__mokojoomstorelocator_locations'); + + // Load distinct cities + $query = $db->getQuery(true) + ->select('DISTINCT ' . $db->quoteName('city')) + ->from($table) + ->where($db->quoteName('published') . ' = 1') + ->where($db->quoteName('city') . ' != ' . $db->quote('')) + ->order($db->quoteName('city') . ' ASC'); + $db->setQuery($query); + $data['cities'] = $db->loadColumn(); + + // Load distinct states + $query = $db->getQuery(true) + ->select('DISTINCT ' . $db->quoteName('state')) + ->from($table) + ->where($db->quoteName('published') . ' = 1') + ->where($db->quoteName('state') . ' != ' . $db->quote('')) + ->order($db->quoteName('state') . ' ASC'); + $db->setQuery($query); + $data['states'] = $db->loadColumn(); + + // Parse radius options from params + $params = $data['params']; + $radiusString = $params->get('radius_options', '5,10,25,50,100'); + $data['radius_options'] = array_map('intval', explode(',', $radiusString)); + $data['radius_unit'] = $params->get('radius_unit', 'miles'); return $data; } diff --git a/src/packages/mod_mokojoomstorelocator_search/tmpl/default.php b/src/packages/mod_mokojoomstorelocator_search/tmpl/default.php index 3a82d3b..70e91d2 100644 --- a/src/packages/mod_mokojoomstorelocator_search/tmpl/default.php +++ b/src/packages/mod_mokojoomstorelocator_search/tmpl/default.php @@ -12,32 +12,143 @@ use Joomla\CMS\Language\Text; use Joomla\CMS\Router\Route; /** @var array $displayData */ -$params = $displayData['params']; +$params = $displayData['params']; +$moduleId = $displayData['module']->id; ?> -