@framework-cwf/booking-embed
v0.3.3
Published
<BookingModal> + <AccountModal> iframe components and the typed buildEmbedUrl helper. Implements the booking-web postMessage protocol.
Readme
@framework-cwf/booking-embed
Runtime home for the booking iframe story. Hosts the URL builders that mint
the booking-web iframe URL, the <BookingModal /> + <AccountModal />
components that mount the iframe and exchange typed postMessages with it,
and the consumer-side useStripeReturn() hook for the Stripe-return
auto-open flow.
Installation
Published to GitHub Packages under the @framework-cwf scope. Consumers need an
.npmrc pointing the scope at the GitHub Packages registry plus an auth token:
@framework-cwf:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}pnpm add @framework-cwf/booking-embedPublic API
| Export | Purpose |
| ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| BookingModal | The booking-flow iframe modal (Client Component, 'use client'). |
| AccountModal | The profile-management iframe modal. Shares the auth-handshake + origin contract with BookingModal but has its own message subset (profile:sign_in_required, profile:book_now) and is sign-in-mandatory. |
| useStripeReturn | Hook — detects ?payment_result=success&… on mount, fires onDetected. |
| configureBookingEmbed | Process-level config: { iframeOrigin }. Call once on app boot. |
| buildEmbedUrl | Re-export from @framework-cwf/contracts — pure URL builder. |
| buildBookingUrl | Convenience wrapper over buildEmbedUrl (variant defaults + overrides). |
| parseStripeReturn / stripStripeReturnFromHistory | Low-level Stripe-return helpers. |
| BookingEmbedError (+ subclasses) | Typed errors. |
| MissingIframeOriginError | Thrown on first render in production when no origin is configured. |
| VARIANTS, VARIANT_DEFAULTS, … | Re-exports from contracts for one-stop import. |
Wiring on app boot
<BookingModal /> needs to know the exact origin of the iframe host so it
can validate every inbound MessageEvent.origin and target every outbound
postMessage() call. In production, a missing origin is a hard error —
the modal throws MissingIframeOriginError on first render rather than
falling back to "*", which would let any cross-origin iframe receive
privileged messages (auth tokens, payment session IDs).
Configure once from the root layout — typically by @framework-cwf/core
(T1.G.1) — and forget about it:
import { configureBookingEmbed } from "@framework-cwf/booking-embed";
configureBookingEmbed({ iframeOrigin: "https://booking.example.com" });In development the origin can be derived automatically from iframeBaseUrl,
so the dev loop works without calling configureBookingEmbed at all.
Using <BookingModal />
"use client";
import { BookingModal, useStripeReturn } from "@framework-cwf/booking-embed";
import { useState } from "react";
export default function BookNowCta() {
const [open, setOpen] = useState(false);
// Auto-open the modal on Stripe return — the modal handles the rest
// (posts booking:payment_complete, strips URL params, fires onPaymentComplete).
useStripeReturn({ onDetected: () => setOpen(true) });
return (
<>
<button type="button" onClick={() => setOpen(true)}>
Book now
</button>
<BookingModal
open={open}
onClose={() => setOpen(false)}
iframeBaseUrl="https://booking.example.com/booking-platform"
businessGuid="a1b20001-0000-4000-8000-000000000001"
variant="premium"
onPaymentComplete={(paymentId) => {
// e.g. show a toast, refresh the "your bookings" widget, fire analytics
console.log("booked!", paymentId);
}}
/>
</>
);
}Using <AccountModal />
Same iframe origin contract as <BookingModal />, smaller surface, no
Stripe handoff. Sign-in is mandatory — opening the modal while
unauthed closes it and bounces the user through Cognito's hosted UI via
@framework-cwf/auth's initiateLogin().
"use client";
import { AccountModal, BookingModal } from "@framework-cwf/booking-embed";
import { useState } from "react";
export default function AccountAndBookCtas() {
const [accountOpen, setAccountOpen] = useState(false);
const [bookingOpen, setBookingOpen] = useState(false);
return (
<>
<button type="button" onClick={() => setAccountOpen(true)}>
My account
</button>
<AccountModal
open={accountOpen}
onClose={() => setAccountOpen(false)}
iframeBaseUrl="https://booking.example.com/booking-platform"
businessGuid="a1b20001-0000-4000-8000-000000000001"
variant="premium"
onBookNow={() => {
// User clicked "Book now" inside the profile iframe.
// Account modal auto-closes; we open the booking modal.
setBookingOpen(true);
}}
/>
<BookingModal
open={bookingOpen}
onClose={() => setBookingOpen(false)}
iframeBaseUrl="https://booking.example.com/booking-platform"
businessGuid="a1b20001-0000-4000-8000-000000000001"
variant="premium"
/>
</>
);
}Message subset handled (per packages/contracts/src/postmessage.ts):
| Message | Direction | Action |
| -------------------------- | ------------- | --------------------------------------------------------------------------------- |
| booking:ready | iframe→parent | Post auth:token once resolveSession() has resolved (handshake races handled). |
| profile:sign_in_required | iframe→parent | Close + call initiateLogin(). |
| profile:book_now | iframe→parent | Close + call onBookNow?.(). |
| auth:token | parent→iframe | Sent in response to booking:ready. No returnUrl/cancelUrl (no Stripe). |
booking:resize and booking:payment_ready are silently dropped — those
are <BookingModal />'s concern; a consumer mounting both modals against
the same origin won't see them bleed.
Why useStripeReturn is a hook, not a prop
<BookingModal /> is a controlled component (open is owned by the
consumer). The Stripe-return auto-open signal therefore has to flow OUT of
the framework and into the consumer's state. A hook expresses that flow
cleanly; a prop-driven design would force the modal to manage open
internally, contradicting the controlled-component contract.
Lifecycle
- Consumer flips
opentotruefor the first time → iframe is mounted lazily withsrc = buildBookingUrl(...). - Iframe stays in the DOM thereafter, even when closed (hidden via CSS). Unmounting + remounting would blow away the booking session.
- Iframe posts
booking:ready→ parent readsresolveSession()and postsauth:token. If a Stripe return was detected on mount, the parent ALSO postsbooking:payment_completeand firesonPaymentComplete(paymentId)AFTER stripping?payment_result=success&cart_session_id=…&payment_id=…from the URL. - Iframe posts
booking:resizeperiodically → parent updates iframe height insiderequestAnimationFrameto avoid layout thrash. - User clicks "Pay" → iframe posts
booking:payment_ready { stripeUrl }→ parent callswindow.location.assign(stripeUrl)(full top-frame navigation; iframes can't navigate their own top). - Stripe redirects back → consumer's
useStripeReturn()hook fires, modal auto-opens, lifecycle resumes at step 3.
Security
- Strict origin validation. Every inbound
MessageEventis rejected silently ifevent.origin≠ the resolved iframe origin. Silent rejection (no logs, no throws) is the correct pattern here — loud rejection leaks information to an attacker probing the parent. - No
"*"fallback in production.MissingIframeOriginErroris thrown on first render if no origin is configured. - Wire-shape runtime guard.
isBookingMessagefrom contracts validates every inbound message against the discriminated-union schema before the switch ever runs. Malformed messages drop silently.
Tests
| File | Coverage |
| ----------------------------------- | --------------------------------------------------------------------------------------------------------- |
| BookingModal.test.tsx (20 cases) | All four acceptance scenarios + render shape + memory hygiene. |
| AccountModal.test.tsx (22 cases) | Auth gate, ready/token handshake (both race orderings), profile messages, origin silence, memory hygiene. |
| build-booking-url.test.ts (47) | Variant × brand-param matrix (T1.D.1). |
| stripe-return.test.ts (10) | Parse + strip helpers, including history preservation. |
| resolve-iframe-origin.test.ts (6) | Dev fallback + production hard-fail + override. |
| config.test.ts (4) | Singleton get/set/reset. |
Total: 109 vitest cases, all green under jsdom. Real-browser end-to-end
coverage (Playwright) is deferred to T1.G.2 when apps/template exists to
host the parent page in a real browser.
