The second conversion path never leaves the page. Any element with class js-quote-btn opens the shared quote modal via event delegation (theme.js — no inline onclick, deliberately, to avoid XSS from templated handlers):
const btn = e.target.closest('.js-quote-btn');
if (btn) {
openQuoteModal(
btn.dataset.spaceId,
btn.dataset.name,
btn.dataset.category,
btn.dataset.price,
btn.dataset.duration
);
return;
}
The js-quote-btn data attributes
From snippets/space-card.fluid (the detail page's button in templates/space-detail.fluid is identical in shape):
<button type="button" class="space-card__cta-secondary js-quote-btn"
data-space-id="{{item.Id}}" data-name="{{item.Name}}"
data-category="{{item.CategoryName}}"{% unless Settings.HidePricing %} data-price="{{item.Price}}"{% endunless %}
data-duration="{{item.DurationType}}">Get a free quote</button>
data-space-id— Purpose: IntegerSpace.Id— becomes the POST URL segment (the quote endpoint has no GUID overload, unlike price/promotions)data-name— Purpose: Shown in the modal header info blockdata-category— Purpose: Shown as a tag in the modaldata-price— Purpose: Shown as$x/moin the modal; omitted entirely whenSettings.HidePricingdata-duration— Purpose: Duration type, abbreviated for the price display (week→w, etc.)
Gate the button on Settings.ShowGetQuote.
The POST
submitQuote() in theme.js posts to:
fetch('/api/public/spaces/' + spaceId + '/get-quote', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal: controller.signal
})
with this body:
const body = {
name: document.getElementById('quoteName').value,
email: document.getElementById('quoteEmail').value,
phone: document.getElementById('quotePhone').value,
contactPreference: document.getElementById('quoteContactPref').value, // "Email" | "Phone"
recaptchaToken: token
};
Server-side this is PostSpaceQuote/Endpoint.cs (MapPost("/{spaceId}/get-quote", ...)): it validates the command, verifies the reCAPTCHA token (action name getquote), and emails the space's location support admin — no order, draft, or customer record is created. Success is 201 Created with an empty body; the theme treats res.ok || res.status === 201 as success and swaps the form for the thank-you panel.
reCAPTCHA wiring
The modal markup lives in partials/quote-modal.fluid, which also publishes the site key when the tenant has one configured:
{% if Settings.RecaptchaSiteKey %}
<script>window.__RECAPTCHA_SITE_KEY__ = '{{Settings.RecaptchaSiteKey}}';</script>
{% endif %}
theme.js then uses reCAPTCHA Enterprise if it's loaded, and otherwise submits with an empty token:
if (window.grecaptcha && window.grecaptcha.enterprise && window.__RECAPTCHA_SITE_KEY__) {
window.grecaptcha.enterprise.ready(() => {
window.grecaptcha.enterprise.execute(window.__RECAPTCHA_SITE_KEY__, { action: 'getquote' })
.then(doSubmit)
.catch(/* show error, re-enable button */);
});
} else {
// No reCAPTCHA configured — submit without token
doSubmit('');
}
A custom theme should reproduce this shape: same endpoint, same body fields, same getquote action. Both layouts (layouts/main.fluid and layouts/embed.fluid) {% include 'quote-modal' %} at body level so the button works on every page — if you write your own layout, include it (or an equivalent) yourself.