feat(component): implement final issues #21 and #27

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) <noreply@anthropic.com>
This commit is contained in:
Jonathan Miller
2026-05-21 16:47:01 -05:00
parent a7145dc108
commit c855b85ec4
6 changed files with 485 additions and 73 deletions
@@ -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']))
{
@@ -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',
];
?>
<form action="<?php echo Route::_('index.php?option=com_mokojoomstorelocator&task=import.execute'); ?>"
method="post" name="adminForm" id="adminForm" enctype="multipart/form-data">
<div id="import-step1">
<form id="importUploadForm" enctype="multipart/form-data">
<div class="row">
<div class="col-lg-8">
<div class="card">
<div class="card-header">
<h3 class="card-title"><?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_IMPORT_TITLE'); ?></h3>
</div>
<div class="card-body">
<p>Step 1: Upload your CSV file. Step 2: Map columns. Step 3: Preview and import.</p>
<div class="row">
<div class="col-lg-8">
<div class="card">
<div class="card-header">
<h3 class="card-title"><?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_IMPORT_TITLE'); ?></h3>
<div class="mb-3">
<label for="import_file" class="form-label"><?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_IMPORT_FILE'); ?></label>
<input type="file" class="form-control" id="import_file" name="import_file" accept=".csv" required />
</div>
<div class="mb-3">
<label class="form-label">CSV Delimiter</label>
<select id="csv_delimiter" class="form-select" style="width:auto;">
<option value="," selected>Comma (,)</option>
<option value=";">Semicolon (;)</option>
<option value=" ">Tab</option>
</select>
</div>
<div class="row mb-3">
<div class="col-auto">
<div class="form-check">
<input type="checkbox" class="form-check-input" id="geocode" name="geocode" value="1" />
<label class="form-check-label" for="geocode"><?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_IMPORT_GEOCODE'); ?></label>
</div>
</div>
<div class="col-auto">
<div class="form-check">
<input type="checkbox" class="form-check-input" id="update_existing" name="update_existing" value="1" />
<label class="form-check-label" for="update_existing"><?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_IMPORT_UPDATE_EXISTING'); ?></label>
</div>
</div>
</div>
<button type="button" id="parseBtn" class="btn btn-outline-primary">
<span class="icon-arrow-right" aria-hidden="true"></span> Parse &amp; Map Columns
</button>
</div>
</div>
<div class="card-body">
<div class="mb-3">
<label for="import_file" class="form-label">
<?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_IMPORT_FILE'); ?>
</label>
<input type="file" class="form-control" id="import_file" name="import_file" accept=".csv" required />
<div class="form-text">
<?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_IMPORT_FILE_DESC'); ?>
</div>
</div>
<div class="col-lg-4">
<div class="card">
<div class="card-header"><h4 class="card-title"><?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_IMPORT_FORMAT'); ?></h4></div>
<div class="card-body">
<p>Supports CSV with any column order. Map your columns in step 2.</p>
<a href="<?php echo Route::_('index.php?option=com_mokojoomstorelocator&task=sampledata.download&' . Session::getFormToken() . '=1'); ?>"
class="btn btn-sm btn-outline-secondary">
<span class="icon-download" aria-hidden="true"></span>
<?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_IMPORT_DOWNLOAD_TEMPLATE'); ?>
</a>
</div>
<div class="mb-3">
<div class="form-check">
<input type="checkbox" class="form-check-input" id="geocode" name="geocode" value="1" />
<label class="form-check-label" for="geocode">
<?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_IMPORT_GEOCODE'); ?>
</label>
<div class="form-text">
<?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_IMPORT_GEOCODE_DESC'); ?>
</div>
</div>
</div>
<div class="mb-3">
<div class="form-check">
<input type="checkbox" class="form-check-input" id="update_existing" name="update_existing" value="1" />
<label class="form-check-label" for="update_existing">
<?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_IMPORT_UPDATE_EXISTING'); ?>
</label>
<div class="form-text">
<?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_IMPORT_UPDATE_EXISTING_DESC'); ?>
</div>
</div>
</div>
<button type="submit" class="btn btn-primary">
<span class="icon-upload" aria-hidden="true"></span>
<?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_IMPORT_SUBMIT'); ?>
</button>
</div>
</div>
</div>
</form>
</div>
<div class="col-lg-4">
<div class="card">
<div class="card-header">
<h4 class="card-title"><?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_IMPORT_FORMAT'); ?></h4>
</div>
<div class="card-body">
<p><?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_IMPORT_FORMAT_DESC'); ?></p>
<code class="d-block p-2 bg-light rounded" style="font-size: 11px; overflow-x: auto;">
title,address,city,state,postcode,country,latitude,longitude,phone,email,website,hours,description
</code>
<hr>
<a href="<?php echo Route::_('index.php?option=com_mokojoomstorelocator&task=sampledata.download&' . Session::getFormToken() . '=1'); ?>"
class="btn btn-sm btn-outline-secondary">
<span class="icon-download" aria-hidden="true"></span>
<?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_IMPORT_DOWNLOAD_TEMPLATE'); ?>
</a>
</div>
</div>
<div id="import-step2" style="display:none;">
<div class="card mb-3">
<div class="card-header"><h3 class="card-title">Step 2: Map Columns</h3></div>
<div class="card-body">
<p>Match each CSV column to a location field. Columns that don't match will be skipped.</p>
<table class="table table-sm" id="columnMapTable">
<thead><tr><th>CSV Column</th><th>Sample Data</th><th>Maps To</th></tr></thead>
<tbody></tbody>
</table>
<button type="button" id="previewBtn" class="btn btn-outline-primary">
<span class="icon-eye" aria-hidden="true"></span> Preview Import
</button>
<button type="button" class="btn btn-outline-secondary ms-2" onclick="document.getElementById('import-step1').style.display='';document.getElementById('import-step2').style.display='none';">
Back
</button>
</div>
</div>
</div>
<?php echo HTMLHelper::_('form.token'); ?>
</form>
<div id="import-step3" style="display:none;">
<div class="card mb-3">
<div class="card-header"><h3 class="card-title">Step 3: Preview &amp; Import</h3></div>
<div class="card-body">
<div id="previewSummary" class="alert alert-info mb-3"></div>
<div style="max-height:400px;overflow:auto;">
<table class="table table-sm table-striped" id="previewTable">
<thead></thead>
<tbody></tbody>
</table>
</div>
<form action="<?php echo Route::_('index.php?option=com_mokojoomstorelocator&task=import.execute'); ?>"
method="post" id="importExecuteForm" enctype="multipart/form-data">
<input type="hidden" name="import_file" id="importFileHidden" />
<input type="hidden" name="column_map" id="columnMapHidden" />
<input type="hidden" name="geocode" id="geocodeHidden" value="0" />
<input type="hidden" name="update_existing" id="updateHidden" value="0" />
<?php echo HTMLHelper::_('form.token'); ?>
<button type="submit" class="btn btn-primary mt-3">
<span class="icon-upload" aria-hidden="true"></span>
<?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_IMPORT_SUBMIT'); ?>
</button>
<button type="button" class="btn btn-outline-secondary ms-2 mt-3" onclick="document.getElementById('import-step2').style.display='';document.getElementById('import-step3').style.display='none';">
Back to Mapping
</button>
</form>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
var parsedHeaders = [];
var parsedRows = [];
var fieldOptions = <?php echo json_encode($locationFields); ?>;
// Auto-detect common column name mappings
var autoMap = {
'name': 'title', 'store': 'title', 'location': 'title', 'title': 'title',
'address': 'address', 'street': 'address', 'addr': 'address',
'city': 'city', 'town': 'city',
'state': 'state', 'province': 'state', 'region': 'state',
'zip': 'postcode', 'zipcode': 'postcode', 'postal': 'postcode', 'postcode': 'postcode',
'country': 'country',
'lat': 'latitude', 'latitude': 'latitude',
'lng': 'longitude', 'lon': 'longitude', 'longitude': 'longitude',
'phone': 'phone', 'tel': 'phone', 'telephone': 'phone',
'email': 'email', 'mail': 'email',
'website': 'website', 'url': 'website', 'web': 'website',
'hours': 'hours', 'opening': 'hours',
'description': 'description', 'desc': 'description', 'about': 'description',
'video': 'video_url'
};
document.getElementById('parseBtn').addEventListener('click', function() {
var fileInput = document.getElementById('import_file');
if (!fileInput.files.length) { alert('Please select a CSV file.'); return; }
var reader = new FileReader();
reader.onload = function(e) {
var delimiter = document.getElementById('csv_delimiter').value;
var lines = e.target.result.split('\n').filter(function(l) { return l.trim(); });
if (lines.length < 2) { alert('CSV must have a header row and at least one data row.'); return; }
parsedHeaders = parseCSVLine(lines[0], delimiter);
parsedRows = [];
for (var i = 1; i < Math.min(lines.length, 11); i++) {
parsedRows.push(parseCSVLine(lines[i], delimiter));
}
// Build column mapping table
var tbody = document.querySelector('#columnMapTable tbody');
tbody.textContent = '';
parsedHeaders.forEach(function(header, idx) {
var tr = document.createElement('tr');
var tdHeader = document.createElement('td');
tdHeader.textContent = header;
var tdSample = document.createElement('td');
tdSample.textContent = (parsedRows[0] && parsedRows[0][idx]) || '';
tdSample.style.color = '#6b7280';
var tdSelect = document.createElement('td');
var select = document.createElement('select');
select.className = 'form-select form-select-sm column-map-select';
select.dataset.index = idx;
Object.keys(fieldOptions).forEach(function(val) {
var opt = document.createElement('option');
opt.value = val;
opt.textContent = fieldOptions[val];
// Auto-detect
var norm = header.toLowerCase().replace(/[^a-z]/g, '');
if (autoMap[norm] === val) opt.selected = true;
select.appendChild(opt);
});
tdSelect.appendChild(select);
tr.appendChild(tdHeader);
tr.appendChild(tdSample);
tr.appendChild(tdSelect);
tbody.appendChild(tr);
});
document.getElementById('import-step1').style.display = 'none';
document.getElementById('import-step2').style.display = '';
};
reader.readAsText(fileInput.files[0]);
});
document.getElementById('previewBtn').addEventListener('click', function() {
var mapping = {};
document.querySelectorAll('.column-map-select').forEach(function(sel) {
if (sel.value) mapping[sel.dataset.index] = sel.value;
});
var mappedFields = Object.values(mapping);
if (!mappedFields.includes('title')) { alert('You must map at least the Title column.'); return; }
// Build preview table
var thead = document.querySelector('#previewTable thead');
var tbody = document.querySelector('#previewTable tbody');
thead.textContent = '';
tbody.textContent = '';
var headerRow = document.createElement('tr');
mappedFields.forEach(function(f) {
var th = document.createElement('th');
th.textContent = fieldOptions[f] || f;
headerRow.appendChild(th);
});
thead.appendChild(headerRow);
var valid = 0, warnings = 0;
parsedRows.forEach(function(row) {
var tr = document.createElement('tr');
var hasTitle = false;
Object.keys(mapping).forEach(function(idx) {
var td = document.createElement('td');
var val = row[parseInt(idx)] || '';
td.textContent = val;
if (mapping[idx] === 'title' && val) hasTitle = true;
if (mapping[idx] === 'title' && !val) { td.style.background = '#fee2e2'; }
tr.appendChild(td);
});
if (hasTitle) valid++; else warnings++;
tbody.appendChild(tr);
});
var summary = document.getElementById('previewSummary');
summary.textContent = 'Showing first ' + parsedRows.length + ' rows. ' + valid + ' valid, ' + warnings + ' with issues.';
document.getElementById('columnMapHidden').value = JSON.stringify(mapping);
document.getElementById('geocodeHidden').value = document.getElementById('geocode').checked ? '1' : '0';
document.getElementById('updateHidden').value = document.getElementById('update_existing').checked ? '1' : '0';
document.getElementById('import-step2').style.display = 'none';
document.getElementById('import-step3').style.display = '';
});
// Reattach file to the execute form
document.getElementById('importExecuteForm').addEventListener('submit', function(e) {
var fileInput = document.getElementById('import_file');
if (fileInput.files.length) {
var clone = fileInput.cloneNode(true);
clone.style.display = 'none';
this.appendChild(clone);
}
});
function parseCSVLine(line, delimiter) {
var result = [];
var current = '';
var inQuotes = false;
for (var i = 0; i < line.length; i++) {
var c = line[i];
if (c === '"') { inQuotes = !inQuotes; }
else if (c === delimiter && !inQuotes) { result.push(current.trim()); current = ''; }
else { current += c; }
}
result.push(current.trim());
return result;
}
});
</script>
@@ -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."
@@ -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."
@@ -0,0 +1,118 @@
<?php
/**
* @package MokoJoomStoreLocator
* @subpackage com_mokojoomstorelocator
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoJoomStoreLocator\Site\Controller;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Mail\MailerFactoryInterface;
use Joomla\CMS\MVC\Controller\BaseController;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Session\Session;
use Joomla\Database\DatabaseInterface;
use Joomla\Database\ParameterType;
/**
* Contact form controller — sends email to a location's configured email address.
*
* @since 1.0.0
*/
class ContactController extends BaseController
{
public function send(): void
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
$app = Factory::getApplication();
$input = $app->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));
}
}
@@ -162,6 +162,38 @@ $wa->registerAndUseStyle('com_mokojoomstorelocator.site', 'components/com_mokojo
</div>
</div>
<?php endif; ?>
<?php if ($item->email) : ?>
<div class="mokojoomstorelocator-contact card mb-3">
<div class="card-body">
<h4 class="card-title"><?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_CONTACT_TITLE'); ?></h4>
<form action="<?php echo Route::_('index.php?option=com_mokojoomstorelocator&task=contact.send'); ?>"
method="post" class="mokojoomstorelocator-contact-form">
<div class="mb-2">
<input type="text" name="contact_name" class="form-control" required
placeholder="<?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_CONTACT_NAME'); ?>" />
</div>
<div class="mb-2">
<input type="email" name="contact_email" class="form-control" required
placeholder="<?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_CONTACT_EMAIL'); ?>" />
</div>
<div class="mb-2">
<input type="text" name="contact_subject" class="form-control"
placeholder="<?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_CONTACT_SUBJECT'); ?>" />
</div>
<div class="mb-2">
<textarea name="contact_message" class="form-control" rows="4" required
placeholder="<?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_CONTACT_MESSAGE'); ?>"></textarea>
</div>
<input type="hidden" name="location_id" value="<?php echo (int) $item->id; ?>" />
<?php echo \Joomla\CMS\HTML\HTMLHelper::_('form.token'); ?>
<button type="submit" class="btn btn-primary">
<?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_CONTACT_SEND'); ?>
</button>
</form>
</div>
</div>
<?php endif; ?>
</div>
<div class="col-lg-5">