From 4b37489c41589c474fdd5a3cf873f502c3e63f4c Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Tue, 23 Jun 2026 08:49:07 -0500 Subject: [PATCH] feat: add site frontend with locations list, detail view, and SEF router (Phase 2) Build the complete public-facing frontend for the store locator: - Site DisplayController routing to list and detail views - LocationsModel with search, city, and state filters (published only) - LocationModel for single location by ID - Locations list template with Schema.org LocalBusiness markup - Location detail template with address, contact, hours, map placeholder - SEF URL router with menu/standard/nomenu rules - Menu item types: "All Locations" list and "Location Detail" picker - Site language strings for views and menu items - Router wired into component extension class and service provider - .gitignore exception for source/packages/*/site/ Addresses #50 Authored-by: Moko Consulting --- .gitignore | 1 + CHANGELOG.md | 9 ++ .../admin/services/provider.php | 4 + .../MokoSuiteStoreLocatorComponent.php | 6 +- .../en-GB/com_mokosuitestorelocator.ini | 19 +++ .../site/src/Controller/DisplayController.php | 29 ++++ .../site/src/Model/LocationModel.php | 81 +++++++++++ .../site/src/Model/LocationsModel.php | 132 ++++++++++++++++++ .../site/src/Service/Router.php | 51 +++++++ .../site/src/View/Location/HtmlView.php | 48 +++++++ .../site/src/View/Locations/HtmlView.php | 57 ++++++++ .../site/tmpl/location/default.php | 104 ++++++++++++++ .../site/tmpl/location/default.xml | 22 +++ .../site/tmpl/locations/default.php | 69 +++++++++ .../site/tmpl/locations/default.xml | 9 ++ 15 files changed, 639 insertions(+), 2 deletions(-) create mode 100644 source/packages/com_mokosuitestorelocator/site/language/en-GB/com_mokosuitestorelocator.ini create mode 100644 source/packages/com_mokosuitestorelocator/site/src/Controller/DisplayController.php create mode 100644 source/packages/com_mokosuitestorelocator/site/src/Model/LocationModel.php create mode 100644 source/packages/com_mokosuitestorelocator/site/src/Model/LocationsModel.php create mode 100644 source/packages/com_mokosuitestorelocator/site/src/Service/Router.php create mode 100644 source/packages/com_mokosuitestorelocator/site/src/View/Location/HtmlView.php create mode 100644 source/packages/com_mokosuitestorelocator/site/src/View/Locations/HtmlView.php create mode 100644 source/packages/com_mokosuitestorelocator/site/tmpl/location/default.php create mode 100644 source/packages/com_mokosuitestorelocator/site/tmpl/location/default.xml create mode 100644 source/packages/com_mokosuitestorelocator/site/tmpl/locations/default.php create mode 100644 source/packages/com_mokosuitestorelocator/site/tmpl/locations/default.xml 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 10a2d87..70a3d49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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 ### Removed - Makefile (no longer used) diff --git a/source/packages/com_mokosuitestorelocator/admin/services/provider.php b/source/packages/com_mokosuitestorelocator/admin/services/provider.php index c5bb72e..3008ab5 100644 --- a/source/packages/com_mokosuitestorelocator/admin/services/provider.php +++ b/source/packages/com_mokosuitestorelocator/admin/services/provider.php @@ -8,10 +8,12 @@ defined('_JEXEC') or die; +use Joomla\CMS\Component\Router\RouterFactoryInterface; use Joomla\CMS\Dispatcher\ComponentDispatcherFactoryInterface; use Joomla\CMS\Extension\ComponentInterface; use Joomla\CMS\Extension\Service\Provider\ComponentDispatcherFactory; use Joomla\CMS\Extension\Service\Provider\MVCFactory; +use Joomla\CMS\Extension\Service\Provider\RouterFactory; use Joomla\CMS\MVC\Factory\MVCFactoryInterface; use Joomla\DI\Container; use Joomla\DI\ServiceProviderInterface; @@ -37,6 +39,7 @@ return new class implements ServiceProviderInterface { $container->registerServiceProvider(new MVCFactory('\\Moko\\Component\\MokoSuiteStoreLocator')); $container->registerServiceProvider(new ComponentDispatcherFactory('\\Moko\\Component\\MokoSuiteStoreLocator')); + $container->registerServiceProvider(new RouterFactory('\\Moko\\Component\\MokoSuiteStoreLocator')); $container->set( ComponentInterface::class, @@ -45,6 +48,7 @@ return new class implements ServiceProviderInterface $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/src/Extension/MokoSuiteStoreLocatorComponent.php b/source/packages/com_mokosuitestorelocator/admin/src/Extension/MokoSuiteStoreLocatorComponent.php index f02a57a..2238b9f 100644 --- a/source/packages/com_mokosuitestorelocator/admin/src/Extension/MokoSuiteStoreLocatorComponent.php +++ b/source/packages/com_mokosuitestorelocator/admin/src/Extension/MokoSuiteStoreLocatorComponent.php @@ -10,6 +10,8 @@ namespace Moko\Component\MokoSuiteStoreLocator\Administrator\Extension; defined('_JEXEC') or die; +use Joomla\CMS\Component\Router\RouterServiceInterface; +use Joomla\CMS\Component\Router\RouterServiceTrait; use Joomla\CMS\Extension\MVCComponent; /** @@ -17,7 +19,7 @@ use Joomla\CMS\Extension\MVCComponent; * * @since 1.0.0 */ -class MokoSuiteStoreLocatorComponent extends MVCComponent +class MokoSuiteStoreLocatorComponent extends MVCComponent implements RouterServiceInterface { - // TODO: Add boot(), getRouterRules(), or custom services as needed + use RouterServiceTrait; } 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..46bbd04 --- /dev/null +++ b/source/packages/com_mokosuitestorelocator/site/tmpl/location/default.php @@ -0,0 +1,104 @@ +item; +?> +
+

escape($item->title); ?>

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

+ address) : ?> + escape($item->address); ?>
+ + city) : ?> + escape($item->city); ?>, + + state) : ?> + escape($item->state); ?> + + postcode) : ?> + escape($item->postcode); ?> + + country) : ?> +
escape($item->country); ?> + +
+ +
+

+ phone) : ?> +
+ : + + escape($item->phone); ?> + +
+ + + email) : ?> +
+ : + + escape($item->email); ?> + +
+ + + website) : ?> +
+ : + +
+ +
+ + hours) : ?> +
+

+
+ escape($item->hours)); ?> +
+
+ +
+ + latitude && $item->longitude) : ?> + + +
+
+ +
diff --git a/source/packages/com_mokosuitestorelocator/site/tmpl/location/default.xml b/source/packages/com_mokosuitestorelocator/site/tmpl/location/default.xml new file mode 100644 index 0000000..8d500fd --- /dev/null +++ b/source/packages/com_mokosuitestorelocator/site/tmpl/location/default.xml @@ -0,0 +1,22 @@ + + + + + + + + +
+ +
+
+
diff --git a/source/packages/com_mokosuitestorelocator/site/tmpl/locations/default.php b/source/packages/com_mokosuitestorelocator/site/tmpl/locations/default.php new file mode 100644 index 0000000..2732468 --- /dev/null +++ b/source/packages/com_mokosuitestorelocator/site/tmpl/locations/default.php @@ -0,0 +1,69 @@ + +
+

+ + items)) : ?> +

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

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

+ +
+ address) : ?> + escape($item->address); ?>
+ + city) : ?> + escape($item->city); ?>, + + state) : ?> + escape($item->state); ?> + + postcode) : ?> + escape($item->postcode); ?> + +
+ + phone) : ?> + + +
+
+ +
+ + pagination->getListFooter(); ?> + +
diff --git a/source/packages/com_mokosuitestorelocator/site/tmpl/locations/default.xml b/source/packages/com_mokosuitestorelocator/site/tmpl/locations/default.xml new file mode 100644 index 0000000..dc34b45 --- /dev/null +++ b/source/packages/com_mokosuitestorelocator/site/tmpl/locations/default.xml @@ -0,0 +1,9 @@ + + + + + + + +