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