@mena-ai/booking-widget
v1.2.0
Published
Embeddable <mena-booking> web component to book therapy appointments on any website. Zero runtime dependencies, Shadow DOM isolation, CSS-custom-property theming, en + pt.
Maintainers
Readme
@mena-ai/booking-widget
<mena-booking> — an embeddable booking widget for clinics and therapists on
Mena. One custom element, zero runtime dependencies, Shadow DOM
isolation, fully themeable through CSS custom properties.
It runs the full booking flow end to end: service picker → therapist picker → time-slot picker → visitor details + consents → email verification (one-time code) → confirmed booking, with localized error recovery throughout. It is keyboard-accessible, WCAG AA across every preset, and ships English + Portuguese.
Install
npm
npm install @mena-ai/booking-widgetimport '@mena-ai/booking-widget';Importing the package registers the <mena-booking> element (idempotent — safe to import
from multiple bundles). Named exports: defineMenaBooking() to register explicitly, and
the MenaBookingElement class.
CDN script injection
Pin an exact version (recommended for production — the bytes are immutable, so the Subresource-Integrity hash stays valid forever):
<script
type="module"
src="https://cdn.mena-ai.pt/booking-widget/1.1.0/mena-booking.js"
integrity="sha384-HJ7oFkCdSLln0eHrHGoa4tposvInwKf0G560+crcOLVCrYEoFOxCZF9KaXCpLs9M"
crossorigin="anonymous"
></script>
<mena-booking publishable-key="mena_pk_live_..." locale="pt"></mena-booking>For pages that cannot use module scripts, load the IIFE build:
<script
src="https://cdn.mena-ai.pt/booking-widget/1.1.0/mena-booking.iife.js"
integrity="sha384-vFzFc0iAL63/BCu9vvffwykQcjqDBXCVJTJl0ATV9lx5D/8JShuboNzhZaoXC4oV"
crossorigin="anonymous"
></script>To auto-receive non-breaking updates, use the rolling major channel without an
integrity hash (the file is replaced on each patch, so a pinned hash would break):
<script type="module" src="https://cdn.mena-ai.pt/booking-widget/v1/mena-booking.js"></script>Each published version's hashes are also available at
https://cdn.mena-ai.pt/booking-widget/<version>/sri.json and in the GitHub Release notes.
Usage
<mena-booking publishable-key="mena_pk_..." preset="calm" locale="pt"></mena-booking>Attributes
| Attribute | Required | Values / default | Meaning |
| ----------------- | -------- | -------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| publishable-key | yes | — | The company's public booking key. Missing/invalid keys render a polite "booking unavailable" state (and emit booking:error) — never a broken widget. |
| preset | no | calm (default) · clinical · minimal · warm · bold · dark | Visual preset. Unknown values fall back to calm. |
| locale | no | en · pt | UI language. Default resolution: this attribute → browser language → the company's configured locales → en. Unsupported values fall back, never throw. |
| therapist-id | no | therapist UUID | Locks the flow to one therapist (the therapist step is skipped). Unknown ids degrade to the config-error state. |
| service-id | no | service UUID | Preselects a service; the service step is skipped when the id matches a bookable service. |
| api-base | no | https://api.mena-ai.pt | Origin of the production booking API (the default). Override only to point at a non-production backend — e.g. the demo targets a local mock, staging targets the staging origin. |
Events
| Event | When | detail |
| ------------------- | ------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------- |
| booking:error | Config cannot load (bad key, origin not allow-listed, company not found, network) or catalog/slot data fails | { scope: 'config' \| 'data', code?: string, status: number } |
| booking:completed | A booking is created (status pending = awaiting clinic confirmation, or scheduled = auto-confirmed) | { bookingReference: string, status: 'pending' \| 'scheduled' } |
Both events are dispatched with composed: true, bubbles: true, so you can listen on the
element, an ancestor, or document. Details carry no PII and no tokens — only the
booking reference and status — and never include the publishable key or response bodies.
Theming
The widget is styled entirely through --mb-* custom properties. The preset attribute
selects one of six built-in token sheets; any token can be overridden from the host page
with plain CSS, because author CSS on the host element always beats the shadow :host
defaults:
mena-booking {
--mb-primary: #0a6e5c;
--mb-primary-soft: #e1f1ec;
--mb-focus: #0a6e5c;
}Tokens (26) and preset values
| Token | calm | clinical | minimal | warm | bold | dark |
| -------------------- | -------------------------------------------------------------- | ---------------------------------------- | ------------------------------------------------ | -------------------------------------------------------------- | ---------------------------------------- | -------------------------------------- |
| --mb-primary | #3E6557 | #1D5C9C | #161616 | #99471F | #5526D9 | #8FD7BE |
| --mb-on-primary | #FFFFFF | #FFFFFF | #FFFFFF | #FFFFFF | #FFFFFF | #0C1A14 |
| --mb-primary-soft | #E7EFEA | #E7EFF8 | #F1F1F0 | #F5E7DB | #ECE6FC | #1F2D28 |
| --mb-surface | #FAF9F7 | #FFFFFF | #FFFFFF | #FBF5ED | #FFFFFF | #16191F |
| --mb-surface-2 | #FFFFFF | #F6F8FA | #FFFFFF | #FFFBF5 | #FAFAFC | #1D2128 |
| --mb-field-bg | #FFFFFF | #FFFFFF | #FFFFFF | #FFFBF5 | #FFFFFF | #14171D |
| --mb-text | #262E2A | #1B2733 | #161616 | #382E26 | #17131F | #E8EAEF |
| --mb-muted | #5C6963 | #4E6071 | #666666 | #6E5F52 | #5C5768 | #9BA4B5 |
| --mb-border | #E5E2DA | #D9E0E7 | #E4E4E2 | #EADCCB | #E5E2EC | #2B313C |
| --mb-widget-border | none | 1px solid #D9E0E7 | 1px solid #161616 | none | 1.5px solid #17131F | 1px solid #2B313C |
| --mb-card-border | 1px solid #ECE9E1 | 1px solid #D9E0E7 | 1px solid #E4E4E2 | 1px solid #EFE3D3 | 1px solid #E5E2EC | 1px solid #2B313C |
| --mb-shadow | 0 2px 6px rgba(38,46,42,.04), 0 18px 44px rgba(38,46,42,.09) | 0 1px 2px rgba(27,39,51,.05) | none | 0 2px 8px rgba(96,72,48,.06), 0 18px 44px rgba(96,72,48,.10) | 10px 10px 0 #ECE6FC | 0 20px 50px rgba(0,0,0,.45) |
| --mb-shadow-card | 0 1px 3px rgba(38,46,42,.05) | none | none | 0 1px 3px rgba(96,72,48,.06) | 0 1px 2px rgba(23,19,31,.05) | none |
| --mb-radius | 18px | 10px | 0px | 22px | 14px | 14px |
| --mb-radius-btn | 999px | 7px | 0px | 999px | 10px | 10px |
| --mb-radius-field | 12px | 7px | 0px | 14px | 10px | 10px |
| --mb-radius-check | 7px | 4px | 0px | 8px | 6px | 6px |
| --mb-font-heading | 'Lora', Georgia, serif | 'IBM Plex Sans', system-ui, sans-serif | 'EB Garamond', Georgia, serif | 'Nunito', system-ui, sans-serif | 'Space Grotesk', system-ui, sans-serif | 'Sora', system-ui, sans-serif |
| --mb-font-body | 'Karla', system-ui, sans-serif | 'IBM Plex Sans', system-ui, sans-serif | 'Helvetica Neue', Helvetica, Arial, sans-serif | 'Nunito Sans', system-ui, sans-serif | system-ui, -apple-system, sans-serif | system-ui, -apple-system, sans-serif |
| --mb-h-size | 23px | 21px | 27px | 22px | 24px | 22px |
| --mb-h-weight | 600 | 600 | 500 | 800 | 700 | 600 |
| --mb-h-track | 0em | -0.01em | 0em | 0em | -0.02em | -0.01em |
| --mb-density | 1.1 | 0.9 | 1 | 1.15 | 1 | 1 |
| --mb-focus | #3E6557 | #1D5C9C | #161616 | #99471F | #5526D9 | #8FD7BE |
| --mb-error | #A8453A | #B3362B | #9E2F23 | #A8453A | #C72C2C | #E89890 |
| --mb-error-bg | #F7ECEA | #F9EBEA | #F6EDEB | #F8ECE6 | #FBEAEA | #3A2422 |
Every preset passes an automated WCAG AA contrast gate enforced by unit tests — text,
muted, on-primary and error colours at 4.5:1, focus ring at 3:1, on every surface the
widget uses (surface, surface-2, field background, the brand-soft notice tint, and error
background). The three "bold" alternate-primary brand colours (violet/crimson/teal) are
checked the same way, so a custom --mb-primary keeps AA.
::part() styling
For styling beyond the tokens, these shadow parts are exported:
| Part | Surface |
| -------------- | -------------------------------------------------------------- |
| container | The widget box itself |
| header | Clinic logo + name row |
| logo | The 30px logo/initials disc |
| progress | Progress row (segments + "Step a of 5" label) |
| step | The step heading (h2) |
| back | The "← Back" button |
| card | Service cards, therapist cards, and the selection summary card |
| cta | The primary Continue button |
| slot | Individual time-slot buttons |
| day | Day cells in the date strip |
| day-strip | The scrollable date strip |
| jump | The "Next availability" jump button on empty days |
| field | Text inputs on the details form (name, email, phone, note) |
| consent | A consent card on the details step |
| consent-box | The checkbox box inside a consent card |
| otp-input | The one-time-code input |
| notice | The slot-taken / past / advance-limit notice banner |
| chip | The "awaiting confirmation" chip on the pending screen |
| confirm-icon | The clock / check icon disc on the confirmation screens |
| lock | The "too many attempts" panel on the verification step |
| rate-limit | The 429 cooldown box |
| retry | The retry button in the data-error state |
| error | The config-error / data-error containers |
| footer | The "Bookings by Mena.ai" attribution row |
mena-booking::part(cta) {
text-transform: uppercase;
}Fonts
Presets name webfonts but the widget never loads font files (privacy + CSP: it makes no third-party requests). Every stack carries a bundled fallback, so the widget always renders correctly. If you want the exact designed look, load the preset's family on your page:
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />| Preset | Add this stylesheet link |
| ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| calm | <link href="https://fonts.googleapis.com/css2?family=Lora:wght@500;600&family=Karla:wght@400;500;600;700&display=swap" rel="stylesheet"> |
| clinical | <link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&display=swap" rel="stylesheet"> |
| minimal | <link href="https://fonts.googleapis.com/css2?family=EB+Garamond:wght@500;600&display=swap" rel="stylesheet"> |
| warm | <link href="https://fonts.googleapis.com/css2?family=Nunito:wght@700;800&family=Nunito+Sans:wght@400;600;700&display=swap" rel="stylesheet"> |
| bold | <link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;600;700&display=swap" rel="stylesheet"> |
| dark | <link href="https://fonts.googleapis.com/css2?family=Sora:wght@400;500;600&display=swap" rel="stylesheet"> |
Accessibility
- Keyboard: every control is reachable and operable by keyboard. The date strip and the time-slot grid use a roving tabindex — one Tab stop each, then arrow keys (and Home/End) move between days/times; disabled days are skipped. The consent checkboxes toggle with Space/Enter.
- Focus: focus moves to the step heading on each step change and is restored after
re-renders. Every interactive element shows a visible
--mb-focusring (:focus-visible). - Screen readers: the verification code is a single labelled field; form errors are
associated with their inputs and announced; the progress indicator exposes
role="progressbar"; the confirmation screen announces via a live region. - Motion: all animation honours
prefers-reduced-motion: reduce. - An automated axe-core gate (run in CI, in en + pt, across every step) asserts zero serious/critical violations.
Design deviations
A few intentional differences from the visual prototype, each for a functional reason:
- The verification code is one segmented input (
autocomplete="one-time-code"), not six boxes, so screen readers hear a single field (the design's own production note). - "Change" on the time step restarts the selection at the service picker; the "← Back" arrow still moves one step.
- The header omits the prototype's clinic city label — the public config carries no city field.
- Webfonts are named but never loaded by the widget (see Fonts above).
- A network failure after the server has already created a booking (response lost) can't be told apart from a pre-commit failure; the backend de-duplicates the visitor, so a v1 re-attempt is safe rather than producing two distinct patients.
Privacy
No cookies, no localStorage/sessionStorage, no analytics, no tracking of any kind.
Visitor data and tokens live in memory only and disappear when the page unloads.
The publishable key is never logged and never included in error events or messages.
The widget runs no inline scripts and loads no third-party scripts or fonts; it makes network requests only to the Mena booking API. A host-page Content-Security-Policy needs the CDN origin in
script-srcand the API origin inconnect-src:Content-Security-Policy: script-src 'self' https://cdn.mena-ai.pt; connect-src 'self' https://api.mena-ai.pt;Pin the
integrityhash on an exact-version CDN<script>(see CDN script injection). If you opt into the designed webfonts, also addhttps://fonts.googleapis.comtostyle-srcandhttps://fonts.gstatic.comtofont-src.
Browser support
Modern evergreen browsers. The bundle targets ES2022 and uses container queries and
constructable stylesheets (with a <style> fallback where adoptedStyleSheets is
unavailable).
Versioning & channels
The widget follows semver. The CDN exposes two kinds of path:
…/booking-widget/<major>.<minor>.<patch>/— an exact version. Immutable, cached one year, safe to pin with Subresource Integrity. Use this in production.…/booking-widget/v<major>/— a rolling channel that always serves the latest non-breaking (v1= newest 1.x) release. Short cache; do not pin SRI here.
Non-breaking changes (new features, fixes) ship within the same /vN/ channel. Breaking
changes ship a new major and a new /v<major+1>/ channel; the previous channel keeps
working. The npm package version and the CDN path always match.
