# "View on storefront" link 404s after a title edit (2026-09-29)

## The bug

The Listings page's "View on storefront" link could point to a URL that 404s, even though the
real Shopify listing is completely fine — found on **RTC4472B** (product id 21083): created
successfully, then its title was edited (added "Britpart") via the admin. The real Shopify
listing kept working throughout; only Cydekick's own *displayed link* broke.

## Root cause

`manage-listings-v2.blade.php` guessed the Shopify handle for display like this:

```php
$shopifyHandle = $row['attributes']['handle']
    ?? \Illuminate\Support\Str::slug($row['shopify_product_title'] ?? $row['product_name']);
```

— channel-level override, else slugify whatever title is currently cached. But **Shopify
handles are sticky**: they don't change just because the product's title changes via the API
(unless the mutation explicitly includes a new handle). So the moment a title is edited *after*
the product's first push, this guess permanently diverges from Shopify's real, unchanged handle.

The real bug: `ShopifyBatchSyncJob` (what actually pushes to Shopify) already resolves the
handle it sends via a **three-tier** priority — channel override → **`product.url_key`** →
auto-generated slug (`plugins/webkul/channels/src/Jobs/ShopifyBatchSyncJob.php`, `$manualHandle`).
The display logic only implemented two of those three tiers, silently skipping `product.url_key`
— the exact field that, for RTC4472B, already held the correct, currently-live handle
(`rtc4472b-land-rover-defender-steering-damper`, an exact match confirmed via a live
`getProductAttributesFromShopify()` GraphQL call). The display logic just never looked at it.

## Fix

`manage-listings-v2.blade.php` now uses the identical three-tier order:

```php
$shopifyHandle = $row['attributes']['handle']
    ?? ($row['product_url_key'] ?: null)
    ?? \Illuminate\Support\Str::slug($row['shopify_product_title'] ?? $row['product_name']);
```

`product_url_key` (new select column `p.url_key as product_url_key` in
`ManageListingsV2::getProductRows()`) needed adding to the query and row array — it wasn't
fetched at all before this fix.

## Diagnosing a similar case in future

Compares Cydekick's guessed handle against Shopify's real one via a live API call
(`Channel::driver()->getProductAttributesFromShopify($shopifyProductId)`):

```bash
cat > /tmp/diagnose-handle.php <<'EOF'
<?php
use Illuminate\Support\Str;
use Webkul\Channel\Models\Channel;

$sku = 'SKU_HERE';
$product = \DB::table('products_products')->where('sku', $sku)->first();
$mapping = \DB::table('channels_sku_mappings')->where('product_id', $product->id)->whereNotNull('shopify_product_id')->first();
$listing = \DB::table('channel_listings')->where('product_id', $product->id)->where('channel_id', $mapping->channel_id)->first();
$attrs = $listing ? (json_decode($listing->attributes ?? '{}', true) ?? []) : [];
$variant = \DB::table('channels_shopify_variants')->where('channel_id', $mapping->channel_id)->where('sku', $mapping->shopify_sku)->first();

$guessedHandle = $attrs['handle'] ?? ($product->url_key ?: null) ?? Str::slug($variant->product_title ?? $product->name);
echo "Cydekick's displayed handle: {$guessedHandle}\n";

$channel = Channel::find($mapping->channel_id);
$real = $channel->driver()->getProductAttributesFromShopify($mapping->shopify_product_id);
echo "Shopify's real handle: " . ($real['handle'] ?? '(none)') . "\n";
EOF
php artisan tinker --execute="require '/tmp/diagnose-handle.php';"
```

## Tests

`tests/Feature/ShopifyStorefrontLinkHandleTest.php` — 5 tests, locking in the exact priority
order (override wins > url_key wins over a guessed slug > slug is only a last resort > an empty
string url_key doesn't short-circuit past the slug fallback), plus confirming
`getProductRows()` actually supplies `product_url_key`. All passing.

## Second occurrence: the per-product "Channel Listings" tab

Found immediately after deploying the fix above — a *different* page,
`ManageChannelListings.php` (Product → Channel Listings tab, not the Channels → Listings page),
has its own separate implementation of the same link, with an even weaker version of the bug:
it derived the handle *only* from the cached `variant->product_title`, with no `url_key`
fallback at all, and left the link icon fully disabled (`null`) whenever that cache was empty
— which it was for RTC4472B, so the icon just looked greyed out/non-functional.

Fixed with the identical three-tier order (`channel_listings.attributes['handle']` override →
`product.url_key` → a guessed slug of the cached variant title, last resort). Verified live via
`Livewire::test()` against real data (product ERR3340 / id 1): `channel_url` now correctly
resolves to `https://banwells1.myshopify.com/products/err3340-...` instead of staying `null`.

Test: `tests/Feature/ChannelListingsStorefrontLinkTest.php` — 2 tests (url_key is used when no
override exists; an explicit override still wins over url_key), both passing.

**Worth knowing for next time**: this same "derive the handle from a cached title instead of a
real stored handle" pattern might exist anywhere else in the app that builds a Shopify
storefront link — these were the two places found and fixed so far, but a broader search
wasn't done. If another broken storefront link shows up somewhere else, check first whether it
has the same shape (only reads `variant->product_title`, never `url_key`) before re-deriving a
fix from scratch.

## Deploy note

Pure Filament/Livewire page display logic — no job, queue, or scheduled-task code touched.
`sudo supervisorctl restart cydekick-worker:*`/`cydekick-scheduler` are **not** needed for this
fix to take effect; the usual `view:clear`/`optimize:clear` deploy steps are enough.
