← back to Dw Contact Us Pages

README.md

226 lines

# dw-contact-us-pages — TK-11925

Turn every **ACTIVE** product of three to-the-trade vendors into a **contact-us page**:
no price, no add-to-cart, no sample button — a contact block directly under the product
image instead.

| Vendor | ACTIVE products |
|---|---|
| Designers Guild | 568 |
| Ralph Lauren | 140 |
| Christian Lacroix Europe | 77 |
| **total** | **785** |

Store: `designer-laboratory-sandbox.myshopify.com` — **this IS the live production
Designer Wallcoverings store** despite the handle. Live theme: `145556635699`
("DW Sample-Shipping DEV", role `main`). Admin API `2024-10`.

Quadrille is explicitly **not** in scope.

## Hard rails baked into every script

* **Dry-run is the default.** No script writes anything without an explicit `--apply`.
* `push-theme.mjs` **refuses** a theme whose role is `main` (and refuses theme id
  `145556635699` by id) unless `--allow-main` is *also* passed.
* Every write script captures a **preimage first** and has a `--rollback`.
* Every applied run appends to `~/.claude/yolo-queue/executed-reversible/ledger.jsonl`
  with a concrete undo command.
* The **$4.25 Sample variant is never touched** — 785 of them, all titled `Sample`.
  The swatch stays orderable, which is the whole point of keeping the PDP live.

## How the "no price / no cart / no sample" actually works

`snippets/product-form-content.liquid` renders the buy box from
`{% for block in section.blocks %}`: the `price` block prints the price, the `form`
block prints the variant selector, the sample buttons and add-to-cart. So a product
template **without those two blocks renders none of it**. That is the primary
mechanism — CSS is only a backstop.

1. **`theme/templates/product.contact-us.json`** — a copy of the live
   `templates/product.json` with the `price` and `form` blocks removed, the
   `description` block enabled, and the tag-gated `contact_for_price` section dropped.
   `recommendations` and the `div.product-section` wrapper are kept.
   *(Written as strict JSON with no `/* */` comment header — Shopify documents comment
   support in JSON templates but it was not verifiable from here, so the zero-risk
   form was chosen and the rationale lives in this README instead.)*
2. **`theme/snippets/dw-contact-us-block.liquid`** — the contact block. Self-gates on
   `template.suffix == 'contact-us'`. Heading, vendor/pattern/base-SKU line, the
   "available through our showroom" lede, a `tel:` and a prefilled `mailto:`, and an
   inline inquiry form that POSTs the **same JSON body to the same live endpoint**
   as `sections/contact-for-price.liquid`
   (`https://api.designerwallcoverings.com/api/sku-inquiry`; fields `productId
   productTitle sku name email phone company role quantity unit projectName city
   timeline budget notes`), with inline success/error states. Self-contained CSS under
   `.dw-cu*`, Lora headings / `#3D4246` text to match the house look. No external deps.
   It also carries the belt-and-braces CSS under `body.template-suffix-contact-us`
   hiding `.product__price .product__form .product-price .cfp-section .dw-quote-section
   .dw-sample-banner .add-to-cart .dl-sample-btn .dl-second-sample-btn .product-options
   .mm_quantity .shopify-payment-button .product-details .money` — the class inventory
   measured out of `product-form-content.liquid`.
3. **`theme/snippets/product.liquid`** — the live file, one additive block: right after
   `{% render 'product-gallery' %}` it renders the contact block, so it sits directly
   **below the image column** and above `.product-details-wrapper`.

   *Layout note (measured, not guessed).* `theme.css` builds the PDP as a **float**
   two-column at `>=770px`: `.product-images{float:left;width:50%}` /
   `.product-details-wrapper{float:right;width:50%}`; the rendered gallery root is
   `<div class="product-images product-gallery">`. A `float:left; clear:left` block
   inserted between them does **not** work — CSS 2.1 forbids a later float
   (`.product-details-wrapper`, `float:right`) from sitting higher than an earlier one,
   so clearing under the image would drag the entire details column down with it.
   Instead, **on `body.template-suffix-contact-us` only**, `.product` becomes a 2-column
   grid: image top-left, contact block directly beneath it, details column spanning both
   rows on the right. Below 770px the theme is single-column and the block falls into
   normal flow right under the image. Nothing outside the contact-us template is touched.
4. **`theme/assets/dw-contact-us-cards.js`** + **`theme/snippets/hide-browse-hidden.liquid`**
   — Boost SD renders browse/search grids client-side. The asset sweeps
   `.boost-sd__product-item`, identifies the cohort by the card's
   `[class*="product-vendor"]` text, and swaps `.boost-sd__product-price` /
   `.boost-sd__format-currency` for **"Contact us for pricing"**. Vendor list comes from
   `window.DW_CONTACT_US_VENDORS`, emitted by liquid from
   `shop.metafields.custom.contact_us_vendors` → `settings.contact_us_vendors` → the
   literal three names (mirrors the `showroom_vendors` pattern). An optional
   `window.DW_CONTACT_US_IDS` id-set backstop is honoured if ever emitted.
5. **`theme/snippets/product-list-item.liquid`** — the Liquid-rendered card path. Where
   `item.vendor == 'Newmor Wallcoverings'` prints "Showroom inquiry", a branch keyed on
   `item.template_suffix == 'contact-us'` now prints "Contact us for pricing".

## Order of operations

```sh
cd ~/Projects/dw-contact-us-pages

node scripts/enumerate.mjs                                   # READ ONLY -> data/targets.json

# 1. theme — push to a DUPLICATE theme first and eyeball a PDP
node scripts/push-theme.mjs --theme <dup-id>
node scripts/push-theme.mjs --theme <dup-id> --apply
# live main theme (Steve-gated):
node scripts/push-theme.mjs --theme 145556635699 --apply --allow-main

# 2. template assignment  (785 products, templateSuffix null/'' -> contact-us)
node scripts/assign-template.mjs
node scripts/assign-template.mjs --apply

# 3. hide + harden        (737 non-sample variants; 785 Sample variants untouched)
node scripts/harden-variants.mjs
node scripts/harden-variants.mjs --apply

# 4. channels             (2,355 unpublish ops over 785 products)
node scripts/unpublish-channels.mjs
node scripts/unpublish-channels.mjs --apply

# 5. verify
node scripts/verify.mjs
```

Steps 2–4 are order-independent of each other, but the **theme must be pushed before the
template is assigned** — otherwise `templateSuffix=contact-us` points at a template that
does not exist yet and the PDP 404s the template.

## Rollback

```sh
node scripts/rollback-theme.mjs --theme <id> --apply          # restores byte-for-byte; deletes assets that were new
node scripts/assign-template.mjs  --rollback --apply          # restores the old templateSuffix
node scripts/harden-variants.mjs  --rollback --apply          # policy -> quantities -> tracked:false LAST
node scripts/unpublish-channels.mjs --rollback --apply        # re-publishes only what was published before
```

Preimages: `data/theme-preimage/<themeId>/**` (+ `manifest.json`),
`data/ledger-template.jsonl`, `data/ledger-variants.jsonl`, `data/ledger-channels.jsonl`.
Rollback always reads the **first** ledger record per id, so a re-run of an apply cannot
overwrite the true preimage.

## Inventory hardening — why the order matters

Measured on the live cohort (785 products / 1,522 variants):

* 737 non-sample variants, **all** `inventoryPolicy = CONTINUE`.
* **598 of them have `inventoryItem.tracked = false`** (DG 521 + CL 77). An untracked
  variant is *always* available no matter the policy, and `inventorySetQuantities`
  **fails** on an untracked item. So the forward order is
  **`inventoryItemUpdate tracked:true` → `inventorySetQuantities on_hand 0` → `policy DENY`**,
  and the rollback order is the reverse with `tracked:false` restored **last**.
* 139 tracked variants (Ralph Lauren) have `qty > 0`; their per-location on-hand is in
  the ledger and is restored exactly.
* Exactly **one** location: `gid://shopify/Location/5795643504` ("15442 Ventura Blvd.").
* **48 products** (DG 47, RL 1) are Sample-only with no sellable variant — hardening has
  nothing to do for them; template assignment and unpublishing still apply. `verify.mjs`
  reports those as **NOT-MEASURED (0 of 0)**, never green.

## Channels

Unpublished from **Google & YouTube** (`29646651457`), **Shop** (`44317507635`) and
**Buy Button** (`22497296496`) only — 785 × 3 = **2,355** operations, all three currently
published for all 785. **Online Store stays published**: the PDP *is* the contact-us page.
Facebook & Instagram, Pinterest, TikTok, Houzz, Fabricut, POS, Inbox, Rakuten and
DWAutoPostBlog are deliberately untouched.

## verify.mjs — what it measures, and what it refuses to call green

`node scripts/verify.mjs [--n 5] [--control <handle>]` samples N products per vendor and
reports **PASS / FAIL / NOT-MEASURED** per check (admin `templateSuffix`, variant
hardening, sample-variant presence, the 3 channels, Online Store still on, live PDP HTML,
public `.json` availability). `0 of 0` is **NOT-MEASURED**, never PASS — which is how the
48 sample-only products are reported. Verdict is `FAIL` if anything failed, `WARN` if
anything was unmeasured, `PASS` only when everything was measured and good. Result is
written to `data/verify-latest.json`.

**Scope matters, and it was measured.** On a PDP that already has *no* buy box (a Newmor
showroom product) the literal strings `Add to cart` (x4), `dl-sample-btn` (x4) and
`Complimentary Sample` (x1) still appear in the document — locale JSON, quick-shop and
recommendation templates. A whole-document match therefore reports FAIL on a page that is
already correct. Scoped to the product-details region those same tokens score **0**, while
a buy-box page scores non-zero. Both polarities were run before shipping:

```
control bark-commercial-wallcovering-bark-vbk-102: scope=59430B buybox_markup=false   # clean page
control dinetah-stripe-indigo:                     scope=65313B buybox_markup=true    # buy-box page
```

A truncated scope extraction returns `null` → NOT-MEASURED, so a broken extractor cannot
score 0 and fake a PASS. Run `verify.mjs` **before** applying too: it must come back FAIL.
It does (46 failures on the untouched cohort) — that is the negative test on the detector.

`push-theme.mjs --apply` against the live main theme without `--allow-main` exits **2**
before any PUT; that negative test was run and is the guard's proof.

## Residual risks

1. **`/products/<handle>.json` and the storefront product JSON still expose variant price
   strings** even when `available:false`. Shopify offers no way to suppress that for a
   published product. Price is hidden from the *page*, not from the public JSON endpoint.
   Same for `oembed`/structured-data consumers that read that JSON.
2. **The Boost grid treatment depends on client-side JS.** `verify.mjs` cannot assert it
   from raw HTML and says so explicitly rather than staying silent.
    No-JS crawlers and the moment
   before the sweep runs will still show the Boost price. The Liquid card path
   (`product-list-item.liquid`) has no such gap, but Boost renders most grids.
3. **The `body.template-suffix-contact-us` hook** comes from `layout/theme.liquid`
   (~line 798). If that body class is ever removed, the belt-and-braces CSS silently
   stops applying — the template block removal still holds, which is why the CSS is the
   backstop and not the mechanism.
4. **Boost vendor text is the card key.** If the Boost card layout is configured to hide
   the vendor line, `[class*="product-vendor"]` disappears and the sweep misses; the
   `window.DW_CONTACT_US_IDS` backstop exists for that case but is not emitted today.
5. **Theme editor drift.** Someone re-adding a `price`/`form` block to
   `product.contact-us.json` in the theme editor re-introduces the buy box; only the CSS
   backstop catches it.
6. **`templateSuffix` restore is `''`, not `null`.** 645 products read `null` and 140 read
   `''` today; Shopify's write API has no `null`, so rollback sets `''` for both. The two
   are behaviourally identical (no suffix).
7. **Sample-only products (48)** show a contact block *and* an orderable $4.25 swatch —
   intended, but worth an eyeball on one of them before the full run.

## Cost

All operations are Shopify Admin API calls against an existing plan — **$0 incremental
spend**. Local scripting: `$0 (local)`.

## Review-driven additions (2026-09-19)
- `harden-variants.mjs --limit 1 --apply` = one-variant canary before the full run (recommended; inventorySetQuantities right after tracked:true could not be dry-run-proven).
- `layout/theme.liquid` is now in the pushed set: the JSON-LD Offer carve-out (Newmor precedent) extended to `product.template_suffix == contact-us`.
- All gates key on `product.template_suffix` (not `template.suffix`) so the quick-shop modal path is covered; `.dw-contact-us-product` wrapper class scopes the belt-and-braces CSS in any context.
- Write scripts: null mutation payload = failure; per-call try/catch (a run never aborts mid-way); ledger written BEFORE the first write; `--apply` refuses a `data/targets.json` older than 2h (`--stale-ok` overrides).