Skip to main content

Templates

Templates are versioned Handlebars HTML files (templates/<doc_type>/<version>/template.html) that render a validated dataset into the document a guest or agent sees. One template per document type renders every variant of that type: the reference proposal template renders escorted touring, FIT, cruising, and hybrid trade documents — and all four create-action variants — from one file. Variation is expressed through data and display flags, not template forks.

The canonical section id registry

Every top-level section in a template carries a registered id. These ids are a contract: the QA harness asserts the presence or absence of each section per dataset and per action, the blueprint's section toggles map onto them, and PDF page-break rules target their CSS classes. Templates must use these exact ids:

Document groupSection ids
Guest documents (proposal)hero, personal-message, highlights, journey-overview, itinerary, services, flights, accommodations, stay-units, arrival-logistics, inclusions, pricing, price-lines, payment-cta, hold-banner, agent-banner, travel-protection, terms, specialist, trust-pillars, footer, action-dock
Finance documents (invoice family)invoice-header, bill-to, line-items, totals, payment-schedule, agent-card, commission-summary, distribution-guard
Booking confirmationconfirmation-header, what-happens-next, trip-summary, financial-summary, how-to-pay
Final travel documentstrip-summary, travelers, service-confirmations, important-info, emergency-contacts

Regulatory certificates (supporting_document_example) sit outside this registry — their layouts are prescribed per scheme.

The _display flag convention

Document actions control section visibility by injecting boolean flags into the dataset's _display object at generation time (see the blueprint mapping). Templates honour two guard patterns, depending on the section's default:

  • Default-hidden sections (hold-banner, agent-banner, price-lines) render only when a flag is explicitly true:
{{#if _display.showHoldBanner}}
...
{{/if}}
  • Default-shown content sections (pricing, inclusions, terms, and so on) render unless a flag is explicitly false. Because an absent flag must mean "shown", the guard tests for explicit false rather than truthiness:
{{#unless (eq _display.showTerms false)}}
...
{{/unless}}

The flags are visible in the rendered output. These are sections of the same proposal template rendered under different actions:

Hold banner strip reading: Option held — held for you until 30 June 2026
hold-banner — hidden by default, switched on by the option-hold action
Agent banner identifying the trade partner on an agent proposal
agent-banner — hidden by default, switched on by the trade action
Standard pricing panel with package price and payment call to action
pricing on a standard proposal — package price with payment call to action
Line-level price breakdown table shown on trade proposals
price-lines on an agent proposal — the line-level breakdown trade partners see

Here is a real default-hidden section from the reference proposal template — note the registered id, the _display guard, and the inner data guard:

{{!-- HOLD BANNER (default hidden; shown by create_option) --}}
{{#if _display.showHoldBanner}}
{{#if pricing.holdUntil}}
<section class="hold-banner" id="hold-banner">
<div class="container">
<div class="hold-banner__inner">
<div class="hold-banner__main">
<div class="hold-banner__eyebrow">Option held</div>
<div class="hold-banner__label">Held for you until
<strong class="hold-banner__date">{{> localeDate value=pricing.holdUntil}}</strong></div>
</div>
<span class="hold-banner__note">Your places and this price are reserved while you decide.</span>
</div>
</div>
</section>
{{/if}}
{{/if}}

Itinerary mode: which sections render

The proposal template selects its itinerary presentation from meta.itineraryMode together with data presence:

  • #itinerary renders when itinerary.days[] is non-empty and the mode is not services — covering days, both, and the absent-mode fallback.
  • #services renders whenever services[] is non-empty. Cruise documents carry the cruise service entry alongside port-call days, so both sections can appear together.
  • #journey-overview renders only when the mode is services and serviceDates[] is non-empty.

This is why the mode is explicit in the data rather than inferred: a hybrid document that happens to carry both arrays renders predictably, and the QA harness can assert exactly which sections appear for each dataset.

Empty-section collapse

Independently of display flags, a section with no backing data never renders — header included. The hold banner above collapses without pricing.holdUntil even when its flag is true; the pricing section renders only when the pricing object carries a renderable headline figure; an inclusions section without included or excluded items produces no output at all. This is why schema optionals are nullable by design: a sparse dataset yields a shorter document, never an empty band with a heading over nothing.

The two-layer CSS token system

Templates are styled exclusively through CSS custom properties, organised in two layers in assets/css/variables.css:

  • Layer 1 — brand primitives. The raw brand values (palette, shadows). This is the only layer a rebrand replaces.
  • Layer 2 — semantic API. The names templates and main.css actually consume. These names are identical across tenants, so templates stay brand-agnostic.
:root {
/* Layer 1 · brand primitives */
--flux-primary-600: #034955;
--flux-yellow-400: #FFBC42;
--flux-background: #F9FAF8;
--flux-black: #212121;

/* Layer 2 · semantic API — consumed by templates; do NOT rename */
--color-primary: var(--flux-primary-600);
--color-accent: var(--flux-yellow-400);
--color-canvas: var(--flux-background);
--color-ink: var(--flux-black); /* inverted bands: trust pillars, footer */
--color-text: var(--flux-black);
}

Templates never contain raw hex values; they reference Layer 2 tokens only (var(--color-accent)). When you rebrand, you replace Layer 1 values and the font definitions — every template picks up the new brand with zero template edits. See the rebranding guide.

Handlebars helpers: what templates may use

Templates may only use helpers that are available in all of the platform's render registries — the document renderer, the PDF renderer, and the QA harness all execute your template, and a helper missing from any one of them breaks that path. The QA suite includes a helper parity gate for exactly this reason.

The two patterns that matter most:

  • formatCurrency — the one sanctioned way to render money. Takes the raw numeric value and the ISO currency code:
{{formatCurrency pricing.grandTotal pricing.currency}}
  • The localeDate inline-partial pattern — locale-aware dates are implemented as inline partials declared once at the top of the template, taking the date through an explicit value hash argument and resolving the locale from @root so they work at any block depth:
{{#*inline "localeDate"}}{{#if (eq @root.meta.locale "en-US")}}{{date value "MMMM D, YYYY"}}{{else}}{{date value "D MMMM YYYY"}}{{/if}}{{/inline}}

{{!-- usage --}}
due {{> localeDate value=pricing.deposit.dueDate}}

One constraint catches almost everyone once: there are no and / or / not helpers. A condition like "A and B" is expressed by nesting blocks:

{{#if _display.showHoldBanner}}{{#if pricing.holdUntil}}
...
{{/if}}{{/if}}

Comparison helpers such as eq are available in subexpressions ({{#if (eq tour.departureType "Fixed")}}), which combined with nesting covers the practical cases.

Quality gates on rendered output

Every template change runs through the QA harness, which renders each dataset (and each action's display-flag set) at desktop and mobile viewports, asserts the expected and absent section ids, and scans the output for forbidden strings — undefined, [object Object], NaN, unrendered {{ braces, and invalid dates. A binding typo therefore fails CI rather than reaching a guest. Keep this in mind when adding bindings: every field a template references should exist in the schema, and every guard should tolerate null.

Locale-driven rendering

meta.locale selects more than date formats. Templates branch on it for spelling and phrasing conventions as well — for example, the reference pricing panel renders "per person, double occupancy" for en-US and "per person, twin share" otherwise. Currency formatting via formatCurrency is likewise locale-aware. The dataset never carries pre-formatted display strings for money (the one exception is tour.dates, a display date range, which the schema explicitly documents as such).