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 @@ + +
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/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) : ?> +