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:
@@ -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 & 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 & 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>
|
||||
|
||||
+13
@@ -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."
|
||||
|
||||
+13
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user