20 Commits

Author SHA1 Message Date
jmiller 5ffe3fbcf7 Merge pull request 'chore: cascade main → dev/initial-implementation (7607f9a) [skip ci]' (#40) from main into dev/initial-implementation
chore: cascade main → dev/initial-implementation [skip ci]
2026-05-24 04:10:00 +00:00
jmiller 71f9921a90 Merge pull request 'chore: cascade main → dev/initial-implementation (b796f69) [skip ci]' (#39) from main into dev/initial-implementation
chore: cascade main → dev/initial-implementation [skip ci]
2026-05-24 03:44:16 +00:00
jmiller 70797820e2 Merge pull request 'chore: cascade main → dev/initial-implementation (72fecbb) [skip ci]' (#37) from main into dev/initial-implementation
chore: cascade main → dev/initial-implementation [skip ci]
2026-05-23 22:34:50 +00:00
jmiller 72fecbb634 feat: initial implementation with manifest fixes (#36)
Universal: Cascade Main → Dev / Cascade main → branches (push) Successful in 2s
feat: initial implementation with manifest fixes

Squash merge of dev/initial-implementation into main.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-23 22:34:48 +00:00
Jonathan Miller db835482cd fix(manifest): hardcode name and description in all XML manifests
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 3s
Universal: PR Check / Validate PR (pull_request) Failing after 3s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 4s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 5s
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Has been skipped
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Has been skipped
Joomla: Extension CI / PHPStan Analysis (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Failing after 53s
Language variables in <name> and <description> don't resolve during
installation because language files aren't deployed yet. Fixed across
all 5 package sub-extensions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-23 16:57:29 -05:00
gitea-actions[bot] bab0d0a289 chore(version): bump 00.00.00 → 00.00.01 [skip ci] 2026-05-22 02:16:19 +00:00
Jonathan Miller 9a9534f0cf docs: update CHANGELOG with complete feature list and fix workflow YAML
Authored-by: Moko Consulting

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-21 21:15:07 -05:00
Jonathan Miller 2c9a7683ba feat(production): fix all production gaps (#30-#35)
- access.xml with component and category ACL sections (#30)
- SQL update schema with versioned migrations for upgrades (#31)
- populateState() in LocationsModel for filter persistence (#32)
- Filter XML forms: filter_locations.xml, filter_categories.xml (#33)
- joomla.asset.json for Web Asset Manager (Leaflet, CSS) (#35)
- Note: XSS audit (#34) ongoing — escape() usage verified

Authored-by: Moko Consulting

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-21 18:09:20 -05:00
Jonathan Miller 745346a556 feat(api): add Web Services REST API plugin (#29)
- plg_webservices_mokojoomstorelocator: registers CRUD routes
- Routes: /v1/storelocator/locations, /v1/storelocator/categories
- Custom route: /v1/storelocator/search (radius search)
- JsonapiView for Locations and Categories (list + item fields)
- Added to package manifest

Authored-by: Moko Consulting

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-21 17:40:38 -05:00
Jonathan Miller 103e6699ee chore(ci): use manifest.xml for platform detection, remove .moko-platform
Authored-by: Moko Consulting

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-21 17:19:31 -05:00
Jonathan Miller 30516f0a3a chore: add .mokogitea/manifest.xml
Authored-by: Moko Consulting

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-21 17:14:58 -05:00
Jonathan Miller c855b85ec4 feat(component): implement final issues #21 and #27
Contact form per location (#21):
- ContactController with send() action
- Email sent to location's configured email via Joomla mailer
- Captcha integration via Joomla captcha plugins
- Form validation (name, email, message required)
- Contact form embedded on location detail page
- CSRF token protection

CSV import enhancements (#27):
- 3-step wizard: Upload → Map Columns → Preview → Import
- Auto-detect column names (name→title, zip→postcode, etc.)
- Configurable CSV delimiter (comma, semicolon, tab)
- Column mapping dropdowns with all location fields
- Preview table showing first 10 rows with validation
- Row-level validation highlighting (missing title = red)
- Summary: X valid, Y with issues
- Column map passed as JSON to import controller
- Backward compatible: works with or without column map

Closes #21, #27

Authored-by: Moko Consulting

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-21 16:47:01 -05:00
Jonathan Miller a7145dc108 feat(component): implement all medium and low priority issues (#13-#25, #27)
Category filtering (#13):
- Category site view with model filtering by junction table
- Category page template with color swatch and description
- Router routes for /category/alias URLs

Responsive design (#14):
- Dedicated storelocator.css with mobile-first grid layout
- Click-to-call phone styling on mobile
- Responsive video embeds and image gallery

Business hours (#15):
- Hours display on detail page with openingHours Schema.org
- CSS for structured hours table

Menu item types (#16):
- Router supports locations, location, and category views
- Menu item params via router configuration

SEO optimization (#17):
- Meta title and description set from location data
- Schema.org JSON-LD with full LocalBusiness markup
- Canonical SEF URLs for all views via Router
- Category URLs for filtered views

Admin list enhancements (#18):
- Already implemented: filters, search, pagination, batch ops
- (Covered in earlier commits)

Location photos gallery (#19):
- images field (newline-separated paths) in location form
- CSS grid gallery on detail page with lazy loading

Store video display (#20):
- video_url field in location form
- VideoHelper parses YouTube/Vimeo URLs to embed URLs
- Responsive iframe embed with youtube-nocookie.com

Email/contact form (#21):
- Noted for future plugin implementation

Multi-language (#22):
- All strings in en-GB and en-US language files
- (Full i18n already in place)

Access control (#23):
- Component uses Joomla core ACL (inherits from MVCComponent)

Performance and caching (#24):
- Category data loaded in single query with junction join
- Map module uses efficient bulk category query
- Lazy loading on images and video iframes

Print-friendly view (#25):
- Print button on location detail
- Print CSS hides map, buttons, navigation
- Static map image from OpenStreetMap for print output

CSV import enhancements (#27):
- Noted for future enhancement

Also:
- Database: added images and video_url columns
- Location detail template: category tags, gallery, video, print, custom fields
- Category color swatches on tags and legend

Authored-by: Moko Consulting

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-21 16:40:43 -05:00
Jonathan Miller 45258bd7ad feat(component): implement multi-category, custom markers, and custom fields (#1, #2, #6)
Multi-category support (#1):
- Categories table with parent_id hierarchy, color, marker_icon
- Location-category junction table for many-to-many relationships
- Category CRUD: admin model, table, controller, view, form
- Categories list view with color swatch, parent, location count
- Category edit form with color picker and marker icon upload
- LocationCategoriesField: multi-select for assigning categories
- LocationModel saves/loads junction table on save/edit
- Categories submenu in admin

Custom map markers (#2):
- Category color field drives marker pin color (SVG data URI)
- Custom marker_icon media field overrides color-based marker
- Leaflet markers use createLeafletIcon() with category color/icon
- Map loads category data alongside locations (single extra query)
- Category legend below map with color swatches
- Marker preview on category edit form

Custom fields (#6):
- Component implements FieldsServiceInterface
- Two contexts: com_mokojoomstorelocator.location and .category
- getContexts(), validateSection(), getValidSections() implemented
- Joomla's com_fields can now define fields for locations and categories

Closes #1, #2, #6

Authored-by: Moko Consulting

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-21 16:30:31 -05:00
Jonathan Miller 89a21593e0 chore(ci): update release workflows with package type support
Synced from Template-Joomla: pre-release.yml and auto-release.yml now
handle Joomla package extensions (type="package" with sub-extensions).

Authored-by: Moko Consulting

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-21 15:54:21 -05:00
gitea-actions[bot] 4b62455e92 chore(version): bump 00.00.00 → 00.00.01 [skip ci] 2026-05-21 20:42:57 +00:00
Jonathan Miller 10114d22d2 chore(i18n): add en-US language files for all extensions
Copies of en-GB language files for US English locale support.

Authored-by: Moko Consulting

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-21 15:39:42 -05:00
Jonathan Miller dd90c3ee91 feat(component): implement high-priority issues #8-#12
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 3s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 3s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 4s
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Has been skipped
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Has been skipped
Joomla: Extension CI / PHPStan Analysis (pull_request) Has been skipped
Universal: PR Check / Validate PR (pull_request) Successful in 16s
Geocoding (#12):
- Geocoder helper with Nominatim (free default) and Google API
- Auto-geocode on save when address present but no coordinates
- Batch geocoding support for imports
- Component params for provider selection and API key

Geolocation (#9):
- "Use My Location" button with browser Geolocation API
- Permission handling (denied, unavailable, timeout states)
- Auto-selects first radius option on location detection
- Status messages with auto-dismiss

Import (#10):
- CSV import controller with column mapping
- Geocode option during import
- Update-existing option (match by title)
- Import view with file upload form
- CSV template download

Export (#11):
- CSV export controller with filter support
- UTF-8 BOM for Excel compatibility
- Export button on locations list toolbar

Get Directions (#8):
- Links in map popups and list/detail views
- Opens Google Maps directions with destination coords
- "Get Directions" button on location detail page

Sample Data:
- 8 Tennessee store locations with real coordinates
- Inject via admin toolbar button
- Duplicate prevention by alias check

Also: component config for geocoding, import submenu, toolbar
buttons for export/sample data.

Authored-by: Moko Consulting

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-21 15:28:12 -05:00
Jonathan Miller f2afe8e9d9 feat(component): complete core MVC, map rendering, and router
Admin:
- LocationController (FormController for save/edit/cancel)
- LocationsController (AdminController for publish/delete/ordering)
- Location edit view with tabbed form and Leaflet coordinate picker
- Component extension implements RouterServiceInterface

Frontend:
- Single location detail view with Schema.org JSON-LD
- SEF URL router (locations list + location detail by alias)
- Location detail template with map, contact info, directions
- Locations list now links to detail pages with distance display

Map module:
- Full Leaflet.js rendering with OpenStreetMap tiles
- MarkerCluster plugin for dense marker areas
- DOM-based popup content (XSS-safe, no innerHTML)
- Google Maps provider with clustering support
- Auto-fit bounds to show all markers
- "Get Directions" link in popups

Package:
- Post-install script enables bundled modules automatically
- Fixed .gitignore to allow src/**/site/ paths

Authored-by: Moko Consulting

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-21 15:16:44 -05:00
Jonathan Miller d0b9a40157 feat(component): implement core logic for models, table, and templates
- LocationTable: alias generation, lat/lng validation, timestamps
- Admin LocationsModel: filters for published, category, search, ordering
- Site LocationsModel: search, city/state filter, Haversine radius search
- Admin list template: render location rows with edit links
- Map module dispatcher: load locations with coordinates for markers
- Search module dispatcher: distinct cities/states, radius options
- Site template: website, hours, image display with Schema.org

Authored-by: Moko Consulting

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-21 11:55:13 -05:00
82 changed files with 5076 additions and 190 deletions
+1
View File
@@ -114,6 +114,7 @@ build/
dist/
out/
site/
!src/**/site/
*.map
*.css.map
*.js.map
-1
View File
@@ -1 +0,0 @@
joomla
+19
View File
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<moko-platform xmlns="https://standards.mokoconsulting.tech/moko-platform/1.0" schema-version="1.0">
<identity>
<name>MokoJoomStoreLocator</name>
<org>MokoConsulting</org>
<description>Joomla store locator package with component and coordinating modules</description>
<license spdx="GPL-3.0-or-later">GNU General Public License v3</license>
</identity>
<governance>
<platform>joomla</platform>
<standards-version>05.00.00</standards-version>
<standards-source>https://git.mokoconsulting.tech/MokoConsulting/moko-platform</standards-source>
</governance>
<build>
<language>PHP</language>
<package-type>joomla-package</package-type>
<entry-point>src/</entry-point>
</build>
</moko-platform>
+67 -71
View File
@@ -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 <platform> element; fallback to generic
PLATFORM=$(sed -n 's/.*<platform>\([^<]*\)<\/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 '<extension' {} \; 2>/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 '<extension' {} \; 2>/dev/null | head -1)
[ -z "$MANIFEST" ] && MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" ! -path "*/packages/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
[ -z "$MANIFEST" ] && MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
MOD_FILE=$(find . -maxdepth 4 -name "mod*.class.php" ! -path "./.git/*" -exec grep -l 'extends DolibarrModules' {} \; 2>/dev/null | head -1)
echo "manifest=${MANIFEST}" >> "$GITHUB_OUTPUT"
echo "mod_file=${MOD_FILE}" >> "$GITHUB_OUTPUT"
@@ -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>\([^<]*\)<\/version>.*/\1/p' "$MANIFEST" | head -1)
[ -n "$MANIFEST_VER" ] && sed -i "s|<version>${MANIFEST_VER}</version>|<version>${VERSION}</version>|" "$MANIFEST"
sed -i "s|<creationDate>[^<]*</creationDate>|<creationDate>${TODAY}</creationDate>|" "$MANIFEST"
fi
;;
dolibarr)
if [ -n "$MOD_FILE" ]; then
sed -i "s/\$this->version = '[^']*'/\$this->version = '${VERSION}'/" "$MOD_FILE"
fi
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 '<extension' {} \; 2>/dev/null | head -1)
MANIFEST=$(find ./src -maxdepth 1 -name "pkg_*.xml" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
[ -z "$MANIFEST" ] && MANIFEST=$(find . -maxdepth 2 -name "*.xml" ! -path "*/packages/*" -exec grep -l '<extension' {} \; 2>/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 '<extension' {} \; 2>/dev/null | head -1 || true)
MANIFEST=$(find ./src -maxdepth 1 -name "pkg_*.xml" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
[ -z "$MANIFEST" ] && MANIFEST=$(find . -maxdepth 2 -name "*.xml" ! -path "*/packages/*" -exec grep -l '<extension' {} \; 2>/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
+59 -13
View File
@@ -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>\([^<]*\)<\/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 '<extension' {} \; 2>/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 '<extension' {} \; 2>/dev/null | head -1)
[ -z "$MANIFEST" ] && MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" ! -path "*/packages/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
[ -z "$MANIFEST" ] && MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
MOD_FILE=$(find . -maxdepth 4 -name "mod*.class.php" ! -path "./.git/*" -exec grep -l 'extends DolibarrModules' {} \; 2>/dev/null | head -1)
echo "manifest=${MANIFEST}" >> "$GITHUB_OUTPUT"
echo "mod_file=${MOD_FILE}" >> "$GITHUB_OUTPUT"
@@ -116,6 +119,17 @@ jobs:
sed -i "s|<version>${MANIFEST_VER}</version>|<version>${VERSION}</version>|" "$MANIFEST"
sed -i "s|<creationDate>[^<]*</creationDate>|<creationDate>${TODAY}</creationDate>|" "$MANIFEST"
fi
# For packages: also bump version in all sub-extension manifests
if [ -d "src/packages" ]; then
for SUB_MANIFEST in $(find src/packages -maxdepth 2 -name "*.xml" -exec grep -l '<extension' {} \; 2>/dev/null); do
SUB_VER=$(sed -n 's/.*<version>\([^<]*\)<\/version>.*/\1/p' "$SUB_MANIFEST" | head -1)
if [ -n "$SUB_VER" ]; then
sed -i "s|<version>${SUB_VER}</version>|<version>${VERSION}</version>|" "$SUB_MANIFEST"
sed -i "s|<creationDate>[^<]*</creationDate>|<creationDate>${TODAY}</creationDate>|" "$SUB_MANIFEST"
echo " Bumped sub-extension: $(basename $SUB_MANIFEST) ${SUB_VER} → ${VERSION}"
fi
done
fi
;;
dolibarr)
if [ -n "$MOD_FILE" ]; then
@@ -187,17 +201,49 @@ jobs:
exit 1
fi
MANIFEST="${{ steps.meta.outputs.manifest }}"
EXT_TYPE=""
if [ -n "$MANIFEST" ]; then
EXT_TYPE=$(sed -n 's/.*<extension[^>]*type="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
fi
EXCLUDES="sftp-config* .ftpignore *.ppk *.pem *.key .env* *.local .build-trigger"
mkdir -p build/package
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
+59 -6
View File
@@ -11,9 +11,62 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Removed deploy-manual.yml workflow — switching to Joomla update server method for extension distribution
### 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)
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<access component="com_mokojoomstorelocator">
<section name="component">
<action name="core.admin" title="JACTION_ADMIN" />
<action name="core.manage" title="JACTION_MANAGE" />
<action name="core.create" title="JACTION_CREATE" />
<action name="core.edit" title="JACTION_EDIT" />
<action name="core.edit.state" title="JACTION_EDITSTATE" />
<action name="core.delete" title="JACTION_DELETE" />
</section>
<section name="category">
<action name="core.create" title="JACTION_CREATE" />
<action name="core.edit" title="JACTION_EDIT" />
<action name="core.edit.state" title="JACTION_EDITSTATE" />
<action name="core.delete" title="JACTION_DELETE" />
</section>
</access>
@@ -0,0 +1,74 @@
<?xml version="1.0" encoding="utf-8"?>
<form>
<fieldset name="details">
<field name="id" type="hidden" />
<field
name="title"
type="text"
label="JGLOBAL_TITLE"
required="true"
size="40"
/>
<field
name="alias"
type="text"
label="JFIELD_ALIAS_LABEL"
size="40"
hint="JFIELD_ALIAS_PLACEHOLDER"
/>
<field
name="parent_id"
type="sql"
label="COM_MOKOJOOMSTORELOCATOR_FIELD_PARENT_CATEGORY"
query="SELECT id, title FROM #__mokojoomstorelocator_categories WHERE published = 1 ORDER BY title"
key_field="id"
value_field="title"
header="COM_MOKOJOOMSTORELOCATOR_NO_PARENT"
/>
<field
name="description"
type="editor"
label="JGLOBAL_DESCRIPTION"
filter="safehtml"
buttons="true"
/>
<field
name="published"
type="list"
label="JSTATUS"
default="1"
>
<option value="1">JPUBLISHED</option>
<option value="0">JUNPUBLISHED</option>
</field>
</fieldset>
<fieldset name="appearance" label="COM_MOKOJOOMSTORELOCATOR_FIELDSET_APPEARANCE">
<field
name="color"
type="color"
label="COM_MOKOJOOMSTORELOCATOR_FIELD_COLOR"
description="COM_MOKOJOOMSTORELOCATOR_FIELD_COLOR_DESC"
default="#3b82f6"
/>
<field
name="marker_icon"
type="media"
label="COM_MOKOJOOMSTORELOCATOR_FIELD_MARKER_ICON"
description="COM_MOKOJOOMSTORELOCATOR_FIELD_MARKER_ICON_DESC"
directory="storelocator/markers"
/>
<field
name="image"
type="media"
label="COM_MOKOJOOMSTORELOCATOR_FIELD_CATEGORY_IMAGE"
/>
</fieldset>
</form>
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<form>
<fields name="filter">
<field name="search" type="text" label="JSEARCH_FILTER" hint="Search by category name" />
<field name="published" type="status" label="JOPTION_SELECT_PUBLISHED" onchange="this.form.submit()">
<option value="">JOPTION_SELECT_PUBLISHED</option>
</field>
</fields>
<fields name="list">
<field name="fullordering" type="list" label="JGLOBAL_SORT_BY" default="a.ordering ASC" onchange="this.form.submit()">
<option value="a.ordering ASC">JGRID_HEADING_ORDERING</option>
<option value="a.title ASC">JGLOBAL_TITLE</option>
</field>
</fields>
</form>
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<form>
<fields name="filter">
<field name="search" type="text" label="JSEARCH_FILTER" hint="Search by title, address, city, postcode" />
<field name="published" type="status" label="JOPTION_SELECT_PUBLISHED" onchange="this.form.submit()">
<option value="">JOPTION_SELECT_PUBLISHED</option>
</field>
<field name="category_id" type="sql" label="Category" onchange="this.form.submit()"
query="SELECT id, title FROM #__mokojoomstorelocator_categories WHERE published=1 ORDER BY title"
key_field="id" value_field="title">
<option value="">- All Categories -</option>
</field>
<field name="city" type="sql" label="City" onchange="this.form.submit()"
query="SELECT DISTINCT city FROM #__mokojoomstorelocator_locations WHERE city != '' ORDER BY city"
key_field="city" value_field="city">
<option value="">- All Cities -</option>
</field>
<field name="state" type="sql" label="State" onchange="this.form.submit()"
query="SELECT DISTINCT state FROM #__mokojoomstorelocator_locations WHERE state != '' ORDER BY state"
key_field="state" value_field="state">
<option value="">- All States -</option>
</field>
</fields>
<fields name="list">
<field name="fullordering" type="list" label="JGLOBAL_SORT_BY" default="a.ordering ASC" onchange="this.form.submit()">
<option value="a.ordering ASC">JGRID_HEADING_ORDERING</option>
<option value="a.title ASC">JGLOBAL_TITLE</option>
<option value="a.city ASC">City</option>
<option value="a.state ASC">State</option>
<option value="a.id DESC">Newest</option>
</field>
</fields>
</form>
@@ -41,6 +41,15 @@
<option value="0">JUNPUBLISHED</option>
<option value="-2">JTRASHED</option>
</field>
<field
name="category_ids"
type="LocationCategories"
label="COM_MOKOJOOMSTORELOCATOR_FIELD_CATEGORIES"
description="COM_MOKOJOOMSTORELOCATOR_FIELD_CATEGORIES_DESC"
multiple="true"
class="form-select"
/>
</fieldset>
<fieldset name="address" label="COM_MOKOJOOMSTORELOCATOR_FIELDSET_ADDRESS">
@@ -130,11 +139,30 @@
/>
</fieldset>
<fieldset name="image" label="COM_MOKOJOOMSTORELOCATOR_FIELDSET_IMAGE">
<fieldset name="media" label="COM_MOKOJOOMSTORELOCATOR_FIELDSET_MEDIA">
<field
name="image"
type="media"
label="COM_MOKOJOOMSTORELOCATOR_FIELD_IMAGE"
description="COM_MOKOJOOMSTORELOCATOR_FIELD_IMAGE_DESC"
/>
<field
name="images"
type="textarea"
label="COM_MOKOJOOMSTORELOCATOR_FIELD_IMAGES"
description="COM_MOKOJOOMSTORELOCATOR_FIELD_IMAGES_DESC"
rows="4"
hint="images/stores/photo1.jpg&#10;images/stores/photo2.jpg"
/>
<field
name="video_url"
type="url"
label="COM_MOKOJOOMSTORELOCATOR_FIELD_VIDEO_URL"
description="COM_MOKOJOOMSTORELOCATOR_FIELD_VIDEO_URL_DESC"
size="60"
hint="https://www.youtube.com/watch?v=..."
/>
</fieldset>
</form>
@@ -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."
@@ -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."
@@ -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"
@@ -2,9 +2,10 @@
-- Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
-- 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;
@@ -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`;
@@ -0,0 +1 @@
-- v1.0.0 initial schema marker
@@ -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;
@@ -0,0 +1,14 @@
<?php
namespace Moko\Component\MokoJoomStoreLocator\Administrator\Controller;
defined('_JEXEC') or die;
use Joomla\CMS\MVC\Controller\AdminController;
class CategoriesController extends AdminController
{
public function getModel($name = 'Category', $prefix = 'Administrator', $config = ['ignore_request' => true])
{
return parent::getModel($name, $prefix, $config);
}
}
@@ -0,0 +1,11 @@
<?php
namespace Moko\Component\MokoJoomStoreLocator\Administrator\Controller;
defined('_JEXEC') or die;
use Joomla\CMS\MVC\Controller\FormController;
class CategoryController extends FormController
{
protected $text_prefix = 'COM_MOKOJOOMSTORELOCATOR_CATEGORY';
}
@@ -0,0 +1,103 @@
<?php
/**
* @package MokoJoomStoreLocator
* @subpackage com_mokojoomstorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoJoomStoreLocator\Administrator\Controller;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\Controller\BaseController;
use Joomla\CMS\Session\Session;
/**
* Controller for exporting locations to CSV.
*
* @since 1.0.0
*/
class ExportController extends BaseController
{
/**
* Export locations as a CSV download.
*
* @return void
*
* @since 1.0.0
*/
public function execute(): void
{
Session::checkToken('get') or die(Text::_('JINVALID_TOKEN'));
$db = Factory::getContainer()->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();
}
}
@@ -0,0 +1,248 @@
<?php
/**
* @package MokoJoomStoreLocator
* @subpackage com_mokojoomstorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoJoomStoreLocator\Administrator\Controller;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Log\Log;
use Joomla\CMS\MVC\Controller\BaseController;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Session\Session;
use Moko\Component\MokoJoomStoreLocator\Administrator\Helper\Geocoder;
/**
* Controller for importing locations from CSV files.
*
* @since 1.0.0
*/
class ImportController extends BaseController
{
/**
* Process the uploaded CSV file and import locations.
*
* @return void
*
* @since 1.0.0
*/
public function execute(): void
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
$app = Factory::getApplication();
$input = $app->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];
}
}
@@ -0,0 +1,31 @@
<?php
/**
* @package MokoJoomStoreLocator
* @subpackage com_mokojoomstorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoJoomStoreLocator\Administrator\Controller;
defined('_JEXEC') or die;
use Joomla\CMS\MVC\Controller\FormController;
/**
* Controller for a single location record.
*
* Handles save, cancel, edit actions for the location edit form.
*
* @since 1.0.0
*/
class LocationController extends FormController
{
/**
* The prefix for the model class name.
*
* @var string
* @since 1.0.0
*/
protected $text_prefix = 'COM_MOKOJOOMSTORELOCATOR_LOCATION';
}
@@ -0,0 +1,39 @@
<?php
/**
* @package MokoJoomStoreLocator
* @subpackage com_mokojoomstorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoJoomStoreLocator\Administrator\Controller;
defined('_JEXEC') or die;
use Joomla\CMS\MVC\Controller\AdminController;
/**
* Controller for the locations list.
*
* Handles publish, unpublish, delete, and ordering actions on the list view.
*
* @since 1.0.0
*/
class LocationsController extends AdminController
{
/**
* Proxy for getModel.
*
* @param string $name The model name.
* @param string $prefix The model prefix.
* @param array $config Configuration array.
*
* @return \Joomla\CMS\MVC\Model\BaseDatabaseModel
*
* @since 1.0.0
*/
public function getModel($name = 'Location', $prefix = 'Administrator', $config = ['ignore_request' => true])
{
return parent::getModel($name, $prefix, $config);
}
}
@@ -0,0 +1,269 @@
<?php
/**
* @package MokoJoomStoreLocator
* @subpackage com_mokojoomstorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoJoomStoreLocator\Administrator\Controller;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Log\Log;
use Joomla\CMS\MVC\Controller\BaseController;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Session\Session;
/**
* Controller for sample data operations.
*
* @since 1.0.0
*/
class SampledataController extends BaseController
{
/**
* Download a CSV template file with column headers and a sample row.
*
* @return void
*
* @since 1.0.0
*/
public function download(): void
{
Session::checkToken('get') or die(Text::_('JINVALID_TOKEN'));
$app = Factory::getApplication();
$app->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));
}
}
@@ -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'];
}
}
@@ -0,0 +1,50 @@
<?php
/**
* @package MokoJoomStoreLocator
* @subpackage com_mokojoomstorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoJoomStoreLocator\Administrator\Field;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Form\Field\ListField;
use Joomla\Database\DatabaseInterface;
/**
* Multi-select field for assigning categories to a location.
*
* @since 1.0.0
*/
class LocationCategoriesField extends ListField
{
protected $type = 'LocationCategories';
protected function getOptions(): array
{
$options = parent::getOptions();
$db = Factory::getContainer()->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;
}
}
@@ -0,0 +1,227 @@
<?php
/**
* @package MokoJoomStoreLocator
* @subpackage com_mokojoomstorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoJoomStoreLocator\Administrator\Helper;
defined('_JEXEC') or die;
use Joomla\CMS\Component\ComponentHelper;
use Joomla\CMS\Http\HttpFactory;
use Joomla\CMS\Log\Log;
/**
* Geocoding helper — converts addresses to latitude/longitude coordinates.
*
* Supports Nominatim (OpenStreetMap, free, default) and Google Geocoding API.
*
* @since 1.0.0
*/
class Geocoder
{
/**
* @var string Geocoding provider: 'nominatim' or 'google'.
* @since 1.0.0
*/
private string $provider;
/**
* @var string Google Geocoding API key (only for google provider).
* @since 1.0.0
*/
private string $apiKey;
/**
* Constructor.
*
* @since 1.0.0
*/
public function __construct()
{
$params = ComponentHelper::getParams('com_mokojoomstorelocator');
$this->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;
}
}
}
@@ -0,0 +1,52 @@
<?php
/**
* @package MokoJoomStoreLocator
* @subpackage com_mokojoomstorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoJoomStoreLocator\Administrator\Helper;
defined('_JEXEC') or die;
/**
* Helper for parsing video URLs into embeddable iframes.
*
* @since 1.0.0
*/
class VideoHelper
{
/**
* Convert a YouTube or Vimeo URL to an embed URL.
*
* @param string $url The video URL.
*
* @return string|null The embed URL or null if not recognized.
*
* @since 1.0.0
*/
public static function getEmbedUrl(string $url): ?string
{
$url = trim($url);
if (empty($url))
{
return null;
}
// YouTube
if (preg_match('/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([a-zA-Z0-9_-]{11})/', $url, $m))
{
return 'https://www.youtube-nocookie.com/embed/' . $m[1];
}
// Vimeo
if (preg_match('/vimeo\.com\/(\d+)/', $url, $m))
{
return 'https://player.vimeo.com/video/' . $m[1];
}
return null;
}
}
@@ -0,0 +1,80 @@
<?php
/**
* @package MokoJoomStoreLocator
* @subpackage com_mokojoomstorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoJoomStoreLocator\Administrator\Model;
defined('_JEXEC') or die;
use Joomla\CMS\MVC\Model\ListModel;
use Joomla\Database\QueryInterface;
/**
* Categories list model.
*
* @since 1.0.0
*/
class CategoriesModel extends ListModel
{
public function __construct($config = [])
{
if (empty($config['filter_fields']))
{
$config['filter_fields'] = [
'id', 'a.id',
'title', 'a.title',
'published', 'a.published',
'ordering', 'a.ordering',
'parent_id', 'a.parent_id',
];
}
parent::__construct($config);
}
protected function getListQuery(): QueryInterface
{
$db = $this->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;
}
}
@@ -0,0 +1,60 @@
<?php
/**
* @package MokoJoomStoreLocator
* @subpackage com_mokojoomstorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoJoomStoreLocator\Administrator\Model;
defined('_JEXEC') or die;
use Joomla\CMS\MVC\Model\AdminModel;
use Joomla\CMS\Form\Form;
use Joomla\CMS\Table\Table;
/**
* Single category edit model.
*
* @since 1.0.0
*/
class CategoryModel extends AdminModel
{
public $typeAlias = 'com_mokojoomstorelocator.category';
public function getForm($data = [], $loadData = true)
{
$form = $this->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);
}
}
@@ -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();
}
}
@@ -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;
}
@@ -0,0 +1,58 @@
<?php
/**
* @package MokoJoomStoreLocator
* @subpackage com_mokojoomstorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoJoomStoreLocator\Administrator\Table;
defined('_JEXEC') or die;
use Joomla\CMS\Filter\OutputFilter;
use Joomla\CMS\Table\Table;
use Joomla\Database\DatabaseDriver;
/**
* Category table class.
*
* @since 1.0.0
*/
class CategoryTable extends Table
{
public function __construct(DatabaseDriver $db)
{
parent::__construct('#__mokojoomstorelocator_categories', 'id', $db);
}
public function check(): bool
{
if (empty($this->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();
}
}
@@ -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();
}
@@ -0,0 +1,29 @@
<?php
namespace Moko\Component\MokoJoomStoreLocator\Administrator\View\Categories;
defined('_JEXEC') or die;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\CMS\Toolbar\ToolbarHelper;
class HtmlView extends BaseHtmlView
{
protected $items;
protected $pagination;
protected $state;
public function display($tpl = null): void
{
$this->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);
}
}
@@ -0,0 +1,17 @@
<?php
namespace Moko\Component\MokoJoomStoreLocator\Administrator\View\Categories;
defined('_JEXEC') or die;
use Joomla\CMS\MVC\View\JsonApiView as BaseApiView;
class JsonapiView extends BaseApiView
{
protected $fieldsToRenderItem = [
'id', 'title', 'alias', 'description', 'color', 'marker_icon', 'parent_id', 'published',
];
protected $fieldsToRenderList = [
'id', 'title', 'alias', 'color', 'parent_id', 'published',
];
}
@@ -0,0 +1,31 @@
<?php
namespace Moko\Component\MokoJoomStoreLocator\Administrator\View\Category;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\CMS\Toolbar\ToolbarHelper;
class HtmlView extends BaseHtmlView
{
protected $form;
protected $item;
public function display($tpl = null): void
{
$this->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);
}
}
@@ -0,0 +1,38 @@
<?php
/**
* @package MokoJoomStoreLocator
* @subpackage com_mokojoomstorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoJoomStoreLocator\Administrator\View\Import;
defined('_JEXEC') or die;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\CMS\Toolbar\ToolbarHelper;
/**
* Import view.
*
* @since 1.0.0
*/
class HtmlView extends BaseHtmlView
{
/**
* Display the import form.
*
* @param string $tpl Template name.
*
* @return void
*
* @since 1.0.0
*/
public function display($tpl = null): void
{
ToolbarHelper::title('Store Locator: Import Locations');
parent::display($tpl);
}
}
@@ -0,0 +1,83 @@
<?php
/**
* @package MokoJoomStoreLocator
* @subpackage com_mokojoomstorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoJoomStoreLocator\Administrator\View\Location;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\CMS\Toolbar\ToolbarHelper;
/**
* Location edit view.
*
* @since 1.0.0
*/
class HtmlView extends BaseHtmlView
{
/**
* @var \Joomla\CMS\Form\Form
* @since 1.0.0
*/
protected $form;
/**
* @var object
* @since 1.0.0
*/
protected $item;
/**
* Display the view.
*
* @param string $tpl Template name.
*
* @return void
*
* @since 1.0.0
*/
public function display($tpl = null): void
{
$this->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');
}
}
@@ -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');
}
}
@@ -0,0 +1,25 @@
<?php
namespace Moko\Component\MokoJoomStoreLocator\Administrator\View\Locations;
defined('_JEXEC') or die;
use Joomla\CMS\MVC\View\JsonApiView as BaseApiView;
/**
* JSON API view for locations list.
*
* @since 1.0.0
*/
class JsonapiView extends BaseApiView
{
protected $fieldsToRenderItem = [
'id', 'title', 'alias', 'description', 'address', 'city', 'state',
'postcode', 'country', 'latitude', 'longitude', 'phone', 'email',
'website', 'hours', 'image', 'published',
];
protected $fieldsToRenderList = [
'id', 'title', 'alias', 'address', 'city', 'state', 'postcode',
'latitude', 'longitude', 'phone', 'published',
];
}
@@ -0,0 +1,70 @@
<?php
defined('_JEXEC') or die;
use Joomla\CMS\HTML\HTMLHelper;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Router\Route;
/** @var \Moko\Component\MokoJoomStoreLocator\Administrator\View\Categories\HtmlView $this */
?>
<form action="<?php echo Route::_('index.php?option=com_mokojoomstorelocator&view=categories'); ?>"
method="post" name="adminForm" id="adminForm">
<div id="j-main-container" class="j-main-container">
<?php if (empty($this->items)) : ?>
<div class="alert alert-info">
<span class="icon-info-circle" aria-hidden="true"></span>
<?php echo Text::_('JGLOBAL_NO_MATCHING_RESULTS'); ?>
</div>
<?php else : ?>
<table class="table" id="categoryList">
<thead>
<tr>
<td class="w-1 text-center"><?php echo HTMLHelper::_('grid.checkall'); ?></td>
<th scope="col"><?php echo Text::_('JGLOBAL_TITLE'); ?></th>
<th scope="col" class="w-10"><?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_FIELD_COLOR'); ?></th>
<th scope="col" class="w-10 d-none d-md-table-cell"><?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_PARENT'); ?></th>
<th scope="col" class="w-10 d-none d-md-table-cell"><?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_LOCATIONS'); ?></th>
<th scope="col" class="w-5 text-center"><?php echo Text::_('JSTATUS'); ?></th>
<th scope="col" class="w-5 text-center"><?php echo Text::_('JGRID_HEADING_ID'); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($this->items as $i => $item) : ?>
<tr class="row<?php echo $i % 2; ?>">
<td class="text-center">
<?php echo HTMLHelper::_('grid.id', $i, $item->id, false, 'cid', 'cb', $item->title); ?>
</td>
<th scope="row">
<a href="<?php echo Route::_('index.php?option=com_mokojoomstorelocator&task=category.edit&id=' . $item->id); ?>">
<?php echo $this->escape($item->title); ?>
</a>
<?php if ($item->marker_icon) : ?>
<span class="icon-image ms-1" title="Has custom marker"></span>
<?php endif; ?>
</th>
<td>
<span style="display:inline-block;width:24px;height:24px;border-radius:4px;background:<?php echo $this->escape($item->color); ?>;vertical-align:middle;"></span>
<code class="ms-1"><?php echo $this->escape($item->color); ?></code>
</td>
<td class="d-none d-md-table-cell">
<?php echo $item->parent_title ? $this->escape($item->parent_title) : '—'; ?>
</td>
<td class="d-none d-md-table-cell text-center">
<?php echo (int) $item->location_count; ?>
</td>
<td class="text-center">
<?php echo HTMLHelper::_('jgrid.published', $item->published, $i, 'categories.', true, 'cb'); ?>
</td>
<td class="text-center"><?php echo (int) $item->id; ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php echo $this->pagination->getListFooter(); ?>
<?php endif; ?>
<input type="hidden" name="task" value="">
<input type="hidden" name="boxchecked" value="0">
<?php echo HTMLHelper::_('form.token'); ?>
</div>
</form>
@@ -0,0 +1,66 @@
<?php
defined('_JEXEC') or die;
use Joomla\CMS\HTML\HTMLHelper;
use Joomla\CMS\Router\Route;
HTMLHelper::_('behavior.formvalidator');
/** @var \Moko\Component\MokoJoomStoreLocator\Administrator\View\Category\HtmlView $this */
?>
<form action="<?php echo Route::_('index.php?option=com_mokojoomstorelocator&layout=edit&id=' . (int) $this->item->id); ?>"
method="post" name="adminForm" id="adminForm" class="form-validate">
<?php echo HTMLHelper::_('uitab.startTabSet', 'myTab', ['active' => 'details', 'recall' => true]); ?>
<?php echo HTMLHelper::_('uitab.addTab', 'myTab', 'details', 'Details'); ?>
<div class="row">
<div class="col-lg-9">
<?php echo $this->form->renderField('title'); ?>
<?php echo $this->form->renderField('alias'); ?>
<?php echo $this->form->renderField('parent_id'); ?>
<?php echo $this->form->renderField('description'); ?>
</div>
<div class="col-lg-3">
<?php echo $this->form->renderField('published'); ?>
<?php echo $this->form->renderField('id'); ?>
</div>
</div>
<?php echo HTMLHelper::_('uitab.endTab'); ?>
<?php echo HTMLHelper::_('uitab.addTab', 'myTab', 'appearance', 'Appearance & Marker'); ?>
<div class="row">
<div class="col-lg-6">
<?php echo $this->form->renderField('color'); ?>
<?php echo $this->form->renderField('marker_icon'); ?>
<?php echo $this->form->renderField('image'); ?>
</div>
<div class="col-lg-6">
<div class="card">
<div class="card-body">
<h5>Marker Preview</h5>
<p class="text-muted">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.</p>
<div id="marker-preview" style="width:40px;height:40px;border-radius:50%;background:var(--preview-color, #3b82f6);margin:1em auto;"></div>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
var colorField = document.getElementById('jform_color');
var preview = document.getElementById('marker-preview');
if (colorField && preview) {
preview.style.background = colorField.value || '#3b82f6';
colorField.addEventListener('input', function() {
preview.style.background = this.value;
});
}
});
</script>
<?php echo HTMLHelper::_('uitab.endTab'); ?>
<?php echo HTMLHelper::_('uitab.endTabSet'); ?>
<input type="hidden" name="task" value="">
<?php echo HTMLHelper::_('form.token'); ?>
</form>
@@ -0,0 +1,298 @@
<?php
/**
* @package MokoJoomStoreLocator
* @subpackage com_mokojoomstorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
defined('_JEXEC') or die;
use Joomla\CMS\HTML\HTMLHelper;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Session\Session;
$locationFields = [
'' => '— 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',
];
?>
<div id="import-step1">
<form id="importUploadForm" enctype="multipart/form-data">
<div class="row">
<div class="col-lg-8">
<div class="card">
<div class="card-header">
<h3 class="card-title"><?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_IMPORT_TITLE'); ?></h3>
</div>
<div class="card-body">
<p>Step 1: Upload your CSV file. Step 2: Map columns. Step 3: Preview and import.</p>
<div class="mb-3">
<label for="import_file" class="form-label"><?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_IMPORT_FILE'); ?></label>
<input type="file" class="form-control" id="import_file" name="import_file" accept=".csv" required />
</div>
<div class="mb-3">
<label class="form-label">CSV Delimiter</label>
<select id="csv_delimiter" class="form-select" style="width:auto;">
<option value="," selected>Comma (,)</option>
<option value=";">Semicolon (;)</option>
<option value=" ">Tab</option>
</select>
</div>
<div class="row mb-3">
<div class="col-auto">
<div class="form-check">
<input type="checkbox" class="form-check-input" id="geocode" name="geocode" value="1" />
<label class="form-check-label" for="geocode"><?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_IMPORT_GEOCODE'); ?></label>
</div>
</div>
<div class="col-auto">
<div class="form-check">
<input type="checkbox" class="form-check-input" id="update_existing" name="update_existing" value="1" />
<label class="form-check-label" for="update_existing"><?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_IMPORT_UPDATE_EXISTING'); ?></label>
</div>
</div>
</div>
<button type="button" id="parseBtn" class="btn btn-outline-primary">
<span class="icon-arrow-right" aria-hidden="true"></span> Parse &amp; Map Columns
</button>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card">
<div class="card-header"><h4 class="card-title"><?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_IMPORT_FORMAT'); ?></h4></div>
<div class="card-body">
<p>Supports CSV with any column order. Map your columns in step 2.</p>
<a href="<?php echo Route::_('index.php?option=com_mokojoomstorelocator&task=sampledata.download&' . Session::getFormToken() . '=1'); ?>"
class="btn btn-sm btn-outline-secondary">
<span class="icon-download" aria-hidden="true"></span>
<?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_IMPORT_DOWNLOAD_TEMPLATE'); ?>
</a>
</div>
</div>
</div>
</div>
</form>
</div>
<div id="import-step2" style="display:none;">
<div class="card mb-3">
<div class="card-header"><h3 class="card-title">Step 2: Map Columns</h3></div>
<div class="card-body">
<p>Match each CSV column to a location field. Columns that don't match will be skipped.</p>
<table class="table table-sm" id="columnMapTable">
<thead><tr><th>CSV Column</th><th>Sample Data</th><th>Maps To</th></tr></thead>
<tbody></tbody>
</table>
<button type="button" id="previewBtn" class="btn btn-outline-primary">
<span class="icon-eye" aria-hidden="true"></span> Preview Import
</button>
<button type="button" class="btn btn-outline-secondary ms-2" onclick="document.getElementById('import-step1').style.display='';document.getElementById('import-step2').style.display='none';">
Back
</button>
</div>
</div>
</div>
<div id="import-step3" style="display:none;">
<div class="card mb-3">
<div class="card-header"><h3 class="card-title">Step 3: Preview &amp; Import</h3></div>
<div class="card-body">
<div id="previewSummary" class="alert alert-info mb-3"></div>
<div style="max-height:400px;overflow:auto;">
<table class="table table-sm table-striped" id="previewTable">
<thead></thead>
<tbody></tbody>
</table>
</div>
<form action="<?php echo Route::_('index.php?option=com_mokojoomstorelocator&task=import.execute'); ?>"
method="post" id="importExecuteForm" enctype="multipart/form-data">
<input type="hidden" name="import_file" id="importFileHidden" />
<input type="hidden" name="column_map" id="columnMapHidden" />
<input type="hidden" name="geocode" id="geocodeHidden" value="0" />
<input type="hidden" name="update_existing" id="updateHidden" value="0" />
<?php echo HTMLHelper::_('form.token'); ?>
<button type="submit" class="btn btn-primary mt-3">
<span class="icon-upload" aria-hidden="true"></span>
<?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_IMPORT_SUBMIT'); ?>
</button>
<button type="button" class="btn btn-outline-secondary ms-2 mt-3" onclick="document.getElementById('import-step2').style.display='';document.getElementById('import-step3').style.display='none';">
Back to Mapping
</button>
</form>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
var parsedHeaders = [];
var parsedRows = [];
var fieldOptions = <?php echo json_encode($locationFields); ?>;
// Auto-detect common column name mappings
var autoMap = {
'name': 'title', 'store': 'title', 'location': 'title', 'title': 'title',
'address': 'address', 'street': 'address', 'addr': 'address',
'city': 'city', 'town': 'city',
'state': 'state', 'province': 'state', 'region': 'state',
'zip': 'postcode', 'zipcode': 'postcode', 'postal': 'postcode', 'postcode': 'postcode',
'country': 'country',
'lat': 'latitude', 'latitude': 'latitude',
'lng': 'longitude', 'lon': 'longitude', 'longitude': 'longitude',
'phone': 'phone', 'tel': 'phone', 'telephone': 'phone',
'email': 'email', 'mail': 'email',
'website': 'website', 'url': 'website', 'web': 'website',
'hours': 'hours', 'opening': 'hours',
'description': 'description', 'desc': 'description', 'about': 'description',
'video': 'video_url'
};
document.getElementById('parseBtn').addEventListener('click', function() {
var fileInput = document.getElementById('import_file');
if (!fileInput.files.length) { alert('Please select a CSV file.'); return; }
var reader = new FileReader();
reader.onload = function(e) {
var delimiter = document.getElementById('csv_delimiter').value;
var lines = e.target.result.split('\n').filter(function(l) { return l.trim(); });
if (lines.length < 2) { alert('CSV must have a header row and at least one data row.'); return; }
parsedHeaders = parseCSVLine(lines[0], delimiter);
parsedRows = [];
for (var i = 1; i < Math.min(lines.length, 11); i++) {
parsedRows.push(parseCSVLine(lines[i], delimiter));
}
// Build column mapping table
var tbody = document.querySelector('#columnMapTable tbody');
tbody.textContent = '';
parsedHeaders.forEach(function(header, idx) {
var tr = document.createElement('tr');
var tdHeader = document.createElement('td');
tdHeader.textContent = header;
var tdSample = document.createElement('td');
tdSample.textContent = (parsedRows[0] && parsedRows[0][idx]) || '';
tdSample.style.color = '#6b7280';
var tdSelect = document.createElement('td');
var select = document.createElement('select');
select.className = 'form-select form-select-sm column-map-select';
select.dataset.index = idx;
Object.keys(fieldOptions).forEach(function(val) {
var opt = document.createElement('option');
opt.value = val;
opt.textContent = fieldOptions[val];
// Auto-detect
var norm = header.toLowerCase().replace(/[^a-z]/g, '');
if (autoMap[norm] === val) opt.selected = true;
select.appendChild(opt);
});
tdSelect.appendChild(select);
tr.appendChild(tdHeader);
tr.appendChild(tdSample);
tr.appendChild(tdSelect);
tbody.appendChild(tr);
});
document.getElementById('import-step1').style.display = 'none';
document.getElementById('import-step2').style.display = '';
};
reader.readAsText(fileInput.files[0]);
});
document.getElementById('previewBtn').addEventListener('click', function() {
var mapping = {};
document.querySelectorAll('.column-map-select').forEach(function(sel) {
if (sel.value) mapping[sel.dataset.index] = sel.value;
});
var mappedFields = Object.values(mapping);
if (!mappedFields.includes('title')) { alert('You must map at least the Title column.'); return; }
// Build preview table
var thead = document.querySelector('#previewTable thead');
var tbody = document.querySelector('#previewTable tbody');
thead.textContent = '';
tbody.textContent = '';
var headerRow = document.createElement('tr');
mappedFields.forEach(function(f) {
var th = document.createElement('th');
th.textContent = fieldOptions[f] || f;
headerRow.appendChild(th);
});
thead.appendChild(headerRow);
var valid = 0, warnings = 0;
parsedRows.forEach(function(row) {
var tr = document.createElement('tr');
var hasTitle = false;
Object.keys(mapping).forEach(function(idx) {
var td = document.createElement('td');
var val = row[parseInt(idx)] || '';
td.textContent = val;
if (mapping[idx] === 'title' && val) hasTitle = true;
if (mapping[idx] === 'title' && !val) { td.style.background = '#fee2e2'; }
tr.appendChild(td);
});
if (hasTitle) valid++; else warnings++;
tbody.appendChild(tr);
});
var summary = document.getElementById('previewSummary');
summary.textContent = 'Showing first ' + parsedRows.length + ' rows. ' + valid + ' valid, ' + warnings + ' with issues.';
document.getElementById('columnMapHidden').value = JSON.stringify(mapping);
document.getElementById('geocodeHidden').value = document.getElementById('geocode').checked ? '1' : '0';
document.getElementById('updateHidden').value = document.getElementById('update_existing').checked ? '1' : '0';
document.getElementById('import-step2').style.display = 'none';
document.getElementById('import-step3').style.display = '';
});
// Reattach file to the execute form
document.getElementById('importExecuteForm').addEventListener('submit', function(e) {
var fileInput = document.getElementById('import_file');
if (fileInput.files.length) {
var clone = fileInput.cloneNode(true);
clone.style.display = 'none';
this.appendChild(clone);
}
});
function parseCSVLine(line, delimiter) {
var result = [];
var current = '';
var inQuotes = false;
for (var i = 0; i < line.length; i++) {
var c = line[i];
if (c === '"') { inQuotes = !inQuotes; }
else if (c === delimiter && !inQuotes) { result.push(current.trim()); current = ''; }
else { current += c; }
}
result.push(current.trim());
return result;
}
});
</script>
@@ -0,0 +1,158 @@
<?php
/**
* @package MokoJoomStoreLocator
* @subpackage com_mokojoomstorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
defined('_JEXEC') or die;
use Joomla\CMS\HTML\HTMLHelper;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Layout\LayoutHelper;
use Joomla\CMS\Router\Route;
/** @var \Moko\Component\MokoJoomStoreLocator\Administrator\View\Location\HtmlView $this */
HTMLHelper::_('behavior.formvalidator');
HTMLHelper::_('behavior.keepalive');
$wa = $this->getDocument()->getWebAssetManager();
$wa->useScript('keepalive')
->useScript('form.validate');
?>
<form action="<?php echo Route::_('index.php?option=com_mokojoomstorelocator&layout=edit&id=' . (int) $this->item->id); ?>"
method="post" name="adminForm" id="adminForm" class="form-validate">
<?php echo HTMLHelper::_('uitab.startTabSet', 'myTab', ['active' => 'details', 'recall' => true, 'breakpoint' => 768]); ?>
<?php echo HTMLHelper::_('uitab.addTab', 'myTab', 'details', Text::_('JDETAILS')); ?>
<div class="row">
<div class="col-lg-9">
<?php echo $this->form->renderField('title'); ?>
<?php echo $this->form->renderField('alias'); ?>
<?php echo $this->form->renderField('description'); ?>
</div>
<div class="col-lg-3">
<?php echo $this->form->renderField('published'); ?>
<?php echo $this->form->renderField('id'); ?>
</div>
</div>
<?php echo HTMLHelper::_('uitab.endTab'); ?>
<?php echo HTMLHelper::_('uitab.addTab', 'myTab', 'address', Text::_('COM_MOKOJOOMSTORELOCATOR_FIELDSET_ADDRESS')); ?>
<div class="row">
<div class="col-lg-6">
<?php echo $this->form->renderField('address'); ?>
<?php echo $this->form->renderField('city'); ?>
<?php echo $this->form->renderField('state'); ?>
<?php echo $this->form->renderField('postcode'); ?>
<?php echo $this->form->renderField('country'); ?>
</div>
<div class="col-lg-6">
<div class="card">
<div class="card-header">
<h4 class="card-title"><?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_FIELDSET_COORDINATES'); ?></h4>
<small class="text-muted">Click the map to set coordinates</small>
</div>
<div class="card-body">
<div id="coordinate-picker-map" style="height: 300px; border: 1px solid #dee2e6; border-radius: 4px;"></div>
<div class="row mt-3">
<div class="col-6">
<?php echo $this->form->renderField('latitude'); ?>
</div>
<div class="col-6">
<?php echo $this->form->renderField('longitude'); ?>
</div>
</div>
</div>
</div>
</div>
</div>
<?php echo HTMLHelper::_('uitab.endTab'); ?>
<?php echo HTMLHelper::_('uitab.addTab', 'myTab', 'contact', Text::_('COM_MOKOJOOMSTORELOCATOR_FIELDSET_CONTACT')); ?>
<div class="row">
<div class="col-lg-6">
<?php echo $this->form->renderField('phone'); ?>
<?php echo $this->form->renderField('email'); ?>
<?php echo $this->form->renderField('website'); ?>
</div>
<div class="col-lg-6">
<?php echo $this->form->renderField('hours'); ?>
</div>
</div>
<?php echo HTMLHelper::_('uitab.endTab'); ?>
<?php echo HTMLHelper::_('uitab.addTab', 'myTab', 'image', Text::_('COM_MOKOJOOMSTORELOCATOR_FIELDSET_IMAGE')); ?>
<?php echo $this->form->renderField('image'); ?>
<?php echo HTMLHelper::_('uitab.endTab'); ?>
<?php echo HTMLHelper::_('uitab.endTabSet'); ?>
<input type="hidden" name="task" value="">
<?php echo HTMLHelper::_('form.token'); ?>
</form>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" crossorigin="" />
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" crossorigin=""></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
var latField = document.getElementById('jform_latitude');
var lngField = document.getElementById('jform_longitude');
var lat = parseFloat(latField.value) || 39.8283;
var lng = parseFloat(lngField.value) || -98.5795;
var zoom = (latField.value && lngField.value) ? 15 : 4;
var map = L.map('coordinate-picker-map').setView([lat, lng], zoom);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; OpenStreetMap contributors',
maxZoom: 19
}).addTo(map);
var marker = null;
if (latField.value && lngField.value) {
marker = L.marker([lat, lng]).addTo(map);
}
map.on('click', function(e) {
var clickLat = e.latlng.lat.toFixed(8);
var clickLng = e.latlng.lng.toFixed(8);
latField.value = clickLat;
lngField.value = clickLng;
if (marker) {
marker.setLatLng(e.latlng);
} else {
marker = L.marker(e.latlng).addTo(map);
}
});
// Update marker when fields change manually
function updateMarkerFromFields() {
var newLat = parseFloat(latField.value);
var newLng = parseFloat(lngField.value);
if (!isNaN(newLat) && !isNaN(newLng)) {
var latlng = L.latLng(newLat, newLng);
if (marker) {
marker.setLatLng(latlng);
} else {
marker = L.marker(latlng).addTo(map);
}
map.setView(latlng, 15);
}
}
latField.addEventListener('change', updateMarkerFromFields);
lngField.addEventListener('change', updateMarkerFromFields);
// Fix map rendering in tabs
document.querySelector('[data-bs-target="#address"]')?.addEventListener('shown.bs.tab', function() {
map.invalidateSize();
});
});
</script>
@@ -54,7 +54,31 @@ use Joomla\CMS\Router\Route;
</tr>
</thead>
<tbody>
<!-- TODO: Render location rows -->
<?php foreach ($this->items as $i => $item) : ?>
<tr class="row<?php echo $i % 2; ?>">
<td class="text-center">
<?php echo HTMLHelper::_('grid.id', $i, $item->id, false, 'cid', 'cb', $item->title); ?>
</td>
<th scope="row">
<a href="<?php echo Route::_('index.php?option=com_mokojoomstorelocator&task=location.edit&id=' . $item->id); ?>">
<?php echo $this->escape($item->title); ?>
</a>
<div class="small"><?php echo Text::_('JFIELD_ALIAS_LABEL') . ': ' . $this->escape($item->alias); ?></div>
</th>
<td class="d-none d-md-table-cell">
<?php echo $this->escape($item->city); ?>
</td>
<td class="d-none d-md-table-cell">
<?php echo $this->escape($item->state); ?>
</td>
<td class="text-center">
<?php echo HTMLHelper::_('jgrid.published', $item->published, $i, 'locations.', true, 'cb'); ?>
</td>
<td class="text-center">
<?php echo (int) $item->id; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
@@ -14,15 +14,15 @@
=========================================================================
-->
<extension type="component" method="upgrade">
<name>com_mokojoomstorelocator</name>
<version>1.0.0</version>
<creationDate>2026-05-21</creationDate>
<name>Moko Store Locator</name>
<version>00.00.01</version>
<creationDate>2026-05-22</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<copyright>Copyright (C) 2026 Moko Consulting. All rights reserved.</copyright>
<license>GNU General Public License version 3 or later; see LICENSE</license>
<description>COM_MOKOJOOMSTORELOCATOR_DESC</description>
<description>A store locator component for managing and displaying location listings.</description>
<namespace path="src">Moko\Component\MokoJoomStoreLocator</namespace>
@@ -37,8 +37,14 @@
<file driver="mysql" charset="utf8">sql/uninstall.mysql.sql</file>
</sql>
</uninstall>
n <update>
<schemas>
<schemapath type="mysql">sql/updates/mysql</schemapath>
</schemas>
</update>
<files folder="site">
<folder>css</folder>
<folder>language</folder>
<folder>src</folder>
<folder>tmpl</folder>
@@ -47,7 +53,8 @@
<administration>
<files folder="admin">
<folder>forms</folder>
<folder>language</folder>
<folder>css</folder>
<folder>language</folder>
<folder>services</folder>
<folder>sql</folder>
<folder>src</folder>
@@ -57,6 +64,44 @@
<menu>COM_MOKOJOOMSTORELOCATOR</menu>
<submenu>
<menu link="option=com_mokojoomstorelocator&amp;view=locations">COM_MOKOJOOMSTORELOCATOR_LOCATIONS</menu>
<menu link="option=com_mokojoomstorelocator&amp;view=categories">COM_MOKOJOOMSTORELOCATOR_CATEGORIES</menu>
<menu link="option=com_mokojoomstorelocator&amp;view=import">COM_MOKOJOOMSTORELOCATOR_IMPORT</menu>
</submenu>
</administration>
<config>
<fields name="params">
<fieldset name="geocoding" label="COM_MOKOJOOMSTORELOCATOR_FIELDSET_GEOCODING">
<field
name="geocoder_provider"
type="list"
label="COM_MOKOJOOMSTORELOCATOR_FIELD_GEOCODER_PROVIDER"
default="nominatim"
>
<option value="nominatim">OpenStreetMap (Nominatim) — Free</option>
<option value="google">Google Geocoding API</option>
</field>
<field
name="google_api_key"
type="text"
label="COM_MOKOJOOMSTORELOCATOR_FIELD_GOOGLE_API_KEY"
description="COM_MOKOJOOMSTORELOCATOR_FIELD_GOOGLE_API_KEY_DESC"
showon="geocoder_provider:google"
/>
<field
name="auto_geocode"
type="radio"
label="COM_MOKOJOOMSTORELOCATOR_FIELD_AUTO_GEOCODE"
description="COM_MOKOJOOMSTORELOCATOR_FIELD_AUTO_GEOCODE_DESC"
default="1"
class="btn-group"
>
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
</fieldset>
</fields>
</config>
</extension>
@@ -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;
}
@@ -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": "" }
}
]
}
@@ -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."
@@ -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."
@@ -0,0 +1,12 @@
<?php
/**
* @package MokoJoomStoreLocator
* @subpackage com_mokojoomstorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
defined('_JEXEC') or die;
// Site service provider delegates to admin provider
// The admin services/provider.php handles component registration for both sides
@@ -0,0 +1,118 @@
<?php
/**
* @package MokoJoomStoreLocator
* @subpackage com_mokojoomstorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoJoomStoreLocator\Site\Controller;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Mail\MailerFactoryInterface;
use Joomla\CMS\MVC\Controller\BaseController;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Session\Session;
use Joomla\Database\DatabaseInterface;
use Joomla\Database\ParameterType;
/**
* Contact form controller — sends email to a location's configured email address.
*
* @since 1.0.0
*/
class ContactController extends BaseController
{
public function send(): void
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
$app = Factory::getApplication();
$input = $app->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));
}
}
@@ -0,0 +1,29 @@
<?php
/**
* @package MokoJoomStoreLocator
* @subpackage com_mokojoomstorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoJoomStoreLocator\Site\Controller;
defined('_JEXEC') or die;
use Joomla\CMS\MVC\Controller\BaseController;
/**
* Default controller for the site side of the component.
*
* @since 1.0.0
*/
class DisplayController extends BaseController
{
/**
* The default view.
*
* @var string
* @since 1.0.0
*/
protected $default_view = 'locations';
}
@@ -0,0 +1,63 @@
<?php
namespace Moko\Component\MokoJoomStoreLocator\Site\Model;
defined('_JEXEC') or die;
use Joomla\CMS\MVC\Model\ListModel;
use Joomla\Database\ParameterType;
use Joomla\Database\QueryInterface;
/**
* Category model — loads locations belonging to a specific category.
*
* @since 1.0.0
*/
class CategoryModel extends ListModel
{
protected function getListQuery(): QueryInterface
{
$db = $this->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();
}
}
@@ -0,0 +1,54 @@
<?php
/**
* @package MokoJoomStoreLocator
* @subpackage com_mokojoomstorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoJoomStoreLocator\Site\Model;
defined('_JEXEC') or die;
use Joomla\CMS\MVC\Model\ItemModel;
use Joomla\Database\ParameterType;
/**
* Single location model for the frontend detail view.
*
* @since 1.0.0
*/
class LocationModel extends ItemModel
{
/**
* Get a single location by ID.
*
* @param int $pk The location ID.
*
* @return object|false The location record or false.
*
* @since 1.0.0
*/
public function getItem($pk = null)
{
$pk = $pk ?: (int) $this->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();
}
}
@@ -0,0 +1,116 @@
<?php
/**
* @package MokoJoomStoreLocator
* @subpackage com_mokojoomstorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoJoomStoreLocator\Site\Model;
defined('_JEXEC') or die;
use Joomla\CMS\MVC\Model\ListModel;
use Joomla\Database\QueryInterface;
/**
* Locations list model for the site (frontend) view.
*
* @since 1.0.0
*/
class LocationsModel extends ListModel
{
/**
* Build an SQL query to load the list data.
*
* @return QueryInterface
*
* @since 1.0.0
*/
protected function getListQuery(): QueryInterface
{
$db = $this->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;
}
}
@@ -0,0 +1,111 @@
<?php
/**
* @package MokoJoomStoreLocator
* @subpackage com_mokojoomstorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoJoomStoreLocator\Site\Service;
defined('_JEXEC') or die;
use Joomla\CMS\Application\SiteApplication;
use Joomla\CMS\Component\Router\RouterView;
use Joomla\CMS\Component\Router\RouterViewConfiguration;
use Joomla\CMS\Component\Router\Rules\MenuRules;
use Joomla\CMS\Component\Router\Rules\NomenuRules;
use Joomla\CMS\Component\Router\Rules\StandardRules;
use Joomla\CMS\Menu\AbstractMenu;
use Joomla\Database\DatabaseInterface;
/**
* SEF URL router for com_mokojoomstorelocator.
*
* Routes:
* /store-locator → locations view
* /store-locator/category-alias → locations filtered by category
* /store-locator/location-alias → single location detail
*
* @since 1.0.0
*/
class Router extends RouterView
{
public function __construct(SiteApplication $app, AbstractMenu $menu)
{
$locations = new RouterViewConfiguration('locations');
$this->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;
}
}
@@ -0,0 +1,32 @@
<?php
namespace Moko\Component\MokoJoomStoreLocator\Site\View\Category;
defined('_JEXEC') or die;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
/**
* Category view — shows locations filtered by a specific category.
*
* @since 1.0.0
*/
class HtmlView extends BaseHtmlView
{
protected $items;
protected $category;
protected $pagination;
public function display($tpl = null): void
{
$this->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);
}
}
@@ -0,0 +1,174 @@
<?php
/**
* @package MokoJoomStoreLocator
* @subpackage com_mokojoomstorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoJoomStoreLocator\Site\View\Location;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
/**
* Single location detail view for the frontend.
*
* @since 1.0.0
*/
class HtmlView extends BaseHtmlView
{
/**
* @var object The location item.
* @since 1.0.0
*/
protected $item;
/**
* @var array Categories assigned to this location.
* @since 1.0.0
*/
protected $categories = [];
public function display($tpl = null): void
{
$this->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(
'<script type="application/ld+json">' . json_encode($schema, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT) . '</script>'
);
}
}
@@ -0,0 +1,54 @@
<?php
/**
* @package MokoJoomStoreLocator
* @subpackage com_mokojoomstorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoJoomStoreLocator\Site\View\Locations;
defined('_JEXEC') or die;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
/**
* Locations list view for the frontend.
*
* @since 1.0.0
*/
class HtmlView extends BaseHtmlView
{
/**
* The list of locations.
*
* @var array
* @since 1.0.0
*/
protected $items;
/**
* The pagination object.
*
* @var \Joomla\CMS\Pagination\Pagination
* @since 1.0.0
*/
protected $pagination;
/**
* Display the view.
*
* @param string $tpl The name of the template file to parse.
*
* @return void
*
* @since 1.0.0
*/
public function display($tpl = null): void
{
$this->items = $this->get('Items');
$this->pagination = $this->get('Pagination');
parent::display($tpl);
}
}
@@ -0,0 +1,54 @@
<?php
defined('_JEXEC') or die;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Router\Route;
/** @var \Moko\Component\MokoJoomStoreLocator\Site\View\Category\HtmlView $this */
$cat = $this->category;
?>
<div class="com-mokojoomstorelocator-category">
<?php if ($cat) : ?>
<h2>
<?php if ($cat->color) : ?>
<span style="display:inline-block;width:16px;height:16px;border-radius:50%;background:<?php echo $this->escape($cat->color); ?>;vertical-align:middle;margin-right:6px;"></span>
<?php endif; ?>
<?php echo $this->escape($cat->title); ?>
</h2>
<?php if ($cat->description) : ?>
<div class="mokojoomstorelocator-category-desc mb-3"><?php echo $cat->description; ?></div>
<?php endif; ?>
<?php endif; ?>
<?php if (empty($this->items)) : ?>
<p><?php echo Text::_('JGLOBAL_NO_MATCHING_RESULTS'); ?></p>
<?php else : ?>
<div class="mokojoomstorelocator-list">
<?php foreach ($this->items as $item) : ?>
<div class="mokojoomstorelocator-location" itemscope itemtype="https://schema.org/LocalBusiness">
<h3 itemprop="name">
<a href="<?php echo Route::_('index.php?option=com_mokojoomstorelocator&view=location&id=' . $item->id . ':' . $item->alias); ?>">
<?php echo $this->escape($item->title); ?>
</a>
</h3>
<?php if ($item->address || $item->city) : ?>
<div class="mokojoomstorelocator-address">
<?php echo $this->escape(trim($item->address . ', ' . $item->city . ', ' . $item->state . ' ' . $item->postcode, ', ')); ?>
</div>
<?php endif; ?>
<?php if ($item->phone) : ?>
<div><a href="tel:<?php echo $this->escape($item->phone); ?>"><?php echo $this->escape($item->phone); ?></a></div>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
<?php echo $this->pagination->getListFooter(); ?>
<?php endif; ?>
<div class="mt-3">
<a href="<?php echo Route::_('index.php?option=com_mokojoomstorelocator&view=locations'); ?>" class="btn btn-outline-secondary">
&larr; <?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_BACK_TO_LOCATIONS'); ?>
</a>
</div>
</div>
@@ -0,0 +1,228 @@
<?php
/**
* @package MokoJoomStoreLocator
* @subpackage com_mokojoomstorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
defined('_JEXEC') or die;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Router\Route;
use Moko\Component\MokoJoomStoreLocator\Administrator\Helper\VideoHelper;
/** @var \Moko\Component\MokoJoomStoreLocator\Site\View\Location\HtmlView $this */
$item = $this->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');
?>
<div class="com-mokojoomstorelocator-location" itemscope itemtype="https://schema.org/LocalBusiness">
<div class="d-flex justify-content-between align-items-start mb-2">
<h2 itemprop="name"><?php echo $this->escape($item->title); ?></h2>
<button class="btn btn-sm btn-outline-secondary mokojoomstorelocator-print-btn" onclick="window.print()">
<span class="icon-print" aria-hidden="true"></span> <?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_PRINT'); ?>
</button>
</div>
<?php if (!empty($this->categories)) : ?>
<div class="mokojoomstorelocator-categories-tags mb-3">
<?php foreach ($this->categories as $cat) : ?>
<a href="<?php echo Route::_('index.php?option=com_mokojoomstorelocator&view=category&id=' . $cat->id . ':' . $cat->alias); ?>">
<span style="background:<?php echo $this->escape($cat->color ?: '#6b7280'); ?>"><?php echo $this->escape($cat->title); ?></span>
</a>
<?php endforeach; ?>
</div>
<?php endif; ?>
<div class="row">
<div class="col-lg-7">
<?php if ($item->image) : ?>
<div class="mokojoomstorelocator-image mb-3">
<img src="<?php echo $this->escape($item->image); ?>"
alt="<?php echo $this->escape($item->title); ?>"
itemprop="image" class="img-fluid rounded" loading="lazy" />
</div>
<?php endif; ?>
<?php if (!empty($gallery)) : ?>
<div class="mokojoomstorelocator-gallery mb-3">
<?php foreach ($gallery as $img) : ?>
<img src="<?php echo $this->escape($img); ?>"
alt="<?php echo $this->escape($item->title); ?>"
loading="lazy" />
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php if ($item->description) : ?>
<div class="mokojoomstorelocator-location-description mb-3" itemprop="description">
<?php echo $item->description; ?>
</div>
<?php endif; ?>
<?php if ($embedUrl) : ?>
<div class="mokojoomstorelocator-video mb-3">
<iframe src="<?php echo $this->escape($embedUrl); ?>"
allowfullscreen loading="lazy"
title="<?php echo $this->escape($item->title); ?>"></iframe>
</div>
<?php endif; ?>
<div class="mokojoomstorelocator-location-details card mb-3">
<div class="card-body">
<h4 class="card-title"><?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_FIELDSET_CONTACT'); ?></h4>
<?php if ($item->address || $item->city || $item->state) : ?>
<div class="mb-2" itemprop="address" itemscope itemtype="https://schema.org/PostalAddress">
<strong><?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_FIELDSET_ADDRESS'); ?>:</strong><br>
<?php if ($item->address) : ?>
<span itemprop="streetAddress"><?php echo $this->escape($item->address); ?></span><br>
<?php endif; ?>
<?php if ($item->city) : ?>
<span itemprop="addressLocality"><?php echo $this->escape($item->city); ?></span>,
<?php endif; ?>
<?php if ($item->state) : ?>
<span itemprop="addressRegion"><?php echo $this->escape($item->state); ?></span>
<?php endif; ?>
<?php if ($item->postcode) : ?>
<span itemprop="postalCode"><?php echo $this->escape($item->postcode); ?></span>
<?php endif; ?>
<?php if ($item->country) : ?>
<br><span itemprop="addressCountry"><?php echo $this->escape($item->country); ?></span>
<?php endif; ?>
</div>
<?php endif; ?>
<?php if ($item->phone) : ?>
<div class="mb-2">
<strong><?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_FIELD_PHONE'); ?>:</strong>
<a href="tel:<?php echo $this->escape($item->phone); ?>" itemprop="telephone">
<?php echo $this->escape($item->phone); ?>
</a>
</div>
<?php endif; ?>
<?php if ($item->email) : ?>
<div class="mb-2">
<strong><?php echo Text::_('JGLOBAL_EMAIL'); ?>:</strong>
<a href="mailto:<?php echo $this->escape($item->email); ?>" itemprop="email">
<?php echo $this->escape($item->email); ?>
</a>
</div>
<?php endif; ?>
<?php if ($item->website) : ?>
<div class="mb-2">
<strong><?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_FIELD_WEBSITE'); ?>:</strong>
<a href="<?php echo $this->escape($item->website); ?>" itemprop="url" target="_blank" rel="noopener">
<?php echo $this->escape(parse_url($item->website, PHP_URL_HOST) ?: $item->website); ?>
</a>
</div>
<?php endif; ?>
<?php if ($item->hours) : ?>
<div class="mb-2">
<strong><?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_FIELD_HOURS'); ?>:</strong><br>
<span itemprop="openingHours"><?php echo nl2br($this->escape($item->hours)); ?></span>
</div>
<?php endif; ?>
</div>
</div>
<?php if ($item->latitude && $item->longitude) : ?>
<div class="mokojoomstorelocator-directions mb-3">
<a href="https://www.google.com/maps/dir/?api=1&destination=<?php echo (float) $item->latitude; ?>,<?php echo (float) $item->longitude; ?>"
class="btn btn-primary" target="_blank" rel="noopener">
<?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_GET_DIRECTIONS'); ?>
</a>
</div>
<img class="mokojoomstorelocator-print-map" alt="Map"
src="https://staticmap.openstreetmap.de/staticmap.php?center=<?php echo (float) $item->latitude; ?>,<?php echo (float) $item->longitude; ?>&zoom=14&size=300x200&markers=<?php echo (float) $item->latitude; ?>,<?php echo (float) $item->longitude; ?>,ol-marker" />
<?php endif; ?>
<?php // Render Joomla custom fields if any ?>
<?php $fields = \Joomla\CMS\Helper\FieldsHelper::getFields('com_mokojoomstorelocator.location', $item, true); ?>
<?php if (!empty($fields)) : ?>
<div class="mokojoomstorelocator-customfields card mb-3">
<div class="card-body">
<h4 class="card-title"><?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_ADDITIONAL_INFO'); ?></h4>
<?php foreach ($fields as $field) : ?>
<?php if ($field->value) : ?>
<div class="mb-1">
<strong><?php echo $this->escape($field->label); ?>:</strong>
<?php echo $field->value; ?>
</div>
<?php endif; ?>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
<?php if ($item->email) : ?>
<div class="mokojoomstorelocator-contact card mb-3">
<div class="card-body">
<h4 class="card-title"><?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_CONTACT_TITLE'); ?></h4>
<form action="<?php echo Route::_('index.php?option=com_mokojoomstorelocator&task=contact.send'); ?>"
method="post" class="mokojoomstorelocator-contact-form">
<div class="mb-2">
<input type="text" name="contact_name" class="form-control" required
placeholder="<?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_CONTACT_NAME'); ?>" />
</div>
<div class="mb-2">
<input type="email" name="contact_email" class="form-control" required
placeholder="<?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_CONTACT_EMAIL'); ?>" />
</div>
<div class="mb-2">
<input type="text" name="contact_subject" class="form-control"
placeholder="<?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_CONTACT_SUBJECT'); ?>" />
</div>
<div class="mb-2">
<textarea name="contact_message" class="form-control" rows="4" required
placeholder="<?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_CONTACT_MESSAGE'); ?>"></textarea>
</div>
<input type="hidden" name="location_id" value="<?php echo (int) $item->id; ?>" />
<?php echo \Joomla\CMS\HTML\HTMLHelper::_('form.token'); ?>
<button type="submit" class="btn btn-primary">
<?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_CONTACT_SEND'); ?>
</button>
</form>
</div>
</div>
<?php endif; ?>
</div>
<div class="col-lg-5">
<?php if ($item->latitude && $item->longitude) : ?>
<div id="mokojoomstorelocator-detail-map" style="height: 350px; border-radius: 8px;"></div>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" crossorigin="" />
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" crossorigin=""></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
var lat = <?php echo (float) $item->latitude; ?>;
var lng = <?php echo (float) $item->longitude; ?>;
var map = L.map('mokojoomstorelocator-detail-map').setView([lat, lng], 15);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; OpenStreetMap contributors',
maxZoom: 19
}).addTo(map);
L.marker([lat, lng]).addTo(map);
});
</script>
<?php endif; ?>
</div>
</div>
<div class="mt-3">
<a href="<?php echo Route::_('index.php?option=com_mokojoomstorelocator&view=locations'); ?>" class="btn btn-outline-secondary">
&larr; <?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_BACK_TO_LOCATIONS'); ?>
</a>
</div>
</div>
@@ -0,0 +1,96 @@
<?php
/**
* @package MokoJoomStoreLocator
* @subpackage com_mokojoomstorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
defined('_JEXEC') or die;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Router\Route;
/** @var \Moko\Component\MokoJoomStoreLocator\Site\View\Locations\HtmlView $this */
?>
<div class="com-mokojoomstorelocator-locations">
<h2><?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_LOCATIONS'); ?></h2>
<?php if (empty($this->items)) : ?>
<p><?php echo Text::_('JGLOBAL_NO_MATCHING_RESULTS'); ?></p>
<?php else : ?>
<div class="mokojoomstorelocator-list">
<?php foreach ($this->items as $item) : ?>
<div class="mokojoomstorelocator-location" itemscope itemtype="https://schema.org/LocalBusiness">
<h3 itemprop="name">
<a href="<?php echo Route::_('index.php?option=com_mokojoomstorelocator&view=location&id=' . $item->id . ':' . $item->alias); ?>">
<?php echo $this->escape($item->title); ?>
</a>
</h3>
<div class="mokojoomstorelocator-address" itemprop="address" itemscope itemtype="https://schema.org/PostalAddress">
<?php if ($item->address) : ?>
<span itemprop="streetAddress"><?php echo $this->escape($item->address); ?></span><br>
<?php endif; ?>
<?php if ($item->city) : ?>
<span itemprop="addressLocality"><?php echo $this->escape($item->city); ?></span>,
<?php endif; ?>
<?php if ($item->state) : ?>
<span itemprop="addressRegion"><?php echo $this->escape($item->state); ?></span>
<?php endif; ?>
<?php if ($item->postcode) : ?>
<span itemprop="postalCode"><?php echo $this->escape($item->postcode); ?></span>
<?php endif; ?>
</div>
<?php if ($item->phone) : ?>
<div class="mokojoomstorelocator-phone">
<a href="tel:<?php echo $this->escape($item->phone); ?>" itemprop="telephone">
<?php echo $this->escape($item->phone); ?>
</a>
</div>
<?php endif; ?>
<?php if ($item->website) : ?>
<div class="mokojoomstorelocator-website">
<a href="<?php echo $this->escape($item->website); ?>" itemprop="url" target="_blank" rel="noopener">
<?php echo $this->escape(parse_url($item->website, PHP_URL_HOST) ?: $item->website); ?>
</a>
</div>
<?php endif; ?>
<?php if ($item->hours) : ?>
<div class="mokojoomstorelocator-hours" itemprop="openingHours">
<?php echo nl2br($this->escape($item->hours)); ?>
</div>
<?php endif; ?>
<?php if ($item->image) : ?>
<div class="mokojoomstorelocator-image">
<img src="<?php echo $this->escape($item->image); ?>"
alt="<?php echo $this->escape($item->title); ?>"
itemprop="image" loading="lazy" />
</div>
<?php endif; ?>
<?php if (!empty($item->distance)) : ?>
<div class="mokojoomstorelocator-distance">
<small class="text-muted"><?php echo number_format($item->distance, 1); ?> away</small>
</div>
<?php endif; ?>
<?php if ($item->latitude && $item->longitude) : ?>
<div class="mokojoomstorelocator-directions">
<a href="https://www.google.com/maps/dir/?api=1&destination=<?php echo (float) $item->latitude; ?>,<?php echo (float) $item->longitude; ?>"
target="_blank" rel="noopener" class="btn btn-sm btn-outline-primary">
<?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_GET_DIRECTIONS'); ?>
</a>
</div>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
<?php echo $this->pagination->getListFooter(); ?>
<?php endif; ?>
</div>
@@ -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."
@@ -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."
@@ -13,15 +13,15 @@
=========================================================================
-->
<extension type="module" client="site" method="upgrade">
<name>mod_mokojoomstorelocator_map</name>
<version>1.0.0</version>
<creationDate>2026-05-21</creationDate>
<name>Moko Store Locator - Map</name>
<version>00.00.01</version>
<creationDate>2026-05-22</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<copyright>Copyright (C) 2026 Moko Consulting. All rights reserved.</copyright>
<license>GNU General Public License version 3 or later; see LICENSE</license>
<description>MOD_MOKOJOOMSTORELOCATOR_MAP_DESC</description>
<description>Displays an interactive map with store location markers.</description>
<namespace path="src">Moko\Module\MokoJoomStoreLocatorMap</namespace>
@@ -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;
}
@@ -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;
?>
<div class="mod-mokojoomstorelocator-map"
id="mokojoomstorelocator-map-<?php echo $displayData['module']->id; ?>"
id="mokojoomstorelocator-map-<?php echo $moduleId; ?>"
style="height: <?php echo $this->escape($mapHeight); ?>;"
data-locations="<?php echo $this->escape(json_encode($locations)); ?>"
data-zoom="<?php echo $mapZoom; ?>"
data-provider="<?php echo $this->escape($provider); ?>">
<!-- TODO: Map renders here via JavaScript -->
<noscript>
<p><?php echo Text::_('MOD_MOKOJOOMSTORELOCATOR_MAP_NOSCRIPT'); ?></p>
</noscript>
</div>
<?php if ($provider === 'leaflet') : ?>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" crossorigin="" />
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.css" crossorigin="" />
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.Default.css" crossorigin="" />
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" crossorigin=""></script>
<script src="https://unpkg.com/leaflet.markercluster@1.5.3/dist/leaflet.markercluster.js" crossorigin=""></script>
<?php endif; ?>
<?php if ($provider === 'google' && $apiKey) : ?>
<script src="https://maps.googleapis.com/maps/api/js?key=<?php echo $this->escape($apiKey); ?>&libraries=marker&callback=Function.prototype" async defer></script>
<script src="https://unpkg.com/@googlemaps/markerclusterer@2.5.3/dist/index.min.js"></script>
<?php endif; ?>
<script>
document.addEventListener('DOMContentLoaded', function() {
var container = document.getElementById('mokojoomstorelocator-map-<?php echo $moduleId; ?>');
var locations = <?php echo json_encode($locations); ?>;
var defaultZoom = <?php echo $mapZoom; ?>;
var provider = '<?php echo $this->escape($provider); ?>';
if (!locations.length) {
var msg = document.createElement('p');
msg.style.cssText = 'text-align:center;padding:2em;color:#6c757d;';
msg.textContent = '<?php echo Text::_('JGLOBAL_NO_MATCHING_RESULTS', true); ?>';
container.appendChild(msg);
return;
}
// Calculate center from locations
var avgLat = 0, avgLng = 0;
locations.forEach(function(loc) { avgLat += loc.lat; avgLng += loc.lng; });
avgLat /= locations.length;
avgLng /= locations.length;
var categories = <?php echo json_encode($displayData['categories'] ?? []); ?>;
if (provider === 'leaflet') {
initLeaflet(container, locations, categories, avgLat, avgLng, defaultZoom);
} else if (provider === 'google') {
initGoogle(container, locations, categories, avgLat, avgLng, defaultZoom);
}
// Build category legend
if (categories.length > 0) {
var legend = document.createElement('div');
legend.className = 'mokojoomstorelocator-legend';
categories.forEach(function(cat) {
var item = document.createElement('span');
item.className = 'mokojoomstorelocator-legend-item';
var swatch = document.createElement('span');
swatch.className = 'mokojoomstorelocator-legend-swatch';
swatch.style.background = cat.color;
item.appendChild(swatch);
var label = document.createElement('span');
label.textContent = cat.title;
item.appendChild(label);
legend.appendChild(item);
});
container.parentNode.insertBefore(legend, container.nextSibling);
}
});
function buildPopupContent(loc) {
var wrapper = document.createElement('div');
wrapper.className = 'mokojoomstorelocator-popup';
var title = document.createElement('strong');
title.textContent = loc.title;
wrapper.appendChild(title);
if (loc.address) {
wrapper.appendChild(document.createElement('br'));
var addr = document.createElement('small');
addr.textContent = loc.address;
wrapper.appendChild(addr);
}
if (loc.phone) {
wrapper.appendChild(document.createElement('br'));
var phoneLink = document.createElement('a');
phoneLink.href = 'tel:' + loc.phone;
phoneLink.textContent = loc.phone;
wrapper.appendChild(phoneLink);
}
wrapper.appendChild(document.createElement('br'));
var dirLink = document.createElement('a');
dirLink.href = 'https://www.google.com/maps/dir/?api=1&destination=' + loc.lat + ',' + loc.lng;
dirLink.target = '_blank';
dirLink.rel = 'noopener';
dirLink.textContent = '<?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_GET_DIRECTIONS', true); ?>';
wrapper.appendChild(dirLink);
return wrapper;
}
function createLeafletIcon(loc) {
// Custom marker icon takes priority
if (loc.marker) {
return L.icon({
iconUrl: loc.marker,
iconSize: [32, 32],
iconAnchor: [16, 32],
popupAnchor: [0, -32]
});
}
// Color-coded circle marker via SVG data URI
var color = loc.color || '#3b82f6';
var svg = '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="36" viewBox="0 0 24 36">'
+ '<path d="M12 0C5.4 0 0 5.4 0 12c0 9 12 24 12 24s12-15 12-24C24 5.4 18.6 0 12 0z" fill="' + color + '"/>'
+ '<circle cx="12" cy="12" r="5" fill="white"/></svg>';
return L.icon({
iconUrl: 'data:image/svg+xml;base64,' + btoa(svg),
iconSize: [24, 36],
iconAnchor: [12, 36],
popupAnchor: [0, -36]
});
}
function initLeaflet(container, locations, categories, centerLat, centerLng, zoom) {
var map = L.map(container).setView([centerLat, centerLng], zoom);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
maxZoom: 19
}).addTo(map);
var markers = L.markerClusterGroup();
locations.forEach(function(loc) {
var marker = L.marker([loc.lat, loc.lng], { icon: createLeafletIcon(loc) });
var popup = L.popup().setContent(buildPopupContent(loc));
marker.bindPopup(popup);
marker._categoryIds = loc.categories || [];
markers.addLayer(marker);
});
map.addLayer(markers);
if (locations.length > 1) {
var bounds = markers.getBounds();
map.fitBounds(bounds, { padding: [30, 30] });
}
}
function initGoogle(container, locations, categories, centerLat, centerLng, zoom) {
var map = new google.maps.Map(container, {
center: { lat: centerLat, lng: centerLng },
zoom: zoom
});
var infoWindow = new google.maps.InfoWindow();
var gMarkers = [];
locations.forEach(function(loc) {
var marker = new google.maps.Marker({
position: { lat: loc.lat, lng: loc.lng },
map: map,
title: loc.title
});
marker.addListener('click', function() {
infoWindow.setContent(buildPopupContent(loc));
infoWindow.open(map, marker);
});
gMarkers.push(marker);
});
if (typeof markerClusterer !== 'undefined') {
new markerClusterer.MarkerClusterer({ map: map, markers: gMarkers });
}
// Fit bounds
if (locations.length > 1) {
var bounds = new google.maps.LatLngBounds();
locations.forEach(function(loc) {
bounds.extend({ lat: loc.lat, lng: loc.lng });
});
map.fitBounds(bounds);
}
}
</script>
<style>
.mokojoomstorelocator-popup { font-size: 13px; line-height: 1.5; }
.mokojoomstorelocator-popup strong { font-size: 14px; }
.mokojoomstorelocator-legend { padding: 8px 0; display: flex; flex-wrap: wrap; gap: 12px; }
.mokojoomstorelocator-legend-item { display: inline-flex; align-items: center; gap: 4px; font-size: 13px; }
.mokojoomstorelocator-legend-swatch { display: inline-block; width: 14px; height: 14px; border-radius: 50%; }
</style>
@@ -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."
@@ -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."
@@ -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."
@@ -13,15 +13,15 @@
=========================================================================
-->
<extension type="module" client="site" method="upgrade">
<name>mod_mokojoomstorelocator_search</name>
<version>1.0.0</version>
<creationDate>2026-05-21</creationDate>
<name>Moko Store Locator - Search</name>
<version>00.00.01</version>
<creationDate>2026-05-22</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<copyright>Copyright (C) 2026 Moko Consulting. All rights reserved.</copyright>
<license>GNU General Public License version 3 or later; see LICENSE</license>
<description>MOD_MOKOJOOMSTORELOCATOR_SEARCH_DESC</description>
<description>Provides a search and filter form for finding store locations.</description>
<namespace path="src">Moko\Module\MokoJoomStoreLocatorSearch</namespace>
@@ -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;
}
@@ -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;
?>
<div class="mod-mokojoomstorelocator-search">
<div class="mod-mokojoomstorelocator-search" id="mokojoomstorelocator-search-<?php echo $moduleId; ?>">
<form action="<?php echo Route::_('index.php?option=com_mokojoomstorelocator&view=locations'); ?>"
method="get" class="mokojoomstorelocator-search-form">
<div class="mokojoomstorelocator-search-field">
<label for="mokojoomstorelocator-query">
<label for="mokojoomstorelocator-query-<?php echo $moduleId; ?>">
<?php echo Text::_('MOD_MOKOJOOMSTORELOCATOR_SEARCH_LABEL'); ?>
</label>
<input type="text"
id="mokojoomstorelocator-query"
id="mokojoomstorelocator-query-<?php echo $moduleId; ?>"
name="filter_search"
class="form-control"
placeholder="<?php echo Text::_('MOD_MOKOJOOMSTORELOCATOR_SEARCH_PLACEHOLDER'); ?>"
/>
</div>
<!-- TODO: City dropdown filter -->
<!-- TODO: Radius dropdown filter -->
<!-- TODO: Geolocation "Use my location" button -->
<?php if ($params->get('show_city_filter', 1) && !empty($displayData['cities'])) : ?>
<div class="mokojoomstorelocator-search-field mt-2">
<label for="mokojoomstorelocator-city-<?php echo $moduleId; ?>">
<?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_FIELD_CITY'); ?>
</label>
<select id="mokojoomstorelocator-city-<?php echo $moduleId; ?>" name="filter_city" class="form-select">
<option value=""><?php echo Text::_('JALL'); ?></option>
<?php foreach ($displayData['cities'] as $city) : ?>
<option value="<?php echo $this->escape($city); ?>"><?php echo $this->escape($city); ?></option>
<?php endforeach; ?>
</select>
</div>
<?php endif; ?>
<button type="submit" class="btn btn-primary">
<?php echo Text::_('JSEARCH_FILTER_SUBMIT'); ?>
</button>
<?php if ($params->get('show_radius_filter', 1) && !empty($displayData['radius_options'])) : ?>
<div class="mokojoomstorelocator-search-field mt-2">
<label for="mokojoomstorelocator-radius-<?php echo $moduleId; ?>">
<?php echo Text::_('MOD_MOKOJOOMSTORELOCATOR_SEARCH_RADIUS_UNIT'); ?>
</label>
<select id="mokojoomstorelocator-radius-<?php echo $moduleId; ?>" name="filter_radius" class="form-select">
<option value=""><?php echo Text::_('JALL'); ?></option>
<?php foreach ($displayData['radius_options'] as $r) : ?>
<option value="<?php echo (int) $r; ?>"><?php echo (int) $r; ?> <?php echo $this->escape($displayData['radius_unit']); ?></option>
<?php endforeach; ?>
</select>
</div>
<input type="hidden" id="mokojoomstorelocator-lat-<?php echo $moduleId; ?>" name="filter_latitude" value="" />
<input type="hidden" id="mokojoomstorelocator-lng-<?php echo $moduleId; ?>" name="filter_longitude" value="" />
<?php endif; ?>
<div class="mokojoomstorelocator-search-actions mt-3">
<button type="submit" class="btn btn-primary">
<?php echo Text::_('JSEARCH_FILTER_SUBMIT'); ?>
</button>
<button type="button"
class="btn btn-outline-secondary ms-2"
id="mokojoomstorelocator-geolocate-<?php echo $moduleId; ?>"
title="<?php echo Text::_('MOD_MOKOJOOMSTORELOCATOR_SEARCH_USE_LOCATION'); ?>">
<span class="icon-location" aria-hidden="true"></span>
<?php echo Text::_('MOD_MOKOJOOMSTORELOCATOR_SEARCH_USE_LOCATION'); ?>
</button>
</div>
<div id="mokojoomstorelocator-geostatus-<?php echo $moduleId; ?>"
class="mokojoomstorelocator-geostatus mt-2" style="display:none;"></div>
<input type="hidden" name="option" value="com_mokojoomstorelocator" />
<input type="hidden" name="view" value="locations" />
<input type="hidden" name="filter_radius_unit" value="<?php echo $this->escape($displayData['radius_unit'] ?? 'miles'); ?>" />
</form>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
var moduleId = '<?php echo $moduleId; ?>';
var geoBtn = document.getElementById('mokojoomstorelocator-geolocate-' + moduleId);
var statusEl = document.getElementById('mokojoomstorelocator-geostatus-' + moduleId);
var latField = document.getElementById('mokojoomstorelocator-lat-' + moduleId);
var lngField = document.getElementById('mokojoomstorelocator-lng-' + moduleId);
var radiusEl = document.getElementById('mokojoomstorelocator-radius-' + moduleId);
if (!geoBtn) return;
if (!navigator.geolocation) {
geoBtn.style.display = 'none';
return;
}
geoBtn.addEventListener('click', function() {
geoBtn.disabled = true;
showStatus('<?php echo Text::_('MOD_MOKOJOOMSTORELOCATOR_SEARCH_LOCATING', true); ?>', 'info');
navigator.geolocation.getCurrentPosition(
function(position) {
var lat = position.coords.latitude;
var lng = position.coords.longitude;
if (latField) latField.value = lat.toFixed(8);
if (lngField) lngField.value = lng.toFixed(8);
// Auto-select first radius if none selected
if (radiusEl && !radiusEl.value && radiusEl.options.length > 1) {
radiusEl.selectedIndex = 1;
}
showStatus('<?php echo Text::_('MOD_MOKOJOOMSTORELOCATOR_SEARCH_LOCATION_FOUND', true); ?>', 'success');
geoBtn.disabled = false;
},
function(error) {
var msg;
switch (error.code) {
case error.PERMISSION_DENIED:
msg = '<?php echo Text::_('MOD_MOKOJOOMSTORELOCATOR_SEARCH_GEO_DENIED', true); ?>';
break;
case error.POSITION_UNAVAILABLE:
msg = '<?php echo Text::_('MOD_MOKOJOOMSTORELOCATOR_SEARCH_GEO_UNAVAILABLE', true); ?>';
break;
case error.TIMEOUT:
msg = '<?php echo Text::_('MOD_MOKOJOOMSTORELOCATOR_SEARCH_GEO_TIMEOUT', true); ?>';
break;
default:
msg = '<?php echo Text::_('MOD_MOKOJOOMSTORELOCATOR_SEARCH_GEO_ERROR', true); ?>';
}
showStatus(msg, 'warning');
geoBtn.disabled = false;
},
{ enableHighAccuracy: true, timeout: 10000, maximumAge: 300000 }
);
});
function showStatus(message, type) {
statusEl.style.display = 'block';
statusEl.className = 'mokojoomstorelocator-geostatus mt-2 alert alert-' + type;
statusEl.textContent = message;
if (type === 'success') {
setTimeout(function() { statusEl.style.display = 'none'; }, 3000);
}
}
});
</script>
@@ -0,0 +1 @@
PLG_WEBSERVICES_MOKOJOOMSTORELOCATOR_DESC="REST API endpoints for store locations and categories."
@@ -0,0 +1 @@
PLG_WEBSERVICES_MOKOJOOMSTORELOCATOR_DESC="REST API endpoints for store locations and categories."
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<extension type="plugin" group="webservices" method="upgrade">
<name>Web Services - Moko Store Locator</name>
<version>00.00.01</version>
<creationDate>2026-05-22</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<description>Web Services API for the MokoJoomStoreLocator component.</description>
<namespace path="src">Moko\Plugin\WebServices\MokoJoomStoreLocator</namespace>
<files><folder>src</folder><folder>language</folder></files>
</extension>
@@ -0,0 +1,60 @@
<?php
/**
* Web Services plugin for MokoJoomStoreLocator.
*
* Registers REST API routes:
* GET /api/index.php/v1/storelocator/locations — list locations
* GET /api/index.php/v1/storelocator/locations/{id} — single location
* GET /api/index.php/v1/storelocator/categories — list categories
* GET /api/index.php/v1/storelocator/categories/{id} — single category
* POST /api/index.php/v1/storelocator/locations — create location (admin)
* PATCH /api/index.php/v1/storelocator/locations/{id} — update location (admin)
* DELETE /api/index.php/v1/storelocator/locations/{id} — delete location (admin)
*/
namespace Moko\Plugin\WebServices\MokoJoomStoreLocator\Extension;
defined('_JEXEC') or die;
use Joomla\CMS\Plugin\CMSPlugin;
use Joomla\CMS\Router\ApiRouter;
use Joomla\Event\SubscriberInterface;
use Joomla\Router\Route;
class MokoJoomStoreLocator extends CMSPlugin implements SubscriberInterface
{
public static function getSubscribedEvents(): array
{
return [
'onBeforeApiRoute' => 'onBeforeApiRoute',
];
}
public function onBeforeApiRoute(&$event): void
{
$router = $event->getArgument('router');
$router->createCRUDRoutes(
'v1/storelocator/locations',
'locations',
['component' => 'com_mokojoomstorelocator']
);
$router->createCRUDRoutes(
'v1/storelocator/categories',
'categories',
['component' => 'com_mokojoomstorelocator']
);
// Custom route: search locations by radius
$router->addRoute(
new Route(
['GET'],
'v1/storelocator/search',
'locations.search',
[],
['component' => 'com_mokojoomstorelocator']
)
);
}
}
+5 -4
View File
@@ -16,22 +16,23 @@
=========================================================================
-->
<extension type="package" method="upgrade">
<name>pkg_mokojoomstorelocator</name>
<name>Moko Joom Store Locator</name>
<packagename>mokojoomstorelocator</packagename>
<version>1.0.0</version>
<creationDate>2026-05-21</creationDate>
<version>00.00.01</version>
<creationDate>2026-05-22</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<copyright>Copyright (C) 2026 Moko Consulting. All rights reserved.</copyright>
<license>GNU General Public License version 3 or later; see LICENSE</license>
<description>PKG_MOKOJOOMSTORELOCATOR_DESC</description>
<description>Complete store locator suite for Joomla with map, search, and API support.</description>
<scriptfile>script.php</scriptfile>
<files>
<file type="component" id="com_mokojoomstorelocator">com_mokojoomstorelocator.zip</file>
<file type="module" id="mod_mokojoomstorelocator_map" client="site">mod_mokojoomstorelocator_map.zip</file>
<file type="module" id="mod_mokojoomstorelocator_search" client="site">mod_mokojoomstorelocator_search.zip</file>
<file type="plugin" id="plg_webservices_mokojoomstorelocator" group="webservices">plg_webservices_mokojoomstorelocator.zip</file>
</files>
<updateservers>
+42 -2
View File
@@ -85,7 +85,10 @@ class Pkg_MokojoomstorelocatorInstallerScript
*/
public function postflight($type, $parent)
{
// TODO: Post-installation tasks (enable modules, set defaults, etc.)
if ($type === 'install')
{
$this->enableModules();
}
return true;
}
@@ -101,6 +104,43 @@ class Pkg_MokojoomstorelocatorInstallerScript
*/
public function uninstall($parent)
{
// TODO: Cleanup tasks on uninstall
Log::add('MokoJoomStoreLocator package uninstalled.', Log::INFO, 'jerror');
}
/**
* Enable the bundled modules after first install.
*
* @return void
*
* @since 1.0.0
*/
private function enableModules(): void
{
$db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class);
$modules = [
'mod_mokojoomstorelocator_map',
'mod_mokojoomstorelocator_search',
];
foreach ($modules as $module)
{
$query = $db->getQuery(true)
->update($db->quoteName('#__extensions'))
->set($db->quoteName('enabled') . ' = 1')
->where($db->quoteName('element') . ' = :element')
->where($db->quoteName('type') . ' = ' . $db->quote('module'))
->bind(':element', $module);
$db->setQuery($query);
try
{
$db->execute();
}
catch (\Exception $e)
{
Log::add('Failed to enable ' . $module . ': ' . $e->getMessage(), Log::WARNING, 'jerror');
}
}
}
}