feat: v1.2 multi-category, REST API, ACL, security hardening (#1, #2, #29, #30, #31, #34, #48)
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 3s
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Generic: Repo Health / Access control (pull_request) Successful in 2s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Universal: PR Check / Validate PR (pull_request) Failing after 4s
Universal: PR Check / Secret Scan (pull_request) Successful in 5s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 10s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Has been skipped
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 37s
Universal: Auto Version Bump / Version Bump (push) Successful in 8s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 10s
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Has been cancelled
Joomla: Extension CI / PHPStan Analysis (pull_request) Has been cancelled
Joomla: Extension CI / Build RC Pre-Release (pull_request) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report: Scripts Governance (pull_request) Has been cancelled
Generic: Repo Health / Report: Repository Health (pull_request) Has been cancelled

Multi-category support with parent/child hierarchy, junction table,
admin CRUD, and per-category custom marker icons on the Leaflet map.

REST API via Web Services plugin with JSON:API endpoints. ACL
permissions via access.xml. SQL update schema for safe upgrades.

Shop integration bridge (LocationBridgeHelper, LocationSavedEvent).
Security: CSV formula injection prevention, MIME validation, file size
limit, ORDER BY allowlist, map height CSS regex validation.

Authored-by: Moko Consulting
This commit is contained in:
2026-06-27 14:37:15 -05:00
parent cf495cd8ce
commit 6426fee428
38 changed files with 1559 additions and 46 deletions
+30 -1
View File
@@ -5,7 +5,35 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.1.0] - Unreleased
## [1.2.0] - Unreleased
### Added
- Multi-category support with parent/child hierarchy (#1)
- Categories admin CRUD — list, edit, color picker, custom marker icon
- Location-category junction table (many-to-many)
- Categories tab on location edit form (multi-select)
- Category filtering on site frontend (`catid` parameter)
- Custom map markers per category — SVG/PNG icon support (#2)
- Map module JOINs category data for marker icons and colors
- `access.xml` with full Joomla ACL permissions (#30)
- SQL update schema with `sql/updates/mysql/` versioned files (#31)
- REST API via Web Services plugin (`plg_webservices_mokosuitestorelocator`) (#29)
- API controller + JSON:API view for locations CRUD at `/api/v1/mokosuitestorelocator/locations`
- `LocationBridgeHelper` — static helper for cross-extension integration (#48)
- `LocationSavedEvent` — fires `onStoreLocatorLocationSaved` for cache invalidation
- Plugin added to package manifest
### Changed
- Map module dispatcher uses aliased table queries with category JOIN
- ORDER BY clauses in admin and site models now validated against filter_fields allowlist
### Security
- CSV import: MIME type validation, 2 MB file size limit, delimiter allowlist (#34)
- CSV import: formula injection prevention (strips leading `=+\-@\t\r` characters)
- ORDER BY injection prevention — replaced `$db->escape()` with allowlist validation
- Map module: `$mapHeight` CSS value validated with regex pattern
## [1.1.0] - 2026-06-23
### Added
- Haversine proximity search — filter locations by distance from user's coordinates
@@ -18,6 +46,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- CSV import: auto-detect column headers (title/name/store, address/street, city, etc.)
- CSV import: per-row validation via LocationTable::bind()->check()->store()
- CSV import view accessible from admin toolbar and submenu
- FocalPoint (Shack Locations) migration import
- Language strings for directions, geocoding feedback, and import UI
## [01.00.00] - 2026-06-23
+14 -6
View File
@@ -9,6 +9,7 @@ A Joomla 4/5 package providing a store locator listing component with coordinati
| Store Locator Component | component | `com_mokosuitestorelocator` |
| Store Locator Map | module (site) | `mod_mokosuitestorelocator_map` |
| Store Locator Search | module (site) | `mod_mokosuitestorelocator_search` |
| Web Services API | plugin (webservices) | `plg_webservices_mokosuitestorelocator` |
## Requirements
@@ -27,7 +28,9 @@ A Joomla 4/5 package providing a store locator listing component with coordinati
### Implemented
- **Admin CRUD** — full location management with tabbed edit form (details, address, coordinates, contact, image)
- **Admin list** — searchable, filterable, sortable locations list with bulk publish/unpublish/delete
- **Site frontend** — locations list and detail views with pagination
- **Multi-category** — categories with parent/child hierarchy, color, custom marker icons, many-to-many assignments
- **Custom map markers** — per-category SVG/PNG marker icons on the Leaflet map
- **Site frontend** — locations list and detail views with pagination and category filtering
- **Schema.org** — LocalBusiness structured data markup on all frontend templates
- **SEF URLs** — router with menu, standard, and nomenu rules
- **Menu items** — "All Locations" list and single "Location Detail" picker
@@ -37,13 +40,18 @@ A Joomla 4/5 package providing a store locator listing component with coordinati
- **Get Directions** — Google Maps directions link on detail page and map popups
- **Auto-geocoding** — coordinates auto-populated from address on save (Nominatim/OSM)
- **CSV import** — bulk-create locations from spreadsheet with auto-detected column mapping
- **FocalPoint migration** — one-click import from Shack Locations / FocalPoint
- **REST API** — JSON:API endpoints via Joomla Web Services plugin
- **ACL permissions** — `access.xml` with standard Joomla permission actions
- **SQL update schema** — versioned migration files for safe upgrades
- **Shop integration** — `LocationBridgeHelper` for cross-extension data access, `LocationSavedEvent` for cache invalidation
- **Security hardening** — CSV injection prevention, MIME validation, ORDER BY allowlists, input sanitization
### Planned
- Marker clustering for dense location areas
- Multi-category support with custom map markers
- ACL permissions and SQL upgrade schema
- REST API via Joomla Web Services plugin
- MokoSuiteShop integration for multi-store ecommerce
- Marker clustering for dense location areas (Leaflet.markercluster)
- Google Maps provider as alternative to Leaflet
- CSV export
- Photo gallery per location
## Development
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<access component="com_mokosuitestorelocator">
<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.delete" title="JACTION_DELETE" />
<action name="core.edit" title="JACTION_EDIT" />
<action name="core.edit.state" title="JACTION_EDITSTATE" />
<action name="core.edit.own" title="JACTION_EDITOWN" />
</section>
</access>
@@ -0,0 +1,69 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Category edit form -->
<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_CATEGORY_PARENT"
default="0"
query="SELECT id AS value, title AS text FROM #__mokosuitestorelocator_categories WHERE published = 1 ORDER BY ordering"
>
<option value="0">COM_MOKOJOOMSTORELOCATOR_CATEGORY_NO_PARENT</option>
</field>
<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_CATEGORY_COLOR"
default=""
/>
<field
name="marker_icon"
type="media"
label="COM_MOKOJOOMSTORELOCATOR_CATEGORY_MARKER_ICON"
/>
</fieldset>
</form>
@@ -0,0 +1,47 @@
<?xml version="1.0" encoding="utf-8"?>
<form>
<fields name="filter">
<field
name="search"
type="text"
label="COM_MOKOJOOMSTORELOCATOR_FILTER_SEARCH_LABEL"
hint="JSEARCH_FILTER"
/>
<field
name="published"
type="list"
label="JOPTION_SELECT_PUBLISHED"
onchange="this.form.submit();"
>
<option value="">JOPTION_SELECT_PUBLISHED</option>
<option value="1">JPUBLISHED</option>
<option value="0">JUNPUBLISHED</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.title ASC">JGLOBAL_TITLE_ASC</option>
<option value="a.title DESC">JGLOBAL_TITLE_DESC</option>
<option value="a.ordering ASC">JGRID_HEADING_ORDERING_ASC</option>
<option value="a.ordering DESC">JGRID_HEADING_ORDERING_DESC</option>
<option value="a.id ASC">JGRID_HEADING_ID_ASC</option>
<option value="a.id DESC">JGRID_HEADING_ID_DESC</option>
</field>
<field
name="limit"
type="limitbox"
label="JGLOBAL_LIST_LIMIT"
default="25"
onchange="this.form.submit();"
/>
</fields>
</form>
@@ -43,6 +43,16 @@
</field>
</fieldset>
<fieldset name="categories" label="COM_MOKOJOOMSTORELOCATOR_FIELDSET_CATEGORIES">
<field
name="categories"
type="sql"
label="COM_MOKOJOOMSTORELOCATOR_CATEGORIES"
multiple="true"
query="SELECT id AS value, title AS text FROM #__mokosuitestorelocator_categories WHERE published = 1 ORDER BY ordering"
/>
</fieldset>
<fieldset name="address" label="COM_MOKOJOOMSTORELOCATOR_FIELDSET_ADDRESS">
<field
name="address"
@@ -64,3 +64,17 @@ COM_MOKOJOOMSTORELOCATOR_IMPORT_FP_TITLE="Import from FocalPoint"
COM_MOKOJOOMSTORELOCATOR_IMPORT_FP_DESC="Migrate locations from an installed FocalPoint (Shack Locations) component. Coordinates, custom fields (email, website, hours), and metadata are mapped automatically."
COM_MOKOJOOMSTORELOCATOR_IMPORT_FP_BUTTON="Import FocalPoint Locations"
COM_MOKOJOOMSTORELOCATOR_IMPORT_FP_SUCCESS="%d location(s) imported from FocalPoint."
COM_MOKOJOOMSTORELOCATOR_IMPORT_FILE_TOO_LARGE="The uploaded file exceeds the 2 MB size limit."
COM_MOKOJOOMSTORELOCATOR_CATEGORIES="Categories"
COM_MOKOJOOMSTORELOCATOR_CATEGORY_NEW="New Category"
COM_MOKOJOOMSTORELOCATOR_CATEGORY_EDIT="Edit Category"
COM_MOKOJOOMSTORELOCATOR_CATEGORY_PARENT="Parent Category"
COM_MOKOJOOMSTORELOCATOR_CATEGORY_NO_PARENT="- No Parent -"
COM_MOKOJOOMSTORELOCATOR_CATEGORY_COLOR="Color"
COM_MOKOJOOMSTORELOCATOR_CATEGORY_MARKER_ICON="Custom Marker Icon"
COM_MOKOJOOMSTORELOCATOR_CATEGORIES_TABLE_CAPTION="Store Location Categories"
COM_MOKOJOOMSTORELOCATOR_ERROR_CATEGORY_TITLE_REQUIRED="A category title is required."
COM_MOKOJOOMSTORELOCATOR_FIELDSET_CATEGORIES="Categories"
COM_MOKOJOOMSTORELOCATOR_FIELDSET_APPEARANCE="Appearance"
@@ -38,3 +38,38 @@ CREATE TABLE IF NOT EXISTS `#__mokosuitestorelocator_locations` (
KEY `idx_alias` (`alias`(191)),
KEY `idx_coordinates` (`latitude`, `longitude`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- =========================================================================
-- Categories table
-- =========================================================================
CREATE TABLE IF NOT EXISTS `#__mokosuitestorelocator_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 '',
`marker_icon` varchar(255) NOT NULL DEFAULT '',
`published` tinyint(4) NOT NULL DEFAULT 1,
`ordering` int(11) NOT NULL DEFAULT 0,
`level` int(10) unsigned NOT NULL DEFAULT 1,
`path` varchar(400) NOT NULL DEFAULT '',
`created` datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
`modified` datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
PRIMARY KEY (`id`),
KEY `idx_published` (`published`),
KEY `idx_parent_id` (`parent_id`),
KEY `idx_alias` (`alias`(191))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- =========================================================================
-- Location-Category junction table (many-to-many)
-- =========================================================================
CREATE TABLE IF NOT EXISTS `#__mokosuitestorelocator_location_categories` (
`location_id` int(11) NOT NULL,
`category_id` int(11) NOT NULL,
PRIMARY KEY (`location_id`, `category_id`),
KEY `idx_category_id` (`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 `#__mokosuitestorelocator_location_categories`;
DROP TABLE IF EXISTS `#__mokosuitestorelocator_categories`;
DROP TABLE IF EXISTS `#__mokosuitestorelocator_locations`;
@@ -0,0 +1 @@
-- MokoSuiteStoreLocator 01.00.00 — Initial release, no schema changes needed.
@@ -0,0 +1,28 @@
-- MokoSuiteStoreLocator 01.00.01 — Add categories and location-category junction tables.
CREATE TABLE IF NOT EXISTS `#__mokosuitestorelocator_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 '',
`marker_icon` varchar(255) NOT NULL DEFAULT '',
`published` tinyint(4) NOT NULL DEFAULT 1,
`ordering` int(11) NOT NULL DEFAULT 0,
`level` int(10) unsigned NOT NULL DEFAULT 1,
`path` varchar(400) NOT NULL DEFAULT '',
`created` datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
`modified` datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
PRIMARY KEY (`id`),
KEY `idx_published` (`published`),
KEY `idx_parent_id` (`parent_id`),
KEY `idx_alias` (`alias`(191))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `#__mokosuitestorelocator_location_categories` (
`location_id` int(11) NOT NULL,
`category_id` int(11) NOT NULL,
PRIMARY KEY (`location_id`, `category_id`),
KEY `idx_category_id` (`category_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@@ -0,0 +1,37 @@
<?php
/**
* @package MokoSuiteStoreLocator
* @subpackage com_mokosuitestorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoSuiteStoreLocator\Administrator\Controller;
defined('_JEXEC') or die;
use Joomla\CMS\MVC\Controller\AdminController;
/**
* Categories list controller.
*
* @since 1.2.0
*/
class CategoriesController extends AdminController
{
/**
* Get the model for this controller.
*
* @param string $name Model name.
* @param string $prefix Model prefix.
* @param array $config Configuration.
*
* @return \Joomla\CMS\MVC\Model\BaseDatabaseModel
*
* @since 1.2.0
*/
public function getModel($name = 'Category', $prefix = 'Administrator', $config = ['ignore_request' => true])
{
return parent::getModel($name, $prefix, $config);
}
}
@@ -0,0 +1,22 @@
<?php
/**
* @package MokoSuiteStoreLocator
* @subpackage com_mokosuitestorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoSuiteStoreLocator\Administrator\Controller;
defined('_JEXEC') or die;
use Joomla\CMS\MVC\Controller\FormController;
/**
* Category edit controller.
*
* @since 1.2.0
*/
class CategoryController extends FormController
{
}
@@ -49,6 +49,12 @@ class ImportController extends BaseController
$file = $this->input->files->get('jform', [], 'array');
$delimiter = $this->input->post->getString('delimiter', ',');
// Validate delimiter against allowlist
if (!\in_array($delimiter, [',', ';', '|', "\t"], true))
{
$delimiter = ',';
}
$csvFile = $file['csv_file'] ?? null;
if (!$csvFile || $csvFile['error'] !== UPLOAD_ERR_OK || !is_uploaded_file($csvFile['tmp_name']))
@@ -59,6 +65,15 @@ class ImportController extends BaseController
return;
}
// Enforce 2 MB file size limit
if ($csvFile['size'] > 2 * 1024 * 1024)
{
$this->setMessage(Text::_('COM_MOKOJOOMSTORELOCATOR_IMPORT_FILE_TOO_LARGE'), 'error');
$this->setRedirect(Route::_('index.php?option=com_mokosuitestorelocator&view=import', false));
return;
}
// Validate file extension
$ext = strtolower(pathinfo($csvFile['name'], PATHINFO_EXTENSION));
@@ -70,6 +85,19 @@ class ImportController extends BaseController
return;
}
// Validate MIME type
$finfo = new \finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($csvFile['tmp_name']);
$allowedMimes = ['text/csv', 'text/plain', 'application/csv', 'application/vnd.ms-excel', 'application/octet-stream'];
if (!$mime || !\in_array($mime, $allowedMimes, true))
{
$this->setMessage(Text::_('COM_MOKOJOOMSTORELOCATOR_IMPORT_INVALID_FILE'), 'error');
$this->setRedirect(Route::_('index.php?option=com_mokosuitestorelocator&view=import', false));
return;
}
$result = $model->processImport($csvFile['tmp_name'], $delimiter);
if ($result['imported'] > 0)
@@ -0,0 +1,46 @@
<?php
/**
* @package MokoSuiteStoreLocator
* @subpackage com_mokosuitestorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoSuiteStoreLocator\Administrator\Event;
defined('_JEXEC') or die;
use Joomla\CMS\Event\AbstractEvent;
/**
* Event fired after a location is saved, for cross-extension integration.
*
* @since 1.2.0
*/
class LocationSavedEvent extends AbstractEvent
{
/**
* Constructor.
*
* @param string $name The event name.
* @param array $arguments Event arguments: ['locationData' => array].
*
* @since 1.2.0
*/
public function __construct(string $name, array $arguments = [])
{
parent::__construct($name, $arguments);
}
/**
* Get the saved location data.
*
* @return array
*
* @since 1.2.0
*/
public function getLocationData(): array
{
return $this->getArgument('locationData', []);
}
}
@@ -0,0 +1,162 @@
<?php
/**
* @package MokoSuiteStoreLocator
* @subpackage com_mokosuitestorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoSuiteStoreLocator\Administrator\Helper;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\Database\ParameterType;
/**
* Bridge helper for external extensions (e.g. MokoSuiteShop) to query location data.
*
* All methods are static and return plain objects/arrays with no Joomla model dependencies.
*
* @since 1.2.0
*/
class LocationBridgeHelper
{
/**
* Get all active locations.
*
* @param bool $publishedOnly Only return published locations.
*
* @return array
*
* @since 1.2.0
*/
public static function getLocations(bool $publishedOnly = true): array
{
$db = Factory::getContainer()->get('DatabaseDriver');
$query = $db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokosuitestorelocator_locations'));
if ($publishedOnly)
{
$query->where($db->quoteName('published') . ' = 1');
}
$query->order($db->quoteName('ordering') . ' ASC');
$db->setQuery($query);
return $db->loadObjectList() ?: [];
}
/**
* Get a single location by ID.
*
* @param int $locationId The location ID.
*
* @return object|null
*
* @since 1.2.0
*/
public static function getById(int $locationId): ?object
{
$db = Factory::getContainer()->get('DatabaseDriver');
$query = $db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokosuitestorelocator_locations'))
->where($db->quoteName('id') . ' = :id')
->bind(':id', $locationId, ParameterType::INTEGER);
$db->setQuery($query);
return $db->loadObject() ?: null;
}
/**
* Get locations within a radius using Haversine formula.
*
* @param float $lat Latitude of the search origin.
* @param float $lng Longitude of the search origin.
* @param float $radiusMiles Search radius in miles.
* @param int $limit Maximum results.
*
* @return array Objects with an additional `distance` property (miles).
*
* @since 1.2.0
*/
public static function getNearby(float $lat, float $lng, float $radiusMiles = 25, int $limit = 10): array
{
$db = Factory::getContainer()->get('DatabaseDriver');
$query = $db->getQuery(true);
$haversine = '(3959 * ACOS(LEAST(1, GREATEST(-1, '
. 'SIN(RADIANS(' . $db->quoteName('latitude') . ')) * SIN(RADIANS(' . (float) $lat . ')) '
. '+ COS(RADIANS(' . $db->quoteName('latitude') . ')) * COS(RADIANS(' . (float) $lat . ')) '
. '* COS(RADIANS(' . $db->quoteName('longitude') . ' - ' . (float) $lng . '))'
. '))))';
$query->select('*')
->select($haversine . ' AS distance')
->from($db->quoteName('#__mokosuitestorelocator_locations'))
->where($db->quoteName('published') . ' = 1')
->where($db->quoteName('latitude') . ' IS NOT NULL')
->where($db->quoteName('longitude') . ' IS NOT NULL')
->where($haversine . ' <= ' . (float) $radiusMiles)
->order('distance ASC');
$db->setQuery($query, 0, $limit);
return $db->loadObjectList() ?: [];
}
/**
* Get locations by city.
*
* @param string $city City name.
*
* @return array
*
* @since 1.2.0
*/
public static function getByCity(string $city): array
{
$db = Factory::getContainer()->get('DatabaseDriver');
$query = $db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokosuitestorelocator_locations'))
->where($db->quoteName('published') . ' = 1')
->where($db->quoteName('city') . ' = :city')
->bind(':city', $city)
->order($db->quoteName('ordering') . ' ASC');
$db->setQuery($query);
return $db->loadObjectList() ?: [];
}
/**
* Get locations by state/province.
*
* @param string $state State/province name.
*
* @return array
*
* @since 1.2.0
*/
public static function getByState(string $state): array
{
$db = Factory::getContainer()->get('DatabaseDriver');
$query = $db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokosuitestorelocator_locations'))
->where($db->quoteName('published') . ' = 1')
->where($db->quoteName('state') . ' = :state')
->bind(':state', $state)
->order($db->quoteName('ordering') . ' ASC');
$db->setQuery($query);
return $db->loadObjectList() ?: [];
}
}
@@ -0,0 +1,126 @@
<?php
/**
* @package MokoSuiteStoreLocator
* @subpackage com_mokosuitestorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoSuiteStoreLocator\Administrator\Model;
defined('_JEXEC') or die;
use Joomla\CMS\MVC\Model\ListModel;
use Joomla\Database\QueryInterface;
/**
* Categories list model.
*
* @since 1.2.0
*/
class CategoriesModel extends ListModel
{
/**
* Constructor.
*
* @param array $config Configuration settings.
*
* @since 1.2.0
*/
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::__construct($config);
}
/**
* Populate the model state.
*
* @param string $ordering Default ordering column.
* @param string $direction Default ordering direction.
*
* @return void
*
* @since 1.2.0
*/
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);
parent::populateState($ordering, $direction);
}
/**
* Build an SQL query to load the list data.
*
* @return QueryInterface
*
* @since 1.2.0
*/
protected function getListQuery(): QueryInterface
{
$db = $this->getDatabase();
$query = $db->getQuery(true);
$query->select('a.*')
->from($db->quoteName('#__mokosuitestorelocator_categories', 'a'));
// Count locations per category
$subQuery = $db->getQuery(true)
->select('COUNT(*)')
->from($db->quoteName('#__mokosuitestorelocator_location_categories', 'lc'))
->where($db->quoteName('lc.category_id') . ' = ' . $db->quoteName('a.id'));
$query->select('(' . $subQuery . ') AS ' . $db->quoteName('location_count'));
// Filter by published state
$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 filter
$search = $this->getState('filter.search');
if (!empty($search))
{
$search = '%' . trim($search) . '%';
$query->where($db->quoteName('a.title') . ' LIKE :search')
->bind(':search', $search);
}
// Ordering — validate against filter_fields allowlist
$orderCol = $this->state->get('list.ordering', 'a.ordering');
$orderDir = $this->state->get('list.direction', 'ASC');
if (!\in_array($orderCol, $this->filter_fields, true))
{
$orderCol = 'a.ordering';
}
$orderDir = strtoupper($orderDir) === 'DESC' ? 'DESC' : 'ASC';
$query->order($db->quoteName($orderCol) . ' ' . $orderDir);
return $query;
}
}
@@ -0,0 +1,85 @@
<?php
/**
* @package MokoSuiteStoreLocator
* @subpackage com_mokosuitestorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoSuiteStoreLocator\Administrator\Model;
defined('_JEXEC') or die;
use Joomla\CMS\Form\Form;
use Joomla\CMS\MVC\Model\AdminModel;
use Joomla\CMS\Table\Table;
/**
* Single category edit model.
*
* @since 1.2.0
*/
class CategoryModel extends AdminModel
{
/**
* The type alias for this content type.
*
* @var string
* @since 1.2.0
*/
public $typeAlias = 'com_mokosuitestorelocator.category';
/**
* 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
*
* @since 1.2.0
*/
public function getForm($data = [], $loadData = true)
{
$form = $this->loadForm(
'com_mokosuitestorelocator.category',
'category',
['control' => 'jform', 'load_data' => $loadData]
);
if (empty($form))
{
return false;
}
return $form;
}
/**
* Load the data for the form.
*
* @return mixed
*
* @since 1.2.0
*/
protected function loadFormData()
{
return $this->getItem();
}
/**
* Get the table for this model.
*
* @param string $name The table name.
* @param string $prefix The table prefix.
* @param array $options Configuration array.
*
* @return Table
*
* @since 1.2.0
*/
public function getTable($name = 'Category', $prefix = 'Administrator', $options = [])
{
return parent::getTable($name, $prefix, $options);
}
}
@@ -206,12 +206,35 @@ class ImportModel extends BaseDatabaseModel
foreach ($mapping as $dbField => $csvIndex)
{
$data[$dbField] = trim($row[$csvIndex] ?? '');
$value = trim($row[$csvIndex] ?? '');
$data[$dbField] = $this->sanitizeCsvValue($value);
}
return $data;
}
/**
* Sanitize a CSV value to prevent formula injection.
*
* Strips leading characters that spreadsheet applications interpret as formulas.
*
* @param string $value Raw CSV cell value.
*
* @return string Sanitized value.
*
* @since 1.1.0
*/
private function sanitizeCsvValue(string $value): string
{
if ($value === '')
{
return $value;
}
// Strip leading formula trigger characters
return ltrim($value, "=+\-@\t\r");
}
/**
* Import locations from an installed FocalPoint (Shack Locations) component.
*
@@ -13,6 +13,7 @@ defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Form\Form;
use Joomla\CMS\Http\HttpFactory;
use Moko\Component\MokoSuiteStoreLocator\Administrator\Event\LocationSavedEvent;
use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\Model\AdminModel;
use Joomla\CMS\Table\Table;
@@ -58,20 +59,6 @@ class LocationModel extends AdminModel
return $form;
}
/**
* Load the data for the form.
*
* @return mixed The data for the form.
*
* @since 1.0.0
*/
protected function loadFormData()
{
$data = $this->getItem();
return $data;
}
/**
* Get the table for this model.
*
@@ -118,7 +105,99 @@ class LocationModel extends AdminModel
}
}
return parent::save($data);
// Extract categories before parent::save (it won't know about junction table)
$categories = $data['categories'] ?? [];
unset($data['categories']);
if (!parent::save($data))
{
return false;
}
// Save category associations
$locationId = (int) $this->getState($this->getName() . '.id');
$this->saveCategories($locationId, $categories);
// Fire event for cross-extension integration (e.g. MokoSuiteShop)
$data['id'] = $locationId;
Factory::getApplication()->getDispatcher()->dispatch(
'onStoreLocatorLocationSaved',
new LocationSavedEvent('onStoreLocatorLocationSaved', ['locationData' => $data])
);
return true;
}
/**
* Save location-category associations in the junction table.
*
* @param int $locationId The location ID.
* @param array $categories Array of category IDs.
*
* @return void
*
* @since 1.2.0
*/
private function saveCategories(int $locationId, array $categories): void
{
$db = $this->getDatabase();
// Remove existing associations
$query = $db->getQuery(true)
->delete($db->quoteName('#__mokosuitestorelocator_location_categories'))
->where($db->quoteName('location_id') . ' = :locationId')
->bind(':locationId', $locationId, \Joomla\Database\ParameterType::INTEGER);
$db->setQuery($query);
$db->execute();
// Insert new associations
if (!empty($categories))
{
$query = $db->getQuery(true)
->insert($db->quoteName('#__mokosuitestorelocator_location_categories'))
->columns([$db->quoteName('location_id'), $db->quoteName('category_id')]);
foreach ($categories as $catId)
{
$catId = (int) $catId;
if ($catId > 0)
{
$query->values($locationId . ', ' . $catId);
}
}
$db->setQuery($query);
$db->execute();
}
}
/**
* Load the data for the form, including category associations.
*
* @return mixed
*
* @since 1.0.0
*/
protected function loadFormData()
{
$data = $this->getItem();
if ($data && (int) $data->id > 0)
{
$db = $this->getDatabase();
$query = $db->getQuery(true)
->select($db->quoteName('category_id'))
->from($db->quoteName('#__mokosuitestorelocator_location_categories'))
->where($db->quoteName('location_id') . ' = :id')
->bind(':id', $data->id, \Joomla\Database\ParameterType::INTEGER);
$db->setQuery($query);
$data->categories = $db->loadColumn();
}
return $data;
}
/**
@@ -111,10 +111,17 @@ class LocationsModel extends ListModel
->bind(':search4', $search);
}
// Ordering
$orderCol = $this->state->get('list.ordering', 'a.title');
$orderDir = $this->state->get('list.direction', 'ASC');
$query->order($db->escape($orderCol) . ' ' . $db->escape($orderDir));
// Ordering — validate against filter_fields allowlist
$orderCol = $this->state->get('list.ordering', 'a.title');
$orderDir = $this->state->get('list.direction', 'ASC');
if (!\in_array($orderCol, $this->filter_fields, true))
{
$orderCol = 'a.title';
}
$orderDir = strtoupper($orderDir) === 'DESC' ? 'DESC' : 'ASC';
$query->order($db->quoteName($orderCol) . ' ' . $orderDir);
return $query;
}
@@ -0,0 +1,81 @@
<?php
/**
* @package MokoSuiteStoreLocator
* @subpackage com_mokosuitestorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoSuiteStoreLocator\Administrator\Table;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Filter\OutputFilter;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Table\Table;
use Joomla\Database\DatabaseDriver;
/**
* Category table class.
*
* @since 1.2.0
*/
class CategoryTable extends Table
{
/**
* Constructor.
*
* @param DatabaseDriver $db Database driver object.
*
* @since 1.2.0
*/
public function __construct(DatabaseDriver $db)
{
parent::__construct('#__mokosuitestorelocator_categories', 'id', $db);
}
/**
* Overloaded check method to ensure data integrity.
*
* @return boolean True if the data is valid.
*
* @since 1.2.0
*/
public function check(): bool
{
if (trim($this->title) === '')
{
$this->setError(Text::_('COM_MOKOJOOMSTORELOCATOR_ERROR_CATEGORY_TITLE_REQUIRED'));
return false;
}
if (trim($this->alias) === '')
{
$this->alias = $this->title;
}
$this->alias = OutputFilter::stringURLSafe($this->alias);
// Validate color format
if ($this->color !== '' && !preg_match('/^#[0-9a-fA-F]{6}$/', $this->color))
{
$this->color = '';
}
$now = Factory::getDate()->toSql();
if (!(int) $this->id)
{
if (!$this->created || $this->created === '0000-00-00 00:00:00')
{
$this->created = $now;
}
}
$this->modified = $now;
return parent::check();
}
}
@@ -0,0 +1,51 @@
<?php
/**
* @package MokoSuiteStoreLocator
* @subpackage com_mokosuitestorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoSuiteStoreLocator\Administrator\View\Categories;
defined('_JEXEC') or die;
use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\CMS\Toolbar\ToolbarHelper;
/**
* Categories list view.
*
* @since 1.2.0
*/
class HtmlView extends BaseHtmlView
{
protected $items;
protected $pagination;
protected $state;
public $filterForm;
public $activeFilters;
public function display($tpl = null): void
{
$this->items = $this->get('Items');
$this->pagination = $this->get('Pagination');
$this->state = $this->get('State');
$this->filterForm = $this->get('FilterForm');
$this->activeFilters = $this->get('ActiveFilters');
$this->addToolbar();
parent::display($tpl);
}
protected function addToolbar(): void
{
ToolbarHelper::title(Text::_('COM_MOKOJOOMSTORELOCATOR_CATEGORIES'), 'folder');
ToolbarHelper::addNew('category.add');
ToolbarHelper::publish('categories.publish', 'JTOOLBAR_PUBLISH', true);
ToolbarHelper::unpublish('categories.unpublish', 'JTOOLBAR_UNPUBLISH', true);
ToolbarHelper::deleteList('', 'categories.delete', 'JTOOLBAR_DELETE');
}
}
@@ -0,0 +1,54 @@
<?php
/**
* @package MokoSuiteStoreLocator
* @subpackage com_mokosuitestorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoSuiteStoreLocator\Administrator\View\Category;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\CMS\Toolbar\ToolbarHelper;
/**
* Category edit view.
*
* @since 1.2.0
*/
class HtmlView extends BaseHtmlView
{
protected $form;
protected $item;
public function display($tpl = null): void
{
$this->form = $this->get('Form');
$this->item = $this->get('Item');
$this->addToolbar();
parent::display($tpl);
}
protected function addToolbar(): void
{
Factory::getApplication()->input->set('hidemainmenu', true);
$isNew = ($this->item->id == 0);
ToolbarHelper::title(
Text::_('COM_MOKOJOOMSTORELOCATOR_CATEGORY_' . ($isNew ? 'NEW' : 'EDIT')),
'folder'
);
ToolbarHelper::apply('category.apply');
ToolbarHelper::save('category.save');
ToolbarHelper::save2new('category.save2new');
ToolbarHelper::cancel('category.cancel', $isNew ? 'JTOOLBAR_CANCEL' : 'JTOOLBAR_CLOSE');
}
}
@@ -0,0 +1,102 @@
<?php
/**
* @package MokoSuiteStoreLocator
* @subpackage com_mokosuitestorelocator
* @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\MokoSuiteStoreLocator\Administrator\View\Categories\HtmlView $this */
?>
<form action="<?php echo Route::_('index.php?option=com_mokosuitestorelocator&view=categories'); ?>"
method="post" name="adminForm" id="adminForm">
<div class="row">
<div class="col-md-12">
<div id="j-main-container" class="j-main-container">
<?php echo LayoutHelper::render('joomla.searchtools.default', ['view' => $this]); ?>
<?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">
<caption class="visually-hidden">
<?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_CATEGORIES_TABLE_CAPTION'); ?>
</caption>
<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-5 text-center">
<?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_CATEGORY_COLOR'); ?>
</th>
<th scope="col" class="w-10 text-center 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="w-1 text-center">
<?php echo HTMLHelper::_('grid.id', $i, $item->id, false, 'cid', 'cb', $item->title); ?>
</td>
<th scope="row">
<?php if ((int) $item->level > 1) : ?>
<?php echo str_repeat('<span class="gi">&mdash;</span>', (int) $item->level - 1); ?>
<?php endif; ?>
<a href="<?php echo Route::_('index.php?option=com_mokosuitestorelocator&task=category.edit&id=' . (int) $item->id); ?>">
<?php echo $this->escape($item->title); ?>
</a>
</th>
<td class="text-center">
<?php if ($item->color) : ?>
<span style="display:inline-block;width:20px;height:20px;border-radius:3px;background-color:<?php echo $this->escape($item->color); ?>;"></span>
<?php else : ?>
&mdash;
<?php endif; ?>
</td>
<td class="text-center d-none d-md-table-cell">
<?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>
</div>
</div>
</form>
@@ -0,0 +1,52 @@
<?php
/**
* @package MokoSuiteStoreLocator
* @subpackage com_mokosuitestorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
defined('_JEXEC') or die;
use Joomla\CMS\HTML\HTMLHelper;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Router\Route;
/** @var \Moko\Component\MokoSuiteStoreLocator\Administrator\View\Category\HtmlView $this */
HTMLHelper::_('behavior.formvalidator');
HTMLHelper::_('behavior.keepalive');
?>
<form action="<?php echo Route::_('index.php?option=com_mokosuitestorelocator&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('parent_id'); ?>
<?php echo $this->form->renderField('description'); ?>
</div>
<div class="col-lg-3">
<?php echo $this->form->renderField('published'); ?>
</div>
</div>
<?php echo HTMLHelper::_('uitab.endTab'); ?>
<?php echo HTMLHelper::_('uitab.addTab', 'myTab', 'appearance', Text::_('COM_MOKOJOOMSTORELOCATOR_FIELDSET_APPEARANCE')); ?>
<div class="row">
<div class="col-lg-6">
<?php echo $this->form->renderField('color'); ?>
<?php echo $this->form->renderField('marker_icon'); ?>
</div>
</div>
<?php echo HTMLHelper::_('uitab.endTab'); ?>
<?php echo HTMLHelper::_('uitab.endTabSet'); ?>
<input type="hidden" name="task" value="">
<?php echo HTMLHelper::_('form.token'); ?>
</form>
@@ -37,6 +37,14 @@ HTMLHelper::_('behavior.keepalive');
</div>
<?php echo HTMLHelper::_('uitab.endTab'); ?>
<?php echo HTMLHelper::_('uitab.addTab', 'myTab', 'categories', Text::_('COM_MOKOJOOMSTORELOCATOR_FIELDSET_CATEGORIES')); ?>
<div class="row">
<div class="col-lg-9">
<?php echo $this->form->renderField('categories'); ?>
</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">
@@ -0,0 +1,37 @@
<?php
/**
* @package MokoSuiteStoreLocator
* @subpackage com_mokosuitestorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoSuiteStoreLocator\Api\Controller;
defined('_JEXEC') or die;
use Joomla\CMS\MVC\Controller\ApiController;
/**
* REST API controller for locations.
*
* @since 1.2.0
*/
class LocationsController extends ApiController
{
/**
* The content type.
*
* @var string
* @since 1.2.0
*/
protected $contentType = 'locations';
/**
* The default view.
*
* @var string
* @since 1.2.0
*/
protected $default_view = 'locations';
}
@@ -0,0 +1,68 @@
<?php
/**
* @package MokoSuiteStoreLocator
* @subpackage com_mokosuitestorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoSuiteStoreLocator\Api\View\Locations;
defined('_JEXEC') or die;
use Joomla\CMS\MVC\View\JsonApiView as BaseApiView;
/**
* JSON:API view for locations.
*
* @since 1.2.0
*/
class JsonapiView extends BaseApiView
{
/**
* Fields to render for a single item.
*
* @var array
* @since 1.2.0
*/
protected $fieldsToRenderItem = [
'id',
'title',
'alias',
'description',
'address',
'city',
'state',
'postcode',
'country',
'latitude',
'longitude',
'phone',
'email',
'website',
'hours',
'image',
'published',
];
/**
* Fields to render for a list.
*
* @var array
* @since 1.2.0
*/
protected $fieldsToRenderList = [
'id',
'title',
'alias',
'address',
'city',
'state',
'postcode',
'country',
'latitude',
'longitude',
'phone',
'published',
];
}
@@ -38,12 +38,24 @@
</sql>
</uninstall>
<update>
<schemas>
<schemapath type="mysql">sql/updates/mysql</schemapath>
</schemas>
</update>
<files folder="site">
<folder>language</folder>
<folder>src</folder>
<folder>tmpl</folder>
</files>
<api>
<files folder="api">
<folder>src</folder>
</files>
</api>
<administration>
<files folder="admin">
<folder>forms</folder>
@@ -57,6 +69,7 @@
<menu>COM_MOKOJOOMSTORELOCATOR</menu>
<submenu>
<menu link="option=com_mokosuitestorelocator&amp;view=locations">COM_MOKOJOOMSTORELOCATOR_LOCATIONS</menu>
<menu link="option=com_mokosuitestorelocator&amp;view=categories">COM_MOKOJOOMSTORELOCATOR_CATEGORIES</menu>
<menu link="option=com_mokosuitestorelocator&amp;view=import">COM_MOKOJOOMSTORELOCATOR_IMPORT</menu>
</submenu>
</administration>
@@ -99,6 +99,9 @@ class LocationsModel extends ListModel
$this->setState('filter.radius_unit', in_array($radiusUnit, ['miles', 'km']) ? $radiusUnit : 'miles');
$catid = $app->input->getInt('catid', 0);
$this->setState('filter.catid', $catid);
parent::populateState($ordering, $direction);
}
@@ -154,6 +157,17 @@ class LocationsModel extends ListModel
->bind(':state', $state);
}
// Category filter
$catid = (int) $this->getState('filter.catid');
if ($catid > 0)
{
$query->join('INNER', $db->quoteName('#__mokosuitestorelocator_location_categories', 'lc')
. ' ON ' . $db->quoteName('lc.location_id') . ' = ' . $db->quoteName('a.id'))
->where($db->quoteName('lc.category_id') . ' = :catid')
->bind(':catid', $catid, ParameterType::INTEGER);
}
// Proximity / Haversine distance filter
$lat = $this->getState('filter.lat');
$lng = $this->getState('filter.lng');
@@ -179,10 +193,17 @@ class LocationsModel extends ListModel
}
else
{
// Default ordering
$orderCol = $this->state->get('list.ordering', 'a.ordering');
$orderDir = $this->state->get('list.direction', 'ASC');
$query->order($db->escape($orderCol) . ' ' . $db->escape($orderDir));
// Default ordering — validate against filter_fields allowlist
$orderCol = $this->state->get('list.ordering', 'a.ordering');
$orderDir = $this->state->get('list.direction', 'ASC');
if (!\in_array($orderCol, $this->filter_fields, true))
{
$orderCol = 'a.ordering';
}
$orderDir = strtoupper($orderDir) === 'DESC' ? 'DESC' : 'ASC';
$query->order($db->quoteName($orderCol) . ' ' . $orderDir);
}
return $query;
@@ -41,20 +41,29 @@ class Dispatcher extends AbstractModuleDispatcher implements HelperFactoryAwareI
$query = $db->getQuery(true);
$query->select([
$db->quoteName('id'),
$db->quoteName('title'),
$db->quoteName('address'),
$db->quoteName('city'),
$db->quoteName('state'),
$db->quoteName('postcode'),
$db->quoteName('phone'),
$db->quoteName('latitude'),
$db->quoteName('longitude'),
$db->quoteName('a.id'),
$db->quoteName('a.title'),
$db->quoteName('a.address'),
$db->quoteName('a.city'),
$db->quoteName('a.state'),
$db->quoteName('a.postcode'),
$db->quoteName('a.phone'),
$db->quoteName('a.latitude'),
$db->quoteName('a.longitude'),
])
->from($db->quoteName('#__mokosuitestorelocator_locations'))
->where($db->quoteName('published') . ' = 1')
->where($db->quoteName('latitude') . ' IS NOT NULL')
->where($db->quoteName('longitude') . ' IS NOT NULL');
->from($db->quoteName('#__mokosuitestorelocator_locations', 'a'))
->where($db->quoteName('a.published') . ' = 1')
->where($db->quoteName('a.latitude') . ' IS NOT NULL')
->where($db->quoteName('a.longitude') . ' IS NOT NULL');
// Join to get primary category marker icon
$query->select([$db->quoteName('c.marker_icon'), $db->quoteName('c.color', 'cat_color')])
->join('LEFT', $db->quoteName('#__mokosuitestorelocator_location_categories', 'lc')
. ' ON ' . $db->quoteName('lc.location_id') . ' = ' . $db->quoteName('a.id'))
->join('LEFT', $db->quoteName('#__mokosuitestorelocator_categories', 'c')
. ' ON ' . $db->quoteName('c.id') . ' = ' . $db->quoteName('lc.category_id')
. ' AND ' . $db->quoteName('c.published') . ' = 1')
->group($db->quoteName('a.id'));
$db->setQuery($query);
$locations = $db->loadObjectList() ?: [];
@@ -63,7 +72,7 @@ class Dispatcher extends AbstractModuleDispatcher implements HelperFactoryAwareI
foreach ($locations as $loc)
{
$markers[] = [
$marker = [
'id' => (int) $loc->id,
'title' => $loc->title,
'address' => trim($loc->address . ', ' . $loc->city . ', ' . $loc->state . ' ' . $loc->postcode, ', '),
@@ -71,6 +80,18 @@ class Dispatcher extends AbstractModuleDispatcher implements HelperFactoryAwareI
'lat' => (float) $loc->latitude,
'lng' => (float) $loc->longitude,
];
if (!empty($loc->marker_icon))
{
$marker['marker_icon'] = $loc->marker_icon;
}
if (!empty($loc->cat_color))
{
$marker['cat_color'] = $loc->cat_color;
}
$markers[] = $marker;
}
$data['locations'] = $markers;
@@ -15,6 +15,11 @@ $params = $displayData['params'];
$locations = $displayData['locations'] ?? [];
$moduleId = $displayData['module']->id;
$mapHeight = $params->get('map_height', '400px');
if (!preg_match('/^\d+(px|em|rem|vh|%)$/', $mapHeight))
{
$mapHeight = '400px';
}
$mapZoom = (int) $params->get('map_zoom', 10);
$provider = $params->get('map_provider', 'leaflet');
$apiKey = $params->get('api_key', '');
@@ -69,7 +74,16 @@ document.addEventListener('DOMContentLoaded', function() {
}
locations.forEach(function(loc) {
var marker = L.marker([loc.lat, loc.lng]).addTo(map);
var markerOptions = {};
if (loc.marker_icon) {
markerOptions.icon = L.icon({
iconUrl: loc.marker_icon,
iconSize: [32, 32],
iconAnchor: [16, 32],
popupAnchor: [0, -32]
});
}
var marker = L.marker([loc.lat, loc.lng], markerOptions).addTo(map);
var popup = '<strong>' + esc(loc.title) + '</strong>';
if (loc.address) popup += '<br>' + esc(loc.address);
if (loc.phone) popup += '<br><a href="tel:' + esc(loc.phone) + '">' + esc(loc.phone) + '</a>';
@@ -0,0 +1,2 @@
PLG_WEBSERVICES_MOKOSUITESTORELOCATOR="MokoSuiteStoreLocator - Web Services"
PLG_WEBSERVICES_MOKOSUITESTORELOCATOR_DESC="Provides REST API endpoints for the MokoSuiteStoreLocator component."
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- =========================================================================
Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
SPDX-License-Identifier: GPL-3.0-or-later
========================================================================= -->
<extension type="plugin" group="webservices" method="upgrade">
<name>plg_webservices_mokosuitestorelocator</name>
<version>01.00.01</version>
<creationDate>2026-06-24</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>PLG_WEBSERVICES_MOKOSUITESTORELOCATOR_DESC</description>
<namespace path="src">Moko\Plugin\WebServices\MokoSuiteStoreLocator</namespace>
<files>
<folder>services</folder>
<folder>src</folder>
<folder>language</folder>
</files>
</extension>
@@ -0,0 +1,37 @@
<?php
/**
* @package MokoSuiteStoreLocator
* @subpackage plg_webservices_mokosuitestorelocator
* @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\Extension\PluginInterface;
use Joomla\CMS\Factory;
use Joomla\CMS\Plugin\PluginHelper;
use Joomla\DI\Container;
use Joomla\DI\ServiceProviderInterface;
use Joomla\Event\DispatcherInterface;
use Moko\Plugin\WebServices\MokoSuiteStoreLocator\Extension\MokoSuiteStoreLocator;
return new class implements ServiceProviderInterface
{
public function register(Container $container): void
{
$container->set(
PluginInterface::class,
function (Container $container) {
$dispatcher = $container->get(DispatcherInterface::class);
$plugin = new MokoSuiteStoreLocator(
$dispatcher,
(array) PluginHelper::getPlugin('webservices', 'mokosuitestorelocator')
);
$plugin->setApplication(Factory::getApplication());
return $plugin;
}
);
}
};
@@ -0,0 +1,57 @@
<?php
/**
* @package MokoSuiteStoreLocator
* @subpackage plg_webservices_mokosuitestorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Plugin\WebServices\MokoSuiteStoreLocator\Extension;
defined('_JEXEC') or die;
use Joomla\CMS\Plugin\CMSPlugin;
use Joomla\CMS\Router\ApiRouter;
use Joomla\Event\SubscriberInterface;
/**
* Web Services plugin for MokoSuiteStoreLocator REST API.
*
* @since 1.2.0
*/
final class MokoSuiteStoreLocator extends CMSPlugin implements SubscriberInterface
{
/**
* Returns the subscribed events.
*
* @return array
*
* @since 1.2.0
*/
public static function getSubscribedEvents(): array
{
return [
'onBeforeApiRoute' => 'onBeforeApiRoute',
];
}
/**
* Register API routes.
*
* @param \Joomla\CMS\Event\ApiRouterEvent $event The event.
*
* @return void
*
* @since 1.2.0
*/
public function onBeforeApiRoute($event): void
{
$router = $event->getArgument('router') ?? $event->getRouter();
$router->createCRUDRoutes(
'v1/mokosuitestorelocator/locations',
'locations',
['component' => 'com_mokosuitestorelocator']
);
}
}
+1
View File
@@ -32,6 +32,7 @@
<file type="component" id="com_mokosuitestorelocator">com_mokosuitestorelocator.zip</file>
<file type="module" id="mod_mokosuitestorelocator_map" client="site">mod_mokosuitestorelocator_map.zip</file>
<file type="module" id="mod_mokosuitestorelocator_search" client="site">mod_mokosuitestorelocator_search.zip</file>
<file type="plugin" id="mokosuitestorelocator" group="webservices">plg_webservices_mokosuitestorelocator.zip</file>
</files>
<updateservers>