Product import
This commit is contained in:
@@ -15,6 +15,7 @@ use TYPO3\CMS\Backend\Routing\UriBuilder;
|
||||
use TYPO3\CMS\Backend\Template\ModuleTemplateFactory;
|
||||
use TYPO3\CMS\Core\Database\ConnectionPool;
|
||||
use TYPO3\CMS\Core\DataHandling\DataHandler;
|
||||
use TYPO3\CMS\Core\Http\JsonResponse;
|
||||
use TYPO3\CMS\Core\Http\RedirectResponse;
|
||||
use TYPO3\CMS\Core\Messaging\FlashMessage;
|
||||
use TYPO3\CMS\Core\Messaging\FlashMessageService;
|
||||
@@ -50,28 +51,50 @@ final class ProductTextImportController
|
||||
/**
|
||||
* Never offered as import targets. `slug` changes URLs - that is redirect
|
||||
* territory, not a text import; the workbook URL is checked against the
|
||||
* record's slug informationally instead.
|
||||
* record's slug informationally instead. The flags and the shortcut pid are
|
||||
* editorial switches that will never come out of a workbook (decision
|
||||
* 2026-08-28) - a text import writing "1" into hideonwebsite would silently
|
||||
* unpublish a product. Only this XLSX flow filters them; the CSV flow keeps
|
||||
* the full field list.
|
||||
*/
|
||||
private const EXCLUDED_TARGETS = ['slug'];
|
||||
private const EXCLUDED_TARGETS = [
|
||||
'slug',
|
||||
'hideonapp',
|
||||
'hideonwebsite',
|
||||
'hideonproducts',
|
||||
'hideondatasheets',
|
||||
'shortcutpid',
|
||||
'shortcut',
|
||||
'legacy',
|
||||
'supportproduct',
|
||||
'subproduct',
|
||||
];
|
||||
|
||||
/**
|
||||
* Pre-seed for the very first mapping (no row in tx_vitec_import_mapping
|
||||
* yet). Editors change everything in the dropdowns; this only saves the
|
||||
* first manual pass. Keys are product columns, values source selectors as
|
||||
* ProductXlsxReader defines them.
|
||||
*
|
||||
* Verified against the finished MGW-Diamond-H page (2026-08-28): the hero
|
||||
* one-liner lives in `teaser` (subtitle is unused there), the ~45-70-word
|
||||
* introduction in `description`, the ~80-120-word overview in
|
||||
* `description2`, and the three Key Capability Groups land as ONE HTML
|
||||
* block in `capabilities` - hence the composed `g:` selector. Why-Choose
|
||||
* cards, resources, pre-footer and hero CTA are page content elements or
|
||||
* template copy on the finished page, not record fields, and therefore
|
||||
* have no defaults here. An earlier version of this seed mapped onto
|
||||
* `subtitle` and onto columns that exist only in the DB but not in the
|
||||
* TCA (`cta`, `key1-3`, `apptext1-3`) - both corrected.
|
||||
*/
|
||||
private const DEFAULT_MAPPING = [
|
||||
'subtitle' => 'c:Hero — H1#1:body',
|
||||
'teaser' => 'c:Product Introduction#1:body',
|
||||
'description' => 'c:Product Overview — H2#1:body',
|
||||
'cta' => 'c:Pre-footer CTA — H2#1:cta',
|
||||
'seotitle' => 'x:seotitle',
|
||||
'teaser' => 'c:Hero — H1#1:body',
|
||||
'description' => 'c:Product Introduction#1:body',
|
||||
'description2' => 'c:Product Overview — H2#1:body',
|
||||
'capabilities' => 'g:Key Capability Group',
|
||||
'textrelatedproducts' => 'p:Related Products — H2#1',
|
||||
'keywords' => 'm:primaryKeyword',
|
||||
'key1' => 'c:Why Choose Card#1:title',
|
||||
'apptext1' => 'c:Why Choose Card#1:body',
|
||||
'key2' => 'c:Why Choose Card#2:title',
|
||||
'apptext2' => 'c:Why Choose Card#2:body',
|
||||
'key3' => 'c:Why Choose Card#3:title',
|
||||
'apptext3' => 'c:Why Choose Card#3:body',
|
||||
];
|
||||
|
||||
private const SORTABLE = ['title', 'slug', 'tstamp'];
|
||||
@@ -252,6 +275,16 @@ final class ProductTextImportController
|
||||
|
||||
$map = array_map('strval', (array)($body['map'] ?? []));
|
||||
$apply = array_map('strval', (array)($body['apply'] ?? []));
|
||||
// A per-row "Save" button writes exactly its own row; the checkbox
|
||||
// state of every other row is deliberately ignored then. Only one
|
||||
// submit button ever posts its value, so the two can't both be set.
|
||||
$applySingle = trim((string)($body['applySingle'] ?? ''));
|
||||
$applyRelatedSingle = trim((string)($body['applyRelatedSingle'] ?? ''));
|
||||
if ($applySingle !== '') {
|
||||
$apply = [$applySingle];
|
||||
} elseif ($applyRelatedSingle !== '') {
|
||||
$apply = [];
|
||||
}
|
||||
$targets = $this->targetFields();
|
||||
|
||||
$data = [];
|
||||
@@ -271,6 +304,11 @@ final class ProductTextImportController
|
||||
// cannot be related to itself.
|
||||
$relatedChoices = array_map('intval', (array)($body['related'] ?? []));
|
||||
$applyRelated = array_map('intval', (array)($body['applyRelated'] ?? []));
|
||||
if ($applySingle !== '') {
|
||||
$applyRelated = [];
|
||||
} elseif ($applyRelatedSingle !== '') {
|
||||
$applyRelated = [(int)$applyRelatedSingle];
|
||||
}
|
||||
$relatedCards = $this->relatedCards($parsed);
|
||||
$relatedTodo = [];
|
||||
foreach ($applyRelated as $index) {
|
||||
@@ -352,6 +390,60 @@ final class ProductTextImportController
|
||||
return $this->renderTexts($request, $this->loadProduct($uid), $parsed, $map);
|
||||
}
|
||||
|
||||
// ------------------------------------------------- inline field editing
|
||||
|
||||
/**
|
||||
* AJAX (vitec_product_field_get): the FULL raw value of one product field
|
||||
* for the click-to-edit cell - the cell itself only shows a truncated
|
||||
* preview. RTE fields return their stored HTML; editing is deliberately
|
||||
* source-level (decision 2026-08-28: no inline WYSIWYG).
|
||||
*/
|
||||
public function fieldGet(ServerRequestInterface $request): ResponseInterface
|
||||
{
|
||||
$params = $request->getQueryParams();
|
||||
$uid = (int)($params['uid'] ?? 0);
|
||||
$field = (string)($params['field'] ?? '');
|
||||
$product = $this->loadProduct($uid);
|
||||
$targets = $this->targetFields();
|
||||
if ($product === null || !isset($targets[$field])) {
|
||||
return new JsonResponse(['success' => false, 'message' => 'Unknown product or field.']);
|
||||
}
|
||||
return new JsonResponse([
|
||||
'success' => true,
|
||||
'value' => (string)($product[$field] ?? ''),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX (vitec_product_field_save): write one product field. Same target
|
||||
* whitelist and the same DataHandler path as the form apply - RTE
|
||||
* transformations and record history included. Responds with the freshly
|
||||
* re-read value so the cell preview shows what was actually stored.
|
||||
*/
|
||||
public function fieldSave(ServerRequestInterface $request): ResponseInterface
|
||||
{
|
||||
$body = (array)$request->getParsedBody();
|
||||
$uid = (int)($body['uid'] ?? 0);
|
||||
$field = (string)($body['field'] ?? '');
|
||||
$value = (string)($body['value'] ?? '');
|
||||
$targets = $this->targetFields();
|
||||
if ($this->loadProduct($uid) === null || !isset($targets[$field])) {
|
||||
return new JsonResponse(['success' => false, 'message' => 'Unknown product or field.']);
|
||||
}
|
||||
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
|
||||
$dataHandler->start([self::TABLE => [(string)$uid => [$field => $value]]], []);
|
||||
$dataHandler->process_datamap();
|
||||
if ($dataHandler->errorLog !== []) {
|
||||
return new JsonResponse(['success' => false, 'message' => implode(' | ', $dataHandler->errorLog)]);
|
||||
}
|
||||
$fresh = (string)($this->loadProduct($uid)[$field] ?? '');
|
||||
return new JsonResponse([
|
||||
'success' => true,
|
||||
'value' => $fresh,
|
||||
'preview' => $this->preview($fresh),
|
||||
]);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ internals
|
||||
|
||||
/**
|
||||
@@ -439,6 +531,9 @@ final class ProductTextImportController
|
||||
|
||||
$view = $this->moduleTemplateFactory->create($request);
|
||||
$this->pageRenderer->addCssFile('EXT:vitec/Resources/Public/Css/backend-import.css');
|
||||
// Click-to-edit for the "Current value" column; a module because the
|
||||
// backend CSP blocks inline handlers.
|
||||
$this->pageRenderer->loadJavaScriptModule('@evomedien/vitec/product-inline-edit.js');
|
||||
$view->assignMultiple([
|
||||
'product' => $product,
|
||||
'parsed' => $parsed,
|
||||
|
||||
@@ -144,7 +144,7 @@ final class ProductXlsxReader
|
||||
*/
|
||||
public function sources(array $parsed): array
|
||||
{
|
||||
$groups = ['Meta' => [], 'Components' => [], 'Page SEO Check' => []];
|
||||
$groups = ['Meta' => [], 'Components' => [], 'Composed' => [], 'Page SEO Check' => []];
|
||||
|
||||
foreach ($parsed['meta'] ?? [] as $key => $value) {
|
||||
$groups['Meta'][] = $this->entry('m:' . $key, 'Meta · ' . $key, (string)$value);
|
||||
@@ -169,6 +169,47 @@ final class ProductXlsxReader
|
||||
$value
|
||||
);
|
||||
}
|
||||
|
||||
// Section-intro rows ("... — H2") additionally as ONE ready RTE
|
||||
// value: heading plus paragraph in the exact markup the finished
|
||||
// MGW-Diamond product stores in `textrelatedproducts` - imported
|
||||
// and hand-written intros then render identically.
|
||||
if (str_ends_with($name, '— H2')) {
|
||||
$pair = $this->composePair($component);
|
||||
if ($pair !== null) {
|
||||
$groups['Composed'][] = $this->entry(
|
||||
'p:' . $name . '#' . $component['occurrence'],
|
||||
$name . $suffix . ' · Title+Body as HTML',
|
||||
$pair
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Composed sources: a component occurring several times can be pulled
|
||||
// as ONE value - every occurrence as <h3> + bullets/paragraph, prefixed
|
||||
// with the section heading. Built for the Key Capability Groups, whose
|
||||
// three rows land in the single `capabilities` field; that is the shape
|
||||
// the finished product pages already use.
|
||||
$counts = [];
|
||||
foreach ($parsed['components'] ?? [] as $component) {
|
||||
$counts[(string)$component['component']] = ((int)($component['occurrence'] ?? 1));
|
||||
}
|
||||
foreach ($counts as $name => $count) {
|
||||
if ($count < 2 || in_array($name, self::HIDDEN_COMPONENTS, true)) {
|
||||
continue;
|
||||
}
|
||||
$html = $this->composeGroup($parsed, $name);
|
||||
if ($html !== null) {
|
||||
$groups['Composed'][] = $this->entry('g:' . $name, $name . ' · all ' . $count . ' as HTML', $html);
|
||||
}
|
||||
}
|
||||
|
||||
// Cross-component composite for the SEO title: hero title, em dash,
|
||||
// introduction heading - "Aligo — High-performance AV over IP and KVM".
|
||||
$seoTitle = $this->composeSeoTitle($parsed);
|
||||
if ($seoTitle !== null) {
|
||||
$groups['Composed'][] = $this->entry('x:seotitle', 'Hero title — Intro heading (SEO title)', $seoTitle);
|
||||
}
|
||||
|
||||
foreach ($parsed['seo'] ?? [] as $header => $value) {
|
||||
@@ -191,6 +232,20 @@ final class ProductXlsxReader
|
||||
$value = $parsed['meta'][substr($selector, 2)] ?? null;
|
||||
return is_string($value) && $value !== '' ? $value : null;
|
||||
}
|
||||
if (str_starts_with($selector, 'g:')) {
|
||||
return $this->composeGroup($parsed, substr($selector, 2));
|
||||
}
|
||||
if (str_starts_with($selector, 'p:') && preg_match('/^p:(.+)#(\d+)$/', $selector, $m)) {
|
||||
foreach ($parsed['components'] ?? [] as $component) {
|
||||
if ((string)$component['component'] === $m[1] && (int)$component['occurrence'] === (int)$m[2]) {
|
||||
return $this->composePair($component);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if ($selector === 'x:seotitle') {
|
||||
return $this->composeSeoTitle($parsed);
|
||||
}
|
||||
if (str_starts_with($selector, 's:')) {
|
||||
$value = $parsed['seo'][substr($selector, 2)] ?? null;
|
||||
return is_string($value) && $value !== '' ? $value : null;
|
||||
@@ -206,6 +261,121 @@ final class ProductXlsxReader
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* SEO title composite: the hero title (the product name), an em dash, and
|
||||
* the introduction heading. Plain text - `seotitle` is a plain input, no
|
||||
* escaping wanted here.
|
||||
*
|
||||
* @param array<string,mixed> $parsed
|
||||
*/
|
||||
private function composeSeoTitle(array $parsed): ?string
|
||||
{
|
||||
$hero = null;
|
||||
$intro = null;
|
||||
foreach ($parsed['components'] ?? [] as $component) {
|
||||
$name = (string)($component['component'] ?? '');
|
||||
if ($name === 'Hero — H1' && (int)($component['occurrence'] ?? 1) === 1) {
|
||||
$hero = trim((string)($component['title'] ?? ''));
|
||||
} elseif ($name === 'Product Introduction' && (int)($component['occurrence'] ?? 1) === 1) {
|
||||
$intro = trim((string)($component['title'] ?? ''));
|
||||
}
|
||||
}
|
||||
if ($hero === null || $hero === '' || $intro === null || $intro === '') {
|
||||
return null;
|
||||
}
|
||||
return $hero . ' — ' . $intro;
|
||||
}
|
||||
|
||||
/**
|
||||
* One component row as a heading/paragraph pair. The markup - centred h3
|
||||
* with an inner span, centred p - is copied verbatim from what the
|
||||
* finished MGW-Diamond product carries in `textrelatedproducts`, so the
|
||||
* front end renders imported intros exactly like the hand-made one.
|
||||
*
|
||||
* @param array<string,mixed> $component
|
||||
*/
|
||||
private function composePair(array $component): ?string
|
||||
{
|
||||
$title = trim((string)($component['title'] ?? ''));
|
||||
$body = trim((string)($component['body'] ?? ''));
|
||||
if ($body === '') {
|
||||
$body = trim((string)($component['cta'] ?? ''));
|
||||
}
|
||||
$body = trim((string)preg_replace('/\|\s*CTA:.*$/su', '', $body));
|
||||
if ($title === '' || $body === '') {
|
||||
return null;
|
||||
}
|
||||
return '<h3 class="text-center"><span>' . htmlspecialchars($title, ENT_QUOTES) . '</span></h3>' . "\n"
|
||||
. '<p class="text-center">' . htmlspecialchars($body, ENT_QUOTES) . '</p>';
|
||||
}
|
||||
|
||||
/**
|
||||
* All occurrences of one component, composed into a single HTML block in
|
||||
* the exact markup the hand-made MGW-Diamond `capabilities` field uses
|
||||
* (the front end styles only that shape): the immediately preceding
|
||||
* "... — H2" row (found by ORDER, not by name - "Key Capability Group" vs
|
||||
* "Key Capabilities — H2" makes name matching fragile) becomes
|
||||
* <h3 class="text-center"><span>, each occurrence's title a
|
||||
* <p><strong> group heading, and a body whose text uses "•" bullets
|
||||
* becomes a <ul>. Plain text otherwise.
|
||||
*
|
||||
* @param array<string,mixed> $parsed
|
||||
*/
|
||||
private function composeGroup(array $parsed, string $name): ?string
|
||||
{
|
||||
$components = $parsed['components'] ?? [];
|
||||
$members = array_values(array_filter(
|
||||
$components,
|
||||
static fn(array $c): bool => (string)($c['component'] ?? '') === $name
|
||||
));
|
||||
if ($members === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$html = '';
|
||||
$firstOrder = (int)($members[0]['order'] ?? 0);
|
||||
foreach ($components as $component) {
|
||||
if ((int)($component['order'] ?? 0) === $firstOrder - 1
|
||||
&& str_ends_with((string)($component['component'] ?? ''), '— H2')
|
||||
&& trim((string)($component['title'] ?? '')) !== ''
|
||||
) {
|
||||
$html .= '<h3 class="text-center"><span>'
|
||||
. htmlspecialchars(trim((string)$component['title']), ENT_QUOTES)
|
||||
. '</span></h3>' . "\n";
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($members as $member) {
|
||||
$title = trim((string)($member['title'] ?? ''));
|
||||
$body = trim((string)($member['body'] ?? ''));
|
||||
if ($body === '') {
|
||||
$body = trim((string)($member['cta'] ?? ''));
|
||||
}
|
||||
// Card copy carries a "| CTA: <label>" suffix; the label is
|
||||
// display-only everywhere else and would be junk inside a <p>.
|
||||
$body = trim((string)preg_replace('/\|\s*CTA:.*$/su', '', $body));
|
||||
if ($title !== '') {
|
||||
$html .= '<p><strong>' . htmlspecialchars($title, ENT_QUOTES) . '</strong></p>';
|
||||
}
|
||||
if ($body === '') {
|
||||
continue;
|
||||
}
|
||||
if (str_contains($body, '•')) {
|
||||
$items = array_filter(array_map('trim', explode('•', $body)));
|
||||
$html .= '<ul>';
|
||||
foreach ($items as $item) {
|
||||
$html .= '<li>' . htmlspecialchars($item, ENT_QUOTES) . '</li>';
|
||||
}
|
||||
$html .= '</ul>';
|
||||
} else {
|
||||
$html .= '<p>' . htmlspecialchars($body, ENT_QUOTES) . '</p>';
|
||||
}
|
||||
}
|
||||
|
||||
return $html !== '' ? $html : null;
|
||||
}
|
||||
|
||||
/** @param array<int,array<string,mixed>> $components */
|
||||
private function detectPageType(array $components): string
|
||||
{
|
||||
|
||||
35
packages/vitec/Classes/Seo/VitecMetaHandler.php
Normal file
35
packages/vitec/Classes/Seo/VitecMetaHandler.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Evomedien\Vitec\Seo;
|
||||
|
||||
use FriendsOfTYPO3\Headless\Seo\MetaHandler;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
|
||||
/**
|
||||
* Mirrors the real page <title> into `meta.title` of the page JSON.
|
||||
*
|
||||
* Why: `meta.title` is rendered from TypoScript (lib.meta) BEFORE the page
|
||||
* content, so on product/usecase detail pages it still shows the generic
|
||||
* page title ("Product") - the record's seotitle only reaches the
|
||||
* PageTitleProvider while the content renders. Headless fixes `seo.title`
|
||||
* afterwards through this handler; we extend it so `meta.title` gets the
|
||||
* same final value. Registered via the MetaHandlerInterface alias in
|
||||
* Services.yaml, which covers both the cacheable listener and the
|
||||
* USER_INT middleware path.
|
||||
*/
|
||||
class VitecMetaHandler extends MetaHandler
|
||||
{
|
||||
public function process(ServerRequestInterface $request, array $content): array
|
||||
{
|
||||
$content = parent::process($request, $content);
|
||||
|
||||
$title = trim((string)($content['seo']['title'] ?? ''));
|
||||
if ($title !== '' && is_array($content['meta'] ?? null)) {
|
||||
$content['meta']['title'] = $title;
|
||||
}
|
||||
|
||||
return $content;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Evomedien\Vitec\Controller\Backend\ProductTextImportController;
|
||||
use Evomedien\Vitec\Controller\Backend\StructuredDataController;
|
||||
|
||||
/**
|
||||
@@ -16,4 +17,14 @@ return [
|
||||
'path' => '/vitec/structureddata/generate',
|
||||
'target' => StructuredDataController::class . '::generate',
|
||||
],
|
||||
// Click-to-edit in the product text import's "Current value" column:
|
||||
// read the full raw value / write one field through DataHandler.
|
||||
'vitec_product_field_get' => [
|
||||
'path' => '/vitec/product/field/get',
|
||||
'target' => ProductTextImportController::class . '::fieldGet',
|
||||
],
|
||||
'vitec_product_field_save' => [
|
||||
'path' => '/vitec/product/field/save',
|
||||
'target' => ProductTextImportController::class . '::fieldSave',
|
||||
],
|
||||
];
|
||||
|
||||
@@ -16,3 +16,9 @@ services:
|
||||
TYPO3\CMS\Backend\View\BackendLayoutView:
|
||||
alias: Evomedien\Vitec\View\VitecBackendLayoutView
|
||||
public: true
|
||||
|
||||
# Headless resolves the meta handler via this interface (cacheable listener
|
||||
# AND the USER_INT middleware). Pointing the alias at our subclass mirrors
|
||||
# the final page title (seo.title) into meta.title as well.
|
||||
FriendsOfTYPO3\Headless\Seo\MetaHandlerInterface:
|
||||
alias: Evomedien\Vitec\Seo\VitecMetaHandler
|
||||
|
||||
@@ -79,6 +79,11 @@ fields:
|
||||
minitems: 0
|
||||
maxitems: 1
|
||||
allowed: mp4,webm,ogv,mov,m4v
|
||||
- identifier: video_poster
|
||||
type: File
|
||||
minitems: 0
|
||||
maxitems: 1
|
||||
allowed: common-image-types
|
||||
- identifier: link
|
||||
type: Link
|
||||
allowedTypes:
|
||||
|
||||
@@ -34,6 +34,9 @@
|
||||
<trans-unit id="items.video.label">
|
||||
<source>Video</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="items.video_poster.label">
|
||||
<source>Video Poster Image</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="items.link.label">
|
||||
<source>Link</source>
|
||||
</trans-unit>
|
||||
|
||||
@@ -78,6 +78,12 @@ fields:
|
||||
maxitems: 1
|
||||
allowed: mp4,webm,ogv,mov,m4v
|
||||
|
||||
- identifier: featured_video_poster
|
||||
type: File
|
||||
minitems: 0
|
||||
maxitems: 1
|
||||
allowed: common-image-types
|
||||
|
||||
- identifier: featured_video_autoplay
|
||||
type: Checkbox
|
||||
default: 0
|
||||
|
||||
@@ -61,6 +61,12 @@
|
||||
<trans-unit id="featured_video_muted.label">
|
||||
<source>Mute video</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="featured_video_poster.label">
|
||||
<source>Video Poster Image</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="featured_video_poster.description">
|
||||
<source>Preview image shown before the video plays.</source>
|
||||
</trans-unit>
|
||||
|
||||
<!-- Overlay -->
|
||||
<trans-unit id="overlay_color.label">
|
||||
|
||||
@@ -135,6 +135,12 @@ fields:
|
||||
maxitems: 1
|
||||
allowed: mp4,webm,ogv,mov,m4v
|
||||
|
||||
- identifier: hero_video_poster
|
||||
type: File
|
||||
minitems: 0
|
||||
maxitems: 1
|
||||
allowed: common-image-types
|
||||
|
||||
- identifier: hero_video_alignment
|
||||
type: Select
|
||||
renderType: selectSingle
|
||||
@@ -170,6 +176,12 @@ fields:
|
||||
maxitems: 1
|
||||
allowed: mp4,webm,ogv,mov,m4v
|
||||
|
||||
- identifier: hero_bgvideo_poster
|
||||
type: File
|
||||
minitems: 0
|
||||
maxitems: 1
|
||||
allowed: common-image-types
|
||||
|
||||
- identifier: hero_overlay_color
|
||||
type: Color
|
||||
label: Overlay Color
|
||||
|
||||
@@ -128,6 +128,18 @@
|
||||
<trans-unit id="hero_bgvideo.description">
|
||||
<source>Fullscreen background video behind the hero content.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="hero_video_poster.label">
|
||||
<source>Video Poster Image</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="hero_video_poster.description">
|
||||
<source>Preview image shown before the video plays.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="hero_bgvideo_poster.label">
|
||||
<source>Background Video Poster Image</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="hero_bgvideo_poster.description">
|
||||
<source>Preview image shown before the background video plays.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="hero_overlay_color.label">
|
||||
<source>Overlay Color</source>
|
||||
</trans-unit>
|
||||
|
||||
@@ -77,6 +77,12 @@ fields:
|
||||
maxitems: 1
|
||||
allowed: mp4,webm,ogv,mov,m4v
|
||||
|
||||
- identifier: intro_video_poster
|
||||
type: File
|
||||
minitems: 0
|
||||
maxitems: 1
|
||||
allowed: common-image-types
|
||||
|
||||
- identifier: intro_video_alignment
|
||||
type: Select
|
||||
renderType: selectSingle
|
||||
|
||||
@@ -100,6 +100,8 @@
|
||||
<trans-unit id="intro_video_autoplay.label"><source>Autoplay</source></trans-unit>
|
||||
<trans-unit id="intro_video_loop.label"><source>Loop</source></trans-unit>
|
||||
<trans-unit id="intro_video_muted.label"><source>Muted</source></trans-unit>
|
||||
<trans-unit id="intro_video_poster.label"><source>Video Poster Image</source></trans-unit>
|
||||
<trans-unit id="intro_video_poster.description"><source>Preview image shown before the video plays.</source></trans-unit>
|
||||
</body>
|
||||
</file>
|
||||
</xliff>
|
||||
|
||||
@@ -79,7 +79,10 @@
|
||||
</f:for>
|
||||
</select>
|
||||
</td>
|
||||
<td><small>{row.oldPreview}</small></td>
|
||||
<td data-vitec-inline-edit="1" data-uid="{product.uid}" data-field="{row.field}"
|
||||
style="cursor:pointer;" title="Click to edit this field">
|
||||
<small data-vitec-preview="1">{row.oldPreview}</small>
|
||||
</td>
|
||||
<td>
|
||||
<f:if condition="{row.new}">
|
||||
<f:then>
|
||||
@@ -96,6 +99,10 @@
|
||||
<td>
|
||||
<input class="form-check-input" type="checkbox" name="apply[]" value="{row.field}"
|
||||
{f:if(condition: row.changed, then: 'checked="checked"')} />
|
||||
<button type="submit" class="btn btn-sm btn-default" style="margin-left:.5rem;"
|
||||
name="applySingle" value="{row.field}"
|
||||
formaction="{f:be.uri(route: 'web_vitecimport.product_apply')}"
|
||||
title="Write only this field now">Save</button>
|
||||
</td>
|
||||
</tr>
|
||||
</f:for>
|
||||
@@ -152,6 +159,10 @@
|
||||
<td>
|
||||
<input class="form-check-input" type="checkbox" name="applyRelated[]" value="{rel.index}"
|
||||
{f:if(condition: rel.changed, then: 'checked="checked"')} />
|
||||
<button type="submit" class="btn btn-sm btn-default" style="margin-left:.5rem;"
|
||||
name="applyRelatedSingle" value="{rel.index}"
|
||||
formaction="{f:be.uri(route: 'web_vitecimport.product_apply')}"
|
||||
title="Write only this card now">Save</button>
|
||||
</td>
|
||||
</tr>
|
||||
</f:for>
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* VITEC – click-to-edit for the "Current value" column of the product text
|
||||
* import (Edit Product view).
|
||||
* EXT:vitec/Resources/Public/Javascript/product-inline-edit.js
|
||||
*
|
||||
* Delegated click handler on [data-vitec-inline-edit] cells (backend CSP
|
||||
* forbids inline handlers). Clicking a cell fetches the FULL raw field value
|
||||
* (the cell itself only shows a truncated preview), swaps in a textarea with
|
||||
* Save/Cancel, and writes through the vitec_product_field_save AJAX route,
|
||||
* which uses the same whitelist and DataHandler path as the form apply.
|
||||
* RTE fields are edited as raw HTML on purpose - no inline WYSIWYG
|
||||
* (decision 2026-08-28).
|
||||
*/
|
||||
import AjaxRequest from "@typo3/core/ajax/ajax-request.js";
|
||||
import Notification from "@typo3/backend/notification.js";
|
||||
|
||||
const SELECTOR = "[data-vitec-inline-edit]";
|
||||
|
||||
function previewOf(cell) {
|
||||
return cell.querySelector("[data-vitec-preview]");
|
||||
}
|
||||
|
||||
function closeEditor(cell) {
|
||||
const editor = cell.querySelector("[data-vitec-editor]");
|
||||
if (editor) {
|
||||
editor.remove();
|
||||
}
|
||||
const preview = previewOf(cell);
|
||||
if (preview) {
|
||||
preview.hidden = false;
|
||||
}
|
||||
delete cell.dataset.vitecEditing;
|
||||
}
|
||||
|
||||
async function openEditor(cell) {
|
||||
if (cell.dataset.vitecEditing === "1") {
|
||||
return;
|
||||
}
|
||||
cell.dataset.vitecEditing = "1";
|
||||
|
||||
const getUrl = TYPO3.settings.ajaxUrls["vitec_product_field_get"];
|
||||
const saveUrl = TYPO3.settings.ajaxUrls["vitec_product_field_save"];
|
||||
if (!getUrl || !saveUrl) {
|
||||
Notification.error("Inline edit", "AJAX routes are not registered.");
|
||||
delete cell.dataset.vitecEditing;
|
||||
return;
|
||||
}
|
||||
|
||||
let value = "";
|
||||
try {
|
||||
const response = await new AjaxRequest(getUrl)
|
||||
.withQueryArguments({ uid: cell.dataset.uid, field: cell.dataset.field })
|
||||
.get();
|
||||
const data = await response.resolve();
|
||||
if (!data.success) {
|
||||
Notification.error("Inline edit", data.message || "Could not load the field value.");
|
||||
delete cell.dataset.vitecEditing;
|
||||
return;
|
||||
}
|
||||
value = data.value;
|
||||
} catch (e) {
|
||||
Notification.error("Inline edit", "Loading the field value failed.");
|
||||
delete cell.dataset.vitecEditing;
|
||||
return;
|
||||
}
|
||||
|
||||
const preview = previewOf(cell);
|
||||
if (preview) {
|
||||
preview.hidden = true;
|
||||
}
|
||||
|
||||
const wrap = document.createElement("div");
|
||||
wrap.setAttribute("data-vitec-editor", "1");
|
||||
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.className = "form-control";
|
||||
textarea.rows = Math.min(14, Math.max(3, Math.ceil(value.length / 80)));
|
||||
textarea.style.fontFamily = "monospace";
|
||||
textarea.style.fontSize = "11px";
|
||||
textarea.value = value;
|
||||
wrap.appendChild(textarea);
|
||||
|
||||
const bar = document.createElement("div");
|
||||
bar.style.marginTop = ".25rem";
|
||||
|
||||
// type="button" is essential: the cells live inside the big mapping <form>,
|
||||
// a bare <button> would submit it.
|
||||
const saveBtn = document.createElement("button");
|
||||
saveBtn.type = "button";
|
||||
saveBtn.className = "btn btn-sm btn-primary";
|
||||
saveBtn.textContent = "Save";
|
||||
|
||||
const cancelBtn = document.createElement("button");
|
||||
cancelBtn.type = "button";
|
||||
cancelBtn.className = "btn btn-sm btn-default";
|
||||
cancelBtn.style.marginLeft = ".5rem";
|
||||
cancelBtn.textContent = "Cancel";
|
||||
|
||||
bar.appendChild(saveBtn);
|
||||
bar.appendChild(cancelBtn);
|
||||
wrap.appendChild(bar);
|
||||
cell.appendChild(wrap);
|
||||
textarea.focus();
|
||||
|
||||
cancelBtn.addEventListener("click", () => closeEditor(cell));
|
||||
textarea.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape") {
|
||||
closeEditor(cell);
|
||||
}
|
||||
});
|
||||
|
||||
saveBtn.addEventListener("click", async () => {
|
||||
saveBtn.disabled = true;
|
||||
try {
|
||||
const response = await new AjaxRequest(saveUrl).post({
|
||||
uid: cell.dataset.uid,
|
||||
field: cell.dataset.field,
|
||||
value: textarea.value,
|
||||
});
|
||||
const data = await response.resolve();
|
||||
if (!data.success) {
|
||||
Notification.error("Inline edit", data.message || "Saving failed.");
|
||||
saveBtn.disabled = false;
|
||||
return;
|
||||
}
|
||||
if (preview) {
|
||||
preview.textContent = data.preview;
|
||||
}
|
||||
closeEditor(cell);
|
||||
Notification.success("Inline edit", cell.dataset.field + " saved.");
|
||||
} catch (e) {
|
||||
Notification.error("Inline edit", "Saving failed.");
|
||||
saveBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("click", (event) => {
|
||||
const cell = event.target.closest(SELECTOR);
|
||||
if (cell === null) {
|
||||
return;
|
||||
}
|
||||
// Clicks on the editor's own controls must not re-open it.
|
||||
if (event.target.closest("[data-vitec-editor]")) {
|
||||
return;
|
||||
}
|
||||
openEditor(cell);
|
||||
});
|
||||
Reference in New Issue
Block a user