From 7ef9a23ef802a9b9454fe88fa6d11b1acbad842d Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Tue, 23 Jun 2026 11:50:02 -0500 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20v1.1=20competitive=20parity=20?= =?UTF-8?q?=E2=80=94=20proximity=20search,=20directions,=20geocoding,=20CS?= =?UTF-8?q?V=20import?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Haversine proximity search: - LocationsModel filters by distance using Haversine formula - populateState captures lat/lng/radius/radius_unit from search form - Distance-sorted results when proximity filter is active - Hidden radius_unit field added to search module form Get Directions: - Google Maps directions link on location detail page (no API key) - Directions link in Leaflet popup markers Auto-geocoding: - LocationModel::save() override auto-geocodes empty coordinates - Calls Nominatim/OSM API when address present but coords missing - Success/failure messages via Joomla enqueueMessage CSV Import: - ImportController handles file upload with CSRF token check - ImportModel parses CSV via SplFileObject with configurable delimiter - Auto-detects column headers (title/name, address/street, city, etc.) - Per-row validation via LocationTable::bind()->check()->store() - Import view with upload form, delimiter picker, and column help - Toolbar button and submenu item for import access Addresses #54 Authored-by: Moko Consulting --- CHANGELOG.md | 15 ++ README.md | 5 +- .../en-GB/com_mokosuitestorelocator.ini | 18 ++ .../admin/src/Controller/ImportController.php | 66 ++++++ .../admin/src/Model/ImportModel.php | 209 ++++++++++++++++++ .../admin/src/Model/LocationModel.php | 96 +++++++- .../admin/src/View/Import/HtmlView.php | 39 ++++ .../admin/src/View/Locations/HtmlView.php | 1 + .../admin/tmpl/import/default.php | 88 ++++++++ .../mokosuitestorelocator.xml | 1 + .../en-GB/com_mokosuitestorelocator.ini | 1 + .../site/src/Model/LocationsModel.php | 56 ++++- .../site/tmpl/location/default.php | 14 ++ .../tmpl/default.php | 1 + .../tmpl/default.php | 1 + 15 files changed, 605 insertions(+), 6 deletions(-) create mode 100644 source/packages/com_mokosuitestorelocator/admin/src/Controller/ImportController.php create mode 100644 source/packages/com_mokosuitestorelocator/admin/src/Model/ImportModel.php create mode 100644 source/packages/com_mokosuitestorelocator/admin/src/View/Import/HtmlView.php create mode 100644 source/packages/com_mokosuitestorelocator/admin/tmpl/import/default.php diff --git a/CHANGELOG.md b/CHANGELOG.md index d68fa61..5bdd974 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,21 @@ 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 + +### Added +- Haversine proximity search — filter locations by distance from user's coordinates +- Hidden `radius_unit` field in search module to pass miles/km preference to component +- Distance-sorted results when proximity search is active +- "Get Directions" link on location detail page (Google Maps, no API key needed) +- "Get Directions" link in Leaflet map popup markers +- Auto-geocoding on admin save — coordinates populated from address via Nominatim/OSM API +- CSV import: upload CSV file to bulk-create locations +- 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 +- Language strings for directions, geocoding feedback, and import UI + ## [1.0.0] - 2026-06-23 ### Added diff --git a/README.md b/README.md index 89fefb4..4d997c6 100644 --- a/README.md +++ b/README.md @@ -33,9 +33,12 @@ A Joomla 4/5 package providing a store locator listing component with coordinati - **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") +- **Proximity search** — Haversine distance filtering with distance-sorted results +- **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 ### 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 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 index 3b64378..90a0380 100644 --- a/source/packages/com_mokosuitestorelocator/admin/language/en-GB/com_mokosuitestorelocator.ini +++ b/source/packages/com_mokosuitestorelocator/admin/language/en-GB/com_mokosuitestorelocator.ini @@ -41,3 +41,21 @@ 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." + +COM_MOKOJOOMSTORELOCATOR_GEOCODING_SUCCESS="Coordinates were auto-populated from the address via OpenStreetMap." +COM_MOKOJOOMSTORELOCATOR_GEOCODING_FAILED="Geocoding failed: %s. You can enter coordinates manually." +COM_MOKOJOOMSTORELOCATOR_GET_DIRECTIONS="Get Directions" + +COM_MOKOJOOMSTORELOCATOR_IMPORT="Import Locations" +COM_MOKOJOOMSTORELOCATOR_IMPORT_DESC="Import store locations from a CSV file." +COM_MOKOJOOMSTORELOCATOR_IMPORT_UPLOAD="Upload CSV File" +COM_MOKOJOOMSTORELOCATOR_IMPORT_FILE="CSV File" +COM_MOKOJOOMSTORELOCATOR_IMPORT_FILE_DESC="Select a CSV file with location data. First row must be column headers." +COM_MOKOJOOMSTORELOCATOR_IMPORT_DELIMITER="Delimiter" +COM_MOKOJOOMSTORELOCATOR_IMPORT_DELIMITER_DESC="The character separating fields in your CSV file." +COM_MOKOJOOMSTORELOCATOR_IMPORT_SUCCESS="%d location(s) imported successfully." +COM_MOKOJOOMSTORELOCATOR_IMPORT_SKIPPED="%d row(s) skipped due to errors." +COM_MOKOJOOMSTORELOCATOR_IMPORT_NO_FILE="No file was uploaded." +COM_MOKOJOOMSTORELOCATOR_IMPORT_INVALID_FILE="The uploaded file is not a valid CSV." +COM_MOKOJOOMSTORELOCATOR_IMPORT_NO_ROWS="The CSV file contains no data rows." +COM_MOKOJOOMSTORELOCATOR_IMPORT_MISSING_TITLE="Row %d: Title is required." diff --git a/source/packages/com_mokosuitestorelocator/admin/src/Controller/ImportController.php b/source/packages/com_mokosuitestorelocator/admin/src/Controller/ImportController.php new file mode 100644 index 0000000..79519d8 --- /dev/null +++ b/source/packages/com_mokosuitestorelocator/admin/src/Controller/ImportController.php @@ -0,0 +1,66 @@ +getModel('Import', 'Administrator'); + + $file = $this->input->files->get('jform', [], 'array'); + $delimiter = $this->input->post->getString('delimiter', ','); + + $csvFile = $file['csv_file'] ?? null; + + if (!$csvFile || $csvFile['error'] !== UPLOAD_ERR_OK || !is_uploaded_file($csvFile['tmp_name'])) + { + $this->setMessage(Text::_('COM_MOKOJOOMSTORELOCATOR_IMPORT_NO_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) + { + $this->setMessage(Text::sprintf('COM_MOKOJOOMSTORELOCATOR_IMPORT_SUCCESS', $result['imported'])); + } + + if ($result['skipped'] > 0) + { + $this->setMessage(Text::sprintf('COM_MOKOJOOMSTORELOCATOR_IMPORT_SKIPPED', $result['skipped']), 'warning'); + } + + $this->setRedirect(Route::_('index.php?option=com_mokosuitestorelocator&view=locations', false)); + } +} diff --git a/source/packages/com_mokosuitestorelocator/admin/src/Model/ImportModel.php b/source/packages/com_mokosuitestorelocator/admin/src/Model/ImportModel.php new file mode 100644 index 0000000..4fcdc63 --- /dev/null +++ b/source/packages/com_mokosuitestorelocator/admin/src/Model/ImportModel.php @@ -0,0 +1,209 @@ + 'title', + 'name' => 'title', + 'store' => 'title', + 'location' => 'title', + 'description' => 'description', + 'address' => 'address', + 'street' => 'address', + 'city' => 'city', + 'state' => 'state', + 'province' => 'state', + 'region' => 'state', + 'postcode' => 'postcode', + 'zip' => 'postcode', + 'zipcode' => 'postcode', + 'postal_code' => 'postcode', + 'country' => 'country', + 'latitude' => 'latitude', + 'lat' => 'latitude', + 'longitude' => 'longitude', + 'lng' => 'longitude', + 'lon' => 'longitude', + 'phone' => 'phone', + 'telephone' => 'phone', + 'email' => 'email', + 'website' => 'website', + 'url' => 'website', + 'hours' => 'hours', + 'image' => 'image', + 'published' => 'published', + ]; + + /** + * Process a CSV file and import locations. + * + * @param string $filePath Path to the uploaded CSV file. + * @param string $delimiter CSV delimiter character. + * + * @return array ['imported' => int, 'skipped' => int, 'errors' => array] + * + * @since 1.1.0 + */ + public function processImport(string $filePath, string $delimiter = ','): array + { + $result = ['imported' => 0, 'skipped' => 0, 'errors' => []]; + + $file = new SplFileObject($filePath, 'r'); + $file->setFlags(SplFileObject::READ_CSV | SplFileObject::SKIP_EMPTY | SplFileObject::DROP_NEW_LINE); + $file->setCsvControl($delimiter); + + // Read and map headers + $headers = $file->fgetcsv(); + + if (!$headers || \count($headers) < 2) + { + $result['errors'][] = 'Invalid CSV headers.'; + + return $result; + } + + $mapping = $this->mapColumns($headers); + + if (!isset($mapping['title'])) + { + $result['errors'][] = 'CSV must contain a "title" or "name" column.'; + + return $result; + } + + $db = $this->getDatabase(); + $table = $this->getMVCFactory()->createTable('Location', 'Administrator'); + $user = Factory::getApplication()->getIdentity(); + $rowNum = 1; + + foreach ($file as $row) + { + $rowNum++; + + if (empty($row) || (\count($row) === 1 && $row[0] === null)) + { + continue; + } + + $data = $this->mapRowToData($row, $headers, $mapping); + + if (empty($data['title'])) + { + $result['errors'][] = "Row $rowNum: missing title"; + $result['skipped']++; + continue; + } + + $data['published'] = (int) ($data['published'] ?? 1); + $data['created_by'] = $user->id; + + // Reset table state for each row + $table->reset(); + $table->id = 0; + + if (!$table->bind($data)) + { + $result['errors'][] = "Row $rowNum: " . $table->getError(); + $result['skipped']++; + continue; + } + + if (!$table->check()) + { + $result['errors'][] = "Row $rowNum: " . $table->getError(); + $result['skipped']++; + continue; + } + + if (!$table->store()) + { + $result['errors'][] = "Row $rowNum: " . $table->getError(); + $result['skipped']++; + continue; + } + + $result['imported']++; + } + + return $result; + } + + /** + * Auto-map CSV headers to database field names. + * + * @param array $headers CSV column headers. + * + * @return array Associative array of db_field => csv_index. + * + * @since 1.1.0 + */ + private function mapColumns(array $headers): array + { + $mapping = []; + + foreach ($headers as $index => $header) + { + $normalized = strtolower(trim(str_replace([' ', '-', '_'], ['_', '_', '_'], $header))); + + if (isset(self::COLUMN_MAP[$normalized])) + { + $dbField = self::COLUMN_MAP[$normalized]; + + if (!isset($mapping[$dbField])) + { + $mapping[$dbField] = $index; + } + } + } + + return $mapping; + } + + /** + * Map a CSV row to a data array using the column mapping. + * + * @param array $row CSV row values. + * @param array $headers CSV column headers. + * @param array $mapping Column mapping (db_field => csv_index). + * + * @return array Data array ready for table bind. + * + * @since 1.1.0 + */ + private function mapRowToData(array $row, array $headers, array $mapping): array + { + $data = []; + + foreach ($mapping as $dbField => $csvIndex) + { + $data[$dbField] = trim($row[$csvIndex] ?? ''); + } + + return $data; + } +} diff --git a/source/packages/com_mokosuitestorelocator/admin/src/Model/LocationModel.php b/source/packages/com_mokosuitestorelocator/admin/src/Model/LocationModel.php index 8f4aa99..3b75653 100644 --- a/source/packages/com_mokosuitestorelocator/admin/src/Model/LocationModel.php +++ b/source/packages/com_mokosuitestorelocator/admin/src/Model/LocationModel.php @@ -10,8 +10,11 @@ namespace Moko\Component\MokoSuiteStoreLocator\Administrator\Model; defined('_JEXEC') or die; -use Joomla\CMS\MVC\Model\AdminModel; +use Joomla\CMS\Factory; use Joomla\CMS\Form\Form; +use Joomla\CMS\Http\HttpFactory; +use Joomla\CMS\Language\Text; +use Joomla\CMS\MVC\Model\AdminModel; use Joomla\CMS\Table\Table; /** @@ -84,4 +87,95 @@ class LocationModel extends AdminModel { return parent::getTable($name, $prefix, $options); } + + /** + * Save the location, auto-geocoding the address if coordinates are empty. + * + * @param array $data The form data. + * + * @return boolean True on success. + * + * @since 1.1.0 + */ + public function save($data) + { + $hasCoords = !empty($data['latitude']) && !empty($data['longitude']); + $hasAddress = !empty($data['address']) || !empty($data['city']) || !empty($data['postcode']); + + if (!$hasCoords && $hasAddress) + { + $coords = $this->geocodeAddress($data); + + if ($coords) + { + $data['latitude'] = $coords['lat']; + $data['longitude'] = $coords['lng']; + Factory::getApplication()->enqueueMessage( + Text::_('COM_MOKOJOOMSTORELOCATOR_GEOCODING_SUCCESS'), + 'success' + ); + } + } + + return parent::save($data); + } + + /** + * Geocode an address using the Nominatim (OpenStreetMap) API. + * + * @param array $data Location data with address fields. + * + * @return array|null ['lat' => float, 'lng' => float] or null on failure. + * + * @since 1.1.0 + */ + private function geocodeAddress(array $data): ?array + { + $parts = array_filter([ + $data['address'] ?? '', + $data['city'] ?? '', + $data['state'] ?? '', + $data['postcode'] ?? '', + $data['country'] ?? '', + ]); + + if (empty($parts)) + { + return null; + } + + $query = implode(', ', $parts); + + try + { + $http = HttpFactory::getHttp(); + $url = 'https://nominatim.openstreetmap.org/search?' + . http_build_query(['format' => 'json', 'limit' => 1, 'q' => $query]); + $response = $http->get($url, ['User-Agent' => 'MokoSuiteStoreLocator/1.1 (+https://mokoconsulting.tech)'], 10); + + if ($response->code !== 200) + { + return null; + } + + $results = json_decode($response->body, true); + + if (!empty($results[0]['lat']) && !empty($results[0]['lon'])) + { + return [ + 'lat' => round((float) $results[0]['lat'], 8), + 'lng' => round((float) $results[0]['lon'], 8), + ]; + } + } + catch (\Exception $e) + { + Factory::getApplication()->enqueueMessage( + Text::sprintf('COM_MOKOJOOMSTORELOCATOR_GEOCODING_FAILED', $e->getMessage()), + 'warning' + ); + } + + return null; + } } diff --git a/source/packages/com_mokosuitestorelocator/admin/src/View/Import/HtmlView.php b/source/packages/com_mokosuitestorelocator/admin/src/View/Import/HtmlView.php new file mode 100644 index 0000000..0e074e9 --- /dev/null +++ b/source/packages/com_mokosuitestorelocator/admin/src/View/Import/HtmlView.php @@ -0,0 +1,39 @@ + +
+ +
+
+
+
+

+

+ +
+ + +
+ +
+ + +
+ +
+ + + + +
+
+
+
+ +
+
+
+

+

Supported column headers (auto-detected):

+
    +
  • title / name / store / location (required)
  • +
  • address / street
  • +
  • city
  • +
  • state / province / region
  • +
  • postcode / zip / zipcode / postal_code
  • +
  • country
  • +
  • latitude / lat
  • +
  • longitude / lng / lon
  • +
  • phone / telephone
  • +
  • email
  • +
  • website / url
  • +
  • hours
  • +
  • published (0 or 1)
  • +
+
+
+
+
+ + +
diff --git a/source/packages/com_mokosuitestorelocator/mokosuitestorelocator.xml b/source/packages/com_mokosuitestorelocator/mokosuitestorelocator.xml index f882ef5..05789af 100644 --- a/source/packages/com_mokosuitestorelocator/mokosuitestorelocator.xml +++ b/source/packages/com_mokosuitestorelocator/mokosuitestorelocator.xml @@ -57,6 +57,7 @@ COM_MOKOJOOMSTORELOCATOR COM_MOKOJOOMSTORELOCATOR_LOCATIONS + COM_MOKOJOOMSTORELOCATOR_IMPORT 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 index 00eb1fb..41badd2 100644 --- a/source/packages/com_mokosuitestorelocator/site/language/en-GB/com_mokosuitestorelocator.ini +++ b/source/packages/com_mokosuitestorelocator/site/language/en-GB/com_mokosuitestorelocator.ini @@ -17,3 +17,4 @@ COM_MOKOJOOMSTORELOCATOR_LOCATIONS_VIEW_DEFAULT_DESC="Displays a list of all sto 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" +COM_MOKOJOOMSTORELOCATOR_GET_DIRECTIONS="Get Directions" diff --git a/source/packages/com_mokosuitestorelocator/site/src/Model/LocationsModel.php b/source/packages/com_mokosuitestorelocator/site/src/Model/LocationsModel.php index 168e680..9e395ba 100644 --- a/source/packages/com_mokosuitestorelocator/site/src/Model/LocationsModel.php +++ b/source/packages/com_mokosuitestorelocator/site/src/Model/LocationsModel.php @@ -67,6 +67,28 @@ class LocationsModel extends ListModel $state = $app->input->getString('state', ''); $this->setState('filter.state', $state); + $lat = $app->input->getFloat('lat', 0.0); + $lng = $app->input->getFloat('lng', 0.0); + $radius = $app->input->getInt('radius', 0); + $radiusUnit = $app->input->getString('radius_unit', 'miles'); + + if ($lat >= -90 && $lat <= 90 && $lat != 0.0) + { + $this->setState('filter.lat', $lat); + } + + if ($lng >= -180 && $lng <= 180 && $lng != 0.0) + { + $this->setState('filter.lng', $lng); + } + + if ($radius > 0) + { + $this->setState('filter.radius', $radius); + } + + $this->setState('filter.radius_unit', in_array($radiusUnit, ['miles', 'km']) ? $radiusUnit : 'miles'); + parent::populateState($ordering, $direction); } @@ -122,10 +144,36 @@ class LocationsModel extends ListModel ->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)); + // Proximity / Haversine distance filter + $lat = $this->getState('filter.lat'); + $lng = $this->getState('filter.lng'); + $radius = $this->getState('filter.radius'); + + if ($lat && $lng && $radius) + { + $unit = $this->getState('filter.radius_unit', 'miles'); + $earthRadius = ($unit === 'km') ? 6371 : 3959; + + $haversine = '(' . $earthRadius . ' * ACOS(LEAST(1, GREATEST(-1, ' + . 'SIN(RADIANS(' . $db->quoteName('a.latitude') . ')) * SIN(RADIANS(' . (float) $lat . ')) ' + . '+ COS(RADIANS(' . $db->quoteName('a.latitude') . ')) * COS(RADIANS(' . (float) $lat . ')) ' + . '* COS(RADIANS(' . $db->quoteName('a.longitude') . ' - ' . (float) $lng . '))' + . '))))'; + + $query->where($db->quoteName('a.latitude') . ' IS NOT NULL') + ->where($db->quoteName('a.longitude') . ' IS NOT NULL') + ->where($haversine . ' <= ' . (int) $radius); + + $query->select($haversine . ' AS distance'); + $query->order('distance ASC'); + } + 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)); + } return $query; } diff --git a/source/packages/com_mokosuitestorelocator/site/tmpl/location/default.php b/source/packages/com_mokosuitestorelocator/site/tmpl/location/default.php index 9530029..bd41e58 100644 --- a/source/packages/com_mokosuitestorelocator/site/tmpl/location/default.php +++ b/source/packages/com_mokosuitestorelocator/site/tmpl/location/default.php @@ -50,6 +50,20 @@ $item = $this->item; country) : ?>
escape($item->country); ?> + + latitude && $item->longitude) : ?> +
+ + + +
+
diff --git a/source/packages/mod_mokosuitestorelocator_map/tmpl/default.php b/source/packages/mod_mokosuitestorelocator_map/tmpl/default.php index d05fa24..2fed686 100644 --- a/source/packages/mod_mokosuitestorelocator_map/tmpl/default.php +++ b/source/packages/mod_mokosuitestorelocator_map/tmpl/default.php @@ -73,6 +73,7 @@ document.addEventListener('DOMContentLoaded', function() { var popup = '' + esc(loc.title) + ''; if (loc.address) popup += '
' + esc(loc.address); if (loc.phone) popup += '
' + esc(loc.phone) + ''; + popup += '
'; marker.bindPopup(popup); bounds.extend([loc.lat, loc.lng]); }); diff --git a/source/packages/mod_mokosuitestorelocator_search/tmpl/default.php b/source/packages/mod_mokosuitestorelocator_search/tmpl/default.php index 3e21765..4434ad6 100644 --- a/source/packages/mod_mokosuitestorelocator_search/tmpl/default.php +++ b/source/packages/mod_mokosuitestorelocator_search/tmpl/default.php @@ -72,6 +72,7 @@ $moduleId = $displayData['module']->id; +
- website) : ?> + website && preg_match('#^https?://#i', $item->website)) : ?>
:
+ phone); ?> + escape($item->phone); ?>
diff --git a/source/packages/com_mokosuitestorelocator/site/tmpl/locations/default.php b/source/packages/com_mokosuitestorelocator/site/tmpl/locations/default.php index 2732468..e5e21dd 100644 --- a/source/packages/com_mokosuitestorelocator/site/tmpl/locations/default.php +++ b/source/packages/com_mokosuitestorelocator/site/tmpl/locations/default.php @@ -54,7 +54,8 @@ use Joomla\CMS\Router\Route; phone) : ?>