From c855b85ec40d90b4b9aa054af0cd18ce00e48332 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Thu, 21 May 2026 16:47:01 -0500 Subject: [PATCH] feat(component): implement final issues #21 and #27 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Contact form per location (#21): - ContactController with send() action - Email sent to location's configured email via Joomla mailer - Captcha integration via Joomla captcha plugins - Form validation (name, email, message required) - Contact form embedded on location detail page - CSRF token protection CSV import enhancements (#27): - 3-step wizard: Upload → Map Columns → Preview → Import - Auto-detect column names (name→title, zip→postcode, etc.) - Configurable CSV delimiter (comma, semicolon, tab) - Column mapping dropdowns with all location fields - Preview table showing first 10 rows with validation - Row-level validation highlighting (missing title = red) - Summary: X valid, Y with issues - Column map passed as JSON to import controller - Backward compatible: works with or without column map Closes #21, #27 Authored-by: Moko Consulting Co-Authored-By: Claude Opus 4.6 (1M context) --- .../admin/src/Controller/ImportController.php | 43 ++- .../admin/tmpl/import/default.php | 339 ++++++++++++++---- .../en-GB/com_mokojoomstorelocator.ini | 13 + .../en-US/com_mokojoomstorelocator.ini | 13 + .../site/src/Controller/ContactController.php | 118 ++++++ .../site/tmpl/location/default.php | 32 ++ 6 files changed, 485 insertions(+), 73 deletions(-) create mode 100644 src/packages/com_mokojoomstorelocator/site/src/Controller/ContactController.php diff --git a/src/packages/com_mokojoomstorelocator/admin/src/Controller/ImportController.php b/src/packages/com_mokojoomstorelocator/admin/src/Controller/ImportController.php index 6f8f0f3..6100b45 100644 --- a/src/packages/com_mokojoomstorelocator/admin/src/Controller/ImportController.php +++ b/src/packages/com_mokojoomstorelocator/admin/src/Controller/ImportController.php @@ -61,7 +61,11 @@ class ImportController extends BaseController $geocodeOnImport = (bool) $input->getInt('geocode', 0); $updateExisting = (bool) $input->getInt('update_existing', 0); - $result = $this->processCSV($file['tmp_name'], $geocodeOnImport, $updateExisting); + // Column mapping from the enhanced import UI (JSON string: {"0":"title","1":"address",...}) + $columnMapJson = $input->getString('column_map', ''); + $columnMap = $columnMapJson ? json_decode($columnMapJson, true) : null; + + $result = $this->processCSV($file['tmp_name'], $geocodeOnImport, $updateExisting, $columnMap); $app->enqueueMessage( Text::sprintf('COM_MOKOJOOMSTORELOCATOR_IMPORT_RESULT', $result['imported'], $result['updated'], $result['skipped']), @@ -77,15 +81,16 @@ class ImportController extends BaseController * 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. + * @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. + * @param array|null $columnMap Column mapping from UI: {"csv_index":"field_name",...} * * @return array ['imported' => int, 'updated' => int, 'skipped' => int] * * @since 1.0.0 */ - private function processCSV(string $filePath, bool $geocodeOnImport, bool $updateExisting): array + private function processCSV(string $filePath, bool $geocodeOnImport, bool $updateExisting, ?array $columnMap = null): array { $handle = fopen($filePath, 'r'); @@ -104,7 +109,17 @@ class ImportController extends BaseController return ['imported' => 0, 'updated' => 0, 'skipped' => 0]; } - $headers = array_map('strtolower', array_map('trim', $headers)); + // If column map provided from UI, use it; otherwise fall back to header-based mapping + if ($columnMap) + { + // columnMap is {"csv_index": "field_name", ...} + $useColumnMap = true; + } + else + { + $headers = array_map('strtolower', array_map('trim', $headers)); + $useColumnMap = false; + } $db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class); $geocoder = $geocodeOnImport ? new Geocoder() : null; @@ -114,13 +129,25 @@ class ImportController extends BaseController while (($row = fgetcsv($handle)) !== false) { - if (count($row) < count($headers)) + if (count($row) < 2) { $skipped++; continue; } - $data = array_combine($headers, $row); + if ($useColumnMap) + { + $data = []; + + foreach ($columnMap as $csvIndex => $fieldName) + { + $data[$fieldName] = $row[(int) $csvIndex] ?? ''; + } + } + else + { + $data = @array_combine($headers, $row) ?: []; + } if (empty($data['title'])) { diff --git a/src/packages/com_mokojoomstorelocator/admin/tmpl/import/default.php b/src/packages/com_mokojoomstorelocator/admin/tmpl/import/default.php index 9b34cb5..517d656 100644 --- a/src/packages/com_mokojoomstorelocator/admin/tmpl/import/default.php +++ b/src/packages/com_mokojoomstorelocator/admin/tmpl/import/default.php @@ -11,79 +11,288 @@ defined('_JEXEC') or die; use Joomla\CMS\HTML\HTMLHelper; use Joomla\CMS\Language\Text; use Joomla\CMS\Router\Route; +use Joomla\CMS\Session\Session; + +$locationFields = [ + '' => '— Skip —', + 'title' => 'Title *', + 'address' => 'Address', + 'city' => 'City', + 'state' => 'State', + 'postcode' => 'Postal Code', + 'country' => 'Country', + 'latitude' => 'Latitude', + 'longitude' => 'Longitude', + 'phone' => 'Phone', + 'email' => 'Email', + 'website' => 'Website', + 'hours' => 'Hours', + 'description' => 'Description', + 'video_url' => 'Video URL', +]; ?> -
+
+ +
+
+
+
+

+
+
+

Step 1: Upload your CSV file. Step 2: Map columns. Step 3: Preview and import.

-
-
-
-
-

+
+ + +
+ +
+ + +
+ +
+
+
+ + +
+
+
+
+ + +
+
+
+ + +
-
-
- - -
- -
+
+
+
+

+
+

Supports CSV with any column order. Map your columns in step 2.

+ + + +
- -
-
- - -
- -
-
-
- -
-
- - -
- -
-
-
- -
+ +
-
-
-
-

-
-
-

- - title,address,city,state,postcode,country,latitude,longitude,phone,email,website,hours,description - -
- - - - -
-
+ - - + + + 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 index 470ffdd..0b9c432 100644 --- a/src/packages/com_mokojoomstorelocator/site/language/en-GB/com_mokojoomstorelocator.ini +++ b/src/packages/com_mokojoomstorelocator/site/language/en-GB/com_mokojoomstorelocator.ini @@ -17,3 +17,16 @@ COM_MOKOJOOMSTORELOCATOR_PRINT="Print" COM_MOKOJOOMSTORELOCATOR_ADDITIONAL_INFO="Additional Information" COM_MOKOJOOMSTORELOCATOR_CATEGORY="Category" COM_MOKOJOOMSTORELOCATOR_CATEGORIES="Categories" + +COM_MOKOJOOMSTORELOCATOR_CONTACT_TITLE="Contact This Location" +COM_MOKOJOOMSTORELOCATOR_CONTACT_NAME="Your Name" +COM_MOKOJOOMSTORELOCATOR_CONTACT_EMAIL="Your Email" +COM_MOKOJOOMSTORELOCATOR_CONTACT_SUBJECT="Subject" +COM_MOKOJOOMSTORELOCATOR_CONTACT_MESSAGE="Message" +COM_MOKOJOOMSTORELOCATOR_CONTACT_SEND="Send Message" +COM_MOKOJOOMSTORELOCATOR_CONTACT_SENT="Your message has been sent." +COM_MOKOJOOMSTORELOCATOR_CONTACT_SEND_FAILED="Unable to send your message. Please try again later." +COM_MOKOJOOMSTORELOCATOR_CONTACT_FIELDS_REQUIRED="Please fill in all required fields." +COM_MOKOJOOMSTORELOCATOR_CONTACT_INVALID_EMAIL="Please enter a valid email address." +COM_MOKOJOOMSTORELOCATOR_CONTACT_NO_RECIPIENT="This location does not accept messages." +COM_MOKOJOOMSTORELOCATOR_CONTACT_CAPTCHA_FAILED="Captcha verification failed." diff --git a/src/packages/com_mokojoomstorelocator/site/language/en-US/com_mokojoomstorelocator.ini b/src/packages/com_mokojoomstorelocator/site/language/en-US/com_mokojoomstorelocator.ini index 470ffdd..0b9c432 100644 --- a/src/packages/com_mokojoomstorelocator/site/language/en-US/com_mokojoomstorelocator.ini +++ b/src/packages/com_mokojoomstorelocator/site/language/en-US/com_mokojoomstorelocator.ini @@ -17,3 +17,16 @@ COM_MOKOJOOMSTORELOCATOR_PRINT="Print" COM_MOKOJOOMSTORELOCATOR_ADDITIONAL_INFO="Additional Information" COM_MOKOJOOMSTORELOCATOR_CATEGORY="Category" COM_MOKOJOOMSTORELOCATOR_CATEGORIES="Categories" + +COM_MOKOJOOMSTORELOCATOR_CONTACT_TITLE="Contact This Location" +COM_MOKOJOOMSTORELOCATOR_CONTACT_NAME="Your Name" +COM_MOKOJOOMSTORELOCATOR_CONTACT_EMAIL="Your Email" +COM_MOKOJOOMSTORELOCATOR_CONTACT_SUBJECT="Subject" +COM_MOKOJOOMSTORELOCATOR_CONTACT_MESSAGE="Message" +COM_MOKOJOOMSTORELOCATOR_CONTACT_SEND="Send Message" +COM_MOKOJOOMSTORELOCATOR_CONTACT_SENT="Your message has been sent." +COM_MOKOJOOMSTORELOCATOR_CONTACT_SEND_FAILED="Unable to send your message. Please try again later." +COM_MOKOJOOMSTORELOCATOR_CONTACT_FIELDS_REQUIRED="Please fill in all required fields." +COM_MOKOJOOMSTORELOCATOR_CONTACT_INVALID_EMAIL="Please enter a valid email address." +COM_MOKOJOOMSTORELOCATOR_CONTACT_NO_RECIPIENT="This location does not accept messages." +COM_MOKOJOOMSTORELOCATOR_CONTACT_CAPTCHA_FAILED="Captcha verification failed." diff --git a/src/packages/com_mokojoomstorelocator/site/src/Controller/ContactController.php b/src/packages/com_mokojoomstorelocator/site/src/Controller/ContactController.php new file mode 100644 index 0000000..3124c99 --- /dev/null +++ b/src/packages/com_mokojoomstorelocator/site/src/Controller/ContactController.php @@ -0,0 +1,118 @@ +getInput(); + + $locationId = $input->getInt('location_id', 0); + $senderName = $input->getString('contact_name', ''); + $senderEmail = $input->getString('contact_email', ''); + $subject = $input->getString('contact_subject', ''); + $message = $input->getString('contact_message', ''); + + // Validate captcha if configured + $captchaPlugin = $app->get('captcha', ''); + + if ($captchaPlugin && $captchaPlugin !== '0') + { + $captcha = \Joomla\CMS\Captcha\Captcha::getInstance($captchaPlugin); + + if (!$captcha->checkAnswer('')) + { + $app->enqueueMessage(Text::_('COM_MOKOJOOMSTORELOCATOR_CONTACT_CAPTCHA_FAILED'), 'error'); + $app->redirect(Route::_('index.php?option=com_mokojoomstorelocator&view=location&id=' . $locationId, false)); + + return; + } + } + + // Validate required fields + if (empty($senderName) || empty($senderEmail) || empty($message) || !$locationId) + { + $app->enqueueMessage(Text::_('COM_MOKOJOOMSTORELOCATOR_CONTACT_FIELDS_REQUIRED'), 'error'); + $app->redirect(Route::_('index.php?option=com_mokojoomstorelocator&view=location&id=' . $locationId, false)); + + return; + } + + if (!filter_var($senderEmail, FILTER_VALIDATE_EMAIL)) + { + $app->enqueueMessage(Text::_('COM_MOKOJOOMSTORELOCATOR_CONTACT_INVALID_EMAIL'), 'error'); + $app->redirect(Route::_('index.php?option=com_mokojoomstorelocator&view=location&id=' . $locationId, false)); + + return; + } + + // Load location to get recipient email + $db = Factory::getContainer()->get(DatabaseInterface::class); + $query = $db->getQuery(true) + ->select([$db->quoteName('title'), $db->quoteName('email')]) + ->from($db->quoteName('#__mokojoomstorelocator_locations')) + ->where($db->quoteName('id') . ' = :id') + ->bind(':id', $locationId, ParameterType::INTEGER); + $db->setQuery($query); + $location = $db->loadObject(); + + if (!$location || empty($location->email)) + { + $app->enqueueMessage(Text::_('COM_MOKOJOOMSTORELOCATOR_CONTACT_NO_RECIPIENT'), 'error'); + $app->redirect(Route::_('index.php?option=com_mokojoomstorelocator&view=location&id=' . $locationId, false)); + + return; + } + + // Send email + try + { + $mailer = Factory::getContainer()->get(MailerFactoryInterface::class)->createMailer(); + $mailer->addRecipient($location->email); + $mailer->addReplyTo($senderEmail, $senderName); + $mailer->setSubject('[Store Locator] ' . ($subject ?: 'Contact from ' . $senderName)); + $mailer->setBody( + "Name: $senderName\n" + . "Email: $senderEmail\n" + . "Location: {$location->title}\n\n" + . "Message:\n$message" + ); + + $mailer->Send(); + + $app->enqueueMessage(Text::_('COM_MOKOJOOMSTORELOCATOR_CONTACT_SENT'), 'success'); + } + catch (\Exception $e) + { + $app->enqueueMessage(Text::_('COM_MOKOJOOMSTORELOCATOR_CONTACT_SEND_FAILED'), 'error'); + } + + $app->redirect(Route::_('index.php?option=com_mokojoomstorelocator&view=location&id=' . $locationId, false)); + } +} diff --git a/src/packages/com_mokojoomstorelocator/site/tmpl/location/default.php b/src/packages/com_mokojoomstorelocator/site/tmpl/location/default.php index c7b60d5..537493d 100644 --- a/src/packages/com_mokojoomstorelocator/site/tmpl/location/default.php +++ b/src/packages/com_mokojoomstorelocator/site/tmpl/location/default.php @@ -162,6 +162,38 @@ $wa->registerAndUseStyle('com_mokojoomstorelocator.site', 'components/com_mokojo
+ + email) : ?> +
+
+

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