# Channel Listings — How It Works

## Channel tabs are platform-specific (2026-09-26)

`ChannelPlatform::supportedTabs()` (`plugins/webkul/channels/src/Enums/ChannelPlatform.php`) returns the tab keys
each platform gets on a channel's View/Edit sub-navigation (view, edit, sku-sync, order-sync, product-import,
category, listing-validation, sync-reference, sync-log). `ChannelResource::getRecordSubNavigation()` filters the
tab list against it, and `ChannelResource::abortUnlessTab()` 404s a tab page directly if the platform doesn't
support it (a direct URL hit, not just hiding the link). Add a platform to a tab's key in `supportedTabs()` to
expose it there — no other change needed. Amazon and eBay only get `product-import`/`sync-log`/`sku-sync` if
added; today only Shopify has those three (they're Shopify-SKU-table-shaped pages).

Each channel page also shows an identity card above the tabs (logo + channel name, e.g. "Somerset4×4 Amazon") —
`HasRecordNavigationTabs::getChannelHeaderData()` / the `record-navigation-tabs.blade.php` `$channelData` block,
sibling to the existing product-identity card the same trait already drew for product pages.

## Channel Activity Log Page

A standalone log page at `Admin → /channel-activity-log` shows all job activity for any channel. Accessed via the **Logs** button on each row of the Channels list table.

**File:** `plugins/webkul/channels/src/Filament/Pages/ChannelActivityLogPage.php`
**View:** `resources/views/filament/pages/channel-activity-log.blade.php`

- URL param `?channel={id}` pre-selects a channel (passed by the Logs button)
- When a channel is selected: filters `job_activity_logs` rows by `display_name LIKE '%{channel->name}%'`
- When no channel selected: filters to a fixed list of channel-related `job_class` values
- Filters: channel dropdown, status (ok/error/warn/running/info), date range, free-text search
- Shows: timestamp, activity name, status badge, items (processed/total/failed), duration, message

The `display_name` in `job_activity_logs` consistently embeds the channel name for all channel jobs (e.g. `"Bulk Sync · Somerset4x4 · 1000 products"`), making the LIKE filter reliable.

---

The Channel Listings tab on the product edit page lets you override listing attributes (title, description, vendor etc.) on a per-channel basis. Changes are pushed to the remote platform (Shopify etc.) via the existing background sync job.

---

## Sync State Tracking Table

**Table: `channels_sku_sync_status`** — records what was last pushed to Shopify for each SKU at each location. Written by `ShopifyBatchSyncJob` (Phase 6) and `ShopifyBatchCreateJob` (Phase 7) after every bulk operation.

| Column | Type | Purpose |
|--------|------|---------|
| `channel_id` | FK → channels_channels | Which channel |
| `sku_mapping_id` | FK → channels_sku_mappings | Which SKU / product |
| `location_mapping_id` | FK → channels_location_mappings | Which Shopify location |
| `shopify_qty` | int nullable | Qty pushed in the last sync |
| `shopify_price` | decimal nullable | Price set on the Shopify variant |
| `shopify_cost` | decimal nullable | Cost pushed to Shopify |
| `cydekick_price` | decimal nullable | Cydekick channel price at time of sync |
| `sync_status` | string | `pending` / `synced` / `error` |
| `sync_message` | text nullable | Error message if status = error |
| `last_synced_at` | timestamp nullable | When the last successful push ran |

Unique constraint on `(sku_mapping_id, location_mapping_id)` — one row per SKU per location. `updateOrCreate` is used so rows are upserted on every sync.

**Model:** `Webkul\Channel\Models\SkuSyncStatus`
**Migration:** `2026_03_30_000006_create_channels_sku_sync_status_table` (+ `2026_04_01_000001_add_shopify_cost_to_sku_sync_status`)

This table is **not** a Shopify field — it is internal state used by the Stock & Price Sync tab to show what was last pushed and detect drift between Cydekick and Shopify.

---

## Database

**Table: `channels_product_listing_attributes`**

| Column | Type | Purpose |
|--------|------|---------|
| `channel_id` | FK → channels_channels | Which channel this override is for |
| `product_id` | FK → products_products | Which product |
| `attribute_key` | varchar(100) | e.g. `title`, `description`, `vendor`, `status` |
| `attribute_value` | text nullable | The override value. NULL/empty = use the product's default |

Unique constraint on `(channel_id, product_id, attribute_key)` — one row per attribute per channel per product.

**Why rows, not columns?** Each channel platform needs different attributes. Shopify wants `title`, `description`, `vendor`, `status`, `tags`. A future Amazon channel would need `bullet_point_1`–`bullet_point_5`, `search_terms` etc. Using rows means adding a new channel with new attribute types requires zero DB migrations — just update the driver.

---

## Adding attributes to an existing channel (Shopify)

Open [ShopifyClient.php](../../plugins/webkul/channels/src/Services/ShopifyClient.php) and add an entry to `getListingAttributeSchema()`:

```php
[
    'key'         => 'my_new_field',   // stored as attribute_key in DB
    'label'       => 'My New Field',   // shown in the UI
    'type'        => 'text',           // 'text' | 'textarea' | 'select'
    'placeholder' => 'Optional hint',
],
```

Then make sure `pushListingAttributes()` in the same file maps it to the correct Shopify REST field (if the key doesn't match the Shopify API field name, add a rename like `description` → `body_html`).

That's it — no migration, no model change, no UI change. The tab auto-renders the new field on next page load.

---

## Adding a brand-new channel platform

1. Create a driver class implementing `ChannelDriverInterface` (see [ChannelDriverInterface.php](../../plugins/webkul/channels/src/Contracts/ChannelDriverInterface.php))
2. Implement `getListingAttributeSchema()` returning the attributes your platform needs
3. Implement `pushListingAttributes(string $platformProductId, array $attributes): bool` to call your platform's API
4. Register the driver in `ChannelPlatform` enum and `Channel::driver()`

The tab UI, DB storage, and sync job work automatically for any driver that implements the interface.

---

## How the sync works

When you click **Save & Sync** on the Channel Listings tab:

1. Each changed attribute row is upserted into `channels_product_listing_attributes`
2. `SyncProductToChannels` job is dispatched (only for channels where something changed)
3. The job runs in the background via the queue worker
4. Inside the job, step 0 reads all non-null attribute rows for the product+channel, calls `driver->pushListingAttributes()` which sends a single `PUT /products/{id}.json` to Shopify with all overrides at once
5. If no listing attributes are set for a product, the step is skipped entirely

The sync is also triggered automatically whenever the product's price, cost, or inventory changes — so listing attribute overrides are re-pushed on every sync, keeping Shopify in sync even after bulk operations.

---

## Key files

| File | Purpose |
|------|---------|
| [channels_product_listing_attributes migration](../../plugins/webkul/channels/database/migrations/2026_06_16_102326_create_channels_product_listing_attributes_table.php) | Creates the DB table |
| [ChannelProductListingAttribute.php](../../plugins/webkul/channels/src/Models/ChannelProductListingAttribute.php) | Eloquent model |
| [ChannelDriverInterface.php](../../plugins/webkul/channels/src/Contracts/ChannelDriverInterface.php) | Contract — `getListingAttributeSchema()` and `pushListingAttributes()` |
| [ShopifyClient.php](../../plugins/webkul/channels/src/Services/ShopifyClient.php) | Shopify implementation of the above |
| [SyncProductToChannels.php](../../plugins/webkul/channels/src/Jobs/SyncProductToChannels.php) | Background job — step 0 pushes listing attributes |
| [ManageChannelListings.php](../../plugins/webkul/inventories/src/Filament/Clusters/Products/Resources/ProductResource/Pages/ManageChannelListings.php) | The tab page — renders form, handles save |
| [manage-channel-listings.blade.php](../../plugins/webkul/inventories/resources/views/filament/clusters/products/resources/product-resource/pages/manage-channel-listings.blade.php) | Blade view for the tab |

---

## Multi-channel publishing (Shopify)

Products must be explicitly published to each Shopify sales channel (Online Store, Shop, Point of Sale, etc.). The REST `published: true` field only covers the Online Store publication — other channels are silently excluded.

### How publishing works

`ShopifyBatchSyncJob` and `ShopifyBatchCreateJob` both call `ShopifyClient::bulkPublishProducts()` at the end of each batch. This method:

1. Calls `getPublicationIds()` to fetch all publication GIDs from Shopify (cached 24 hours)
2. Sends batched `publishablePublish` GraphQL mutations — 10 products per request, 800ms between chunks
3. `publishablePublish` is idempotent — safe to call on already-published products

A one-time backfill command exists for existing products:
```bash
php artisan shopify:publish-all --channel=5 --dry-run   # preview
php artisan shopify:publish-all --channel=5              # run
```

### Required OAuth scope

The Shopify app must have **`read_publications`** and **`write_publications`** scopes. Without them, `getPublicationIds()` returns empty and no publishing happens. After adding scopes in the Shopify Partner Dashboard, re-authenticate the channel via Admin → Channels → [channel] → Re-authenticate with Shopify.

### API version gotcha — `publishablePublish` input format

The API version in use is `2024-01`. In this version, `publishablePublish` takes an **array of `PublicationInput` objects** with a singular `publicationId` field:

```graphql
# CORRECT for API 2024-01
publishablePublish(id: "gid://shopify/Product/123", input: [
    {publicationId: "gid://shopify/Publication/1"},
    {publicationId: "gid://shopify/Publication/2"}
])
```

Do **not** use `PublishablePublishInput` with `publicationIds` (plural) — that is the newer API format and is **not** recognised in 2024-01:

```graphql
# WRONG — PublishablePublishInput not defined in API 2024-01
mutation($input: PublishablePublishInput!) {
    publishablePublish(id: "...", input: $input) { ... }
}
```

`publishablePublish` is also **not** supported as a Bulk Operation mutation — attempting it via `bulkOperationRunMutation` returns `"Invalid Bulk Mutation Field"`. Use regular aliased GraphQL mutations instead.

### Rate limiting

Each `publishablePublish` costs 10 GraphQL points. The API bucket is 2000 points with a 100 points/sec restore rate. At 10 products per chunk (100 points/chunk), allow 800ms between chunks to stay within the restore rate. ~27 minutes for a 20,000-product backfill.

---

## Expanding in future

Planned future attributes (just add to driver schema + handle in `pushListingAttributes`):

- SEO title / SEO description (Shopify metafields)
- Custom metafields (namespace + key pairs)
- Product images (per-channel image overrides)
- Amazon: bullet points, search terms, browse node

---

---

# ManageListingsV2 — Channel Listing Driver Pattern

`ManageListingsV2` (`plugins/webkul/channels/src/Filament/Pages/ManageListingsV2.php`) is the global product-channel management page (Admin → Channels → Manage Listings V2). It handles linking, unlinking, sync dispatch, and bulk operations across **all** channel platforms.

> **Note:** This is separate from the per-product Channel Listings attribute tab above. That tab uses `ChannelDriverInterface`. The page below uses `ChannelListingDriver`.

---

## Why a driver pattern?

Every sales channel has platform-specific behaviour for:
- How product mappings are stored (Shopify: `channels_sku_mappings`; eBay: `channels_ebay_sku_mappings`)
- How products are unlinked (Shopify: soft-delete `is_active = false`; eBay: hard-delete + remove channel_listing row)
- How the "Link" modal searches for existing platform items (Shopify: LIKE search on `channels_shopify_variants`; eBay: eBay API by exact SKU)
- How the link is persisted

Previously these were `if ($isEbay) { ... } else { ... }` branches. With three or more channels that becomes unreadable. The driver pattern puts each channel's logic in its own class, with one place to add a new channel.

---

## The interface

**`plugins/webkul/channels/src/Contracts/ChannelListingDriver.php`**

```php
interface ChannelListingDriver
{
    public function platformLabel(): string;
    public function unlink(int $productId, int $channelId): void;
    public function bulkUnlink(array $productIds, int $channelId): int;
    public function searchForLink(string $query, int $channelId, ?int $currentProductId): array;
    public function linkItem(int $productId, int $channelId, string $externalId): void;
}
```

| Method | Called by | Notes |
|--------|-----------|-------|
| `platformLabel()` | Notifications, modal copy | e.g. `'Shopify'`, `'eBay'` |
| `unlink()` | `unlinkProduct()` wire action | Single product; no return value |
| `bulkUnlink()` | `bulkUnlink()` wire action | Returns count for notification |
| `searchForLink()` | `updatedLinkSearch()` as the user types | Returns normalised result rows (see below) |
| `linkItem()` | `linkItem()` wire action | Persists the link; throw `\RuntimeException` on conflict |

### `searchForLink()` result format

Every driver must return rows with these keys so the blade modal works without branching:

```php
[
    'external_id'    => string,       // opaque ID passed back to linkItem()
    'external_sku'   => string,       // displayed in the result row
    'product_title'  => string,       // main description line
    'variant_title'  => string|null,  // sub-line (null if not applicable)
    'already_mapped' => bool,         // true → button disabled + "Already linked" label
]
```

---

## Existing drivers

| Driver | File | Mapping table | Link search |
|--------|------|---------------|-------------|
| `ShopifyListingDriver` | `src/ListingDrivers/ShopifyListingDriver.php` | `channels_sku_mappings` (soft-delete) | LIKE search on `channels_shopify_variants` |
| `EbayListingDriver` | `src/ListingDrivers/EbayListingDriver.php` | `channels_ebay_sku_mappings` (hard-delete) | eBay API — `GET /sell/inventory/v1/offer?sku=` (exact SKU match) |

---

## How the driver is resolved

`ChannelPlatform` enum has `getListingDriverClass()`:

```php
// src/Enums/ChannelPlatform.php
public function getListingDriverClass(): ?string
{
    return match ($this) {
        self::Shopify => \Webkul\Channel\ListingDrivers\ShopifyListingDriver::class,
        self::Ebay    => \Webkul\Channel\ListingDrivers\EbayListingDriver::class,
        default       => null,
    };
}
```

`ManageListingsV2::getListingDriver()` resolves it:

```php
private function getListingDriver(): ?\Webkul\Channel\Contracts\ChannelListingDriver
{
    $channel = $this->getActiveChannel();
    $driverClass = $channel?->platform?->getListingDriverClass();
    return $driverClass ? new $driverClass($channel) : null;
}
```

All driver constructors accept `Channel $channel` as the only argument.

---

## The link modal (blade)

**`resources/views/filament/pages/manage-listings-v2.blade.php`**, around line 1052.

The modal uses `$isEbay` / `$isShopify` only for copy (title, placeholder, empty-state text). The result rows are always rendered using the normalised keys above. The wire action is always `wire:click="linkItem('{{ $result['external_id'] }}')"`.

### eBay link behaviour — two modes

The eBay link modal accepts two input formats, detected automatically by `EbayListingDriver::searchForLink()`:

**Mode 1 — eBay item number (10+ digits)**
The number from the listing URL (`www.ebay.co.uk/itm/227166049658`). Use this for *traditional listings* — any listing that was NOT created via Cydekick's EbayBatchCreateJob (e.g. pre-existing Seller Hub listings, CSV imports, Trading API listings). No API call is made; a mapping row is written directly with `ebay_listing_id` set and `ebay_offer_id = null`. The product's own SKU is stored as `ebay_sku`. **Note:** price/stock updates via EbayBatchSyncJob will not function for these rows since they have no offer ID; they serve as a tracking link only.

**Mode 2 — exact eBay SKU**
The inventory SKU as stored in the eBay Inventory API (case-sensitive, exact match). Only finds listings that were created via Cydekick's EbayBatchCreateJob (which calls `createOrReplaceInventoryItem` to register the SKU in the Inventory API). If found, the offer details (`offerId`, `listingId`) are fetched live and stored in `channels_ebay_sku_mappings`.

**Why the Inventory API can't find legacy listings:** eBay's `/sell/inventory/v1/offer?sku=` only returns offers that were created through the Inventory/Offers API. Traditional listings created via Seller Hub, CSV upload, or the old Trading API are invisible to it — even if the "Custom label" matches the SKU exactly.

---

## Adding a new channel platform (e.g. Amazon)

1. **Create the driver class** in `src/ListingDrivers/AmazonListingDriver.php` implementing `ChannelListingDriver`.

2. **Implement all 5 methods.** For `searchForLink()`, return the normalised result format above. For `linkItem()`, persist to your platform's mapping table.

3. **Register in `ChannelPlatform` enum:**
   ```php
   case Amazon = 'amazon';
   // in getListingDriverClass():
   self::Amazon => \Webkul\Channel\ListingDrivers\AmazonListingDriver::class,
   ```

4. **Blade toolbar** — the header toolbar (`@if ($isShopify) ... @elseif ($isEbay) ...`) still uses direct conditionals for platform-specific toolbar buttons (Shopify Catalogue Sync, eBay Configurators link, etc.). Add an `@elseif ($platformValue === 'amazon')` block if Amazon needs toolbar buttons.

5. **Bulk create** — the "Create All" and bulk create buttons also branch on `$isShopify`/`$isEbay`. Add the Amazon `@elseif` block and wire it to the appropriate PHP method. The PHP method itself should live directly in `ManageListingsV2` (bulk create is orchestration, not driver logic) or optionally extend the interface if it becomes complex enough.

6. **No migration needed for the driver pattern itself.** You will need migrations for whatever mapping table Amazon uses.

---

## What is NOT in the driver

These remain directly in `ManageListingsV2.php` because they are orchestration, not platform-specific:

- `bulkPush()` / `pushNow()` — dispatch jobs (different jobs per platform, branched inside)
- `bulkCreate()` / `createInShopify()` / `dispatchEbayCreate()` — create-new-listing flows
- `retryEbayCreate()` / `bulkRetryEbayCreate()` — eBay-specific retry logic
- `bulkSyncEbay()` / `bulkEndEbayListings()` — eBay sync / end-listing bulk actions
- `cancelPendingSync()` / `markAllListedAsNeedsUpdate()` — cross-platform queue management
- `getProductRows()` / `getStatusCounts()` — unified DB queries (join both mapping tables)

These can be moved into the driver or a separate interface if a third platform makes the branching intolerable.

---

## Amazon listing status: Pending → Listed (2026-09-25)

`channel_listings.status` gained a new value, **`verifying`**, shown as a blue "Pending" badge on Manage
Listings V2. A clean `putListingsItem()` submission only means Amazon didn't reject it — it does not mean the
listing is live (confirmed for real: a submission can stay clean with zero issues for over an hour while never
resolving into a live offer). So `AmazonBatchCreateJob` now writes `status = 'verifying'` instead of `'listed'`
straight after a clean submission; `VerifyAmazonListingJob` (already existed, runs 5 min later then retries at
10/20/30/60 min) flips it to `'listed'` only once `getListingsItem()` shows a populated `offers` array, or to
`'error'` if it's still unconfirmed after ~2 hours or Amazon reports an issue. The "Re-verify" action on a listing
also resets it to `verifying` while it re-checks. `verifying` rows count toward the **Listed** tab/total (they were
submitted, just not confirmed yet) via the same `whereIn('cl.status', ['listed', 'verifying'])` pattern already
used for `needs_update`/`pending_sync`.

## Amazon listing title override (2026-09-25)

`AmazonBatchCreateJob` built the `item_name` attribute from whatever the configurator's Title mapping pointed at
(normally the product name) and never looked at the listing's own saved title (the bold text shown on Manage
Listings, set via the listing modal, `channel_listings.attributes.title`). eBay's create job already preferred
that override (`EbayBatchCreateJob`'s `$listingTitleOverrides`); Amazon now does the same — the same
`channel_listings.attributes.title` lookup replaces the mapped `item_name` value when present, falling back to
the product name otherwise. Existing live Amazon listings aren't retroactively corrected by this — Amazon's
create endpoint doesn't reliably let a later `putListingsItem` overwrite the title on an ASIN it already
accepted; the practical fix for an existing wrong title is via Seller Central directly.

## Key files summary

| File | Purpose |
|------|---------|
| `src/Contracts/ChannelListingDriver.php` | Interface — must implement to add a new channel |
| `src/ListingDrivers/ShopifyListingDriver.php` | Shopify: link/unlink/search via Shopify variants table |
| `src/ListingDrivers/EbayListingDriver.php` | eBay: link/unlink/search via eBay Inventory API |
| `src/Enums/ChannelPlatform.php` | `getListingDriverClass()` — maps enum case → driver class |
| `src/Services/EbayClient.php` | `getOffersBySku(string $sku): array` — used by eBay driver |
| `src/Filament/Pages/ManageListingsV2.php` | Page class — `getListingDriver()`, `linkItem()`, `unlinkProduct()`, `bulkUnlink()`, `updatedLinkSearch()` |
| `resources/views/filament/pages/manage-listings-v2.blade.php` | Blade — link modal uses normalised result keys; toolbar still branches per platform |
