20 KiB
Per-Integration Product Content Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Store separate name, short_description, and description per integration (shopPRO instance), with global product_translations as fallback.
Architecture: New table product_integration_translations (product_id, integration_id, name, short_description, description) stores overrides. Import saves content to both global and per-integration tables. Edit form shows tabs: Globalna | per-integration.
Tech Stack: PHP 8.4, MariaDB, vanilla JS (Quill WYSIWYG already loaded on edit page)
Task 1: Database migration — create table
Files:
- Create:
database/migrations/20260227_000014_create_product_integration_translations.sql
Step 1: Create the migration file
CREATE TABLE IF NOT EXISTS product_integration_translations (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
product_id INT UNSIGNED NOT NULL,
integration_id INT UNSIGNED NOT NULL,
name VARCHAR(255) NULL,
short_description TEXT NULL,
description LONGTEXT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY pit_product_integration_unique (product_id, integration_id),
KEY pit_product_idx (product_id),
KEY pit_integration_idx (integration_id),
CONSTRAINT pit_product_fk
FOREIGN KEY (product_id) REFERENCES products(id)
ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT pit_integration_fk
FOREIGN KEY (integration_id) REFERENCES integrations(id)
ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Migrate existing products to marianek.pl integration.
-- Finds the integration by name 'marianek.pl' and copies current
-- product_translations content for all linked products.
INSERT INTO product_integration_translations
(product_id, integration_id, name, short_description, description, created_at, updated_at)
SELECT
pt.product_id,
i.id AS integration_id,
pt.name,
pt.short_description,
pt.description,
NOW(),
NOW()
FROM product_translations pt
INNER JOIN product_channel_map pcm ON pcm.product_id = pt.product_id
INNER JOIN integrations i ON i.id = pcm.integration_id
WHERE i.name = 'marianek.pl'
AND pt.lang = 'pl'
ON DUPLICATE KEY UPDATE
name = VALUES(name),
short_description = VALUES(short_description),
description = VALUES(description),
updated_at = VALUES(updated_at);
Step 2: Run the migration via settings panel
Navigate to /settings/database and run pending migrations, or trigger via the app's migration runner. Verify table exists:
SHOW TABLES LIKE 'product_integration_translations';
SELECT COUNT(*) FROM product_integration_translations;
Step 3: Commit
git add database/migrations/20260227_000014_create_product_integration_translations.sql
git commit -m "feat: add product_integration_translations table and migrate marianek.pl data"
Task 2: ProductRepository — two new methods
Files:
- Modify:
src/Modules/Products/ProductRepository.php
Step 1: Add findIntegrationTranslations method
Add after the findImagesByProductId method (around line 250):
/**
* @return array<int, array<string, mixed>>
*/
public function findIntegrationTranslations(int $productId): array
{
$stmt = $this->pdo->prepare(
'SELECT pit.id, pit.product_id, pit.integration_id,
pit.name, pit.short_description, pit.description,
i.name AS integration_name
FROM product_integration_translations pit
INNER JOIN integrations i ON i.id = pit.integration_id
WHERE pit.product_id = :product_id
ORDER BY i.name ASC'
);
$stmt->execute(['product_id' => $productId]);
$rows = $stmt->fetchAll();
if (!is_array($rows)) {
return [];
}
return array_map(static fn (array $row): array => [
'id' => (int) ($row['id'] ?? 0),
'product_id' => (int) ($row['product_id'] ?? 0),
'integration_id' => (int) ($row['integration_id'] ?? 0),
'integration_name' => (string) ($row['integration_name'] ?? ''),
'name' => isset($row['name']) ? (string) $row['name'] : null,
'short_description' => isset($row['short_description']) ? (string) $row['short_description'] : null,
'description' => isset($row['description']) ? (string) $row['description'] : null,
], $rows);
}
Step 2: Add upsertIntegrationTranslation method
Add immediately after the method above:
public function upsertIntegrationTranslation(
int $productId,
int $integrationId,
?string $name,
?string $shortDescription,
?string $description
): void {
$now = date('Y-m-d H:i:s');
$stmt = $this->pdo->prepare(
'INSERT INTO product_integration_translations
(product_id, integration_id, name, short_description, description, created_at, updated_at)
VALUES
(:product_id, :integration_id, :name, :short_description, :description, :created_at, :updated_at)
ON DUPLICATE KEY UPDATE
name = VALUES(name),
short_description = VALUES(short_description),
description = VALUES(description),
updated_at = VALUES(updated_at)'
);
$stmt->execute([
'product_id' => $productId,
'integration_id' => $integrationId,
'name' => $name !== '' ? $name : null,
'short_description' => $shortDescription !== '' ? $shortDescription : null,
'description' => $description !== '' ? $description : null,
'created_at' => $now,
'updated_at' => $now,
]);
}
Step 3: Commit
git add src/Modules/Products/ProductRepository.php
git commit -m "feat: add findIntegrationTranslations and upsertIntegrationTranslation to ProductRepository"
Task 3: SettingsController — save per-integration content on import
Files:
- Modify:
src/Modules/Settings/SettingsController.php
The import flow is in importExternalProductById (line ~677). After the transaction commits (line ~783), $savedProductId and $integrationId are both set.
Step 1: Inject ProductRepository into SettingsController
Check the constructor of SettingsController. Add ProductRepository as a dependency if it is not already present. Look for the constructor and add:
use App\Modules\Products\ProductRepository;
And in the constructor parameter list:
private readonly ProductRepository $products,
If $this->products already exists (check the constructor), skip adding it — just use the existing reference.
Step 2: Add upsert call after transaction commit in importExternalProductById
Locate the block after $this->pdo->commit(); (around line 783). Add the upsert call inside the try block, before the commit:
// Save per-integration content override
if ($integrationId > 0) {
$this->products->upsertIntegrationTranslation(
$savedProductId,
$integrationId,
$normalized['translation']['name'] ?? null,
$normalized['translation']['short_description'] ?? null,
$normalized['translation']['description'] ?? null
);
}
Place this BEFORE $this->pdo->commit() so it's inside the transaction.
Step 3: Commit
git add src/Modules/Settings/SettingsController.php
git commit -m "feat: save per-integration name/short_description/description on product import"
Task 4: ProductsController — load per-integration data for edit
Files:
- Modify:
src/Modules/Products/ProductsController.php
Step 1: Update the edit action (line ~186)
Find the block that builds data for the edit view. Currently it passes form, productImages, etc. Add two new variables:
$activeIntegrations = $this->integrations->listByType('shoppro');
$integrationTranslations = $this->products->findIntegrationTranslations($id);
// Index integration translations by integration_id for easy lookup in view
$integrationTranslationsMap = [];
foreach ($integrationTranslations as $it) {
$integrationTranslationsMap[(int) $it['integration_id']] = $it;
}
Add them to the render() call:
'activeIntegrations' => $activeIntegrations,
'integrationTranslationsMap' => $integrationTranslationsMap,
Step 2: Commit
git add src/Modules/Products/ProductsController.php
git commit -m "feat: pass active integrations and per-integration translations to product edit view"
Task 5: ProductsController — save per-integration content on update
Files:
- Modify:
src/Modules/Products/ProductsController.php
Step 1: Update the update action (line ~416)
After the successful $this->service->update(...) call (and before the redirect), add:
// Save per-integration content overrides
$integrationContent = $request->input('integration_content', []);
if (is_array($integrationContent)) {
foreach ($integrationContent as $rawIntegrationId => $content) {
$integrationId = (int) $rawIntegrationId;
if ($integrationId <= 0 || !is_array($content)) {
continue;
}
$this->products->upsertIntegrationTranslation(
$id,
$integrationId,
isset($content['name']) ? trim((string) $content['name']) : null,
isset($content['short_description']) ? trim((string) $content['short_description']) : null,
isset($content['description']) ? trim((string) $content['description']) : null
);
}
}
Place this block AFTER the image changes block and BEFORE the success Flash/redirect.
Step 2: Commit
git add src/Modules/Products/ProductsController.php
git commit -m "feat: save per-integration content overrides on product update"
Task 6: Edit view — content tabs UI
Files:
- Modify:
resources/views/products/edit.php
Step 1: Replace the static name/short_description/description fields with a tabbed section
Current structure (around line 20-25 for name, and lines 111-125 for descriptions):
<label class="form-field">
<span class="field-label"><?= $e($t('products.fields.name')) ?></span>
<input class="form-control" type="text" name="name" required value="...">
</label>
And:
<div class="form-field mt-16"> <!-- short_description -->
<div class="form-field mt-12"> <!-- description -->
New structure: wrap name + short_description + description in a tabbed card. Add this BEFORE the <div class="form-grid"> (the existing grid with SKU, EAN etc.), replacing the name field in the grid:
Remove the name label from form-grid and create a new card section above it:
<?php
$activeIntegrations = is_array($activeIntegrations ?? null) ? $activeIntegrations : [];
$integrationTranslationsMap = is_array($integrationTranslationsMap ?? null) ? $integrationTranslationsMap : [];
?>
<div class="content-tabs-card mt-0">
<div class="content-tabs-nav" id="content-tabs-nav">
<button type="button" class="content-tab-btn is-active" data-tab="global">
<?= $e($t('products.content_tabs.global')) ?>
</button>
<?php foreach ($activeIntegrations as $integration): ?>
<?php $intId = (int) ($integration['id'] ?? 0); ?>
<?php if ($intId <= 0) continue; ?>
<button type="button" class="content-tab-btn" data-tab="integration-<?= $e((string) $intId) ?>">
<?= $e((string) ($integration['name'] ?? '#' . $intId)) ?>
</button>
<?php endforeach; ?>
</div>
<!-- GLOBAL TAB -->
<div class="content-tab-panel is-active" id="content-tab-global">
<label class="form-field">
<span class="field-label"><?= $e($t('products.fields.name')) ?> *</span>
<input class="form-control" type="text" name="name" required value="<?= $e((string) ($form['name'] ?? '')) ?>">
</label>
<div class="form-field mt-12">
<span class="field-label"><?= $e($t('products.fields.short_description')) ?></span>
<div class="wysiwyg-wrap">
<div id="editor-short-description"></div>
</div>
<textarea name="short_description" id="input-short-description" style="display:none"><?= $e((string) ($form['short_description'] ?? '')) ?></textarea>
</div>
<div class="form-field mt-12">
<span class="field-label"><?= $e($t('products.fields.description')) ?></span>
<div class="wysiwyg-wrap" style="--editor-min-height:180px">
<div id="editor-description"></div>
</div>
<textarea name="description" id="input-description" style="display:none"><?= $e((string) ($form['description'] ?? '')) ?></textarea>
</div>
</div>
<!-- PER-INTEGRATION TABS -->
<?php foreach ($activeIntegrations as $integration): ?>
<?php
$intId = (int) ($integration['id'] ?? 0);
if ($intId <= 0) continue;
$intData = $integrationTranslationsMap[$intId] ?? [];
$intName = isset($intData['name']) ? (string) $intData['name'] : '';
$intShort = isset($intData['short_description']) ? (string) $intData['short_description'] : '';
$intDesc = isset($intData['description']) ? (string) $intData['description'] : '';
?>
<div class="content-tab-panel" id="content-tab-integration-<?= $e((string) $intId) ?>">
<p class="muted" style="margin-bottom:8px">
Puste pole = używana wartość globalna.
</p>
<label class="form-field">
<span class="field-label"><?= $e($t('products.fields.name')) ?></span>
<input class="form-control" type="text"
name="integration_content[<?= $e((string) $intId) ?>][name]"
value="<?= $e($intName) ?>">
</label>
<div class="form-field mt-12">
<span class="field-label"><?= $e($t('products.fields.short_description')) ?></span>
<div class="wysiwyg-wrap">
<div id="editor-int-short-<?= $e((string) $intId) ?>"></div>
</div>
<textarea name="integration_content[<?= $e((string) $intId) ?>][short_description]"
id="input-int-short-<?= $e((string) $intId) ?>"
style="display:none"><?= $e($intShort) ?></textarea>
</div>
<div class="form-field mt-12">
<span class="field-label"><?= $e($t('products.fields.description')) ?></span>
<div class="wysiwyg-wrap" style="--editor-min-height:180px">
<div id="editor-int-desc-<?= $e((string) $intId) ?>"></div>
</div>
<textarea name="integration_content[<?= $e((string) $intId) ?>][description]"
id="input-int-desc-<?= $e((string) $intId) ?>"
style="display:none"><?= $e($intDesc) ?></textarea>
</div>
</div>
<?php endforeach; ?>
</div>
Step 2: Update the Quill initialization script at the bottom of edit.php
The current script initializes quillShort and quillDesc for global fields. Extend it to also initialize editors for each per-integration tab, and sync all on form submit:
// --- existing global editors ---
var quillShort = new Quill('#editor-short-description', { theme: 'snow', modules: { toolbar: toolbarShort } });
var quillDesc = new Quill('#editor-description', { theme: 'snow', modules: { toolbar: toolbarFull } });
if (shortInput && shortInput.value) quillShort.clipboard.dangerouslyPasteHTML(shortInput.value);
if (descInput && descInput.value) quillDesc.clipboard.dangerouslyPasteHTML(descInput.value);
// --- per-integration editors ---
var intEditors = []; // array of {shortQuill, descQuill, shortInput, descInput}
document.querySelectorAll('[id^="editor-int-short-"]').forEach(function(el) {
var suffix = el.id.replace('editor-int-short-', '');
var shortEl = el;
var descEl = document.getElementById('editor-int-desc-' + suffix);
var shortInp = document.getElementById('input-int-short-' + suffix);
var descInp = document.getElementById('input-int-desc-' + suffix);
if (!shortEl || !descEl || !shortInp || !descInp) return;
var qShort = new Quill(shortEl, { theme: 'snow', modules: { toolbar: toolbarShort } });
var qDesc = new Quill(descEl, { theme: 'snow', modules: { toolbar: toolbarFull } });
if (shortInp.value) qShort.clipboard.dangerouslyPasteHTML(shortInp.value);
if (descInp.value) qDesc.clipboard.dangerouslyPasteHTML(descInp.value);
intEditors.push({ shortQuill: qShort, descQuill: qDesc, shortInput: shortInp, descInput: descInp });
});
// --- sync all on submit ---
var form = document.querySelector('.product-form');
if (form) {
form.addEventListener('submit', function() {
if (shortInput) shortInput.value = quillShort.root.innerHTML;
if (descInput) descInput.value = quillDesc.root.innerHTML;
intEditors.forEach(function(e) {
e.shortInput.value = e.shortQuill.root.innerHTML;
e.descInput.value = e.descQuill.root.innerHTML;
});
});
}
Step 3: Commit
git add resources/views/products/edit.php
git commit -m "feat: add per-integration content tabs to product edit form"
Task 7: Tab switching CSS + JS
Files:
- Modify:
resources/scss/app.scss - JS inline in
resources/views/products/edit.php
Step 1: Add tab styles to app.scss
.content-tabs-card {
margin-top: 0;
}
.content-tabs-nav {
display: flex;
gap: 4px;
border-bottom: 2px solid var(--c-border);
margin-bottom: 16px;
flex-wrap: wrap;
}
.content-tab-btn {
padding: 8px 16px;
border: none;
background: none;
cursor: pointer;
font-size: 14px;
font-weight: 500;
color: var(--c-text-muted, #6b7280);
border-bottom: 2px solid transparent;
margin-bottom: -2px;
border-radius: 4px 4px 0 0;
transition: color 0.15s, border-color 0.15s;
&:hover {
color: var(--c-text-strong, #111827);
}
&.is-active {
color: var(--c-primary, #2563eb);
border-bottom-color: var(--c-primary, #2563eb);
}
}
.content-tab-panel {
display: none;
&.is-active {
display: block;
}
}
Step 2: Add tab-switching JS in edit.php (inline, after the Quill script)
(function() {
var nav = document.getElementById('content-tabs-nav');
if (!nav) return;
nav.addEventListener('click', function(e) {
var btn = e.target.closest('.content-tab-btn');
if (!btn) return;
var tabId = btn.getAttribute('data-tab');
if (!tabId) return;
// deactivate all
nav.querySelectorAll('.content-tab-btn').forEach(function(b) {
b.classList.remove('is-active');
});
document.querySelectorAll('.content-tab-panel').forEach(function(p) {
p.classList.remove('is-active');
});
// activate selected
btn.classList.add('is-active');
var panel = document.getElementById('content-tab-' + tabId);
if (panel) panel.classList.add('is-active');
});
})();
Step 3: Rebuild CSS
cd "C:/visual studio code/projekty/orderPRO" && npm run build:css
Step 4: Commit
git add resources/scss/app.scss resources/views/products/edit.php public/assets/css/app.css
git commit -m "feat: tab switching styles and JS for per-integration content"
Task 8: Add translations key
Files:
- Modify:
resources/lang/pl.php
Step 1: Add content_tabs key under products
Find the products array and add:
'content_tabs' => [
'global' => 'Globalna',
],
Step 2: Commit
git add resources/lang/pl.php
git commit -m "feat: add content_tabs translation key"
Task 9: Manual smoke test
- Navigate to
/settings/database→ run pending migrations → confirmproduct_integration_translationsexists and has rows for marianek.pl products - Navigate to
/products/edit?id=32→ confirm tabs appear: "Globalna" and "marianek.pl" - Switch tabs → confirm fields toggle correctly
- Edit marianek.pl tab name/description → Save → confirm saved in DB:
SELECT * FROM product_integration_translations WHERE product_id = 32; - Import a product from shopPRO → confirm row created in
product_integration_translations - Verify global fields unchanged after editing integration tab