# Quote Module Collection

HubSpot platform 2026.03 · CRMdemo portal (20648280)

For the platform-level reference on how CPQ quote modules work (Islands, hublDataTemplate, PDF behaviour, sandbox limitations):
- `C:\Projects\docs\platform\quote-modules.md`
- `C:\Projects\docs\reference\quote-template-context.md`

---

## Deployment

```bash
cd "C:\Projects\Themes\Quotes\CPQ-quotes\Modules\Quote Module Collection"

# If serverless functions were changed — rebuild before upload
cd src/app/functions && npm run build && cd ../../..

hs project upload
```

---

## Module overview

| Module | Editor label | Island | Nav section | Purpose |
|---|---|---|---|---|
| `QuoteHeader` | Quote Header | — | — | Formal document header — logo (position + URL override), quote reference, issue/expiry dates, prepared for/by |
| `QuoteCover` | Quote Cover | ✓ countdown | `qs-cover` | Branded hero — buyer company name, quote title, sender card, status flag (top-right ribbon), expiry countdown |
| `QuoteNav` | Quote Navigation | ✓ sticky nav | — | Sticky section nav; auto-discovers `qs-*` anchors from other modules |
| `QuoteIntro` | Quote Introduction | — | `qs-intro` | Executive summary — editable message, optional video embed, sender bio card |
| `QuoteLineItems` | Quote Line Items | ✓ editor buttons, tier modal | `qs-products` (optional) | Custom line items table — SKU-based include/exclusion filtering, recurring billing badges, tiered-pricing support (model badge + editor-only tier modal), subtotal row; replaces QuoteProductsAnchor. In the HubSpot editor it shows native "Add line item" / "Edit all line items" buttons (see [Editor line-item buttons](#editor-line-item-buttons)) |
| `QuoteProductsAnchor` | Quote Products Anchor (Nav) | — | `qs-products` | Zero-height anchor — only needed when using the standard HubSpot line items module instead of QuoteLineItems |
| `QuotePricingSummary` | Quote Pricing Summary | — | `qs-pricing` | Subtotal / discount / tax / total from context; payment terms and methods |
| `QuoteTimeline` | Quote Timeline | — | `qs-timeline` | "What happens next" — configurable post-signing milestones |
| `QuoteTerms` | Quote Terms | ✓ accordion | `qs-terms` | T&C sections — expanded in PDF, collapsible with expand/collapse all on web |
| `QuoteSignature` | Quote Signature | — | `qs-signature` | Signer status cards, pulsing CTA button, PDF signature lines, accepted banner |
| `FancyExample` | Fancy Example | ✓ carousel + API tests | — | Developer reference — demonstrates every field type, CSS capability, Island pattern |
| `DebugQuoteContext` | Debug: Quote Template Context | — | — | Dumps `brand_settings`, `site_settings` and `quoteTemplateContext` as JSON; probes brand colour and logo candidates with visual previews — add to a live quote to inspect, remove when done |

### Recommended template order (v2 buyer journey)

Module labels carry a numeric prefix (`01`–`13`) so the editor's module picker lists them in exactly this order — assemble top to bottom. The journey: identity → welcome → offer → price → future → people → trust → doubts → deep-dive material → legal → sign.

```
01 Quote Header          ← formal document header; not in nav
02 Quote Cover
03 Quote Navigation      ← sticky; discovers sections below it
04 Quote Introduction
05 Quote Line Items      ← or: 05 Quote Products Anchor + HubSpot's native line items module
06 Quote Pricing Summary
07 Quote Mutual Plan     ← replaces the legacy Quote Timeline
08 Quote Team
09 Quote Proof           ← renders nothing until configured
10 Quote FAQ
11 Quote Resources
12 Quote Terms
13 Quote Signature
```

Unconfigured/omitted modules simply drop out — the nav only shows tabs for sections that exist. The trust cluster (07–11) deliberately sits after the price: that's where buyer doubt peaks and where stakeholders who weren't in the room start reading. Unnumbered modules (Debug, Fancy Example, the legacy Timeline) are utilities, not part of the journey.

**Multiple QuoteLineItems tables (SKU-filtered sections):**
```
QuoteLineItems  heading="Software Licences"     includeSkuPrefixes="sw-,lic-"   registerAnchor=true
QuoteLineItems  heading="Professional Services"  includeSkuPrefixes="svc-,ps-"   registerAnchor=false
QuoteLineItems  heading="Hardware"               excludeSkuPrefixes="sw-,lic-,svc-,ps-"  registerAnchor=false
QuotePricingSummary   ← always shows the grand total across all line items
```

---

## Navigation system

`QuoteNav` works by querying the DOM for elements with `id` attributes matching `qs-{section}`. Each participating module renders one such element at its top level. `QuoteNav` auto-discovers whichever anchors are present — modules that aren't on the template simply don't appear as tabs.

`QuoteHeader` does not participate in the nav system — it has no `qs-*` anchor and is not in `KNOWN_SECTIONS`.

| Section id | Registered by |
|---|---|
| `qs-cover` | `QuoteCover` |
| `qs-intro` | `QuoteIntro` |
| `qs-products` | `QuoteProductsAnchor` |
| `qs-pricing` | `QuotePricingSummary` |
| `qs-timeline` | `QuoteTimeline` |
| `qs-terms` | `QuoteTerms` |
| `qs-signature` | `QuoteSignature` |

Tab labels are editable per-module in `QuoteNav`'s Content fields.

---

## Brand auto-detection

All modules that accept a primary colour read it from `brand_settings.primaryColor.hex` via `hublData.brandPrimary`. Each module's Primary colour field is left blank by default so the brand kit value is used automatically. Setting the field overrides it per-module.

| Property | HubL variable | Notes |
|---|---|---|
| Primary colour | `brand_settings.primaryColor.hex` | Used as `brandPrimary` in all modules |
| Secondary colour | `brand_settings.secondaryColor.hex` | Available in DebugQuoteContext only |
| Accent colour 1 | `brand_settings.accentColor1.hex` | Available in DebugQuoteContext only |
| Primary logo | `brand_settings.primaryLogo.src` | Used in QuoteHeader logo resolution |

**`site_settings.*` is not available in the quote template context** — `site_settings.primary_color` and `site_settings.logo_url` always return empty. Use `brand_settings` instead.

### QuoteHeader logo resolution order

1. ImageField override (`fieldValues.content.logoUrl.src`) — set in the module editor
2. `quoteTemplateContext.quote.hs_logo_url` — quote-level explicit logo
3. `brand_settings.primaryLogo.src` — brand kit primary logo
4. `quoteTemplateContext.quote.hs_sender_company_image_url` — CRM sender company logo
5. Falls back to sender company name as text if all are empty

---

## Editor line-item buttons

HubSpot's built-in line-items section shows "Add line item" / "Edit all line items" hover buttons in the quote editor. Custom modules don't get those natively — this collection recreates them, and **every custom line-item module should include them**. `QuoteLineItems` is the reference implementation.

### How it works (background — skippable)

Inside the quote editor and the template editor, HubSpot renders the template preview in an iframe and loads its own editor runtime (`InpageEditorUI`) into that same iframe. That runtime:

- sets `window.hsInEditor = true` (never set on the published quote or during PDF rendering), and
- exposes its message bus to the parent editor app at `window.hs.app.eventBus`.

Our island code runs in the same iframe, so it can use that bus directly. The two buttons dispatch the exact Redux actions HubSpot's own buttons dispatch:

| Button | Action dispatched | What opens |
|---|---|---|
| Add line item | `{ type: 'OPEN_MANAGE_ADD_LINE_ITEM_DRAWER' }` | Product library drawer |
| Edit all line items | `{ type: 'quoteModal/setLineItemModalOpen' }` | Line-item editor grid |

Everything is fail-silent: outside the editor the bus doesn't exist and the island renders nothing at all — so the buttons can never leak into the published quote or the PDF.

> ⚠️ This rides an **internal, undocumented HubSpot protocol** (verified against `quote-inpage-editor-ui` static-1.15413 on 2026-07-26). HubSpot can change it without notice. If the buttons ever stop working, the module still renders fine — turn them off with the "Editor: line item buttons" field and check whether `window.hs.app.eventBus` still exists in the preview-iframe console (in DevTools, switch the console's context dropdown from `top` to the preview iframe first).

### Adding the buttons to your own line-item module

Five steps. Steps 1–3 are verbatim copy-paste; steps 4–5 contain class names and paths you adapt to your module.

Two different elements of your module play a role — don't conflate them:

| Role | Which element | What it needs |
|---|---|---|
| **Anchor** | the card/container the buttons visually sit on (in `QuoteLineItems`: `.qli-card`) | `position: relative` + the `<Island>` inside it |
| **Reveal** | your module's outermost element (in `QuoteLineItems`: `.qli`) | named in the `:hover`/`:focus-within` CSS rule |

Hovering anywhere on the module (reveal element) fades in the buttons, which stay pinned to the card's top-right (anchor element). The two are usually different elements; if your module has only one wrapper, use it for both roles.

**1. Copy the island file — unchanged.**

Copy `QuoteLineItems/islands/EditorButtonsIsland.tsx` into your module:

```
components/modules/YourModule/islands/EditorButtonsIsland.tsx
```

It is fully self-contained (no imports besides React) and module-agnostic.

**2. Import it in your module's `index.tsx`:**

```tsx
import { Island } from '@hubspot/cms-components';
// @ts-ignore
import EditorButtonsIsland from './islands/EditorButtonsIsland.tsx?island';
```

**3. Add the two fields** inside your `<FieldGroup name="content">`. (`BooleanField` must be in your imports from `@hubspot/cms-components/fields`. If your group is named something other than `content`, also change `content` in step 4's read paths to match.)

```tsx
<BooleanField
  name="editorButtons"
  label="Editor: line item buttons"
  default={true}
  helpText="Show 'Add line item' / 'Edit all line items' buttons while editing the quote in HubSpot. Never shown on the published quote or in the PDF."
/>
<BooleanField
  name="editorButtonsAlwaysVisible"
  label="Editor: always show buttons"
  default={false}
  helpText="Show the editor buttons permanently instead of on hover."
/>
```

**4. Render the island in your `Component`.** Both snippets below go into the `Component` function, which must receive `hublData` as a prop — its signature is `export function Component({ fieldValues, hublData }: any)`.

At the top of `Component`, read the toggles:

```tsx
const editorButtons       = String(fieldValues?.content?.editorButtons) !== 'false';
const editorButtonsAlways = String(fieldValues?.content?.editorButtonsAlwaysVisible) === 'true';
const isPdf = hublData?.isPdf || false;
```

In the returned JSX, place the `<Island>` as the first child of your **anchor** element (replace `your-card` with your real class — step 5 gives it `position: relative`):

```tsx
<div className="your-card">
  {editorButtons && !isPdf && (
    <Island
      module={EditorButtonsIsland}
      alwaysVisible={editorButtonsAlways}
      hydrateOn="load"
    />
  )}
  {/* ...rest of your table... */}
</div>
```

`isPdf` comes from your module's `hublDataTemplate` export. If yours already sets a `hublData` object, add this key to it; if your module has no `hublDataTemplate` yet, add the whole export:

```tsx
export const hublDataTemplate = `
  {% set hublData = {
    "isPdf": true if quoteTemplateContext.quote.hs_pdf_generation_status == "PDF_GENERATING" else false
  } %}
`;
```

**5. Copy the CSS block** into your module's CSS (in this collection that is the `const css` template string at the top of `index.tsx`, injected via a `<style>` tag) and adapt the **two marked rules** — everything else copies as-is:

```css
/* ADAPT: your anchor element (the card the buttons sit on) must be positioned —
   add this to its existing rule if it has one */
.your-card { position: relative; }

/* Editor-only line item buttons — generic block, copy as-is */
.qeb {
  position: absolute; top: 14px; right: 16px;
  display: flex; gap: 8px; z-index: 20;
  opacity: 0; pointer-events: none;
  transition: opacity 0.15s ease;
}
.qeb-btn {
  display: inline-flex; align-items: center; gap: 6px;
  border-radius: 3px; padding: 6px 12px; cursor: pointer;
  font-family: inherit; font-size: 12px; font-weight: 600; line-height: 1;
}
.qeb-btn svg { width: 12px; height: 12px; fill: currentColor; }
.qeb-btn--primary   { background: #ff5c35; border: 1px solid #ff5c35; color: #fff; }
.qeb-btn--primary:hover { background: #e5482a; border-color: #e5482a; }
.qeb-btn--secondary { background: #fff; border: 1px solid #cbd6e2; color: #33475b; }
.qeb-btn--secondary:hover { background: #f5f8fa; }

/* ADAPT: reveal rule — replace .your-root with your module's outermost class
   (NOT the card class, unless your module has only one wrapper) */
.your-root:hover .qeb,
.your-root:focus-within .qeb,
.qeb--always { opacity: 1; pointer-events: auto; }
```

For the `QuoteLineItems` reference: `.your-card` = `.qli-card`, `.your-root` = `.qli`.

### Verify it works

1. `hs project upload`, then open a quote in the **quote editor** using a template with your module.
2. Hover the module → both buttons fade in top-right.
3. Click **Add line item** → HubSpot's product-library drawer opens; add a product → your table refreshes with it.
4. Click **Edit all line items** → the line-item grid opens.
5. Open the **published quote** and the **PDF** → no buttons, no empty gap.

### Troubleshooting

| Symptom | Cause / fix |
|---|---|
| Buttons never appear in the editor | "Editor: line item buttons" field switched off · anchor element lacks `position: relative` · module not re-uploaded — check the build number in the project UI |
| Buttons appear but in the wrong place | The island must sit inside the element that has `position: relative`; the nearest positioned ancestor is what they anchor to |
| Buttons only appear when hovering the wrong area | The reveal selector still says `.your-root` (or the wrong class) — it must match your module's root class |
| Clicks do nothing | HubSpot changed the internal protocol. In DevTools, switch the console context dropdown from `top` to the preview iframe, then check `window.hs?.app?.eventBus` — if `undefined`, the integration needs re-reverse-engineering; switch the field off meanwhile |
| Drawer errors for a specific user | That user lacks the `inbounddb-product-library-read` scope. HubSpot's native button greys out for them; ours cannot know the scope and opens a failing drawer instead |
| Buttons show on the live quote | Should be impossible (`window.hsInEditor` is editor-only) — if it happens, verify nothing else on the page defines `hsInEditor` |

---

## Tiered pricing support

HubSpot line items can carry tiered pricing (`hs_pricing_model` = `volume`, `graduated`, or `stairstep`). `QuoteLineItems` supports it out of the box; this section documents how it works and how to add the same support to **any other custom line-item module**.

### What HubSpot stores (and computes) — the facts

Three properties on the line item (`hs_tier_ranges`/`hs_tier_prices` are hidden from the properties-metadata API but present on the object **and in `quoteTemplateContext.lineItems`** — all live-verified):

| Property | Example | Meaning |
|---|---|---|
| `hs_pricing_model` | `"graduated"` | `flat` (default) or `volume` / `graduated` / `stairstep` |
| `hs_tier_ranges` | `"[{\"start\":0,\"end\":99},{\"start\":100,\"end\":199},{\"start\":200}]"` | JSON **string**. Last tier is open-ended (no `end`) |
| `hs_tier_prices` | `"[{\"index\":0,\"price\":100},{\"index\":1,\"price\":90},{\"index\":2,\"price\":80}]"` | JSON **string**. Multi-currency portals repeat entries with a `currency` key |

HubSpot computes the line's `amount` per model (verified with qty 250 against the tiers above):

| Model | Meaning | Example amount |
|---|---|---|
| `volume` | full quantity × the price of the tier the quantity falls in; `hs_effective_unit_price` holds that tier price | 250 × 80 = 20 000 |
| `graduated` | each tier's price applies to the units inside that tier (tier "0–99" holds units 1–99) | 99×100 + 100×90 + 51×80 = 22 980 |
| `stairstep` | the reached tier's price is a **flat fee**, not per-unit | 80 |

### The golden rule — we never calculate money

**Render `item.amount`, never `price × quantity`** — that multiplication is wrong for every tiered model (and `price` is empty on tiered items anyway). The only arithmetic this module does is presentational: summing HubSpot's `amount` values into the subtotal row, and deriving tier labels + which tier(s) the quantity falls in for the editor modal. All money values come from HubSpot, recomputed by HubSpot whenever the quote is (re)published.

### What the buyer sees vs the editor

- **Published quote / PDF:** calculated prices only. Total = `amount`; Unit price = the applied tier price (`volume`), "Varies" (`graduated`), or "Flat fee" (`stairstep`). No tier schedule, no badge.
- **Editor only:** a small tier-list icon in the Unit price cell opens a modal with the full schedule, highlighting the active tier(s). Gated on `window.hsInEditor` — the same mechanism as the [editor line-item buttons](#editor-line-item-buttons), but purely visual (no bus, no internal protocol risk).

### Adding tiered-pricing display to your own line-item module

Five steps; steps 1–2 are verbatim copies.

**1. Copy the island file — unchanged.**

Copy `QuoteLineItems/islands/TierModalIsland.tsx` into your module's `islands/` folder. It is self-contained (React only) and renders nothing outside the editor.

**2. Copy the helper block — unchanged.**

From `QuoteLineItems/index.tsx`, copy the block between `// ─── Tiered pricing ───` and the end of `parseTierInfo` (the `MODEL_LABELS` map, the `TierRow`/`TierInfo` interfaces, and the `parseTierInfo` function). It needs no imports. Paste it at module scope in your `index.tsx`, above `Component`, alongside your other helpers.

**3. Add the kill-switch field** inside your `<FieldGroup name="content">` (same convention as the editor buttons — it gates the icon in step 4):

```tsx
<BooleanField
  name="showTiers"
  label="Editor: tier schedule icon"
  default={true}
  helpText="For line items with volume, graduated, or stair-step pricing: show a small icon (editor only) that opens the tier schedule in a modal. Buyers always see only the calculated price on the published quote and PDF."
/>
```

And read it at the top of `Component`:

```tsx
const showTiers = String(fieldValues?.content?.showTiers) !== 'false';
```

**4. Wire it into your row rendering.** Import the island alongside your other imports:

```tsx
import { Island } from '@hubspot/cms-components';
// @ts-ignore
import TierModalIsland from './islands/TierModalIsland.tsx?island';
```

Inside your `items.map(...)` row loop, derive the tier info (`currency` and `locale` are the quote's currency and locale your `hublDataTemplate` already passes — `quoteTemplateContext.quote.hs_currency` / `hs_locale`):

```tsx
const tierInfo = parseTierInfo(item, currency);
```

Then apply the two render rules. Note: your `fmt()` currency helper must accept `null` and render a dash (like the collection's `fmtCurrency`) — a number-only formatter produces "NaN" for tiered edge cases:

```tsx
{/* Unit price cell — never price × quantity */}
<span>
  {tierInfo
    ? tierInfo.model === 'volume'
      ? fmt(item.hs_effective_unit_price != null ? Number(item.hs_effective_unit_price) : null)
      : tierInfo.model === 'stairstep' ? 'Flat fee' : 'Varies'
    : fmt(item.price)}
</span>
{showTiers && tierInfo && tierInfo.tiers.length > 0 && (
  <Island
    module={TierModalIsland}
    itemName={item.name}
    model={tierInfo.model}
    modelLabel={MODEL_LABELS[tierInfo.model] || tierInfo.model}
    tiers={tierInfo.tiers}
    currency={currency}
    locale={locale}
    hydrateOn="load"
  />
)}
```

Your Total column and subtotal need **no change** as long as they already use `item.amount`. Unlike the editor-buttons Island there is no `!isPdf` gate here — the island renders nothing outside the editor by itself, and its empty inline wrapper is harmless in the PDF.

**5. Keep the icon inline.** Islands render as a wrapper `<div data-hs-island id="island-…">`, which breaks the price cell onto a new line unless you add (replace `.your-price-cell` with the class on your unit-price `<td>`):

```css
.your-price-cell [data-hs-island], .your-price-cell div[id^="island-"] { display: inline; }
```

### Verify it works

1. Get a tiered line item onto a quote. Easiest: add a product with tiered pricing from the product library while editing the quote. Via API instead (`$TOKEN` = a private-app token with line-item scopes): create the line item (verified payload — note tier properties are JSON *strings*), then associate it to a **DRAFT** quote (a published quote is locked and refuses new lines):
   ```bash
   curl -X POST "https://api.hubapi.com/crm/v3/objects/line_items" \
     -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
     -d '{"properties": {"name": "Tier test", "quantity": "250",
       "hs_pricing_model": "volume",
       "hs_tier_ranges": "[{\"start\":0,\"end\":99},{\"start\":100,\"end\":199},{\"start\":200}]",
       "hs_tier_prices": "[{\"index\":0,\"price\":100},{\"index\":1,\"price\":90},{\"index\":2,\"price\":80}]"}}'

   # then attach it to the quote (quote→line_item association, typeId 67):
   curl -X PUT "https://api.hubapi.com/crm/v4/objects/quotes/<quoteId>/associations/line_items/<lineItemId>" \
     -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
     -d '[{"associationCategory": "HUBSPOT_DEFINED", "associationTypeId": 67}]'
   ```
   Do **not** set `price` on a tiered line item — HubSpot derives pricing from the tiers.
2. In the **editor**: the row shows the tier icon in the Unit price cell; clicking it opens the modal with the correct tier(s) highlighted.
3. On the **published quote**: no icon, no schedule — just Unit price and Total, with Total matching HubSpot's own total for the quote.
4. In the **PDF**: same as the published quote.

### Troubleshooting

| Symptom | Cause / fix |
|---|---|
| No icon in the editor | Line item's `hs_pricing_model` is `flat`/empty · "Editor: tier schedule icon" field is off · tier ranges empty (icon only renders when there are tiers to show) |
| Icon pushed onto its own line | The inline-wrapper CSS rule from step 4 is missing or names the wrong cell class |
| Unit price shows "—" on a volume item | `hs_effective_unit_price` missing on the line item — republish the quote so HubSpot recomputes |
| Totals look wrong | Something is computing `price × quantity` — render `item.amount`, always |
| Tier prices show "—" in the modal | Multi-currency portal where no price entry matches the quote currency — every currency must cover every range on the product/line item |

---

## Accordions — collapsed on the web, always expanded in print

`QuoteTerms` and `QuoteFAQ` use accordions. Any future quote module with collapsible content MUST follow this three-layer contract, or content silently disappears from the PDF.

**Which reference to copy:** start from `QuoteTerms/islands/QuoteTermsIsland.tsx` — it is the minimal, clean accordion island. `QuoteFAQ` implements the identical contract but adds Document/Card presentation machinery (`getPresentation`, inlined `var(--hsQuotes--…)` literals, hover states) that is orthogonal to the accordion itself — use it as the reference for *that* layer, not to learn accordions. For fields scaffolding (per-section show/question/RichText groups) copy QuoteFAQ's `qaGroup` helper. One constraint that lives only in code comments: **islands must never import `shared/quoteTheme`** — pass values as props or inline `var(--hsQuotes--…, fallback)` literals.

### The contract

**Layer 1 — PDF renders static, expanded, island-free.** `hublDataTemplate` provides `isPdf` (the standard expression — see [PDF behaviour](#pdf-behaviour) below). When `isPdf` is true the `Component` renders every visible section as plain expanded HTML and does NOT render the `<Island>` at all. The PDF never depends on hydration. The split looks like this — and the direction matters; do **not** invert it into "always render the island, add static rows for PDF":

```tsx
{isPdf && items.map((s, i) => (
  <div key={i} className="myacc-static-row">
    <h3>{s.heading}</h3>
    <div dangerouslySetInnerHTML={{ __html: s.body }} />
  </div>
))}
{!isPdf && (
  <Island module={MyAccordionIsland} items={items} primary={primary} hydrateOn="load" />
)}
```

Why the web path is still PDF-first-safe: **Islands server-render their markup before hydrating**, so layer 2's hidden bodies are already present in the server HTML — no content ever depends on client JS to exist, only to become visible.

**Layer 2 — island bodies are always in the DOM.** On the web, all sections start **collapsed** (`useState({})`). Bodies are hidden with `display: none`, **never** conditionally rendered:

```tsx
{/* WRONG — invisible to print CSS, layer 3 cannot save you: */}
{isOpen && <div>{body}</div>}

{/* RIGHT — always in the DOM, CSS-hidden when closed: */}
<div className="qacc-body" style={{ display: isOpen ? 'block' : 'none', /* … */ }} />
```

**Layer 3 — the `@media print` safety net.** The platform documents that the PDF flag can be briefly wrong, and buyers can always Ctrl+P the web page. The island renders this style block at its root, using the collection's standard class names:

```tsx
const printCss = `
  @media print {
    .qacc-body { display: block !important; }
    .qacc-controls, .qacc-chevron { display: none !important; }
  }
`;
// in the island's returned JSX, first child:
<style dangerouslySetInnerHTML={{ __html: printCss }} />
```

Class conventions: `qacc-body` on every collapsible body, `qacc-chevron` on open/close indicators, `qacc-controls` on web-only chrome like an Expand/Collapse-all button. Copy the full block as-is even if your module has no controls yet — `QuoteFAQIsland`'s printCss omits `.qacc-controls` only because FAQ renders none; the README block above is the canonical superset.

### Rules

- Never use `<details>`/`<summary>` — they render collapsed in the PDF and layer 3 cannot open them reliably.
- Never gate section *content* behind `useEffect`/state — SSR must already contain it (layer 2 is about visibility, not existence).
- All-collapsed is the collection's default opening state — the buyer opens what they care about.

### Verify

1. Web quote: everything collapsed on load; toggles work.
2. Downloaded PDF: every section expanded, no chevrons/controls.
3. Ctrl+P on the web quote: print preview shows every section expanded.

| Symptom | Cause |
|---|---|
| Section content missing from the PDF | Body conditionally rendered (layer 2 violated) — the single most likely mistake |
| Chevrons/buttons visible in the PDF or print | Missing `qacc-chevron`/`qacc-controls` class, or printCss block absent |
| Content flashes open then closes on web load | Open-state initialised non-empty, or hydration mismatch between SSR and island initial state |

---

## PDF behaviour

All modules follow the PDF-first principle: the static `Component` output is always fully readable. Islands layer interactivity on top for web only.

| Module | PDF | Web (Island enhancement) |
|---|---|---|
| `QuoteHeader` | Full static render | No difference (no island) |
| `QuoteCover` | Static render (no countdown) | Live countdown timer |
| `QuoteNav` | Hidden entirely (`isPdf` returns `null`) | Sticky scroll nav |
| `QuoteIntro` | Full static render | No difference (no island) |
| `QuotePricingSummary` | Full static render | No difference (no island) |
| `QuoteTimeline` | Full static render | No difference (CSS only) |
| `QuoteTerms` | All sections expanded (static HTML; `@media print` safety net force-expands even if the PDF flag misfires) | Collapsible accordion, all sections collapsed on load, expand/collapse all button |
| `QuoteSignature` | Signature lines rendered under each pending signer | Pulsing CTA button with `hs_quote_link` |

`isPdf` is derived in every module's `hublDataTemplate` as:
```hubl
"isPdf": true if quoteTemplateContext.quote.hs_pdf_generation_status == "PDF_GENERATING" else false
```

---

## Module reference

### QuoteHeader

**Fields**

| Group | Field | Type | Default |
|---|---|---|---|
| Content | Logo image | Image | *(blank — auto-resolves via brand kit and CRM sender logo)* |
| Content | Logo position | Choice | left |
| Content | Document type label | Text | Quote |
| Content | Show quote reference | Boolean | true |
| Content | Reference label | Text | Reference |
| Content | Show issue date | Boolean | true |
| Content | Issue date label | Text | Date |
| Content | Show expiry date | Boolean | true |
| Content | Expiry date label | Text | Valid until |
| Content | Show 'Prepared for' | Boolean | true |
| Content | Show 'Prepared by' | Boolean | true |
| Style | Accent colour | Color | *(blank — auto-detected from brand kit)* |
| Style | Background colour | Color | `#ffffff` |
| Style | Show bottom border | Boolean | true |

**hublData props**

`quoteNumber`, `issueDate` (`hs_createdate`), `expirationDate`, `locale`, `buyerCompany`, `buyerFirstName`, `buyerLastName` (first contact), `senderFirstName`, `senderLastName`, `senderEmail`, `senderCompany`, `senderLogoUrl`, `brandPrimary`

**Notes**
- Logo resolution order: ImageField override → `hs_logo_url` → `brand_settings.primaryLogo.src` → `hs_sender_company_image_url` → sender company name as text
- Logo position: left / center / right (CSS flexbox justify)
- Document type label: change to "Proposal", "Offer", "Service Agreement" etc. to match your sales language
- Issue date sourced from `hs_createdate` — the date the quote record was created in HubSpot
- No Island — entirely static; works identically in PDF and on web

---

### QuoteCover

**Fields**

| Group | Field | Type | Default |
|---|---|---|---|
| Content | Tagline (above buyer name) | Text | *(blank — auto-shows "Prepared for")* |
| Content | Show quote status chip | Boolean | true |
| Content | Show sender card | Boolean | true |
| Content | Show expiry countdown | Boolean | true |
| Style | Primary colour | Color | *(blank — auto-detected from brand kit)* |
| Style | Accent colour | Color | `#8b5cf6` |
| Style | Border radius | Number | 16 |

**hublData props**

`quoteTitle`, `quoteStatus`, `expirationDate`, `expired`, `locale`, `isPdf`, `buyerCompany`, `senderFirstName`, `senderLastName`, `senderEmail`, `senderImageUrl`, `senderCompany`, `brandPrimary`

**Notes**
- Logo and document reference fields (quote number, issue/expiry dates) are handled by `QuoteHeader` — keep QuoteCover focused on the visual hero
- Status flag: absolutely positioned ribbon anchored to the right edge of the hero (`clip-path` polygon, no pseudo-elements). Colour-codes: DRAFT (grey), PUBLISHED / APPROVAL_NOT_NEEDED (green), PENDING_SIGNATURE / PENDING_APPROVAL (amber), REJECTED (red)
- Sender avatar falls back to an initial circle if `senderImageUrl` is null
- Island countdown clears its interval on expiry — no unnecessary re-renders
- The countdown island is web-only; use QuoteHeader's expiry date field for the PDF-visible static date

---

### QuoteNav

**Fields**

| Group | Field | Type | Default |
|---|---|---|---|
| Content | Compact / pill style | Boolean | false |
| Content | Cover tab label | Text | Cover |
| Content | Introduction tab label | Text | Introduction |
| Content | Products tab label | Text | Products |
| Content | Pricing tab label | Text | Pricing |
| Content | Timeline tab label | Text | Timeline |
| Content | Terms tab label | Text | Terms |
| Content | Signature tab label | Text | Sign |
| Style | Active tab colour | Color | *(blank — auto-detected from brand kit)* |
| Style | Bar background | Color | `#ffffff` |

**hublData props**

`brandPrimary`, `isPdf`

**Notes**
- Renders `null` when `isPdf` — no empty space in the PDF
- Uses `IntersectionObserver` (rootMargin `-20% 0px -70% 0px`) to track the active section as the buyer scrolls
- Sticky detection via a zero-height sentinel div placed immediately before the nav bar
- Two styles: underline tabs (default) or pill buttons (compact mode)

---

### QuoteIntro

**Fields**

| Group | Field | Type | Default |
|---|---|---|---|
| Content | Message | RichText | Default welcome copy |
| Content | Video URL | Text | *(blank)* |
| Content | Show sender bio card | Boolean | true |
| Content | Sender job title / role | Text | *(blank)* |
| Style | Primary colour | Color | *(blank — auto-detected from brand kit)* |
| Style | Accent colour | Color | `#8b5cf6` |
| Style | Border radius | Number | 16 |

**hublData props**

`buyerFirstName`, `senderFirstName`, `senderLastName`, `senderEmail`, `senderImageUrl`, `senderCompany`, `brandPrimary`

**Notes**
- Greeting: `Hello, {buyerFirstName}` — the buyer's name is rendered in a `<span>` styled with the primary colour; gracefully omitted if no contact is associated
- Video URL: supports YouTube, Vimeo, and Loom share URLs — converted to embed URLs at render time
- Sender avatar falls back to an initial circle

---

### QuoteLineItems

**Fields**

| Group | Field | Type | Default |
|---|---|---|---|
| Content | Section heading | Text | "Products & Services" |
| Content | Section subheading | Text | *(blank)* |
| Content | Show SKU | Boolean | true |
| Content | Show description | Boolean | true |
| Content | Show unit price column | Boolean | true |
| Content | Show discount column | Boolean | true |
| Content | Show subtotal row | Boolean | true |
| Content | Editor: tier schedule icon | Boolean | true |
| Content | Register as Products nav anchor | Boolean | true |
| Content | Editor: line item buttons | Boolean | true |
| Content | Editor: always show buttons | Boolean | false |
| Content | Include only — SKU starts with | Text | *(blank)* |
| Content | Exclude — SKU starts with | Text | *(blank)* |
| Style | Accent colour | Color | *(blank — auto-detected from brand kit)* |
| Style | Border radius | Number | 16 |

**hublData props**

`lineItems` (sorted by `hs_position_on_quote`), `currency`, `locale`, `isPdf`, `brandPrimary`

**Notes**
- When `registerAnchor` is true, renders `id="qs-products"` — this replaces `QuoteProductsAnchor`. Set to false on all but the first table when using multiple instances
- Discount column only renders when at least one item actually has a discount — the toggle is a maximum, not a guarantee
- Recurring billing frequency badge appears on the unit price cell when `recurringbillingfrequency` is present on a line item
- Descriptions: uses `hs_rich_text_description` if it contains HTML tags, otherwise converts plain-text newlines to `<br>`
- **SKU filter — include:** items must have a SKU matching one of the prefixes; items with no SKU are excluded from include-filtered tables
- **SKU filter — exclude:** items whose SKU matches any prefix are hidden; items with no SKU always pass through
- Both filters can be active simultaneously — include is applied first, then exclude
- **Editor buttons:** in the HubSpot quote/template editor, hovering the table reveals "Add line item" and "Edit all line items" buttons (top-right) that open HubSpot's native product-library drawer and line-item grid. They never appear on the published quote or in the PDF. Implementation and reuse recipe: [Editor line-item buttons](#editor-line-item-buttons)
- **Tiered pricing** (`hs_pricing_model` = `volume` / `graduated` / `stairstep`): buyers see only the calculated prices. The Total column always shows HubSpot's computed `amount` (correct for every model — never recomputed); the Unit price cell shows the applied tier price for `volume` (`hs_effective_unit_price`), "Varies" for `graduated`, "Flat fee" for `stairstep`. In the **editor only**, a small icon in the Unit price cell opens a modal with the full tier schedule (`islands/TierModalIsland.tsx`, gated on `window.hsInEditor` like the editor buttons), highlighting the tier(s) the quantity falls in. The icon can be disabled with the "Editor: tier schedule icon" field. Tier data comes from `hs_tier_ranges` / `hs_tier_prices` on the line item — both confirmed to be exposed in `quoteTemplateContext.lineItems`; platform details in the hub's `platform/quote-modules.md` § Tiered pricing

**SKU filtering examples**

```
# Table A — services and consulting only
includeSkuPrefixes: service-,consult-

# Table B — everything except services and consulting
excludeSkuPrefixes: service-,consult-

# Table C — all items (no filters set)
includeSkuPrefixes: (blank)
excludeSkuPrefixes: (blank)
```

---

### QuoteProductsAnchor

No fields. No visible output. Renders a single zero-height `<div id="qs-products">`.

Place this module immediately above the HubSpot line items module in the template editor.

---

### QuotePricingSummary

**Fields**

| Group | Field | Type | Default |
|---|---|---|---|
| Content | Show price breakdown | Boolean | true |
| Content | Show payment terms | Boolean | true |
| Content | Show payment methods | Boolean | true |
| Content | Show signature reminder | Boolean | true |
| Style | Primary colour | Color | *(blank — auto-detected from brand kit)* |
| Style | Accent colour | Color | `#8b5cf6` |
| Style | Border radius | Number | 16 |

**hublData props**

`currency`, `locale`, `grandTotal` (`hs_quote_amount`), `taxTotal`, `totalDiscount`, `netTerms` (`hs_net_payment_terms`), `collectionProcess`, `allowedMethods` (`hs_allowed_payment_methods`), `paymentEnabled`, `billingEnabled`, `esignRequired`, `esignCompleted`, `brandPrimary`

**Notes**
- Subtotal row only shown if it differs from `grandTotal` (i.e. there is tax or discount)
- Discount row only shown if `totalDiscount > 0`
- Tax row only shown if `taxTotal > 0`
- All currency formatting uses `Intl.NumberFormat` with `style: 'currency'` — consistent symbol across all rows and the total block
- Signature reminder strip only shown if eSign is required and not yet complete
- Payment methods: CREDIT_OR_DEBIT_CARD, SEPA, ACH, CHECK, WIRE are all labelled

---

### QuoteTimeline

**Fields**

| Group | Field | Type | Notes |
|---|---|---|---|
| Content | Section heading | Text | "What happens next" |
| Content | Section subheading | Text | "Here's what to expect…" |
| Content › Step 1–5 | Show this step | Boolean | Steps 1–4 on by default, Step 5 off |
| Content › Step 1–5 | Icon (emoji) | Text | Emoji character |
| Content › Step 1–5 | Step title | Text | |
| Content › Step 1–5 | Description | Text | |
| Content › Step 1–5 | Timing | Text | e.g. "Day 1", "Week 2" |
| Style | Primary colour | Color | *(blank — auto-detected from brand kit)* |
| Style | Accent colour | Color | `#8b5cf6` |
| Style | Border radius | Number | 16 |

**hublData props**

`brandPrimary`

**Notes**
- No island — pure CSS (`fadeSlide` animation, gradient vertical track line)
- Default steps: Sign → Kickoff call → Project starts → First milestone → Project complete

---

### QuoteTerms

**Fields**

| Group | Field | Type | Notes |
|---|---|---|---|
| Content | Module heading | Text | "Terms & Conditions" |
| Content | Module subheading | Text | |
| Content › Section 1–6 | Include this section | Boolean | All on by default |
| Content › Section 1–6 | Section heading (`heading`) | Text | |
| Content › Section 1–6 | Content | RichText | |
| Style | Accent colour | Color | *(blank — auto-detected from brand kit)* |
| Style | Border radius | Number | 16 |

Default sections: Payment Terms · Scope of Work · Confidentiality · Support & SLA · Intellectual Property · Limitation of Liability

**hublData props**

`brandPrimary`, `isPdf`

**Notes**
- PDF: all sections rendered as expanded static HTML
- Web (Island): collapsible accordion; all sections collapsed on load; expand/collapse all button. Accordion bodies are always in the DOM (CSS-hidden when closed) with an `@media print` force-expand — so printing the web page, or a PDF render where the `isPdf` flag misfires, still shows every section (same pattern in QuoteFAQ)
- Section body is `dangerouslySetInnerHTML` of a RichTextField — content is editor-supplied and sanitized by HubSpot

---

### QuoteSignature

**Fields**

| Group | Field | Type | Default |
|---|---|---|---|
| Content | Show sign CTA block | Boolean | true |
| Content | CTA heading | Text | "Ready to move forward?" |
| Content | CTA body text | Text | Default copy |
| Content | CTA button text | Text | "Sign this quote" |
| Content | Show signature lines in PDF | Boolean | true |
| Style | Primary colour | Color | *(blank — auto-detected from brand kit)* |
| Style | Accent colour | Color | `#8b5cf6` |
| Style | Border radius | Number | 16 |

**hublData props**

`signers` (full array from `quoteTemplateContext.signers`), `counterSigners`, `quoteStatus`, `acceptanceMethod`, `esignRequired`, `esignCompleted`, `quoteLink` (`hs_quote_link`), `isPdf`, `brandPrimary`

**Notes**
- CTA block: only shown when `acceptanceMethod === 'esignature'`, not already fully signed, and not PDF. The button links to `hs_quote_link` — this is null on DRAFT quotes (not yet published), so the button renders disabled on drafts
- Accepted banner: shown when `esignCompleted >= esignRequired`
- Signer status (signed / pending) is inferred from `esignCompleted` count in order — HubSpot does not expose per-signer status in the context
- Counter-signers rendered in a separate card below signers
- PDF signature lines only rendered for pending (unsigned) signers when `showPdfLines` is enabled

---

## Project structure

```
src/
├── app/
│   ├── app-hsmeta.json
│   └── functions/
│       ├── quote-serverless-test.js             # Source (excluded from upload via .hsignore)
│       ├── quote-serverless-test-hsmeta.json
│       ├── quote-serverless-testBundled.js      # esbuild output — what gets uploaded
│       ├── build.mjs
│       └── package.json
└── cms-assets/
    ├── cms-asset-hsmeta.json
    └── my-react-assets/
        └── components/modules/
            ├── QuoteHeader/
            │   └── index.tsx
            ├── QuoteCover/
            │   ├── index.tsx
            │   └── islands/QuoteCoverIsland.tsx
            ├── QuoteNav/
            │   ├── index.tsx
            │   └── islands/QuoteNavIsland.tsx
            ├── QuoteIntro/
            │   └── index.tsx
            ├── QuoteLineItems/
            │   ├── index.tsx
            │   └── islands/
            │       ├── EditorButtonsIsland.tsx
            │       └── TierModalIsland.tsx
            ├── QuoteProductsAnchor/
            │   └── index.tsx
            ├── QuotePricingSummary/
            │   └── index.tsx
            ├── QuoteTimeline/
            │   └── index.tsx
            ├── QuoteTerms/
            │   ├── index.tsx
            │   └── islands/QuoteTermsIsland.tsx
            ├── QuoteSignature/
            │   └── index.tsx
            ├── FancyExample/
            │   ├── index.tsx
            │   └── islands/FancyExampleIsland.tsx
            └── DebugQuoteContext/
                └── index.tsx
```
