# Product Categories — Architecture & Dev Guide

## Overview

Cydekick uses a **multi-category** system where a single product can belong to many categories simultaneously. This was introduced in June 2026 to support vehicle fitment-based categorisation (a product like an oil filter can fit a Defender, Discovery, and Range Rover at the same time).

---

## Database Structure

### `products_categories` — the category tree

| Column | Description |
|---|---|
| `id` | Primary key |
| `name` | Short name, e.g. `"Engine Parts"` |
| `full_name` | Full path, e.g. `"Land Rover / Discovery / Discovery 1 1989 - 1998 / Engine Parts"` |
| `parent_id` | Self-referential FK — null for root nodes |
| `parent_path` | Slash-delimited ancestor IDs, e.g. `/2/5/12/` |
| `company_id` | Somerset4x4 = `2` |
| `sort_order` | Controls tree ordering |
| `image` | Storage path for collection image (e.g. `category-images/01KV….png`) |

The `full_name` and `parent_path` columns are **computed automatically** by the `Category` model's `boot()` hooks and cascade to all descendants when a node is renamed or moved. Never update these manually.

`image` is served via `Category::getImageUrl()` which resolves to R2 or `public` disk. Images are pushed to the corresponding Shopify collection by `shopify:create-vehicle-collections`.

### `products_product_categories` — the pivot (M2M)

```sql
product_id   BIGINT FK → products_products.id (CASCADE DELETE)
category_id  BIGINT FK → products_categories.id (CASCADE DELETE)
PRIMARY KEY (product_id, category_id)
INDEX (category_id, product_id)
```

This is the authoritative source of category membership. All counts, filters, and assignments use this table.

### `products_products.category_id` — legacy column

A single FK to `products_categories` still exists on the products table. It is kept for backward compatibility with parts of the codebase not yet migrated (exports, API). **Do not rely on it for new code.** It is gradually being phased out.

---

## Category Tree Structure

> **Note**: The "Vehicle Models" wrapper root was removed in June 2026. Jaguar and Land Rover are now root-level categories (depth 0). All depth references and tag mappings reflect this change.

```
Uncategorised                     ← catch-all for products with no category
Jaguar                            ← root / depth 0
  ├── Classic                     ← family / depth 1
  │     ├── E-Type Series 1       ← year-range model / depth 2
  │     │     ├── Axles & Suspension   ← part category / depth 3
  │     │     ├── Body & Chassis
  │     │     ├── Braking System
  │     │     ├── Engine Parts
  │     │     ├── Powertrain & Clutch
  │     │     ├── Cooling & Climate
  │     │     ├── Fuel & Air
  │     │     ├── Electrical
  │     │     ├── Steering
  │     │     ├── Exhaust System
  │     │     ├── Service & Maintenance
  │     │     └── Accessories & More
  │     ├── E-Type Series 2 / S3, 420, MK10, XJ6 S1/S2/S3, XJ12 S1, XJ40, XJ81, XJS
  ├── E-Pace (X540)               ← family / depth 1 (no year-range children — modern)
  ├── F-Pace (X761)               ← family / depth 1 (no year-range children — modern)
  ├── F-Type, I-Pace, S-Type, X-Type, XE, XF, XJ, XK, XK8  ← (all no year-range children)
Land Rover                        ← root / depth 0
  ├── Discovery                   ← family / depth 1
  │     ├── Discovery 1 1989-1998 ← year-range model / depth 2 (has 12 part children)
  │     ├── Discovery 2-5, Discovery Sport
  ├── Defender                    ← family / depth 1
  │     ├── Defender 90 1983-2006, 110, 130, 2007>, 2020
  ├── Evoque, Freelander, Range Rover, Series
```

The **core 12 part categories** under each year-range model node are always the same names:
`Axles & Suspension`, `Body & Chassis`, `Braking System`, `Engine Parts`, `Powertrain & Clutch`, `Cooling & Climate`, `Fuel & Air`, `Electrical`, `Steering`, `Exhaust System`, `Service & Maintenance`, `Accessories & More`

Additional part categories (e.g. `Lighting`) can be added via the PSP Category Mapping UI. When added, they are created under **every** model node (including those with no products in that category), mirroring the behaviour of the core 12.

Modern Jaguar families (E-Pace, F-Pace, etc.) have **no year-range children** — their part categories sit directly as depth-2 children. Products in these families are not covered by the `l1_` tag system and do not get model-level Shopify collections.

---

## Eloquent Relationships

All product category work uses the `categories()` BelongsToMany on Product:

```php
// Product model (plugins/webkul/products/src/Models/Product.php)
public function categories(): BelongsToMany
{
    return $this->belongsToMany(Category::class, 'products_product_categories', 'product_id', 'category_id');
}

// Category model — same pivot, reversed
public function products(): BelongsToMany
{
    return $this->belongsToMany(Product::class, 'products_product_categories', 'category_id', 'product_id');
}
```

The `Category` model is overridden in four plugins (`inventories`, `invoices`, `sales`, `products`). All four now use `BelongsToMany` for `products()`. When adding a 5th plugin that extends Category, use BelongsToMany — never HasMany.

### Assigning categories

```php
// Add a category (additive — preserves existing)
$product->categories()->syncWithoutDetaching([$categoryId]);

// Remove from one specific category
$product->categories()->detach($categoryId);

// Replace ALL categories (e.g. from a form save)
$product->categories()->sync([$catId1, $catId2]);
```

### Filtering products by category

```php
// Correct: uses pivot
Product::whereHas('categories', fn($q) => $q->where('id', $categoryId))->get();

// Wrong: uses legacy column only
Product::where('category_id', $categoryId)->get();
```

---

## PSP Auto-Assignment

### Command

```bash
php artisan psp:assign-categories
php artisan psp:assign-categories --dry-run        # preview only
php artisan psp:assign-categories --sku=ERR3340    # single product
php artisan psp:assign-categories --vehicle="Discovery 1"  # one vehicle
```

### How it works

1. Loads DB category mappings from `allmakes_psp_category_mappings` — builds two lookup maps:
   - `$subcatMap["catname|||subcatname"]` → part category name (subcatname-specific mappings, highest priority)
   - `$catnameMap["catname"]` → part category name (catname-level fallback)
2. Loads all model-level category nodes (those with part children) from the tree
3. **Pre-pass**: for any part category names in `allmakes_psp_part_category_names` that are NOT in the core 12, creates their nodes under every model category in the tree. This ensures new categories like "Lighting" exist on all models (including empty ones) before products are assigned.
4. Loads all distinct fitment rows from `allmakes_psp_fitments` (including `subcatname`)
5. Joins to `products_products` on SKU in PHP (avoids slow SQL function-wrapped join)
6. Maps PSP `vehiclename` → tree model category using:
   - **Auto-map**: `UPPER(tree_category.name) === vehiclename` for most Land Rover models
   - **Override map**: hardcoded in `VEHICLE_OVERRIDE_MAP` constant for name mismatches (e.g. "DISCOVERY SPORT 2015 ONWARDS" → "Discovery Sport 2015>") and Jaguar entries
   - **Fan-out**: generic PSP entries ("ALL DEFENDERS", "DEFENDER 1986 - 2006") expand to multiple specific model variants
7. Maps each fitment to a part category name using: subcatname-specific match → catname fallback → `"Accessories & More"` default
8. `INSERT OR IGNORE` into pivot in 500-row chunks
9. Removes "Uncategorised" pivot entries for any product that received a real category

### PSP source tables

Three Jaguar catalogues exist in `allmakes_psp_fitments` under marques `JAGUAR`, `JAG2021`, `JAG2024`. All three are processed and deduplicated. `JAGUAR` has the most SKUs; `JAG2021`/`JAG2024` are supplemental.

### Skipped entries

These PSP vehicle entries are intentionally skipped (no tree node):
- `LAND ROVER|ALL VEHICLES` — too generic
- `LAND ROVER|FORWARD CONTROL 101` — not in tree
- `JAGUAR|ALL` — too generic
- `JAG2021|DAIMLER LIMOUSINE CLASSIC` — not in tree

---

## PSP Category Mapping UI

**Path**: Connectors → Allmakes PSP → Category Mapping (`/admin/allmakes-psp-category-mapping`)

**Page class**: `plugins/webkul/allmakes-psp/src/Filament/Pages/AllmakesCategoryMapping.php`

**View**: `plugins/webkul/allmakes-psp/resources/views/filament/pages/allmakes-category-mapping.blade.php`

### Purpose

Replaces the old hardcoded `CATNAME_MAP` constant. Lets you map PSP catname/subcatname combinations to Cydekick part categories without touching code. Supports subcatname-level specificity — e.g. map `Electrical > Lighting` to a new `Lighting` category rather than the catch-all `Electrical`.

### Two backing tables

| Table | Purpose |
|---|---|
| `allmakes_psp_part_category_names` | Source of truth for the left-panel category list. Pre-seeded with core 12. New categories are inserted here the moment you click Add. |
| `allmakes_psp_category_mappings` | Maps PSP terms to part categories. Columns: `part_category_name`, `psp_catname`, `psp_subcatname` (NULL = entire catname). Unique on `(psp_catname, psp_subcatname)`. Pre-seeded with the 24 original catname-level mappings. |

### Workflow

1. **Left panel** — lists all Cydekick part categories. Click one to select it.
2. **Right panel** — shows all PSP catnames (collapsed) with their subcatnames. Tick any catname or subcatname to map it to the selected Cydekick category. **Changes auto-save** on each tick (no Save button).
3. **Add** — type a new category name and click Add. It is immediately written to `allmakes_psp_part_category_names` and persists across page refreshes before any PSP terms are ticked.
4. **Delete** — removes the category from `part_category_names` and all its mapping rows. Core 12 cannot be deleted.
5. **Apply to Products** — dispatches `ApplyPspCategoryMappingsJob` which runs `psp:assign-categories` via the queue. Watch the job console bar for progress.

### Key behaviours

- **New PSP terms appear automatically** after a scrape — `pspTermGroups()` queries `allmakes_psp_fitments` directly (no config update needed).
- **Subcatname takes priority over catname**: if `Electrical|||Lighting → Lighting` exists, a fitment with catname=Electrical/subcatname=Lighting maps to `Lighting`, not `Electrical`.
- **New categories are created on all models**: the `psp:assign-categories` pre-pass creates a new category node under every model (58 models as of 2026-07) before fitment processing, even if that model has no products in that category — matching the behaviour of the core 12.
- **One PSP term → one Cydekick category**: the `(psp_catname, psp_subcatname)` unique key means a PSP term can only map to one category at a time. Saving a new mapping overwrites any prior mapping for that term.

### Migrations registered in AllmakesPspServiceProvider

```
2026_07_10_000001_create_allmakes_psp_category_mappings_table   ← mapping table + 24-row seed
2026_07_10_000002_create_allmakes_psp_part_category_names_table ← names table + 12-row seed
```

---

## Correcting PSP Errors

**The command is idempotent (INSERT OR IGNORE) — re-running it will re-add any PSP categories you manually removed.**

If PSP has an error and you remove a product from a wrong category:
- Your removal survives until the next full re-run of `psp:assign-categories`
- A full re-run will re-add the wrong category because PSP still says the part fits

### Safe correction workflow (current)

1. Remove the product from the wrong category in the tree page or product form
2. Do **not** run `psp:assign-categories` again for that product
3. For new products, use `--sku=` to process just the new SKU — this is safe as long as that product's PSP data is correct

### Recommended future improvement — `is_psp` flag

Add a `is_psp` boolean column to `products_product_categories`:

```sql
ALTER TABLE products_product_categories ADD COLUMN is_psp TINYINT(1) NOT NULL DEFAULT 0;
```

- PSP command sets `is_psp = 1` for all rows it creates
- Manual assignments (tree page, product form) set `is_psp = 0`
- On re-run, the command only touches `is_psp = 1` rows — never overwrites manual ones

This would make corrections permanent and allow the command to be safely re-run after PSP catalogue updates.

---

## UI — Category Tree Page

**Path**: `app/Filament/Pages/CategoryTreePage.php`
**View**: `resources/views/filament/pages/category-tree.blade.php`

### Tree View tab
- Displays the full category hierarchy with product counts (from `withCount('products')` on pivot)
- Click any leaf category → opens "Manage products" modal for that category
- Drag handles allow reordering (sort_order)
- "Assign products" button → modal to search and add products to a category
- "Manage" → shows products in that category, allows removing or moving to another category

### Bulk Assign tab
- Filter by any category to see its products, or leave blank to see all
- Multi-select products, then:
  - **Add to category**: adds to the selected target category (additive)
  - **Remove from filter category**: detaches selected products from the currently filtered category (only visible when a filter is active)
- "CURRENT CATEGORY" column shows all categories the product is in, comma-separated from pivot

### Uncategorised behaviour
- "Uncategorised" is a real category node, not a null state
- When a product is assigned to any real category, it is automatically removed from Uncategorised
- This happens in: `assignProducts()`, `bulkAssignProducts()`, `moveSelectedProducts()`, and `psp:assign-categories`
- Products genuinely without fitment data remain in Uncategorised

---

## ProductResource — category fields

`plugins/webkul/products/src/Filament/Resources/ProductResource.php`

| Location | Component | What it does |
|---|---|---|
| Form | `Select::make('categories')->multiple()->relationship('categories', 'full_name')` | Multi-select, saves via pivot `sync()` |
| Table column | `TextColumn::make('categories.name')->badge()` | Shows all categories as badges |
| Table filter | `SelectFilter::make('categories')->relationship('categories', 'full_name')->multiple()` | Filters via `whereHas` on pivot |
| Infolist | `TextEntry::make('categories.full_name')->badge()` | Full paths as badges |

---

## Migrations registered in ServiceProvider

`plugins/webkul/products/src/ProductServiceProvider.php` → `hasMigrations([])`:

```
2026_06_24_000010_create_products_product_categories_table   ← pivot table
2026_06_24_000011_backfill_product_categories_pivot          ← backfill from category_id
2026_06_24_000002_add_sort_order_to_products_categories      ← sort_order column
2026_06_24_000003_add_image_to_products_categories           ← image column
2026_06_24_000004_add_soft_deletes_to_products_categories    ← SoftDeletes
2026_06_24_000005_add_company_id_to_products_categories      ← company_id column
```

---

## Phase completion status

| Phase | Description | Status |
|---|---|---|
| 1 | Pivot table + backfill | ✅ Done |
| 2 | Models — BelongsToMany on all Category classes | ✅ Done |
| 3 | CategoryTreePage — 7 locations updated to use pivot | ✅ Done |
| 4 | ProductResource — form, table column, filter, infolist | ✅ Done |
| 5 | API, Export (ProductController, ExportManager, ShopifyProductImporter) | ⏳ Pending |
| 6 | PSP auto-assign command | ✅ Done |
| 7 | Drop legacy `category_id` column | ⏳ After Phase 5 |
| — | `is_psp` flag for manual override protection | 🔮 Recommended future |

---

## Shopify — Tag & Metafield Mapping

### Purpose

Every product sync translates the product's category tree membership into
Shopify **tags** and **metafields**, enabling:

1. **Automated collections** — one per year-range model — built automatically from tags.
   No manual collection maintenance needed when products are re-categorised.
2. **Search & Discovery filters** — Category and Subcategory filters on the
   collection page via metafields (not tags — Shopify collapses all tags into one
   messy filter blob).
3. **Customer journey**: mega menu → Brand → Family → Year-range model collection → filter by Category → filter by Subcategory.

### Tag scheme (`l1_` / `l2_` / `l3_` / `l4_`)

Tags use a level prefix based on **absolute depth in the tree** (brands are at depth 0):

| Prefix | Tree depth | Meaning       | Example                      |
|--------|-----------|---------------|------------------------------|
| `l1_`  | 2         | Year-range    | `l1_discovery-1-1989-1998`   |
| `l2_`  | 3         | Part category | `l2_engine-parts`            |
| `l3_`  | 4         | Subcategory   | `l3_oil-filters`             |
| `l4_`  | 5         | Group         | `l4_cartridge-type`          |

Depths 0 (Brand) and 1 (Family) do not generate tags.
Nodes under "Uncategorised" or any other excluded root are silently skipped.

> **Depth change from original design**: Before June 2026, a "Vehicle Models" root wrapper existed at depth 0, pushing all depths one level deeper (l1_ was at depth 3, etc.). When Vehicle Models was removed and brands became root-level, `CategoryTagMapper::DEPTH_TAG_PREFIXES` was updated to match. Any documentation or comments referencing "depth 3 = l1_" is outdated.

**Slugification**: lowercase, `& , ( ) ' " /` → space, non-alphanumeric stripped,
spaces/hyphens collapsed. `"Service & Maintenance"` → `"service-maintenance"`.

The regex `^l\d+_` matches all managed prefixes (including any future `l5_`) so
new levels are automatically stripped without code changes.

### Vehicle root detection

`CategoryTagMapper` determines which root categories are "vehicle brands" by querying for all root-level categories (`parent_id IS NULL`) and excluding `EXCLUDED_ROOT_NAMES` (`uncategorised`, `uncategorized`). This means any new brand added to the tree at root level is automatically included — there is no hardcoded brand list.

### Tag write behaviour (safe merge)

Shopify tags are a **full replace** — sending the wrong array wipes existing tags.
The sync does this safely on every product update:

1. Fetch current tag array from Shopify (`ShopifyClient::getProductTags`)
2. Strip all tags matching `^l\d+_` (managed prefixes)
3. Merge: non-managed Shopify tags + manual listing tags + fresh category tags
4. Send the merged array — `bestseller`, `clearance`, etc. are never touched

On **new product creation** (`CreateProductInShopify`) there are no existing
Shopify tags, so the merge is simply: manual listing tags + category tags.

### Metafields (product-level)

Two `list.single_line_text_field` metafields carry human-readable display names
for Search & Discovery filters:

| Key               | Depth | Example value                               |
|-------------------|-------|---------------------------------------------|
| `part_category`   | 3     | `["Engine Parts", "Service & Maintenance"]` |
| `part_subcategory`| 4     | `["Oil Filters"]`                           |

Both are under `namespace: custom`. The value is a JSON-encoded string array
(Shopify GraphQL requirement for `list.*` types).

Definitions are created automatically on first sync via
`ensureCategoryMetafieldDefinitions()` — cached 7 days per store.

`custom.fitment` (separate JSON metafield, type `json`) is populated from the
`products.vehicle_fitment` DB column for a detailed compatibility table on the
product page. Unrelated to this system — see `vehicle_fitment_table.md`.

### Relevant code

| File | Role |
|------|------|
| `plugins/webkul/channels/src/Services/CategoryTagMapper.php` | Builds tags, metafields, l1 collection descriptors from the category pivot |
| `plugins/webkul/channels/src/Services/ShopifyClient.php` | Tag/metafield methods, collection methods, sub_collections methods |
| `plugins/webkul/channels/src/Jobs/SyncProductToChannels.php` | Merges tags, pushes metafields, ensures collections on every sync |
| `plugins/webkul/channels/src/Jobs/CreateProductInShopify.php` | Includes category tags in initial payload, ensures collections at create time |
| `plugins/webkul/channels/src/Console/Commands/BulkTagBackfillCommand.php` | `php artisan shopify:bulk-tag-backfill --channel=5` |
| `plugins/webkul/channels/src/Console/Commands/CreateVehicleCollectionsCommand.php` | `php artisan shopify:create-vehicle-collections --channel=5` |

---

## Shopify — Collections Architecture

Three tiers of Shopify collections are maintained by `shopify:create-vehicle-collections`:

### Tier 1 — Year-range model collections

One smart collection per year-range model (depth 2). Single rule:
`tag equals "l1_{handle}"` e.g. `l1_discovery-1-1989-1998`.

Handle = slugified model name (no prefix): `discovery-1-1989-1998`
These are **leaf collections** — they contain products and use the **default collection template**.

### Tier 2 — Family collections (brand-prefixed)

One smart collection per family (depth 1). Rules: OR of all child year-range model `l1_` tags.

Handle = `{brand}-{family}` e.g. `jaguar-classic`, `land-rover-discovery`.
Brand-prefixing ensures handles are globally unique (both brands could have a "Classic").

These collections are **landing pages** — they render a tile grid of year-range model collections using `template_suffix: subcollections` and `custom.sub_collections` metafield.

Families with **no year-range children** (modern Jaguar: E-Pace, F-Pace, etc.) have a collection created but no sub_collections metafield and no template suffix — they link directly to a product grid.

### Tier 3 — Brand collections

One smart collection per brand (depth 0). Rules: OR of ALL descendant year-range model `l1_` tags.

Handle = slugified brand name: `jaguar`, `land-rover`.
These are also **landing pages** (`template_suffix: subcollections`, `custom.sub_collections` = ordered family GIDs).

### Collection images

If a category has an `image` stored in Cydekick (uploaded via the tree page), the command pushes it to the corresponding Shopify collection on every run:
- New collection: image included in the `POST /smart_collections.json` payload
- Existing collection: image updated via `PUT /smart_collections/{id}.json`

Currently 5 collections have images: Discovery 1–5 year-range models.

### Artisan command

```bash
php artisan shopify:create-vehicle-collections --channel=5            # full sync
php artisan shopify:create-vehicle-collections --channel=5 --dry-run  # preview
php artisan shopify:create-vehicle-collections --channel=5 --brand="Land Rover"
```

The command is **fully idempotent** — safe to re-run at any time. On each run it:
1. Ensures the `custom.sub_collections` metafield definition exists and is pinned
2. For each depth-2 node: upserts the collection + pushes image if available + sets `template_suffix: parts-categories`
3. For each depth-1 node with depth-2 children: upserts the collection + sets `sub_collections` metafield (ordered depth-2 GIDs) + sets `template_suffix: subcollections`
4. For each depth-0 node: upserts the collection + sets `sub_collections` metafield (ordered depth-1 GIDs) + sets `template_suffix: subcollections`

---

## Shopify — Parts Catalogue Pages (`parts-categories` template)

### Overview

Depth-2 (Tier 3) collections use the `parts-categories` Shopify theme template. Instead of rendering a flat product grid immediately, the page shows a **tile grid of part categories** at the top, with the full product listing below. Cydekick sets this template suffix automatically — no manual Shopify admin work is needed after the first `shopify:create-vehicle-collections` run.

### How a category tile click works

Clicking a tile (e.g. "Engine Parts") **does not navigate to a separate collection**. It applies a filter to the same collection page via a URL parameter:

```
/collections/discovery-1-1989-1998?filter.p.m.custom.part_category=Engine+Parts
```

Only matching products are shown below the tiles. A "← All categories" back-link resets the filter. This keeps the product URL structure flat (one collection per depth-2 node) while giving customers a tile-based drill-down experience.

### What powers the filter — Search & Discovery

The filter values come from the `custom.part_category` product metafield, populated by Cydekick on every product sync.

**One-time setup required in Shopify:**
> Apps → Search & Discovery → Filters → Add filter → Product metafields → Part Category (`custom.part_category`) → Save

Without this, the `filter.p.m.custom.part_category` URL parameter is ignored and products won't filter. This is a store-level setting — it only needs to be done once per Shopify store.

### Product metafields (written by Cydekick on sync)

Three `list.single_line_text_field` metafields are pushed per product:

| Metafield key | Content | Example |
|---|---|---|
| `custom.part_category` | Depth-3 category names | `["Engine Parts", "Braking System"]` |
| `custom.part_subcategory` | Depth-4 subcategory names | `["Oil Filters"]` |
| `custom.vehicle_fitment` | Depth-2 node names (display) | `["Discovery 1 1989-1998"]` |

Values are human-readable (not slugified) because they appear as filter labels in the Search & Discovery UI. The slugified `l1_`/`l2_` tags handle collection membership separately.

### Template suffix is set automatically

`shopify:create-vehicle-collections` calls `setSmartCollectionTemplateSuffix($gid, 'parts-categories')` for every depth-2 collection after creating or updating it. No manual template assignment in Shopify admin is needed — running **Sync Collections & Menus** from the Channels → Listings page applies it to all depth-2 collections in one go.

---

## Shopify — Sub-Collections Landing Page System

### Overview

Brand and family collection pages render as **tile grids** instead of product grids. The theme reads a single metafield to know which child collections to display.

### Metafield definition

| Property | Value |
|---|---|
| Namespace | `custom` |
| Key | `sub_collections` |
| Type | `list.collection_reference` |
| Owner | `COLLECTION` |
| Pinned | Yes (position 1 — appears on collection edit page) |

Created automatically by `ensureSubCollectionsDefinition()` on the first run of `shopify:create-vehicle-collections`. Cached per store for 30 days (cache key `shopify_sub_coll_def_v3_{md5(domain)}`).

### What gets set where

| Collection | template_suffix | sub_collections value |
|---|---|---|
| `jaguar` | `subcollections` | GIDs of all Jaguar family collections (Classic, E-Pace, F-Pace, …) |
| `land-rover` | `subcollections` | GIDs of all Land Rover family collections (Discovery, Defender, …) |
| `jaguar-classic` | `subcollections` | GIDs of all Classic year-range model collections |
| `land-rover-discovery` | `subcollections` | GIDs of Discovery 1–5 + Discovery Sport collections |
| `land-rover-defender`, `evoque`, `series`, `range-rover`, `freelander` | `subcollections` | GIDs of respective year-range collections |
| `jaguar-e-pace-x540` (modern, no year-ranges) | _(empty)_ | _(not set)_ |
| `discovery-1-1989-1998` (year-range model) | _(empty)_ | _(not set)_ |

### Template suffix

Set via REST: `PUT /admin/api/2024-01/smart_collections/{id}.json`
```json
{ "smart_collection": { "id": 123, "template_suffix": "subcollections" } }
```

Collections with `template_suffix: subcollections` tell the theme to render tiles from `custom.sub_collections` instead of a product grid.

### ShopifyClient methods

| Method | Description |
|---|---|
| `ensureSubCollectionsDefinition()` | Creates + pins the metafield definition if missing |
| `setSubCollections(string $gid, array $childGids)` | Sets/replaces `custom.sub_collections` via `metafieldsSet` mutation (upsert) |
| `setSmartCollectionTemplateSuffix(string $gid, string $suffix)` | REST PUT to set `template_suffix` |
| `resolveCollectionGids(array $handles)` | Batch aliased GraphQL to resolve handles → GIDs |
| `updateSmartCollectionImage(string $gid, string $url)` | REST PUT to set collection image |
| `createSmartCollection(title, handle, rules, disjunctive, imageUrl)` | REST POST — creates with optional image |

---

## Shopify — Navigation Menus

### Structure

Two separate brand menus (within Shopify's 3-tier nesting limit):

| Menu handle | Tier 1 (top-level items) | Tier 2 (sub-items) |
|---|---|---|
| `jaguar` | Family collections (Classic, E-Pace, F-Pace, …) | Year-range models under Classic |
| `land-rover` | Family collections (Discovery, Defender, …) | Year-range models per family |

Each menu item links to the corresponding Shopify collection (`resourceId` GID when collection exists, plain URL fallback).

Family items link to brand-prefixed collection handles (`jaguar-classic`, `land-rover-discovery`).
Year-range model items link to unprefixed handles (`discovery-1-1989-1998`).

The **main-menu** has Jaguar and Land Rover as top-level items linking to their brand collections (`/collections/jaguar`, `/collections/land-rover`). Other existing main-menu items (Home, etc.) are preserved.

### Artisan command

```bash
php artisan shopify:sync-main-menu --channel=5            # sync all brands
php artisan shopify:sync-main-menu --channel=5 --dry-run  # preview tree
php artisan shopify:sync-main-menu --channel=5 --brand="Land Rover"
```

This command:
1. Syncs each brand menu (creates if missing, replaces items if exists)
2. Updates `main-menu` to add/replace Jaguar + Land Rover items while preserving other items

### Key classes

| File | Role |
|---|---|
| `plugins/webkul/channels/src/Services/MainMenuSyncer.php` | Core service: `getAllBrands()`, `buildBrandData()`, `syncBrandMenu()`, `syncMainMenuLinks()` |
| `plugins/webkul/channels/src/Console/Commands/SyncMainMenuCommand.php` | Artisan command |
| `plugins/webkul/channels/src/Filament/…/ViewChannel.php` | "Sync Vehicle Menus" action button |

### Exact-handle lookup

Shopify's `menus` GraphQL search is full-text/fuzzy — `query:"handle:jaguar"` can return `main-menu` instead of the `jaguar` menu. The syncer always fetches all menus (`menus(first: 50)`) and filters in PHP for exact handle match.

### Modern Jaguar limitation

Modern Jaguar families (E-Pace, F-Pace, S-Type, XE, XF, XJ modern, XK) appear in the `jaguar` menu as tier-1 items but have no tier-2 sub-items because they have no year-range children in the tree. Clicking them goes directly to their product collection.

These families also do not contribute `l1_` tags (no year-range models = no model tags), so their products do not appear in the `jaguar` brand smart collection's OR rules. The `jaguar` brand collection only covers Classic year-range models.

### Deprecated — "Search By Vehicle" menu

`NavMenuBuilder.php` and `SyncShopifyNavMenuCommand.php` are the **old** nav menu approach. They looked for a `Vehicle Models` root category which no longer exists. These files still exist in the codebase but are effectively dead:
- `ensureNavMenuExists()` in ShopifyClient is no longer called from any job (removed June 2026)
- `NavMenuBuilder::build()` returns `[]` (root not found) so `syncNavMenu()` is never triggered
- Do not use these for new work

---

## Which button actually pushes what (2026-09-29)

Two separate buttons in two separate places are easy to confuse — confirmed directly in code
after a real report of "I edited a category's SEO Description and nothing synced":

| Button | Where | Calls | What it touches |
|---|---|---|---|
| **Sync Vehicle Menus** | Channel's own **View** page (`ChannelResource\Pages\ViewChannel.php`) | `MainMenuSyncer` directly (same as `shopify:sync-main-menu`) | **Navigation menus only** — family/year-range links. Never touches collection title, image, or SEO description, by design. |
| **Sync Collections** (main button) | **Channels → Listings** page (`ManageListingsV2.php`), `wire:click="syncChangedCollections"` | `SyncChangedCollectionsJob` | **Incremental.** Only categories with `updated_at` newer than the channel's `collections_last_synced_at` watermark. Pushes title, image, and `seo_description` → Shopify's `body_html` on the matching collection. Seconds, not minutes. Does *not* touch menus or collection templates. |
| **Full Sync (+ Menus)** (dropdown next to Sync Collections) | Same Listings page, `openSyncCollectionsModal()` | `CreateVehicleCollectionsCommand` + `shopify:sync-main-menu`, both full runs | Rebuilds the **entire** collection tree and both nav menus from scratch. 5–10 minutes. **Resets any custom Shopify collection template** set directly in Shopify admin — only use when adding/removing/reordering a family or brand in the tree, not for routine content edits. |

**If a category's image/title/SEO description isn't showing up on Shopify, "Sync Vehicle Menus"
is the wrong button — it was never wired to push any of that.** Use **Sync Collections** (the
main button, not the dropdown) on the Listings page instead.

### `SyncChangedCollectionsJob` — the incremental mechanism

`plugins/webkul/channels/src/Jobs/SyncChangedCollectionsJob.php`. Watermark-based, not a diff
against Shopify's actual current state:

1. Reads `channels_channels.collections_last_synced_at` for the channel.
2. Queries `products_categories` for `updated_at > $lastSynced` (every row, unfiltered, if the
   watermark is null — i.e. the very first run for that channel touches everything).
3. Derives each changed category's Shopify handle with the exact same depth-based logic
   `CreateVehicleCollectionsCommand` uses (brand → `slugify(name)`; family →
   `slugify(parent).'-'.slugify(name)`; model/leaf → `slugify(name)`, no prefix) — kept in
   sync deliberately so an incremental update never resolves to a different collection than a
   full sync would.
4. Batch-resolves handles → GIDs in one GraphQL call, then `updateSmartCollection()` per
   changed category (title, image if set, `body_html` from `seo_description` if non-empty).
5. Stamps `collections_last_synced_at = now()` at the end — **always**, even on a no-op run or
   one with some `$notFound` handles — so the next run only picks up genuinely newer changes.

Relies on the category Edit action (`app/Filament/Pages/CategoryTreePage.php`) saving via plain
Eloquent `$cat->update([...])`, which bumps `updated_at` automatically — confirmed this is
still the case, so the watermark comparison is reliable as long as edits go through that path
(not a raw `DB::table()->update()`, which wouldn't touch `updated_at`).

**Gotcha found in practice**: `collections_last_synced_at` starts genuinely `NULL` for a
channel until "Sync Collections" is clicked at least once — it is *not* seeded by
`create-vehicle-collections` or by connecting the channel. A channel that has only ever had
"Sync Vehicle Menus" run against it (never "Sync Collections") will show `NULL` here
indefinitely, which is easy to mistake for a broken incremental sync when it's really just
"never been run yet."

---

## Initial / Re-setup Sequence

```bash
# 1. Backfill tags + metafields for all existing mapped products
php artisan shopify:bulk-tag-backfill --channel=5 --dry-run   # preview first
php artisan shopify:bulk-tag-backfill --channel=5

# 2. Create/update all vehicle collections + set sub_collections metafields
php artisan shopify:create-vehicle-collections --channel=5 --dry-run
php artisan shopify:create-vehicle-collections --channel=5

# 3. Sync brand menus + main-menu
php artisan shopify:sync-main-menu --channel=5 --dry-run
php artisan shopify:sync-main-menu --channel=5
```

After initial setup, product syncs handle tag/metafield updates automatically. Re-run step 2 whenever category images are added or the tree structure changes. Re-run step 3 whenever the brand/family list changes.
