diff --git a/.gitignore b/.gitignore
index 391f47d..bbef309 100644
--- a/.gitignore
+++ b/.gitignore
@@ -114,6 +114,7 @@ build/
dist/
out/
site/
+!source/packages/*/site/
*.map
*.css.map
*.js.map
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b95424b..d68fa61 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,15 +5,45 @@ 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).
-## [Unreleased]
-
-### Removed
-- Removed deploy-manual.yml workflow — switching to Joomla update server method for extension distribution
+## [1.0.0] - 2026-06-23
### Added
+- Admin `LocationController` (FormController) for single-record save/cancel/apply
+- Admin `LocationsController` (AdminController) for bulk publish/unpublish/delete
+- Admin location edit view and tabbed template (Details, Address, Contact)
+- Admin locations list renders data rows with edit links and published toggle
+- `LocationTable::check()` validation: required title, auto-alias, lat/lng range, timestamps
+- `LocationsModel::populateState()` for filter persistence
+- Search filter across title, city, state, address
+- Published state filter and sort ordering support
+- Filter form XML (`filter_locations.xml`) with search tools bar
+- Language strings for filters, sort options, and save messages
+- Site frontend `DisplayController` routing to list and detail views
+- Site `LocationsModel` — published locations with search, city, and state filters
+- Site `LocationModel` — single location by ID (published only)
+- Site locations list view with Schema.org `LocalBusiness` markup and pagination
+- Site location detail view with address, contact, hours, and map placeholder
+- SEF URL router (`Service\Router`) with menu/standard/nomenu rules
+- Menu item types: "All Locations" list and "Location Detail" with location picker
+- Site language strings for frontend views and menu items
+- Router registered in service provider and component extension class
+- Map module dispatcher loads published locations with coordinates from DB
+- Leaflet.js/OpenStreetMap integration with markers, popups, and auto-fit bounds
+- Leaflet CSS/JS loaded via Joomla Web Asset Manager (`registerAndUseStyle`/`registerAndUseScript`)
+- Search module dispatcher loads distinct cities/states and builds radius options
+- City dropdown filter on search form (populated from DB, toggled by module param)
+- Radius dropdown filter with configurable distance values and unit (miles/km)
+- Geolocation "Use My Location" button with browser geolocation API
+- Hidden lat/lng fields passed to component for proximity search
+- Language strings for search module (city, radius, geolocation states)
+
+### Removed
+- Makefile (no longer used)
+- deploy-manual.yml workflow
+
+### Previous (scaffold)
- Initial package scaffold with component, map module, and search module
- Database schema for locations table with coordinates
-- Admin MVC for location CRUD
-- Frontend location listing view with Schema.org markup
-- Map module with Leaflet/Google Maps provider support
-- Search module with city and radius filter options
+- Admin MVC skeleton for location CRUD
+- Map module with Leaflet/Google Maps provider support (stub)
+- Search module with city and radius filter options (stub)
diff --git a/CLAUDE.md b/CLAUDE.md
index e65deda..eea75a2 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -4,37 +4,29 @@ This file provides guidance to Claude Code when working with this repository.
## Project Overview
-**MokoJoomStoreLocator** -- A Joomla 4/5 package providing a store locator listing component with coordinating map and search modules.
+**MokoSuiteStoreLocator** -- A Joomla 5/6 package providing a store locator listing component with coordinating map and search modules.
| Field | Value |
|---|---|
| **Platform** | joomla |
| **Extension type** | package (component + modules) |
-| **Element** | `pkg_mokojoomstorelocator` |
+| **Element** | `pkg_mokosuitestorelocator` |
| **Language** | PHP |
| **Default branch** | main |
| **License** | GPL-3.0-or-later |
-| **Wiki** | [MokoJoomStoreLocator Wiki](https://git.mokoconsulting.tech/MokoConsulting/MokoJoomStoreLocator/wiki) |
+| **Wiki** | [MokoSuiteStoreLocator Wiki](https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteStoreLocator/wiki) |
| **Standards** | [MokoStandards](https://git.mokoconsulting.tech/MokoConsulting/moko-platform/wiki/Home) |
## Package Contents
| Extension | Type | Element |
|---|---|---|
-| Store Locator Component | component | `com_mokojoomstorelocator` |
-| Store Locator Map | module (site) | `mod_mokojoomstorelocator_map` |
-| Store Locator Search | module (site) | `mod_mokojoomstorelocator_search` |
+| Store Locator Component | component | `com_mokosuitestorelocator` |
+| Store Locator Map | module (site) | `mod_mokosuitestorelocator_map` |
+| Store Locator Search | module (site) | `mod_mokosuitestorelocator_search` |
## Common Commands
-```bash
-make build # Build package ZIP containing all sub-extensions
-make lint # Run PHP linter
-make validate # Lint + validation checks
-make release # Validate + build
-make clean # Clean build artifacts
-```
-
```bash
composer install # Install PHP dev dependencies
```
@@ -43,19 +35,18 @@ composer install # Install PHP dev dependencies
This is a Joomla package. Key layout:
-- `src/pkg_mokojoomstorelocator.xml` -- package manifest
-- `src/script.php` -- package install/upgrade/uninstall script
-- `src/packages/com_mokojoomstorelocator/` -- main component
+- `source/pkg_mokosuitestorelocator.xml` -- package manifest
+- `source/script.php` -- package install/upgrade/uninstall script
+- `source/packages/com_mokosuitestorelocator/` -- main component
- `admin/` -- admin MVC (controllers, models, views, forms, tables, SQL)
- `site/` -- frontend MVC (controllers, models, views, templates)
- - `mokojoomstorelocator.xml` -- component manifest
-- `src/packages/mod_mokojoomstorelocator_map/` -- map display module
-- `src/packages/mod_mokojoomstorelocator_search/` -- search/filter module
-- `updates.xml` -- Joomla update server manifest
+ - `mokosuitestorelocator.xml` -- component manifest
+- `source/packages/mod_mokosuitestorelocator_map/` -- map display module
+- `source/packages/mod_mokosuitestorelocator_search/` -- search/filter module
## Database Table
-`#__mokojoomstorelocator_locations` -- stores location data including coordinates, address, contact info, and business hours.
+`#__mokosuitestorelocator_locations` -- stores location data including coordinates, address, contact info, and business hours.
## Rules
@@ -66,6 +57,7 @@ This is a Joomla package. Key layout:
- **Branch strategy**: develop on `dev/`, merge to `main` for release
- **Wiki**: documentation lives in the Gitea wiki, not in `docs/` files
- **Standards**: this repo follows [MokoStandards](https://git.mokoconsulting.tech/MokoConsulting/moko-platform/wiki/Home)
-- **PHP minimum**: 8.1
+- **PHP minimum**: 8.2
+- **Joomla minimum**: 5.0
- **Joomla table operations**: always use bind() -> check() -> store(), never save()
-- **Namespace**: `Moko\Component\MokoJoomStoreLocator` for the component
+- **Namespace**: `Moko\Component\MokoSuiteStoreLocator` for the component
diff --git a/README.md b/README.md
index 585b236..89fefb4 100644
--- a/README.md
+++ b/README.md
@@ -1,34 +1,54 @@
-# MokoJoomStoreLocator
+# MokoSuiteStoreLocator
A Joomla 4/5 package providing a store locator listing component with coordinating map and search modules.
## Package Contents
-| Extension | Description |
-|---|---|
-| `com_mokojoomstorelocator` | Component for managing store locations (admin CRUD + frontend listing) |
-| `mod_mokojoomstorelocator_map` | Site module displaying an interactive map with location markers |
-| `mod_mokojoomstorelocator_search` | Site module providing search/filter form for finding locations |
+| Extension | Type | Element |
+|---|---|---|
+| Store Locator Component | component | `com_mokosuitestorelocator` |
+| Store Locator Map | module (site) | `mod_mokosuitestorelocator_map` |
+| Store Locator Search | module (site) | `mod_mokosuitestorelocator_search` |
## Requirements
-- Joomla 4.4+ or 5.x
-- PHP 8.1+
-- MySQL 5.7+ / MariaDB 10.3+
+- Joomla 5.x or 6.x
+- PHP 8.2+
+- MySQL 8.0+ / MariaDB 10.4+
## Installation
-1. Download the latest `pkg_mokojoomstorelocator-x.x.x.zip` from [Releases](https://git.mokoconsulting.tech/MokoConsulting/MokoJoomStoreLocator/releases)
+1. Download the latest `pkg_mokosuitestorelocator-x.x.x.zip` from [Releases](https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteStoreLocator/releases)
2. In Joomla Administrator, go to **System > Install > Extensions**
3. Upload the package ZIP — all extensions install automatically
## Features
-- Manage store locations with address, coordinates, contact info, and business hours
-- Interactive map display (OpenStreetMap/Leaflet or Google Maps)
-- Location search by city, postcode, or radius
-- Schema.org LocalBusiness structured data markup
-- Category support for grouping locations
+### 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
+- **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
+- **Interactive map** — Leaflet.js with OpenStreetMap tiles, markers with popups, auto-fit bounds
+- **Location search** — city dropdown, radius filter, and browser geolocation ("Use My Location")
+
+### Planned
+- Proximity search (Haversine distance filtering)
+- 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
+
+## Development
+
+```bash
+composer install # Install PHP dev dependencies
+```
+
+Source code lives in `source/packages/` — one directory per sub-extension.
## License
diff --git a/source/packages/com_mokosuitestorelocator/admin/forms/filter_locations.xml b/source/packages/com_mokosuitestorelocator/admin/forms/filter_locations.xml
new file mode 100644
index 0000000..6e36331
--- /dev/null
+++ b/source/packages/com_mokosuitestorelocator/admin/forms/filter_locations.xml
@@ -0,0 +1,48 @@
+
+
diff --git a/source/packages/com_mokosuitestorelocator/admin/forms/location.xml b/source/packages/com_mokosuitestorelocator/admin/forms/location.xml
new file mode 100644
index 0000000..cb7cb2f
--- /dev/null
+++ b/source/packages/com_mokosuitestorelocator/admin/forms/location.xml
@@ -0,0 +1,140 @@
+
+
+
diff --git a/source/packages/com_mokosuitestorelocator/admin/language/en-GB/com_mokosuitestorelocator.ini b/source/packages/com_mokosuitestorelocator/admin/language/en-GB/com_mokosuitestorelocator.ini
new file mode 100644
index 0000000..3b64378
--- /dev/null
+++ b/source/packages/com_mokosuitestorelocator/admin/language/en-GB/com_mokosuitestorelocator.ini
@@ -0,0 +1,43 @@
+; MokoSuiteStoreLocator - Admin language strings
+; Copyright (C) 2026 Moko Consulting. All rights reserved.
+; License: GNU General Public License version 3 or later; see LICENSE
+
+COM_MOKOJOOMSTORELOCATOR="Store Locator"
+COM_MOKOJOOMSTORELOCATOR_DESC="A store locator component for managing and displaying location listings."
+COM_MOKOJOOMSTORELOCATOR_LOCATIONS="Locations"
+COM_MOKOJOOMSTORELOCATOR_LOCATION_NEW="New Location"
+COM_MOKOJOOMSTORELOCATOR_LOCATION_EDIT="Edit Location"
+COM_MOKOJOOMSTORELOCATOR_TABLE_CAPTION="Store Location List"
+
+COM_MOKOJOOMSTORELOCATOR_CITY="City"
+COM_MOKOJOOMSTORELOCATOR_STATE="State"
+
+COM_MOKOJOOMSTORELOCATOR_FIELDSET_ADDRESS="Address"
+COM_MOKOJOOMSTORELOCATOR_FIELDSET_COORDINATES="Coordinates"
+COM_MOKOJOOMSTORELOCATOR_FIELDSET_CONTACT="Contact Information"
+COM_MOKOJOOMSTORELOCATOR_FIELDSET_IMAGE="Image"
+
+COM_MOKOJOOMSTORELOCATOR_FIELD_ADDRESS="Street Address"
+COM_MOKOJOOMSTORELOCATOR_FIELD_CITY="City"
+COM_MOKOJOOMSTORELOCATOR_FIELD_STATE="State / Province"
+COM_MOKOJOOMSTORELOCATOR_FIELD_POSTCODE="Postal Code"
+COM_MOKOJOOMSTORELOCATOR_FIELD_COUNTRY="Country"
+COM_MOKOJOOMSTORELOCATOR_FIELD_LATITUDE="Latitude"
+COM_MOKOJOOMSTORELOCATOR_FIELD_LONGITUDE="Longitude"
+COM_MOKOJOOMSTORELOCATOR_FIELD_PHONE="Phone"
+COM_MOKOJOOMSTORELOCATOR_FIELD_WEBSITE="Website"
+COM_MOKOJOOMSTORELOCATOR_FIELD_HOURS="Business Hours"
+COM_MOKOJOOMSTORELOCATOR_FIELD_IMAGE="Location Image"
+
+COM_MOKOJOOMSTORELOCATOR_FILTER_SEARCH_LABEL="Search Locations"
+COM_MOKOJOOMSTORELOCATOR_CITY_ASC="City ascending"
+COM_MOKOJOOMSTORELOCATOR_CITY_DESC="City descending"
+
+COM_MOKOJOOMSTORELOCATOR_LOCATION_SAVE_SUCCESS="Location saved successfully."
+COM_MOKOJOOMSTORELOCATOR_LOCATIONS_N_ITEMS_PUBLISHED="%d location(s) published."
+COM_MOKOJOOMSTORELOCATOR_LOCATIONS_N_ITEMS_UNPUBLISHED="%d location(s) unpublished."
+COM_MOKOJOOMSTORELOCATOR_LOCATIONS_N_ITEMS_DELETED="%d location(s) deleted."
+
+COM_MOKOJOOMSTORELOCATOR_ERROR_TITLE_REQUIRED="A location title is required."
+COM_MOKOJOOMSTORELOCATOR_ERROR_LATITUDE_RANGE="Latitude must be between -90 and 90."
+COM_MOKOJOOMSTORELOCATOR_ERROR_LONGITUDE_RANGE="Longitude must be between -180 and 180."
diff --git a/source/packages/com_mokosuitestorelocator/admin/language/en-GB/com_mokosuitestorelocator.sys.ini b/source/packages/com_mokosuitestorelocator/admin/language/en-GB/com_mokosuitestorelocator.sys.ini
new file mode 100644
index 0000000..fa1f199
--- /dev/null
+++ b/source/packages/com_mokosuitestorelocator/admin/language/en-GB/com_mokosuitestorelocator.sys.ini
@@ -0,0 +1,7 @@
+; MokoSuiteStoreLocator - System language strings
+; Copyright (C) 2026 Moko Consulting. All rights reserved.
+; License: GNU General Public License version 3 or later; see LICENSE
+
+COM_MOKOJOOMSTORELOCATOR="Store Locator"
+COM_MOKOJOOMSTORELOCATOR_DESC="A store locator component for managing and displaying location listings."
+COM_MOKOJOOMSTORELOCATOR_LOCATIONS="Locations"
diff --git a/source/packages/com_mokosuitestorelocator/admin/services/provider.php b/source/packages/com_mokosuitestorelocator/admin/services/provider.php
new file mode 100644
index 0000000..3008ab5
--- /dev/null
+++ b/source/packages/com_mokosuitestorelocator/admin/services/provider.php
@@ -0,0 +1,57 @@
+registerServiceProvider(new MVCFactory('\\Moko\\Component\\MokoSuiteStoreLocator'));
+ $container->registerServiceProvider(new ComponentDispatcherFactory('\\Moko\\Component\\MokoSuiteStoreLocator'));
+ $container->registerServiceProvider(new RouterFactory('\\Moko\\Component\\MokoSuiteStoreLocator'));
+
+ $container->set(
+ ComponentInterface::class,
+ function (Container $container) {
+ $component = new MokoSuiteStoreLocatorComponent(
+ $container->get(ComponentDispatcherFactoryInterface::class)
+ );
+ $component->setMVCFactory($container->get(MVCFactoryInterface::class));
+ $component->setRouterFactory($container->get(RouterFactoryInterface::class));
+
+ return $component;
+ }
+ );
+ }
+};
diff --git a/source/packages/com_mokosuitestorelocator/admin/sql/install.mysql.sql b/source/packages/com_mokosuitestorelocator/admin/sql/install.mysql.sql
new file mode 100644
index 0000000..40a118c
--- /dev/null
+++ b/source/packages/com_mokosuitestorelocator/admin/sql/install.mysql.sql
@@ -0,0 +1,40 @@
+-- =========================================================================
+-- Copyright (C) 2026 Moko Consulting
+-- SPDX-License-Identifier: GPL-3.0-or-later
+--
+-- MokoSuiteStoreLocator - Store locations table
+-- =========================================================================
+
+CREATE TABLE IF NOT EXISTS `#__mokosuitestorelocator_locations` (
+ `id` int(11) NOT NULL AUTO_INCREMENT,
+ `title` varchar(255) NOT NULL DEFAULT '',
+ `alias` varchar(400) NOT NULL DEFAULT '',
+ `description` text NOT NULL,
+ `address` varchar(255) NOT NULL DEFAULT '',
+ `city` varchar(100) NOT NULL DEFAULT '',
+ `state` varchar(100) NOT NULL DEFAULT '',
+ `postcode` varchar(20) NOT NULL DEFAULT '',
+ `country` varchar(100) NOT NULL DEFAULT '',
+ `latitude` decimal(10, 8) DEFAULT NULL,
+ `longitude` decimal(11, 8) DEFAULT NULL,
+ `phone` varchar(50) NOT NULL DEFAULT '',
+ `email` varchar(255) NOT NULL DEFAULT '',
+ `website` varchar(255) NOT NULL DEFAULT '',
+ `hours` text NOT NULL,
+ `image` varchar(255) NOT NULL DEFAULT '',
+ `published` tinyint(4) NOT NULL DEFAULT 0,
+ `ordering` int(11) NOT NULL DEFAULT 0,
+ `catid` int(11) NOT NULL DEFAULT 0,
+ `params` text NOT NULL,
+ `created` datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
+ `created_by` int(10) unsigned NOT NULL DEFAULT 0,
+ `modified` datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
+ `modified_by` int(10) unsigned NOT NULL DEFAULT 0,
+ `checked_out` int(10) unsigned DEFAULT NULL,
+ `checked_out_time` datetime DEFAULT NULL,
+ PRIMARY KEY (`id`),
+ KEY `idx_published` (`published`),
+ KEY `idx_catid` (`catid`),
+ KEY `idx_alias` (`alias`(191)),
+ KEY `idx_coordinates` (`latitude`, `longitude`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
diff --git a/source/packages/com_mokosuitestorelocator/admin/sql/uninstall.mysql.sql b/source/packages/com_mokosuitestorelocator/admin/sql/uninstall.mysql.sql
new file mode 100644
index 0000000..9b5cc55
--- /dev/null
+++ b/source/packages/com_mokosuitestorelocator/admin/sql/uninstall.mysql.sql
@@ -0,0 +1,6 @@
+-- =========================================================================
+-- Copyright (C) 2026 Moko Consulting
+-- SPDX-License-Identifier: GPL-3.0-or-later
+-- =========================================================================
+
+DROP TABLE IF EXISTS `#__mokosuitestorelocator_locations`;
diff --git a/source/packages/com_mokosuitestorelocator/admin/src/Controller/DisplayController.php b/source/packages/com_mokosuitestorelocator/admin/src/Controller/DisplayController.php
new file mode 100644
index 0000000..97b9c17
--- /dev/null
+++ b/source/packages/com_mokosuitestorelocator/admin/src/Controller/DisplayController.php
@@ -0,0 +1,29 @@
+ true])
+ {
+ return parent::getModel($name, $prefix, $config);
+ }
+}
diff --git a/source/packages/com_mokosuitestorelocator/admin/src/Extension/MokoSuiteStoreLocatorComponent.php b/source/packages/com_mokosuitestorelocator/admin/src/Extension/MokoSuiteStoreLocatorComponent.php
new file mode 100644
index 0000000..2238b9f
--- /dev/null
+++ b/source/packages/com_mokosuitestorelocator/admin/src/Extension/MokoSuiteStoreLocatorComponent.php
@@ -0,0 +1,25 @@
+loadForm(
+ 'com_mokosuitestorelocator.location',
+ 'location',
+ ['control' => 'jform', 'load_data' => $loadData]
+ );
+
+ if (empty($form))
+ {
+ return false;
+ }
+
+ 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.
+ *
+ * @param string $name The table name.
+ * @param string $prefix The table prefix.
+ * @param array $options Configuration array for the table.
+ *
+ * @return Table
+ *
+ * @since 1.0.0
+ */
+ public function getTable($name = 'Location', $prefix = 'Administrator', $options = [])
+ {
+ return parent::getTable($name, $prefix, $options);
+ }
+}
diff --git a/source/packages/com_mokosuitestorelocator/admin/src/Model/LocationsModel.php b/source/packages/com_mokosuitestorelocator/admin/src/Model/LocationsModel.php
new file mode 100644
index 0000000..d756a14
--- /dev/null
+++ b/source/packages/com_mokosuitestorelocator/admin/src/Model/LocationsModel.php
@@ -0,0 +1,121 @@
+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.0.0
+ */
+ protected function getListQuery(): QueryInterface
+ {
+ $db = $this->getDatabase();
+ $query = $db->getQuery(true);
+
+ $query->select('a.*')
+ ->from($db->quoteName('#__mokosuitestorelocator_locations', 'a'));
+
+ // 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'
+ . ' OR ' . $db->quoteName('a.city') . ' LIKE :search2'
+ . ' OR ' . $db->quoteName('a.state') . ' LIKE :search3'
+ . ' OR ' . $db->quoteName('a.address') . ' LIKE :search4)'
+ )
+ ->bind(':search', $search)
+ ->bind(':search2', $search)
+ ->bind(':search3', $search)
+ ->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));
+
+ return $query;
+ }
+}
diff --git a/source/packages/com_mokosuitestorelocator/admin/src/Table/LocationTable.php b/source/packages/com_mokosuitestorelocator/admin/src/Table/LocationTable.php
new file mode 100644
index 0000000..fb486cc
--- /dev/null
+++ b/source/packages/com_mokosuitestorelocator/admin/src/Table/LocationTable.php
@@ -0,0 +1,98 @@
+setColumnAlias('published', 'published');
+ }
+
+ /**
+ * Overloaded check method to ensure data integrity.
+ *
+ * @return boolean True if the data is valid.
+ *
+ * @since 1.0.0
+ */
+ public function check(): bool
+ {
+ if (trim($this->title) === '')
+ {
+ $this->setError(Text::_('COM_MOKOJOOMSTORELOCATOR_ERROR_TITLE_REQUIRED'));
+
+ return false;
+ }
+
+ if (trim($this->alias) === '')
+ {
+ $this->alias = $this->title;
+ }
+
+ $this->alias = OutputFilter::stringURLSafe($this->alias);
+
+ if ($this->latitude !== null && ($this->latitude < -90 || $this->latitude > 90))
+ {
+ $this->setError(Text::_('COM_MOKOJOOMSTORELOCATOR_ERROR_LATITUDE_RANGE'));
+
+ return false;
+ }
+
+ if ($this->longitude !== null && ($this->longitude < -180 || $this->longitude > 180))
+ {
+ $this->setError(Text::_('COM_MOKOJOOMSTORELOCATOR_ERROR_LONGITUDE_RANGE'));
+
+ return false;
+ }
+
+ $now = Factory::getDate()->toSql();
+ $user = Factory::getApplication()->getIdentity();
+
+ if (!(int) $this->id)
+ {
+ if (!$this->created || $this->created === '0000-00-00 00:00:00')
+ {
+ $this->created = $now;
+ }
+
+ if (!$this->created_by)
+ {
+ $this->created_by = $user->id;
+ }
+ }
+
+ $this->modified = $now;
+ $this->modified_by = $user->id;
+
+ return parent::check();
+ }
+}
diff --git a/source/packages/com_mokosuitestorelocator/admin/src/View/Location/HtmlView.php b/source/packages/com_mokosuitestorelocator/admin/src/View/Location/HtmlView.php
new file mode 100644
index 0000000..f8208a0
--- /dev/null
+++ b/source/packages/com_mokosuitestorelocator/admin/src/View/Location/HtmlView.php
@@ -0,0 +1,89 @@
+form = $this->get('Form');
+ $this->item = $this->get('Item');
+
+ $this->addToolbar();
+
+ parent::display($tpl);
+ }
+
+ /**
+ * Add the page title and toolbar.
+ *
+ * @return void
+ *
+ * @since 1.0.0
+ */
+ protected function addToolbar(): void
+ {
+ Factory::getApplication()->input->set('hidemainmenu', true);
+
+ $isNew = ($this->item->id == 0);
+
+ ToolbarHelper::title(
+ Text::_('COM_MOKOJOOMSTORELOCATOR_LOCATION_' . ($isNew ? 'NEW' : 'EDIT')),
+ 'location'
+ );
+
+ ToolbarHelper::apply('location.apply');
+ ToolbarHelper::save('location.save');
+ ToolbarHelper::save2new('location.save2new');
+
+ if (!$isNew)
+ {
+ ToolbarHelper::save2copy('location.save2copy');
+ }
+
+ ToolbarHelper::cancel('location.cancel', $isNew ? 'JTOOLBAR_CANCEL' : 'JTOOLBAR_CLOSE');
+ }
+}
diff --git a/source/packages/com_mokosuitestorelocator/admin/src/View/Locations/HtmlView.php b/source/packages/com_mokosuitestorelocator/admin/src/View/Locations/HtmlView.php
new file mode 100644
index 0000000..eaf52f9
--- /dev/null
+++ b/source/packages/com_mokosuitestorelocator/admin/src/View/Locations/HtmlView.php
@@ -0,0 +1,91 @@
+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);
+ }
+
+ /**
+ * Add the page title and toolbar.
+ *
+ * @return void
+ *
+ * @since 1.0.0
+ */
+ protected function addToolbar(): void
+ {
+ ToolbarHelper::title(Text::_('COM_MOKOJOOMSTORELOCATOR_LOCATIONS'), 'location');
+ ToolbarHelper::addNew('location.add');
+ ToolbarHelper::publish('locations.publish', 'JTOOLBAR_PUBLISH', true);
+ ToolbarHelper::unpublish('locations.unpublish', 'JTOOLBAR_UNPUBLISH', true);
+ ToolbarHelper::deleteList('', 'locations.delete', 'JTOOLBAR_DELETE');
+ }
+}
diff --git a/source/packages/com_mokosuitestorelocator/admin/tmpl/location/edit.php b/source/packages/com_mokosuitestorelocator/admin/tmpl/location/edit.php
new file mode 100644
index 0000000..d8aec50
--- /dev/null
+++ b/source/packages/com_mokosuitestorelocator/admin/tmpl/location/edit.php
@@ -0,0 +1,73 @@
+
+
diff --git a/source/packages/com_mokosuitestorelocator/admin/tmpl/locations/default.php b/source/packages/com_mokosuitestorelocator/admin/tmpl/locations/default.php
new file mode 100644
index 0000000..c00941f
--- /dev/null
+++ b/source/packages/com_mokosuitestorelocator/admin/tmpl/locations/default.php
@@ -0,0 +1,98 @@
+
+
diff --git a/source/packages/com_mokosuitestorelocator/mokosuitestorelocator.xml b/source/packages/com_mokosuitestorelocator/mokosuitestorelocator.xml
new file mode 100644
index 0000000..f882ef5
--- /dev/null
+++ b/source/packages/com_mokosuitestorelocator/mokosuitestorelocator.xml
@@ -0,0 +1,62 @@
+
+
+
+ com_mokosuitestorelocator
+ 1.0.0
+ 2026-06-23
+ Moko Consulting
+ hello@mokoconsulting.tech
+ https://mokoconsulting.tech
+ Copyright (C) 2026 Moko Consulting. All rights reserved.
+ GNU General Public License version 3 or later; see LICENSE
+ COM_MOKOJOOMSTORELOCATOR_DESC
+
+ Moko\Component\MokoSuiteStoreLocator
+
+
+
+ sql/install.mysql.sql
+
+
+
+
+
+ sql/uninstall.mysql.sql
+
+
+
+
+ language
+ src
+ tmpl
+
+
+
+
+ forms
+ language
+ services
+ sql
+ src
+ tmpl
+
+
+
+
+
+
+
+
diff --git a/source/packages/com_mokosuitestorelocator/site/language/en-GB/com_mokosuitestorelocator.ini b/source/packages/com_mokosuitestorelocator/site/language/en-GB/com_mokosuitestorelocator.ini
new file mode 100644
index 0000000..00eb1fb
--- /dev/null
+++ b/source/packages/com_mokosuitestorelocator/site/language/en-GB/com_mokosuitestorelocator.ini
@@ -0,0 +1,19 @@
+; MokoSuiteStoreLocator - Site language strings
+; Copyright (C) 2026 Moko Consulting. All rights reserved.
+; License: GNU General Public License version 3 or later; see LICENSE
+
+COM_MOKOJOOMSTORELOCATOR="Store Locator"
+COM_MOKOJOOMSTORELOCATOR_LOCATIONS="Locations"
+COM_MOKOJOOMSTORELOCATOR_NO_LOCATIONS="No locations found."
+
+COM_MOKOJOOMSTORELOCATOR_FIELDSET_ADDRESS="Address"
+COM_MOKOJOOMSTORELOCATOR_FIELDSET_CONTACT="Contact Information"
+COM_MOKOJOOMSTORELOCATOR_FIELD_PHONE="Phone"
+COM_MOKOJOOMSTORELOCATOR_FIELD_WEBSITE="Website"
+COM_MOKOJOOMSTORELOCATOR_FIELD_HOURS="Business Hours"
+
+COM_MOKOJOOMSTORELOCATOR_LOCATIONS_VIEW_DEFAULT_TITLE="All Locations"
+COM_MOKOJOOMSTORELOCATOR_LOCATIONS_VIEW_DEFAULT_DESC="Displays a list of all store locations."
+COM_MOKOJOOMSTORELOCATOR_LOCATION_VIEW_DEFAULT_TITLE="Location Detail"
+COM_MOKOJOOMSTORELOCATOR_LOCATION_VIEW_DEFAULT_DESC="Displays a single store location with full details."
+COM_MOKOJOOMSTORELOCATOR_FIELD_LOCATION="Select Location"
diff --git a/source/packages/com_mokosuitestorelocator/site/src/Controller/DisplayController.php b/source/packages/com_mokosuitestorelocator/site/src/Controller/DisplayController.php
new file mode 100644
index 0000000..313002a
--- /dev/null
+++ b/source/packages/com_mokosuitestorelocator/site/src/Controller/DisplayController.php
@@ -0,0 +1,29 @@
+getState('location.id');
+
+ if ($this->_item === null)
+ {
+ $this->_item = [];
+ }
+
+ if (!isset($this->_item[$pk]))
+ {
+ $db = $this->getDatabase();
+ $query = $db->getQuery(true);
+
+ $query->select('a.*')
+ ->from($db->quoteName('#__mokosuitestorelocator_locations', 'a'))
+ ->where($db->quoteName('a.id') . ' = :pk')
+ ->where($db->quoteName('a.published') . ' = 1')
+ ->bind(':pk', $pk, ParameterType::INTEGER);
+
+ $db->setQuery($query);
+ $this->_item[$pk] = $db->loadObject();
+ }
+
+ return $this->_item[$pk] ?? null;
+ }
+
+ /**
+ * Populate the model state.
+ *
+ * @return void
+ *
+ * @since 1.0.0
+ */
+ protected function populateState()
+ {
+ $app = $this->getApplication();
+
+ $id = $app->input->getInt('id', 0);
+ $this->setState('location.id', $id);
+ }
+}
diff --git a/source/packages/com_mokosuitestorelocator/site/src/Model/LocationsModel.php b/source/packages/com_mokosuitestorelocator/site/src/Model/LocationsModel.php
new file mode 100644
index 0000000..168e680
--- /dev/null
+++ b/source/packages/com_mokosuitestorelocator/site/src/Model/LocationsModel.php
@@ -0,0 +1,132 @@
+getApplication();
+
+ $search = $app->input->getString('search', '');
+ $this->setState('filter.search', $search);
+
+ $city = $app->input->getString('city', '');
+ $this->setState('filter.city', $city);
+
+ $state = $app->input->getString('state', '');
+ $this->setState('filter.state', $state);
+
+ parent::populateState($ordering, $direction);
+ }
+
+ /**
+ * Build the query for the locations list.
+ *
+ * @return QueryInterface
+ *
+ * @since 1.0.0
+ */
+ protected function getListQuery(): QueryInterface
+ {
+ $db = $this->getDatabase();
+ $query = $db->getQuery(true);
+
+ $query->select('a.*')
+ ->from($db->quoteName('#__mokosuitestorelocator_locations', 'a'))
+ ->where($db->quoteName('a.published') . ' = 1');
+
+ // Search filter
+ $search = $this->getState('filter.search');
+
+ if (!empty($search))
+ {
+ $search = '%' . trim($search) . '%';
+ $query->where(
+ '(' . $db->quoteName('a.title') . ' LIKE :search'
+ . ' OR ' . $db->quoteName('a.city') . ' LIKE :search2'
+ . ' OR ' . $db->quoteName('a.state') . ' LIKE :search3'
+ . ' OR ' . $db->quoteName('a.address') . ' LIKE :search4)'
+ )
+ ->bind(':search', $search)
+ ->bind(':search2', $search)
+ ->bind(':search3', $search)
+ ->bind(':search4', $search);
+ }
+
+ // City filter
+ $city = $this->getState('filter.city');
+
+ if (!empty($city))
+ {
+ $query->where($db->quoteName('a.city') . ' = :city')
+ ->bind(':city', $city);
+ }
+
+ // State filter
+ $state = $this->getState('filter.state');
+
+ if (!empty($state))
+ {
+ $query->where($db->quoteName('a.state') . ' = :state')
+ ->bind(':state', $state);
+ }
+
+ // Ordering
+ $orderCol = $this->state->get('list.ordering', 'a.ordering');
+ $orderDir = $this->state->get('list.direction', 'ASC');
+ $query->order($db->escape($orderCol) . ' ' . $db->escape($orderDir));
+
+ return $query;
+ }
+}
diff --git a/source/packages/com_mokosuitestorelocator/site/src/Service/Router.php b/source/packages/com_mokosuitestorelocator/site/src/Service/Router.php
new file mode 100644
index 0000000..a7fff3f
--- /dev/null
+++ b/source/packages/com_mokosuitestorelocator/site/src/Service/Router.php
@@ -0,0 +1,51 @@
+registerView($locations);
+
+ $location = new RouterViewConfiguration('location');
+ $location->setKey('id')->setParent($locations);
+ $this->registerView($location);
+
+ parent::__construct($app, $menu);
+
+ $this->attachRule(new MenuRules($this));
+ $this->attachRule(new StandardRules($this));
+ $this->attachRule(new NomenuRules($this));
+ }
+}
diff --git a/source/packages/com_mokosuitestorelocator/site/src/View/Location/HtmlView.php b/source/packages/com_mokosuitestorelocator/site/src/View/Location/HtmlView.php
new file mode 100644
index 0000000..91d17d2
--- /dev/null
+++ b/source/packages/com_mokosuitestorelocator/site/src/View/Location/HtmlView.php
@@ -0,0 +1,48 @@
+item = $this->get('Item');
+
+ if ($this->item === null)
+ {
+ throw new \Exception('Location not found', 404);
+ }
+
+ parent::display($tpl);
+ }
+}
diff --git a/source/packages/com_mokosuitestorelocator/site/src/View/Locations/HtmlView.php b/source/packages/com_mokosuitestorelocator/site/src/View/Locations/HtmlView.php
new file mode 100644
index 0000000..dff9a73
--- /dev/null
+++ b/source/packages/com_mokosuitestorelocator/site/src/View/Locations/HtmlView.php
@@ -0,0 +1,57 @@
+items = $this->get('Items');
+ $this->pagination = $this->get('Pagination');
+ $this->state = $this->get('State');
+
+ parent::display($tpl);
+ }
+}
diff --git a/source/packages/com_mokosuitestorelocator/site/tmpl/location/default.php b/source/packages/com_mokosuitestorelocator/site/tmpl/location/default.php
new file mode 100644
index 0000000..9530029
--- /dev/null
+++ b/source/packages/com_mokosuitestorelocator/site/tmpl/location/default.php
@@ -0,0 +1,105 @@
+item;
+?>
+