@voyantjs/bookings-ui 0.19.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/dist/components/booking-dialog.d.ts.map +1 -1
  2. package/dist/components/booking-dialog.js +10 -1
  3. package/dist/components/booking-group-section.d.ts +11 -1
  4. package/dist/components/booking-group-section.d.ts.map +1 -1
  5. package/dist/components/booking-group-section.js +16 -2
  6. package/dist/components/booking-item-list.d.ts.map +1 -1
  7. package/dist/components/booking-item-list.js +71 -7
  8. package/dist/components/booking-list.d.ts.map +1 -1
  9. package/dist/components/booking-list.js +11 -3
  10. package/dist/components/booking-payments-summary.d.ts +28 -1
  11. package/dist/components/booking-payments-summary.d.ts.map +1 -1
  12. package/dist/components/booking-payments-summary.js +66 -11
  13. package/dist/components/traveler-list.d.ts +2 -1
  14. package/dist/components/traveler-list.d.ts.map +1 -1
  15. package/dist/components/traveler-list.js +126 -12
  16. package/dist/i18n/en.d.ts +12 -0
  17. package/dist/i18n/en.d.ts.map +1 -1
  18. package/dist/i18n/en.js +13 -1
  19. package/dist/i18n/messages.d.ts +14 -1
  20. package/dist/i18n/messages.d.ts.map +1 -1
  21. package/dist/i18n/provider.d.ts +24 -0
  22. package/dist/i18n/provider.d.ts.map +1 -1
  23. package/dist/i18n/ro.d.ts +12 -0
  24. package/dist/i18n/ro.d.ts.map +1 -1
  25. package/dist/i18n/ro.js +13 -1
  26. package/dist/journey/components/booking-journey.d.ts +3 -0
  27. package/dist/journey/components/booking-journey.d.ts.map +1 -0
  28. package/dist/journey/components/booking-journey.js +376 -0
  29. package/dist/journey/components/contract-preview-dialog.d.ts +47 -0
  30. package/dist/journey/components/contract-preview-dialog.d.ts.map +1 -0
  31. package/dist/journey/components/contract-preview-dialog.js +119 -0
  32. package/dist/journey/components/journey-steps.d.ts +47 -0
  33. package/dist/journey/components/journey-steps.d.ts.map +1 -0
  34. package/dist/journey/components/journey-steps.js +582 -0
  35. package/dist/journey/components/side-panel.d.ts +12 -0
  36. package/dist/journey/components/side-panel.d.ts.map +1 -0
  37. package/dist/journey/components/side-panel.js +172 -0
  38. package/dist/journey/components/step-header.d.ts +7 -0
  39. package/dist/journey/components/step-header.d.ts.map +1 -0
  40. package/dist/journey/components/step-header.js +28 -0
  41. package/dist/journey/index.d.ts +18 -0
  42. package/dist/journey/index.d.ts.map +1 -0
  43. package/dist/journey/index.js +17 -0
  44. package/dist/journey/lib/draft-state.d.ts +34 -0
  45. package/dist/journey/lib/draft-state.d.ts.map +1 -0
  46. package/dist/journey/lib/draft-state.js +54 -0
  47. package/dist/journey/types.d.ts +248 -0
  48. package/dist/journey/types.d.ts.map +1 -0
  49. package/dist/journey/types.js +17 -0
  50. package/package.json +31 -17
  51. package/src/styles.css +11 -0
@@ -0,0 +1,376 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ /**
4
+ * `<BookingJourney />` — the unified booking journey shell.
5
+ *
6
+ * Per `docs/architecture/booking-journey-architecture.md` §3 + §8.1.
7
+ *
8
+ * Composes:
9
+ * - draft state (held at this root, mutated by step components)
10
+ * - quote orchestration (debounced re-quote on every meaningful change)
11
+ * - server-side draft persistence (PUT on every step transition)
12
+ * - commit (Review step's Confirm button)
13
+ *
14
+ * Surface differences (CRM picker for operators vs. inline contact for
15
+ * customers, B2B billing default vs. B2C, payment provider hookup) live
16
+ * in injectable slots passed via props — same shell on every surface.
17
+ */
18
+ import { DEFAULT_PAX_BANDS, defaultBookingFields, defaultDraftShapeFlags, defaultTravelerFields, paxBandsAllowedTotalFrom, } from "@voyantjs/catalog/booking-engine";
19
+ import { useBookingCommit, useBookingDraft, useBookingDraftShape, useBookingHold, useBookingQuote, } from "@voyantjs/catalog-react/booking-engine";
20
+ import { Button } from "@voyantjs/ui/components/button";
21
+ import { useEffect, useMemo, useRef, useState } from "react";
22
+ import { emptyDraft, totalPax } from "../lib/draft-state.js";
23
+ import { JOURNEY_STEP_ORDER, } from "../types.js";
24
+ import { ContractPreviewDialog } from "./contract-preview-dialog.js";
25
+ import { AccommodationStep, AddonsStep, BillingStep, ConfigureStep, PaymentStep, ReviewStep, TravelersStep, } from "./journey-steps.js";
26
+ import { PriceSidePanel } from "./side-panel.js";
27
+ import { StepHeader } from "./step-header.js";
28
+ export function BookingJourney(props) {
29
+ const surface = props.surface ?? "admin";
30
+ const [draft, setDraft] = useState(() => {
31
+ const base = emptyDraft({
32
+ module: props.entityModule,
33
+ id: props.entityId,
34
+ // Empty when storefront — the public engine route resolves
35
+ // it server-side from (entityModule, entityId).
36
+ sourceKind: props.sourceKind ?? "",
37
+ sourceConnectionId: props.sourceConnectionId,
38
+ sourceRef: props.sourceRef,
39
+ }, { buyerType: props.defaultBuyerType ?? (surface === "admin" ? "B2B" : "B2C") });
40
+ // Seed Configure when the caller passed pre-locked state —
41
+ // detail page picks departure + pax, booking flow only handles
42
+ // travelers + addons + payment.
43
+ if (props.initialConfigure) {
44
+ const seed = props.initialConfigure;
45
+ const seedPax = seed.pax;
46
+ base.configure = Object.assign({}, base.configure, seed, {
47
+ pax: { ...base.configure.pax, ...(seedPax ?? {}) },
48
+ });
49
+ }
50
+ if (props.initialAccommodation) {
51
+ const seed = props.initialAccommodation;
52
+ base.accommodation = Object.assign({ rooms: [], travelerAssignments: {} }, base.accommodation ?? {}, seed);
53
+ }
54
+ return base;
55
+ });
56
+ const fallbackShape = useMemo(() => props.fallbackShape ?? defaultMinimalShape(), [props.fallbackShape]);
57
+ // Server-side draft sync — PUT on each step transition. The shell
58
+ // doesn't read from the server in Phase B (drafts are recovery
59
+ // surface, not source of truth) but the wire is in place.
60
+ const draftSync = useBookingDraft({
61
+ surface,
62
+ draftId: props.draftId,
63
+ enableLoad: false,
64
+ });
65
+ // Live quote — debounced 250ms.
66
+ const quote = useBookingQuote({
67
+ surface,
68
+ draft,
69
+ });
70
+ const shape = useBookingDraftShape({
71
+ surface,
72
+ quote: quote.data,
73
+ fallback: fallbackShape,
74
+ });
75
+ // Step navigation — only show steps the descriptor says are relevant.
76
+ const hideConfigure = props.hideConfigure === true;
77
+ const steps = useMemo(() => JOURNEY_STEP_ORDER.filter((s) => {
78
+ if (hideConfigure && s === "configure")
79
+ return false;
80
+ return isStepVisible(s, shape);
81
+ }), [shape, hideConfigure]);
82
+ const [currentStep, setCurrentStep] = useState(() => steps[0] ?? "configure");
83
+ const [visited, setVisited] = useState(() => new Set([steps[0] ?? "configure"]));
84
+ // If the descriptor changes and removes the current step, reset to
85
+ // the first available step. (Edge case: shape goes from
86
+ // owned→sourced and the relevant step set narrows.)
87
+ useEffect(() => {
88
+ if (!steps.includes(currentStep)) {
89
+ setCurrentStep(steps[0] ?? "configure");
90
+ }
91
+ }, [steps, currentStep]);
92
+ // PUT the draft to the server every time the user transitions
93
+ // steps. Keeps the recovery surface fresh without saving on every
94
+ // keystroke. The mutation reads the latest draft + quote from the
95
+ // closure on each fire — adding them to the deps array would defeat
96
+ // the "save on step change only" semantics.
97
+ // biome-ignore lint/correctness/useExhaustiveDependencies: intentional — fires on step transition only
98
+ useEffect(() => {
99
+ draftSync.save.mutate({
100
+ draft: { ...draft, quoteId: quote.data?.quoteId },
101
+ currentStep,
102
+ currentQuoteId: quote.data?.quoteId,
103
+ });
104
+ }, [currentStep]);
105
+ // Commit
106
+ const commit = useBookingCommit({
107
+ surface,
108
+ draftId: props.draftId,
109
+ onCommitted: props.onCommitted,
110
+ });
111
+ // Inventory hold — fired when the user advances past Configure
112
+ // with a slot + pax picked. Failures are non-blocking (the engine
113
+ // re-validates capacity at commit time anyway); we just don't
114
+ // want to silently let two shoppers race past Configure with one
115
+ // capacity unit between them.
116
+ const holdApi = useBookingHold({ surface });
117
+ const holdState = useRef({});
118
+ const holdSignature = makeHoldSignature(draft, props.entityModule, props.entityId);
119
+ // biome-ignore lint/correctness/useExhaustiveDependencies: signature change is the only trigger; refs + closure read latest values
120
+ useEffect(() => {
121
+ if (currentStep === "configure" || !holdSignature)
122
+ return;
123
+ if (holdState.current.signature === holdSignature)
124
+ return;
125
+ // Inquiry mode is the lead-form path — capture the lead without
126
+ // burning capacity. The operator follows up before any inventory
127
+ // is touched.
128
+ if (draft.payment.intent === "inquiry")
129
+ return;
130
+ const previousToken = holdState.current.holdToken;
131
+ holdState.current = { signature: holdSignature };
132
+ void holdApi
133
+ .place({
134
+ entityModule: props.entityModule,
135
+ entityId: props.entityId,
136
+ draftId: props.draftId,
137
+ parameters: {
138
+ slotId: draft.configure.departureSlotId,
139
+ paxCount: totalPax(draft),
140
+ productId: props.entityId,
141
+ },
142
+ })
143
+ .then((result) => {
144
+ holdState.current = { holdToken: result.holdToken, signature: holdSignature };
145
+ })
146
+ .catch(() => {
147
+ // Non-blocking — see comment above.
148
+ });
149
+ if (previousToken) {
150
+ void holdApi
151
+ .release({ entityModule: props.entityModule, holdToken: previousToken })
152
+ .catch(() => { });
153
+ }
154
+ }, [holdSignature, currentStep]);
155
+ const canAdvance = canAdvanceFromStep(currentStep, draft, shape, quote.data?.available !== false);
156
+ const warnings = warningsForStep(currentStep, draft, shape);
157
+ const idx = steps.indexOf(currentStep);
158
+ const next = steps[idx + 1];
159
+ const prev = steps[idx - 1];
160
+ const advance = () => {
161
+ if (!next || !canAdvance)
162
+ return;
163
+ setCurrentStep(next);
164
+ setVisited((s) => new Set(s).add(next));
165
+ };
166
+ const goBack = () => {
167
+ if (!prev)
168
+ return;
169
+ setCurrentStep(prev);
170
+ };
171
+ const jumpTo = (step) => {
172
+ if (!visited.has(step) && step !== currentStep)
173
+ return;
174
+ setCurrentStep(step);
175
+ };
176
+ const [contractDialogOpen, setContractDialogOpen] = useState(false);
177
+ // Tracks the multi-step storefront checkout flow (book →
178
+ // checkout-start → redirect). The legacy in-process commit has
179
+ // its own `commit.isPending` so the Confirm button merges both
180
+ // when deciding whether to show a spinner.
181
+ const [isHandlingCheckout, setIsHandlingCheckout] = useState(false);
182
+ const contractConfig = props.contract;
183
+ const contractVariables = useMemo(() => {
184
+ if (!contractConfig)
185
+ return {};
186
+ return contractConfig.resolveVariables({ draft, pricing: quote.data?.pricing ?? null });
187
+ }, [contractConfig, draft, quote.data?.pricing]);
188
+ const commitDraft = async () => {
189
+ if (!quote.data?.quoteId)
190
+ return;
191
+ await commit.mutateAsync({
192
+ draft: { ...draft, quoteId: quote.data.quoteId },
193
+ quoteId: quote.data.quoteId,
194
+ paymentIntent: { type: draft.payment.intent === "card" ? "card" : "hold" },
195
+ });
196
+ };
197
+ const handleAccepted = async (acceptance) => {
198
+ if (!props.onContractAccepted) {
199
+ await commitDraft();
200
+ return;
201
+ }
202
+ setIsHandlingCheckout(true);
203
+ try {
204
+ await props.onContractAccepted(acceptance, {
205
+ draft,
206
+ pricing: quote.data?.pricing ?? null,
207
+ quoteId: quote.data?.quoteId,
208
+ });
209
+ }
210
+ finally {
211
+ setIsHandlingCheckout(false);
212
+ }
213
+ };
214
+ const onConfirm = async () => {
215
+ if (!quote.data?.quoteId)
216
+ return;
217
+ // 1. Contract is wired → open the dialog. Acceptance triggers
218
+ // onContractAccepted (the storefront's checkout-start path).
219
+ // 2. No contract but onContractAccepted is wired → call it
220
+ // directly without an acceptance payload. The storefront uses
221
+ // this to drive the /book + /checkout/start handoff so card
222
+ // intents redirect to the PSP. Skipping the dialog when no
223
+ // template is configured is intentional — the dialog is an
224
+ // optional gate, not a required step.
225
+ // 3. Neither wired → in-process commit (the operator dashboard's
226
+ // legacy path).
227
+ if (contractConfig) {
228
+ setContractDialogOpen(true);
229
+ return;
230
+ }
231
+ await handleAccepted(null);
232
+ };
233
+ const onContractAccept = async (acceptance) => {
234
+ setContractDialogOpen(false);
235
+ await handleAccepted(acceptance);
236
+ };
237
+ return (_jsxs("div", { className: props.className, children: [_jsxs("div", { className: "grid grid-cols-1 gap-6 md:grid-cols-8 md:items-start", children: [_jsxs("div", { className: "space-y-6 md:col-span-5", children: [_jsx(StepHeader, { current: currentStep, visited: [...visited], steps: steps, shape: shape, onJumpTo: jumpTo }), currentStep === "configure" ? (_jsx(ConfigureStep, { draft: draft, setDraft: setDraft, shape: shape })) : null, currentStep === "billing" ? (_jsx(BillingStep, { draft: draft, setDraft: setDraft, shape: shape, renderLeadContactPicker: props.renderLeadContactPicker, renderExtras: props.renderBillingExtras })) : null, currentStep === "travelers" ? (_jsx(TravelersStep, { draft: draft, setDraft: setDraft, shape: shape, renderTravelerContactPicker: props.renderTravelerContactPicker })) : null, currentStep === "accommodation" ? (_jsx(AccommodationStep, { draft: draft, setDraft: setDraft, shape: shape })) : null, currentStep === "addons" ? (_jsx(AddonsStep, { draft: draft, setDraft: setDraft, shape: shape })) : null, currentStep === "payment" ? (_jsx(PaymentStep, { draft: draft, setDraft: setDraft, shape: shape, capabilities: props.paymentCapabilities ?? {
238
+ acceptsCard: false,
239
+ acceptsHold: true,
240
+ acceptsTicketOnCredit: false,
241
+ }, renderProviderStep: props.renderPaymentProviderStep })) : null, currentStep === "review" ? (_jsx(ReviewStep, { draft: draft, setDraft: setDraft, isCommitting: commit.isPending || isHandlingCheckout, onConfirm: onConfirm, renderExtras: props.renderReviewExtras, surface: surface })) : null, warnings.length > 0 ? (_jsx("ul", { className: "space-y-1 rounded border border-amber-300 bg-amber-50 p-3 text-amber-900 text-sm dark:border-amber-700 dark:bg-amber-950 dark:text-amber-100", children: warnings.map((w) => (_jsxs("li", { children: ["\u26A0 ", w] }, w))) })) : null, _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Button, { type: "button", variant: "outline", onClick: () => {
242
+ if (prev)
243
+ goBack();
244
+ else
245
+ props.onCancelled?.();
246
+ }, children: "Back" }), next ? (_jsx(Button, { type: "button", onClick: advance, disabled: !canAdvance, className: "ml-auto", children: "Next" })) : null] }), commit.error ? (_jsx("p", { className: "text-destructive text-sm", children: commit.error instanceof Error ? commit.error.message : String(commit.error) })) : null] }), _jsx("aside", { className: "md:sticky md:top-4 md:col-span-3", children: _jsx(PriceSidePanel, { pricing: quote.data?.pricing ?? null, isQuoting: quote.isQuoting, invalidReason: quote.data?.invalidReason, entitySummary: props.entitySummary, currentStep: currentStep, steps: steps, shape: shape, draft: draft, className: props.sidePanelClassName }) })] }), contractConfig ? (_jsx(ContractPreviewDialog, { open: contractDialogOpen, onOpenChange: setContractDialogOpen, previewUrl: contractConfig.previewUrl, acceptLanguage: contractConfig.acceptLanguage, variables: contractVariables, marketingLabel: contractConfig.marketingLabel, termsLabel: contractConfig.termsLabel, onAccept: onContractAccept })) : null] }));
247
+ }
248
+ function isStepVisible(step, shape) {
249
+ switch (step) {
250
+ case "configure":
251
+ return shape.showsConfigure;
252
+ case "billing":
253
+ return shape.showsBilling;
254
+ case "travelers":
255
+ return shape.showsTravelers;
256
+ case "accommodation":
257
+ return shape.showsAccommodation;
258
+ case "addons":
259
+ return shape.showsAddons;
260
+ case "payment":
261
+ return shape.showsPayment;
262
+ case "review":
263
+ return shape.showsReview;
264
+ }
265
+ }
266
+ function canAdvanceFromStep(step, draft, shape, available) {
267
+ if (!available)
268
+ return false;
269
+ switch (step) {
270
+ case "configure": {
271
+ const total = totalPax(draft);
272
+ return total >= shape.paxBandsAllowedTotal.min && total <= shape.paxBandsAllowedTotal.max;
273
+ }
274
+ case "billing": {
275
+ const c = draft.billing.contact;
276
+ return c.firstName.length > 0 && c.lastName.length > 0 && c.email.length > 0;
277
+ }
278
+ case "travelers": {
279
+ // Hard-reject only on canonical traveler fields (firstName,
280
+ // lastName) — those are always required regardless of
281
+ // descriptor configuration. All other required fields surface
282
+ // as warnings so operators can complete the journey and fill
283
+ // them in later from the booking detail page.
284
+ return draft.travelers.every((t) => t.firstName && t.lastName);
285
+ }
286
+ default:
287
+ return true;
288
+ }
289
+ }
290
+ /**
291
+ * Soft warnings for the current step — surfaced inline above the
292
+ * Next button. Don't block advancement; they're hints. Per
293
+ * booking-journey-architecture §12.5.
294
+ *
295
+ * The hard-reject path stays in `canAdvanceFromStep` for fields
296
+ * that are physically required to commit (e.g. traveler names);
297
+ * everything else is a warning here.
298
+ */
299
+ function warningsForStep(step, draft, shape) {
300
+ const warnings = [];
301
+ switch (step) {
302
+ case "billing": {
303
+ const c = draft.billing.contact;
304
+ if (c.phone == null || c.phone.length === 0) {
305
+ warnings.push("Phone number not set — useful for last-minute supplier contact.");
306
+ }
307
+ if (!draft.billing.address.country) {
308
+ warnings.push("Billing country not set — taxes won't compute until it's filled in.");
309
+ }
310
+ if (draft.billing.buyerType === "B2B" && !draft.billing.company?.vatId) {
311
+ warnings.push("VAT id missing — required for B2B reverse-charge invoicing.");
312
+ }
313
+ break;
314
+ }
315
+ case "travelers": {
316
+ const requiredKeys = shape.travelerFields.filter((f) => f.required).map((f) => f.key);
317
+ const skipBaseline = new Set(["firstName", "lastName"]);
318
+ const optionalRequired = requiredKeys.filter((k) => !skipBaseline.has(k));
319
+ for (const t of draft.travelers) {
320
+ for (const key of optionalRequired) {
321
+ const docs = t.documents ?? {};
322
+ // Email is on the row directly; everything else lives in
323
+ // the document map.
324
+ const value = key === "email" ? t.email : docs[key];
325
+ if (value == null || value === "") {
326
+ const traveler = `${t.firstName || "Traveler"} ${t.lastName || ""}`.trim();
327
+ warnings.push(`${traveler}: ${labelForFieldKey(key, shape)} is required.`);
328
+ }
329
+ }
330
+ }
331
+ break;
332
+ }
333
+ case "review": {
334
+ if (!draft.payment.intent) {
335
+ warnings.push("Payment intent not set — booking will default to hold.");
336
+ }
337
+ if (draft.travelers.length === 0) {
338
+ warnings.push("No travelers added — at least one is recommended for ops handoff.");
339
+ }
340
+ break;
341
+ }
342
+ }
343
+ return warnings;
344
+ }
345
+ function labelForFieldKey(key, shape) {
346
+ return shape.travelerFields.find((f) => f.key === key)?.label ?? key;
347
+ }
348
+ /**
349
+ * Compose a stable signature off the inputs the hold cares about.
350
+ * Includes entity + slot + pax so any change re-issues the hold;
351
+ * excludes billing / traveler details so cosmetic edits don't
352
+ * thrash the inventory layer.
353
+ */
354
+ function makeHoldSignature(draft, entityModule, entityId) {
355
+ const slot = draft.configure.departureSlotId;
356
+ if (!slot)
357
+ return null;
358
+ const pax = totalPax(draft);
359
+ if (pax <= 0)
360
+ return null;
361
+ return `${entityModule}/${entityId}/${slot}/${pax}`;
362
+ }
363
+ function defaultMinimalShape() {
364
+ return {
365
+ ...defaultDraftShapeFlags(),
366
+ paxBands: DEFAULT_PAX_BANDS,
367
+ paxBandsAllowedTotal: paxBandsAllowedTotalFrom(DEFAULT_PAX_BANDS),
368
+ travelerFields: defaultTravelerFields(),
369
+ bookingFields: defaultBookingFields(),
370
+ // Engine-level allow list. Capabilities (per-deployment toggles)
371
+ // narrow further at render time — listing every supported intent
372
+ // here means consumers can opt in via PaymentProviderCapabilities
373
+ // without needing a custom fallbackShape.
374
+ paymentIntents: ["card", "bank_transfer", "hold", "inquiry", "ticket_on_credit"],
375
+ };
376
+ }
@@ -0,0 +1,47 @@
1
+ import * as React from "react";
2
+ /**
3
+ * Contract preview dialog. Renders a contract template with the
4
+ * draft variables prefilled and gates the journey on two
5
+ * checkboxes — terms acceptance and an optional marketing opt-in.
6
+ *
7
+ * The dialog is template-driven: the storefront wires a
8
+ * `templateSlug` plus a `resolveVariables(draft)` function that maps
9
+ * the booking draft to the template's variable schema. The dialog
10
+ * fetches the rendered HTML from
11
+ * `POST /v1/public/legal/contracts/templates/by-slug/:slug/preview`
12
+ * and renders it in an iframe (sandboxed — same-origin removed) so
13
+ * inline styles in the contract HTML don't leak into the page.
14
+ */
15
+ export interface ContractPreviewDialogProps {
16
+ open: boolean;
17
+ onOpenChange: (open: boolean) => void;
18
+ /** Variables to interpolate into the template body. */
19
+ variables: Record<string, unknown>;
20
+ /**
21
+ * URL of the public render endpoint. The storefront wraps the
22
+ * journey and supplies an absolute URL so the dialog stays
23
+ * agnostic about where the API base lives.
24
+ */
25
+ previewUrl: string;
26
+ /** Optional Accept-Language header for locale resolution. */
27
+ acceptLanguage?: string;
28
+ /** Fired when the user clicks Accept after ticking the gates. */
29
+ onAccept: (acceptance: ContractAcceptance) => void;
30
+ /** Optional marketing-opt-in label. When omitted, marketing
31
+ * consent isn't required to accept. */
32
+ marketingLabel?: string;
33
+ termsLabel?: React.ReactNode;
34
+ }
35
+ export interface ContractAcceptance {
36
+ templateId: string;
37
+ templateSlug: string;
38
+ templateName: string;
39
+ acceptedTerms: true;
40
+ acceptedMarketing: boolean;
41
+ acceptedAt: string;
42
+ /** The exact rendered HTML the user accepted — captured for the
43
+ * audit trail so we can reproduce what they saw. */
44
+ renderedHtml: string;
45
+ }
46
+ export declare function ContractPreviewDialog({ open, onOpenChange, variables, previewUrl, acceptLanguage, onAccept, marketingLabel, termsLabel, }: ContractPreviewDialogProps): React.ReactElement;
47
+ //# sourceMappingURL=contract-preview-dialog.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"contract-preview-dialog.d.ts","sourceRoot":"","sources":["../../../src/journey/components/contract-preview-dialog.tsx"],"names":[],"mappings":"AAYA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAE9B;;;;;;;;;;;;GAYG;AACH,MAAM,WAAW,0BAA0B;IACzC,IAAI,EAAE,OAAO,CAAA;IACb,YAAY,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,IAAI,CAAA;IACrC,uDAAuD;IACvD,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;IAClC;;;;OAIG;IACH,UAAU,EAAE,MAAM,CAAA;IAClB,6DAA6D;IAC7D,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB,iEAAiE;IACjE,QAAQ,EAAE,CAAC,UAAU,EAAE,kBAAkB,KAAK,IAAI,CAAA;IAClD;4CACwC;IACxC,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB,UAAU,CAAC,EAAE,KAAK,CAAC,SAAS,CAAA;CAC7B;AAED,MAAM,WAAW,kBAAkB;IACjC,UAAU,EAAE,MAAM,CAAA;IAClB,YAAY,EAAE,MAAM,CAAA;IACpB,YAAY,EAAE,MAAM,CAAA;IACpB,aAAa,EAAE,IAAI,CAAA;IACnB,iBAAiB,EAAE,OAAO,CAAA;IAC1B,UAAU,EAAE,MAAM,CAAA;IAClB;yDACqD;IACrD,YAAY,EAAE,MAAM,CAAA;CACrB;AAgBD,wBAAgB,qBAAqB,CAAC,EACpC,IAAI,EACJ,YAAY,EACZ,SAAS,EACT,UAAU,EACV,cAAc,EACd,QAAQ,EACR,cAAc,EACd,UAAU,GACX,EAAE,0BAA0B,GAAG,KAAK,CAAC,YAAY,CA6JjD"}
@@ -0,0 +1,119 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { Button } from "@voyantjs/ui/components/button";
4
+ import { Checkbox } from "@voyantjs/ui/components/checkbox";
5
+ import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, } from "@voyantjs/ui/components/dialog";
6
+ import { Skeleton } from "@voyantjs/ui/components/skeleton";
7
+ import * as React from "react";
8
+ export function ContractPreviewDialog({ open, onOpenChange, variables, previewUrl, acceptLanguage, onAccept, marketingLabel, termsLabel, }) {
9
+ const [loadState, setLoadState] = React.useState("idle");
10
+ const [errorMessage, setErrorMessage] = React.useState(null);
11
+ const [renderedHtml, setRenderedHtml] = React.useState("");
12
+ const [template, setTemplate] = React.useState(null);
13
+ const [acceptedTerms, setAcceptedTerms] = React.useState(false);
14
+ // Marketing consent defaults to opted-in; the customer can untick
15
+ // before accepting. Terms still require an explicit tick.
16
+ const [acceptedMarketing, setAcceptedMarketing] = React.useState(true);
17
+ // Stringify the variables once so the effect re-fetches only on
18
+ // meaningful changes — equivalent to a deep-equality check, but
19
+ // cheap enough to run every render.
20
+ const variablesKey = React.useMemo(() => JSON.stringify(variables), [variables]);
21
+ const variablesRef = React.useRef(variables);
22
+ variablesRef.current = variables;
23
+ // biome-ignore lint/correctness/useExhaustiveDependencies: variablesKey is the meaningful-change signal; the actual variables are read off variablesRef so the effect doesn't re-fire on every render
24
+ React.useEffect(() => {
25
+ if (!open)
26
+ return;
27
+ let cancelled = false;
28
+ setLoadState("loading");
29
+ setErrorMessage(null);
30
+ setAcceptedTerms(false);
31
+ setAcceptedMarketing(true);
32
+ void fetch(previewUrl, {
33
+ method: "POST",
34
+ credentials: "include",
35
+ headers: {
36
+ "content-type": "application/json",
37
+ ...(acceptLanguage ? { "accept-language": acceptLanguage } : {}),
38
+ },
39
+ body: JSON.stringify({ variables: variablesRef.current }),
40
+ })
41
+ .then(async (res) => {
42
+ if (!res.ok) {
43
+ throw new Error(`Preview request failed: ${res.status}`);
44
+ }
45
+ const json = (await res.json());
46
+ if (cancelled)
47
+ return;
48
+ const body = json.data?.rendered ?? "";
49
+ const tmpl = json.data?.template;
50
+ if (!body || !tmpl) {
51
+ throw new Error("Preview response missing rendered body or template metadata");
52
+ }
53
+ setRenderedHtml(body);
54
+ setTemplate({ id: tmpl.id, slug: tmpl.slug, name: tmpl.name });
55
+ setLoadState("ready");
56
+ })
57
+ .catch((err) => {
58
+ if (cancelled)
59
+ return;
60
+ setErrorMessage(err instanceof Error ? err.message : String(err));
61
+ setLoadState("error");
62
+ });
63
+ return () => {
64
+ cancelled = true;
65
+ };
66
+ }, [open, previewUrl, acceptLanguage, variablesKey]);
67
+ const canAccept = loadState === "ready" && acceptedTerms && Boolean(template);
68
+ const handleAccept = () => {
69
+ if (!canAccept || !template)
70
+ return;
71
+ onAccept({
72
+ templateId: template.id,
73
+ templateSlug: template.slug,
74
+ templateName: template.name,
75
+ acceptedTerms: true,
76
+ acceptedMarketing,
77
+ acceptedAt: new Date().toISOString(),
78
+ renderedHtml,
79
+ });
80
+ };
81
+ return (_jsx(Dialog, { open: open, onOpenChange: onOpenChange, children: _jsxs(DialogContent, { className: "flex h-[85vh] w-[95vw] max-w-4xl flex-col gap-0 p-0", children: [_jsxs(DialogHeader, { className: "border-b p-6 pb-4", children: [_jsx(DialogTitle, { children: template?.name ?? "Booking contract" }), _jsx("p", { className: "text-muted-foreground text-sm", children: "Please review the contract below. You can scroll through the document, then tick the boxes and click Accept to continue." })] }), _jsxs("div", { className: "flex-1 overflow-hidden bg-muted/30", children: [loadState === "loading" ? (_jsxs("div", { className: "space-y-3 p-6", children: [_jsx(Skeleton, { className: "h-6 w-1/2" }), _jsx(Skeleton, { className: "h-4 w-full" }), _jsx(Skeleton, { className: "h-4 w-5/6" }), _jsx(Skeleton, { className: "h-4 w-4/5" }), _jsx(Skeleton, { className: "h-4 w-full" })] })) : null, loadState === "error" ? (_jsx("div", { className: "p-6", children: _jsxs("p", { className: "text-destructive text-sm", children: ["Couldn't load the contract preview: ", errorMessage] }) })) : null, loadState === "ready" ? (_jsx("iframe", { title: `${template?.name ?? "Contract"} preview`, srcDoc: wrapPreviewHtml(renderedHtml), sandbox: "", className: "h-full w-full border-0 bg-white" })) : null] }), _jsxs(DialogFooter, { className: "border-t p-6 sm:flex-col sm:items-stretch sm:gap-3", children: [_jsxs("div", { className: "space-y-2 text-sm", children: [_jsxs("label", { className: "flex items-start gap-2", children: [_jsx(Checkbox, { checked: acceptedTerms, onCheckedChange: (v) => setAcceptedTerms(v === true), className: "mt-0.5" }), _jsx("span", { children: termsLabel ?? (_jsx(_Fragment, { children: "I have read and agree to the terms of this contract. I understand that this booking is binding once accepted." })) })] }), _jsxs("label", { className: "flex items-start gap-2", children: [_jsx(Checkbox, { checked: acceptedMarketing, onCheckedChange: (v) => setAcceptedMarketing(v === true), className: "mt-0.5" }), _jsx("span", { children: marketingLabel ??
82
+ "I'd like to receive occasional travel offers and updates by email. (You can unsubscribe at any time.)" })] })] }), _jsxs("div", { className: "flex justify-end gap-2", children: [_jsx(Button, { type: "button", variant: "outline", onClick: () => onOpenChange(false), children: "Cancel" }), _jsx(Button, { type: "button", disabled: !canAccept, onClick: handleAccept, children: "Accept and continue" })] })] })] }) }));
83
+ }
84
+ /**
85
+ * Wrap the rendered template body in a self-contained light-theme HTML
86
+ * document. Templates author their own typography but rarely set a
87
+ * background, so without this the iframe inherits the browser default
88
+ * (transparent) and we'd see whatever shows through — which on the
89
+ * storefront's dark dialog reads as black-on-black.
90
+ */
91
+ function wrapPreviewHtml(body) {
92
+ return `<!DOCTYPE html>
93
+ <html lang="en">
94
+ <head>
95
+ <meta charset="utf-8" />
96
+ <style>
97
+ :root { color-scheme: light; }
98
+ html, body { margin: 0; background: #ffffff; color: #111827; }
99
+ body {
100
+ padding: 1.5rem 2rem;
101
+ font-family: ui-serif, Georgia, "Times New Roman", serif;
102
+ font-size: 15px;
103
+ line-height: 1.6;
104
+ }
105
+ h1, h2, h3 { font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; color: #0f172a; }
106
+ h1 { font-size: 1.5rem; margin: 0 0 1rem; }
107
+ h2 { font-size: 1.15rem; margin: 1.5rem 0 0.5rem; }
108
+ p { margin: 0.5rem 0; }
109
+ ul, ol { padding-left: 1.5rem; }
110
+ strong { color: #0f172a; }
111
+ a { color: #2563eb; }
112
+ table { border-collapse: collapse; width: 100%; margin: 0.75rem 0; }
113
+ th, td { border: 1px solid #e5e7eb; padding: 0.5rem 0.75rem; text-align: left; }
114
+ th { background: #f9fafb; }
115
+ </style>
116
+ </head>
117
+ <body>${body}</body>
118
+ </html>`;
119
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Step components rendered inside `<BookingJourney />`. Each takes a
3
+ * draft + setDraft pair plus the active descriptor; updates flow up
4
+ * via setDraft and the shell re-quotes on the next debounce tick.
5
+ *
6
+ * Per booking-journey-architecture §3.
7
+ */
8
+ import type { BookingDraftShape } from "@voyantjs/catalog/booking-engine";
9
+ import { type Draft } from "../lib/draft-state.js";
10
+ import type { LeadContactPickerProps, PaymentProviderCapabilities, PaymentProviderStepRenderProps, TravelerContactPickerProps } from "../types.js";
11
+ interface StepCommonProps {
12
+ draft: Draft;
13
+ setDraft: (next: Draft) => void;
14
+ shape: BookingDraftShape;
15
+ }
16
+ export declare function ConfigureStep({ draft, setDraft, shape, }: StepCommonProps & {
17
+ renderExtras?: () => React.ReactNode;
18
+ }): React.ReactElement;
19
+ export declare function BillingStep({ draft, setDraft, renderLeadContactPicker, renderExtras, }: StepCommonProps & {
20
+ renderLeadContactPicker?: (props: LeadContactPickerProps) => React.ReactNode;
21
+ renderExtras?: () => React.ReactNode;
22
+ }): React.ReactElement;
23
+ export declare function TravelersStep({ draft, setDraft, shape, renderTravelerContactPicker, }: StepCommonProps & {
24
+ renderTravelerContactPicker?: (props: TravelerContactPickerProps) => React.ReactNode;
25
+ }): React.ReactElement;
26
+ export declare function AccommodationStep({ draft, setDraft, shape }: StepCommonProps): React.ReactElement;
27
+ export declare function AddonsStep({ draft, setDraft, shape }: StepCommonProps): React.ReactElement;
28
+ export declare function PaymentStep({ draft, setDraft, shape, capabilities, renderProviderStep, }: StepCommonProps & {
29
+ capabilities: PaymentProviderCapabilities;
30
+ renderProviderStep?: (props: PaymentProviderStepRenderProps) => React.ReactNode;
31
+ }): React.ReactElement;
32
+ export declare function ReviewStep({ draft, setDraft, isCommitting, onConfirm, renderExtras, surface, }: {
33
+ draft: Draft;
34
+ setDraft: (next: Draft) => void;
35
+ isCommitting: boolean;
36
+ onConfirm: () => void;
37
+ renderExtras?: () => React.ReactNode;
38
+ /**
39
+ * Drives the notes field. Public storefronts collect
40
+ * customer-facing "anything we should know?" notes; operator
41
+ * surfaces collect operator-only internal notes. Defaults to
42
+ * `admin` so existing operator usage stays unchanged.
43
+ */
44
+ surface?: "admin" | "public";
45
+ }): React.ReactElement;
46
+ export {};
47
+ //# sourceMappingURL=journey-steps.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"journey-steps.d.ts","sourceRoot":"","sources":["../../../src/journey/components/journey-steps.tsx"],"names":[],"mappings":"AAEA;;;;;;GAMG;AAEH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,kCAAkC,CAAA;AAYzE,OAAO,EACL,KAAK,KAAK,EASX,MAAM,uBAAuB,CAAA;AAC9B,OAAO,KAAK,EACV,sBAAsB,EACtB,2BAA2B,EAC3B,8BAA8B,EAC9B,0BAA0B,EAC3B,MAAM,aAAa,CAAA;AAEpB,UAAU,eAAe;IACvB,KAAK,EAAE,KAAK,CAAA;IACZ,QAAQ,EAAE,CAAC,IAAI,EAAE,KAAK,KAAK,IAAI,CAAA;IAC/B,KAAK,EAAE,iBAAiB,CAAA;CACzB;AAMD,wBAAgB,aAAa,CAAC,EAC5B,KAAK,EACL,QAAQ,EACR,KAAK,GACN,EAAE,eAAe,GAAG;IACnB,YAAY,CAAC,EAAE,MAAM,KAAK,CAAC,SAAS,CAAA;CACrC,GAAG,KAAK,CAAC,YAAY,CAYrB;AA+UD,wBAAgB,WAAW,CAAC,EAC1B,KAAK,EACL,QAAQ,EACR,uBAAuB,EACvB,YAAY,GACb,EAAE,eAAe,GAAG;IACnB,uBAAuB,CAAC,EAAE,CAAC,KAAK,EAAE,sBAAsB,KAAK,KAAK,CAAC,SAAS,CAAA;IAC5E,YAAY,CAAC,EAAE,MAAM,KAAK,CAAC,SAAS,CAAA;CACrC,GAAG,KAAK,CAAC,YAAY,CA8JrB;AAMD,wBAAgB,aAAa,CAAC,EAC5B,KAAK,EACL,QAAQ,EACR,KAAK,EACL,2BAA2B,GAC5B,EAAE,eAAe,GAAG;IACnB,2BAA2B,CAAC,EAAE,CAAC,KAAK,EAAE,0BAA0B,KAAK,KAAK,CAAC,SAAS,CAAA;CACrF,GAAG,KAAK,CAAC,YAAY,CAiFrB;AAmTD,wBAAgB,iBAAiB,CAAC,EAAE,KAAK,EAAE,QAAQ,EAAE,KAAK,EAAE,EAAE,eAAe,GAAG,KAAK,CAAC,YAAY,CAmGjG;AA6DD,wBAAgB,UAAU,CAAC,EAAE,KAAK,EAAE,QAAQ,EAAE,KAAK,EAAE,EAAE,eAAe,GAAG,KAAK,CAAC,YAAY,CA8C1F;AAyDD,wBAAgB,WAAW,CAAC,EAC1B,KAAK,EACL,QAAQ,EACR,KAAK,EACL,YAAY,EACZ,kBAAkB,GACnB,EAAE,eAAe,GAAG;IACnB,YAAY,EAAE,2BAA2B,CAAA;IACzC,kBAAkB,CAAC,EAAE,CAAC,KAAK,EAAE,8BAA8B,KAAK,KAAK,CAAC,SAAS,CAAA;CAChF,GAAG,KAAK,CAAC,YAAY,CAuFrB;AA8ED,wBAAgB,UAAU,CAAC,EACzB,KAAK,EACL,QAAQ,EACR,YAAY,EACZ,SAAS,EACT,YAAY,EACZ,OAAO,GACR,EAAE;IACD,KAAK,EAAE,KAAK,CAAA;IACZ,QAAQ,EAAE,CAAC,IAAI,EAAE,KAAK,KAAK,IAAI,CAAA;IAC/B,YAAY,EAAE,OAAO,CAAA;IACrB,SAAS,EAAE,MAAM,IAAI,CAAA;IACrB,YAAY,CAAC,EAAE,MAAM,KAAK,CAAC,SAAS,CAAA;IACpC;;;;;OAKG;IACH,OAAO,CAAC,EAAE,OAAO,GAAG,QAAQ,CAAA;CAC7B,GAAG,KAAK,CAAC,YAAY,CA2DrB"}