diff --git a/manifest.json b/manifest.json index 0da8ef6..db8f04d 100644 --- a/manifest.json +++ b/manifest.json @@ -112,7 +112,7 @@ { "slug": "themes/theme-clone", "name": "Theme Clone", - "description": "Clone any website into a pixel-perfect Fluid theme — scrape the source, build sections, push, and visually verify against the original.", + "description": "Clone any website into a pixel-perfect Fluid theme \u2014 scrape the source, build sections, push, and visually verify against the original.", "category": "themes", "icon": "copy", "path": "themes/theme-clone/SKILL.md", @@ -297,7 +297,7 @@ { "slug": "marketing/brand-social-import", "name": "Brand Social Content Import", - "description": "Import a company's OWN YouTube + TikTok videos into Fluid: find the official accounts, register them in social-media settings, and pull their videos into the DAM + Media. Not UGC — this is the brand's own content.", + "description": "Import a company's OWN YouTube + TikTok videos into Fluid: find the official accounts, register them in social-media settings, and pull their videos into the DAM + Media. Not UGC \u2014 this is the brand's own content.", "category": "marketing", "icon": "video", "path": "marketing/brand-social-import.md", @@ -306,7 +306,7 @@ { "slug": "marketing/brand-setup", "name": "Brand Setup", - "description": "Interview the company to build a living brand.md — a brand-voice document that Mist, themes, portals, and widgets read to match the company's tone and style. Prefills colors/name from brand guidelines and saves via the update_brand_voice tool. Also use to append a single brand decision later.", + "description": "Interview the company to build a living brand.md \u2014 a brand-voice document that Mist, themes, portals, and widgets read to match the company's tone and style. Prefills colors/name from brand guidelines and saves via the update_brand_voice tool. Also use to append a single brand decision later.", "category": "marketing", "icon": "palette", "path": "marketing/brand-setup.md", @@ -315,7 +315,7 @@ { "slug": "themes/suggested-changes", "name": "Suggested Changes", - "description": "Review Lighthouse performance and compliance findings for this store, then approve or dismiss each recommended fix — approvals are applied and their impact is tracked.", + "description": "Review Lighthouse performance and compliance findings for this store, then approve or dismiss each recommended fix \u2014 approvals are applied and their impact is tracked.", "category": "themes", "icon": "sparkles", "path": "themes/suggested-changes.md", @@ -333,7 +333,7 @@ { "slug": "compliance/compliance-manager", "name": "Country Compliance Manager", - "description": "Audit a storefront against a country's legal requirements — disclosure pages, price display, cookie/privacy law, agreements, product labeling, storefront language — using the Country Atlas as the rulebook. Runs standalone and as the final step of the mode finalizers.", + "description": "Audit a storefront against a country's legal requirements \u2014 disclosure pages, price display, cookie/privacy law, agreements, product labeling, storefront language \u2014 using the Country Atlas as the rulebook. Runs standalone and as the final step of the mode finalizers.", "category": "compliance", "icon": "scale", "path": "compliance/compliance-manager/SKILL.md", @@ -351,7 +351,7 @@ { "slug": "mist/smart-dashboard", "name": "Smart Dashboard", - "description": "Self-fabricate a live business dashboard from real data — gather what every department cares about, render it with show_dashboard, then learn what the user wants front-and-center and remember it.", + "description": "Self-fabricate a live business dashboard from real data \u2014 gather what every department cares about, render it with show_dashboard, then learn what the user wants front-and-center and remember it.", "category": "mist", "icon": "layout-dashboard", "path": "mist/smart-dashboard/SKILL.md", @@ -378,7 +378,7 @@ { "slug": "sales/aov-lever-audit", "name": "AOV lever audit", - "description": "Find which levers actually move average order value — item count, bundles, discount depth — with a dollar figure per lever.", + "description": "Find which levers actually move average order value \u2014 item count, bundles, discount depth \u2014 with a dollar figure per lever.", "category": "sales", "icon": "trending-up", "path": "sales/aov-lever-audit.md", @@ -441,7 +441,7 @@ { "slug": "themes/clone-system-pages", "name": "Clone System Pages", - "description": "Build the Fluid system routes a source rarely exposes — themed 404 and 503 error pages with full shell, plus the reusable generic content-page template.", + "description": "Build the Fluid system routes a source rarely exposes \u2014 themed 404 and 503 error pages with full shell, plus the reusable generic content-page template.", "category": "themes", "icon": "triangle-alert", "path": "themes/clone-system-pages/SKILL.md", @@ -473,6 +473,35 @@ "icon": "rocket", "path": "onboarding/launch-setup/SKILL.md", "updated_at": "2026-07-30T23:10:00Z" + }, + { + "slug": "themes/dynamic-bundles", + "name": "Dynamic Bundles", + "description": "Understand a company's existing bundle implementation (or a source configurator page) and recreate it as Fluid Dynamic Bundles, with one reusable theme implementation that renders every current and future bundle.", + "category": "themes", + "icon": "boxes", + "path": "themes/dynamic-bundles/SKILL.md", + "updated_at": "2026-08-07T00:00:00Z", + "references": [ + "themes/dynamic-bundles/PLAN.md", + "themes/dynamic-bundles/playbooks/01-discovery.md", + "themes/dynamic-bundles/playbooks/02-translation.md", + "themes/dynamic-bundles/playbooks/03-theme-generation.md", + "themes/dynamic-bundles/playbooks/04-routing.md", + "themes/dynamic-bundles/playbooks/05-validation.md", + "themes/dynamic-bundles/playbooks/06-greenfield.md", + "themes/dynamic-bundles/playbooks/07-troubleshooting.md", + "themes/dynamic-bundles/playbooks/08-api-write-recipe.md", + "themes/dynamic-bundles/playbooks/09-pricing-patterns.md", + "themes/dynamic-bundles/playbooks/10-ui-and-price-surfaces.md" + ], + "assets": [ + "themes/dynamic-bundles/templates/sections/bundle_builder/index.liquid", + "themes/dynamic-bundles/templates/assets/bundle-builder.js", + "themes/dynamic-bundles/templates/assets/bundle-builder.css", + "themes/dynamic-bundles/templates/product/bundle/index.liquid", + "themes/dynamic-bundles/schemas/bundle-manifest.schema.json" + ] } ], "workflows": [ @@ -505,6 +534,11 @@ "slug": "speed-import", "path": "workflows/speed-import.workflow.json", "updated_at": "2026-07-30T18:00:00Z" + }, + { + "slug": "dynamic-bundles", + "path": "workflows/dynamic-bundles.workflow.json", + "updated_at": "2026-08-07T00:00:00Z" } ] } diff --git a/themes/dynamic-bundles/PLAN.md b/themes/dynamic-bundles/PLAN.md new file mode 100644 index 0000000..32a3922 --- /dev/null +++ b/themes/dynamic-bundles/PLAN.md @@ -0,0 +1,522 @@ +# Universal Dynamic Bundles Skill — Research & Implementation Plan + +Status: **IMPLEMENTED AND FIELD-VERIFIED.** Planned 2026-08-05; built and exercised against a live company 2026-08-06. +Author: Mist. Date: 2026-08-05, revised 2026-08-06. Reference corpus: the 18 docs now shipped alongside this skill at `reference/*.md` (originally read from `fluid-dynamic-bundles copy/`). + +> **Read PART 7 before trusting PART 1.** Parts 1–6 are the pre-build research record and are +> preserved deliberately, including the questions that were open at planning time. PART 7 records +> what changed once the writes actually ran — including **two claims in PART 1 that live testing +> disproved**. Where the two disagree, PART 7 wins. The operational form of PART 7 is +> `playbooks/08-api-write-recipe.md`; this part exists to show the evidence behind it. +Primary test company: **Chipotle** (`980243433`) — confirmed live: `GET /api/v2025-06/bundles` → `[]`, active theme `57123` "Chipotle Onboarding Theme", sibling theme `57124`. Clean slate, exactly as the readiness doc predicts. + +--- + +# PART 1 — What I actually learned + +## 1.1 The model, in one page + +A bundle is **a Product** with `products.bundle = true`, a shadow `Bundle` row (1 live row per product, unique index), and N `ProductBundleGroup` rows each holding N `BundleGroupItem` rows. Three layers, and which layer owns a setting determines what it can influence: + +``` +BUNDLE (products.* + products.bundle_config + bundles.settings) + └ GROUP (product_bundle_groups.* + .pricing_config) group_type = included | customizable + └ ITEM (bundle_group_items.* + .config) one variant, in one group +``` + +Load-bearing consequences: + +- **`group_type` is a stored NOT-NULL column**, not emergent. The *bundle's* static/dynamic nature is emergent (`static?` = all groups included). +- **Static/`included` groups are reconstituted server-side** and must **not** be sent in `bundled_items` — with exactly one exception: an included group that is a member of an exclusive pair *must* be sent, because the server cannot know which branch was chosen. +- **Bundle-level flat pricing collapses the layers.** When on, group and item prices become display-only; nothing beneath can move the charge. It is *illegal* to combine with a `dynamic_price` group (model validation), so a theme never has to reconcile those two. +- Almost everything a theme cares about lives in four schemaless JSONB bags: `bundles.settings`, `products.bundle_config`, `product_bundle_groups.pricing_config`, `bundle_group_items.config`. Unknown keys round-trip verbatim and are never rejected — a typo returns 200 with the key missing. + +## 1.2 Detection — the only gate worth writing + +```liquid +{% if product and product.product_bundle_groups.size > 0 %} +``` + +Valid in every scope. `is_bundle` is API-only and nil everywhere in Liquid. `slug`/`handle` are nil in page and section scope. `product.bundle` *does* work (contrary to the old skill) but is absent from the enrollment product hashes and can be `true` with zero visible groups. `bundle_in_stock` is `true` on non-bundles — never a detector. + +## 1.3 Three "product" shapes, and the one that matters + +| Scope | Producer | Keys | Has `mutually_exclusive_groups`? | +|---|---|---|---| +| **A** page `product` (a PDP, and any `{% section %}` on it) | `Variables::Product#product_hash` | 50 | ❌ **no** | +| **B** `section.settings.` | `ProductDrop#as_json` | 40 | ✅ yes | +| **C** live `ProductDrop` (`products[i]`) | Drop object, every public method | — | ✅ yes | + +A section **inherits the page scope verbatim** — inside `sections/*.liquid` on a product template, `product` is always scope A. So on the deployment shape we want (one shared bundle template, no pinning), **exclusivity data is only reachable at `product.bundle_config.mutually_exclusive_groups`**. And that value holds **`sort_order`s, not group ids**, in two possible shapes (`[{ids:[…],default:…}]` or a bare `Array`), and is `null` — not `[]` — on older bundles. + +## 1.4 The three moving parts (and the two that are missing by default) + +| Part | Default on a new company | +|---|---| +| The `product_bundle` section | ✅ free everywhere — one global row unioned into every theme, byte-identical across 33 themes / 6 companies, renders on companies with zero bundle infrastructure | +| A host `product` template containing it | ❌ **no seed ships one** | +| Routing (product → that template) | ❌ **no auto-assignment exists** | + +Routing is not a column — it is a `template_resources` join **scoped to the currently-active theme**. So: switching or cloning a theme silently un-routes every bundle; and a bundle created via API with no explicit `application_theme_template_id` comes back `null` and renders as an ordinary product. + +**A bundle on a bundle-unaware theme is a silent revenue hole**: normal-looking product page, working native add-to-cart, hard failure at checkout (`orchestrator.rb` `validate_bundle_configuration`). Nothing surfaces to the merchant. + +## 1.5 The trilemma that decides the architecture + +Global sections **never resolve typed `{% schema %}` settings** (`section_templates_for_template` omits the global union — defect **P3**). Measured, all four combinations: + +| Setup | Data blob | exclusivity | schema settings | scales? | +|---|---|---|---|---| +| Global section, no pin | 50-key page hash | ❌ | ❌ inert | ✅ one template | +| Global section + `bundle_product` pinned | ❌ **dead shell** | — | — | ❌ | +| Local clone, no pin | 50-key page hash | ❌ | ✅ | ✅ one template | +| Local clone + pin | 40-key drop | ✅ | ✅ | ❌ **one template per bundle** | + +**No stock configuration gives both exclusivity and scale.** Pinning is also actively *destructive* on a global-only install (a raw integer is non-blank, suppresses the page-product fallback, emits no blob). And a theme-local clone **shadows the global permanently and never receives updates** (`auto_upgradeable?` is hardcoded `false`; two Chick-fil-A clones are already 15.7 KB stale). + +⇒ The scalable shape is: **theme owns the section source, never pin `bundle_product` on `product/*`, read exclusivity from `bundle_config`.** + +## 1.6 Write surfaces — one is safe, one is not + +**Surface A — `POST /api/company/v1/products/create_bundle_product` / `PATCH …/{id}/update_bundle_product`** (what the admin Bundle Builder uses). Only this surface can: +- write `products.bundle_config` — *the place the cart validator and cart pricer actually read* `mutually_exclusive_groups` +- set `products.bundle`, `track_inventory_on_bundle_items`, variants, `variant_countries`, images, SEO +- run `BundleConfigValidation` (exclusive-pair arity/uniqueness + the `hidden`-flag rules) +- do surgical nested deletes via `_destroy` + +**Surface B — `/api/v2025-06/bundles`** is a trap: no pagination, `per_page`/`page` silently ignored, **omits `product_id`** (you cannot get from bundle to product), skips `BundleConfigValidation` entirely, writes `bundles.settings` *without* mirroring to `products.bundle_config` (so exclusivity written here is honoured by the portal and **silently ignored by the cart**), returns **500 not 422** on any schema violation, `items[].is_default` 500s on update and is dropped on create, `sort_order` is overwritten by array position, `groups: []` destroys all groups, and it is entirely undocumented and untested upstream. + +⇒ **The skill writes bundles exclusively through Surface A**, reads through `GET /api/company/v1/products/{id}`, and uses Surface B only as a *read* cross-check (never as the source of truth, never for bundle→product resolution). + +Two more write rules learned the hard way: +- `is_default` must be sent **inside `config`** on both surfaces. +- **Match bundle → product by `product.product_bundle_groups[].id`, never by slug.** Duplicate/orphan Bundle rows share a title and the later row often owns the uniquified slug — slug matching resolved the *wrong* bundle on ≥3 fixtures, one of which reports empty `country_pricing` while the product actually charges $25. +- **Delete the Bundle before the Product.** Reverse order leaves an orphan whose `DELETE` then 500s forever (nine such orphans exist on CrashTest). + +## 1.7 The cart contract + +```js +window.FairShareSDK.addCartItems(PARENT_VARIANT_ID, { + quantity: 1, + bundled_items: [{ variant_id, quantity, product_bundle_group_id, subscription?, subscription_plan_id? }] +}); +``` + +- **Always send `product_bundle_group_id`.** Schema-optional, load-bearing: omitting it changes `bundle_selection_key` (`1527:56794x1|…` vs `56794x1|…`), so the same bundle added tagged once and untagged once produces **two lines and a double charge**. Mixing tagged/untagged for one variant is a hard 422. +- **Never send included/static items** — except exclusive-set members (§1.1). +- `quantity: 0` entries are **dropped before rule counting**, not rejected. +- **Duplicates are not de-duplicated** — counting is over *entries*, not distinct variants. One entry per click ⇒ silent over-charge. Collapse into `quantity` yourself. +- A cold cart is **two** HTTP calls for a bundle (create empty, then `…/items`); non-bundles are one. +- Re-adding **sets** quantity, it does not increment. In-place edits via `POST` + `cart_item_id` are silently ignored; `PATCH …/bundled_items` works but leaves a stale selection key. **Use delete-then-re-add.** +- **The promise never rejects.** `swallowErrors: true` ⇒ resolves `undefined` on 4xx/5xx/network, and `performAddCartItems` replaces the server body with a generic `"Failed to add items to cart"`. A theme `.catch()` never runs. The stock section's `.then()` therefore opens an unchanged cart drawer *after a failed add*. +- **HTTP 200 is not success.** A country-unavailable child returns 200, prices the bundle at **$0.00**, and (finding D) leaves `items[].errors` **empty** — no programmatic signal at all. `valid_for_checkout` is `false` on every bare cart, so it is not a guard. Out-of-stock is *skippable*: the line is dropped and reported in a top-level `skipped_items` array that is **not** on the resolved Cart object (only on the event detail). + +## 1.8 Pricing — the chains a theme must implement + +- **Item:** `country_prices[ISO]` → `item.price` → `variant.variant_countries[ISO].price`. `item.price` is `"0.0"` on **100%** of fixtures, and a zero here means *unset*, not free. +- **Group:** `pricing_config.country_pricing[ISO]` → `pricing_config.fixed_price`. `group.fixed_price` reads `"0.0"` when unset — indistinguishable from a real zero unless you check `pricing_config.fixed_price` for key presence. `min_price`/`max_price`/`compare_at_price` are effectively dead (`"0.0"` on every live group). +- **Bundle:** `bundle_config.bundle_pricing_config.country_pricing[]` (values are **strings**). `product.bundle_price` is empty even with bundle pricing on. `primary_price`/`primary_currency` are admin echo — **never read by Rails**. +- `product.price` in the Liquid drop **is** correct and country-resolved (`"$50.00"`, or a `"$30.00 - $70.00"` range when any group is dynamic). Top-level `price`/`display_price` from *both* JSON APIs are computed with `country_code: nil` and read `$0.00` — unusable. But **`price_range`/`bundle_price_range` are correct and country-resolved**, and are the primary write-verification signal (verified 2026-08-06 on nine bundles, including one *wrong* `$55.75–$66.75` that exposed a real pricing defect — see `playbooks/08-api-write-recipe.md` §4). +- **Mode behaviour, verified three-way (display == cart == checkout):** `dynamic_price` sums selections and scales with quantity ✅; `fixed_price` group ignores picks *and* quantities ✅; bundle-level flat ignores everything below ✅; mixed = dynamic sum + fixed group price ✅. Two exceptions, both real money: an `included` group with **no** `fixed_price` displays as its item sum and **charges $0.00** (P11/finding C); a child with no priced country row **zeroes the whole bundle** (P6/finding D). +- **CV/QV only flow from `dynamic_price` groups.** Fixed-price / flat bundles credit 0/0 unless CV/QV is re-entered on the group or bundle `country_pricing` row. A theme that sums `variant_countries[].cv` to promise "you'll earn N CV" **overstates every non-dynamic group**. +- **Compare-at**: only render when `compare_price > price`, else you advertise an increase. +- Type traps in one object: `variant_countries[].price`/`wholesale`/`compare_price` are **floats**, `subscription_price` is a **string**; `cv`/`qv` are Integers in Liquid and **strings** in both APIs; `display_*` carries a `" (USD)"` suffix; drop prices can be BigDecimal engineering notation (`"0.4999e2"`) — `parseFloat` survives it, `{{ item.price }}` in Liquid does not. + +## 1.9 Subscriptions + +Precedence: bundle-wide `subscription: true` **suppresses** per-item forced-subscription checks. Item `force_subscription` ⇒ must send `subscription: true` (422 otherwise); `allow_subscription: false` ⇒ must not (422). Group `force_subscriptions` / `allow_subscriptions` apply the same way — but note group `allow_subscriptions` is **never read server-side**, and on real fixtures the forcing lives on the *item* while the group flag is false, so **check both levels**. A missing plan id with `subscription: true` gets the company default. `force_subscription` requires the item's product to have ≥1 active plan (model validation). + +**P7 (by design, not filable): renewal price comes from the BGI variant's own `variant_country`, deliberately not the parent** — so a $35 bundle can renew at $100/mo or $0/mo depending only on which child was ticked, and **the PDP shows nothing**. The only theme-side remedy is disclosure. + +## 1.10 What the platform section actually is + +3063 lines. Lines 1–839 are inlined static CSS (there is no separate stylesheet to port). Lines 863–1148 are markup: 5 `