@php $allChannels = $this->getAllChannels(); $isLanding = $this->channelId === ''; @endphp @if ($isLanding) {{-- ═══════════════════════════════════════════════════════════════ --}} {{-- LANDING: channel card overview --}} {{-- ═══════════════════════════════════════════════════════════════ --}} @php $channelStats = $this->getChannelStats(); $global = $this->getGlobalStats(); $palette = ['#4f46e5','#0891b2','#059669','#d97706','#dc2626','#7c3aed','#be185d','#065f46']; @endphp
Manage Listings
@if ($global['listed'] > 0) {{ number_format($global['listed']) }} live listings @endif @if ($global['errors'] > 0) · {{ number_format($global['errors']) }} errors @endif
@foreach ($allChannels as $index => $channel) @php $connected = $channel->isConnected(); $stats = $channelStats[$channel->id] ?? ['mapped' => 0, 'listed' => 0, 'errors' => 0]; $bgColor = $palette[$index % count($palette)]; $iconSlug = file_exists(public_path("vendor/channels/platforms/small/{$channel->platform->value}.svg")) ? $channel->platform->value : null; $initial = mb_strtoupper(mb_substr($channel->name, 0, 1)); @endphp
@if ($iconSlug) {{ $channel->platform->getLabel() }} @else {{ $initial }} @endif
{{ $channel->name }}
@if ($channel->shop_domain)
{{ $channel->shop_domain }}
@else
{{ $channel->platform->getLabel() }}
@endif
{{ $connected ? 'Connected' : 'Not connected' }}
@if ($connected)
{{ number_format($stats['mapped']) }}
Linked
{{ number_format($stats['listed']) }}
Listed
{{ number_format($stats['errors']) }}
Errors
@else

Connect this channel to start managing listings.

@endif @if ($connected) @else Connect Channel @endif
@endforeach
@else {{-- ═══════════════════════════════════════════════════════════════ --}} {{-- PRODUCT TABLE: per-channel listing management (V2) --}} {{-- ═══════════════════════════════════════════════════════════════ --}} @php $activeChannel = $this->getActiveChannel(); $statusCounts = $this->getStatusCounts(); $data = $this->getProductRows(); $rows = $data['rows']; $total = $data['total']; $totalPages = max(1, (int) ceil($total / $this->perPage)); $showReasonCol = $this->statusFilter === 'needs_update' && $this->search === ''; $pageProductIds = collect($rows)->pluck('product_id')->map(fn ($id) => (string) $id)->toArray(); // Maps product_id → bulk-action context for Alpine to use when search crosses tab boundaries. $pageProductStatuses = []; foreach ($rows as $row) { $s = $row['listing_status'] ?? 'listed'; if ($s === 'not_mapped') $ctx = 'not_mapped'; elseif (in_array($s, ['not_listed', 'pending_sync'], true)) $ctx = 'not_synced'; elseif ($s === 'error') $ctx = 'error'; else $ctx = 'listed'; // covers 'listed' and 'needs_update' $pageProductStatuses[(string) $row['product_id']] = $ctx; } $platformValue = $activeChannel?->platform?->value ?? 'unknown'; $isShopify = $platformValue === 'shopify'; $isEbay = $platformValue === 'ebay'; $isAmazon = $platformValue === 'amazon'; $restrictedTerms = $isAmazon && $activeChannel ? \Webkul\Channel\Services\RestrictedTermChecker::termsFor($activeChannel->id) : []; $platformLabel = match($platformValue) { 'shopify' => 'Shopify', 'ebay' => 'eBay', 'amazon' => 'Amazon', default => 'Channel', }; @endphp {{-- Alpine + poll wrapper: owns all selection state so checkboxes respond instantly. Inline x-data avoids the SPA-navigation race (no named-component registration needed). v2-bulk-bar is a direct child so its Alpine directives always see selectedCount/isSelecting. --}}
{{-- Header row --}}
› {{ $activeChannel?->name ?? 'Listings' }}
@if ($isShopify) {{-- Split button: Sync Changes (fast) + Full Sync dropdown --}}
Shopify Orphans Gap Report @if($activeChannel && $activeChannel->shop_domain) Shopify Count @endif @elseif ($isEbay) eBay Configurators @elseif ($isAmazon) Amazon Configurators @endif
{{-- Toolbar: search + filter tabs + quick actions --}}
@if ($this->search !== '')
Searching all statuses
@else
@foreach ([ 'listed' => ['label' => 'Listed', 'count' => $statusCounts['listed'], 'cls' => ''], 'verifying' => ['label' => 'Pending', 'count' => $statusCounts['verifying'], 'cls' => ''], 'needs_update' => ['label' => 'Needs Update', 'count' => $statusCounts['needs_update'], 'cls' => 'needs_update'], 'not_synced' => ['label' => 'Awaiting Sync', 'count' => $statusCounts['not_synced'], 'cls' => ''], 'not_mapped' => ['label' => 'Not Linked', 'count' => $statusCounts['not_mapped'], 'cls' => ''], 'error' => ['label' => 'Errors', 'count' => $statusCounts['error'], 'cls' => 'error'], ] as $value => $tab) @endforeach
@endif {{-- Quick action buttons depending on active tab --}} @if (($statusCounts['pending_sync'] ?? 0) > 0) @endif @if ($this->statusFilter === 'not_mapped' && ($statusCounts['not_mapped'] ?? 0) > 0 && count($this->selectedIds) === 0) @if ($isShopify) @elseif ($isEbay) @elseif ($isAmazon) @endif @elseif (in_array($this->statusFilter, ['not_synced', 'needs_update']) && count($this->selectedIds) === 0) @php $pushCount = $this->statusFilter === 'needs_update' ? $statusCounts['needs_update'] : $statusCounts['not_synced']; @endphp @if ($pushCount > 0 && $isShopify) @endif @endif {{-- Filter button --}} @php $activeFilterCount = ($this->warehouseFilter > 0 ? 1 : 0) + ($this->stockFilter !== 'all' ? 1 : 0); @endphp
@if ($activeFilterCount > 0) {{ $activeFilterCount }} @endif
{{-- Active filter chips --}} @php $warehouses = $this->getWarehouses(); $selectedWarehouse = $this->warehouseFilter ? collect($warehouses)->firstWhere('id', $this->warehouseFilter) : null; $activeFilterCount = ($this->warehouseFilter > 0 ? 1 : 0) + ($this->stockFilter !== 'all' ? 1 : 0); @endphp @if ($activeFilterCount > 0)
@if ($selectedWarehouse) Warehouse: {{ $selectedWarehouse->name }} @endif @if ($this->stockFilter !== 'all') {{ $this->stockFilter === 'in_stock' ? 'In stock only' : 'Out of stock only' }} @endif
@endif {{-- Filter panel (right-side slide-out, Alpine-controlled) --}}
{{-- Header --}}
Filters
{{-- Body --}}
{{-- Warehouse --}}
{{-- Stock level --}}
@foreach (['all' => 'All stock', 'in_stock' => 'In stock only', 'out_of_stock' => 'Out of stock only'] as $val => $label) @endforeach
@if (!$this->warehouseFilter)

Select a warehouse above to filter by stock level.

@endif
{{-- Footer --}}
{{-- Table --}}
{{-- Select-all banner (Alpine-driven — instant, no server round-trip) --}}
@php $selectedStatusLabel = match ($this->statusFilter) { 'all' => '', 'not_synced' => 'awaiting sync ', default => str_replace('_', ' ', $this->statusFilter).' ', }; @endphp All {{ $selectedStatusLabel }}products selected. selected.
@if (empty($rows))

No products found

@if ($this->search !== '') No products match "{{ $this->search }}" @else No products with status "{{ $this->statusFilter === 'not_synced' ? 'awaiting sync' : str_replace('_', ' ', $this->statusFilter) }}" @endif

@else @if ($isAmazon) @endif @if ($isAmazon || $isEbay) @endif @if ($isEbay) @endif @if ($isEbay || $isAmazon || $isShopify) @endif @if ($this->warehouseFilter) @endif @if ($showReasonCol) @endif @foreach ($rows as $row) @php $isMapped = $row['listing_status'] !== 'not_mapped'; $isLinked = ! empty($row['shopify_variant_id']); $isListed = $row['listing_status'] === 'listed'; $isError = $row['listing_status'] === 'error'; $needsUpd = $row['listing_status'] === 'needs_update'; $isPendingSync = $row['listing_status'] === 'pending_sync'; $rowContext = match(true) { $row['listing_status'] === 'not_mapped' => 'not_mapped', in_array($row['listing_status'], ['not_listed','pending_sync'], true) => 'not_synced', $row['listing_status'] === 'error' => 'error', default => 'listed', }; // Same priority order ShopifyBatchSyncJob actually uses to resolve the handle it // pushes: channel-level override, then the product's own "URL Handle" field, then // an auto-generated slug as a last resort. Handles are sticky on Shopify's side // (don't change just because the title does), so guessing straight from the // current title here -- skipping url_key -- silently drifts from the real handle // the moment a title is edited after the product was first pushed, producing a // "View on storefront" link that 404s even though the real listing is fine. $shopifyHandle = $row['attributes']['handle'] ?? ($row['product_url_key'] ?: null) ?? \Illuminate\Support\Str::slug($row['shopify_product_title'] ?? $row['product_name']); $storefrontUrl = ($isLinked && $shopifyHandle && $activeChannel?->shop_domain) ? 'https://' . $activeChannel->shop_domain . '/products/' . $shopifyHandle : null; $statusLabel = match($row['listing_status']) { 'listed' => 'Listed', 'needs_update' => 'Needs Update', 'error' => 'Error', 'not_mapped' => 'Not Linked', 'not_listed' => 'Not Synced', 'pending_sync' => 'Queued', 'verifying' => 'Pending', // Amazon: submitted, waiting for Amazon to confirm it is live default => 'Not Synced', }; @endphp @php $displayTitle = $row['attributes']['title'] ?? $row['product_name']; $titleLen = $isEbay ? mb_strlen($displayTitle) : null; $titleOver = $titleLen !== null && $titleLen > 80; @endphp @if ($isAmazon) @endif @if ($isAmazon || $isEbay) @php $configuratorName = $isAmazon ? $row['amazon_configurator_name'] : $row['ebay_configurator_name']; @endphp @endif @if ($isEbay) @php $catName = $row['ebay_category_name'] ?? null; $catSource = $row['ebay_category_source'] ?? null; $catBadge = match ($catSource) { 'override' => ['Override', '#dbeafe', '#1e40af'], 'map' => ['Mapped', '#d1fae5', '#065f46'], 'default' => ['Default', '#fef3c7', '#92400e'], default => ['Unmapped', '#fee2e2', '#991b1b'], }; @endphp @endif @if ($isEbay || $isAmazon) @elseif ($isShopify) @endif @if ($this->warehouseFilter) @endif @if ($showReasonCol) @endif @endforeach
Product Brand {{ $isShopify ? 'Shopify SKU' : ($isEbay ? 'eBay Offer ID' : 'Platform SKU') }}ASINConfiguratoreBay CategoryQty to PushPriceStockStatusReasonLast Synced
@if ($row['thumbnail']) @else
@endif
{{ $displayTitle }} @if ($isEbay)
{{ $titleLen }}/80 @if ($titleOver)  — over by {{ $titleLen - 80 }} @endif @endif @if ($restrictedTerms && empty($row['amazon_asin'])) @php $termHits = \Webkul\Channel\Services\RestrictedTermChecker::find($displayTitle, $restrictedTerms); @endphp @if ($termHits)
Restricted term in title: {{ implode(', ', $termHits) }} @endif @endif @if (!empty($row['attributes']['title']) && $row['attributes']['title'] !== $row['product_name'])
{{ $row['product_name'] }} @endif @if ($row['product_sku'])
{{ $row['product_sku'] }} @endif @if ($isError && $row['last_error'])
{{ $row['last_error'] }} @endif
@if ($row['product_vendor']) {{ $row['product_vendor'] }} @else — @endif @if ($isAmazon) @if (! empty($row['amazon_seller_sku']) && ! empty($row['amazon_asin'])) {{ $row['amazon_seller_sku'] }} @elseif (! empty($row['amazon_seller_sku'])) {{ $row['amazon_seller_sku'] }} @else — @endif @elseif ($isEbay) @php $ebayOfferId = $row['ebay_offer_id'] ?? null; $ebayListingId = $row['ebay_listing_id'] ?? null; $ebayItemUrl = $ebayListingId ? 'https://www.ebay.co.uk/itm/' . $ebayListingId : null; @endphp @if ($ebayItemUrl) {{ $ebayOfferId }} @elseif ($ebayOfferId) {{ $ebayOfferId }} @else — @endif @else @if ($storefrontUrl) {{ $row['shopify_sku'] }} @elseif ($row['shopify_sku']) {{ $row['shopify_sku'] }} @else — @endif @endif
@if (! empty($row['amazon_asin'])) @endif
@if ($configuratorName) {{ $configuratorName }} @else — @endif @if ($catName) {{ $catName }} @else — @endif {{ $catBadge[0] }} @php $qtyToPush = $isEbay ? ($row['ebay_qty_to_push'] ?? null) : ($row['amazon_qty_to_push'] ?? null); $qtyCap = $isEbay ? $row['ebay_qty_cap'] : $row['amazon_qty_cap']; @endphp @if ($qtyToPush === null) — @else {{ $qtyToPush }} @if ($qtyCap !== null) (cap {{ $qtyCap }}) @endif @endif @php $qtyToPush = $row['shopify_qty_to_push'] ?? null; $perLocation = $row['shopify_qty_per_location'] ?? []; $tooltip = collect($perLocation)->map(fn ($q, $name) => "{$name}: {$q}")->implode(' · '); @endphp @if ($qtyToPush === null) — @else {{ $qtyToPush }} @endif £{{ $row['price'] }} @php $qty = $row['available_qty'] ?? 0; @endphp {{ $qty }} @if ($row['listing_status'] === 'needs_update' && !empty($row['ebay_offer_id'])) {{-- Product IS live on eBay but has a pending update it can't push --}}
Listed Needs Update
@else {{ $statusLabel }} @endif
@if (!empty($row['update_reason'])) {{ $row['update_reason'] }} @else — @endif {{ $row['last_synced_at'] ?? '—' }}
@if (! $isMapped) @else @if ($isError) @if ($isEbay) {{-- Retry Create for eBay errors is bulk-only now: tick the row(s) and use the sticky bulk action bar's "Retry Create" button (bulkRetryEbayCreate) instead of a redundant per-row button here. --}} @elseif ($isAmazon) @php $isUnconfirmed = str_starts_with($row['last_error'] ?? '', 'Unconfirmed after'); @endphp @if ($isUnconfirmed) @else @endif @else @endif @endif @if ($isAmazon && in_array($row['listing_status'] ?? '', ['listed', 'verifying'])) @endif @if ($isAmazon) @endif @endif
@if ($totalPages > 1)
Showing {{ ($this->page - 1) * $this->perPage + 1 }}–{{ min($this->page * $this->perPage, $total) }} of {{ number_format($total) }}
@endif @endif
{{-- ══ STICKY BULK ACTION BAR (Alpine-driven — visible immediately on selection) ══ --}} {{-- wire:ignore: Livewire morph must not overwrite Alpine's display changes on this element. style="display:none": initial hidden state (replaces x-cloak, which morph would re-add). --}}
{{-- end Alpine listingSelection / wire:poll wrapper --}} @include('channels::filament.partials.listing-modal') {{-- Link modal --}} @if ($linkModalProductId !== null)
Link to {{ $platformLabel }} {{ $isEbay ? 'Offer' : ($isAmazon ? 'Listing' : 'Variant') }}
{{ $linkModalProductName }}
@if (mb_strlen($linkSearch) < 2)
@if ($isEbay) Two ways to link:
• Type the exact eBay SKU (e.g. TF382) — only finds listings created via Cydekick
• Or paste the item number from the eBay listing URL (e.g. 227166049658) — works for all listings @elseif ($isAmazon) Enter the listing's ASIN (e.g. B016KDO2YA) or its exact seller SKU from Seller Central. Listings created directly in Amazon get an auto-generated SKU, so the ASIN is the easiest way to find them. @else Type at least 2 characters to search Shopify variants. @endif
@elseif (empty($linkResults))
@if ($isEbay) No eBay offers found for "{{ $linkSearch }}".
Tip: SKU search is case-sensitive and only finds Inventory API listings. To link a traditional listing, paste its item number from the URL (e.g. 227166049658). @elseif ($isAmazon) No Amazon listing found for "{{ $linkSearch }}". Use the ASIN, or the exact seller SKU (case-sensitive). @else No Shopify variants found matching "{{ $linkSearch }}". @endif
@else @foreach ($linkResults as $result)
{{ $result['external_sku'] }}
{{ $result['product_title'] }} @if (($result['variant_title'] ?? null) && $result['variant_title'] !== 'Default Title') — {{ $result['variant_title'] }} @endif
@if ($result['already_mapped'])
Already linked to another product
@endif
@endforeach @endif
@endif {{-- Mark-all modal --}} @if ($showMarkAllModal)

Mark all listed as Needs Update?

This will mark every currently Listed product as needing a sync. The update reason will be set to "Manual mark-all" so you can see why in the Needs Update tab.

@endif {{-- Push images modal --}} @if ($showPushImagesModal)

Push Images to Shopify

{{ $activeChannel?->name }}

This will update the images only for every product with an active Shopify mapping — skipping price, stock, and metafield updates for speed.

    @if ($activeChannel?->use_watermarked_images)
  • Watermarked images will be used where processed
  • @else
  • Original images will be pushed (watermarking is off for this channel)
  • @endif
  • The job runs in the background — progress shows in the console
  • Existing Shopify listings are not affected beyond their images
@endif {{-- End eBay Listings confirmation modal --}} @if ($showEndEbayModal)

End {{ count($selectedIds) === 1 ? '1 eBay listing' : count($selectedIds).' eBay listings' }}?

This will call Withdraw Offer on eBay and remove {{ count($selectedIds) === 1 ? 'the listing' : 'these listings' }} from the eBay marketplace.

The eBay mapping will be removed. Products remain in Cydekick and can be re-listed via Not Linked when needed.

@endif {{-- Bulk unlink confirmation modal --}} @if ($showUnlinkModal)

Unlink {{ count($selectedIds) === 1 ? '1 product' : count($selectedIds).' products' }} from {{ $platformLabel }}?

The {{ $platformLabel }} link will be removed. {{ count($selectedIds) === 1 ? 'The product' : 'The products' }} will remain in Cydekick and can be re-listed from Not Linked.

@endif {{-- Delete-from-Amazon confirmation modal --}} @if ($showDeleteAmazonModal)

Delete "{{ $deleteAmazonProductSku }}" from Amazon?

This calls Amazon's Listings API directly to remove the live listing — not just unlink it here. This cannot be undone.

@endif {{-- ASIN search results modal --}} @if ($showAsinSearchModal)

Matching ASINs

for "{{ $asinSearchProductName }}" — review carefully before confirming, this is a best-effort search.

@if (empty($asinSearchResults) && $asinSearchRan)
No matches found on Amazon for this product's barcode or title.
@else
@foreach ($asinSearchResults as $result)
@if ($result['image']) @else
@endif
{{ $result['title'] }}
{{ $result['asin'] }}@if($result['brand']) · {{ $result['brand'] }}@endif · {{ $result['match_type'] }}
@endforeach
@endif
@endif {{-- Orphan modal --}} @if ($showOrphanModal)

Orphaned Shopify Collections

{{ count($orphanList) }} collections exist in Shopify but not in the Cydekick category tree.

@foreach ($orphanList as $orphan)
{{ $orphan['title'] ?? $orphan['handle'] }} / {{ $orphan['handle'] }}
@endforeach
@endif {{-- Sync Shopify Catalogue confirmation modal --}} @if ($showSyncCatalogueModal)

Sync Shopify Catalogue

This fetches the full product and variant list from Shopify and refreshes the local cache that Cydekick uses for SKU linking.

What it does:
  • Fetches all Shopify products page by page via the API (~80+ pages for a large catalogue)
  • Saves variant data locally (SKU, Shopify IDs, price, title)
  • Removes variants from the local cache that no longer exist in Shopify
  • Runs in the background — takes 2–4 minutes for a large store
Local cache only — nothing in Shopify is changed.
However, variants removed from Shopify since the last sync will be deleted from the local cache. Any SKU links made using those variants will no longer match in Auto-link by SKU. Your Cydekick listings and channel mappings are not affected.
When to use: After creating new products in Shopify, after a bulk import, or when "Auto-link by SKU" can't find products you know exist in Shopify.
@endif {{-- Sync Collections confirmation modal --}} @if ($showSyncCollectionsModal)

Sync Collections & Menus

This rebuilds Shopify vehicle collections and navigation menus from the Cydekick category tree. It runs in two phases in the background.

Phase 1 — Collections
  • Creates or updates Shopify smart collections for every category in the Cydekick category tree
  • Sets collection images, metafields, and template suffixes
  • Idempotent — safe to re-run; existing collections are updated, not duplicated
Phase 2 — Navigation Menus
  • Rebuilds one Shopify nav menu per root category
  • Menu structure mirrors the category tree, linked to the collections above
This modifies your live Shopify store.
Navigation menus will be overwritten to match the current category tree. If any category data is incomplete or miscategorised in Cydekick, that will be reflected in the live menus immediately.
Custom Shopify collection templates will be reset.
Phase 1 rebuilds every smart collection, which resets any custom theme template you have set in Shopify back to the default. After this sync you must manually reassign them:
Service Kits → Shopify → Collections → Service Kits → Theme template → service-kits
The menu sync requires the write_online_store_navigation Shopify scope. If your channel token is missing this scope, Phase 2 will fail — check the Channel view for your token's live scopes.
Runtime: 5–10 minutes depending on the number of brands and models. Progress is shown in the console.
@endif {{-- Bulk sync confirmation modal --}} @if ($showBulkSyncModal) @php $pushCount = $this->statusFilter === 'needs_update' ? ($statusCounts['needs_update'] ?? 0) : ($statusCounts['not_synced'] ?? 0); $isUpdate = $this->statusFilter === 'needs_update'; @endphp

{{ $isUpdate ? 'Update' : 'Push' }} {{ number_format($pushCount) }} Products to Shopify?

@if ($isUpdate) This will sync all {{ number_format($pushCount) }} products marked as needing an update to Shopify. @else This will push {{ number_format($pushCount) }} unlisted or pending products to Shopify. @endif A single background batch job will be dispatched using the Shopify Bulk Operations API.

  • Uploads a JSONL batch to Shopify — one API call for all products
  • Shopify processes the batch in the background (typically 2–15 minutes)
  • Progress is shown live in the console panel
  • Product images are synced in a separate phase after the batch completes
@endif {{-- Cancel sync confirmation (Alpine) --}}

Stop sync jobs?

This will remove all queued sync jobs and reset those products back to Needs Update.

@endif {{-- eBay configurator picker modal --}} @if ($showEbayConfiguratorModal)

Create on eBay

Choose the listing template (configurator) to use.

Configure templates in eBay Configurators. The selected template defines the category, policies, and listing format.

@endif {{-- Amazon configurator picker modal --}} @if ($showAmazonConfiguratorModal)

{{ $amazonModalContext === 'single' ? 'Retry on Amazon' : 'Create on Amazon' }}

{{ $amazonModalContext === 'single' ? "Pick the configurator to retry this listing under — change it if it errored because it's mapped to the wrong one." : 'Choose the listing template (configurator) to use.' }}

Configure templates in Amazon Configurators. The selected template defines the product type and attribute mapping.

@endif {{-- ── Notes drawer ────────────────────────────────────────────────────── --}}