From 8793e6b3f4b494573541d6468f1cd89676ecb951 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sun, 21 Jun 2026 10:20:38 -0500 Subject: [PATCH] feat: add MokoSuiteShop product OG tag support (#53) - Detect com_mokoshop product views and set og:type to 'product' - Auto-generate OG tags from CRM product data (name, description, image) - Add product:price:amount and product:price:currency meta tags - Add JSON-LD Product schema with offers, SKU, and aggregate ratings - Load product images from linked #__content article images - Cache product DB lookups to avoid duplicate queries per request --- .../src/Extension/MokoOG.php | 66 ++++++++++++++- .../src/Helper/JsonLdBuilder.php | 82 ++++++++++++++++++- 2 files changed, 145 insertions(+), 3 deletions(-) diff --git a/source/packages/plg_system_mokoog/src/Extension/MokoOG.php b/source/packages/plg_system_mokoog/src/Extension/MokoOG.php index d18c81d..37a3321 100644 --- a/source/packages/plg_system_mokoog/src/Extension/MokoOG.php +++ b/source/packages/plg_system_mokoog/src/Extension/MokoOG.php @@ -112,7 +112,8 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface $image = $ogData->og_image ?: $this->findImage($option, $view, $id); $url = Uri::getInstance()->toString(); $siteName = $this->params->get('og_site_name', $app->get('sitename', '')); - $type = $ogData->og_type ?: 'article'; + $defaultType = ($option === 'com_mokoshop' && $view === 'product') ? 'product' : 'article'; + $type = $ogData->og_type ?: $defaultType; // Open Graph tags $doc->setMetaData('og:title', $title, 'property'); @@ -188,6 +189,16 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface } } + // MokoSuiteShop product meta tags + if ($option === 'com_mokoshop' && $view === 'product' && $id > 0) { + $productData = $this->loadShopProduct($id); + + if ($productData) { + $doc->setMetaData('product:price:amount', number_format((float) $productData->price, 2, '.', ''), 'property'); + $doc->setMetaData('product:price:currency', $productData->currency ?: 'USD', 'property'); + } + } + // Fire event so third-party plugins can add custom OG/social tags $eventData = [ 'subject' => $doc, @@ -206,7 +217,9 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface if ($this->params->get('jsonld_enabled', 1)) { $imageUrl = $image ? $this->resolveImageUrl($image) : ''; - if ($option === 'com_content' && $view === 'article' && $id > 0) { + if ($option === 'com_mokoshop' && $view === 'product' && $id > 0) { + $schema = JsonLdBuilder::buildProduct($id, $title, $description, $imageUrl); + } elseif ($option === 'com_content' && $view === 'article' && $id > 0) { $schema = JsonLdBuilder::buildArticle($id, $title, $description, $imageUrl); } else { $schema = JsonLdBuilder::buildWebPage($title, $description); @@ -414,6 +427,25 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface return $this->params->get('default_image', ''); } + // For MokoSuiteShop products, look at the linked article's images + if ($option === 'com_mokoshop' && $id > 0) { + $productData = $this->loadShopProduct($id); + + if ($productData && !empty($productData->images)) { + $imagesData = json_decode($productData->images, true); + + if (!empty($imagesData['image_fulltext'])) { + return $imagesData['image_fulltext']; + } + + if (!empty($imagesData['image_intro'])) { + return $imagesData['image_intro']; + } + } + + return $this->params->get('default_image', ''); + } + // For Joomla articles, look at the intro/full image fields if ($option === 'com_content' && $id > 0) { $db = Factory::getDbo(); @@ -588,6 +620,36 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface * * @return array{0: int, 1: int}|null */ + /** + * Load MokoSuiteShop product data by product ID. + * + * @param int $productId CRM product ID + * + * @return object|null Product with name, description, images, price, currency, sku + */ + private function loadShopProduct(int $productId): ?object + { + static $cache = []; + + if (isset($cache[$productId])) { + return $cache[$productId]; + } + + $db = Factory::getDbo(); + $query = $db->getQuery(true) + ->select('p.id, p.sku, p.price, p.currency, p.stock_qty') + ->select('c.title AS name, c.introtext AS description, c.images') + ->from($db->quoteName('#__mokosuite_crm_products', 'p')) + ->join('LEFT', $db->quoteName('#__content', 'c') . ' ON c.id = p.article_id') + ->where($db->quoteName('p.id') . ' = ' . $productId) + ->where($db->quoteName('p.published') . ' = 1'); + + $db->setQuery($query); + $cache[$productId] = $db->loadObject(); + + return $cache[$productId]; + } + private function getImageDimensions(string $image): ?array { // Cannot determine dimensions for external URLs diff --git a/source/packages/plg_system_mokoog/src/Helper/JsonLdBuilder.php b/source/packages/plg_system_mokoog/src/Helper/JsonLdBuilder.php index b9cf789..5d1b448 100644 --- a/source/packages/plg_system_mokoog/src/Helper/JsonLdBuilder.php +++ b/source/packages/plg_system_mokoog/src/Helper/JsonLdBuilder.php @@ -1,7 +1,7 @@ * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. @@ -152,6 +152,86 @@ class JsonLdBuilder ]; } + /** + * Build Product schema for a MokoSuiteShop product. + * + * @param int $productId CRM product ID + * @param string $title Product title + * @param string $description Product description + * @param string $image Image URL (absolute) + * + * @return array|null + */ + public static function buildProduct(int $productId, string $title, string $description, string $image): ?array + { + if ($productId <= 0) { + return null; + } + + $db = Factory::getDbo(); + $query = $db->getQuery(true) + ->select('p.sku, p.price, p.currency, p.stock_qty') + ->from($db->quoteName('#__mokosuite_crm_products', 'p')) + ->where($db->quoteName('p.id') . ' = ' . $productId) + ->where($db->quoteName('p.published') . ' = 1'); + + $db->setQuery($query); + $product = $db->loadObject(); + + if (!$product) { + return null; + } + + $schema = [ + '@context' => 'https://schema.org', + '@type' => 'Product', + 'name' => $title, + 'description' => $description, + 'url' => Uri::getInstance()->toString(), + ]; + + if (!empty($product->sku)) { + $schema['sku'] = $product->sku; + } + + if (!empty($image)) { + $schema['image'] = $image; + } + + // Offers (pricing and availability) + $availability = ((float) $product->stock_qty > 0) + ? 'https://schema.org/InStock' + : 'https://schema.org/OutOfStock'; + + $schema['offers'] = [ + '@type' => 'Offer', + 'price' => number_format((float) $product->price, 2, '.', ''), + 'priceCurrency' => $product->currency ?: 'USD', + 'availability' => $availability, + 'url' => Uri::getInstance()->toString(), + ]; + + // Aggregate rating from reviews if available + $reviewQuery = $db->getQuery(true) + ->select('COUNT(*) AS review_count, AVG(rating) AS avg_rating') + ->from($db->quoteName('#__mokoshop_reviews')) + ->where($db->quoteName('product_id') . ' = ' . $productId) + ->where($db->quoteName('status') . ' = ' . $db->quote('approved')); + + $db->setQuery($reviewQuery); + $rating = $db->loadObject(); + + if ($rating && (int) $rating->review_count > 0) { + $schema['aggregateRating'] = [ + '@type' => 'AggregateRating', + 'ratingValue' => round((float) $rating->avg_rating, 1), + 'reviewCount' => (int) $rating->review_count, + ]; + } + + return $schema; + } + /** * Encode a schema array to a JSON-LD script tag string. *