diff --git a/.gitignore b/.gitignore index 391f47d..8db86be 100644 --- a/.gitignore +++ b/.gitignore @@ -114,6 +114,7 @@ build/ dist/ out/ site/ +!src/**/site/ *.map *.css.map *.js.map diff --git a/src/packages/com_mokojoomstorelocator/admin/src/Controller/LocationController.php b/src/packages/com_mokojoomstorelocator/admin/src/Controller/LocationController.php new file mode 100644 index 0000000..1edb40b --- /dev/null +++ b/src/packages/com_mokojoomstorelocator/admin/src/Controller/LocationController.php @@ -0,0 +1,31 @@ + true]) + { + return parent::getModel($name, $prefix, $config); + } +} diff --git a/src/packages/com_mokojoomstorelocator/admin/src/Extension/MokoJoomStoreLocatorComponent.php b/src/packages/com_mokojoomstorelocator/admin/src/Extension/MokoJoomStoreLocatorComponent.php index 49f9e3e..dd16d59 100644 --- a/src/packages/com_mokojoomstorelocator/admin/src/Extension/MokoJoomStoreLocatorComponent.php +++ b/src/packages/com_mokojoomstorelocator/admin/src/Extension/MokoJoomStoreLocatorComponent.php @@ -11,13 +11,15 @@ namespace Moko\Component\MokoJoomStoreLocator\Administrator\Extension; defined('_JEXEC') or die; use Joomla\CMS\Extension\MVCComponent; +use Joomla\CMS\Component\Router\RouterServiceInterface; +use Joomla\CMS\Component\Router\RouterServiceTrait; /** * Component class for com_mokojoomstorelocator. * * @since 1.0.0 */ -class MokoJoomStoreLocatorComponent extends MVCComponent +class MokoJoomStoreLocatorComponent extends MVCComponent implements RouterServiceInterface { - // TODO: Add boot(), getRouterRules(), or custom services as needed + use RouterServiceTrait; } diff --git a/src/packages/com_mokojoomstorelocator/admin/src/View/Location/HtmlView.php b/src/packages/com_mokojoomstorelocator/admin/src/View/Location/HtmlView.php new file mode 100644 index 0000000..f3954d4 --- /dev/null +++ b/src/packages/com_mokojoomstorelocator/admin/src/View/Location/HtmlView.php @@ -0,0 +1,83 @@ +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()->getInput()->set('hidemainmenu', true); + + $isNew = ($this->item->id == 0); + + ToolbarHelper::title( + $isNew ? 'Store Locator: New Location' : 'Store Locator: 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/src/packages/com_mokojoomstorelocator/admin/tmpl/location/edit.php b/src/packages/com_mokojoomstorelocator/admin/tmpl/location/edit.php new file mode 100644 index 0000000..09d4d25 --- /dev/null +++ b/src/packages/com_mokojoomstorelocator/admin/tmpl/location/edit.php @@ -0,0 +1,158 @@ +getDocument()->getWebAssetManager(); +$wa->useScript('keepalive') + ->useScript('form.validate'); +?> +
+ + + + diff --git a/src/packages/com_mokojoomstorelocator/site/language/en-GB/com_mokojoomstorelocator.ini b/src/packages/com_mokojoomstorelocator/site/language/en-GB/com_mokojoomstorelocator.ini new file mode 100644 index 0000000..d5720de --- /dev/null +++ b/src/packages/com_mokojoomstorelocator/site/language/en-GB/com_mokojoomstorelocator.ini @@ -0,0 +1,15 @@ +; MokoJoomStoreLocator - 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="Store Locations" +COM_MOKOJOOMSTORELOCATOR_NO_LOCATIONS="No locations found." +COM_MOKOJOOMSTORELOCATOR_LOCATION_DETAIL="Location Detail" +COM_MOKOJOOMSTORELOCATOR_GET_DIRECTIONS="Get Directions" +COM_MOKOJOOMSTORELOCATOR_BACK_TO_LOCATIONS="Back to All Locations" +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" diff --git a/src/packages/com_mokojoomstorelocator/site/services/provider.php b/src/packages/com_mokojoomstorelocator/site/services/provider.php new file mode 100644 index 0000000..b6518ab --- /dev/null +++ b/src/packages/com_mokojoomstorelocator/site/services/provider.php @@ -0,0 +1,12 @@ +getState('location.id'); + + if (!$pk) + { + return false; + } + + $db = $this->getDatabase(); + $query = $db->getQuery(true); + + $query->select('a.*') + ->from($db->quoteName('#__mokojoomstorelocator_locations', 'a')) + ->where($db->quoteName('a.id') . ' = :id') + ->where($db->quoteName('a.published') . ' = 1') + ->bind(':id', $pk, ParameterType::INTEGER); + + $db->setQuery($query); + + return $db->loadObject(); + } +} diff --git a/src/packages/com_mokojoomstorelocator/site/src/Model/LocationsModel.php b/src/packages/com_mokojoomstorelocator/site/src/Model/LocationsModel.php new file mode 100644 index 0000000..f3937bb --- /dev/null +++ b/src/packages/com_mokojoomstorelocator/site/src/Model/LocationsModel.php @@ -0,0 +1,116 @@ +getDatabase(); + $query = $db->getQuery(true); + + $query->select('a.*') + ->from($db->quoteName('#__mokojoomstorelocator_locations', 'a')) + ->where($db->quoteName('a.published') . ' = 1'); + + $catId = (int) $this->getState('filter.category_id'); + + if ($catId > 0) + { + $query->where($db->quoteName('a.catid') . ' = :catid') + ->bind(':catid', $catId, \Joomla\Database\ParameterType::INTEGER); + } + + $search = $this->getState('filter.search'); + + if (!empty($search)) + { + $search = '%' . trim($search) . '%'; + $query->where( + '(' . $db->quoteName('a.title') . ' LIKE :search1' + . ' OR ' . $db->quoteName('a.city') . ' LIKE :search2' + . ' OR ' . $db->quoteName('a.address') . ' LIKE :search3' + . ' OR ' . $db->quoteName('a.postcode') . ' LIKE :search4)' + ) + ->bind(':search1', $search) + ->bind(':search2', $search) + ->bind(':search3', $search) + ->bind(':search4', $search); + } + + $city = $this->getState('filter.city'); + + if (!empty($city)) + { + $query->where($db->quoteName('a.city') . ' = :city') + ->bind(':city', $city); + } + + $state = $this->getState('filter.state'); + + if (!empty($state)) + { + $query->where($db->quoteName('a.state') . ' = :state') + ->bind(':state', $state); + } + + // Radius search using Haversine formula (bounding box pre-filter + SQL distance) + $lat = (float) $this->getState('filter.latitude'); + $lng = (float) $this->getState('filter.longitude'); + $radius = (float) $this->getState('filter.radius'); + $unit = $this->getState('filter.radius_unit', 'miles'); + + if ($lat && $lng && $radius > 0) + { + // Earth radius: 3959 miles or 6371 km + $earthRadius = ($unit === 'km') ? 6371 : 3959; + + $query->where($db->quoteName('a.latitude') . ' IS NOT NULL') + ->where($db->quoteName('a.longitude') . ' IS NOT NULL'); + + // Haversine distance calculation + $haversine = sprintf( + '(%f * ACOS(COS(RADIANS(%f)) * COS(RADIANS(a.latitude))' + . ' * COS(RADIANS(a.longitude) - RADIANS(%f))' + . ' + SIN(RADIANS(%f)) * SIN(RADIANS(a.latitude))))', + $earthRadius, + $lat, + $lng, + $lat + ); + + $query->select($haversine . ' AS distance') + ->having('distance <= ' . (float) $radius) + ->order('distance ASC'); + } + else + { + $query->order($db->quoteName('a.ordering') . ' ASC'); + } + + return $query; + } +} diff --git a/src/packages/com_mokojoomstorelocator/site/src/Service/Router.php b/src/packages/com_mokojoomstorelocator/site/src/Service/Router.php new file mode 100644 index 0000000..841c5fc --- /dev/null +++ b/src/packages/com_mokojoomstorelocator/site/src/Service/Router.php @@ -0,0 +1,112 @@ +registerView($locations); + + // Single location view + $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)); + } + + /** + * Get the segment for a location. + * + * @param string $id The ID with alias (e.g., "5:my-store"). + * @param array $query The request query. + * + * @return array The segment. + * + * @since 1.0.0 + */ + public function getLocationSegment($id, $query): array + { + if (strpos($id, ':') === false) + { + $db = \Joomla\CMS\Factory::getContainer()->get(DatabaseInterface::class); + $dbQuery = $db->getQuery(true) + ->select($db->quoteName('alias')) + ->from($db->quoteName('#__mokojoomstorelocator_locations')) + ->where($db->quoteName('id') . ' = :id') + ->bind(':id', $id, \Joomla\Database\ParameterType::INTEGER); + $db->setQuery($dbQuery); + $alias = $db->loadResult(); + + if ($alias) + { + $id = $id . ':' . $alias; + } + } + + [$numericId, $alias] = explode(':', $id, 2) + [1 => '']; + + return [$numericId => $alias ?: $numericId]; + } + + /** + * Get the ID for a location segment. + * + * @param string $segment The URL segment. + * @param array $query The request query. + * + * @return int|false The location ID or false. + * + * @since 1.0.0 + */ + public function getLocationId($segment, $query): int|false + { + $db = \Joomla\CMS\Factory::getContainer()->get(DatabaseInterface::class); + $dbQuery = $db->getQuery(true) + ->select($db->quoteName('id')) + ->from($db->quoteName('#__mokojoomstorelocator_locations')) + ->where($db->quoteName('alias') . ' = :alias') + ->bind(':alias', $segment); + $db->setQuery($dbQuery); + $id = $db->loadResult(); + + return $id ? (int) $id : (int) $segment; + } +} diff --git a/src/packages/com_mokojoomstorelocator/site/src/View/Location/HtmlView.php b/src/packages/com_mokojoomstorelocator/site/src/View/Location/HtmlView.php new file mode 100644 index 0000000..b2cabfa --- /dev/null +++ b/src/packages/com_mokojoomstorelocator/site/src/View/Location/HtmlView.php @@ -0,0 +1,145 @@ +item = $this->get('Item'); + + if (!$this->item) + { + throw new \Exception('Location not found', 404); + } + + // Set page title + $this->getDocument()->setTitle($this->item->title); + + // Add Schema.org structured data + $this->addStructuredData(); + + parent::display($tpl); + } + + /** + * Add Schema.org LocalBusiness JSON-LD to the document. + * + * @return void + * + * @since 1.0.0 + */ + protected function addStructuredData(): void + { + $item = $this->item; + + $schema = [ + '@context' => 'https://schema.org', + '@type' => 'LocalBusiness', + 'name' => $item->title, + ]; + + if ($item->description) + { + $schema['description'] = strip_tags($item->description); + } + + $address = []; + + if ($item->address) + { + $address['streetAddress'] = $item->address; + } + + if ($item->city) + { + $address['addressLocality'] = $item->city; + } + + if ($item->state) + { + $address['addressRegion'] = $item->state; + } + + if ($item->postcode) + { + $address['postalCode'] = $item->postcode; + } + + if ($item->country) + { + $address['addressCountry'] = $item->country; + } + + if (!empty($address)) + { + $address['@type'] = 'PostalAddress'; + $schema['address'] = $address; + } + + if ($item->latitude && $item->longitude) + { + $schema['geo'] = [ + '@type' => 'GeoCoordinates', + 'latitude' => (float) $item->latitude, + 'longitude' => (float) $item->longitude, + ]; + } + + if ($item->phone) + { + $schema['telephone'] = $item->phone; + } + + if ($item->email) + { + $schema['email'] = $item->email; + } + + if ($item->website) + { + $schema['url'] = $item->website; + } + + if ($item->image) + { + $schema['image'] = $item->image; + } + + $this->getDocument()->addScriptOptions('com_mokojoomstorelocator.schema', $schema); + $this->getDocument()->addCustomTag( + '' + ); + } +} diff --git a/src/packages/com_mokojoomstorelocator/site/src/View/Locations/HtmlView.php b/src/packages/com_mokojoomstorelocator/site/src/View/Locations/HtmlView.php new file mode 100644 index 0000000..5c3bc71 --- /dev/null +++ b/src/packages/com_mokojoomstorelocator/site/src/View/Locations/HtmlView.php @@ -0,0 +1,54 @@ +items = $this->get('Items'); + $this->pagination = $this->get('Pagination'); + + parent::display($tpl); + } +} diff --git a/src/packages/com_mokojoomstorelocator/site/tmpl/location/default.php b/src/packages/com_mokojoomstorelocator/site/tmpl/location/default.php new file mode 100644 index 0000000..62ca958 --- /dev/null +++ b/src/packages/com_mokojoomstorelocator/site/tmpl/location/default.php @@ -0,0 +1,138 @@ +item; +?> +