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
This commit is contained in:
Jonathan Miller
2026-06-23 08:49:07 -05:00
parent c415d4813f
commit 4b37489c41
15 changed files with 639 additions and 2 deletions
+1
View File
@@ -114,6 +114,7 @@ build/
dist/
out/
site/
!source/packages/*/site/
*.map
*.css.map
*.js.map
+9
View File
@@ -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)
@@ -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;
}
@@ -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;
}
@@ -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"
@@ -0,0 +1,29 @@
<?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\Site\Controller;
defined('_JEXEC') or die;
use Joomla\CMS\MVC\Controller\BaseController;
/**
* Default site controller.
*
* @since 1.0.0
*/
class DisplayController extends BaseController
{
/**
* The default view.
*
* @var string
* @since 1.0.0
*/
protected $default_view = 'locations';
}
@@ -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\Site\Model;
defined('_JEXEC') or die;
use Joomla\CMS\MVC\Model\ItemModel;
use Joomla\Database\ParameterType;
/**
* Single location model for the site frontend.
*
* @since 1.0.0
*/
class LocationModel extends ItemModel
{
/**
* The location item.
*
* @var object|null
* @since 1.0.0
*/
protected $_item = null;
/**
* Get a single location item.
*
* @param integer $pk The item primary key. If null, uses the model state.
*
* @return object|null The location object or null if not found.
*
* @since 1.0.0
*/
public function getItem($pk = null)
{
$pk = $pk ?: (int) $this->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);
}
}
@@ -0,0 +1,132 @@
<?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\Site\Model;
defined('_JEXEC') or die;
use Joomla\CMS\MVC\Model\ListModel;
use Joomla\Database\ParameterType;
use Joomla\Database\QueryInterface;
/**
* Locations list model for the site frontend.
*
* @since 1.0.0
*/
class LocationsModel extends ListModel
{
/**
* Constructor.
*
* @param array $config Configuration settings.
*
* @since 1.0.0
*/
public function __construct($config = [])
{
if (empty($config['filter_fields']))
{
$config['filter_fields'] = [
'id', 'a.id',
'title', 'a.title',
'city', 'a.city',
'state', 'a.state',
'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.0.0
*/
protected function populateState($ordering = 'a.ordering', $direction = 'ASC')
{
$app = $this->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;
}
}
@@ -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\Site\Service;
defined('_JEXEC') or die;
use Joomla\CMS\Application\SiteApplication;
use Joomla\CMS\Component\Router\RouterView;
use Joomla\CMS\Component\Router\RouterViewConfiguration;
use Joomla\CMS\Component\Router\Rules\MenuRules;
use Joomla\CMS\Component\Router\Rules\NomenuRules;
use Joomla\CMS\Component\Router\Rules\StandardRules;
use Joomla\CMS\Menu\AbstractMenu;
/**
* SEF URL router for the store locator component.
*
* @since 1.0.0
*/
class Router extends RouterView
{
/**
* Constructor.
*
* @param SiteApplication $app The application object.
* @param AbstractMenu $menu The menu object.
*
* @since 1.0.0
*/
public function __construct(SiteApplication $app, AbstractMenu $menu)
{
$locations = new RouterViewConfiguration('locations');
$this->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));
}
}
@@ -0,0 +1,48 @@
<?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\Site\View\Location;
defined('_JEXEC') or die;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
/**
* Single location detail view for the site frontend.
*
* @since 1.0.0
*/
class HtmlView extends BaseHtmlView
{
/**
* @var object|null
* @since 1.0.0
*/
protected $item;
/**
* Display the view.
*
* @param string $tpl The template name.
*
* @return void
*
* @since 1.0.0
*/
public function display($tpl = null): void
{
$this->item = $this->get('Item');
if ($this->item === null)
{
throw new \Exception('Location not found', 404);
}
parent::display($tpl);
}
}
@@ -0,0 +1,57 @@
<?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\Site\View\Locations;
defined('_JEXEC') or die;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
/**
* Locations list view for the site frontend.
*
* @since 1.0.0
*/
class HtmlView extends BaseHtmlView
{
/**
* @var array
* @since 1.0.0
*/
protected $items;
/**
* @var \Joomla\CMS\Pagination\Pagination
* @since 1.0.0
*/
protected $pagination;
/**
* @var \Joomla\Registry\Registry
* @since 1.0.0
*/
protected $state;
/**
* Display the view.
*
* @param string $tpl The template name.
*
* @return void
*
* @since 1.0.0
*/
public function display($tpl = null): void
{
$this->items = $this->get('Items');
$this->pagination = $this->get('Pagination');
$this->state = $this->get('State');
parent::display($tpl);
}
}
@@ -0,0 +1,104 @@
<?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\Language\Text;
/** @var \Moko\Component\MokoSuiteStoreLocator\Site\View\Location\HtmlView $this */
$item = $this->item;
?>
<div class="com-mokosuitestorelocator-location" itemscope itemtype="https://schema.org/LocalBusiness">
<h2 itemprop="name"><?php echo $this->escape($item->title); ?></h2>
<?php if ($item->image) : ?>
<div class="com-mokosuitestorelocator-location__image">
<img src="<?php echo $this->escape($item->image); ?>"
alt="<?php echo $this->escape($item->title); ?>"
itemprop="image">
</div>
<?php endif; ?>
<?php if ($item->description) : ?>
<div class="com-mokosuitestorelocator-location__description" itemprop="description">
<?php echo $item->description; ?>
</div>
<?php endif; ?>
<div class="com-mokosuitestorelocator-location__details">
<div class="com-mokosuitestorelocator-location__address" itemprop="address" itemscope itemtype="https://schema.org/PostalAddress">
<h3><?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_FIELDSET_ADDRESS'); ?></h3>
<?php if ($item->address) : ?>
<span itemprop="streetAddress"><?php echo $this->escape($item->address); ?></span><br>
<?php endif; ?>
<?php if ($item->city) : ?>
<span itemprop="addressLocality"><?php echo $this->escape($item->city); ?></span>,
<?php endif; ?>
<?php if ($item->state) : ?>
<span itemprop="addressRegion"><?php echo $this->escape($item->state); ?></span>
<?php endif; ?>
<?php if ($item->postcode) : ?>
<span itemprop="postalCode"><?php echo $this->escape($item->postcode); ?></span>
<?php endif; ?>
<?php if ($item->country) : ?>
<br><span itemprop="addressCountry"><?php echo $this->escape($item->country); ?></span>
<?php endif; ?>
</div>
<div class="com-mokosuitestorelocator-location__contact">
<h3><?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_FIELDSET_CONTACT'); ?></h3>
<?php if ($item->phone) : ?>
<div>
<strong><?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_FIELD_PHONE'); ?>:</strong>
<a href="tel:<?php echo $this->escape($item->phone); ?>" itemprop="telephone">
<?php echo $this->escape($item->phone); ?>
</a>
</div>
<?php endif; ?>
<?php if ($item->email) : ?>
<div>
<strong><?php echo Text::_('JGLOBAL_EMAIL'); ?>:</strong>
<a href="mailto:<?php echo $this->escape($item->email); ?>" itemprop="email">
<?php echo $this->escape($item->email); ?>
</a>
</div>
<?php endif; ?>
<?php if ($item->website) : ?>
<div>
<strong><?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_FIELD_WEBSITE'); ?>:</strong>
<a href="<?php echo $this->escape($item->website); ?>" itemprop="url" target="_blank" rel="noopener">
<?php echo $this->escape($item->website); ?>
</a>
</div>
<?php endif; ?>
</div>
<?php if ($item->hours) : ?>
<div class="com-mokosuitestorelocator-location__hours">
<h3><?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_FIELD_HOURS'); ?></h3>
<div itemprop="openingHours">
<?php echo nl2br($this->escape($item->hours)); ?>
</div>
</div>
<?php endif; ?>
</div>
<?php if ($item->latitude && $item->longitude) : ?>
<meta itemprop="latitude" content="<?php echo $this->escape($item->latitude); ?>">
<meta itemprop="longitude" content="<?php echo $this->escape($item->longitude); ?>">
<div class="com-mokosuitestorelocator-location__map"
data-lat="<?php echo $this->escape($item->latitude); ?>"
data-lng="<?php echo $this->escape($item->longitude); ?>"
data-title="<?php echo $this->escape($item->title); ?>"
style="height: 300px;">
</div>
<?php endif; ?>
</div>
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<metadata>
<layout title="COM_MOKOJOOMSTORELOCATOR_LOCATION_VIEW_DEFAULT_TITLE"
option="COM_MOKOJOOMSTORELOCATOR_LOCATION_VIEW_DEFAULT_DESC">
<message>
<![CDATA[COM_MOKOJOOMSTORELOCATOR_LOCATION_VIEW_DEFAULT_DESC]]>
</message>
</layout>
<fields name="request">
<fieldset name="request">
<field
name="id"
type="sql"
label="COM_MOKOJOOMSTORELOCATOR_FIELD_LOCATION"
query="SELECT id, title FROM #__mokosuitestorelocator_locations WHERE published = 1 ORDER BY title"
key_field="id"
value_field="title"
required="true"
/>
</fieldset>
</fields>
</metadata>
@@ -0,0 +1,69 @@
<?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\Language\Text;
use Joomla\CMS\Router\Route;
/** @var \Moko\Component\MokoSuiteStoreLocator\Site\View\Locations\HtmlView $this */
?>
<div class="com-mokosuitestorelocator-locations">
<h2><?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_LOCATIONS'); ?></h2>
<?php if (empty($this->items)) : ?>
<p><?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_NO_LOCATIONS'); ?></p>
<?php else : ?>
<div class="com-mokosuitestorelocator-locations__list">
<?php foreach ($this->items as $item) : ?>
<div class="com-mokosuitestorelocator-location-card" itemscope itemtype="https://schema.org/LocalBusiness">
<?php if ($item->image) : ?>
<div class="com-mokosuitestorelocator-location-card__image">
<img src="<?php echo $this->escape($item->image); ?>"
alt="<?php echo $this->escape($item->title); ?>"
itemprop="image" loading="lazy">
</div>
<?php endif; ?>
<div class="com-mokosuitestorelocator-location-card__body">
<h3 itemprop="name">
<a href="<?php echo Route::_('index.php?option=com_mokosuitestorelocator&view=location&id=' . (int) $item->id); ?>">
<?php echo $this->escape($item->title); ?>
</a>
</h3>
<div itemprop="address" itemscope itemtype="https://schema.org/PostalAddress">
<?php if ($item->address) : ?>
<span itemprop="streetAddress"><?php echo $this->escape($item->address); ?></span><br>
<?php endif; ?>
<?php if ($item->city) : ?>
<span itemprop="addressLocality"><?php echo $this->escape($item->city); ?></span>,
<?php endif; ?>
<?php if ($item->state) : ?>
<span itemprop="addressRegion"><?php echo $this->escape($item->state); ?></span>
<?php endif; ?>
<?php if ($item->postcode) : ?>
<span itemprop="postalCode"><?php echo $this->escape($item->postcode); ?></span>
<?php endif; ?>
</div>
<?php if ($item->phone) : ?>
<div class="com-mokosuitestorelocator-location-card__phone">
<a href="tel:<?php echo $this->escape($item->phone); ?>" itemprop="telephone">
<?php echo $this->escape($item->phone); ?>
</a>
</div>
<?php endif; ?>
</div>
</div>
<?php endforeach; ?>
</div>
<?php echo $this->pagination->getListFooter(); ?>
<?php endif; ?>
</div>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<metadata>
<layout title="COM_MOKOJOOMSTORELOCATOR_LOCATIONS_VIEW_DEFAULT_TITLE"
option="COM_MOKOJOOMSTORELOCATOR_LOCATIONS_VIEW_DEFAULT_DESC">
<message>
<![CDATA[COM_MOKOJOOMSTORELOCATOR_LOCATIONS_VIEW_DEFAULT_DESC]]>
</message>
</layout>
</metadata>