# Commerce vertical (WooCommerce)

The `commerce` vertical activates automatically when WooCommerce is
detected (`class_exists('WooCommerce') && function_exists('WC')`). On
stores without Woo the vertical stays dormant — no errors, no admin
nag, no boot-time penalty.

## Scope

The indexer walks:

- All `'publish'` products with `'visible'` or `'catalog'` catalog
  visibility (`searchable` is implied).
- Variations are intentionally NOT folded into the parent product's
  Typesense doc — the gateway commerce schema doesn't model them
  and the storefront re-resolves them at SERP / typeahead render
  time via `wc_get_product()` (see
  `Commerce/Typeahead::nativeFallback`). Keeping the doc lean
  matches the per-tenant Typesense RAM budget in `seekmodo` repo
  `PROJECT_PLAN.md` §6.

## Search swap

Hooks `woocommerce_product_query` (Woo's main archive query) and
`posts_search` (the SQL `WHERE` builder) for queries with a non-empty
`'s'` param and `post_type=product`. The swap is mode-aware:

- `MODE=off`: untouched.
- `MODE=shadow`: gateway called for divergence logging; native Woo
  query result is what shoppers see.
- `MODE=active|enforce`: ranked IDs from the gateway are used to
  hydrate via WC's normal product fetcher, preserving Woo's price /
  stock / availability filters.

## Product document shape (v0.3.0+)

The shape is locked to the gateway commerce Typesense collection
schema defined in `services/mcp-gateway/ops/tenant-add.php` (commerce
default branch). `name` and `id` are required; everything else is
optional and pruned when empty so the gateway never sees explicit
nulls.

```json
{
  "id": "9821",
  "name": "Spruce Hoodie",
  "description": "Cozy & warm hoodie.",
  "brand": "Spruce Co",
  "category_id": [14, 27],
  "category_breadcrumbs": ["Apparel > Hoodies"],
  "price": 79.95,
  "sale_price": 79.95,
  "on_sale": true,
  "currency": "CAD",
  "in_stock": true,
  "sku": "SK-RED-M",
  "url": "https://shop.example/product/spruce-hoodie",
  "image_url": "https://shop.example/.../hoodie-medium.jpg",
  "last_indexed_at": 1749600000
}
```

Notable mappings from the WC API:

| WC API method                   | Doc field             | Notes                                              |
|---------------------------------|-----------------------|----------------------------------------------------|
| `WC_Product::get_id()`          | `id` (string)         | Stringified per Typesense `id` constraint          |
| `WC_Product::get_name()`        | `name`                | Was `title` pre-v0.3.0                             |
| `WC_Product::get_short_description()` (fallback long) | `description` | HTML-stripped, capped at 4 KB              |
| `WC_Product::get_stock_status()` | `in_stock` (bool)    | `instock`→true; everything else→false              |
| `WC_Product::get_permalink()`   | `url`                 | Was `permalink` pre-v0.3.0                         |
| `WC_Product::get_price()`       | `price` (float)       |                                                    |
| `WC_Product::get_sale_price()`  | `sale_price` (float)  | Pruned when null                                   |
| `WC_Product::is_on_sale()`      | `on_sale` (bool)      | Facet for "On sale" filter chips                   |
| `get_woocommerce_currency()`    | `currency`            | ISO-4217, e.g. "USD"                               |
| `wp_get_post_terms('product_cat')` | `category_id` (int[]) + `category_breadcrumbs` (string[]) | Was `categories` (string[]) pre-v0.3.0 |
| `WC Brands` / `pwb-brand` / `yith_product_brand` | `brand` | Auto-detected, first match wins                  |
| `WC_Product::get_image_id()`    | `image_url`           | Resolved to the `medium` size                      |
| _(not from Woo)_                | `last_indexed_at`     | Connector stamps `time()` on every upsert          |

`brand` is auto-detected from common providers (WC Brands, Perfect
Brands, YITH Brand) when present; otherwise pruned.

## What we deliberately don't ship to the gateway

The gateway commerce schema is intentionally narrow. The following
WC concepts are stripped on the connector side and re-resolved at
storefront-render time (typeahead + SERP) via `wc_get_product()`:

- **Variations** (`get_children()` of variable products). Hundreds
  of floats per parent; would dominate per-tenant Typesense RAM.
- **Attributes** (`get_attributes()` map). Layered-nav filter state
  is captured at SEARCH time, not INDEX time, via
  `Commerce/SearchSwap::extractFilters()` reading `?filter_*` /
  `?pa_*` from the request and posting it on the `filters` axis.
- **Regular price**. The gateway's `price` is the effective price
  the shopper will pay; `regular_price` is theme-side display only.
- **Stock quantity**. The gateway needs the binary `in_stock` flag
  for facet partitioning; exact count is theme-side display.
- **`excerpt` / `content_plain`**. Folded into `description`.

If a store wants any of these in the gateway-side doc (e.g. to power
a numeric-stock-aware boost in a custom rerank), add them to the
gateway commerce schema first (`services/mcp-gateway/ops/`
migration), then extend the doc via the `seekmodo_indexer_doc`
filter on the WP side.

## Delta hooks (commerce)

In addition to the content delta hooks, the commerce indexer listens
for:

- `woocommerce_update_product` (after `WC_Product::save()`)
- `woocommerce_delete_product`
- `woocommerce_variation_set_stock` / `woocommerce_product_set_stock`
- `transition_post_status` for `post_type=product` and
  `post_type=product_variation`
- `woocommerce_update_product_variation`

Each marks the parent product dirty; the cron tick re-renders the
product doc in batches of 500.

## Schema-version auto-promote

The connector tracks the commerce-vertical doc-shape version in its
own `wp_options` row (`seekmodo_wp_commerce_schema_version`,
default 0) separate from the content-vertical
`seekmodo_wp_schema_version`. When the constant
`Options::COMMERCE_SCHEMA_VERSION` (currently `1`) drifts from the
stored value, the very next `seekmodo_wp_commerce_index_tick`
auto-promotes to a full reindex via `Commerce/Indexer::tick()`, so
upgraded sites re-emit every product under the new shape without
operator action. Same pattern as the content vertical's
`Options::SCHEMA_VERSION`.

## Facets / filters

If your theme uses Woo's stock sidebar layered nav (price slider, color
swatches, attribute filters), the gateway can rank within that filtered
set. The plugin auto-detects active `WC_Query` filter state and posts
it as `filters` on the search request. Custom facet plugins can wire in
via the `seekmodo_search_params` filter:

```php
add_filter('seekmodo_search_params', function (array $params): array {
    if (!empty($_GET['pa_color'])) {
        $params['filters']['attributes.color'] = sanitize_text_field($_GET['pa_color']);
    }
    return $params;
});
```

## Typeahead (commerce shape)

`GET /wp-json/seekmodo/v1/typeahead?q=hood&vertical=commerce` returns
rows shaped for product cards (`title`, `permalink`, `image_url`,
`price`, `sale_price`, `on_sale`, `stock_status`, `currency`, `sku`).
The optional `[seekmodo_search vertical=commerce]` shortcode renders
a drop-in dropdown.

The typeahead's OUTPUT shape stays on the WC-friendly names
(`title`, `permalink`, `stock_status`-as-enum) for theme-contract
stability across the v0.3.0 doc-shape realignment, even though the
underlying Typesense fields are now `name`, `url`, `in_stock` (bool).
`Commerce/Typeahead::shape()` is the single mapping point.
