Product Groups (Sibling Colours)

Product groups (sibling colours) — #420

No new Shopify scope, nothing written to Shopify. Every source below is a read: GraphQL fields already covered by existing scopes, a merchant metafield, or the store’s own public storefront HTML.

Problem

Many stores sell each colour as a separate product, glued together in the storefront by a swatch app. Example: Icedout sunglasses “Leon” / “Leon Brown” / “Leon Blue” are three distinct Shopify products, one variant each, stitched into one swatch picker on icedout.bg by StarApps’ “Combined Listings – Product Groups”. Shopify’s product JSON knows nothing about that grouping — each product looks standalone to catalog_products.

Tryvio keeps the group itself, so the try-on modal can offer the sibling colours and swap the product without a page redirect (#421, see Part B).

Data model

Migration: 20260821_product_groups.sql.

product_groups

One row per (store, product) — a product is in at most one group. The group itself has no row of its own (no name/settings); it’s just the set of rows sharing group_id.

ColumnNotes
iduuid PK
store_idFK stores, on delete cascade
group_iduuid — shared by every member of the group; not a foreign key to anything
product_idFK catalog_products, on delete cascade — a deleted product leaves its group automatically
labeltext, default '' — the colour name shown; '' = unknown, UI/widget fall back to the product title
swatch_hextext, check #rrggbb (or null)
positioninteger, default 0
sourcecheck in ('auto_combined_listing', 'auto_metafield', 'auto_storefront', 'manual')
source_keytext — stable key of the auto source (cl:<parent gid>, mf:<value>, sa:<starapps group id>); null for manual
created_at / updated_attimestamptz

Constraints: unique(store_id, product_id) (one group per product), unique(group_id, position). Index: (store_id, group_id, position) — the two reads are “all groups of a store” (products API, one query) and “this group’s members”. RLS enabled, service-role only — no anon/authenticated policies.

product_group_scans

Storefront-scan state, one row per store (PK store_id, FK stores, on delete cascade).

ColumnNotes
statusidle | running | done | unsupported | failed
supportedboolean, nullable — null = not probed yet, false = the storefront carries no group data we recognise
cursor_idresume point over catalog_products ordered by id; null = start from the beginning
scanned_count / total_count / groups_foundprogress counters
last_errortext
started_at / finished_at / updated_attimestamptz

Sources

Rules across all sources: manual wins over auto and survives re-syncs (planAutoGroupRows skips products already held by a manual row); one group per product — the first derived group to claim a product keeps it; groups with fewer than 2 resolvable members are dropped; editing an auto group in the UI saves it as manual from then on.

1. auto_combined_listing — Shopify native Combined Listings

The ProductSync query (runShopifyProductSync, Catalog sync) now selects combinedListingRole on every product. For nodes where that role is PARENT, one follow-up query per parent — not nested in the page query (GraphQL cost) —

product(id: $id) {
  combinedListing {
    combinedListingChildren(first: 50) {
      nodes {
        product { id }
        parentVariant { title selectedOptions { name value } }
      }
    }
  }
}

The result is stored on the parent’s catalog_products.metadata as combinedListingRole + combinedListing.children[{ productId, label }]. Label resolution order: the colour option value (matching Colour/Color/Цвят/… by name), else the joined option values, else the variant title. The derived group = the parent (position 0, label '') + its children.

2. auto_metafield — recognised plain merchant metafields

A metafield whose value names the group. Recognised keys (GROUP_METAFIELD_KEYS in src/lib/products/product-groups.ts):

  • custom.color_group
  • custom.colour_group
  • custom.product_group
  • custom.sibling_group
  • tryvio.color_group
  • tryvio.colour_group

Labels are derived from the colour word found in the product title (extractColourWord).

⚠️

StarApps keeps its own group in an app-owned metafield we cannot read — that data does not land here via this source. See source 3 for how StarApps groups are actually picked up.

3. auto_storefront — crawling the public product page

StarApps (and similar swatch apps) render the full group into every public product page as window.vkcl_data.product_group_data:

[{ group_id, group_name, products: [
  { id, handle, published, product_display_name: { en: "Black" }, product_position }
] }]

parseStarAppsGroupsFromHtml parses it out of the raw HTML (fixture: src/lib/products/__fixtures__/starapps-leon-product-page.html, captured from icedout.bg, 2026-08-21).

Crawl: runStorefrontGroupScanSlice (src/lib/server/product-groups-storefront.ts). The merchant clicks “Detect colour groups” in /products, which repeatedly POSTs /api/shopify/product-groups/detect. Each call is a bounded slice — ≤40 page fetches / ~20s each, maxDuration 60 — and the client keeps calling until remaining === 0. A cursor walks catalog_products ordered by id; a page that turns out to already be in a group yields all its siblings in that one fetch, so they’re skipped without a separate request. The first fetch decides supported (hasStarAppsEmbed) — if the storefront carries no recognisable embed, the scan stops after that one fetch and status becomes unsupported. { reset: true } re-detects the whole store from scratch, dropping existing auto_storefront rows first. GET on the same route returns the current scan state (for the “detecting… X/Y” progress caption).

4. manual

The merchant links products directly in /products → Edit try-on → Colours (linked products).

Sync integration

At the end of a full catalog sync (final chunk of executeShopifyProductSync), deriveAndApplyAutoGroupsForStore runs and replaces only auto_combined_listing + auto_metafield rows for the store — auto_storefront rows are untouched (they’re owned by the scan, not the sync) and manual rows are untouched (they always win).

API

GET /api/shopify/products now returns, per product, an additive field:

group: {
  id: string
  source: 'auto_combined_listing' | 'auto_metafield' | 'auto_storefront' | 'manual'
  members: Array<{
    productId: string
    label: string
    swatchHex: string | null
    position: number
    title: string
    handle: string
    thumbnailUrl: string | null
    tryOnEnabled: boolean
    status: string
  }>
} | null

Built with one store-wide listProductGroupRows query, joined in memory (attachProductGroups) — not N+1. Thumbnail precedence: merchant’s chosen reference image → primary image → first image. A member with try-on off is kept in the list but flagged (tryOnEnabled: false); a group left with fewer than 2 visible members is hidden entirely.

PUT /api/shopify/product-groups — body { groupId?, members: [{ productId, label?, swatchHex? }] } (2–50 members). This is a replace-set: every id is ownership-checked (filterOwnedProductIds → 403 on a foreign id), an unknown groupId → 404, and a member already in another group is moved here (the old group is pruned if it drops below 2 members). Response: { ok, group }.

DELETE /api/shopify/product-groups?groupId= — unlinks the group.

GET /api/shopify/product-groups/suggestions — AC7 title-based suggestions: same title minus a colour word + same vendor + same type, ≥2 candidates, ≥1 recognised colour word, excludes products already grouped. Colour lexicon (EN/BG/RO) lives in extractColourWord.

GET / POST /api/shopify/product-groups/detect — the storefront-scan trigger/status route described above.

Every route: resolveAuthorizedShopForShopifyRequest, store-scoped queries, and revalidateTag(merchantProductsCacheTag) after any write.

UI

/products → Edit try-on drawer → section “Colours (linked products)” (ProductGroupEditor, src/app/products/product-group-editor.tsx):

  • Staged into a DRAFT lane (row.colour_group) that feeds the page’s one save bar — nothing writes until Save.
  • Member rows: thumbnail, colour-label input, swatch-colour input, remove.
  • Add via ProductEntityPicker (name + thumbnail, existing members excluded); >10 members shows a filter box.
  • No group yet → “Link as colours” CTA; a title-based suggestion may show as a chip, “Link these N”.
  • “Unlink group” stages the removal with an Undo affordance.
  • AC5 — adding a product that already belongs to another group opens a confirm dialog naming that group’s current members, CTA “Move it here”.
  • Fewer than 2 members after edits → inline error, blocks save.
  • A source badge distinguishes “Detected automatically” vs “Linked by you”.
  • Header button “Detect colour groups” with a live state caption (idle / detecting X/Y / done / not supported on this storefront).
  • Save = planGroupWrites → one PUT per changed group, one DELETE per unlink, then a silent reload of the product list.

Code map

FileRole
src/lib/products/product-groups.tsPure, browser-safe logic (label extraction, colour lexicon, planning)
src/lib/server/product-groups.tsServer-side reads/writes, sync integration
src/lib/server/product-groups-storefront.tsStarApps HTML crawl / scan slices
src/app/products/product-group-editor.tsxDrawer UI
src/lib/server/storefront-product-snapshot.tsSnapshot builder — adds siblings to StorefrontProductSnapshot
02_app/shopify-app/extensions/tryvio-theme/src/tryvio-modal/index.jsWidget colour row + switchSiblingProduct

Tests: product-groups.test.ts (incl. the #421 sibling-builder block), save-model-groups.test.ts, product-groups-ui.test.ts, product-groups-storefront.test.ts, api/shopify/product-groups/route.test.ts, storefront-snapshot-siblings.test.ts. Real-seam: scripts/real-seam/rs-420-product-groups.mjs.

Part B (#421) — the widget: colour row + in-modal swap

Additive, backward-compatible API changes only — old cached widgets keep working unchanged. Deploy order: API first, then the widget (manual deploy-widget.mjs).

API

StorefrontProductSnapshot (src/lib/server/storefront-product-snapshot.ts) gained siblings: StorefrontSibling[]:

type StorefrontSibling = {
  productId: string
  handle: string
  title: string
  label: string
  swatchHex: string | null
  thumbnailUrl: string | null
  productUrl: string
  current: boolean
}

buildStorefrontProductSnapshot(product, selected, imageSidesCollectionRule, siblings?) — the caller resolves the list (it’s a DB read); omitted/empty → siblings: [] and every other field stays byte-identical to the pre-#421 snapshot (AC4). Old cached widgets simply ignore the new field (AC5).

Pure builder buildStorefrontSiblings({ currentProductId, rows, candidates }) in src/lib/products/product-groups.ts: members in position order; only try-on ON (the store’s auto_enable_new_products default applied), widget_visible !== false and status === 'active' members are offered — the CURRENT product is always kept and marked current: true. label falls back to the product title when the DB label is ''. productUrl = /products/&lt;handle&gt;. Fewer than 2 offerable members (or the current product not in the group) → [].

Server read listStorefrontSiblings({ storeId, product, storeAutoEnable }) in src/lib/server/product-groups.ts: the product’s own product_groups row (group_id) → getProductGroupRows + listStorefrontProductsByIds → the builder. Thumbnail = memberThumbnailUrl (merchant’s reference pick → primary → first).

GET /api/proxy runs it inside the existing Promise.allSettled fan-out (a failed read → []). POST /api/storefront/session runs it in Promise.all beside createTryOnSession (.catch → [], logged as session.siblings_failed) and attaches it to product.siblings in the response. No new endpoints, no new scopes, nothing written to Shopify.

Widget (tryvio-modal.js, lazy stage)

Built from src/tryvio-modal/index.js; the tryvio-theme.js 10 KB app-block budget is untouched. cloneProductSnapshot (product-utils.js and the tryvio-widget.js mirror) carries siblings through.

A colour row .tryvio-modal__siblings (rail .tryvio-modal__siblings-rail, chips .tryvio-modal__sibling reusing the shared .tryvio-modal__variant-swatch thumb, or a swatchHex dot + label when there’s no thumbnail) is appended under the product line of the product card:

  • Shown on the upload/preview/result cards; hidden on the camera’s slim card.
  • While a generation runs the row is aria-disabled and a tap shows the widget.sibling_wait hint.
  • Only rendered when the ACTIVE product is one of the siblings — a saved look from a different product hides it.
  • More than 10 colours scroll horizontally; the current chip is scrolled into view (AC6).

Tap → switchSiblingProduct:

  • A busy guard makes a double-tap a single request.
  • POST /api/storefront/session for the sibling’s handle — a NEW session, since the try-on is per product.
  • On success, config.sessionId / productHandle / promptFamily / product become the sibling’s, config.tryvioProductSource = "sibling_colour", and the variant picker is REBUILT from the sibling’s own variantPicker (rebuildVariantPicker, AC8 — never the previous product’s shades relabelled under the new product).
  • The result image, related strip and discount card reset; cookies + cart attributes are repointed (syncCartAttributes); the shopper’s photo (selectedImageDataUrl) is kept.
  • From the result state the modal returns to preview (photo kept) — the result belonged to the colour it was generated in.
  • The page URL never changes. “View product” (the card title/thumbnail) deep-links to the sibling’s productUrl.
  • Analytics event tryon_sibling_switch fires with eventPayload { fromHandle, toHandle, groupSize } and the new tryonSessionId.

Failure (network / 403): everything rolls back to the previous product, an inline error (widget.sibling_switch_error, mapped through mapStorefrontError) is shown, and the row is live again — tapping the colour again IS the retry (AC7). No blank state.

The try-on route itself is unchanged — after a swap the widget simply sends the sibling’s productHandle and the new sessionId.

i18n

New keys, EN + BG, merchant-editable in the translation catalog (group default): widget.colour (“Colour” / “Цвят”), widget.sibling_wait, widget.sibling_switch_error.

Proof

02_app/shopify-app/playwright-debug/proof-421-sibling-colours.js — 16 checks covering AC1–AC8 plus the generating-lock and double-tap edges → 05_tasks/proof/421/ (desktop + 390px screenshots, screen recording video/ac2-sibling-swap.webm). Vitest: src/lib/products/product-groups.test.ts (#421 block), src/lib/server/storefront-snapshot-siblings.test.ts, plus the session and proxy route tests.