Developer docs  /  Themes

theme.js behaviours

File: Alyta.Web/wwwroot/themes/origin/assets/theme.js (~945 lines, no dependencies, no buildstep). It is inlined into the page by the layout — <script>{{ theme_js | raw }}</script> in bothlayouts/main.fluid and layouts/embed.fluid — so every behaviour below is available on everypage, and each one no-ops when its root element is absent. All behaviours attach onDOMContentLoaded; interactive triggers use event delegation on document against .js-*classes (deliberately — no inline onclick, which also avoids XSS via attribute injection).

All fetch calls use an AbortController with a 15-second timeout.

Quote modal

Wired by the global click delegator + DOMContentLoaded setup; markup lives inpartials/quote-modal.fluid, which both layouts include ({% include 'quote-modal' %} —the embed layout ships its own copy because it omits the main layout's chrome).

Trigger — any element with class js-quote-btn and these data attributes:

<button type="button" class="space-card__cta-secondary js-quote-btn"
data-space-id="{{item.Id}}" data-name="{{item.Name}}"
data-category="{{item.CategoryName}}" data-price="{{item.Price}}"
data-duration="{{item.DurationType}}">Get a free quote</button>

(data-price is conditionally omitted when Settings.HidePricing is on — the modal then skipsthe price line.)

Required modal elements (ids are hard-coded in theme.js):

  • #quoteModalPurpose: Overlay; shown with display:flex, click on the backdrop closes
  • #quoteModalTitlePurpose: Heading (aria-labelledby target)
  • #quoteUnitInfoPurpose: Populated via DOM methods (never innerHTML) with name, category tag (.quote-modal__tag), price (.quote-modal__price, $X.XX/mo-style using its own cycle-abbrev map)
  • #quoteFormPurpose: The form; submit handler attached on DOMContentLoaded
  • #quoteSpaceIdPurpose: Hidden input carrying the space id
  • #quoteName, #quoteEmail, #quotePhone, #quoteContactPrefPurpose: Field values posted to the API
  • #quoteSubmitBtnPurpose: Disabled + relabelled "Submitting..." during the POST
  • #quoteError, #quoteSuccessPurpose: Toggled via style.display
  • .js-close-quotePurpose: Any close button (header ×, success-panel Close)

Network: POST /api/public/spaces/{spaceId}/get-quote with JSON body{ name, email, phone, contactPreference, recaptchaToken }. Ifwindow.__RECAPTCHA_SITE_KEY__ is set (emitted by quote-modal.fluid whenSettings.RecaptchaSiteKey exists) and reCAPTCHA Enterprise is loaded, a token is fetched withaction getquote first; otherwise the form submits with an empty token. 200/201 swaps theform for #quoteSuccess; anything else shows #quoteError and re-enables the button. Opening themodal sets body { overflow: hidden }; closing restores it and reset()s the form.

Pricing calculator (space detail page)

initPricingCalculator() — attaches only when #pricingCard exists.

Required root (from templates/space-detail.fluid):

<div class="space-calc" id="pricingCard"
data-space-id="{{Space.Id}}"
data-space-uid="{{Space.UId}}"
data-duration-type="{{Space.DurationType}}">

  • data-space-id — numeric id used in the two API URLs.
  • data-space-uid — the UId used in the Book Online checkout URL (UIds are globally unique and URL-safe; internal ids are not exposed in navigation URLs).
  • data-duration-typeDay/Week/Fortnight/Month/Quarterly/HalfYearly/Annual; defaults to Month. Drives all the label wording (CYCLE_ADVERB map → "Monthly cost", "Half-yearly cost", …; Annual displays as "Year"/"Years").

Required children:

  • #quantityInputPurpose: Quantity <input type="number">; clamped to 1–100 on change
  • .js-qty-btn with data-delta="-1" / data-delta="1"Purpose: Stepper buttons (calculator binds directly; a global fallback delegator also routes clicks if a changeQuantity function exists)
  • #priceBreakdownPurpose: Emptied and rebuilt on every fetch (omit it under Settings.HidePricing — the calculator tolerates its absence)
  • #bookOnlineBtnPurpose: Optional <a>; href rewritten on every state change

Network — both fetched in parallel on load and on every quantity change:

GET /api/public/spaces/{id}/promotions?quantity={n} → array of promotion pricings
GET /api/public/spaces/{id}/price?quantity={n} → base pricing object

Promo auto-select rules: promotions are sorted by (priority ?? 0) ascending — null/0priority sorts first, matching the Angular checkout calculator (the code comment warns that theold || 99 fallback pushed the top promotion to the back and auto-selected the wrong one). Thefirst promotion after sorting is auto-selected (selectedPromotionUId = promotions[0].promotionUId);if there are none, the base price object is used.

Payment mode: quantity > 1advance-pay (upfront multi-cycle), otherwisepay-as-you-go. The breakdown renders from these pricing fields (same names on both the promotionand base-price responses): selectedQuantityBasePriceExcludingTax, basePricePerItemExcludingTax,quantityOfAdditionalItems, priceForAdditionalItemsExcludingTax,selectedQuantityAppliedPriceExcludingTax, calculatedTotalPriceExcludingTax,calculatedTotalTax, depositAmount, processingFee, calculatedTotalPrice. A discount lineonly renders when base − applied > 0.001. The final line is labelled Upfront (advance-pay),First {Duration} (deposit present) or Total per {duration} and totalscalculatedTotalPrice + deposit + processingFee. Rows are built as.order-line / .order-line__label / .order-line__note / .order-line__amount(--negative for discounts, modifiers --strong and --total), with .order-divider betweengroups — style those classes in your theme CSS.

Book Online URL:

/checkouts/cn/booking?spaceUId={uid}[&months={n>1}][&promotionUId={uid}]

Listing filters, sort and pagination (spaces / embed-spaces pages)

Three cooperating pieces: initSpaceFilters() (builds the attribute checkboxes),applyFilters() (match + count + pills), and renderSpacePage() (owns card visibility sofiltering, sorting and paging compose without fighting).

The card contract — every card in the listing must be a .space-card inside #spaceListwith these data attributes (see snippets/space-card.fluid):

<article class="space-card"
data-location="{{item.LocationName}}"
data-category="{{item.CategoryName}}"
data-price="{{item.Price}}"
data-duration="{{item.DurationType}}"
data-width="{{item.Width}}"
data-length="{{item.Length}}"
data-attributes="{% for attr in item.Attributes %}{{ attr }}|{% endfor %}">

  • data-attributes uses | as the separator (attribute names can contain commas). A trailing | is fine — the JS trims and drops empties.
  • data-match is written by applyFilters() ('1'/'0'); don't set it yourself.

Monthly-price normalisation. Price filtering and sorting never compare raw prices — a$90/week space must not sort below a $200/month one. monthlyPrice(card) dividesdata-price by the cycle length from:

const CYCLE_MONTHS = {
day: 1 / 30, daily: 1 / 30,
week: 12 / 52, weekly: 12 / 52,
fortnight: 12 / 26, fortnightly: 12 / 26,
month: 1, monthly: 1,
quarterly: 3, halfyearly: 6,
annual: 12, year: 12, yearly: 12
};

Filter controls (see snippets/space-listing.fluid for the canonical markup):

  • input[data-filter="price"].js-filter-cb with value="min-max"Behaviour: Client-side price bands. The origin bands: 0-100, 100-200, 200-500, 500-99999 (labelled per month; matched against monthlyPrice, inclusive lower / exclusive upper). A change delegator on .js-filter-cb triggers applyFilters()
  • #attributeFilterGroup + #attributeFiltersBehaviour: Feature checkboxes are built by JS from the union of every card's data-attributes; the group stays display:none until at least one attribute exists. Attribute filters are AND-combined (every), other types OR within the type
  • input[data-filter="location"] / [data-filter="category"]Behaviour: Supported by getActiveFilters() if your theme renders such checkboxes (origin uses URL-driven selects instead)
  • #filterPillsBehaviour: Rebuilt per apply; each pill gets a × button that unchecks the matching box
  • #filterCountBehaviour: Badge with the active-filter total (hidden at 0)
  • #resetAllFilters / any .js-reset-filtersBehaviour: Clears every checkbox. If the URL carries ?category=, ?location= or ?attributes=, it strips them and reloads (a "Reset all" that leaves the URL-driven Type filter active reads as broken)
  • #visibleCountBehaviour: Live count in the results bar
  • #noFilterResultsBehaviour: Shown (and #spaceList hidden) when nothing matches
  • #filterSidebar + .js-toggle-mobile-filtersBehaviour: Mobile: toggles the filter-sidebar--open class

URL-driven selects (server re-projects, full navigation):

  • #locationSelect — navigates to /t/spaces?location={id} (or /t/spaces for "All"). It deliberately drops any /locations/{slug} path scope and stale ?category=.
  • #categorySelect — merges ?category={id} into the current URL (empty clears it).

Sort#sort-select with option values priceAsc (default), priceDesc, sizeAsc,sizeDesc. Size = data-width × data-length. Sorting re-appends the existing card nodes inorder (visibility state survives), then re-applies filters/paging. The value is persisted to?sort= via history.replaceStatepriceAsc is removed from the URL so the canonical no-sortURL stays clean. The load-time handler also seeds the initial sort from ?sort= and makes theonly load-time applyFilters() call, which is what paints pagination on first load — if yourtheme omits #sort-select, call applyFilters() yourself on DOMContentLoaded.

Pagination — opt-in: it activates only if #spacePagination exists (an empty<nav id="spacePagination" style="display:none"> is enough; JS builds Previous / "Page x of y" /Next into it, classes .space-pagination__btn / .space-pagination__label). Page size is fixedat SPACE_PAGE_SIZE = 9. Without the nav, all matched cards simply show. Page changes scroll#spaceList into view; any filter/sort change resets to page 1.

Space-grid sections (per-section filter + pager)

initSpaceGridSections() — independent of §4.3; runs for every [data-space-grid] element,so multiple grids per page each get their own state. Markup contract (fromsections/space-grid.fluid):

  • [data-space-grid] on the section rootPurpose: Enables the behaviour
  • data-items-per-page="6"Purpose: Page size (default 6)
  • [data-space-grid-list]Purpose: Container whose .space-card children get paged; cards reuse data-category and data-attributes (same | separator)
  • [data-filter-category] / [data-filter-attribute]Purpose: Optional <select>s; options are auto-populated from the rendered cards' data attributes (keep only the empty "All" option in markup). Category is exact match; attribute is single-select membership
  • [data-space-grid-pager]Purpose: Pager wrapper, hidden when only one page
  • [data-pager-prev] / [data-pager-next] / [data-pager-info]Purpose: Buttons + "Page x of y" label

Embed inline space detail

Only meaningful on the embed spaces page (templates/embed-spaces.fluid sets{% assign EmbedInlineDetail = true %} before including space-listing, which makes the cardrender a js-view-space button instead of an <a> link — other embed pages link out likethe full site so the button is never a dead click):

{% if Request.IsEmbed and EmbedInlineDetail %}
<button type="button" class="space-card__cta js-view-space">View {{Organisation.SpaceName}}</button>
{% else %}
<a href="/t/spaces/{{item.UId}}" class="space-card__cta">View {{Organisation.SpaceName}}</a>
{% endif %}

Contract:

  • #embedSpaceDetailPurpose: Empty, hidden panel next to the listing (<div id="embedSpaceDetail" class="embed-space-detail" style="display:none"></div>)
  • .spaces-layoutPurpose: The listing wrapper that gets hidden while the detail shows
  • .js-view-spacePurpose: Button inside a .space-card; click clones the card's .space-card__visual, .space-card__body and .space-card__price-block into the panel — no fetch, no navigation, the visitor stays inside the iframe
  • .js-back-to-spacesPurpose: Injected Back button (also: Escape closes the detail)

If the card has a .js-quote-btn, the detail view gets a CTA that re-opens the quote modal withthe same data-* payload — the embed converts to a lead without leaving the iframe.

Detail-page image gallery (shared delegator, main site): .js-detail-thumb buttons withdata-src="{{ media }}" swap #detailHeroImage.src and move the is-active class.

Contact form

submitContactForm — attaches when #contactForm exists (markup insections/contact-form.fluid). Required ids: #contactName, #contactEmail, #contactPhone,#contactMessage, #contactSubmitBtn, #contactError, #contactSuccess.POST /api/public/contact with { name, email, phone, message }; success hides the form andshows #contactSuccess. No reCAPTCHA on this one.

Testimonial / review carousel

initTestimonialCarousels() — for every [data-testimonial-carousel] root:[data-carousel-track] (the horizontal scroller), optional [data-carousel-prev] /[data-carousel-next] buttons. The scroll step is the width of the first .testimonial-cardplus 24 px (the gap); buttons disable at the ends via a passive scroll listener. Used by bothsections/testimonials.fluid (carousel layout) and sections/google-reviews.fluid (minimalstyle).

Global click-delegation reference

One document-level click listener routes all of these (first match wins, in this order):.js-quote-btn, .js-close-quote, .js-detail-thumb, .js-view-space, .js-back-to-spaces,.js-reset-filters, .js-toggle-mobile-filters, .js-pay-toggle (calls setPaymentType(dataset.type)if such a function exists — a hook, not implemented in the origin theme), .js-qty-btn(calls changeQuantity(dataset.delta) if defined). A document-level change listener routes.js-filter-cb, and a keydown listener closes the inline embed detail on Escape.