- New extension fe_comment (Evomedien, framework-agnostic): floating page

feedback widget served from /api/fe-comment/widget.js, note pad in
  localStorage, submissions mailed via /api/fe-comment/submit with
  recipients from the extension configuration; classic sites get the
  script injected via AssetCollector when the jsonOutput setting is off.
  An enabled master switch serves the widget EMPTY when off (no 404, no
  console errors) and rejects submissions - the go-live kill switch, no
  front-end build change needed
- URL swap finished: /_json prefix middleware, SPA fallback live with a
  dedicated root rule (REQUEST_FILENAME of / is a directory) and a
  query-string exception for the TYPO3 v14 install tool entry
  (?__typo3_install on the site root - the backend Settings/Environment
  modules depend on it); /api/ as a whole stays with TYPO3
- MailMessage::send() no longer exists in v14: fe_comment and the form
  EmailDelivery (latently broken since the core upgrade) now send through
  the Mailer service. Mail transport moved to SMTP against the local exim
  so delivery errors surface instead of bouncing into the blackhole -
  exim verifies the envelope sender, which therefore must be a routable
  existing address (transport config lives in the untracked settings.php)
- Related-product card texts editable on the product record (table rebuilt
  with uid/pid, TCA + inline field, one-time migration script); shared
  RteCompare for the module badge and the CLI comparison - batch dry-run
  now reports 0 writes instead of flagging every richtext field
- Workbook clarification cases executed: VisionIO-SD14, VisionSC-SD14 and
  both Diamond OG records imported (40 fields, live-verified); ArtioSign
  and ArtioView created hidden (uid 109/110) via
  create_artio_records.php - the product table has no sorting column;
  WallControl_10 mapping REVERTED and parked: the dry run revealed
  aetria-wallcontrol already carries its own agency workbook, which
  WallControl_10 would have overwritten. Manifest now 75 product /
  24 family / 1 open
This commit is contained in:
orasche committed 2026-09-30 13:59:55 +02:00
1 parent 6db2234851
commit f0b0694842
28 files changed
+1272 -54

No files matched your search

+1
View File
@@ -19,6 +19,7 @@
"b13/container": "^3.1", "b13/container": "^3.1",
"b13/typo3-updater": "^1.1", "b13/typo3-updater": "^1.1",
"evomedien/evo-megamenu-json": "^1.0.0", "evomedien/evo-megamenu-json": "^1.0.0",
"evomedien/fe-comment": "^1.0.0",
"evomedien/vitec": "^1.0.1", "evomedien/vitec": "^1.0.1",
"friendsoftypo3/content-blocks": "^2.4", "friendsoftypo3/content-blocks": "^2.4",
"friendsoftypo3/headless": "^5.0@rc", "friendsoftypo3/headless": "^5.0@rc",
Generated
+34 -4
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "9baf94c6d03b967820c531b235568ece", "content-hash": "0f38c50d2ecb8b6e4b0ec88fc7985249",
"packages": [ "packages": [
{ {
"name": "apache-solr-for-typo3/solr", "name": "apache-solr-for-typo3/solr",
@@ -1457,6 +1457,36 @@
"relative": true "relative": true
} }
}, },
{
"name": "evomedien/fe-comment",
"version": "1.0.0",
"dist": {
"type": "path",
"url": "./packages/fe_comment",
"reference": "ccf2ff931b41a409fa8df3d3e92da84f91c71a33"
},
"require": {
"typo3/cms-core": "^14.0"
},
"type": "typo3-cms-extension",
"extra": {
"typo3/cms": {
"extension-key": "fe_comment"
}
},
"autoload": {
"psr-4": {
"Evomedien\\FeComment\\": "Classes/"
}
},
"license": [
"GPL-2.0-or-later"
],
"description": "Page feedback widget: visitors collect comments per page on a note pad and submit them by mail. Framework-agnostic (works on headless/SPA and classic TYPO3 sites).",
"transport-options": {
"relative": true
}
},
{ {
"name": "evomedien/vitec", "name": "evomedien/vitec",
"version": "1.0.1", "version": "1.0.1",
@@ -10300,10 +10330,10 @@
}, },
"prefer-stable": false, "prefer-stable": false,
"prefer-lowest": false, "prefer-lowest": false,
"platform": {}, "platform": [],
"platform-dev": {}, "platform-dev": [],
"platform-overrides": { "platform-overrides": {
"php": "8.4.0" "php": "8.4.0"
}, },
"plugin-api-version": "2.9.0" "plugin-api-version": "2.3.0"
} }
+4 -2
View File
@@ -24,6 +24,8 @@ dependencies:
- apache-solr-for-typo3/solr-stylesheets - apache-solr-for-typo3/solr-stylesheets
- apache-solr-for-typo3/solr-bootstrap-css - apache-solr-for-typo3/solr-bootstrap-css
- evomedien/megamenu - evomedien/megamenu
- vitec/quotation
- vitec/featured-content
frontendBase: '' frontendBase: ''
headless: 1 headless: 1
languages: languages:
@@ -100,6 +102,7 @@ routeEnhancers:
solr_enabled_read: true solr_enabled_read: true
solr_host_read: vitecsolr.evomedien.de solr_host_read: vitecsolr.evomedien.de
solr_host_write: localhost solr_host_write: localhost
solr_password_read: DvgpSaqrxr9QCd4ES36V
solr_path_read: / solr_path_read: /
solr_path_write: / solr_path_write: /
solr_port_read: '443' solr_port_read: '443'
@@ -107,6 +110,5 @@ solr_port_write: '8983'
solr_scheme_read: https solr_scheme_read: https
solr_scheme_write: '' solr_scheme_write: ''
solr_use_write_connection: false solr_use_write_connection: false
solr_username_read: '%env(SOLR_USERNAME)%' solr_username_read: solr
solr_password_read: '%env(SOLR_PASSWORD)%'
websiteTitle: '' websiteTitle: ''
+75
View File
@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
/**
* Create the ArtioSign and ArtioView product records (decision Olli
* 2026-09-28, last two open manifest cases).
*
* Both arrive HIDDEN: the agency wrote full product workbooks, but neither
* product exists in sitemap V2, so Olli publishes them deliberately, not an
* import. Category: 76 "Aetria" - the home of their closest neighbour
* Aetria WallControl - as a PROVISIONAL placement; moving them is one
* checkbox in the backend. pid and sorting are taken from the existing
* product records.
*
* Idempotent: a record whose slug already exists is skipped.
*
* Usage: php migrations/create_artio_records.php
* then: vendor/bin/typo3 cache:flush
*/
$root = null;
$dir = __DIR__;
for ($i = 0; $i < 5; $i++) {
if (is_file($dir . '/config/system/settings.php')) { $root = $dir; break; }
$dir = dirname($dir);
}
if ($root === null) { exit("Could not locate project root above " . __DIR__ . "\n"); }
$settings = require $root . '/config/system/settings.php';
$db = $settings['DB']['Connections']['Default'];
$pdo = new PDO(
sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', $db['host'] ?? 'localhost', $db['port'] ?? 3306, $db['dbname'] ?? ''),
$db['user'] ?? '', $db['password'] ?? '',
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]
);
const TABLE = 'tx_vitec_domain_model_product';
const CATEGORY_UID = 76; // "Aetria" - provisional, see header
$products = [
['title' => 'ArtioSign', 'slug' => 'artiosign'],
['title' => 'ArtioView', 'slug' => 'artioview'],
];
// The product table has NO sorting column (list order comes from the
// category tree) - only the pid is taken from the existing records.
$ref = $pdo->query('SELECT pid FROM ' . TABLE . ' WHERE deleted = 0 GROUP BY pid ORDER BY COUNT(*) DESC LIMIT 1')->fetch();
if (!$ref) { exit("No existing product records to take the pid from.\n"); }
$pid = (int)$ref['pid'];
$catName = $pdo->query('SELECT title FROM sys_category WHERE uid = ' . CATEGORY_UID . ' AND deleted = 0')->fetchColumn();
if ($catName === false) { exit("Category " . CATEGORY_UID . " not found - adjust CATEGORY_UID.\n"); }
foreach ($products as $product) {
$exists = $pdo->prepare('SELECT uid FROM ' . TABLE . ' WHERE slug = ? AND deleted = 0');
$exists->execute([$product['slug']]);
if (($uid = $exists->fetchColumn()) !== false) {
echo $product['title'] . ": exists already (uid $uid) - skipped.\n";
continue;
}
$insert = $pdo->prepare('INSERT INTO ' . TABLE . ' (pid, tstamp, crdate, hidden, title, slug, categories)
VALUES (?, ?, ?, 1, ?, ?, 1)');
$insert->execute([$pid, time(), time(), $product['title'], $product['slug']]);
$uid = (int)$pdo->lastInsertId();
$mm = $pdo->prepare('INSERT INTO sys_category_record_mm (uid_local, uid_foreign, tablenames, fieldname, sorting, sorting_foreign)
VALUES (?, ?, ?, ?, 0, 1)');
$mm->execute([CATEGORY_UID, $uid, TABLE, 'categories']);
echo $product['title'] . ": created uid $uid (pid $pid, HIDDEN, category $catName).\n";
}
echo "Done. Next: vendor/bin/typo3 cache:flush\n";
+20 -20
View File
@@ -85,12 +85,12 @@
"product": "aligo-workstation" "product": "aligo-workstation"
}, },
"IPTV and Digital Signage Product Pages/ArtioSign_Product Page Content.xlsx": { "IPTV and Digital Signage Product Pages/ArtioSign_Product Page Content.xlsx": {
"product": null, "product": "artiosign",
"note": "manuell zuordnen: verstecktes/abweichend benanntes Produkt ODER Familienseite" "note": "Record angelegt 2026-09-28 (versteckt, Kategorie Aetria provisorisch) - Entscheidung Olli"
}, },
"IPTV and Digital Signage Product Pages/ArtioView_Product Page Content.xlsx": { "IPTV and Digital Signage Product Pages/ArtioView_Product Page Content.xlsx": {
"product": null, "product": "artioview",
"note": "manuell zuordnen: verstecktes/abweichend benanntes Produkt ODER Familienseite" "note": "Record angelegt 2026-09-28 (versteckt, Kategorie Aetria provisorisch) - Entscheidung Olli"
}, },
"IPTV and Digital Signage Product Pages/Avedia End-Points_Product Page Content.xlsx": { "IPTV and Digital Signage Product Pages/Avedia End-Points_Product Page Content.xlsx": {
"product": "avedia-end-points" "product": "avedia-end-points"
@@ -121,8 +121,8 @@
"note": "K-Ebene: EZ TV Platform (URL /platforms-and-end-points/ez-tv-platform/); Auto-Titel-Match auf den Produkt-Record war FALSCH, korrigiert 2026-09-24" "note": "K-Ebene: EZ TV Platform (URL /platforms-and-end-points/ez-tv-platform/); Auto-Titel-Match auf den Produkt-Record war FALSCH, korrigiert 2026-09-24"
}, },
"IPTV and Digital Signage Product Pages/Media Players_Product Page Content.xlsx": { "IPTV and Digital Signage Product Pages/Media Players_Product Page Content.xlsx": {
"product": null, "family": true,
"note": "manuell zuordnen: verstecktes/abweichend benanntes Produkt ODER Familienseite" "note": "Sammelseite (K-Ebene) - Entscheidung Olli 2026-09-28"
}, },
"IPTV and Digital Signage Product Pages/Operator Workstations.xlsx": { "IPTV and Digital Signage Product Pages/Operator Workstations.xlsx": {
"family": true, "family": true,
@@ -136,8 +136,8 @@
"note": "K-Ebene: Accessories & Cables" "note": "K-Ebene: Accessories & Cables"
}, },
"Infrastructure and Accessories Product Pages/Chassis Systems.xlsx": { "Infrastructure and Accessories Product Pages/Chassis Systems.xlsx": {
"product": null, "family": true,
"note": "manuell zuordnen: verstecktes/abweichend benanntes Produkt ODER Familienseite" "note": "Sammelseite (K-Ebene) - Entscheidung Olli 2026-09-28"
}, },
"Infrastructure and Accessories Product Pages/Express Backplanes.xlsx": { "Infrastructure and Accessories Product Pages/Express Backplanes.xlsx": {
"family": true, "family": true,
@@ -166,8 +166,8 @@
"product": "visionhd4" "product": "visionhd4"
}, },
"Video Capture Product Pages/VisionIO-SDI4.xlsx": { "Video Capture Product Pages/VisionIO-SDI4.xlsx": {
"product": null, "product": "visionio-sd14",
"note": "manuell zuordnen: verstecktes/abweichend benanntes Produkt ODER Familienseite" "note": "Dev-Name kanonisch (OCR-Abweichung SDI4/SD14), Zuordnung Olli 2026-09-28"
}, },
"Video Capture Product Pages/VisionIO-XD2.xlsx": { "Video Capture Product Pages/VisionIO-XD2.xlsx": {
"product": "visionio-xd2" "product": "visionio-xd2"
@@ -212,8 +212,8 @@
"product": "visionsc-s2" "product": "visionsc-s2"
}, },
"Video Capture Product Pages/VisionSC-SDI4.xlsx": { "Video Capture Product Pages/VisionSC-SDI4.xlsx": {
"product": null, "product": "visionsc-sd14",
"note": "manuell zuordnen: verstecktes/abweichend benanntes Produkt ODER Familienseite" "note": "Dev-Name kanonisch (OCR-Abweichung SDI4/SD14), Zuordnung Olli 2026-09-28"
}, },
"Video Capture Product Pages/VisionSC-UHD2.xlsx": { "Video Capture Product Pages/VisionSC-UHD2.xlsx": {
"product": "visionsc-uhd2" "product": "visionsc-uhd2"
@@ -247,8 +247,8 @@
"note": "manuell 2026-09-24: Encoder (Hero: ultra-low latency HEVC encoding); Praefix-Match war doppeldeutig (encoder/decoder)" "note": "manuell 2026-09-24: Encoder (Hero: ultra-low latency HEVC encoding); Praefix-Match war doppeldeutig (encoder/decoder)"
}, },
"Video Encoding and Streaming Product Pages/MGW Diamond OG.xlsx": { "Video Encoding and Streaming Product Pages/MGW Diamond OG.xlsx": {
"product": null, "product": "mgw-diamond-og-sdi-composite-blade-encoder",
"note": "KLAEREN mit Olli 2026-09-24: Record uid 20 heisst 'MGW Diamond+ OG' bei Slug mgw-diamond-og-sdi-blade-encoder (Titel/Slug widerspruechlich); gehoert dieses Workbook oder MGW_Diamond_Plus_OG.xlsx zu uid 20?" "note": "uid 19, Titel exakt - geklaert Olli 2026-09-28"
}, },
"Video Encoding and Streaming Product Pages/MGW Diamond-H_Product Page Content.xlsx": { "Video Encoding and Streaming Product Pages/MGW Diamond-H_Product Page Content.xlsx": {
"product": "mgw-diamond-h-hdmi-encoder" "product": "mgw-diamond-h-hdmi-encoder"
@@ -273,12 +273,12 @@
"product": "mgw-diamond-hx-og" "product": "mgw-diamond-hx-og"
}, },
"Video Encoding and Streaming Product Pages/MGW_Diamond_Plus_OG.xlsx": { "Video Encoding and Streaming Product Pages/MGW_Diamond_Plus_OG.xlsx": {
"product": null, "product": "mgw-diamond-og-sdi-blade-encoder",
"note": "KLAEREN mit Olli 2026-09-24: siehe MGW Diamond OG.xlsx - uid 20 traegt den Plus-Titel bei Plain-Slug" "note": "uid 20 MGW Diamond+ OG, Slug ohne plus ist dev-kanonisch - geklaert Olli 2026-09-28"
}, },
"Video Encoding and Streaming Product Pages/PCIe Encoders_Product Page Content.xlsx": { "Video Encoding and Streaming Product Pages/PCIe Encoders_Product Page Content.xlsx": {
"product": null, "family": true,
"note": "manuell zuordnen: verstecktes/abweichend benanntes Produkt ODER Familienseite" "note": "Sammelseite (K-Ebene) - Entscheidung Olli 2026-09-28"
}, },
"Video Encoding and Streaming Product Pages/VITEC OG_Product Page Content.xlsx": { "Video Encoding and Streaming Product Pages/VITEC OG_Product Page Content.xlsx": {
"family": true, "family": true,
@@ -329,11 +329,11 @@
}, },
"Video Wall and Display Processing Product Pages/WallControl_10.xlsx": { "Video Wall and Display Processing Product Pages/WallControl_10.xlsx": {
"product": null, "product": null,
"note": "manuell zuordnen: verstecktes/abweichend benanntes Produkt ODER Familienseite" "note": "GEPARKT 2026-09-28 (Revision der Zuordnung auf aetria-wallcontrol): Dry-Run zeigte, dass aetria-wallcontrol bereits vom Workbook Aetria WallControl (Control Room Platforms) befuellt ist - WallControl_10 haette es ueberschrieben. Eigenes Produkt oder verwerfen: Olli entscheidet."
}, },
"Video Wall and Display Processing Product Pages/X-Series (Multi-display Processors)_Product Page Content.xlsx": { "Video Wall and Display Processing Product Pages/X-Series (Multi-display Processors)_Product Page Content.xlsx": {
"family": true, "family": true,
"note": "K-Ebene: X-Series (Multi-display Processors)" "note": "K-Ebene: X-Series (Multi-display Processors)"
} }
} }
} }
+82
View File
@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
/**
* One-time rebuild of tx_vitec_product_related_text (2026-09-28).
*
* The table stored the per-pair related-product card copy with a composite
* primary key and no uid/pid - fine for the import module, invisible to
* FormEngine. To surface the texts as an inline list on the product record
* the table needs a proper uid PK and a pid, and MySQL cannot grow an
* auto_increment PK onto an existing composite-key table through the TYPO3
* schema compare - hence this manual rebuild.
*
* Copies every row, backfilling:
* pid = the owning product's pid
* sorting = the pair's tx_vitec_product_related_mm sorting (0 when the
* relation has meanwhile been removed - such orphan texts are
* kept; they become active again if the relation returns)
* crdate = old tstamp
*
* Idempotent: exits when the uid column already exists.
*
* Usage: php migrations/rebuild_related_text_table.php
* (BEFORE vendor/bin/typo3 extension:setup)
*/
$root = null;
$dir = __DIR__;
for ($i = 0; $i < 5; $i++) {
if (is_file($dir . '/config/system/settings.php')) { $root = $dir; break; }
$dir = dirname($dir);
}
if ($root === null) { exit("Could not locate project root above " . __DIR__ . "\n"); }
$settings = require $root . '/config/system/settings.php';
$db = $settings['DB']['Connections']['Default'];
$pdo = new PDO(
sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', $db['host'] ?? 'localhost', $db['port'] ?? 3306, $db['dbname'] ?? ''),
$db['user'] ?? '', $db['password'] ?? '',
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]
);
$table = 'tx_vitec_product_related_text';
$cols = $pdo->query('SHOW COLUMNS FROM `' . $table . '`')->fetchAll();
$names = array_column($cols, 'Field');
if (in_array('uid', $names, true)) {
exit("$table already has a uid column - nothing to do.\n");
}
$count = (int)$pdo->query('SELECT COUNT(*) FROM `' . $table . '`')->fetchColumn();
echo "Rebuilding $table ($count rows) ...\n";
$pdo->exec('DROP TABLE IF EXISTS `' . $table . '_new`');
$pdo->exec('CREATE TABLE `' . $table . '_new` (
uid int(11) NOT NULL auto_increment,
pid int(11) DEFAULT 0 NOT NULL,
product_uid int(11) DEFAULT 0 NOT NULL,
related_uid int(11) DEFAULT 0 NOT NULL,
sorting int(11) DEFAULT 0 NOT NULL,
cardtext text,
tstamp int(11) DEFAULT 0 NOT NULL,
crdate int(11) DEFAULT 0 NOT NULL,
PRIMARY KEY (uid),
UNIQUE KEY pair (product_uid, related_uid)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4');
$copied = $pdo->exec('INSERT INTO `' . $table . '_new`
(pid, product_uid, related_uid, sorting, cardtext, tstamp, crdate)
SELECT COALESCE(p.pid, 0), t.product_uid, t.related_uid,
COALESCE(mm.sorting, 0), t.cardtext, t.tstamp, t.tstamp
FROM `' . $table . '` t
LEFT JOIN tx_vitec_domain_model_product p ON p.uid = t.product_uid
LEFT JOIN tx_vitec_product_related_mm mm
ON mm.uid_local = t.product_uid AND mm.uid_foreign = t.related_uid');
$pdo->exec('RENAME TABLE `' . $table . '` TO `' . $table . '_old`, `' . $table . '_new` TO `' . $table . '`');
$pdo->exec('DROP TABLE `' . $table . '_old`');
echo "Done: $copied rows copied, old table dropped.\n";
echo "Next: vendor/bin/typo3 extension:setup && vendor/bin/typo3 cache:flush\n";
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace Evomedien\FeComment\EventListener;
use Evomedien\FeComment\Service\ExtensionSettings;
use TYPO3\CMS\Core\Http\ApplicationType;
use TYPO3\CMS\Core\Page\Event\BeforeJavaScriptsRenderingEvent;
/**
* Classic-site mode (extension setting jsonOutput = 0): add the widget
* script to every TYPO3-rendered frontend page, so the feedback button
* appears with zero TypoScript. In headless mode (jsonOutput = 1, the
* default) this listener does nothing - the SPA embeds
* /api/fe-comment/widget.js itself.
*/
final class InjectWidget
{
public function __invoke(BeforeJavaScriptsRenderingEvent $event): void
{
if ($event->isInline() || $event->isPriority()) {
return;
}
try {
$request = $GLOBALS['TYPO3_REQUEST'] ?? null;
if ($request === null || !ApplicationType::fromRequest($request)->isFrontend()) {
return;
}
$settings = new ExtensionSettings();
if (!$settings->enabled() || $settings->jsonOutput()) {
return;
}
$event->getAssetCollector()->addJavaScript(
'fe-comment-widget',
'/api/fe-comment/widget.js',
['defer' => 'defer'],
['useNonce' => true]
);
} catch (\Throwable) {
// a broken feedback widget must never break the page
}
}
}
@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace Evomedien\FeComment\Middleware;
use Evomedien\FeComment\Service\ExtensionSettings;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use TYPO3\CMS\Core\Http\JsonResponse;
use TYPO3\CMS\Core\Mail\MailMessage;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* POST /api/fe-comment/submit - sends the note pad by mail.
*
* Payload: {"entries": [{"url": "/product/hx4", "comment": "..."}]}
* Response: {"success": true} or {"success": false, "error": "..."}
*
* Recipients (to/cc/bcc) and subject come from the extension configuration;
* without a configured "to" the endpoint refuses, so feedback can never
* silently vanish. The sender is the system default mail-from.
*/
final class SubmitMiddleware implements MiddlewareInterface
{
private const PATH = '/api/fe-comment/submit';
private const MAX_ENTRIES = 100;
private const MAX_URL_LENGTH = 2000;
private const MAX_COMMENT_LENGTH = 10000;
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
if (rtrim($request->getUri()->getPath(), '/') !== self::PATH) {
return $handler->handle($request);
}
if (strtoupper($request->getMethod()) !== 'POST') {
return new JsonResponse(['success' => false, 'error' => 'POST only'], 405);
}
try {
$settings = GeneralUtility::makeInstance(ExtensionSettings::class);
if (!$settings->enabled()) {
return new JsonResponse(['success' => false, 'error' => 'Feedback is disabled'], 403);
}
if ($settings->mailTo() === []) {
return new JsonResponse(['success' => false, 'error' => 'No recipient configured (extension configuration fe_comment)'], 503);
}
$payload = json_decode((string)$request->getBody(), true);
$entries = is_array($payload['entries'] ?? null) ? $payload['entries'] : [];
$clean = [];
foreach (array_slice($entries, 0, self::MAX_ENTRIES) as $entry) {
$url = mb_substr(trim((string)($entry['url'] ?? '')), 0, self::MAX_URL_LENGTH);
$comment = mb_substr(trim((string)($entry['comment'] ?? '')), 0, self::MAX_COMMENT_LENGTH);
if ($comment !== '') {
$clean[] = ['url' => $url, 'comment' => $comment];
}
}
if ($clean === []) {
return new JsonResponse(['success' => false, 'error' => 'No entries'], 400);
}
$host = $request->getUri()->getHost();
$lines = ['Page feedback from ' . $host, 'Submitted: ' . date('Y-m-d H:i'), str_repeat('-', 60), ''];
foreach ($clean as $i => $entry) {
$lines[] = ($i + 1) . ') ' . ($entry['url'] !== '' ? $entry['url'] : '(no url)');
$lines[] = $entry['comment'];
$lines[] = '';
}
$mail = GeneralUtility::makeInstance(MailMessage::class);
$mail->to(...$settings->mailTo())
->subject($settings->subject() . ' — ' . $host . ' (' . count($clean) . ')')
->text(implode("\n", $lines));
if ($settings->mailCc() !== []) {
$mail->cc(...$settings->mailCc());
}
if ($settings->mailBcc() !== []) {
$mail->bcc(...$settings->mailBcc());
}
// v14: MailMessage no longer has send() - the Mailer service does.
GeneralUtility::makeInstance(\TYPO3\CMS\Core\Mail\Mailer::class)->send($mail);
return new JsonResponse(['success' => true, 'sent' => count($clean)]);
} catch (\Throwable $e) {
return new JsonResponse(['success' => false, 'error' => 'Send failed: ' . $e->getMessage()], 500);
}
}
}
@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace Evomedien\FeComment\Middleware;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use TYPO3\CMS\Core\Http\Response;
use TYPO3\CMS\Core\Http\Stream;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Serves the widget assets under STABLE urls, independent of composer's
* hashed _assets publishing:
*
* /api/fe-comment/widget.js
* /api/fe-comment/widget.css
*
* One <script> tag is the whole integration - the JS injects its own CSS
* link. Short client cache so editors see fixes quickly; the widget is a
* review tool, not a high-traffic asset.
*/
final class WidgetAssetMiddleware implements MiddlewareInterface
{
private const FILES = [
'/api/fe-comment/widget.js' => ['EXT:fe_comment/Resources/Public/JavaScript/fe-comment.js', 'application/javascript; charset=utf-8'],
'/api/fe-comment/widget.css' => ['EXT:fe_comment/Resources/Public/Css/fe-comment.css', 'text/css; charset=utf-8'],
];
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$path = $request->getUri()->getPath();
$file = self::FILES[$path] ?? null;
if ($file === null) {
return $handler->handle($request);
}
// Kill switch: the go-live off state serves an EMPTY file instead of
// a 404, so a front end still embedding the script tag stays silent -
// no console errors, no build change needed.
if (!GeneralUtility::makeInstance(\Evomedien\FeComment\Service\ExtensionSettings::class)->enabled()) {
return (new Response())
->withHeader('Content-Type', $file[1])
->withHeader('Cache-Control', 'public, max-age=300')
->withBody(GeneralUtility::makeInstance(\TYPO3\CMS\Core\Http\StreamFactory::class)
->createStream('/* fe_comment is disabled (extension configuration) */'));
}
$absolute = GeneralUtility::getFileAbsFileName($file[0]);
if ($absolute === '' || !is_file($absolute)) {
return (new Response())->withStatus(404);
}
return (new Response())
->withHeader('Content-Type', $file[1])
->withHeader('Cache-Control', 'public, max-age=300')
->withBody(new Stream($absolute, 'rb'));
}
}
@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace Evomedien\FeComment\Service;
use TYPO3\CMS\Core\Configuration\ExtensionConfiguration;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Typed access to the extension configuration (Settings > Extension
* Configuration > fe_comment). Every accessor is failure-tolerant: an
* unconfigured extension behaves like "everything empty, headless mode".
*/
final class ExtensionSettings
{
/** @var array<string,mixed> */
private array $settings;
public function __construct()
{
try {
$this->settings = (array)GeneralUtility::makeInstance(ExtensionConfiguration::class)->get('fe_comment');
} catch (\Throwable) {
$this->settings = [];
}
}
/** @return string[] */
public function mailTo(): array
{
return $this->addressList('mailTo');
}
/** @return string[] */
public function mailCc(): array
{
return $this->addressList('mailCc');
}
/** @return string[] */
public function mailBcc(): array
{
return $this->addressList('mailBcc');
}
public function subject(): string
{
$subject = trim((string)($this->settings['subject'] ?? ''));
return $subject !== '' ? $subject : 'Page feedback';
}
/** Master switch: off serves an empty widget and rejects submissions. */
public function enabled(): bool
{
return (bool)($this->settings['enabled'] ?? true);
}
/** true = headless: TYPO3 injects nothing, the front end embeds the widget itself. */
public function jsonOutput(): bool
{
return (bool)($this->settings['jsonOutput'] ?? true);
}
/** @return string[] valid mail addresses only */
private function addressList(string $key): array
{
$addresses = [];
foreach (GeneralUtility::trimExplode(',', (string)($this->settings[$key] ?? ''), true) as $address) {
if (GeneralUtility::validEmail($address)) {
$addresses[] = $address;
}
}
return $addresses;
}
}
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
/**
* Frontend middlewares for EXT:fe_comment. Both answer before site/page
* resolution - the endpoints exist on every site the extension is loaded in.
*/
return [
'frontend' => [
'fecomment/widget-assets' => [
'target' => \Evomedien\FeComment\Middleware\WidgetAssetMiddleware::class,
'after' => [
'typo3/cms-core/normalized-params-attribute',
],
'before' => [
'typo3/cms-frontend/site',
],
],
'fecomment/submit' => [
'target' => \Evomedien\FeComment\Middleware\SubmitMiddleware::class,
'after' => [
'typo3/cms-core/normalized-params-attribute',
],
'before' => [
'typo3/cms-frontend/site',
],
],
],
];
@@ -0,0 +1,14 @@
services:
_defaults:
autowire: true
autoconfigure: true
public: false
Evomedien\FeComment\:
resource: '../Classes/*'
Evomedien\FeComment\EventListener\InjectWidget:
tags:
- name: event.listener
identifier: 'fecomment/inject-widget'
event: TYPO3\CMS\Core\Page\Event\BeforeJavaScriptsRenderingEvent
+47
View File
@@ -0,0 +1,47 @@
# EXT:fe_comment — page feedback widget
A floating button at the right edge of every page lets reviewers (e.g. the
customer) comment on the page they are looking at. Notes collect on a local
note pad (localStorage, survives navigation and SPA route changes); the pad
lists every note with its URL and editable text and submits them all in one
mail.
Framework-agnostic: the whole UI is one vanilla-JS file served by the
extension itself — it works on a React/SPA front end and on classic
TYPO3-rendered pages alike.
## Endpoints (middleware, active on every site with the extension installed)
| URL | Purpose |
|---|---|
| `/api/fe-comment/widget.js` | the widget (injects its own CSS) |
| `/api/fe-comment/widget.css` | widget styles |
| `/api/fe-comment/submit` | POST `{"entries":[{"url","comment"}]}` → sends the mail |
## Configuration (Settings → Extension Configuration → fe_comment)
- **mailTo** (required) / **mailCc** / **mailBcc** — comma-separated lists.
Without a valid `mailTo`, submissions are rejected (HTTP 503), nothing is
lost silently.
- **subject** — subject line, host and entry count are appended.
- **jsonOutput** — `1` (default): headless mode, TYPO3 injects nothing; the
front end embeds the widget itself:
`<script src="/api/fe-comment/widget.js" defer></script>`
`0`: classic TYPO3 site, the extension injects the script into every
rendered frontend page automatically (AssetCollector, no TypoScript).
The sender address is TYPO3's system default
(`$GLOBALS['TYPO3_CONF_VARS']['MAIL']['defaultMailFromAddress']`).
## Notes for SPA setups
- The page URL is read at SAVE time (`location.pathname + search`), so
client-side route changes are captured correctly.
- If page URLs are rewritten to an SPA fallback (like the vitec `.htaccess`
phase B block), `/api/` paths must stay excluded from the fallback so the
endpoints keep reaching TYPO3.
## Labels
UI labels are German and live in the `T` object at the top of
`Resources/Public/JavaScript/fe-comment.js`.
@@ -0,0 +1,121 @@
/* EXT:fe_comment widget — isolated styles, .evofc-prefixed, high z-index. */
.evofc {
position: fixed;
right: 0;
top: 50%;
transform: translateY(-50%);
z-index: 2147483000;
font-family: -apple-system, "Segoe UI", Roboto, Arial, sans-serif;
font-size: 14px;
line-height: 1.45;
color: #1c2430;
}
.evofc-toggle {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border: 0;
border-radius: 8px 0 0 8px;
background: #1f6feb;
color: #fff;
cursor: pointer;
box-shadow: 0 2px 10px rgba(0, 0, 0, .25);
}
.evofc-toggle:hover { background: #1a5fd0; }
.evofc-badge {
position: absolute;
top: -6px;
left: -6px;
min-width: 18px;
height: 18px;
padding: 0 4px;
border-radius: 9px;
background: #d9463e;
color: #fff;
font-size: 11px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
}
.evofc-panel {
position: absolute;
right: 52px;
top: 50%;
transform: translateY(-50%);
width: 320px;
max-width: calc(100vw - 70px);
max-height: 80vh;
overflow: auto;
background: #fff;
border: 1px solid #d5dbe3;
border-radius: 10px;
box-shadow: 0 8px 30px rgba(0, 0, 0, .22);
padding: 14px;
}
.evofc-title { font-weight: 700; margin-bottom: 10px; }
.evofc-label { display: block; font-size: 12px; color: #5a6572; margin: 8px 0 2px; }
.evofc-url,
.evofc-text {
width: 100%;
box-sizing: border-box;
border: 1px solid #c9d1da;
border-radius: 6px;
padding: 6px 8px;
font: inherit;
color: inherit;
background: #fff;
}
.evofc-url { background: #f3f5f7; color: #5a6572; }
.evofc-text { resize: vertical; }
.evofc-row { display: flex; gap: 8px; align-items: center; margin-top: 10px; }
.evofc-button {
border: 1px solid #c9d1da;
border-radius: 6px;
background: #f6f8fa;
color: #1c2430;
padding: 7px 12px;
font: inherit;
font-weight: 600;
cursor: pointer;
}
.evofc-button:hover { background: #eef1f4; }
.evofc-primary { background: #1f6feb; border-color: #1f6feb; color: #fff; }
.evofc-primary:hover { background: #1a5fd0; }
.evofc-primary:disabled { opacity: .6; cursor: default; }
.evofc-hint { color: #2f9e44; font-size: 12px; }
.evofc-footer { margin-top: 10px; }
.evofc-empty, .evofc-status { margin: 8px 0 0; font-size: 13px; }
.evofc-status { font-weight: 600; }
.evofc-list { display: flex; flex-direction: column; gap: 10px; }
.evofc-item { border: 1px solid #e2e7ec; border-radius: 8px; padding: 8px; }
.evofc-item-head { display: flex; justify-content: space-between; align-items: center; gap: 6px; margin-bottom: 6px; }
.evofc-item-url {
font-size: 12px;
color: #5a6572;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.evofc-remove {
border: 0;
background: none;
color: #d9463e;
font-size: 16px;
line-height: 1;
cursor: pointer;
padding: 2px 4px;
}
@@ -0,0 +1,258 @@
/**
* EXT:fe_comment — page feedback widget (framework-agnostic, vanilla JS).
*
* One floating button at the right edge opens a small form: current page
* URL + a comment field. Saved notes land on a local note pad
* (localStorage, survives navigation — also SPA route changes, the URL is
* read at save time). The pad lists every note (URL + editable text) and
* submits all of them to POST /api/fe-comment/submit, which mails them.
*
* Integration: <script src="/api/fe-comment/widget.js" defer></script>
* (classic TYPO3 sites get this injected by the extension automatically
* when the "JSON output" setting is off).
*/
(function () {
'use strict';
if (window.__evofcLoaded) { return; }
window.__evofcLoaded = true;
var ENDPOINT = '/api/fe-comment/submit';
var CSS_URL = '/api/fe-comment/widget.css';
var STORAGE_KEY = 'evoFeCommentNotes';
var T = {
open: 'Seite kommentieren',
formTitle: 'Anmerkung zu dieser Seite',
urlLabel: 'Seite',
commentLabel: 'Anmerkung',
placeholder: 'Was ist dir aufgefallen?',
save: 'Auf den Merkzettel',
showPad: 'Merkzettel',
padTitle: 'Merkzettel',
padEmpty: 'Noch keine Anmerkungen.',
remove: 'Entfernen',
submit: 'Absenden',
submitting: 'Sende …',
back: 'Zurück',
savedHint: 'Gespeichert ✓',
sentOk: 'Danke! Die Anmerkungen wurden gesendet.',
sentFail: 'Senden fehlgeschlagen: '
};
function loadNotes() {
try {
var raw = localStorage.getItem(STORAGE_KEY);
var list = raw ? JSON.parse(raw) : [];
return Array.isArray(list) ? list : [];
} catch (e) { return []; }
}
function saveNotes(notes) {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(notes)); } catch (e) { /* private mode */ }
}
function currentUrl() {
return location.pathname + location.search;
}
function el(tag, className, text) {
var node = document.createElement(tag);
if (className) { node.className = className; }
if (text) { node.textContent = text; }
return node;
}
// ---- styles ------------------------------------------------------------
var link = document.createElement('link');
link.rel = 'stylesheet';
link.href = CSS_URL;
document.head.appendChild(link);
// ---- structure ---------------------------------------------------------
var root = el('div', 'evofc');
root.setAttribute('data-evofc', '');
var toggle = el('button', 'evofc-toggle');
toggle.type = 'button';
toggle.title = T.open;
toggle.setAttribute('aria-label', T.open);
toggle.innerHTML =
'<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true">' +
'<path fill="currentColor" d="M4 3h16a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H8.4L4 21.6A1 1 0 0 1 2.4 20.8V5a2 2 0 0 1 2-2Zm2.6 5.2h11m-11 4h7"' +
' stroke="currentColor" stroke-width="1.6" stroke-linecap="round" fill="none"/></svg>';
var badge = el('span', 'evofc-badge');
badge.hidden = true;
toggle.appendChild(badge);
var panel = el('div', 'evofc-panel');
panel.hidden = true;
root.appendChild(toggle);
root.appendChild(panel);
// ---- views -------------------------------------------------------------
var view = 'form'; // 'form' | 'pad'
function refreshBadge() {
var count = loadNotes().length;
badge.textContent = String(count);
badge.hidden = count === 0;
}
function renderForm() {
view = 'form';
panel.innerHTML = '';
panel.appendChild(el('div', 'evofc-title', T.formTitle));
var urlLabel = el('label', 'evofc-label', T.urlLabel);
var urlInput = el('input', 'evofc-url');
urlInput.type = 'text';
urlInput.readOnly = true;
urlInput.value = currentUrl();
var commentLabel = el('label', 'evofc-label', T.commentLabel);
var textarea = el('textarea', 'evofc-text');
textarea.rows = 4;
textarea.placeholder = T.placeholder;
var saveButton = el('button', 'evofc-button evofc-primary', T.save);
saveButton.type = 'button';
var hint = el('span', 'evofc-hint');
saveButton.addEventListener('click', function () {
var comment = textarea.value.trim();
if (comment === '') { textarea.focus(); return; }
var notes = loadNotes();
notes.push({ url: urlInput.value, comment: comment, added: Date.now() });
saveNotes(notes);
textarea.value = '';
refreshBadge();
renderFooter(footer);
hint.textContent = T.savedHint;
setTimeout(function () { hint.textContent = ''; }, 1800);
});
var footer = el('div', 'evofc-footer');
renderFooter(footer);
panel.appendChild(urlLabel);
panel.appendChild(urlInput);
panel.appendChild(commentLabel);
panel.appendChild(textarea);
var row = el('div', 'evofc-row');
row.appendChild(saveButton);
row.appendChild(hint);
panel.appendChild(row);
panel.appendChild(footer);
}
function renderFooter(footer) {
footer.innerHTML = '';
var count = loadNotes().length;
if (count > 0) {
var padButton = el('button', 'evofc-button', T.showPad + ' (' + count + ')');
padButton.type = 'button';
padButton.addEventListener('click', renderPad);
footer.appendChild(padButton);
}
}
function renderPad() {
view = 'pad';
panel.innerHTML = '';
panel.appendChild(el('div', 'evofc-title', T.padTitle));
var notes = loadNotes();
if (notes.length === 0) {
panel.appendChild(el('p', 'evofc-empty', T.padEmpty));
}
var list = el('div', 'evofc-list');
notes.forEach(function (note, index) {
var item = el('div', 'evofc-item');
var head = el('div', 'evofc-item-head');
head.appendChild(el('span', 'evofc-item-url', note.url || '(ohne URL)'));
var removeButton = el('button', 'evofc-remove', '×');
removeButton.type = 'button';
removeButton.title = T.remove;
removeButton.addEventListener('click', function () {
var current = loadNotes();
current.splice(index, 1);
saveNotes(current);
refreshBadge();
renderPad();
});
head.appendChild(removeButton);
var textarea = el('textarea', 'evofc-text');
textarea.rows = 3;
textarea.value = note.comment;
textarea.addEventListener('input', function () {
var current = loadNotes();
if (current[index]) {
current[index].comment = textarea.value;
saveNotes(current);
}
});
item.appendChild(head);
item.appendChild(textarea);
list.appendChild(item);
});
panel.appendChild(list);
var status = el('p', 'evofc-status');
var row = el('div', 'evofc-row');
var backButton = el('button', 'evofc-button', T.back);
backButton.type = 'button';
backButton.addEventListener('click', renderForm);
row.appendChild(backButton);
if (notes.length > 0) {
var submitButton = el('button', 'evofc-button evofc-primary', T.submit);
submitButton.type = 'button';
submitButton.addEventListener('click', function () {
submitButton.disabled = true;
submitButton.textContent = T.submitting;
var entries = loadNotes().map(function (note) {
return { url: note.url, comment: note.comment };
});
fetch(ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ entries: entries })
}).then(function (response) { return response.json(); }).then(function (result) {
if (result && result.success) {
saveNotes([]);
refreshBadge();
renderPad();
status.textContent = T.sentOk;
panel.appendChild(status);
} else {
throw new Error((result && result.error) || 'unknown');
}
}).catch(function (error) {
submitButton.disabled = false;
submitButton.textContent = T.submit;
status.textContent = T.sentFail + error.message;
panel.appendChild(status);
});
});
row.appendChild(submitButton);
}
panel.appendChild(row);
}
toggle.addEventListener('click', function () {
if (panel.hidden) {
renderForm();
panel.hidden = false;
} else {
panel.hidden = true;
}
});
refreshBadge();
if (document.body) {
document.body.appendChild(root);
} else {
document.addEventListener('DOMContentLoaded', function () { document.body.appendChild(root); });
}
})();
+20
View File
@@ -0,0 +1,20 @@
{
"name": "evomedien/fe-comment",
"version": "1.0.0",
"type": "typo3-cms-extension",
"description": "Page feedback widget: visitors collect comments per page on a note pad and submit them by mail. Framework-agnostic (works on headless/SPA and classic TYPO3 sites).",
"license": "GPL-2.0-or-later",
"require": {
"typo3/cms-core": "^14.0"
},
"autoload": {
"psr-4": {
"Evomedien\\FeComment\\": "Classes/"
}
},
"extra": {
"typo3/cms": {
"extension-key": "fe_comment"
}
}
}
+17
View File
@@ -0,0 +1,17 @@
# cat=basic//10; type=boolean; label=Enabled: Master switch. 0 = the widget script is served EMPTY and submissions are rejected - the kill switch for go-live, no front-end build change needed.
enabled = 1
# cat=mail//10; type=string; label=Mail to: Recipient address(es) for submitted feedback, comma separated. Empty = submissions are rejected with an error.
mailTo =
# cat=mail//20; type=string; label=Mail cc: Optional, comma separated.
mailCc =
# cat=mail//30; type=string; label=Mail bcc: Optional, comma separated.
mailBcc =
# cat=mail//40; type=string; label=Subject: Subject line of the feedback mail.
subject = Page feedback
# cat=output//10; type=boolean; label=JSON output (headless mode): 1 = TYPO3 injects nothing, the front end embeds <script src="/api/fe-comment/widget.js"> itself (SPA / React). 0 = classic TYPO3 site, the widget script is injected into every rendered page automatically.
jsonOutput = 1
@@ -154,19 +154,14 @@ final class ImportProductWorkbookCommand extends Command
} }
/** /**
* Compare tolerant of the RTE storage transform, which wraps bare * Compare tolerant of the RTE storage transform - shared with the
* content in <p>...</p> and strips newlines on save - without this every * module's "differs" badge, see RteCompare. The local version missed
* re-run reported already-imported richtext fields as WRITE (the known * inter-tag whitespace ("</p> <ul>" vs "</p><ul>") and entity
* false-positive from the module's preview). * re-encoding, which made the g: fields report WRITE on every run.
*/ */
private function isUnchanged(string $old, string $new): bool private function isUnchanged(string $old, string $new): bool
{ {
$normalize = static fn(string $s): string => trim((string)preg_replace('/\s+/u', ' ', $s)); return \Evomedien\Vitec\Service\RteCompare::equal($old, $new);
if ($normalize($old) === $normalize($new)) {
return true;
}
return $normalize($old) === $normalize('<p>' . $new . '</p>');
} }
/** @return array<string,mixed>|null */ /** @return array<string,mixed>|null */
@@ -530,7 +530,10 @@ final class ProductTextImportController
$selector = $submittedMap[$field] ?? $saved[$field] ?? self::DEFAULT_MAPPING[$field] ?? ''; $selector = $submittedMap[$field] ?? $saved[$field] ?? self::DEFAULT_MAPPING[$field] ?? '';
$old = (string)($product[$field] ?? ''); $old = (string)($product[$field] ?? '');
$new = ($parsed !== null && $selector !== '') ? $this->reader->valueFor($parsed, $selector) : null; $new = ($parsed !== null && $selector !== '') ? $this->reader->valueFor($parsed, $selector) : null;
$changed = $new !== null && trim($new) !== trim($old); // RTE-tolerant, shared with the CLI - a plain trim comparison
// flagged "differs" on pure storage-transform noise (inter-tag
// whitespace, <p> wrap, entity re-encoding).
$changed = $new !== null && !\Evomedien\Vitec\Service\RteCompare::equal($old, $new);
$rows[] = [ $rows[] = [
'field' => $field, 'field' => $field,
'label' => $label, 'label' => $label,
@@ -720,9 +723,25 @@ final class ProductTextImportController
$keys = ['product_uid' => $productUid, 'related_uid' => $relatedUid]; $keys = ['product_uid' => $productUid, 'related_uid' => $relatedUid];
if ($connection->count('product_uid', 'tx_vitec_product_related_text', $keys) > 0) { if ($connection->count('product_uid', 'tx_vitec_product_related_text', $keys) > 0) {
$connection->update('tx_vitec_product_related_text', ['cardtext' => $text, 'tstamp' => time()], $keys); $connection->update('tx_vitec_product_related_text', ['cardtext' => $text, 'tstamp' => time()], $keys);
} else { return;
$connection->insert('tx_vitec_product_related_text', $keys + ['cardtext' => $text, 'tstamp' => time()]);
} }
// Since the table became TCA-visible (inline on the product,
// 2026-09-28) new rows need pid, crdate and the pair's MM sorting -
// the relation is written right before this call, so it exists.
$pid = (int)$connection->fetchOne(
'SELECT pid FROM ' . self::TABLE . ' WHERE uid = ?', [$productUid]
);
$sorting = (int)$connection->fetchOne(
'SELECT sorting FROM tx_vitec_product_related_mm WHERE uid_local = ? AND uid_foreign = ?',
[$productUid, $relatedUid]
);
$connection->insert('tx_vitec_product_related_text', $keys + [
'cardtext' => $text,
'tstamp' => time(),
'crdate' => time(),
'pid' => $pid,
'sorting' => $sorting,
]);
} }
private function preview(string $value): string private function preview(string $value): string
@@ -41,8 +41,10 @@ final class EmailDelivery implements DeliveryInterface
$mail->replyTo($submitterEmail); $mail->replyTo($submitterEmail);
} }
if (!$mail->send()) { // v14: MailMessage::send() was removed - sending goes through the
throw new \RuntimeException('MailMessage::send() reported failure.'); // Mailer service now (failures throw). Found 2026-09-28 via the
} // identical call in EXT:fe_comment: this delivery was latently
// broken since the core upgrade.
GeneralUtility::makeInstance(\TYPO3\CMS\Core\Mail\Mailer::class)->send($mail);
} }
} }
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Middleware;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
/**
* /_json/<path> serves the TYPO3 (headless JSON) response for <path>.
*
* Part of the URL swap requested 2026-09-28: page URLs are for visitors
* (the React app - the .htaccess SPA fallback of phase B), the JSON moves
* under the /_json prefix. Apache cannot do the strip itself in a useful
* way - TYPO3 routes by the original REQUEST_URI - so the prefix is
* removed here, before site/page resolution; everything downstream sees
* the plain path and behaves exactly as a direct request would. Same
* pattern as SuccessStoryPathRewrite. The query string is untouched, so
* /_json/?type=834 and the suggest page type work as before.
*
* Runs before the other vitec path middlewares: a front end that prefixes
* ALL its calls may also send /_json/download/... or
* /_json/api/vitec/form/... - after the strip those reach their middleware
* unchanged, so both the prefixed and the direct form keep working.
*
* Exception-safe: any failure leaves the request untouched.
*/
final class JsonPrefixRewrite implements MiddlewareInterface
{
private const PREFIX = '/_json';
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
try {
$path = $request->getUri()->getPath();
if ($path === self::PREFIX || str_starts_with($path, self::PREFIX . '/')) {
$stripped = substr($path, strlen(self::PREFIX));
$request = $request->withUri(
$request->getUri()->withPath($stripped === '' || $stripped === '/' ? '/' : $stripped)
);
}
} catch (\Throwable) {
// untouched request
}
return $handler->handle($request);
}
}
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Service;
/**
* Equality check tolerant of TYPO3's RTE storage transform - THE shared
* comparator for "does the workbook differ from the database?", used by the
* import module's "differs" badge and the CLI's write/unchanged decision.
* One implementation on purpose: the two used to disagree, and the stricter
* one cried wolf on every richtext field.
*
* What the transform changes on save, and what is neutralized here:
* - bare content gets wrapped in <p>...</p> -> wrap simulation
* - newlines are stripped, whitespace collapses -> \s+ collapse
* - whitespace BETWEEN tags is dropped
* ("</p> <ul>" becomes "</p><ul>") -> >\s+< join
* - entities may be re-encoded ("&times;" vs "×") -> entity decode
*
* Anything beyond that (real markup changes) still counts as different.
* The Phase-2 change watch compares workbook against imported_values instead
* of the database precisely because storage transforms exist - this class
* only keeps the LIVE comparison honest.
*/
final class RteCompare
{
public static function equal(string $stored, string $incoming): bool
{
$storedNorm = self::normalize($stored);
if ($storedNorm === self::normalize($incoming)) {
return true;
}
return $storedNorm === self::normalize('<p>' . $incoming . '</p>');
}
private static function normalize(string $html): string
{
$s = html_entity_decode($html, ENT_QUOTES | ENT_HTML5, 'UTF-8');
$s = (string)preg_replace('/\s+/u', ' ', $s);
$s = (string)preg_replace('/>\s+</', '><', $s);
return trim($s);
}
}
@@ -7,6 +7,18 @@ declare(strict_types=1);
*/ */
return [ return [
'frontend' => [ 'frontend' => [
'vitec/json-prefix-rewrite' => [
'target' => \Evomedien\Vitec\Middleware\JsonPrefixRewrite::class,
'after' => [
'typo3/cms-core/normalized-params-attribute',
],
'before' => [
'typo3/cms-frontend/site',
'vitec/form-submission',
'vitec/success-story-path-rewrite',
'vitec/download-file',
],
],
'vitec/form-submission' => [ 'vitec/form-submission' => [
'target' => \Evomedien\Vitec\Middleware\FormSubmissionMiddleware::class, 'target' => \Evomedien\Vitec\Middleware\FormSubmissionMiddleware::class,
'after' => [ 'after' => [
@@ -23,7 +23,7 @@ return [
], ],
], ],
'types' => [ 'types' => [
'1' => ['showitem' => 'title, subtitle, slug, teaser, description, description2, applications, capabilities, highlights, contentelement, contentelementcta, portfolio, showdatapath, textrelatedproducts, relatedprodukt, solutions, '1' => ['showitem' => 'title, subtitle, slug, teaser, description, description2, applications, capabilities, highlights, contentelement, contentelementcta, portfolio, showdatapath, textrelatedproducts, relatedprodukt, related_texts, solutions,
--div--;Resources, resources, datasheets, downloads, --div--;Resources, resources, datasheets, downloads,
--div--;SEO, seotitle, urltitle, seometa, keywords, structureddata, --div--;SEO, seotitle, urltitle, seometa, keywords, structureddata,
--div--;Images and Videos, heroimage, productimage, ogimage, video, videofile, --div--;Images and Videos, heroimage, productimage, ogimage, video, videofile,
@@ -433,6 +433,23 @@ return [
'enableMultiSelectFilterTextfield' => true, // Optional: Adds a search box for filtering 'enableMultiSelectFilterTextfield' => true, // Optional: Adds a search box for filtering
], ],
], ],
'related_texts' => [
'exclude' => true,
'label' => 'Related product card texts',
'description' => 'Card copy per related product (workbook column E). A text is shown only while the same product is also selected under "Related Products" above; extra rows stay dormant, they are not lost.',
'config' => [
'type' => 'inline',
'foreign_table' => 'tx_vitec_product_related_text',
'foreign_field' => 'product_uid',
'foreign_sortby' => 'sorting',
'appearance' => [
'collapseAll' => true,
'useSortable' => true,
'expandSingle' => true,
'newRecordLinkTitle' => 'Add card text',
],
],
],
'solutions' => [ 'solutions' => [
'exclude' => true, 'exclude' => true,
'label' => 'Related Solutions', 'label' => 'Related Solutions',
@@ -0,0 +1,63 @@
<?php
/**
* Per-pair card copy for the related-product cards (workbook column E).
*
* Storage predates this TCA: the import module has been writing these rows
* since 2026-08-21, keyed (product_uid, related_uid), precisely because an MM
* column would be lost - DataHandler rewrites the MM rows on every product
* save. This file only makes the same rows editable, as an inline list on
* the product's General tab (field `related_texts`).
*
* A row whose related product is NOT selected in `relatedprodukt` stays
* dormant: the JSON join is MM-driven, so the text simply does not appear
* until the relation (re)appears. Deliberate - texts survive relation
* round-trips, matching the table's original purpose.
*/
return [
'ctrl' => [
'title' => 'Related product card text',
'label' => 'cardtext',
'label_alt' => 'related_uid',
'tstamp' => 'tstamp',
'crdate' => 'crdate',
'sortby' => 'sorting',
'hideTable' => true,
'iconfile' => 'EXT:vitec/Resources/Public/Icons/Extension.svg',
'security' => [
'ignorePageTypeRestriction' => true,
],
],
'types' => [
'1' => ['showitem' => 'related_uid, cardtext'],
],
'columns' => [
'product_uid' => [
'config' => [
'type' => 'passthrough',
],
],
'related_uid' => [
'label' => 'Related product this text belongs to',
'description' => 'The text is shown on the card of this product. It only reaches the JSON while the same product is also selected under "Related products" above.',
'config' => [
'type' => 'select',
'renderType' => 'selectSingle',
'foreign_table' => 'tx_vitec_domain_model_product',
'sortItems' => ['label' => 'asc'],
'items' => [
['label' => '— choose product —', 'value' => 0],
],
'default' => 0,
],
],
'cardtext' => [
'label' => 'Card text',
'description' => 'Card copy from the workbook (Related Product Card, column E). Plain text, no CTA label - the front end appends its own "Learn more".',
'config' => [
'type' => 'text',
'rows' => 3,
],
],
],
];
+14 -1
View File
@@ -58,6 +58,7 @@ CREATE TABLE tx_vitec_domain_model_product (
capabilities text, capabilities text,
portfolio varchar(1024) DEFAULT '' NOT NULL, portfolio varchar(1024) DEFAULT '' NOT NULL,
textrelatedproducts text, textrelatedproducts text,
related_texts int(11) DEFAULT '0' NOT NULL,
solutions int(11) DEFAULT '0' NOT NULL, solutions int(11) DEFAULT '0' NOT NULL,
resources int(11) DEFAULT '0' NOT NULL, resources int(11) DEFAULT '0' NOT NULL,
datasheets int(11) DEFAULT '0' NOT NULL, datasheets int(11) DEFAULT '0' NOT NULL,
@@ -377,12 +378,24 @@ CREATE TABLE tx_vitec_seo_research (
-- (relation later removed) are simply never joined. No TCA on purpose - -- (relation later removed) are simply never joined. No TCA on purpose -
-- written by the product-text import module, read by ProductShowJsonRenderer. -- written by the product-text import module, read by ProductShowJsonRenderer.
-- --
#
# Since 2026-09-28 a real TCA table (inline on the product, field
# related_texts). Existing installations MUST run
# migrations/rebuild_related_text_table.php BEFORE extension:setup -
# MySQL cannot grow the auto_increment uid onto the old composite-key
# table through the schema compare.
#
CREATE TABLE tx_vitec_product_related_text ( CREATE TABLE tx_vitec_product_related_text (
uid int(11) NOT NULL auto_increment,
pid int(11) DEFAULT '0' NOT NULL,
product_uid int(11) DEFAULT '0' NOT NULL, product_uid int(11) DEFAULT '0' NOT NULL,
related_uid int(11) DEFAULT '0' NOT NULL, related_uid int(11) DEFAULT '0' NOT NULL,
sorting int(11) DEFAULT '0' NOT NULL,
cardtext text, cardtext text,
tstamp int(11) DEFAULT '0' NOT NULL, tstamp int(11) DEFAULT '0' NOT NULL,
PRIMARY KEY (product_uid, related_uid) crdate int(11) DEFAULT '0' NOT NULL,
PRIMARY KEY (uid),
UNIQUE KEY pair (product_uid, related_uid)
); );
-- --
+37 -7
View File
@@ -331,6 +331,36 @@ AddDefaultCharset utf-8
RewriteCond %{REQUEST_FILENAME} !-f RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^typo3/(.*)$ %{ENV:CWD}index.php [QSA,L] RewriteRule ^typo3/(.*)$ %{ENV:CWD}index.php [QSA,L]
# --- VITEC phase B: SPA fallback (PREPARED, switch day only) ----------
# Uncomment this block to make every page URL serve the React app
# (public/_frontend/index.html). TYPO3 keeps answering JSON under
# /_json (the JsonPrefixRewrite middleware strips the prefix) plus the
# direct endpoints excluded below. Requires the front-end build with
# router basename "/" and the /_json API prefix FIRST. Roll back by
# re-commenting. See work log section 5r (2026-09-28).
# TYPO3 v14 install tool: its entry point is the SITE ROOT plus
# ?__typo3_install (install.php only redirects there, PathGuard). The
# backend's Settings/Environment/Maintenance/Upgrade modules depend on
# it - without this exception they show the React app instead.
RewriteCond %{QUERY_STRING} __typo3_install
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^.*$ %{ENV:CWD}index.php [QSA,L]
# The root path needs its own rule: for "/" REQUEST_FILENAME is the
# docroot DIRECTORY, so the !-d guard below skips it and DirectoryIndex
# would hand it to TYPO3 (JSON). The homepage JSON lives at /_json/.
RewriteRule ^$ %{ENV:CWD}_frontend/index.html [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_FILENAME} !-l
RewriteCond %{REQUEST_URI} !^/_json(/|$)
RewriteCond %{REQUEST_URI} !^/typo3(/|$)
RewriteCond %{REQUEST_URI} !^/download/
RewriteCond %{REQUEST_URI} !^/api/
RewriteCond %{REQUEST_URI} !^/_frontend/
RewriteCond %{REQUEST_URI} !^/sitemap
RewriteRule ^.*$ %{ENV:CWD}_frontend/index.html [L]
# ----------------------------------------------------------------------
# If the file/symlink/directory does not exist => Redirect to index.php. # If the file/symlink/directory does not exist => Redirect to index.php.
# For httpd.conf, you need to prefix each '%{REQUEST_FILENAME}' with '%{DOCUMENT_ROOT}'. # For httpd.conf, you need to prefix each '%{REQUEST_FILENAME}' with '%{DOCUMENT_ROOT}'.
RewriteCond %{REQUEST_FILENAME} !-f RewriteCond %{REQUEST_FILENAME} !-f
@@ -397,10 +427,10 @@ FileETag None
# Add your own rules here. # Add your own rules here.
#AuthUserFile "/usr/www/users/vitecevo/live/public/.htpasswd" AuthUserFile "/usr/www/users/vitecevo/live/public/.htpasswd"
#AuthName "DEV" AuthName "DEV"
#AuthType Basic AuthType Basic
#<RequireAny> <RequireAny>
#Require ip 217.70.192.164 Require ip 217.70.192.164
#Require valid-user Require valid-user
#</RequireAny> </RequireAny>
+4 -3
View File
@@ -21,12 +21,13 @@
<meta name="twitter:card" content="summary" /> <meta name="twitter:card" content="summary" />
<meta name="twitter:title" content="VITEC" /> <meta name="twitter:title" content="VITEC" />
<meta name="twitter:description" content="Enterprise IPTV, video streaming, and digital signage solutions." /> <meta name="twitter:description" content="Enterprise IPTV, video streaming, and digital signage solutions." />
<link rel="icon" type="image/svg+xml" href="../fileadmin/icons/favicon.ico" /> <link rel="icon" type="image/svg+xml" href="/fileadmin/icons/favicon.ico" />
<title>VITEC</title> <title>VITEC</title>
<script type="module" crossorigin src="/_frontend/assets/index-B1-7ERRQ.js"></script> <script type="module" crossorigin src="/_frontend/assets/index-DnFsTWc9.js"></script>
<link rel="stylesheet" crossorigin href="/_frontend/assets/index-D5j0LX22.css"> <link rel="stylesheet" crossorigin href="/_frontend/assets/index-D2VJrjll.css">
</head> </head>
<body> <body>
<div id="page"></div>␍ <div id="page"></div>␍
<script src="/api/fe-comment/widget.js" defer></script>
</body> </body>
</html> </html>