From 4bc38b17600b5d32768193f723d8541024e2c420 Mon Sep 17 00:00:00 2001 From: Oliver Rasche Date: Thu, 12 Nov 2026 11:34:59 +0100 Subject: [PATCH] Product content check as page content (vitec_productcheck) The product audit of public/product-check/ becomes data for the React frontend: new content element vitec_productcheck (plugin registration, wizard entry, TypoScript USER func) delivers under content.productcheck either the overview (every product with one {ok,count} check per field - hero, photos, og, video, teaser, subtitle, description, description2, highlights, capabilities, applications, features incl. icon count, resources, datasheets, solutions - plus per-field summary) or, with ?check=, the detail of one product (texts as stored HTML, images with original URL and 480px thumbnail, videos, feature cards with icons, resources, datasheets, solutions). ProductCheckService ports the PDO queries of the standalone page to Doctrine; the renderer is always USER_INT so the audit is never served from the page cache; check is excluded from the cHash. Migration adds the element to page 607 (/products/product-check). The standalone page stays untouched. Verified on DEV: overview 85 products in 0.13 s warm, detail 0.2-0.9 s, thumbnails resolve. Co-Authored-By: Claude Fable 5.1 --- .../add_product_check_element_20261112.php | 25 ++ .../Classes/Service/ProductCheckService.php | 395 ++++++++++++++++++ .../UserFunc/ProductCheckJsonRenderer.php | 59 +++ packages/vitec/Configuration/Services.yaml | 5 + .../Sets/Vitecset/setup.typoscript | 14 + .../TCA/Overrides/tt_content.php | 10 + packages/vitec/Configuration/page.tsconfig | 8 + packages/vitec/ext_localconf.php | 3 + 8 files changed, 519 insertions(+) create mode 100644 migrations/add_product_check_element_20261112.php create mode 100644 packages/vitec/Classes/Service/ProductCheckService.php create mode 100644 packages/vitec/Classes/UserFunc/ProductCheckJsonRenderer.php diff --git a/migrations/add_product_check_element_20261112.php b/migrations/add_product_check_element_20261112.php new file mode 100644 index 0000000..05d6de9 --- /dev/null +++ b/migrations/add_product_check_element_20261112.php @@ -0,0 +1,25 @@ + PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]); + +$page = $pdo->query("SELECT uid, title, slug FROM pages WHERE uid = $pageUid AND deleted = 0")->fetch(); +if ($page === false) { exit("page $pageUid not found\n"); } +echo "page #{$page['uid']} {$page['title']} ({$page['slug']})\n"; + +$existing = $pdo->query("SELECT uid, hidden FROM tt_content WHERE pid = $pageUid AND CType = 'vitec_productcheck' AND deleted = 0")->fetch(); +if ($existing !== false) { echo "element already there: #{$existing['uid']} (hidden={$existing['hidden']})\n"; exit(0); } + +$now = time(); +$stmt = $pdo->prepare('INSERT INTO tt_content (pid, tstamp, crdate, sorting, CType, colPos, header, header_layout, sys_language_uid, hidden, deleted) + VALUES (:pid, :now, :now, 256, :ctype, 0, :header, 0, 0, 0, 0)'); +$stmt->execute([':pid' => $pageUid, ':now' => $now, ':ctype' => 'vitec_productcheck', ':header' => 'Product Content Check']); +echo "inserted tt_content #" . $pdo->lastInsertId() . " (vitec_productcheck, colPos 0)\n"; diff --git a/packages/vitec/Classes/Service/ProductCheckService.php b/packages/vitec/Classes/Service/ProductCheckService.php new file mode 100644 index 0000000..cca8b4d --- /dev/null +++ b/packages/vitec/Classes/Service/ProductCheckService.php @@ -0,0 +1,395 @@ + 'hero', 'label' => 'Hero', 'kind' => 'images'], + ['key' => 'photos', 'label' => 'Photos', 'kind' => 'images'], + ['key' => 'og', 'label' => 'OG image', 'kind' => 'images'], + ['key' => 'video', 'label' => 'Video', 'kind' => 'video'], + ['key' => 'teaser', 'label' => 'Teaser', 'kind' => 'text'], + ['key' => 'subtitle', 'label' => 'Subtitle', 'kind' => 'text'], + ['key' => 'description', 'label' => 'Description', 'kind' => 'text'], + ['key' => 'description2', 'label' => 'Description 2', 'kind' => 'text'], + ['key' => 'highlights', 'label' => 'Highlights', 'kind' => 'text'], + ['key' => 'capabilities', 'label' => 'Capabilities', 'kind' => 'text'], + ['key' => 'applications', 'label' => 'Applications', 'kind' => 'text'], + ['key' => 'features', 'label' => 'Features', 'kind' => 'features'], + ['key' => 'resources', 'label' => 'Resources', 'kind' => 'list'], + ['key' => 'datasheets', 'label' => 'Datasheets', 'kind' => 'list'], + ['key' => 'solutions', 'label' => 'Solutions', 'kind' => 'list'], + ]; + + private const FLAGS = [ + 'hidden' => 'hidden', + 'legacy' => 'legacy', + 'supportproduct' => 'support', + 'subproduct' => 'sub', + 'hideonwebsite' => 'hideWeb', + 'hideonproducts' => 'hideList', + 'showdatapath' => 'datapath', + ]; + + private const TEXT_FIELDS = [ + 'teaser' => 'Teaser', + 'subtitle' => 'Subtitle', + 'description' => 'Description', + 'description2' => 'Description 2', + 'highlights' => 'Highlights', + 'capabilities' => 'Capabilities', + 'applications' => 'Applications', + ]; + + public function __construct( + private readonly ConnectionPool $connectionPool, + ) {} + + /** + * @return array{generatedAt: string, fields: array, summary: array{products: int, filled: array}, products: array>} + */ + public function overview(): array + { + $products = $this->products(); + $uids = array_map(static fn(array $p): int => (int)$p['uid'], $products); + $files = $this->fileCounts($uids); + $features = $this->featureCounts($uids); + $resources = $this->countBy('tx_vitec_product_resource', 'product', 'deleted = 0 AND hidden = 0'); + $datasheets = $this->mmCounts('tx_vitec_product_datasheet_mm', 'tx_vitec_domain_model_download'); + $solutions = $this->mmCounts('tx_vitec_product_solution_mm', 'tx_vitec_domain_model_solution'); + + $filled = array_fill_keys(array_column(self::FIELDS, 'key'), 0); + $rows = []; + foreach ($products as $p) { + $uid = (int)$p['uid']; + $f = $files[$uid] ?? []; + $checks = [ + 'hero' => $this->check($f['heroimage'] ?? 0), + 'photos' => $this->check($f['productimage'] ?? 0), + 'og' => $this->check($f['ogimage'] ?? 0), + 'video' => $this->check($f['videofile'] ?? 0), + 'teaser' => $this->check($this->filled($p['teaser']) ? 1 : 0), + 'subtitle' => $this->check($this->filled($p['subtitle']) ? 1 : 0), + 'description' => $this->check($this->filled($p['description']) ? 1 : 0), + 'description2' => $this->check($this->filled($p['description2']) ? 1 : 0), + 'highlights' => $this->check($this->filled($p['highlights']) ? 1 : 0), + 'capabilities' => $this->check($this->filled($p['capabilities']) ? 1 : 0), + 'applications' => $this->check($this->filled($p['applications']) ? 1 : 0), + 'features' => $this->check($features[$uid]['cards'] ?? 0) + ['icons' => $features[$uid]['icons'] ?? 0], + 'resources' => $this->check($resources[$uid] ?? 0), + 'datasheets' => $this->check($datasheets[$uid] ?? 0), + 'solutions' => $this->check($solutions[$uid] ?? 0), + ]; + foreach ($checks as $key => $c) { + if ($c['ok']) { + $filled[$key]++; + } + } + $rows[] = $this->base($p) + ['checks' => $checks]; + } + + return [ + 'generatedAt' => date(\DATE_ATOM), + 'fields' => self::FIELDS, + 'summary' => ['products' => count($rows), 'filled' => $filled], + 'products' => $rows, + ]; + } + + /** + * @return array|null null when the product does not exist + */ + public function detail(int $uid): ?array + { + $p = $this->connection()->executeQuery( + 'SELECT uid, title, slug, teaser, subtitle, description, description2, highlights, + capabilities, applications, resources_layout, + hidden, legacy, supportproduct, subproduct, hideonwebsite, hideonproducts, showdatapath + FROM ' . self::PRODUCT_TABLE . ' WHERE deleted = 0 AND uid = ?', + [$uid] + )->fetchAssociative(); + if ($p === false) { + return null; + } + + $files = $this->files($uid); + $texts = []; + foreach (self::TEXT_FIELDS as $field => $label) { + $texts[$field] = [ + 'label' => $label, + 'ok' => $this->filled($p[$field]), + 'html' => (string)$p[$field], + ]; + } + + return $this->base($p) + [ + 'images' => [ + 'hero' => $files['heroimage'] ?? [], + 'photos' => $files['productimage'] ?? [], + 'og' => $files['ogimage'] ?? [], + ], + 'videos' => array_map(static fn(array $v): array => ['url' => $v['url'], 'name' => $v['name']], $files['videofile'] ?? []), + 'texts' => $texts, + 'features' => $this->features($uid), + 'resources' => $this->resources($uid), + 'datasheets' => $this->mmTitles('tx_vitec_product_datasheet_mm', 'tx_vitec_domain_model_download', $uid), + 'solutions' => $this->mmTitles('tx_vitec_product_solution_mm', 'tx_vitec_domain_model_solution', $uid), + ]; + } + + // ---- building blocks ---------------------------------------------------- + + /** @param array $p */ + private function base(array $p): array + { + $slug = trim((string)$p['slug'], '/'); + $flags = []; + foreach (self::FLAGS as $column => $label) { + if ((int)($p[$column] ?? 0) === 1) { + $flags[] = $label; + } + } + return [ + 'uid' => (int)$p['uid'], + 'title' => (string)$p['title'], + 'slug' => $slug, + 'url' => '/product/' . $slug, + 'flags' => $flags, + 'resourcesLayout' => (string)($p['resources_layout'] ?? ''), + ]; + } + + /** @return array{ok: bool, count: int} */ + private function check(int $count): array + { + return ['ok' => $count > 0, 'count' => $count]; + } + + private function filled(mixed $value): bool + { + return trim(strip_tags((string)$value)) !== ''; + } + + /** @return array> */ + private function products(): array + { + return $this->connection()->executeQuery( + 'SELECT uid, title, slug, teaser, subtitle, description, description2, highlights, + capabilities, applications, resources_layout, + hidden, legacy, supportproduct, subproduct, hideonwebsite, hideonproducts, showdatapath + FROM ' . self::PRODUCT_TABLE . ' WHERE deleted = 0 ORDER BY LOWER(title), uid' + )->fetchAllAssociative(); + } + + /** + * Visible FAL references per product and field. + * + * @param int[] $uids + * @return array> [productUid][fieldname] = count + */ + private function fileCounts(array $uids): array + { + if ($uids === []) { + return []; + } + $rows = $this->connection()->executeQuery( + "SELECT uid_foreign, fieldname, COUNT(*) AS n FROM sys_file_reference + WHERE deleted = 0 AND hidden = 0 AND tablenames = 'tx_vitec_domain_model_product' + AND fieldname IN ('heroimage','productimage','ogimage','videofile') + AND uid_foreign IN (" . implode(',', array_map('intval', $uids)) . ') + GROUP BY uid_foreign, fieldname' + )->fetchAllAssociative(); + $out = []; + foreach ($rows as $r) { + $out[(int)$r['uid_foreign']][(string)$r['fieldname']] = (int)$r['n']; + } + return $out; + } + + /** + * Files of one product with original URL and a ~480 px thumbnail + * (SVG and non-images keep the original as thumbnail). + * + * @return array> + */ + private function files(int $uid): array + { + $rows = $this->connection()->executeQuery( + "SELECT r.fieldname, r.uid_local, f.identifier, f.name, f.mime_type + FROM sys_file_reference r JOIN sys_file f ON f.uid = r.uid_local + WHERE r.deleted = 0 AND r.hidden = 0 AND r.tablenames = 'tx_vitec_domain_model_product' + AND r.fieldname IN ('heroimage','productimage','ogimage','videofile') AND r.uid_foreign = ? + ORDER BY r.fieldname, r.sorting_foreign", + [$uid] + )->fetchAllAssociative(); + $out = []; + foreach ($rows as $r) { + $url = '/fileadmin' . $r['identifier']; + $out[(string)$r['fieldname']][] = [ + 'url' => $url, + 'thumb' => $this->thumbnail((int)$r['uid_local'], (string)$r['mime_type'], $url), + 'name' => (string)$r['name'], + 'mime' => (string)$r['mime_type'], + ]; + } + return $out; + } + + private function thumbnail(int $fileUid, string $mime, string $fallback): string + { + if (!str_starts_with($mime, 'image/') || $mime === 'image/svg+xml') { + return $fallback; + } + try { + $file = GeneralUtility::makeInstance(ResourceFactory::class)->getFileObject($fileUid); + $imageService = GeneralUtility::makeInstance(ImageService::class); + $processed = $imageService->applyProcessingInstructions($file, ['maxWidth' => 480, 'maxHeight' => 480]); + return $imageService->getImageUri($processed); + } catch (\Throwable) { + return $fallback; + } + } + + /** + * @param int[] $uids + * @return array + */ + private function featureCounts(array $uids): array + { + if ($uids === []) { + return []; + } + $in = implode(',', array_map('intval', $uids)); + $out = []; + foreach ($this->connection()->executeQuery( + "SELECT product, COUNT(*) AS n FROM tx_vitec_product_feature + WHERE deleted = 0 AND hidden = 0 AND product IN ($in) GROUP BY product" + )->fetchAllAssociative() as $r) { + $out[(int)$r['product']] = ['cards' => (int)$r['n'], 'icons' => 0]; + } + foreach ($this->connection()->executeQuery( + "SELECT fe.product, COUNT(DISTINCT fe.uid) AS n + FROM tx_vitec_product_feature fe + JOIN sys_file_reference r ON r.uid_foreign = fe.uid AND r.tablenames = 'tx_vitec_product_feature' + AND r.fieldname = 'icon' AND r.deleted = 0 AND r.hidden = 0 + WHERE fe.deleted = 0 AND fe.hidden = 0 AND fe.product IN ($in) GROUP BY fe.product" + )->fetchAllAssociative() as $r) { + $out[(int)$r['product']]['icons'] = (int)$r['n']; + } + return $out; + } + + /** @return array */ + private function features(int $uid): array + { + $cards = $this->connection()->executeQuery( + 'SELECT uid, title, cardtext FROM tx_vitec_product_feature + WHERE deleted = 0 AND hidden = 0 AND product = ? ORDER BY sorting, uid', + [$uid] + )->fetchAllAssociative(); + if ($cards === []) { + return []; + } + $icons = []; + foreach ($this->connection()->executeQuery( + "SELECT r.uid_foreign, f.identifier, f.name FROM sys_file_reference r JOIN sys_file f ON f.uid = r.uid_local + WHERE r.deleted = 0 AND r.hidden = 0 AND r.tablenames = 'tx_vitec_product_feature' AND r.fieldname = 'icon' + AND r.uid_foreign IN (" . implode(',', array_map(static fn(array $c): int => (int)$c['uid'], $cards)) . ') + ORDER BY r.uid_foreign, r.sorting_foreign' + )->fetchAllAssociative() as $r) { + $icons[(int)$r['uid_foreign']] ??= ['url' => '/fileadmin' . $r['identifier'], 'name' => (string)$r['name']]; + } + return array_map(static fn(array $c): array => [ + 'title' => (string)$c['title'], + 'text' => (string)$c['cardtext'], + 'icon' => $icons[(int)$c['uid']] ?? null, + ], $cards); + } + + /** @return array */ + private function resources(int $uid): array + { + return array_map(static fn(array $r): array => [ + 'title' => (string)$r['title'], + 'type' => (string)$r['restype'], + 'link' => (string)$r['link'], + 'text' => (string)$r['cardtext'], + ], $this->connection()->executeQuery( + 'SELECT title, restype, link, cardtext FROM tx_vitec_product_resource + WHERE deleted = 0 AND hidden = 0 AND product = ? ORDER BY sorting, uid', + [$uid] + )->fetchAllAssociative()); + } + + /** @return array [productUid] = count */ + private function countBy(string $table, string $column, string $where): array + { + $out = []; + foreach ($this->connection()->executeQuery( + "SELECT $column AS k, COUNT(*) AS n FROM $table WHERE $where GROUP BY $column" + )->fetchAllAssociative() as $r) { + $out[(int)$r['k']] = (int)$r['n']; + } + return $out; + } + + /** @return array [productUid] = count of non-deleted foreign records */ + private function mmCounts(string $mmTable, string $foreignTable): array + { + $out = []; + foreach ($this->connection()->executeQuery( + "SELECT mm.uid_local AS k, COUNT(*) AS n FROM $mmTable mm + JOIN $foreignTable t ON t.uid = mm.uid_foreign AND t.deleted = 0 + GROUP BY mm.uid_local" + )->fetchAllAssociative() as $r) { + $out[(int)$r['k']] = (int)$r['n']; + } + return $out; + } + + /** @return array */ + private function mmTitles(string $mmTable, string $foreignTable, int $uid): array + { + return array_map(static fn(array $r): array => ['uid' => (int)$r['uid'], 'title' => (string)$r['title']], $this->connection()->executeQuery( + "SELECT t.uid, t.title FROM $mmTable mm JOIN $foreignTable t ON t.uid = mm.uid_foreign AND t.deleted = 0 + WHERE mm.uid_local = ? ORDER BY mm.sorting", + [$uid] + )->fetchAllAssociative()); + } + + private function connection(): \TYPO3\CMS\Core\Database\Connection + { + return $this->connectionPool->getConnectionForTable(self::PRODUCT_TABLE); + } +} diff --git a/packages/vitec/Classes/UserFunc/ProductCheckJsonRenderer.php b/packages/vitec/Classes/UserFunc/ProductCheckJsonRenderer.php new file mode 100644 index 0000000..480fdb1 --- /dev/null +++ b/packages/vitec/Classes/UserFunc/ProductCheckJsonRenderer.php @@ -0,0 +1,59 @@ + {mode: "detail", product: {…} | null} + * on failure {mode: "error", error} + * + * Always USER_INT: the audit must show the database as it is right now, + * so neither the TYPO3 page cache nor the `check` variant may stick. + * `check` is excluded from the cHash in ext_localconf.php. The front end + * is expected to load the detail variant client-side from /_json (one + * request per opened row), not to navigate the SSR page with the query. + */ +final class ProductCheckJsonRenderer +{ + protected ?ContentObjectRenderer $cObj = null; + + public function setContentObjectRenderer(ContentObjectRenderer $cObj): void + { + $this->cObj = $cObj; + } + + #[AsAllowedCallable] + public function render(string $content, array $conf): string + { + if ($this->cObj !== null && $this->cObj->getUserObjectType() === ContentObjectRenderer::OBJECTTYPE_USER) { + $this->cObj->convertToUserIntObject(); + return ''; + } + try { + $service = GeneralUtility::makeInstance(ProductCheckService::class); + $queryParams = $GLOBALS['TYPO3_REQUEST']?->getQueryParams() ?? []; + $check = (int)($queryParams['check'] ?? 0); + if ($check > 0) { + $payload = ['mode' => 'detail', 'product' => $service->detail($check)]; + } else { + $payload = ['mode' => 'overview'] + $service->overview(); + } + } catch (\Throwable $e) { + $payload = ['mode' => 'error', 'error' => $e->getMessage()]; + } + return (string)json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE); + } +} diff --git a/packages/vitec/Configuration/Services.yaml b/packages/vitec/Configuration/Services.yaml index d71471d..b031bae 100644 --- a/packages/vitec/Configuration/Services.yaml +++ b/packages/vitec/Configuration/Services.yaml @@ -28,6 +28,11 @@ services: Evomedien\Vitec\Service\SsrPurgeService: public: true + # Fetched via GeneralUtility::makeInstance from the USER func + # ProductCheckJsonRenderer (TypoScript userFunc, no DI there). + Evomedien\Vitec\Service\ProductCheckService: + public: true + # SSR dashboard tiles (the WidgetConfigurationInterface argument is # injected by the dashboard compiler pass via the tag). Evomedien\Vitec\Widgets\SsrStatusWidget: diff --git a/packages/vitec/Configuration/Sets/Vitecset/setup.typoscript b/packages/vitec/Configuration/Sets/Vitecset/setup.typoscript index e7b68ce..18b8607 100755 --- a/packages/vitec/Configuration/Sets/Vitecset/setup.typoscript +++ b/packages/vitec/Configuration/Sets/Vitecset/setup.typoscript @@ -100,6 +100,20 @@ tt_content { } } + # Product Content Check - the product audit table as data (page + # /products/product-check). Always USER_INT, see ProductCheckJsonRenderer. + vitec_productcheck < lib.contentElementWithHeader + vitec_productcheck { + fields { + content { + fields { + productcheck = USER + productcheck.userFunc = Evomedien\Vitec\UserFunc\ProductCheckJsonRenderer->render + } + } + } + } + # EXT:solr search results as JSON - the search endpoint for the React frontend solr_pi_results < lib.contentElementWithHeader solr_pi_results { diff --git a/packages/vitec/Configuration/TCA/Overrides/tt_content.php b/packages/vitec/Configuration/TCA/Overrides/tt_content.php index cd7ec39..f224fd9 100644 --- a/packages/vitec/Configuration/TCA/Overrides/tt_content.php +++ b/packages/vitec/Configuration/TCA/Overrides/tt_content.php @@ -27,6 +27,16 @@ use TYPO3\CMS\Extbase\Utility\ExtensionUtility; $registerPluginWithFlexForm('Productlist', 'Show Products by Category', 'FILE:EXT:vitec/Configuration/FlexForms/Productlist.xml', 'vitec-plugin-productlist'); $registerPluginWithFlexForm('Productfinder', 'Product Finder (filter data)', 'FILE:EXT:vitec/Configuration/FlexForms/Productfinder.xml', 'vitec-plugin-productlist'); $registerPluginWithFlexForm('Productfeature', 'Featured Product Card', 'FILE:EXT:vitec/Configuration/FlexForms/Productfeature.xml', 'vitec-plugin-productlist'); + // Product content audit as page content (2026-11-12): no FlexForm, the + // element has nothing to configure - data comes from ProductCheckService. + ExtensionUtility::registerPlugin( + 'Vitec', + 'Productcheck', + 'Product Content Check (audit table)', + 'vitec-plugin-productlist', + 'plugins', + 'Live audit of every product record: one row per product, one check per content field, details per row. Place once on /products/product-check.' + ); $registerPluginWithFlexForm('Simplecard', 'Simple Card', 'FILE:EXT:vitec/Configuration/FlexForms/Simplecard.xml', 'vitec-plugin-simplecard'); $registerPluginWithFlexForm('Productshow', 'Show Single Product', 'FILE:EXT:vitec/Configuration/FlexForms/Productshow.xml', 'vitec-plugin-productshow'); $registerPluginWithFlexForm('Usecaseshow', 'Single Success Story', 'FILE:EXT:vitec/Configuration/FlexForms/Usecase.xml', 'vitec-plugin-usecaseshow'); diff --git a/packages/vitec/Configuration/page.tsconfig b/packages/vitec/Configuration/page.tsconfig index 2cfc528..aef489e 100755 --- a/packages/vitec/Configuration/page.tsconfig +++ b/packages/vitec/Configuration/page.tsconfig @@ -32,6 +32,14 @@ mod { CType = vitec_productfinder } } + productcheck { + iconIdentifier = vitec-plugin-productlist + title = Product Content Check (audit table) + description = Live audit of all product records - one row per product, one check per content field. Internal page /products/product-check. + tt_content_defValues { + CType = vitec_productcheck + } + } productshow { iconIdentifier = vitec-plugin-productshow title = Show single VITEC Product diff --git a/packages/vitec/ext_localconf.php b/packages/vitec/ext_localconf.php index 90ad5be..3e89517 100755 --- a/packages/vitec/ext_localconf.php +++ b/packages/vitec/ext_localconf.php @@ -268,6 +268,9 @@ $GLOBALS['TYPO3_CONF_VARS']['FE']['cacheHash']['excludedParameters'][] = 'page'; $GLOBALS['TYPO3_CONF_VARS']['FE']['cacheHash']['excludedParameters'][] = 'filter'; $GLOBALS['TYPO3_CONF_VARS']['FE']['cacheHash']['excludedParameters'][] = 'market'; $GLOBALS['TYPO3_CONF_VARS']['FE']['cacheHash']['excludedParameters'][] = 'category'; +// Product content audit (/products/product-check): ?check= +// switches the vitec_productcheck element to the detail payload. +$GLOBALS['TYPO3_CONF_VARS']['FE']['cacheHash']['excludedParameters'][] = 'check'; // SSR auto-purge: editors' changes evict the matching pages from the SSR // node's full-page cache (queued per request, one call per save/import).