Medispa booking
How the React booking calendar reads its config, asks Sesami for availability, and turns a treatment booking into a cart line.
The booking calendar is the only React application in the theme. It shows treatment availability, lets a shopper pick a clinic, day and time, and adds the booking to the cart.
Ownership
| Value | Owned by | Changed where |
|---|---|---|
| Clinic name, address, phone, hours | Shopify metaobject | Content > Metaobjects > Medispa locations |
| Treatment price, deposit, pay-in-clinic amount | Product metafields | Products > [product] > Metafields |
| Fallback amounts and wording | Theme settings | Customize > Medispa Deposit |
| Slot availability | Sesami | Sesami's own dashboard. Not in Shopify |
| Deposit selling plan | Downpay app | The app. The theme reads it and never writes it |
| Booking form | Klaviyo | Klaviyo, with the form ID set per product or per store |
This repository holds no availability data. The theme asks Sesami which slots are free and shows the answer.
How the config reaches React
snippets/medispa-calendar-config.liquid builds a JSON object and assigns it to window.theme.medispaCalendarConfig.
For each product it works out the deposit amount, the pay-in-clinic amount, whether each payment option is on, and the
Klaviyo form IDs. When a metafield is absent, it uses the theme setting.
snippets/medispa-calendar-placeholder.liquid renders an empty div and loads the bundle.
src/entrypoints/medispa-calendar-react.tsx finds that div and mounts CalendarRoot into it.
The mount waits for Sesami. deferJS polls until window.SesamiSDK exists, because the calendar cannot ask for
availability without it. If the Sesami script is blocked or fails, the placeholder stays empty and the console shows no
error.
The calendar keeps its state in Jotai atoms in calendar-atoms.ts. Several atoms read
window.theme.medispaCalendarConfig at module scope, so the config must exist before the module is imported, not only
before the component renders.
Two payment paths
A treatment can offer one or both of these.
Pay a deposit now. The shopper pays a deposit at checkout and the balance in clinic. This path needs a selling plan
from the Downpay app. The calendar fetches it with a section render request to
products/<handle>?sections=product-calendar-config, which returns the JSON that
sections/product-calendar-config.liquid outputs. The custom.enable_checkout metafield turns this path off for one
product. The snippet treats an absent metafield as enabled, so older products keep the deposit option.
Pay in clinic. The shopper pays nothing online. The custom.enable_pay_in_store metafield controls this path for
one product, and a custom.pay_in_store metafield on the landing page overrides it for every product on that page. The
snippet detects the override by the metafield's type, not its value, so an explicit false still overrides.
The cart does not store the remaining balance. A mini-cart rule recalculates it on every cart change, for any line whose
vendor contains medispa. See Mini-cart.
Amounts stay in dollars until formatting
The config snippet receives every price in dollars, whether it comes from a render parameter, the landing page metafield or a theme setting. Shopify's money filters expect cents, so the snippet converts once, into a separate variable, at the point of formatting. The source comments state this rule, because converting twice is the most likely way to break it.
The Klaviyo and Zapier path
CalendarRoot listens for the klaviyoForms event on window. When a form is submitted, it builds a payload with a
fixed shape, including the selected product, and posts it to the URL in the Medispa Booking Form Submit URL theme
setting.
Klaviyo still receives the submission, and the webhook gets a copy. Clearing the webhook setting stops the copy and the
form keeps working. The payload type is KlaviyoZapierPayload in src/@types/calendar.ts. The source builds the
payload key by key, so Zapier receives the same fields in the same order every time.
Hong Kong differs from Australia
The Hong Kong and Australian stores share this code. Australia has a custom.medispa_landing_page metafield that
supplies the offer price. Hong Kong does not, so Hong Kong templates pass the landing page's offer price as a render
parameter. The Australian lookup stays first in the fallback chain so the two stores' code can still be merged. Leave it
in place when you edit the snippet.
Failure modes
Empty space where the calendar should be. Sesami did not load. Look for the Sesami script in the network tab, and check for an ad blocker.
The calendar throws on load. window.theme.medispaCalendarConfig is undefined because the template does not render
snippets/medispa-calendar-config.liquid.
The deposit option is missing. Either custom.enable_checkout is false on the product, or the product has no
Downpay selling plan and the section render request returns none.
Wrong clinic against a treatment. The calendar matches clinics to products by looking for the clinic handle inside the product handle. Renaming a product handle can break that match without any error, and the calendar then shows the default name.
Prices a hundred times too large or too small. Code converted dollars to cents twice, or not at all.
Source map
| Concern | File |
|---|---|
| Config construction | snippets/medispa-calendar-config.liquid |
| Inline variant of the config | snippets/medispa-calendar-config-inline.liquid |
| Mount point | snippets/medispa-calendar-placeholder.liquid |
| React entry | src/entrypoints/medispa-calendar-react.tsx |
| Main component and data fetching | src/components/medispa-calendar/CalendarRoot.tsx |
| Payment options and confirmation | src/components/medispa-calendar/CalendarConfirm.tsx |
| Shared state | src/components/medispa-calendar/calendar-atoms.ts |
| Types for config, slots and the Sesami SDK | src/@types/calendar.ts |
| Selling plan JSON | sections/product-calendar-config.liquid |
| Deferred mount helper | src/components/utils/defer.ts |