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..58585f0
--- /dev/null
+++ b/source/packages/com_mokosuitestorelocator/admin/src/Controller/ImportController.php
@@ -0,0 +1,87 @@
+getIdentity()->authorise('core.create', 'com_mokosuitestorelocator'))
+ {
+ $this->setMessage(Text::_('JLIB_APPLICATION_ERROR_CREATE_RECORD_NOT_PERMITTED'), 'error');
+ $this->setRedirect(Route::_('index.php?option=com_mokosuitestorelocator&view=locations', false));
+
+ return;
+ }
+
+ /** @var \Moko\Component\MokoSuiteStoreLocator\Administrator\Model\ImportModel $model */
+ $model = $this->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;
+ }
+
+ // Validate file extension
+ $ext = strtolower(pathinfo($csvFile['name'], PATHINFO_EXTENSION));
+
+ if ($ext !== 'csv' && $ext !== 'txt')
+ {
+ $this->setMessage(Text::_('COM_MOKOJOOMSTORELOCATOR_IMPORT_INVALID_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..e859cb1
--- /dev/null
+++ b/source/packages/com_mokosuitestorelocator/admin/src/Model/ImportModel.php
@@ -0,0 +1,214 @@
+ '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 (strip UTF-8 BOM from Excel exports)
+ $headers = $file->fgetcsv();
+
+ if (!empty($headers[0]))
+ {
+ $headers[0] = ltrim($headers[0], "\xEF\xBB\xBF");
+ }
+
+ 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..aed8fff 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,97 @@ 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 = isset($data['latitude'], $data['longitude'])
+ && is_numeric($data['latitude']) && is_numeric($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 (isset($results[0]['lat']) && is_numeric($results[0]['lat'])
+ && isset($results[0]['lon']) && is_numeric($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 @@
+
+
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 @@
+
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..6d44b90 100644
--- a/source/packages/com_mokosuitestorelocator/site/src/Model/LocationsModel.php
+++ b/source/packages/com_mokosuitestorelocator/site/src/Model/LocationsModel.php
@@ -67,6 +67,38 @@ class LocationsModel extends ListModel
$state = $app->input->getString('state', '');
$this->setState('filter.state', $state);
+ $latRaw = $app->input->getString('lat', '');
+ $lngRaw = $app->input->getString('lng', '');
+ $radius = $app->input->getInt('radius', 0);
+ $radiusUnit = $app->input->getString('radius_unit', 'miles');
+
+ if ($latRaw !== '' && is_numeric($latRaw))
+ {
+ $lat = (float) $latRaw;
+
+ if ($lat >= -90 && $lat <= 90)
+ {
+ $this->setState('filter.lat', $lat);
+ }
+ }
+
+ if ($lngRaw !== '' && is_numeric($lngRaw))
+ {
+ $lng = (float) $lngRaw;
+
+ if ($lng >= -180 && $lng <= 180)
+ {
+ $this->setState('filter.lng', $lng);
+ }
+ }
+
+ if ($radius > 0 && $radius <= 25000)
+ {
+ $this->setState('filter.radius', $radius);
+ }
+
+ $this->setState('filter.radius_unit', in_array($radiusUnit, ['miles', 'km']) ? $radiusUnit : 'miles');
+
parent::populateState($ordering, $direction);
}
@@ -122,10 +154,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 !== null && $lng !== null && $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..fa9d171 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) : ?>
+
+
- website) : ?>
+ website && preg_match('#^https?://#i', $item->website)) : ?>
:
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) : ?>
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;
+