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
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:
+30
-1
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
+14
@@ -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;
|
||||
+37
@@ -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">—</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 : ?>
|
||||
—
|
||||
<?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&view=locations">COM_MOKOJOOMSTORELOCATOR_LOCATIONS</menu>
|
||||
<menu link="option=com_mokosuitestorelocator&view=categories">COM_MOKOJOOMSTORELOCATOR_CATEGORIES</menu>
|
||||
<menu link="option=com_mokosuitestorelocator&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>';
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
PLG_WEBSERVICES_MOKOSUITESTORELOCATOR="MokoSuiteStoreLocator - Web Services"
|
||||
PLG_WEBSERVICES_MOKOSUITESTORELOCATOR_DESC="Provides REST API endpoints for the MokoSuiteStoreLocator component."
|
||||
+24
@@ -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;
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
+57
@@ -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']
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user