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):
#quoteModal— Purpose: Overlay; shown withdisplay:flex, click on the backdrop closes#quoteModalTitle— Purpose: Heading (aria-labelledby target)#quoteUnitInfo— Purpose: 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)#quoteForm— Purpose: The form;submithandler attached on DOMContentLoaded#quoteSpaceId— Purpose: Hidden input carrying the space id#quoteName,#quoteEmail,#quotePhone,#quoteContactPref— Purpose: Field values posted to the API#quoteSubmitBtn— Purpose: Disabled + relabelled "Submitting..." during the POST#quoteError,#quoteSuccess— Purpose: Toggled viastyle.display.js-close-quote— Purpose: 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-type—Day/Week/Fortnight/Month/Quarterly/HalfYearly/Annual; defaults toMonth. Drives all the label wording (CYCLE_ADVERBmap → "Monthly cost", "Half-yearly cost", …;Annualdisplays as "Year"/"Years").
Required children:
#quantityInput— Purpose: Quantity<input type="number">; clamped to 1–100 on change.js-qty-btnwithdata-delta="-1"/data-delta="1"— Purpose: Stepper buttons (calculator binds directly; a global fallback delegator also routes clicks if achangeQuantityfunction exists)#priceBreakdown— Purpose: Emptied and rebuilt on every fetch (omit it underSettings.HidePricing— the calculator tolerates its absence)#bookOnlineBtn— Purpose: 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 > 1 ⇒ advance-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-attributesuses|as the separator (attribute names can contain commas). A trailing|is fine — the JS trims and drops empties.data-matchis written byapplyFilters()('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-cbwithvalue="min-max"— Behaviour: Client-side price bands. The origin bands:0-100,100-200,200-500,500-99999(labelled per month; matched againstmonthlyPrice, inclusive lower / exclusive upper). Achangedelegator on.js-filter-cbtriggersapplyFilters()#attributeFilterGroup+#attributeFilters— Behaviour: Feature checkboxes are built by JS from the union of every card'sdata-attributes; the group staysdisplay:noneuntil at least one attribute exists. Attribute filters are AND-combined (every), other types OR within the typeinput[data-filter="location"]/[data-filter="category"]— Behaviour: Supported bygetActiveFilters()if your theme renders such checkboxes (origin uses URL-driven selects instead)#filterPills— Behaviour: Rebuilt per apply; each pill gets a × button that unchecks the matching box#filterCount— Behaviour: Badge with the active-filter total (hidden at 0)#resetAllFilters/ any.js-reset-filters— Behaviour: 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)#visibleCount— Behaviour: Live count in the results bar#noFilterResults— Behaviour: Shown (and#spaceListhidden) when nothing matches#filterSidebar+.js-toggle-mobile-filters— Behaviour: Mobile: toggles thefilter-sidebar--openclass
URL-driven selects (server re-projects, full navigation):
#locationSelect— navigates to/t/spaces?location={id}(or/t/spacesfor "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.replaceState — priceAsc 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 root — Purpose: Enables the behaviourdata-items-per-page="6"— Purpose: Page size (default 6)[data-space-grid-list]— Purpose: Container whose.space-cardchildren get paged; cards reusedata-categoryanddata-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:
#embedSpaceDetail— Purpose: Empty, hidden panel next to the listing (<div id="embedSpaceDetail" class="embed-space-detail" style="display:none"></div>).spaces-layout— Purpose: The listing wrapper that gets hidden while the detail shows.js-view-space— Purpose: Button inside a.space-card; click clones the card's.space-card__visual,.space-card__bodyand.space-card__price-blockinto the panel — no fetch, no navigation, the visitor stays inside the iframe.js-back-to-spaces— Purpose: Injected Back button (also:Escapecloses 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.