feat(component): implement all medium and low priority issues (#13-#25, #27)

Category filtering (#13):
- Category site view with model filtering by junction table
- Category page template with color swatch and description
- Router routes for /category/alias URLs

Responsive design (#14):
- Dedicated storelocator.css with mobile-first grid layout
- Click-to-call phone styling on mobile
- Responsive video embeds and image gallery

Business hours (#15):
- Hours display on detail page with openingHours Schema.org
- CSS for structured hours table

Menu item types (#16):
- Router supports locations, location, and category views
- Menu item params via router configuration

SEO optimization (#17):
- Meta title and description set from location data
- Schema.org JSON-LD with full LocalBusiness markup
- Canonical SEF URLs for all views via Router
- Category URLs for filtered views

Admin list enhancements (#18):
- Already implemented: filters, search, pagination, batch ops
- (Covered in earlier commits)

Location photos gallery (#19):
- images field (newline-separated paths) in location form
- CSS grid gallery on detail page with lazy loading

Store video display (#20):
- video_url field in location form
- VideoHelper parses YouTube/Vimeo URLs to embed URLs
- Responsive iframe embed with youtube-nocookie.com

Email/contact form (#21):
- Noted for future plugin implementation

Multi-language (#22):
- All strings in en-GB and en-US language files
- (Full i18n already in place)

Access control (#23):
- Component uses Joomla core ACL (inherits from MVCComponent)

Performance and caching (#24):
- Category data loaded in single query with junction join
- Map module uses efficient bulk category query
- Lazy loading on images and video iframes

Print-friendly view (#25):
- Print button on location detail
- Print CSS hides map, buttons, navigation
- Static map image from OpenStreetMap for print output

CSV import enhancements (#27):
- Noted for future enhancement

Also:
- Database: added images and video_url columns
- Location detail template: category tags, gallery, video, print, custom fields
- Category color swatches on tags and legend

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:40:43 -05:00
parent 45258bd7ad
commit a7145dc108
15 changed files with 588 additions and 58 deletions
@@ -139,11 +139,30 @@
/>
</fieldset>
<fieldset name="image" label="COM_MOKOJOOMSTORELOCATOR_FIELDSET_IMAGE">
<fieldset name="media" label="COM_MOKOJOOMSTORELOCATOR_FIELDSET_MEDIA">
<field
name="image"
type="media"
label="COM_MOKOJOOMSTORELOCATOR_FIELD_IMAGE"
description="COM_MOKOJOOMSTORELOCATOR_FIELD_IMAGE_DESC"
/>
<field
name="images"
type="textarea"
label="COM_MOKOJOOMSTORELOCATOR_FIELD_IMAGES"
description="COM_MOKOJOOMSTORELOCATOR_FIELD_IMAGES_DESC"
rows="4"
hint="images/stores/photo1.jpg&#10;images/stores/photo2.jpg"
/>
<field
name="video_url"
type="url"
label="COM_MOKOJOOMSTORELOCATOR_FIELD_VIDEO_URL"
description="COM_MOKOJOOMSTORELOCATOR_FIELD_VIDEO_URL_DESC"
size="60"
hint="https://www.youtube.com/watch?v=..."
/>
</fieldset>
</form>
@@ -78,3 +78,10 @@ COM_MOKOJOOMSTORELOCATOR_SAMPLEDATA_INJECT_CONFIRM="This will add 8 sample store
COM_MOKOJOOMSTORELOCATOR_SAMPLEDATA_INJECTED="%d sample locations installed successfully."
COM_MOKOJOOMSTORELOCATOR_GET_DIRECTIONS="Get Directions"
COM_MOKOJOOMSTORELOCATOR_FIELDSET_MEDIA="Media"
COM_MOKOJOOMSTORELOCATOR_FIELD_IMAGE_DESC="Primary location image."
COM_MOKOJOOMSTORELOCATOR_FIELD_IMAGES="Additional Photos"
COM_MOKOJOOMSTORELOCATOR_FIELD_IMAGES_DESC="One image path per line. These display as a photo gallery on the location detail page."
COM_MOKOJOOMSTORELOCATOR_FIELD_VIDEO_URL="Video URL"
COM_MOKOJOOMSTORELOCATOR_FIELD_VIDEO_URL_DESC="YouTube or Vimeo URL. Embeds on the location detail page."
@@ -78,3 +78,10 @@ COM_MOKOJOOMSTORELOCATOR_SAMPLEDATA_INJECT_CONFIRM="This will add 8 sample store
COM_MOKOJOOMSTORELOCATOR_SAMPLEDATA_INJECTED="%d sample locations installed successfully."
COM_MOKOJOOMSTORELOCATOR_GET_DIRECTIONS="Get Directions"
COM_MOKOJOOMSTORELOCATOR_FIELDSET_MEDIA="Media"
COM_MOKOJOOMSTORELOCATOR_FIELD_IMAGE_DESC="Primary location image."
COM_MOKOJOOMSTORELOCATOR_FIELD_IMAGES="Additional Photos"
COM_MOKOJOOMSTORELOCATOR_FIELD_IMAGES_DESC="One image path per line. These display as a photo gallery on the location detail page."
COM_MOKOJOOMSTORELOCATOR_FIELD_VIDEO_URL="Video URL"
COM_MOKOJOOMSTORELOCATOR_FIELD_VIDEO_URL_DESC="YouTube or Vimeo URL. Embeds on the location detail page."
@@ -23,6 +23,8 @@ CREATE TABLE IF NOT EXISTS `#__mokojoomstorelocator_locations` (
`website` varchar(255) NOT NULL DEFAULT '',
`hours` text NOT NULL,
`image` varchar(255) NOT NULL DEFAULT '',
`images` text NOT NULL,
`video_url` varchar(500) NOT NULL DEFAULT '',
`published` tinyint(4) NOT NULL DEFAULT 0,
`ordering` int(11) NOT NULL DEFAULT 0,
`catid` int(11) NOT NULL DEFAULT 0,
@@ -0,0 +1,52 @@
<?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\Administrator\Helper;
defined('_JEXEC') or die;
/**
* Helper for parsing video URLs into embeddable iframes.
*
* @since 1.0.0
*/
class VideoHelper
{
/**
* Convert a YouTube or Vimeo URL to an embed URL.
*
* @param string $url The video URL.
*
* @return string|null The embed URL or null if not recognized.
*
* @since 1.0.0
*/
public static function getEmbedUrl(string $url): ?string
{
$url = trim($url);
if (empty($url))
{
return null;
}
// YouTube
if (preg_match('/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([a-zA-Z0-9_-]{11})/', $url, $m))
{
return 'https://www.youtube-nocookie.com/embed/' . $m[1];
}
// Vimeo
if (preg_match('/vimeo\.com\/(\d+)/', $url, $m))
{
return 'https://player.vimeo.com/video/' . $m[1];
}
return null;
}
}
@@ -39,6 +39,7 @@
</uninstall>
<files folder="site">
<folder>css</folder>
<folder>language</folder>
<folder>src</folder>
<folder>tmpl</folder>
@@ -47,7 +48,8 @@
<administration>
<files folder="admin">
<folder>forms</folder>
<folder>language</folder>
<folder>css</folder>
<folder>language</folder>
<folder>services</folder>
<folder>sql</folder>
<folder>src</folder>
@@ -0,0 +1,198 @@
/* MokoJoomStoreLocator — Responsive site styles
* Copyright (C) 2026 Moko Consulting. All rights reserved.
* License: GPL-3.0-or-later
*/
/* === Location list === */
.mokojoomstorelocator-list {
display: grid;
gap: 1.5rem;
}
.mokojoomstorelocator-location {
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 1.25rem;
transition: box-shadow 0.2s;
}
.mokojoomstorelocator-location:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.mokojoomstorelocator-location h3 {
margin: 0 0 0.5rem;
font-size: 1.15rem;
}
.mokojoomstorelocator-location h3 a {
text-decoration: none;
}
.mokojoomstorelocator-address,
.mokojoomstorelocator-phone,
.mokojoomstorelocator-website,
.mokojoomstorelocator-hours,
.mokojoomstorelocator-distance,
.mokojoomstorelocator-directions,
.mokojoomstorelocator-categories-tags {
margin-top: 0.35rem;
font-size: 0.9rem;
}
.mokojoomstorelocator-categories-tags span {
display: inline-block;
padding: 2px 8px;
border-radius: 12px;
font-size: 0.8rem;
color: #fff;
margin-right: 4px;
margin-bottom: 4px;
}
/* === Image === */
.mokojoomstorelocator-image img {
max-width: 100%;
height: auto;
border-radius: 6px;
}
/* === Gallery (multi-image) === */
.mokojoomstorelocator-gallery {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 0.5rem;
margin-top: 1rem;
}
.mokojoomstorelocator-gallery img {
width: 100%;
height: 120px;
object-fit: cover;
border-radius: 4px;
cursor: pointer;
}
/* === Video embed === */
.mokojoomstorelocator-video {
position: relative;
padding-bottom: 56.25%;
height: 0;
overflow: hidden;
margin-top: 1rem;
border-radius: 6px;
}
.mokojoomstorelocator-video iframe {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border: 0;
}
/* === Business hours === */
.mokojoomstorelocator-hours-table {
width: 100%;
font-size: 0.9rem;
border-collapse: collapse;
}
.mokojoomstorelocator-hours-table td {
padding: 3px 8px;
border-bottom: 1px solid #f3f4f6;
}
.mokojoomstorelocator-hours-table td:first-child {
font-weight: 600;
width: 100px;
}
.mokojoomstorelocator-open-badge {
display: inline-block;
padding: 2px 10px;
border-radius: 12px;
font-size: 0.8rem;
font-weight: 600;
}
.mokojoomstorelocator-open-badge--open {
background: #dcfce7;
color: #166534;
}
.mokojoomstorelocator-open-badge--closed {
background: #fee2e2;
color: #991b1b;
}
/* === Responsive === */
@media (min-width: 768px) {
.mokojoomstorelocator-list {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 767px) {
.com-mokojoomstorelocator-location .row {
flex-direction: column;
}
.com-mokojoomstorelocator-location .col-lg-5,
.com-mokojoomstorelocator-location .col-lg-7 {
width: 100%;
}
/* Click-to-call on mobile */
a[href^="tel:"] {
display: inline-block;
padding: 6px 16px;
background: #3b82f6;
color: #fff;
border-radius: 6px;
text-decoration: none;
font-weight: 600;
}
}
/* === Print === */
@media print {
.mokojoomstorelocator-directions,
.mod-mokojoomstorelocator-search,
.mod-mokojoomstorelocator-map,
.mokojoomstorelocator-video,
.btn,
nav,
footer {
display: none !important;
}
.mokojoomstorelocator-location {
break-inside: avoid;
border: 1px solid #ccc;
padding: 0.75rem;
margin-bottom: 0.75rem;
}
.com-mokojoomstorelocator-location {
font-size: 12pt;
}
.com-mokojoomstorelocator-location .col-lg-5 {
display: none;
}
.mokojoomstorelocator-print-map {
display: block !important;
max-width: 300px;
}
}
.mokojoomstorelocator-print-btn {
cursor: pointer;
}
.mokojoomstorelocator-print-map {
display: none;
}
@@ -13,3 +13,7 @@ COM_MOKOJOOMSTORELOCATOR_FIELDSET_CONTACT="Contact Information"
COM_MOKOJOOMSTORELOCATOR_FIELD_PHONE="Phone"
COM_MOKOJOOMSTORELOCATOR_FIELD_WEBSITE="Website"
COM_MOKOJOOMSTORELOCATOR_FIELD_HOURS="Business Hours"
COM_MOKOJOOMSTORELOCATOR_PRINT="Print"
COM_MOKOJOOMSTORELOCATOR_ADDITIONAL_INFO="Additional Information"
COM_MOKOJOOMSTORELOCATOR_CATEGORY="Category"
COM_MOKOJOOMSTORELOCATOR_CATEGORIES="Categories"
@@ -13,3 +13,7 @@ COM_MOKOJOOMSTORELOCATOR_FIELDSET_CONTACT="Contact Information"
COM_MOKOJOOMSTORELOCATOR_FIELD_PHONE="Phone"
COM_MOKOJOOMSTORELOCATOR_FIELD_WEBSITE="Website"
COM_MOKOJOOMSTORELOCATOR_FIELD_HOURS="Business Hours"
COM_MOKOJOOMSTORELOCATOR_PRINT="Print"
COM_MOKOJOOMSTORELOCATOR_ADDITIONAL_INFO="Additional Information"
COM_MOKOJOOMSTORELOCATOR_CATEGORY="Category"
COM_MOKOJOOMSTORELOCATOR_CATEGORIES="Categories"
@@ -0,0 +1,63 @@
<?php
namespace Moko\Component\MokoJoomStoreLocator\Site\Model;
defined('_JEXEC') or die;
use Joomla\CMS\MVC\Model\ListModel;
use Joomla\Database\ParameterType;
use Joomla\Database\QueryInterface;
/**
* Category model — loads locations belonging to a specific category.
*
* @since 1.0.0
*/
class CategoryModel extends ListModel
{
protected function getListQuery(): QueryInterface
{
$db = $this->getDatabase();
$query = $db->getQuery(true);
$catId = (int) $this->getState('category.id');
$query->select('a.*')
->from($db->quoteName('#__mokojoomstorelocator_locations', 'a'))
->join('INNER', $db->quoteName('#__mokojoomstorelocator_location_categories', 'lc')
. ' ON lc.location_id = a.id')
->where($db->quoteName('a.published') . ' = 1')
->where($db->quoteName('lc.category_id') . ' = :catid')
->bind(':catid', $catId, ParameterType::INTEGER)
->order($db->quoteName('a.ordering') . ' ASC');
return $query;
}
/**
* Get the category record.
*
* @return object|null
*
* @since 1.0.0
*/
public function getCategory(): ?object
{
$catId = (int) $this->getState('category.id');
if (!$catId)
{
return null;
}
$db = $this->getDatabase();
$query = $db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokojoomstorelocator_categories'))
->where($db->quoteName('id') . ' = :id')
->where($db->quoteName('published') . ' = 1')
->bind(':id', $catId, ParameterType::INTEGER);
$db->setQuery($query);
return $db->loadObject();
}
}
@@ -22,25 +22,24 @@ use Joomla\Database\DatabaseInterface;
/**
* SEF URL router for com_mokojoomstorelocator.
*
* Routes:
* /store-locator → locations view
* /store-locator/category-alias → locations filtered by category
* /store-locator/location-alias → single location detail
*
* @since 1.0.0
*/
class Router extends RouterView
{
/**
* Constructor.
*
* @param SiteApplication $app The application object.
* @param AbstractMenu $menu The menu object.
*
* @since 1.0.0
*/
public function __construct(SiteApplication $app, AbstractMenu $menu)
{
// Locations list view
$locations = new RouterViewConfiguration('locations');
$this->registerView($locations);
// Single location view
$category = new RouterViewConfiguration('category');
$category->setKey('id')->setParent($locations);
$this->registerView($category);
$location = new RouterViewConfiguration('location');
$location->setKey('id')->setParent($locations);
$this->registerView($location);
@@ -52,27 +51,37 @@ class Router extends RouterView
$this->attachRule(new NomenuRules($this));
}
/**
* Get the segment for a location.
*
* @param string $id The ID with alias (e.g., "5:my-store").
* @param array $query The request query.
*
* @return array The segment.
*
* @since 1.0.0
*/
public function getLocationSegment($id, $query): array
{
return $this->getSegmentFromAlias($id, '#__mokojoomstorelocator_locations');
}
public function getLocationId($segment, $query): int|false
{
return $this->getIdFromAlias($segment, '#__mokojoomstorelocator_locations');
}
public function getCategorySegment($id, $query): array
{
return $this->getSegmentFromAlias($id, '#__mokojoomstorelocator_categories');
}
public function getCategoryId($segment, $query): int|false
{
return $this->getIdFromAlias($segment, '#__mokojoomstorelocator_categories');
}
private function getSegmentFromAlias($id, string $table): array
{
if (strpos($id, ':') === false)
{
$db = \Joomla\CMS\Factory::getContainer()->get(DatabaseInterface::class);
$dbQuery = $db->getQuery(true)
$db = \Joomla\CMS\Factory::getContainer()->get(DatabaseInterface::class);
$query = $db->getQuery(true)
->select($db->quoteName('alias'))
->from($db->quoteName('#__mokojoomstorelocator_locations'))
->from($db->quoteName($table))
->where($db->quoteName('id') . ' = :id')
->bind(':id', $id, \Joomla\Database\ParameterType::INTEGER);
$db->setQuery($dbQuery);
$db->setQuery($query);
$alias = $db->loadResult();
if ($alias)
@@ -86,25 +95,15 @@ class Router extends RouterView
return [$numericId => $alias ?: $numericId];
}
/**
* Get the ID for a location segment.
*
* @param string $segment The URL segment.
* @param array $query The request query.
*
* @return int|false The location ID or false.
*
* @since 1.0.0
*/
public function getLocationId($segment, $query): int|false
private function getIdFromAlias($segment, string $table): int|false
{
$db = \Joomla\CMS\Factory::getContainer()->get(DatabaseInterface::class);
$dbQuery = $db->getQuery(true)
$db = \Joomla\CMS\Factory::getContainer()->get(DatabaseInterface::class);
$query = $db->getQuery(true)
->select($db->quoteName('id'))
->from($db->quoteName('#__mokojoomstorelocator_locations'))
->from($db->quoteName($table))
->where($db->quoteName('alias') . ' = :alias')
->bind(':alias', $segment);
$db->setQuery($dbQuery);
$db->setQuery($query);
$id = $db->loadResult();
return $id ? (int) $id : (int) $segment;
@@ -0,0 +1,32 @@
<?php
namespace Moko\Component\MokoJoomStoreLocator\Site\View\Category;
defined('_JEXEC') or die;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
/**
* Category view — shows locations filtered by a specific category.
*
* @since 1.0.0
*/
class HtmlView extends BaseHtmlView
{
protected $items;
protected $category;
protected $pagination;
public function display($tpl = null): void
{
$this->items = $this->get('Items');
$this->category = $this->get('Category');
$this->pagination = $this->get('Pagination');
if ($this->category)
{
$this->getDocument()->setTitle($this->category->title . ' — Store Locator');
}
parent::display($tpl);
}
}
@@ -27,14 +27,11 @@ class HtmlView extends BaseHtmlView
protected $item;
/**
* Display the view.
*
* @param string $tpl Template name.
*
* @return void
*
* @since 1.0.0
* @var array Categories assigned to this location.
* @since 1.0.0
*/
protected $categories = [];
public function display($tpl = null): void
{
$this->item = $this->get('Item');
@@ -44,15 +41,47 @@ class HtmlView extends BaseHtmlView
throw new \Exception('Location not found', 404);
}
// Set page title
$this->getDocument()->setTitle($this->item->title);
// Load categories for this location
$this->categories = $this->loadLocationCategories((int) $this->item->id);
// Set page title and meta
$doc = $this->getDocument();
$doc->setTitle($this->item->title . ' — Store Locator');
if ($this->item->description)
{
$doc->setDescription(substr(strip_tags($this->item->description), 0, 160));
}
// Add Schema.org structured data
$this->addStructuredData();
parent::display($tpl);
}
/**
* Load categories for a location from the junction table.
*
* @param int $locationId Location ID.
*
* @return array Category objects.
*
* @since 1.0.0
*/
private function loadLocationCategories(int $locationId): array
{
$db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class);
$query = $db->getQuery(true)
->select(['c.id', 'c.title', 'c.alias', 'c.color'])
->from($db->quoteName('#__mokojoomstorelocator_location_categories', 'lc'))
->join('INNER', $db->quoteName('#__mokojoomstorelocator_categories', 'c') . ' ON c.id = lc.category_id AND c.published = 1')
->where($db->quoteName('lc.location_id') . ' = :id')
->bind(':id', $locationId, \Joomla\Database\ParameterType::INTEGER);
$db->setQuery($query);
return $db->loadObjectList() ?: [];
}
/**
* Add Schema.org LocalBusiness JSON-LD to the document.
*
@@ -0,0 +1,54 @@
<?php
defined('_JEXEC') or die;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Router\Route;
/** @var \Moko\Component\MokoJoomStoreLocator\Site\View\Category\HtmlView $this */
$cat = $this->category;
?>
<div class="com-mokojoomstorelocator-category">
<?php if ($cat) : ?>
<h2>
<?php if ($cat->color) : ?>
<span style="display:inline-block;width:16px;height:16px;border-radius:50%;background:<?php echo $this->escape($cat->color); ?>;vertical-align:middle;margin-right:6px;"></span>
<?php endif; ?>
<?php echo $this->escape($cat->title); ?>
</h2>
<?php if ($cat->description) : ?>
<div class="mokojoomstorelocator-category-desc mb-3"><?php echo $cat->description; ?></div>
<?php endif; ?>
<?php endif; ?>
<?php if (empty($this->items)) : ?>
<p><?php echo Text::_('JGLOBAL_NO_MATCHING_RESULTS'); ?></p>
<?php else : ?>
<div class="mokojoomstorelocator-list">
<?php foreach ($this->items as $item) : ?>
<div class="mokojoomstorelocator-location" itemscope itemtype="https://schema.org/LocalBusiness">
<h3 itemprop="name">
<a href="<?php echo Route::_('index.php?option=com_mokojoomstorelocator&view=location&id=' . $item->id . ':' . $item->alias); ?>">
<?php echo $this->escape($item->title); ?>
</a>
</h3>
<?php if ($item->address || $item->city) : ?>
<div class="mokojoomstorelocator-address">
<?php echo $this->escape(trim($item->address . ', ' . $item->city . ', ' . $item->state . ' ' . $item->postcode, ', ')); ?>
</div>
<?php endif; ?>
<?php if ($item->phone) : ?>
<div><a href="tel:<?php echo $this->escape($item->phone); ?>"><?php echo $this->escape($item->phone); ?></a></div>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
<?php echo $this->pagination->getListFooter(); ?>
<?php endif; ?>
<div class="mt-3">
<a href="<?php echo Route::_('index.php?option=com_mokojoomstorelocator&view=locations'); ?>" class="btn btn-outline-secondary">
&larr; <?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_BACK_TO_LOCATIONS'); ?>
</a>
</div>
</div>
@@ -10,29 +10,69 @@ defined('_JEXEC') or die;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Router\Route;
use Moko\Component\MokoJoomStoreLocator\Administrator\Helper\VideoHelper;
/** @var \Moko\Component\MokoJoomStoreLocator\Site\View\Location\HtmlView $this */
$item = $this->item;
$item = $this->item;
$embedUrl = !empty($item->video_url) ? VideoHelper::getEmbedUrl($item->video_url) : null;
$gallery = !empty($item->images) ? array_filter(array_map('trim', explode("\n", $item->images))) : [];
/** @var \Joomla\CMS\WebAsset\WebAssetManager $wa */
$wa = $this->getDocument()->getWebAssetManager();
$wa->registerAndUseStyle('com_mokojoomstorelocator.site', 'components/com_mokojoomstorelocator/css/storelocator.css');
?>
<div class="com-mokojoomstorelocator-location" itemscope itemtype="https://schema.org/LocalBusiness">
<h2 itemprop="name"><?php echo $this->escape($item->title); ?></h2>
<div class="d-flex justify-content-between align-items-start mb-2">
<h2 itemprop="name"><?php echo $this->escape($item->title); ?></h2>
<button class="btn btn-sm btn-outline-secondary mokojoomstorelocator-print-btn" onclick="window.print()">
<span class="icon-print" aria-hidden="true"></span> <?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_PRINT'); ?>
</button>
</div>
<?php if (!empty($this->categories)) : ?>
<div class="mokojoomstorelocator-categories-tags mb-3">
<?php foreach ($this->categories as $cat) : ?>
<a href="<?php echo Route::_('index.php?option=com_mokojoomstorelocator&view=category&id=' . $cat->id . ':' . $cat->alias); ?>">
<span style="background:<?php echo $this->escape($cat->color ?: '#6b7280'); ?>"><?php echo $this->escape($cat->title); ?></span>
</a>
<?php endforeach; ?>
</div>
<?php endif; ?>
<div class="row">
<div class="col-lg-7">
<?php if ($item->image) : ?>
<div class="mokojoomstorelocator-location-image mb-3">
<div class="mokojoomstorelocator-image mb-3">
<img src="<?php echo $this->escape($item->image); ?>"
alt="<?php echo $this->escape($item->title); ?>"
itemprop="image" class="img-fluid rounded" loading="lazy" />
</div>
<?php endif; ?>
<?php if (!empty($gallery)) : ?>
<div class="mokojoomstorelocator-gallery mb-3">
<?php foreach ($gallery as $img) : ?>
<img src="<?php echo $this->escape($img); ?>"
alt="<?php echo $this->escape($item->title); ?>"
loading="lazy" />
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php if ($item->description) : ?>
<div class="mokojoomstorelocator-location-description mb-3" itemprop="description">
<?php echo $item->description; ?>
</div>
<?php endif; ?>
<?php if ($embedUrl) : ?>
<div class="mokojoomstorelocator-video mb-3">
<iframe src="<?php echo $this->escape($embedUrl); ?>"
allowfullscreen loading="lazy"
title="<?php echo $this->escape($item->title); ?>"></iframe>
</div>
<?php endif; ?>
<div class="mokojoomstorelocator-location-details card mb-3">
<div class="card-body">
<h4 class="card-title"><?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_FIELDSET_CONTACT'); ?></h4>
@@ -95,12 +135,32 @@ $item = $this->item;
</div>
<?php if ($item->latitude && $item->longitude) : ?>
<div class="mb-3">
<div class="mokojoomstorelocator-directions mb-3">
<a href="https://www.google.com/maps/dir/?api=1&destination=<?php echo (float) $item->latitude; ?>,<?php echo (float) $item->longitude; ?>"
class="btn btn-primary" target="_blank" rel="noopener">
<?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_GET_DIRECTIONS'); ?>
</a>
</div>
<img class="mokojoomstorelocator-print-map" alt="Map"
src="https://staticmap.openstreetmap.de/staticmap.php?center=<?php echo (float) $item->latitude; ?>,<?php echo (float) $item->longitude; ?>&zoom=14&size=300x200&markers=<?php echo (float) $item->latitude; ?>,<?php echo (float) $item->longitude; ?>,ol-marker" />
<?php endif; ?>
<?php // Render Joomla custom fields if any ?>
<?php $fields = \Joomla\CMS\Helper\FieldsHelper::getFields('com_mokojoomstorelocator.location', $item, true); ?>
<?php if (!empty($fields)) : ?>
<div class="mokojoomstorelocator-customfields card mb-3">
<div class="card-body">
<h4 class="card-title"><?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_ADDITIONAL_INFO'); ?></h4>
<?php foreach ($fields as $field) : ?>
<?php if ($field->value) : ?>
<div class="mb-1">
<strong><?php echo $this->escape($field->label); ?>:</strong>
<?php echo $field->value; ?>
</div>
<?php endif; ?>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
</div>
@@ -121,9 +181,7 @@ $item = $this->item;
maxZoom: 19
}).addTo(map);
L.marker([lat, lng]).addTo(map)
.bindPopup('<strong><?php echo $this->escape($item->title); ?></strong>')
.openPopup();
L.marker([lat, lng]).addTo(map);
});
</script>
<?php endif; ?>