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/language/en-GB/com_mokojoomstorelocator.ini b/src/packages/com_mokojoomstorelocator/admin/language/en-GB/com_mokojoomstorelocator.ini index f58dd4f..60c4891 100644 --- a/src/packages/com_mokojoomstorelocator/admin/language/en-GB/com_mokojoomstorelocator.ini +++ b/src/packages/com_mokojoomstorelocator/admin/language/en-GB/com_mokojoomstorelocator.ini @@ -28,3 +28,36 @@ COM_MOKOJOOMSTORELOCATOR_FIELD_PHONE="Phone" COM_MOKOJOOMSTORELOCATOR_FIELD_WEBSITE="Website" COM_MOKOJOOMSTORELOCATOR_FIELD_HOURS="Business Hours" COM_MOKOJOOMSTORELOCATOR_FIELD_IMAGE="Location Image" + +COM_MOKOJOOMSTORELOCATOR_FIELDSET_GEOCODING="Geocoding" +COM_MOKOJOOMSTORELOCATOR_FIELD_GEOCODER_PROVIDER="Geocoding Provider" +COM_MOKOJOOMSTORELOCATOR_FIELD_GOOGLE_API_KEY="Google API Key" +COM_MOKOJOOMSTORELOCATOR_FIELD_GOOGLE_API_KEY_DESC="Required for Google Geocoding and Google Maps. Get one at console.cloud.google.com" +COM_MOKOJOOMSTORELOCATOR_FIELD_AUTO_GEOCODE="Auto-Geocode on Save" +COM_MOKOJOOMSTORELOCATOR_FIELD_AUTO_GEOCODE_DESC="Automatically convert addresses to coordinates when saving a location." + +COM_MOKOJOOMSTORELOCATOR_IMPORT="Import" +COM_MOKOJOOMSTORELOCATOR_IMPORT_TITLE="Import Locations from CSV" +COM_MOKOJOOMSTORELOCATOR_IMPORT_FILE="CSV File" +COM_MOKOJOOMSTORELOCATOR_IMPORT_FILE_DESC="Upload a CSV file with location data. First row must be column headers." +COM_MOKOJOOMSTORELOCATOR_IMPORT_NO_FILE="No file was uploaded." +COM_MOKOJOOMSTORELOCATOR_IMPORT_INVALID_FORMAT="Invalid file format. Only CSV files are accepted." +COM_MOKOJOOMSTORELOCATOR_IMPORT_GEOCODE="Geocode missing coordinates" +COM_MOKOJOOMSTORELOCATOR_IMPORT_GEOCODE_DESC="Auto-fill latitude/longitude for locations without coordinates. Uses your configured geocoding provider." +COM_MOKOJOOMSTORELOCATOR_IMPORT_UPDATE_EXISTING="Update existing locations" +COM_MOKOJOOMSTORELOCATOR_IMPORT_UPDATE_EXISTING_DESC="Match by title and update existing records instead of creating duplicates." +COM_MOKOJOOMSTORELOCATOR_IMPORT_SUBMIT="Import Locations" +COM_MOKOJOOMSTORELOCATOR_IMPORT_FORMAT="CSV Format" +COM_MOKOJOOMSTORELOCATOR_IMPORT_FORMAT_DESC="The CSV file must have a header row. Supported columns:" +COM_MOKOJOOMSTORELOCATOR_IMPORT_DOWNLOAD_TEMPLATE="Download CSV Template" +COM_MOKOJOOMSTORELOCATOR_IMPORT_RESULT="Import complete: %d imported, %d updated, %d skipped." + +COM_MOKOJOOMSTORELOCATOR_EXPORT="Export" +COM_MOKOJOOMSTORELOCATOR_EXPORT_CSV="Export to CSV" + +COM_MOKOJOOMSTORELOCATOR_SAMPLEDATA="Sample Data" +COM_MOKOJOOMSTORELOCATOR_SAMPLEDATA_INJECT="Install Sample Data" +COM_MOKOJOOMSTORELOCATOR_SAMPLEDATA_INJECT_CONFIRM="This will add 8 sample store locations to your database. Continue?" +COM_MOKOJOOMSTORELOCATOR_SAMPLEDATA_INJECTED="%d sample locations installed successfully." + +COM_MOKOJOOMSTORELOCATOR_GET_DIRECTIONS="Get Directions" diff --git a/src/packages/com_mokojoomstorelocator/admin/src/Controller/ExportController.php b/src/packages/com_mokojoomstorelocator/admin/src/Controller/ExportController.php new file mode 100644 index 0000000..79b67dd --- /dev/null +++ b/src/packages/com_mokojoomstorelocator/admin/src/Controller/ExportController.php @@ -0,0 +1,103 @@ +get(\Joomla\Database\DatabaseInterface::class); + $query = $db->getQuery(true); + + $query->select('a.*') + ->from($db->quoteName('#__mokojoomstorelocator_locations', 'a')) + ->order($db->quoteName('a.title') . ' ASC'); + + // Apply filters from request + $app = Factory::getApplication(); + $published = $app->getInput()->getInt('filter_published', null); + + if ($published !== null) + { + $query->where($db->quoteName('a.published') . ' = :published') + ->bind(':published', $published, \Joomla\Database\ParameterType::INTEGER); + } + + $city = $app->getInput()->getString('filter_city', ''); + + if ($city) + { + $query->where($db->quoteName('a.city') . ' = :city') + ->bind(':city', $city); + } + + $db->setQuery($query); + $locations = $db->loadObjectList(); + + // CSV columns + $columns = [ + 'id', 'title', 'alias', 'description', 'address', 'city', 'state', + 'postcode', 'country', 'latitude', 'longitude', 'phone', 'email', + 'website', 'hours', 'published', + ]; + + // Output CSV + $app = Factory::getApplication(); + $app->setHeader('Content-Type', 'text/csv; charset=utf-8'); + $app->setHeader('Content-Disposition', 'attachment; filename="store-locations-' . date('Y-m-d') . '.csv"'); + $app->setHeader('Cache-Control', 'no-cache, must-revalidate'); + $app->sendHeaders(); + + $output = fopen('php://output', 'w'); + + // BOM for Excel UTF-8 compatibility + fwrite($output, "\xEF\xBB\xBF"); + + // Header row + fputcsv($output, $columns); + + // Data rows + foreach ($locations as $location) + { + $row = []; + + foreach ($columns as $col) + { + $row[] = $location->$col ?? ''; + } + + fputcsv($output, $row); + } + + fclose($output); + + $app->close(); + } +} diff --git a/src/packages/com_mokojoomstorelocator/admin/src/Controller/ImportController.php b/src/packages/com_mokojoomstorelocator/admin/src/Controller/ImportController.php new file mode 100644 index 0000000..6f8f0f3 --- /dev/null +++ b/src/packages/com_mokojoomstorelocator/admin/src/Controller/ImportController.php @@ -0,0 +1,221 @@ +getInput(); + $file = $input->files->get('import_file', [], 'array'); + + if (empty($file['tmp_name']) || $file['error'] !== UPLOAD_ERR_OK) + { + $app->enqueueMessage(Text::_('COM_MOKOJOOMSTORELOCATOR_IMPORT_NO_FILE'), 'error'); + $app->redirect(Route::_('index.php?option=com_mokojoomstorelocator&view=import', false)); + + return; + } + + $ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION)); + + if ($ext !== 'csv') + { + $app->enqueueMessage(Text::_('COM_MOKOJOOMSTORELOCATOR_IMPORT_INVALID_FORMAT'), 'error'); + $app->redirect(Route::_('index.php?option=com_mokojoomstorelocator&view=import', false)); + + return; + } + + $geocodeOnImport = (bool) $input->getInt('geocode', 0); + $updateExisting = (bool) $input->getInt('update_existing', 0); + + $result = $this->processCSV($file['tmp_name'], $geocodeOnImport, $updateExisting); + + $app->enqueueMessage( + Text::sprintf('COM_MOKOJOOMSTORELOCATOR_IMPORT_RESULT', $result['imported'], $result['updated'], $result['skipped']), + 'success' + ); + + $app->redirect(Route::_('index.php?option=com_mokojoomstorelocator&view=locations', false)); + } + + /** + * Parse and import a CSV file. + * + * Expected columns: title, address, city, state, postcode, country, latitude, longitude, + * phone, email, website, hours, description + * + * @param string $filePath Path to the CSV file. + * @param bool $geocodeOnImport Whether to geocode missing coordinates. + * @param bool $updateExisting Whether to update existing records by title match. + * + * @return array ['imported' => int, 'updated' => int, 'skipped' => int] + * + * @since 1.0.0 + */ + private function processCSV(string $filePath, bool $geocodeOnImport, bool $updateExisting): array + { + $handle = fopen($filePath, 'r'); + + if (!$handle) + { + return ['imported' => 0, 'updated' => 0, 'skipped' => 0]; + } + + // Read header row + $headers = fgetcsv($handle); + + if (!$headers) + { + fclose($handle); + + return ['imported' => 0, 'updated' => 0, 'skipped' => 0]; + } + + $headers = array_map('strtolower', array_map('trim', $headers)); + + $db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class); + $geocoder = $geocodeOnImport ? new Geocoder() : null; + $imported = 0; + $updated = 0; + $skipped = 0; + + while (($row = fgetcsv($handle)) !== false) + { + if (count($row) < count($headers)) + { + $skipped++; + continue; + } + + $data = array_combine($headers, $row); + + if (empty($data['title'])) + { + $skipped++; + continue; + } + + // Check for existing record + $existingId = null; + + if ($updateExisting) + { + $query = $db->getQuery(true) + ->select('id') + ->from($db->quoteName('#__mokojoomstorelocator_locations')) + ->where($db->quoteName('title') . ' = :title') + ->bind(':title', $data['title']); + $db->setQuery($query); + $existingId = $db->loadResult(); + } + + // Geocode if needed + if ($geocoder && empty($data['latitude']) && empty($data['longitude'])) + { + $coords = $geocoder->geocode( + $data['address'] ?? '', + $data['city'] ?? '', + $data['state'] ?? '', + $data['postcode'] ?? '', + $data['country'] ?? '' + ); + + if ($coords) + { + $data['latitude'] = $coords['lat']; + $data['longitude'] = $coords['lng']; + } + } + + // Build table record + $table = $this->getModel('Location')->getTable(); + + if ($existingId) + { + $table->load($existingId); + } + + $locationData = [ + 'title' => $data['title'] ?? '', + 'address' => $data['address'] ?? '', + 'city' => $data['city'] ?? '', + 'state' => $data['state'] ?? '', + 'postcode' => $data['postcode'] ?? '', + 'country' => $data['country'] ?? '', + 'latitude' => !empty($data['latitude']) ? (float) $data['latitude'] : null, + 'longitude' => !empty($data['longitude']) ? (float) $data['longitude'] : null, + 'phone' => $data['phone'] ?? '', + 'email' => $data['email'] ?? '', + 'website' => $data['website'] ?? '', + 'hours' => $data['hours'] ?? '', + 'description' => $data['description'] ?? '', + 'published' => 1, + ]; + + if (!$table->bind($locationData)) + { + $skipped++; + continue; + } + + if (!$table->check()) + { + $skipped++; + continue; + } + + if (!$table->store()) + { + Log::add('Import failed for: ' . $data['title'] . ' — ' . $table->getError(), Log::WARNING, 'com_mokojoomstorelocator'); + $skipped++; + continue; + } + + if ($existingId) + { + $updated++; + } + else + { + $imported++; + } + } + + fclose($handle); + + return ['imported' => $imported, 'updated' => $updated, 'skipped' => $skipped]; + } +} 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/Controller/SampledataController.php b/src/packages/com_mokojoomstorelocator/admin/src/Controller/SampledataController.php new file mode 100644 index 0000000..1aedb44 --- /dev/null +++ b/src/packages/com_mokojoomstorelocator/admin/src/Controller/SampledataController.php @@ -0,0 +1,269 @@ +setHeader('Content-Type', 'text/csv; charset=utf-8'); + $app->setHeader('Content-Disposition', 'attachment; filename="store-locations-template.csv"'); + $app->sendHeaders(); + + $output = fopen('php://output', 'w'); + fwrite($output, "\xEF\xBB\xBF"); + + $headers = ['title', 'address', 'city', 'state', 'postcode', 'country', 'latitude', 'longitude', 'phone', 'email', 'website', 'hours', 'description']; + fputcsv($output, $headers); + + fputcsv($output, [ + 'Moko HQ', + '123 Main Street', + 'Nashville', + 'TN', + '37201', + 'US', + '36.1627', + '-86.7816', + '(615) 555-0100', + 'hello@example.com', + 'https://example.com', + 'Mon-Fri 9am-5pm', + 'Our main office location.', + ]); + + fclose($output); + $app->close(); + } + + /** + * Inject sample location data into the database for testing. + * + * @return void + * + * @since 1.0.0 + */ + public function inject(): void + { + Session::checkToken() or die(Text::_('JINVALID_TOKEN')); + + $app = Factory::getApplication(); + $db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class); + $now = Factory::getDate()->toSql(); + $uid = Factory::getApplication()->getIdentity()->id ?? 0; + + $samples = [ + [ + 'title' => 'Downtown Nashville Store', + 'alias' => 'downtown-nashville-store', + 'description' => 'Our flagship location in the heart of downtown Nashville.', + 'address' => '200 Broadway', + 'city' => 'Nashville', + 'state' => 'TN', + 'postcode' => '37201', + 'country' => 'US', + 'latitude' => 36.1622, + 'longitude' => -86.7744, + 'phone' => '(615) 555-0101', + 'email' => 'downtown@example.com', + 'website' => 'https://example.com/downtown', + 'hours' => "Mon-Fri: 9am-7pm\nSat: 10am-6pm\nSun: 12pm-5pm", + ], + [ + 'title' => 'East Nashville Location', + 'alias' => 'east-nashville-location', + 'description' => 'Serving the East Nashville community with friendly service.', + 'address' => '1000 Main St', + 'city' => 'Nashville', + 'state' => 'TN', + 'postcode' => '37206', + 'country' => 'US', + 'latitude' => 36.1781, + 'longitude' => -86.7534, + 'phone' => '(615) 555-0102', + 'email' => 'east@example.com', + 'website' => 'https://example.com/east', + 'hours' => "Mon-Sat: 10am-8pm\nSun: Closed", + ], + [ + 'title' => 'Franklin Square', + 'alias' => 'franklin-square', + 'description' => 'Conveniently located in the Franklin town square.', + 'address' => '400 Main St', + 'city' => 'Franklin', + 'state' => 'TN', + 'postcode' => '37064', + 'country' => 'US', + 'latitude' => 35.9251, + 'longitude' => -86.8689, + 'phone' => '(615) 555-0103', + 'email' => 'franklin@example.com', + 'website' => 'https://example.com/franklin', + 'hours' => "Mon-Fri: 8am-6pm\nSat-Sun: 10am-4pm", + ], + [ + 'title' => 'Murfreesboro Plaza', + 'alias' => 'murfreesboro-plaza', + 'description' => 'Our newest location serving Rutherford County.', + 'address' => '1720 Old Fort Pkwy', + 'city' => 'Murfreesboro', + 'state' => 'TN', + 'postcode' => '37129', + 'country' => 'US', + 'latitude' => 35.8353, + 'longitude' => -86.4160, + 'phone' => '(615) 555-0104', + 'email' => 'murfreesboro@example.com', + 'website' => 'https://example.com/murfreesboro', + 'hours' => "Mon-Sat: 9am-9pm\nSun: 11am-6pm", + ], + [ + 'title' => 'Clarksville Center', + 'alias' => 'clarksville-center', + 'description' => 'Serving the Clarksville-Montgomery County area.', + 'address' => '2801 Wilma Rudolph Blvd', + 'city' => 'Clarksville', + 'state' => 'TN', + 'postcode' => '37040', + 'country' => 'US', + 'latitude' => 36.5843, + 'longitude' => -87.3199, + 'phone' => '(931) 555-0105', + 'email' => 'clarksville@example.com', + 'website' => 'https://example.com/clarksville', + 'hours' => "Mon-Fri: 9am-7pm\nSat: 10am-5pm\nSun: Closed", + ], + [ + 'title' => 'Chattanooga Riverfront', + 'alias' => 'chattanooga-riverfront', + 'description' => 'Located near the Tennessee Aquarium on the riverfront.', + 'address' => '1 Broad St', + 'city' => 'Chattanooga', + 'state' => 'TN', + 'postcode' => '37402', + 'country' => 'US', + 'latitude' => 35.0557, + 'longitude' => -85.3097, + 'phone' => '(423) 555-0106', + 'email' => 'chattanooga@example.com', + 'website' => 'https://example.com/chattanooga', + 'hours' => "Daily: 10am-8pm", + ], + [ + 'title' => 'Knoxville Market Square', + 'alias' => 'knoxville-market-square', + 'description' => 'In the heart of downtown Knoxville at Market Square.', + 'address' => '36 Market Square', + 'city' => 'Knoxville', + 'state' => 'TN', + 'postcode' => '37902', + 'country' => 'US', + 'latitude' => 35.9643, + 'longitude' => -83.9198, + 'phone' => '(865) 555-0107', + 'email' => 'knoxville@example.com', + 'website' => 'https://example.com/knoxville', + 'hours' => "Mon-Sat: 9am-8pm\nSun: 12pm-6pm", + ], + [ + 'title' => 'Memphis Beale Street', + 'alias' => 'memphis-beale-street', + 'description' => 'Right on iconic Beale Street in downtown Memphis.', + 'address' => '152 Beale St', + 'city' => 'Memphis', + 'state' => 'TN', + 'postcode' => '38103', + 'country' => 'US', + 'latitude' => 35.1393, + 'longitude' => -90.0530, + 'phone' => '(901) 555-0108', + 'email' => 'memphis@example.com', + 'website' => 'https://example.com/memphis', + 'hours' => "Mon-Thu: 10am-10pm\nFri-Sat: 10am-12am\nSun: 11am-8pm", + ], + ]; + + $inserted = 0; + + foreach ($samples as $sample) + { + $sample['published'] = 1; + $sample['ordering'] = $inserted + 1; + $sample['params'] = '{}'; + $sample['image'] = ''; + $sample['catid'] = 0; + $sample['created'] = $now; + $sample['created_by'] = $uid; + $sample['modified'] = $now; + $sample['modified_by'] = $uid; + + $query = $db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName('#__mokojoomstorelocator_locations')) + ->where($db->quoteName('alias') . ' = :alias') + ->bind(':alias', $sample['alias']); + $db->setQuery($query); + + if ($db->loadResult() > 0) + { + continue; + } + + $query = $db->getQuery(true) + ->insert($db->quoteName('#__mokojoomstorelocator_locations')) + ->columns($db->quoteName(array_keys($sample))) + ->values(implode(',', array_map(function ($v) use ($db) { + return $db->quote($v); + }, array_values($sample)))); + + $db->setQuery($query); + + try + { + $db->execute(); + $inserted++; + } + catch (\Exception $e) + { + Log::add('Sample data insert failed: ' . $e->getMessage(), Log::WARNING, 'com_mokojoomstorelocator'); + } + } + + $app->enqueueMessage( + Text::sprintf('COM_MOKOJOOMSTORELOCATOR_SAMPLEDATA_INJECTED', $inserted), + 'success' + ); + + $app->redirect(Route::_('index.php?option=com_mokojoomstorelocator&view=locations', false)); + } +} 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/Helper/Geocoder.php b/src/packages/com_mokojoomstorelocator/admin/src/Helper/Geocoder.php new file mode 100644 index 0000000..accef5e --- /dev/null +++ b/src/packages/com_mokojoomstorelocator/admin/src/Helper/Geocoder.php @@ -0,0 +1,227 @@ +provider = $params->get('geocoder_provider', 'nominatim'); + $this->apiKey = $params->get('google_api_key', ''); + } + + /** + * Geocode an address string to coordinates. + * + * @param string $address Street address. + * @param string $city City. + * @param string $state State/province. + * @param string $postcode Postal code. + * @param string $country Country. + * + * @return array|null ['lat' => float, 'lng' => float] or null on failure. + * + * @since 1.0.0 + */ + public function geocode(string $address = '', string $city = '', string $state = '', string $postcode = '', string $country = ''): ?array + { + $parts = array_filter([$address, $city, $state, $postcode, $country]); + $query = implode(', ', $parts); + + if (empty($query)) + { + return null; + } + + if ($this->provider === 'google' && $this->apiKey) + { + return $this->geocodeGoogle($query); + } + + return $this->geocodeNominatim($query); + } + + /** + * Batch geocode multiple locations. + * + * @param array $locations Array of arrays with address fields. + * + * @return array Array of results indexed by input key: ['lat' => float, 'lng' => float] or null. + * + * @since 1.0.0 + */ + public function batchGeocode(array $locations): array + { + $results = []; + + foreach ($locations as $key => $loc) + { + $results[$key] = $this->geocode( + $loc['address'] ?? '', + $loc['city'] ?? '', + $loc['state'] ?? '', + $loc['postcode'] ?? '', + $loc['country'] ?? '' + ); + + // Nominatim rate limit: max 1 request per second + if ($this->provider === 'nominatim') + { + usleep(1100000); + } + } + + return $results; + } + + /** + * Geocode using OpenStreetMap Nominatim (free, no API key). + * + * @param string $query The full address string. + * + * @return array|null Coordinates or null. + * + * @since 1.0.0 + */ + private function geocodeNominatim(string $query): ?array + { + $url = 'https://nominatim.openstreetmap.org/search?' . http_build_query([ + 'q' => $query, + 'format' => 'json', + 'limit' => 1, + ]); + + $result = $this->httpGet($url, [ + 'User-Agent' => 'MokoJoomStoreLocator/1.0 (Joomla component)', + 'Accept' => 'application/json', + ]); + + if ($result === null || empty($result)) + { + return null; + } + + $first = $result[0] ?? null; + + if (!$first || !isset($first['lat'], $first['lon'])) + { + return null; + } + + return [ + 'lat' => (float) $first['lat'], + 'lng' => (float) $first['lon'], + ]; + } + + /** + * Geocode using Google Geocoding API. + * + * @param string $query The full address string. + * + * @return array|null Coordinates or null. + * + * @since 1.0.0 + */ + private function geocodeGoogle(string $query): ?array + { + $url = 'https://maps.googleapis.com/maps/api/geocode/json?' . http_build_query([ + 'address' => $query, + 'key' => $this->apiKey, + ]); + + $result = $this->httpGet($url); + + if ($result === null || ($result['status'] ?? '') !== 'OK') + { + $status = $result['status'] ?? 'unknown'; + Log::add("Geocoder: Google returned status $status for: $query", Log::WARNING, 'com_mokojoomstorelocator'); + + return null; + } + + $location = $result['results'][0]['geometry']['location'] ?? null; + + if (!$location) + { + return null; + } + + return [ + 'lat' => (float) $location['lat'], + 'lng' => (float) $location['lng'], + ]; + } + + /** + * Perform an HTTP GET request and return decoded JSON. + * + * @param string $url The URL to fetch. + * @param array $headers Additional headers. + * + * @return array|null Decoded JSON or null on error. + * + * @since 1.0.0 + */ + private function httpGet(string $url, array $headers = []): ?array + { + try + { + $http = HttpFactory::getHttp(); + $response = $http->get($url, $headers, 15); + + if ($response->code !== 200) + { + Log::add("Geocoder: HTTP {$response->code} from $url", Log::WARNING, 'com_mokojoomstorelocator'); + + return null; + } + + return json_decode($response->body, true); + } + catch (\Exception $e) + { + Log::add('Geocoder: ' . $e->getMessage(), Log::ERROR, 'com_mokojoomstorelocator'); + + return null; + } + } +} diff --git a/src/packages/com_mokojoomstorelocator/admin/src/Model/LocationsModel.php b/src/packages/com_mokojoomstorelocator/admin/src/Model/LocationsModel.php index ee3312a..d660c57 100644 --- a/src/packages/com_mokojoomstorelocator/admin/src/Model/LocationsModel.php +++ b/src/packages/com_mokojoomstorelocator/admin/src/Model/LocationsModel.php @@ -59,10 +59,46 @@ class LocationsModel extends ListModel $query->select('a.*') ->from($db->quoteName('#__mokojoomstorelocator_locations', 'a')); - // TODO: Add filter by published state - // TODO: Add filter by category - // TODO: Add search filter - // TODO: Add ordering clause + $published = $this->getState('filter.published'); + + if (is_numeric($published)) + { + $query->where($db->quoteName('a.published') . ' = :published') + ->bind(':published', $published, \Joomla\Database\ParameterType::INTEGER); + } + elseif ($published === '') + { + $query->where($db->quoteName('a.published') . ' IN (0, 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); + } + + $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/src/packages/com_mokojoomstorelocator/admin/src/Table/LocationTable.php b/src/packages/com_mokojoomstorelocator/admin/src/Table/LocationTable.php index 10d81b2..a0f5fd6 100644 --- a/src/packages/com_mokojoomstorelocator/admin/src/Table/LocationTable.php +++ b/src/packages/com_mokojoomstorelocator/admin/src/Table/LocationTable.php @@ -43,10 +43,69 @@ class LocationTable extends Table */ public function check(): bool { - // TODO: Validate title is not empty - // TODO: Auto-generate alias from title if empty - // TODO: Validate latitude/longitude ranges - // TODO: Set created/modified timestamps + if (empty($this->title)) + { + $this->setError('A location title is required.'); + + return false; + } + + if (empty($this->alias)) + { + $this->alias = $this->title; + } + + $this->alias = \Joomla\CMS\Filter\OutputFilter::stringURLSafe($this->alias); + + if (empty($this->alias)) + { + $this->alias = \Joomla\CMS\Factory::getDate()->format('Y-m-d-H-i-s'); + } + + if ($this->latitude !== null && ($this->latitude < -90 || $this->latitude > 90)) + { + $this->setError('Latitude must be between -90 and 90.'); + + return false; + } + + if ($this->longitude !== null && ($this->longitude < -180 || $this->longitude > 180)) + { + $this->setError('Longitude must be between -180 and 180.'); + + return false; + } + + $now = \Joomla\CMS\Factory::getDate()->toSql(); + + if (empty($this->created) || $this->created === '0000-00-00 00:00:00') + { + $this->created = $now; + $this->created_by = \Joomla\CMS\Factory::getApplication()->getIdentity()->id ?? 0; + } + + $this->modified = $now; + $this->modified_by = \Joomla\CMS\Factory::getApplication()->getIdentity()->id ?? 0; + + // Auto-geocode if address present but coordinates missing + if ((empty($this->latitude) || empty($this->longitude)) + && (!empty($this->address) || !empty($this->city))) + { + $geocoder = new \Moko\Component\MokoJoomStoreLocator\Administrator\Helper\Geocoder(); + $coords = $geocoder->geocode( + $this->address ?? '', + $this->city ?? '', + $this->state ?? '', + $this->postcode ?? '', + $this->country ?? '' + ); + + if ($coords) + { + $this->latitude = $coords['lat']; + $this->longitude = $coords['lng']; + } + } return parent::check(); } diff --git a/src/packages/com_mokojoomstorelocator/admin/src/View/Import/HtmlView.php b/src/packages/com_mokojoomstorelocator/admin/src/View/Import/HtmlView.php new file mode 100644 index 0000000..e2c51e7 --- /dev/null +++ b/src/packages/com_mokojoomstorelocator/admin/src/View/Import/HtmlView.php @@ -0,0 +1,38 @@ +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/src/View/Locations/HtmlView.php b/src/packages/com_mokojoomstorelocator/admin/src/View/Locations/HtmlView.php index 2fd41e5..18db727 100644 --- a/src/packages/com_mokojoomstorelocator/admin/src/View/Locations/HtmlView.php +++ b/src/packages/com_mokojoomstorelocator/admin/src/View/Locations/HtmlView.php @@ -11,6 +11,8 @@ namespace Moko\Component\MokoJoomStoreLocator\Administrator\View\Locations; defined('_JEXEC') or die; use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView; +use Joomla\CMS\Session\Session; +use Joomla\CMS\Toolbar\Toolbar; use Joomla\CMS\Toolbar\ToolbarHelper; /** @@ -78,5 +80,15 @@ class HtmlView extends BaseHtmlView ToolbarHelper::publish('locations.publish', 'JTOOLBAR_PUBLISH', true); ToolbarHelper::unpublish('locations.unpublish', 'JTOOLBAR_UNPUBLISH', true); ToolbarHelper::deleteList('', 'locations.delete', 'JTOOLBAR_DELETE'); + + $toolbar = Toolbar::getInstance('toolbar'); + + $exportUrl = 'index.php?option=com_mokojoomstorelocator&task=export.execute&' . Session::getFormToken() . '=1'; + $toolbar->standardButton('download', 'COM_MOKOJOOMSTORELOCATOR_EXPORT_CSV', '')->icon('icon-download')->url($exportUrl); + + $sampleUrl = 'index.php?option=com_mokojoomstorelocator&task=sampledata.inject&' . Session::getFormToken() . '=1'; + $toolbar->standardButton('lightning', 'COM_MOKOJOOMSTORELOCATOR_SAMPLEDATA_INJECT', '')->icon('icon-lightning')->url($sampleUrl); + + ToolbarHelper::preferences('com_mokojoomstorelocator'); } } diff --git a/src/packages/com_mokojoomstorelocator/admin/tmpl/import/default.php b/src/packages/com_mokojoomstorelocator/admin/tmpl/import/default.php new file mode 100644 index 0000000..9b34cb5 --- /dev/null +++ b/src/packages/com_mokojoomstorelocator/admin/tmpl/import/default.php @@ -0,0 +1,89 @@ + +
+ +
+
+
+
+

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

+
+
+

+ + title,address,city,state,postcode,country,latitude,longitude,phone,email,website,hours,description + +
+ + + + +
+
+
+
+ + +
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'); +?> +
+ + 'details', 'recall' => true, 'breakpoint' => 768]); ?> + + +
+
+ form->renderField('title'); ?> + form->renderField('alias'); ?> + form->renderField('description'); ?> +
+
+ form->renderField('published'); ?> + form->renderField('id'); ?> +
+
+ + + +
+
+ form->renderField('address'); ?> + form->renderField('city'); ?> + form->renderField('state'); ?> + form->renderField('postcode'); ?> + form->renderField('country'); ?> +
+
+
+
+

+ Click the map to set coordinates +
+
+
+
+
+ form->renderField('latitude'); ?> +
+
+ form->renderField('longitude'); ?> +
+
+
+
+
+
+ + + +
+
+ form->renderField('phone'); ?> + form->renderField('email'); ?> + form->renderField('website'); ?> +
+
+ form->renderField('hours'); ?> +
+
+ + + + form->renderField('image'); ?> + + + + + + +
+ + + + diff --git a/src/packages/com_mokojoomstorelocator/admin/tmpl/locations/default.php b/src/packages/com_mokojoomstorelocator/admin/tmpl/locations/default.php index ab535a8..bf98c9a 100644 --- a/src/packages/com_mokojoomstorelocator/admin/tmpl/locations/default.php +++ b/src/packages/com_mokojoomstorelocator/admin/tmpl/locations/default.php @@ -54,7 +54,31 @@ use Joomla\CMS\Router\Route; - + items as $i => $item) : ?> + + + id, false, 'cid', 'cb', $item->title); ?> + + + + escape($item->title); ?> + +
escape($item->alias); ?>
+ + + escape($item->city); ?> + + + escape($item->state); ?> + + + published, $i, 'locations.', true, 'cb'); ?> + + + id; ?> + + + diff --git a/src/packages/com_mokojoomstorelocator/mokojoomstorelocator.xml b/src/packages/com_mokojoomstorelocator/mokojoomstorelocator.xml index d1396e4..b9cf1e8 100644 --- a/src/packages/com_mokojoomstorelocator/mokojoomstorelocator.xml +++ b/src/packages/com_mokojoomstorelocator/mokojoomstorelocator.xml @@ -57,6 +57,43 @@ COM_MOKOJOOMSTORELOCATOR COM_MOKOJOOMSTORELOCATOR_LOCATIONS + COM_MOKOJOOMSTORELOCATOR_IMPORT + + + +
+ + + + + + + + + + + +
+
+
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; +?> +
+

escape($item->title); ?>

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

+ + address || $item->city || $item->state) : ?> +
+ :
+ address) : ?> + escape($item->address); ?>
+ + city) : ?> + escape($item->city); ?>, + + state) : ?> + escape($item->state); ?> + + postcode) : ?> + escape($item->postcode); ?> + + country) : ?> +
escape($item->country); ?> + +
+ + + phone) : ?> + + + + email) : ?> + + + + website) : ?> + + + + hours) : ?> +
+ :
+ escape($item->hours)); ?> +
+ +
+
+ + latitude && $item->longitude) : ?> +
+ + + +
+ +
+ +
+ latitude && $item->longitude) : ?> +
+ + + + + +
+
+ +
+ + ← + +
+
diff --git a/src/packages/com_mokojoomstorelocator/site/tmpl/locations/default.php b/src/packages/com_mokojoomstorelocator/site/tmpl/locations/default.php new file mode 100644 index 0000000..0d447c9 --- /dev/null +++ b/src/packages/com_mokojoomstorelocator/site/tmpl/locations/default.php @@ -0,0 +1,96 @@ + +
+

+ + items)) : ?> +

+ +
+ items as $item) : ?> +
+

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

+ +
+ address) : ?> + escape($item->address); ?>
+ + city) : ?> + escape($item->city); ?>, + + state) : ?> + escape($item->state); ?> + + postcode) : ?> + escape($item->postcode); ?> + +
+ + phone) : ?> + + + + website) : ?> + + + + hours) : ?> +
+ escape($item->hours)); ?> +
+ + + image) : ?> +
+ <?php echo $this->escape($item->title); ?> +
+ + + distance)) : ?> +
+ distance, 1); ?> away +
+ + + latitude && $item->longitude) : ?> +
+ + + +
+ +
+ +
+ + pagination->getListFooter(); ?> + +
diff --git a/src/packages/mod_mokojoomstorelocator_map/src/Dispatcher/Dispatcher.php b/src/packages/mod_mokojoomstorelocator_map/src/Dispatcher/Dispatcher.php index 910ef89..f19a3c5 100644 --- a/src/packages/mod_mokojoomstorelocator_map/src/Dispatcher/Dispatcher.php +++ b/src/packages/mod_mokojoomstorelocator_map/src/Dispatcher/Dispatcher.php @@ -34,9 +34,43 @@ class Dispatcher extends AbstractModuleDispatcher implements HelperFactoryAwareI { $data = parent::getLayoutData(); - // TODO: Load published locations with coordinates from the component table - // TODO: Build marker data array for the map JS - $data['locations'] = []; + $db = \Joomla\CMS\Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class); + $query = $db->getQuery(true); + + $query->select([ + $db->quoteName('id'), + $db->quoteName('title'), + $db->quoteName('address'), + $db->quoteName('city'), + $db->quoteName('state'), + $db->quoteName('postcode'), + $db->quoteName('phone'), + $db->quoteName('latitude'), + $db->quoteName('longitude'), + ]) + ->from($db->quoteName('#__mokojoomstorelocator_locations')) + ->where($db->quoteName('published') . ' = 1') + ->where($db->quoteName('latitude') . ' IS NOT NULL') + ->where($db->quoteName('longitude') . ' IS NOT NULL'); + + $db->setQuery($query); + $locations = $db->loadObjectList(); + + $markers = []; + + foreach ($locations as $loc) + { + $markers[] = [ + 'id' => (int) $loc->id, + 'title' => $loc->title, + 'address' => trim($loc->address . ', ' . $loc->city . ', ' . $loc->state . ' ' . $loc->postcode, ', '), + 'phone' => $loc->phone, + 'lat' => (float) $loc->latitude, + 'lng' => (float) $loc->longitude, + ]; + } + + $data['locations'] = $markers; return $data; } diff --git a/src/packages/mod_mokojoomstorelocator_map/tmpl/default.php b/src/packages/mod_mokojoomstorelocator_map/tmpl/default.php index 4137389..69314bc 100644 --- a/src/packages/mod_mokojoomstorelocator_map/tmpl/default.php +++ b/src/packages/mod_mokojoomstorelocator_map/tmpl/default.php @@ -16,15 +16,159 @@ $locations = $displayData['locations'] ?? []; $mapHeight = $params->get('map_height', '400px'); $mapZoom = (int) $params->get('map_zoom', 10); $provider = $params->get('map_provider', 'leaflet'); +$apiKey = $params->get('api_key', ''); +$moduleId = $displayData['module']->id; ?>
-
+ + + + + + + + + + + + + + + + + diff --git a/src/packages/mod_mokojoomstorelocator_search/language/en-GB/mod_mokojoomstorelocator_search.ini b/src/packages/mod_mokojoomstorelocator_search/language/en-GB/mod_mokojoomstorelocator_search.ini index 7d0a8c3..5c551b1 100644 --- a/src/packages/mod_mokojoomstorelocator_search/language/en-GB/mod_mokojoomstorelocator_search.ini +++ b/src/packages/mod_mokojoomstorelocator_search/language/en-GB/mod_mokojoomstorelocator_search.ini @@ -11,3 +11,11 @@ MOD_MOKOJOOMSTORELOCATOR_SEARCH_SHOW_RADIUS="Show Radius Filter" MOD_MOKOJOOMSTORELOCATOR_SEARCH_RADIUS_UNIT="Distance Unit" MOD_MOKOJOOMSTORELOCATOR_SEARCH_RADIUS_OPTIONS="Radius Options" MOD_MOKOJOOMSTORELOCATOR_SEARCH_RADIUS_OPTIONS_DESC="Comma-separated list of radius values (e.g., 5,10,25,50,100)" + +MOD_MOKOJOOMSTORELOCATOR_SEARCH_USE_LOCATION="Use My Location" +MOD_MOKOJOOMSTORELOCATOR_SEARCH_LOCATING="Finding your location..." +MOD_MOKOJOOMSTORELOCATOR_SEARCH_LOCATION_FOUND="Location found! Select a radius and search." +MOD_MOKOJOOMSTORELOCATOR_SEARCH_GEO_DENIED="Location access denied. Please enable location services." +MOD_MOKOJOOMSTORELOCATOR_SEARCH_GEO_UNAVAILABLE="Location information unavailable." +MOD_MOKOJOOMSTORELOCATOR_SEARCH_GEO_TIMEOUT="Location request timed out." +MOD_MOKOJOOMSTORELOCATOR_SEARCH_GEO_ERROR="Unable to determine your location." diff --git a/src/packages/mod_mokojoomstorelocator_search/src/Dispatcher/Dispatcher.php b/src/packages/mod_mokojoomstorelocator_search/src/Dispatcher/Dispatcher.php index 9c4a39e..6cf2095 100644 --- a/src/packages/mod_mokojoomstorelocator_search/src/Dispatcher/Dispatcher.php +++ b/src/packages/mod_mokojoomstorelocator_search/src/Dispatcher/Dispatcher.php @@ -34,8 +34,34 @@ class Dispatcher extends AbstractModuleDispatcher implements HelperFactoryAwareI { $data = parent::getLayoutData(); - // TODO: Load distinct cities/states for filter dropdowns - // TODO: Build radius options array from params + $db = \Joomla\CMS\Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class); + $table = $db->quoteName('#__mokojoomstorelocator_locations'); + + // Load distinct cities + $query = $db->getQuery(true) + ->select('DISTINCT ' . $db->quoteName('city')) + ->from($table) + ->where($db->quoteName('published') . ' = 1') + ->where($db->quoteName('city') . ' != ' . $db->quote('')) + ->order($db->quoteName('city') . ' ASC'); + $db->setQuery($query); + $data['cities'] = $db->loadColumn(); + + // Load distinct states + $query = $db->getQuery(true) + ->select('DISTINCT ' . $db->quoteName('state')) + ->from($table) + ->where($db->quoteName('published') . ' = 1') + ->where($db->quoteName('state') . ' != ' . $db->quote('')) + ->order($db->quoteName('state') . ' ASC'); + $db->setQuery($query); + $data['states'] = $db->loadColumn(); + + // Parse radius options from params + $params = $data['params']; + $radiusString = $params->get('radius_options', '5,10,25,50,100'); + $data['radius_options'] = array_map('intval', explode(',', $radiusString)); + $data['radius_unit'] = $params->get('radius_unit', 'miles'); return $data; } diff --git a/src/packages/mod_mokojoomstorelocator_search/tmpl/default.php b/src/packages/mod_mokojoomstorelocator_search/tmpl/default.php index 3a82d3b..70e91d2 100644 --- a/src/packages/mod_mokojoomstorelocator_search/tmpl/default.php +++ b/src/packages/mod_mokojoomstorelocator_search/tmpl/default.php @@ -12,32 +12,143 @@ use Joomla\CMS\Language\Text; use Joomla\CMS\Router\Route; /** @var array $displayData */ -$params = $displayData['params']; +$params = $displayData['params']; +$moduleId = $displayData['module']->id; ?> -