From f0b069484261513a9d01524a0c602922a097ac34 Mon Sep 17 00:00:00 2001 From: Oliver Rasche Date: Wed, 30 Sep 2026 13:59:55 +0200 Subject: [PATCH] - 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 --- composer.json | 1 + composer.lock | 38 ++- config/sites/vitec/config.yaml | 6 +- migrations/create_artio_records.php | 75 +++++ migrations/products_xlsx/manifest.json | 40 +-- migrations/rebuild_related_text_table.php | 82 ++++++ .../Classes/EventListener/InjectWidget.php | 44 +++ .../Classes/Middleware/SubmitMiddleware.php | 91 ++++++ .../Middleware/WidgetAssetMiddleware.php | 62 +++++ .../Classes/Service/ExtensionSettings.php | 76 ++++++ .../Configuration/RequestMiddlewares.php | 30 ++ .../fe_comment/Configuration/Services.yaml | 14 + packages/fe_comment/README.md | 47 ++++ .../Resources/Public/Css/fe-comment.css | 121 ++++++++ .../Resources/Public/JavaScript/fe-comment.js | 258 ++++++++++++++++++ packages/fe_comment/composer.json | 20 ++ packages/fe_comment/ext_conf_template.txt | 17 ++ .../Command/ImportProductWorkbookCommand.php | 15 +- .../Backend/ProductTextImportController.php | 25 +- .../Classes/Forms/Delivery/EmailDelivery.php | 8 +- .../Classes/Middleware/JsonPrefixRewrite.php | 51 ++++ packages/vitec/Classes/Service/RteCompare.php | 45 +++ .../Configuration/RequestMiddlewares.php | 12 + .../TCA/tx_vitec_domain_model_product.php | 19 +- .../TCA/tx_vitec_product_related_text.php | 63 +++++ packages/vitec/ext_tables.sql | 15 +- public/.htaccess | 44 ++- public/_frontend/index.html | 7 +- 28 files changed, 1272 insertions(+), 54 deletions(-) create mode 100644 migrations/create_artio_records.php create mode 100644 migrations/rebuild_related_text_table.php create mode 100644 packages/fe_comment/Classes/EventListener/InjectWidget.php create mode 100644 packages/fe_comment/Classes/Middleware/SubmitMiddleware.php create mode 100644 packages/fe_comment/Classes/Middleware/WidgetAssetMiddleware.php create mode 100644 packages/fe_comment/Classes/Service/ExtensionSettings.php create mode 100644 packages/fe_comment/Configuration/RequestMiddlewares.php create mode 100644 packages/fe_comment/Configuration/Services.yaml create mode 100644 packages/fe_comment/README.md create mode 100644 packages/fe_comment/Resources/Public/Css/fe-comment.css create mode 100644 packages/fe_comment/Resources/Public/JavaScript/fe-comment.js create mode 100644 packages/fe_comment/composer.json create mode 100644 packages/fe_comment/ext_conf_template.txt create mode 100644 packages/vitec/Classes/Middleware/JsonPrefixRewrite.php create mode 100644 packages/vitec/Classes/Service/RteCompare.php create mode 100644 packages/vitec/Configuration/TCA/tx_vitec_product_related_text.php diff --git a/composer.json b/composer.json index c3abb01..f2d65eb 100644 --- a/composer.json +++ b/composer.json @@ -19,6 +19,7 @@ "b13/container": "^3.1", "b13/typo3-updater": "^1.1", "evomedien/evo-megamenu-json": "^1.0.0", + "evomedien/fe-comment": "^1.0.0", "evomedien/vitec": "^1.0.1", "friendsoftypo3/content-blocks": "^2.4", "friendsoftypo3/headless": "^5.0@rc", diff --git a/composer.lock b/composer.lock index 0caf40c..100baa7 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "9baf94c6d03b967820c531b235568ece", + "content-hash": "0f38c50d2ecb8b6e4b0ec88fc7985249", "packages": [ { "name": "apache-solr-for-typo3/solr", @@ -1457,6 +1457,36 @@ "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", "version": "1.0.1", @@ -10300,10 +10330,10 @@ }, "prefer-stable": false, "prefer-lowest": false, - "platform": {}, - "platform-dev": {}, + "platform": [], + "platform-dev": [], "platform-overrides": { "php": "8.4.0" }, - "plugin-api-version": "2.9.0" + "plugin-api-version": "2.3.0" } diff --git a/config/sites/vitec/config.yaml b/config/sites/vitec/config.yaml index 5576bdc..9b493c7 100644 --- a/config/sites/vitec/config.yaml +++ b/config/sites/vitec/config.yaml @@ -24,6 +24,8 @@ dependencies: - apache-solr-for-typo3/solr-stylesheets - apache-solr-for-typo3/solr-bootstrap-css - evomedien/megamenu + - vitec/quotation + - vitec/featured-content frontendBase: '' headless: 1 languages: @@ -100,6 +102,7 @@ routeEnhancers: solr_enabled_read: true solr_host_read: vitecsolr.evomedien.de solr_host_write: localhost +solr_password_read: DvgpSaqrxr9QCd4ES36V solr_path_read: / solr_path_write: / solr_port_read: '443' @@ -107,6 +110,5 @@ solr_port_write: '8983' solr_scheme_read: https solr_scheme_write: '' solr_use_write_connection: false -solr_username_read: '%env(SOLR_USERNAME)%' -solr_password_read: '%env(SOLR_PASSWORD)%' +solr_username_read: solr websiteTitle: '' diff --git a/migrations/create_artio_records.php b/migrations/create_artio_records.php new file mode 100644 index 0000000..8a3111f --- /dev/null +++ b/migrations/create_artio_records.php @@ -0,0 +1,75 @@ + 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"; diff --git a/migrations/products_xlsx/manifest.json b/migrations/products_xlsx/manifest.json index 5ba97b3..01eac9b 100644 --- a/migrations/products_xlsx/manifest.json +++ b/migrations/products_xlsx/manifest.json @@ -85,12 +85,12 @@ "product": "aligo-workstation" }, "IPTV and Digital Signage Product Pages/ArtioSign_Product Page Content.xlsx": { - "product": null, - "note": "manuell zuordnen: verstecktes/abweichend benanntes Produkt ODER Familienseite" + "product": "artiosign", + "note": "Record angelegt 2026-09-28 (versteckt, Kategorie Aetria provisorisch) - Entscheidung Olli" }, "IPTV and Digital Signage Product Pages/ArtioView_Product Page Content.xlsx": { - "product": null, - "note": "manuell zuordnen: verstecktes/abweichend benanntes Produkt ODER Familienseite" + "product": "artioview", + "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": { "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" }, "IPTV and Digital Signage Product Pages/Media Players_Product Page Content.xlsx": { - "product": null, - "note": "manuell zuordnen: verstecktes/abweichend benanntes Produkt ODER Familienseite" + "family": true, + "note": "Sammelseite (K-Ebene) - Entscheidung Olli 2026-09-28" }, "IPTV and Digital Signage Product Pages/Operator Workstations.xlsx": { "family": true, @@ -136,8 +136,8 @@ "note": "K-Ebene: Accessories & Cables" }, "Infrastructure and Accessories Product Pages/Chassis Systems.xlsx": { - "product": null, - "note": "manuell zuordnen: verstecktes/abweichend benanntes Produkt ODER Familienseite" + "family": true, + "note": "Sammelseite (K-Ebene) - Entscheidung Olli 2026-09-28" }, "Infrastructure and Accessories Product Pages/Express Backplanes.xlsx": { "family": true, @@ -166,8 +166,8 @@ "product": "visionhd4" }, "Video Capture Product Pages/VisionIO-SDI4.xlsx": { - "product": null, - "note": "manuell zuordnen: verstecktes/abweichend benanntes Produkt ODER Familienseite" + "product": "visionio-sd14", + "note": "Dev-Name kanonisch (OCR-Abweichung SDI4/SD14), Zuordnung Olli 2026-09-28" }, "Video Capture Product Pages/VisionIO-XD2.xlsx": { "product": "visionio-xd2" @@ -212,8 +212,8 @@ "product": "visionsc-s2" }, "Video Capture Product Pages/VisionSC-SDI4.xlsx": { - "product": null, - "note": "manuell zuordnen: verstecktes/abweichend benanntes Produkt ODER Familienseite" + "product": "visionsc-sd14", + "note": "Dev-Name kanonisch (OCR-Abweichung SDI4/SD14), Zuordnung Olli 2026-09-28" }, "Video Capture Product Pages/VisionSC-UHD2.xlsx": { "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)" }, "Video Encoding and Streaming Product Pages/MGW Diamond OG.xlsx": { - "product": null, - "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?" + "product": "mgw-diamond-og-sdi-composite-blade-encoder", + "note": "uid 19, Titel exakt - geklaert Olli 2026-09-28" }, "Video Encoding and Streaming Product Pages/MGW Diamond-H_Product Page Content.xlsx": { "product": "mgw-diamond-h-hdmi-encoder" @@ -273,12 +273,12 @@ "product": "mgw-diamond-hx-og" }, "Video Encoding and Streaming Product Pages/MGW_Diamond_Plus_OG.xlsx": { - "product": null, - "note": "KLAEREN mit Olli 2026-09-24: siehe MGW Diamond OG.xlsx - uid 20 traegt den Plus-Titel bei Plain-Slug" + "product": "mgw-diamond-og-sdi-blade-encoder", + "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": { - "product": null, - "note": "manuell zuordnen: verstecktes/abweichend benanntes Produkt ODER Familienseite" + "family": true, + "note": "Sammelseite (K-Ebene) - Entscheidung Olli 2026-09-28" }, "Video Encoding and Streaming Product Pages/VITEC OG_Product Page Content.xlsx": { "family": true, @@ -329,11 +329,11 @@ }, "Video Wall and Display Processing Product Pages/WallControl_10.xlsx": { "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": { "family": true, "note": "K-Ebene: X-Series (Multi-display Processors)" } } -} \ No newline at end of file +} diff --git a/migrations/rebuild_related_text_table.php b/migrations/rebuild_related_text_table.php new file mode 100644 index 0000000..bdd0fb1 --- /dev/null +++ b/migrations/rebuild_related_text_table.php @@ -0,0 +1,82 @@ + 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"; diff --git a/packages/fe_comment/Classes/EventListener/InjectWidget.php b/packages/fe_comment/Classes/EventListener/InjectWidget.php new file mode 100644 index 0000000..aea3d6a --- /dev/null +++ b/packages/fe_comment/Classes/EventListener/InjectWidget.php @@ -0,0 +1,44 @@ +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 + } + } +} diff --git a/packages/fe_comment/Classes/Middleware/SubmitMiddleware.php b/packages/fe_comment/Classes/Middleware/SubmitMiddleware.php new file mode 100644 index 0000000..ed7d21f --- /dev/null +++ b/packages/fe_comment/Classes/Middleware/SubmitMiddleware.php @@ -0,0 +1,91 @@ +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); + } + } +} diff --git a/packages/fe_comment/Classes/Middleware/WidgetAssetMiddleware.php b/packages/fe_comment/Classes/Middleware/WidgetAssetMiddleware.php new file mode 100644 index 0000000..381d3f3 --- /dev/null +++ b/packages/fe_comment/Classes/Middleware/WidgetAssetMiddleware.php @@ -0,0 +1,62 @@ + 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')); + } +} diff --git a/packages/fe_comment/Classes/Service/ExtensionSettings.php b/packages/fe_comment/Classes/Service/ExtensionSettings.php new file mode 100644 index 0000000..d554265 --- /dev/null +++ b/packages/fe_comment/Classes/Service/ExtensionSettings.php @@ -0,0 +1,76 @@ + Extension + * Configuration > fe_comment). Every accessor is failure-tolerant: an + * unconfigured extension behaves like "everything empty, headless mode". + */ +final class ExtensionSettings +{ + /** @var array */ + 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; + } +} diff --git a/packages/fe_comment/Configuration/RequestMiddlewares.php b/packages/fe_comment/Configuration/RequestMiddlewares.php new file mode 100644 index 0000000..9023968 --- /dev/null +++ b/packages/fe_comment/Configuration/RequestMiddlewares.php @@ -0,0 +1,30 @@ + [ + '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', + ], + ], + ], +]; diff --git a/packages/fe_comment/Configuration/Services.yaml b/packages/fe_comment/Configuration/Services.yaml new file mode 100644 index 0000000..686d278 --- /dev/null +++ b/packages/fe_comment/Configuration/Services.yaml @@ -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 diff --git a/packages/fe_comment/README.md b/packages/fe_comment/README.md new file mode 100644 index 0000000..a88971a --- /dev/null +++ b/packages/fe_comment/README.md @@ -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: + `` + `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`. diff --git a/packages/fe_comment/Resources/Public/Css/fe-comment.css b/packages/fe_comment/Resources/Public/Css/fe-comment.css new file mode 100644 index 0000000..5c1c3cc --- /dev/null +++ b/packages/fe_comment/Resources/Public/Css/fe-comment.css @@ -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; +} diff --git a/packages/fe_comment/Resources/Public/JavaScript/fe-comment.js b/packages/fe_comment/Resources/Public/JavaScript/fe-comment.js new file mode 100644 index 0000000..b90b1a8 --- /dev/null +++ b/packages/fe_comment/Resources/Public/JavaScript/fe-comment.js @@ -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: + * (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 = + ''; + 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); }); + } +})(); diff --git a/packages/fe_comment/composer.json b/packages/fe_comment/composer.json new file mode 100644 index 0000000..a84c9d8 --- /dev/null +++ b/packages/fe_comment/composer.json @@ -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" + } + } +} diff --git a/packages/fe_comment/ext_conf_template.txt b/packages/fe_comment/ext_conf_template.txt new file mode 100644 index 0000000..09348e1 --- /dev/null +++ b/packages/fe_comment/ext_conf_template.txt @@ -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 - + +
+