Files
orderPRO/DOCS/plans/2026-02-27-per-integration-product-content.md
2026-02-27 21:43:15 +01:00

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

  1. Navigate to /settings/database → run pending migrations → confirm product_integration_translations exists and has rows for marianek.pl products
  2. Navigate to /products/edit?id=32 → confirm tabs appear: "Globalna" and "marianek.pl"
  3. Switch tabs → confirm fields toggle correctly
  4. Edit marianek.pl tab name/description → Save → confirm saved in DB:
    SELECT * FROM product_integration_translations WHERE product_id = 32;
    
  5. Import a product from shopPRO → confirm row created in product_integration_translations
  6. Verify global fields unchanged after editing integration tab