Developer docs  /  Themes

Book Online integration

This is the most important contract a theme has to honour: the theme does not implement checkout. A ThemeV2 storefront is the discovery layer — browse, filter, price — and its one job at the end of the funnel is to hand the visitor to the Angular public checkout with the right query string. Get this URL wrong and every other page in the theme is decoration.

There are two conversion paths out of a theme:

  • Book OnlineTrigger: #bookOnlineBtn on the space detail page — Destination: /checkouts/cn/booking?... — the Angular public checkout (same host)
  • Get a quoteTrigger: .js-quote-btn anywhere — Destination: POST /api/public/spaces/{id}/get-quote — emails the location admin, no navigation

Everything below is read from the origin theme (Alyta.Web/wwwroot/themes/origin/), the backend renderer/router, and the Angular public frontend (Alyta.Public.Frontend). Where something couldn't be verified in-repo it is called out as inferred.

The Book Online payload

The URL the theme builds

The space detail template renders a placeholder link:

{% if Settings.ShowBookOnline and Space.AllowOnlineBookings %}
<a href="#" id="bookOnlineBtn" class="space-card__cta">Book Online</a>
{% endif %}

(templates/space-detail.fluid)

and theme.js rewrites its href every time the price is recalculated:

function updateBookOnlineUrl() {
const btn = document.getElementById('bookOnlineBtn');
if (!btn) return;
let url = '/checkouts/cn/booking?spaceUId=' + spaceUId;
if (quantity > 1) url += '&months=' + quantity;
if (selectedPromotionUId) url += '&promotionUId=' + selectedPromotionUId;
btn.href = url;
}

(assets/theme.js, inside initPricingCalculator)

This mirrors, character for character, what the legacy Angular /units detail page does (Alyta.Public.Frontend/src/app/pages/unit/unit/unit.component.ts):

let url = `/checkouts/cn/booking?spaceUId=${this.spaceDetails.uId}`;
if (this.monthValue > 1) {
url += `&months=${this.monthValue}`;
}

So a custom theme that builds the same URL is on exactly the same contract as Alyta's own frontend.

Query parameters

  • spaceUIdType: GUID — Required: YesSource: data-space-uid="{{Space.UId}}" on #pricingCardWhen included: Always
  • monthsType: int (1–100) — Required: No — Source: The quantity stepper (#quantityInput, clamped 1–100 in setQuantity) — When included: Only when quantity > 1 (an omitted value means 1)
  • promotionUIdType: GUID — Required: No — Source: promotionUId of the auto-selected promotion from GET /api/public/spaces/{id}/promotionsWhen included: Only when at least one eligible promotion came back

Notes on each:

  • spaceUId must be the GUID UId, not the integer Id. The pricing card carries both, for different jobs:

```liquid

``` *(templates/space-detail.fluid)* `data-space-id` (int) is what `theme.js` uses for the live pricing API calls; `data-space-uid` (GUID) is what goes into the checkout URL. The Angular shell reads it from the query string (`checkout-v2-shell.component.ts`: `pm.get('spaceUId') ?? pm.get('SpaceUId')`), fetches the space, and adds it to the cart as the first line item.- **`months` is really "number of billing cycles".** The name is historical — the same parameter is sent whether the space bills weekly, monthly, or annually, and the checkout's Booking step reads it straight into its quantity (`checkout-v2-booking.component.ts`: `const months = pm.get('months'); if (months) this.months.set(parseInt(months))`). In the origin theme, `quantity > 1` also flips the calculator into "advance-pay" display mode (`paymentMode()` in `theme.js`), but that's presentation only — the checkout derives everything from the param.- **`promotionUId` is the auto-selected top-priority promotion.** After fetching promotions, `theme.js` sorts by `priority` ascending — with null/0 sorting first — and picks the head of the list: ```javascript /* null/0 priority sorts FIRST - matches the Angular checkout calculator ((priority ?? 0) ascending); '|| 99' pushed the top promotion to the back and auto-selected the wrong one. */ promotions.sort((a, b) => ((a.priority ?? 0) - (b.priority ?? 0))); selectedPromotionUId = promotions.length > 0 ? promotions[0].promotionUId : null; ``` If you build your own theme with a promotion picker, pass whichever `promotionUId` the visitor chose. If you skip the promotions call entirely, omit the param — the checkout re-evaluates eligible promotions itself; the param just pre-selects one.## When the button exists at allTwo independent switches gate the Book Online CTA, and a theme should honour both:- **`Settings.ShowBookOnline`** — a theme global setting (`ThemeV2GlobalSettings.ShowBookOnline`, default `true`), i.e. the tenant's sales-channel choice.- **`Space.AllowOnlineBookings`** — projected by the renderer as the AND of the space flag and its location's setting: ```csharp AllowOnlineBookings = s.AllowOnlineBookings && s.Location.Settings.AllowOnlineBookings, ``` *(ThemeV2RenderService.cs, space projection)*If either is false, render no booking CTA — fall back to the quote flow (`Settings.ShowGetQuote`). Also respect `Settings.HidePricing`: the origin theme suppresses every price fragment (and the `data-price` attribute on quote buttons) when it's set.## Where the link lands`/checkouts/cn/booking` is a route in the **Angular public frontend**, not a theme or backend page. In `Alyta.Public.Frontend/src/app/routes.ts`:{
path: 'checkouts',
loadChildren: () => import('./pages/checkout-v2').then(m => m.CheckoutV2Module)
},
and inside `checkout-v2-routing.module.ts` the next segment is a dynamic `:orderType` whose children are the checkout steps (`booking` → `information` → `forms` → `payment`, plus a top-level `success`). So the URL decomposes as:/checkouts / cn / booking ? spaceUId=...&months=...&promotionUId=...
module :orderType step
`orderType` is typed `"do" | "cn" | "o"` in `checkout-v2-shell.component.ts`:| Value | Order service | Meaning ||---|---|---|| `do` | `DraftOrderService` | Resume an admin-created draft order || `o` | `ConvertedOrderService` | Admin-shared converted order (complete remaining steps) || `cn` (or anything else) | `PublicOrderService` | **New customer booking — this is what a theme sends** |The link is host-relative on purpose: the checkout runs on the **same host** as the storefront, so the tenant's domain, org resolution, and any promotions/pricing context stay consistent across the handoff. Don't absolutise it to another domain.---## What handles `/checkouts/*` server-sideShort version: **nothing in the theme system**. `/checkouts/*` bypasses ThemeV2 completely and is answered by the Angular SPA.What was verified in the repos:- **Theme pages are mounted under `/t`**, not at the domain root. `ThemeV2PublicEndpoints.cs`: ```csharp var pageGroup = app .MapGroup("/t") .WithTags("PublicThemeV2") .AddEndpointFilter(); pageGroup.AddGetThemeV2HomeEndpoint(); // GET /t pageGroup.AddGetThemeV2PageEndpoint(); // GET /t/{**path} ``` That's why every internal link in the origin theme is `/t/...` (`/t/spaces`, `/t/spaces/{{item.UId}}`, `/t/locations/...`) while the checkout link is bare `/checkouts/...` — it deliberately steps outside the theme's URL space.- **The route resolver additionally excludes a `checkout` prefix** so that even a path *under* `/t` can never render a checkout-looking theme page. `ThemeV2RouteResolver.cs`: ```csharp private static readonly HashSet ExcludedPrefixes = new(StringComparer.OrdinalIgnoreCase) { "api", "assets", "account", "checkout", "favicon.ico", "robots.txt", "sitemap.xml" }; ``` and the page endpoint returns 404 for them before rendering anything (`GetThemeV2Page/Endpoint.cs`): ```csharp // Exclude paths handled by the Angular SPA if (ThemeV2RouteResolver.IsExcludedPath(path)) return Results.NotFound(); ``` Pedantic but worth knowing: the excluded prefix is `checkout` (singular). `/t/checkouts/...` isn't in the exclusion list — it would fall through to the generic page rule, look for a nonexistent `templates/checkouts.fluid`, and 404 anyway. Either way, no theme template is ever rendered for a checkout URL. Don't create one.- **`Alyta.Web` does not serve the Angular app.** Its `Program.cs` maps static files only for `/swagger-ui` and has no SPA fallback (`MapFallbackToFile` / `UseSpa` don't appear anywhere in the project). The Angular public frontend is a separate deployment with its own SPA-fallback hosting config — all three variants exist in `Alyta.Public.Frontend`: `nginx.conf` (`try_files $uri $uri/ /index.html`), `src/web.config` (IIS rewrite of non-file URLs to `/`), and `src/staticwebapp.config.json` (`navigationFallback` → `index.html`). Any of those serves `index.html` for `/checkouts/cn/booking`, and Angular's router takes it from there.- **Inferred (not in either repo):** the glue that makes the same tenant domain serve both apps — `/t/*` and `/api/*` to `Alyta.Web`, everything else to the Angular SPA — is infrastructure-level path routing. No reverse-proxy rule for it exists in `nginx.conf`, `web.config`, or `Program.cs`, so it must live in the hosting platform config (e.g. a front-door / gateway), which isn't checked in. What *is* verified is that both sides resolve the tenant from the request: theme pages via `HostDomainEndpointFilter` (Host header → organisation), and the `/api/public/*` endpoints via `DomainNameEndpointFilter` (Referer header → organisation). The practical consequence for a theme author is unchanged either way: **emit `/checkouts/...` as a same-host relative URL and it works**.---## Live pricing endpoints a theme may call client-sideThe space detail calculator fetches both endpoints in parallel on load and on every quantity change (`fetchPricing()` in `theme.js`):const promoUrl = '/api/public/spaces/' + spaceId + '/promotions?quantity=' + quantity;
const priceUrl = '/api/public/spaces/' + spaceId + '/price?quantity=' + quantity;

Promise.all([
fetch(promoUrl, { signal: controller.signal }).then((r) => r.ok ? r.json() : []),
fetch(priceUrl, { signal: controller.signal }).then((r) => r.ok ? r.json() : null)
])
Both endpoints are anonymous, live under `/api/public/spaces`, and accept **either** the integer `Id` **or** the GUID `UId` as the `{spaceId}` segment — the backend registers both overloads (`GetSpacePrice/Endpoint.cs`, `GetSpacePromotions/Endpoint.cs`):.MapGet("/{spaceId:int}/price", Endpoint.HandleByIdAsync)
.MapGet("/{spaceId:guid}/price", Endpoint.HandleByUIdAsync)
The origin theme uses the int `Id` (from `data-space-id`). Because the group carries `DomainNameEndpointFilter`, these calls must come from a page on the tenant's domain (the browser's Referer resolves the organisation) — they aren't a general-purpose anonymous API.## `GET /api/public/spaces/{id}/price?quantity=N`Returns a single JSON object — the serialised `SpaceCostCalculationResult` (`Alyta.Core/Domain/Models/Space/SpaceCostCalculationResult.cs`), camelCased by ASP.NET's default JSON options.## `GET /api/public/spaces/{id}/promotions?quantity=N`Returns a JSON **array** of `PromotionCalculationResult` — which *extends* `SpaceCostCalculationResult`, so every promotion entry carries the full price shape plus promotion fields. An empty array means no eligible promotions for that space/quantity.## The fields the origin theme actually consumesTreat **`theme.js` usage as the contract** — the C# models expose more (including `[Obsolete]` aliases like `selectedQuantityPrice`); stick to what the shipped theme reads:| Field | On | Used for (in `renderBreakdown()`) ||---|---|---|| `selectedQuantityBasePriceExcludingTax` | both | The "Monthly cost" line (pay-as-you-go), and the discount delta || `basePricePerItemExcludingTax` | both | The `N × $x` line in advance-pay mode || `selectedQuantityAppliedPriceExcludingTax` | both | Discount = `selectedQuantityBasePriceExcludingTax − selectedQuantityAppliedPriceExcludingTax` || `calculatedTotalPriceExcludingTax` | both | Subtotal line || `calculatedTotalTax` | both | Tax line || `calculatedTotalPrice` | both | Final total (plus deposit and fee) || `depositAmount` | both | "Deposit (refundable)" line when `> 0` || `quantityOfAdditionalItems` | promotion | "Bonus — N extra months" line when `> 0` || `priceForAdditionalItemsExcludingTax` | promotion | The bonus line's amount (`FREE` when `0`) || `promotionUId` | promotion | Selection key + the `promotionUId=` checkout param || `priority` | promotion | Ascending sort; nulls first || `description` | promotion | The note on the Discount line |The selected promotion result, when present, **replaces** the base price object for the whole breakdown (`const pricing = selectedPromotion() || basePrice;`) — the promotion payload's `calculatedTotalPrice`/`calculatedTotalTax` overrides already fold the discounted and bonus-item amounts in, so don't try to combine both objects yourself.Two caveats:- All the `*ExcludingTax`, `calculatedTotal*`, and `depositAmount` values are computed C# getter properties, not stored columns — they serialise fine, but their semantics (tax-inclusive base prices, banker's rounding to 4 dp) live in the model. Don't recompute tax client-side; display what the API gives you.- `theme.js` also reads `pricing.processingFee || 0` to render a "Credit Card Fee" line — but no `processingFee` field exists on either model (the model exposes `processingFeePercentage`). The property is always `undefined`, so that line never renders in the origin theme. Don't rely on it; if you need a card-fee display, that maths currently only happens inside the Angular checkout.Abort/timeout behaviour worth copying: the origin theme wraps both fetches in one `AbortController` with a 15-second timeout and, on failure, leaves the last successful render on screen rather than blanking the card.---