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 - + +
+