@voyant-travel/bookings-react 0.151.5 → 0.153.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.
- package/README.md +6 -1
- package/dist/admin/booking-detail-host.d.ts +12 -12
- package/dist/admin/booking-detail-host.d.ts.map +1 -1
- package/dist/admin/booking-detail-host.js +12 -5
- package/dist/admin/index.d.ts +5 -26
- package/dist/admin/index.d.ts.map +1 -1
- package/dist/admin/index.js +11 -25
- package/dist/admin/pages/booking-detail-page.d.ts +3 -5
- package/dist/admin/pages/booking-detail-page.d.ts.map +1 -1
- package/dist/admin/pages/booking-detail-page.js +3 -5
- package/dist/admin/pages/bookings-index-page.d.ts +2 -12
- package/dist/admin/pages/bookings-index-page.d.ts.map +1 -1
- package/dist/admin/pages/bookings-index-page.js +4 -3
- package/dist/admin/slots.d.ts +7 -0
- package/dist/admin/slots.d.ts.map +1 -1
- package/dist/admin/slots.js +7 -0
- package/dist/hooks/use-public-booking-session-flow-mutation.d.ts +1 -1
- package/dist/hooks/use-public-booking-session.d.ts +1 -1
- package/dist/query-options.d.ts +4 -4
- package/dist/storefront/index.d.ts +4 -0
- package/dist/storefront/index.d.ts.map +1 -0
- package/dist/storefront/index.js +3 -0
- package/dist/storefront/resolve-contract-variables.d.ts +131 -0
- package/dist/storefront/resolve-contract-variables.d.ts.map +1 -0
- package/dist/storefront/resolve-contract-variables.js +492 -0
- package/dist/storefront/storefront-booking-errors.d.ts +13 -0
- package/dist/storefront/storefront-booking-errors.d.ts.map +1 -0
- package/dist/storefront/storefront-booking-errors.js +50 -0
- package/dist/storefront/storefront-booking-journey.d.ts +72 -0
- package/dist/storefront/storefront-booking-journey.d.ts.map +1 -0
- package/dist/storefront/storefront-booking-journey.js +380 -0
- package/dist/storefront/storefront-booking-page.d.ts +36 -0
- package/dist/storefront/storefront-booking-page.d.ts.map +1 -0
- package/dist/storefront/storefront-booking-page.js +213 -0
- package/package.json +59 -24
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
const SPACE = String.fromCharCode(32);
|
|
2
|
+
const PERIOD = String.fromCharCode(46);
|
|
3
|
+
const SENTENCE_SEPARATOR = PERIOD + SPACE;
|
|
4
|
+
export function buildStorefrontBookFailureMessage(body, requestId, fallback, requestReferenceTemplate) {
|
|
5
|
+
const message = firstString(body.error) ?? firstFieldError(body.details);
|
|
6
|
+
const reference = firstString(body.requestId) ?? requestId;
|
|
7
|
+
const withMessage = message ? appendSentence(fallback, message) : fallback;
|
|
8
|
+
return reference
|
|
9
|
+
? appendSentence(withMessage, requestReferenceTemplate.replace("{requestId}", reference))
|
|
10
|
+
: withMessage;
|
|
11
|
+
}
|
|
12
|
+
function appendSentence(base, sentence) {
|
|
13
|
+
const trimmedBase = base.trim();
|
|
14
|
+
const trimmedSentence = sentence.trim();
|
|
15
|
+
if (!trimmedSentence)
|
|
16
|
+
return trimmedBase;
|
|
17
|
+
const separator = /[.!?]$/.test(trimmedBase) ? SPACE : SENTENCE_SEPARATOR;
|
|
18
|
+
const suffix = /[.!?]$/.test(trimmedSentence) ? trimmedSentence : trimmedSentence + PERIOD;
|
|
19
|
+
return `${trimmedBase}${separator}${suffix}`;
|
|
20
|
+
}
|
|
21
|
+
function firstFieldError(details) {
|
|
22
|
+
const record = asObject(details);
|
|
23
|
+
const fields = asObject(record?.fields) ?? record;
|
|
24
|
+
const fieldErrors = asObject(fields?.fieldErrors);
|
|
25
|
+
if (!fieldErrors)
|
|
26
|
+
return null;
|
|
27
|
+
for (const value of Object.values(fieldErrors)) {
|
|
28
|
+
const message = firstString(value);
|
|
29
|
+
if (message)
|
|
30
|
+
return message;
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
function firstString(value) {
|
|
35
|
+
if (typeof value === "string")
|
|
36
|
+
return value.trim() || null;
|
|
37
|
+
if (Array.isArray(value)) {
|
|
38
|
+
for (const item of value) {
|
|
39
|
+
const message = firstString(item);
|
|
40
|
+
if (message)
|
|
41
|
+
return message;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
function asObject(value) {
|
|
47
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
48
|
+
? value
|
|
49
|
+
: null;
|
|
50
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { type BookingEntitySummary, type BookingJourneyCheckoutContext, type ContractAcceptanceEvent, type Draft } from "../journey/index.js";
|
|
2
|
+
import { type ContractSourceContext } from "./resolve-contract-variables.js";
|
|
3
|
+
export interface StorefrontBookingJourneyMessages {
|
|
4
|
+
checkoutFailed: string;
|
|
5
|
+
requestReference: string;
|
|
6
|
+
reserveFailed: string;
|
|
7
|
+
}
|
|
8
|
+
export interface StorefrontBookingJourneyScope {
|
|
9
|
+
marketId?: string;
|
|
10
|
+
locale?: string;
|
|
11
|
+
currency?: string;
|
|
12
|
+
}
|
|
13
|
+
export type StorefrontCheckoutConfirmationKind = "bank_transfer" | "card_pending" | "hold" | "inquiry";
|
|
14
|
+
export interface StorefrontBookingJourneyProps {
|
|
15
|
+
entityModule: string;
|
|
16
|
+
entityId: string;
|
|
17
|
+
/**
|
|
18
|
+
* Source provenance — optional on the storefront. When absent,
|
|
19
|
+
* the public engine route resolves it from
|
|
20
|
+
* `(entityModule, entityId)` via the catalog plane's
|
|
21
|
+
* sourced-entry lookup. Admin surfaces still pass it explicitly
|
|
22
|
+
* via the packaged `<BookingJourneyHost />`
|
|
23
|
+
* (`@voyant-travel/bookings-react/admin/booking-journey-host`).
|
|
24
|
+
*/
|
|
25
|
+
sourceKind?: string;
|
|
26
|
+
sourceConnectionId?: string;
|
|
27
|
+
sourceRef?: string;
|
|
28
|
+
draftId: string;
|
|
29
|
+
/** Pre-locked configure inputs (departure / sailing / cabin /
|
|
30
|
+
* date-range / pax) collected on the detail page. */
|
|
31
|
+
initialConfigure: Record<string, unknown>;
|
|
32
|
+
/** Pre-locked accommodation slice (room/rate for accommodations). */
|
|
33
|
+
initialAccommodation?: Record<string, unknown>;
|
|
34
|
+
/** Optional summary of the entity being booked — surfaces in the
|
|
35
|
+
* side panel so the customer keeps context while filling out the
|
|
36
|
+
* journey. */
|
|
37
|
+
entitySummary?: BookingEntitySummary;
|
|
38
|
+
/**
|
|
39
|
+
* Resolved source provenance for the booked entity — kind /
|
|
40
|
+
* connection / ref + supplier. The `/book` route reads it off the
|
|
41
|
+
* public content endpoint's `provenance` and passes it here so the
|
|
42
|
+
* contract preview's `booking.source` block reflects sourced
|
|
43
|
+
* inventory instead of defaulting to owned/blank (voyant#2619).
|
|
44
|
+
*/
|
|
45
|
+
entitySource?: ContractSourceContext;
|
|
46
|
+
/**
|
|
47
|
+
* Per-product override for the contract template. When unset (the
|
|
48
|
+
* default), the storefront resolves the active customer-scope
|
|
49
|
+
* template via /v1/public/legal/contracts/templates/default —
|
|
50
|
+
* whichever one the operator has marked as the customer default
|
|
51
|
+
* in the legal admin. Set this when a specific product/cruise/
|
|
52
|
+
* hotel needs a non-default contract.
|
|
53
|
+
*/
|
|
54
|
+
contractTemplateSlug?: string;
|
|
55
|
+
/** Optional marketing-opt-in label — when set, an extra checkbox
|
|
56
|
+
* is rendered in the contract dialog. */
|
|
57
|
+
contractMarketingLabel?: string;
|
|
58
|
+
/** Fired after the user accepts the contract (or, when no
|
|
59
|
+
* template is configured, when they click Confirm on Review).
|
|
60
|
+
* The route handles the actual checkout-start dispatch + redirect
|
|
61
|
+
* to Netopia / bank-transfer instructions. */
|
|
62
|
+
onContractAccepted?: (acceptance: ContractAcceptanceEvent | null, context: BookingJourneyCheckoutContext) => void | Promise<void>;
|
|
63
|
+
messages?: StorefrontBookingJourneyMessages;
|
|
64
|
+
scope?: StorefrontBookingJourneyScope;
|
|
65
|
+
onNavigateToShop: () => void;
|
|
66
|
+
onNavigateToConfirmation: (bookingId: string, kind?: StorefrontCheckoutConfirmationKind) => void;
|
|
67
|
+
onRedirectToPayment?: (url: string) => void;
|
|
68
|
+
className?: string;
|
|
69
|
+
}
|
|
70
|
+
export declare function StorefrontBookingJourney({ entityModule, entityId, sourceKind, sourceConnectionId, sourceRef, draftId, initialConfigure, initialAccommodation, entitySummary, entitySource, contractTemplateSlug, contractMarketingLabel, onContractAccepted, messages, scope, onNavigateToShop, onNavigateToConfirmation, onRedirectToPayment, className, }: StorefrontBookingJourneyProps): React.ReactElement;
|
|
71
|
+
export declare function buildStorefrontCommitParty(draft: Draft): Record<string, unknown>;
|
|
72
|
+
//# sourceMappingURL=storefront-booking-journey.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"storefront-booking-journey.d.ts","sourceRoot":"","sources":["../../src/storefront/storefront-booking-journey.tsx"],"names":[],"mappings":"AAuBA,OAAO,EACL,KAAK,oBAAoB,EAEzB,KAAK,6BAA6B,EAElC,KAAK,uBAAuB,EAC5B,KAAK,KAAK,EACX,MAAM,qBAAqB,CAAA;AAC5B,OAAO,EACL,KAAK,qBAAqB,EAG3B,MAAM,iCAAiC,CAAA;AAmBxC,MAAM,WAAW,gCAAgC;IAC/C,cAAc,EAAE,MAAM,CAAA;IACtB,gBAAgB,EAAE,MAAM,CAAA;IACxB,aAAa,EAAE,MAAM,CAAA;CACtB;AAED,MAAM,WAAW,6BAA6B;IAC5C,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,QAAQ,CAAC,EAAE,MAAM,CAAA;CAClB;AAED,MAAM,MAAM,kCAAkC,GAC1C,eAAe,GACf,cAAc,GACd,MAAM,GACN,SAAS,CAAA;AAQb,MAAM,WAAW,6BAA6B;IAC5C,YAAY,EAAE,MAAM,CAAA;IACpB,QAAQ,EAAE,MAAM,CAAA;IAChB;;;;;;;OAOG;IACH,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,kBAAkB,CAAC,EAAE,MAAM,CAAA;IAC3B,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,OAAO,EAAE,MAAM,CAAA;IACf;0DACsD;IACtD,gBAAgB,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;IACzC,qEAAqE;IACrE,oBAAoB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;IAC9C;;mBAEe;IACf,aAAa,CAAC,EAAE,oBAAoB,CAAA;IACpC;;;;;;OAMG;IACH,YAAY,CAAC,EAAE,qBAAqB,CAAA;IACpC;;;;;;;OAOG;IACH,oBAAoB,CAAC,EAAE,MAAM,CAAA;IAC7B;8CAC0C;IAC1C,sBAAsB,CAAC,EAAE,MAAM,CAAA;IAC/B;;;mDAG+C;IAC/C,kBAAkB,CAAC,EAAE,CACnB,UAAU,EAAE,uBAAuB,GAAG,IAAI,EAC1C,OAAO,EAAE,6BAA6B,KACnC,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IACzB,QAAQ,CAAC,EAAE,gCAAgC,CAAA;IAC3C,KAAK,CAAC,EAAE,6BAA6B,CAAA;IACrC,gBAAgB,EAAE,MAAM,IAAI,CAAA;IAC5B,wBAAwB,EAAE,CAAC,SAAS,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,kCAAkC,KAAK,IAAI,CAAA;IAChG,mBAAmB,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAA;IAC3C,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB;AAED,wBAAgB,wBAAwB,CAAC,EACvC,YAAY,EACZ,QAAQ,EACR,UAAU,EACV,kBAAkB,EAClB,SAAS,EACT,OAAO,EACP,gBAAgB,EAChB,oBAAoB,EACpB,aAAa,EACb,YAAY,EACZ,oBAAoB,EACpB,sBAAsB,EACtB,kBAAkB,EAClB,QAA0B,EAC1B,KAAU,EACV,gBAAgB,EAChB,wBAAwB,EACxB,mBAA0D,EAC1D,SAAS,GACV,EAAE,6BAA6B,GAAG,KAAK,CAAC,YAAY,CAkRpD;AASD,wBAAgB,0BAA0B,CAAC,KAAK,EAAE,KAAK,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAgChF"}
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
/**
|
|
4
|
+
* Storefront-flavored wrapper around `<BookingJourney />` —
|
|
5
|
+
* customer-facing, no CRM picker, B2C billing default, post-commit
|
|
6
|
+
* navigation to a confirmation page.
|
|
7
|
+
*
|
|
8
|
+
* Uses `surface="public"` so the engine hits `/v1/public/catalog/*`.
|
|
9
|
+
* Per booking-journey-architecture §8.1 + §10 Phase B.
|
|
10
|
+
*
|
|
11
|
+
* Package-owned so Node starters and dedicated storefronts share the same
|
|
12
|
+
* checkout behavior while route trees remain application-owned.
|
|
13
|
+
*/
|
|
14
|
+
import { useQuery } from "@tanstack/react-query";
|
|
15
|
+
import { computePaymentSchedule, noDepositPolicy, } from "@voyant-travel/finance/payment-policy";
|
|
16
|
+
import { useVoyantReactContext } from "@voyant-travel/react";
|
|
17
|
+
import { BookingJourney, } from "../journey/index.js";
|
|
18
|
+
import { resolveContractVariables, } from "./resolve-contract-variables.js";
|
|
19
|
+
import { buildStorefrontBookFailureMessage, } from "./storefront-booking-errors.js";
|
|
20
|
+
/**
|
|
21
|
+
* Marker for checkout failures we've already turned into a localized,
|
|
22
|
+
* customer-facing message. Native errors (a `fetch` network drop, a
|
|
23
|
+
* `Response.json()` parse of an HTML 502) are NOT this type, so the outer catch
|
|
24
|
+
* can wrap them in the generic message instead of showing raw browser/parser
|
|
25
|
+
* text to the shopper (voyant#2638).
|
|
26
|
+
*/
|
|
27
|
+
class CheckoutError extends Error {
|
|
28
|
+
}
|
|
29
|
+
const defaultMessages = {
|
|
30
|
+
checkoutFailed: "We couldn't complete your booking. Please review your selection or try again.",
|
|
31
|
+
requestReference: "Reference: {requestId}",
|
|
32
|
+
reserveFailed: "This selection is no longer available. Adjust it and try again.",
|
|
33
|
+
};
|
|
34
|
+
export function StorefrontBookingJourney({ entityModule, entityId, sourceKind, sourceConnectionId, sourceRef, draftId, initialConfigure, initialAccommodation, entitySummary, entitySource, contractTemplateSlug, contractMarketingLabel, onContractAccepted, messages = defaultMessages, scope = {}, onNavigateToShop, onNavigateToConfirmation, onRedirectToPayment = (url) => window.location.assign(url), className, }) {
|
|
35
|
+
const { baseUrl, fetcher } = useVoyantReactContext();
|
|
36
|
+
// Carry the shopper's selected market/currency/locale (voyant#2643) into the
|
|
37
|
+
// journey's live quote so checkout prices in the same scope as browse/detail,
|
|
38
|
+
// not the default. The `(storefront)` layout provides the scope; unselected
|
|
39
|
+
// fields stay undefined and the quote falls back to the surface default.
|
|
40
|
+
// Resolve the contract template the journey will preview. The
|
|
41
|
+
// per-product override wins when set; otherwise we fetch
|
|
42
|
+
// whatever the operator marked as the active customer-scope
|
|
43
|
+
// template in `legal/contract_templates`. A 404 means no template
|
|
44
|
+
// has been seeded — the journey skips the preview dialog and
|
|
45
|
+
// commits without a contract.
|
|
46
|
+
const resolvedSlug = useResolvedContractSlug(contractTemplateSlug, baseUrl, fetcher);
|
|
47
|
+
const operatorProfile = usePublicOperatorProfile(baseUrl, fetcher);
|
|
48
|
+
const resolvedPolicy = useResolvedPaymentPolicy(entityModule, entityId, baseUrl, fetcher);
|
|
49
|
+
// Storefront-specific slot wiring. NO CRM picker — customers fill
|
|
50
|
+
// an inline contact form, which is the BookingJourney's default
|
|
51
|
+
// when `renderLeadContactPicker` is absent. Operators who later
|
|
52
|
+
// sign in could swap to the CRM picker mid-journey via an
|
|
53
|
+
// upgrade-path hook (Phase E follow-up).
|
|
54
|
+
const slots = {
|
|
55
|
+
onCommitted(result) {
|
|
56
|
+
onNavigateToConfirmation(result.bookingId);
|
|
57
|
+
},
|
|
58
|
+
onCancelled() {
|
|
59
|
+
onNavigateToShop();
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
// Default checkout-start handler — when the caller doesn't supply
|
|
63
|
+
// its own `onContractAccepted`, we run the standard storefront
|
|
64
|
+
// checkout flow:
|
|
65
|
+
//
|
|
66
|
+
// 1. POST /v1/public/catalog/book with the draft id → bookingId
|
|
67
|
+
// 2. POST /v1/public/catalog/checkout/start with the bookingId,
|
|
68
|
+
// the payment intent, and the captured acceptance.
|
|
69
|
+
// 3. Route the customer based on the response: card → 302 to
|
|
70
|
+
// Netopia; bank_transfer → instructions page; inquiry →
|
|
71
|
+
// thanks page.
|
|
72
|
+
//
|
|
73
|
+
const defaultCheckoutHandler = async (acceptance, context) => {
|
|
74
|
+
try {
|
|
75
|
+
const intent = checkoutIntentFromDraft(context.draft.payment.intent);
|
|
76
|
+
const contact = context.draft.billing?.contact;
|
|
77
|
+
const payerName = [contact?.firstName, contact?.lastName].filter(Boolean).join(" ").trim();
|
|
78
|
+
const idempotencyKey = `bj-${draftId}-${acceptance?.acceptedAt ?? "noaccept"}`;
|
|
79
|
+
// Step 1 — book the entity. Send the live scoped quote id explicitly
|
|
80
|
+
// (the server prefers `quoteId` over resolving the draft's stored
|
|
81
|
+
// `currentQuoteId`), so a market/currency change made mid-journey books
|
|
82
|
+
// the price the shopper is actually looking at rather than a stale one
|
|
83
|
+
// (voyant#2643). Falls back to draft resolution when no live quote yet.
|
|
84
|
+
const bookRes = await fetcher(apiPath(baseUrl, "/v1/public/catalog/book"), {
|
|
85
|
+
method: "POST",
|
|
86
|
+
credentials: "include",
|
|
87
|
+
headers: { "content-type": "application/json" },
|
|
88
|
+
body: JSON.stringify({
|
|
89
|
+
draftId,
|
|
90
|
+
quoteId: context.quoteId,
|
|
91
|
+
party: buildStorefrontCommitParty(context.draft),
|
|
92
|
+
paymentIntent: { type: "hold" },
|
|
93
|
+
idempotencyKey,
|
|
94
|
+
}),
|
|
95
|
+
});
|
|
96
|
+
if (!bookRes.ok) {
|
|
97
|
+
const errBody = (await bookRes.json().catch(() => ({})));
|
|
98
|
+
console.error("[storefront] /book failed", errBody);
|
|
99
|
+
// Reserve failures (e.g. 502 RESERVE_FAILED with reason
|
|
100
|
+
// "rates_missing") must reach the customer — throw so the journey
|
|
101
|
+
// surfaces a visible error instead of silently dropping back to
|
|
102
|
+
// Review (voyant#2638). A missing-rate / availability reason gets the
|
|
103
|
+
// "adjust your selection" copy; everything else the generic message.
|
|
104
|
+
// The engine error serializer nests the upstream payload under
|
|
105
|
+
// `context.upstreamPayload` (ReserveFailedError), not at top level.
|
|
106
|
+
const reason = typeof errBody.context?.upstreamPayload?.reason === "string"
|
|
107
|
+
? errBody.context.upstreamPayload.reason
|
|
108
|
+
: undefined;
|
|
109
|
+
throw new CheckoutError(reason === "rates_missing"
|
|
110
|
+
? messages.reserveFailed
|
|
111
|
+
: buildStorefrontBookFailureMessage(errBody, bookRes.headers.get("x-request-id"), messages.checkoutFailed, messages.requestReference));
|
|
112
|
+
}
|
|
113
|
+
const bookJson = (await bookRes.json());
|
|
114
|
+
const bookingId = bookJson.bookingId;
|
|
115
|
+
if (!bookingId) {
|
|
116
|
+
console.error("[storefront] /book returned no bookingId", bookJson);
|
|
117
|
+
throw new CheckoutError(messages.checkoutFailed);
|
|
118
|
+
}
|
|
119
|
+
// Step 2 — start checkout with the payment method selected in
|
|
120
|
+
// the journey's Payment step. Card goes to the PSP; bank
|
|
121
|
+
// transfer returns IBAN/reference instructions; inquiry skips
|
|
122
|
+
// inventory/payment.
|
|
123
|
+
const startRes = await fetcher(apiPath(baseUrl, "/v1/public/catalog/checkout/start"), {
|
|
124
|
+
method: "POST",
|
|
125
|
+
credentials: "include",
|
|
126
|
+
headers: { "content-type": "application/json" },
|
|
127
|
+
body: JSON.stringify({
|
|
128
|
+
bookingId,
|
|
129
|
+
paymentIntent: intent,
|
|
130
|
+
...(acceptance
|
|
131
|
+
? {
|
|
132
|
+
contractAcceptance: {
|
|
133
|
+
templateId: acceptance.templateId,
|
|
134
|
+
templateSlug: acceptance.templateSlug,
|
|
135
|
+
acceptedTerms: true,
|
|
136
|
+
acceptedMarketing: acceptance.acceptedMarketing,
|
|
137
|
+
acceptedAt: acceptance.acceptedAt,
|
|
138
|
+
renderedHtml: acceptance.renderedHtml,
|
|
139
|
+
},
|
|
140
|
+
}
|
|
141
|
+
: {}),
|
|
142
|
+
...(contact?.email ? { payerEmail: contact.email } : {}),
|
|
143
|
+
...(payerName ? { payerName } : {}),
|
|
144
|
+
returnOrigin: window.location.origin,
|
|
145
|
+
}),
|
|
146
|
+
});
|
|
147
|
+
const json = (await startRes.json());
|
|
148
|
+
if ("error" in json) {
|
|
149
|
+
console.error("[storefront] /checkout/start error", json);
|
|
150
|
+
throw new CheckoutError(messages.checkoutFailed);
|
|
151
|
+
}
|
|
152
|
+
switch (json.kind) {
|
|
153
|
+
case "card_redirect":
|
|
154
|
+
if (json.redirectUrl) {
|
|
155
|
+
onRedirectToPayment(json.redirectUrl);
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
onNavigateToConfirmation(json.bookingId, "card_pending");
|
|
159
|
+
}
|
|
160
|
+
break;
|
|
161
|
+
case "bank_transfer_instructions":
|
|
162
|
+
// Hand the instructions to the confirmation page so the
|
|
163
|
+
// customer leaves with the IBAN + reference visible. Stored
|
|
164
|
+
// in sessionStorage to keep the URL clean — refreshing the
|
|
165
|
+
// page replays the same instructions.
|
|
166
|
+
if (typeof sessionStorage !== "undefined") {
|
|
167
|
+
sessionStorage.setItem(`voyant.checkout.${json.bookingId}`, JSON.stringify(json));
|
|
168
|
+
}
|
|
169
|
+
onNavigateToConfirmation(json.bookingId, "bank_transfer");
|
|
170
|
+
break;
|
|
171
|
+
case "inquiry_received":
|
|
172
|
+
onNavigateToConfirmation(json.bookingId, "inquiry");
|
|
173
|
+
break;
|
|
174
|
+
case "hold_placed":
|
|
175
|
+
onNavigateToConfirmation(json.bookingId, "hold");
|
|
176
|
+
break;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
catch (err) {
|
|
180
|
+
console.error("[storefront] checkout flow failed", err);
|
|
181
|
+
// Re-throw so <BookingJourney /> can render a visible checkout error
|
|
182
|
+
// (voyant#2638). Preserve our own localized CheckoutError messages; wrap
|
|
183
|
+
// anything else (native fetch/network error, JSON parse of an HTML 502)
|
|
184
|
+
// in the generic message so raw browser/parser text never reaches the UI.
|
|
185
|
+
throw err instanceof CheckoutError ? err : new Error(messages.checkoutFailed);
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
return (_jsx(BookingJourney, { surface: "public", scope: { market: scope.marketId, locale: scope.locale, currency: scope.currency }, entityModule: entityModule, entityId: entityId, sourceKind: sourceKind, sourceConnectionId: sourceConnectionId, sourceRef: sourceRef, draftId: draftId, defaultBuyerType: "B2C", hideConfigure: true, initialConfigure: initialConfigure, initialAccommodation: initialAccommodation, entitySummary: entitySummary, contract: resolvedSlug
|
|
189
|
+
? {
|
|
190
|
+
templateSlug: resolvedSlug,
|
|
191
|
+
previewUrl: apiPath(baseUrl, `/v1/public/legal/contracts/templates/by-slug/${encodeURIComponent(resolvedSlug)}/preview`),
|
|
192
|
+
acceptLanguage: typeof navigator !== "undefined" ? navigator.language : undefined,
|
|
193
|
+
resolveVariables: ({ draft, pricing }) => {
|
|
194
|
+
// Use the server-resolved cascade when the public
|
|
195
|
+
// /v1/public/payment-policy/resolve endpoint has
|
|
196
|
+
// come back; while the request is in flight, fall
|
|
197
|
+
// back to the operator default so the customer sees
|
|
198
|
+
// a sensible preview rather than an empty schedule.
|
|
199
|
+
const policy = resolvedPolicy?.policy ??
|
|
200
|
+
operatorProfile?.customerPaymentPolicy ??
|
|
201
|
+
noDepositPolicy;
|
|
202
|
+
const source = resolvedPolicy?.source ?? "operator_default";
|
|
203
|
+
const schedule = pricing
|
|
204
|
+
? computePaymentSchedule({
|
|
205
|
+
totalCents: pricing.total,
|
|
206
|
+
currency: pricing.currency,
|
|
207
|
+
departureDate: entitySummary?.startDate ?? null,
|
|
208
|
+
}, policy)
|
|
209
|
+
: [];
|
|
210
|
+
return resolveContractVariables(draft, {
|
|
211
|
+
entityModule,
|
|
212
|
+
entityId,
|
|
213
|
+
entitySummary,
|
|
214
|
+
pricing,
|
|
215
|
+
operatorInfo: operatorProfile,
|
|
216
|
+
paymentSchedule: schedule,
|
|
217
|
+
paymentPolicySource: source,
|
|
218
|
+
source: entitySource,
|
|
219
|
+
});
|
|
220
|
+
},
|
|
221
|
+
...(contractMarketingLabel ? { marketingLabel: contractMarketingLabel } : {}),
|
|
222
|
+
}
|
|
223
|
+
: undefined, onContractAccepted: onContractAccepted ?? defaultCheckoutHandler, paymentCapabilities: {
|
|
224
|
+
// Storefront defaults — covers the three customer-facing
|
|
225
|
+
// payment paths a typical tour operator wants. The deployment
|
|
226
|
+
// can override per-product (e.g. high-ticket cruises that
|
|
227
|
+
// should always go through inquiry first).
|
|
228
|
+
//
|
|
229
|
+
// - acceptsCard: real-time card via the configured PSP
|
|
230
|
+
// (renderPaymentProviderStep supplies the widget — when
|
|
231
|
+
// absent we fall back to a "we'll email a link" message).
|
|
232
|
+
// - acceptsBankTransfer: lock inventory, send bank details
|
|
233
|
+
// out of band, reconcile on receipt of funds.
|
|
234
|
+
// - acceptsInquiry: lead-only path. No inventory hold, no
|
|
235
|
+
// charge — operator follows up manually. Useful for
|
|
236
|
+
// custom itineraries / availability-on-request products.
|
|
237
|
+
acceptsCard: true,
|
|
238
|
+
acceptsBankTransfer: true,
|
|
239
|
+
acceptsHold: false,
|
|
240
|
+
acceptsTicketOnCredit: false,
|
|
241
|
+
acceptsInquiry: true,
|
|
242
|
+
}, className: className, ...slots }));
|
|
243
|
+
}
|
|
244
|
+
function checkoutIntentFromDraft(intent) {
|
|
245
|
+
if (intent === "card" || intent === "bank_transfer" || intent === "inquiry")
|
|
246
|
+
return intent;
|
|
247
|
+
return "hold";
|
|
248
|
+
}
|
|
249
|
+
export function buildStorefrontCommitParty(draft) {
|
|
250
|
+
const contact = draft.billing.contact;
|
|
251
|
+
const personId = draft.billing.buyerType === "B2C" ? contact.personId : undefined;
|
|
252
|
+
const organizationId = draft.billing.buyerType === "B2B" ? draft.billing.organizationId : undefined;
|
|
253
|
+
return {
|
|
254
|
+
personId,
|
|
255
|
+
organizationId,
|
|
256
|
+
billing: {
|
|
257
|
+
personId,
|
|
258
|
+
organizationId,
|
|
259
|
+
contact: {
|
|
260
|
+
firstName: contact.firstName,
|
|
261
|
+
lastName: contact.lastName,
|
|
262
|
+
email: contact.email,
|
|
263
|
+
phone: contact.phone,
|
|
264
|
+
},
|
|
265
|
+
},
|
|
266
|
+
travelerParty: {
|
|
267
|
+
travelers: draft.travelers.map((traveler) => ({
|
|
268
|
+
personId: traveler.personId,
|
|
269
|
+
firstName: traveler.firstName,
|
|
270
|
+
lastName: traveler.lastName,
|
|
271
|
+
email: traveler.email,
|
|
272
|
+
phone: traveler.phone,
|
|
273
|
+
dateOfBirth: traveler.dateOfBirth,
|
|
274
|
+
band: traveler.band,
|
|
275
|
+
documents: traveler.documents,
|
|
276
|
+
isPrimary: traveler.isPrimary,
|
|
277
|
+
})),
|
|
278
|
+
},
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* Resolve which contract template to preview at the Review step.
|
|
283
|
+
*
|
|
284
|
+
* Order:
|
|
285
|
+
* 1. The per-product `contractTemplateSlug` override the caller
|
|
286
|
+
* passed in (skips the network call).
|
|
287
|
+
* 2. The active customer-scope template returned by
|
|
288
|
+
* `GET /v1/public/legal/contracts/templates/default?scope=customer`.
|
|
289
|
+
*
|
|
290
|
+
* Returns `undefined` while the request is in flight or when the
|
|
291
|
+
* deployment hasn't seeded a customer template — the journey then
|
|
292
|
+
* skips the dialog and routes Confirm straight to the
|
|
293
|
+
* checkout-start handler.
|
|
294
|
+
*/
|
|
295
|
+
function useResolvedContractSlug(override, baseUrl, fetcher) {
|
|
296
|
+
const language = typeof navigator !== "undefined" ? navigator.language?.split("-")[0] : undefined;
|
|
297
|
+
const { data } = useQuery({
|
|
298
|
+
queryKey: ["public-legal-default-template", "customer", language ?? "en"],
|
|
299
|
+
queryFn: async () => {
|
|
300
|
+
const params = new URLSearchParams({ scope: "customer" });
|
|
301
|
+
if (language)
|
|
302
|
+
params.set("language", language);
|
|
303
|
+
const res = await fetcher(apiPath(baseUrl, `/v1/public/legal/contracts/templates/default?${params.toString()}`), { credentials: "include" });
|
|
304
|
+
if (!res.ok)
|
|
305
|
+
return null;
|
|
306
|
+
const json = (await res.json());
|
|
307
|
+
return json.data?.slug ?? null;
|
|
308
|
+
},
|
|
309
|
+
enabled: !override,
|
|
310
|
+
staleTime: 5 * 60 * 1000,
|
|
311
|
+
});
|
|
312
|
+
if (override)
|
|
313
|
+
return override;
|
|
314
|
+
return data ?? undefined;
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* Fetch the operator profile (name / legal name / address / license /
|
|
318
|
+
* default customer payment policy) from the public settings endpoint.
|
|
319
|
+
* The result is cached for 5 minutes — operator
|
|
320
|
+
* details rarely change, and stale-while-revalidate is fine for the
|
|
321
|
+
* contract preview UI.
|
|
322
|
+
*
|
|
323
|
+
* Returns `undefined` while the request is in flight or when the
|
|
324
|
+
* operator hasn't filled in Settings -> Operator profile yet — the contract
|
|
325
|
+
* preview then renders the operator block with `-` placeholders (the
|
|
326
|
+
* template renderer's missing-value substitution kicks in).
|
|
327
|
+
*/
|
|
328
|
+
function usePublicOperatorProfile(baseUrl, fetcher) {
|
|
329
|
+
const { data } = useQuery({
|
|
330
|
+
queryKey: ["public-operator-profile"],
|
|
331
|
+
queryFn: async () => {
|
|
332
|
+
const res = await fetcher(apiPath(baseUrl, "/v1/public/operator-profile"), {
|
|
333
|
+
credentials: "include",
|
|
334
|
+
});
|
|
335
|
+
if (!res.ok)
|
|
336
|
+
return null;
|
|
337
|
+
const json = (await res.json());
|
|
338
|
+
return json.data ?? null;
|
|
339
|
+
},
|
|
340
|
+
staleTime: 5 * 60 * 1000,
|
|
341
|
+
});
|
|
342
|
+
return data ?? undefined;
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* Server-side cascade resolution for the storefront preview.
|
|
346
|
+
*
|
|
347
|
+
* Calls `POST /v1/public/payment-policy/resolve` with the entity
|
|
348
|
+
* coordinates and returns the resolved policy + which cascade layer
|
|
349
|
+
* supplied it (operator_default | supplier | category | listing).
|
|
350
|
+
*
|
|
351
|
+
* Resolves at entity granularity only — the journey's later
|
|
352
|
+
* sailing / cabin / rate-plan selections refine the cascade
|
|
353
|
+
* server-side at booking-confirmed time. Storefront preview shows
|
|
354
|
+
* the entity-level result, which is correct for the common case
|
|
355
|
+
* (single sailing per cruise, single rate plan picked at booking
|
|
356
|
+
* time, etc.) and gracefully degrades to a less-specific layer when
|
|
357
|
+
* the journey hasn't picked yet.
|
|
358
|
+
*/
|
|
359
|
+
function useResolvedPaymentPolicy(entityModule, entityId, baseUrl, fetcher) {
|
|
360
|
+
const { data } = useQuery({
|
|
361
|
+
queryKey: ["public-payment-policy", entityModule, entityId],
|
|
362
|
+
queryFn: async () => {
|
|
363
|
+
const res = await fetcher(apiPath(baseUrl, "/v1/public/payment-policy/resolve"), {
|
|
364
|
+
method: "POST",
|
|
365
|
+
credentials: "include",
|
|
366
|
+
headers: { "content-type": "application/json" },
|
|
367
|
+
body: JSON.stringify({ entityModule, entityId }),
|
|
368
|
+
});
|
|
369
|
+
if (!res.ok)
|
|
370
|
+
return null;
|
|
371
|
+
const json = (await res.json());
|
|
372
|
+
return json.data ?? null;
|
|
373
|
+
},
|
|
374
|
+
staleTime: 5 * 60 * 1000,
|
|
375
|
+
});
|
|
376
|
+
return data ?? undefined;
|
|
377
|
+
}
|
|
378
|
+
function apiPath(baseUrl, path) {
|
|
379
|
+
return `${baseUrl.replace(/\/$/, "")}${path}`;
|
|
380
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { type StorefrontBookingJourneyMessages, type StorefrontBookingJourneyProps } from "./storefront-booking-journey.js";
|
|
3
|
+
export declare const storefrontBookingSearchSchema: z.ZodObject<{
|
|
4
|
+
departureSlotId: z.ZodOptional<z.ZodString>;
|
|
5
|
+
cabinCategoryId: z.ZodOptional<z.ZodString>;
|
|
6
|
+
cabinNumberId: z.ZodOptional<z.ZodString>;
|
|
7
|
+
airArrangement: z.ZodOptional<z.ZodEnum<{
|
|
8
|
+
none: "none";
|
|
9
|
+
cruise_line: "cruise_line";
|
|
10
|
+
independent: "independent";
|
|
11
|
+
}>>;
|
|
12
|
+
checkIn: z.ZodOptional<z.ZodString>;
|
|
13
|
+
checkOut: z.ZodOptional<z.ZodString>;
|
|
14
|
+
roomTypeId: z.ZodOptional<z.ZodString>;
|
|
15
|
+
ratePlanId: z.ZodOptional<z.ZodString>;
|
|
16
|
+
board: z.ZodOptional<z.ZodString>;
|
|
17
|
+
adult: z.ZodOptional<z.ZodCoercedNumber<unknown>>;
|
|
18
|
+
child: z.ZodOptional<z.ZodCoercedNumber<unknown>>;
|
|
19
|
+
infant: z.ZodOptional<z.ZodCoercedNumber<unknown>>;
|
|
20
|
+
draftId: z.ZodOptional<z.ZodString>;
|
|
21
|
+
}, z.core.$strip>;
|
|
22
|
+
export type StorefrontBookingSearch = z.infer<typeof storefrontBookingSearchSchema>;
|
|
23
|
+
export interface StorefrontBookingPageMessages extends StorefrontBookingJourneyMessages {
|
|
24
|
+
marketingLabel?: string;
|
|
25
|
+
}
|
|
26
|
+
export type StorefrontBookingPageExtensions = Pick<StorefrontBookingJourneyProps, "className" | "contractMarketingLabel" | "contractTemplateSlug" | "onContractAccepted" | "onRedirectToPayment">;
|
|
27
|
+
export interface StorefrontBookingPageProps {
|
|
28
|
+
entityModule: string;
|
|
29
|
+
entityId: string;
|
|
30
|
+
search: StorefrontBookingSearch;
|
|
31
|
+
messages: StorefrontBookingPageMessages;
|
|
32
|
+
extensions?: StorefrontBookingPageExtensions;
|
|
33
|
+
}
|
|
34
|
+
/** Customer booking page shared by Node applications and dedicated storefronts. */
|
|
35
|
+
export declare function StorefrontBookingPage({ entityModule, entityId, search, messages, extensions, }: StorefrontBookingPageProps): React.ReactElement;
|
|
36
|
+
//# sourceMappingURL=storefront-booking-page.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"storefront-booking-page.d.ts","sourceRoot":"","sources":["../../src/storefront/storefront-booking-page.tsx"],"names":[],"mappings":"AAMA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAA;AAIvB,OAAO,EAEL,KAAK,gCAAgC,EACrC,KAAK,6BAA6B,EACnC,MAAM,iCAAiC,CAAA;AAExC,eAAO,MAAM,6BAA6B;;;;;;;;;;;;;;;;;;iBAcxC,CAAA;AAEF,MAAM,MAAM,uBAAuB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,6BAA6B,CAAC,CAAA;AAEnF,MAAM,WAAW,6BAA8B,SAAQ,gCAAgC;IACrF,cAAc,CAAC,EAAE,MAAM,CAAA;CACxB;AAED,MAAM,MAAM,+BAA+B,GAAG,IAAI,CAChD,6BAA6B,EAC3B,WAAW,GACX,wBAAwB,GACxB,sBAAsB,GACtB,oBAAoB,GACpB,qBAAqB,CACxB,CAAA;AAED,MAAM,WAAW,0BAA0B;IACzC,YAAY,EAAE,MAAM,CAAA;IACpB,QAAQ,EAAE,MAAM,CAAA;IAChB,MAAM,EAAE,uBAAuB,CAAA;IAC/B,QAAQ,EAAE,6BAA6B,CAAA;IACvC,UAAU,CAAC,EAAE,+BAA+B,CAAA;CAC7C;AAED,mFAAmF;AACnF,wBAAgB,qBAAqB,CAAC,EACpC,YAAY,EACZ,QAAQ,EACR,MAAM,EACN,QAAQ,EACR,UAAU,GACX,EAAE,0BAA0B,GAAG,KAAK,CAAC,YAAY,CAgCjD"}
|