@questpackages/tours 0.1.0-test.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 (54) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +122 -0
  3. package/dist/cli/index.d.ts +2 -0
  4. package/dist/cli/index.js +219 -0
  5. package/dist/cli/templates.d.ts +12 -0
  6. package/dist/cli/templates.js +100 -0
  7. package/dist/next/index.d.ts +4 -0
  8. package/dist/next/index.js +4 -0
  9. package/dist/next/metadata.d.ts +26 -0
  10. package/dist/next/metadata.js +58 -0
  11. package/dist/next/revalidate-handler.d.ts +18 -0
  12. package/dist/next/revalidate-handler.js +42 -0
  13. package/dist/next/signature.d.ts +12 -0
  14. package/dist/next/signature.js +26 -0
  15. package/dist/react/booking-steps.d.ts +22 -0
  16. package/dist/react/booking-steps.js +23 -0
  17. package/dist/react/booking-wizard.d.ts +12 -0
  18. package/dist/react/booking-wizard.js +68 -0
  19. package/dist/react/contact-dialog.d.ts +15 -0
  20. package/dist/react/contact-dialog.js +45 -0
  21. package/dist/react/contact-panel.d.ts +5 -0
  22. package/dist/react/contact-panel.js +15 -0
  23. package/dist/react/contact-shared.d.ts +55 -0
  24. package/dist/react/contact-shared.js +60 -0
  25. package/dist/react/countries.d.ts +4 -0
  26. package/dist/react/countries.js +6 -0
  27. package/dist/react/index.d.ts +12 -0
  28. package/dist/react/index.js +11 -0
  29. package/dist/react/inquiry-wizard.d.ts +5 -0
  30. package/dist/react/inquiry-wizard.js +43 -0
  31. package/dist/react/party-size-rules.d.ts +18 -0
  32. package/dist/react/party-size-rules.js +52 -0
  33. package/dist/react/party-size.d.ts +27 -0
  34. package/dist/react/party-size.js +81 -0
  35. package/dist/react/sections.d.ts +10 -0
  36. package/dist/react/sections.js +58 -0
  37. package/dist/react/shared.d.ts +50 -0
  38. package/dist/react/shared.js +39 -0
  39. package/dist/react/tour-card.d.ts +30 -0
  40. package/dist/react/tour-card.js +22 -0
  41. package/dist/react/tour-detail.d.ts +26 -0
  42. package/dist/react/tour-detail.js +55 -0
  43. package/dist/react/tour-gallery.d.ts +4 -0
  44. package/dist/react/tour-gallery.js +35 -0
  45. package/dist/react/tour-json-ld.d.ts +7 -0
  46. package/dist/react/tour-json-ld.js +13 -0
  47. package/dist/schema/index.d.ts +805 -0
  48. package/dist/schema/index.js +249 -0
  49. package/dist/server/client.d.ts +43 -0
  50. package/dist/server/client.js +85 -0
  51. package/dist/server/index.d.ts +2 -0
  52. package/dist/server/index.js +2 -0
  53. package/dist/styles.css +1395 -0
  54. package/package.json +67 -0
@@ -0,0 +1,22 @@
1
+ import type { Tour } from "../schema/index.js";
2
+ import type { BookingTripDetails } from "./booking-wizard.js";
3
+ export declare function BookingGroupStep({ tour }: {
4
+ tour: Tour;
5
+ }): import("react").JSX.Element;
6
+ export declare function BookingTripStep({ tour, showGroupFields, trip, onChange, }: {
7
+ tour: Tour;
8
+ showGroupFields: boolean;
9
+ trip: BookingTripDetails;
10
+ onChange: (patch: Partial<BookingTripDetails>) => void;
11
+ }): import("react").JSX.Element;
12
+ export declare function BookingReviewStep({ tour, startDate, contact, termsAccepted, onTermsChange, }: {
13
+ tour: Tour;
14
+ startDate: string;
15
+ contact: {
16
+ name: string;
17
+ email: string;
18
+ phone: string;
19
+ };
20
+ termsAccepted: boolean;
21
+ onTermsChange: (accepted: boolean) => void;
22
+ }): import("react").JSX.Element;
@@ -0,0 +1,23 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { todayIsoDate } from "./contact-shared.js";
4
+ import { TourPartySizeFields, useTourPartySize } from "./party-size.js";
5
+ import { defaultPricingNotice, partySizeRangeLabel } from "./party-size-rules.js";
6
+ export function BookingGroupStep({ tour }) {
7
+ const { selected } = useTourPartySize();
8
+ const baselineAdults = tour.pricing.partySize?.baselineAdults ?? tour.enquiry.minTravellers;
9
+ return (_jsxs("section", { className: "qt-step-panel", children: [_jsx("h3", { className: "qt-step-title", children: "How many travelers?" }), _jsx("p", { className: "qt-muted", children: "Select your group size to see the indicative price before you continue." }), _jsxs("p", { children: ["This package accepts ", _jsx("strong", { children: partySizeRangeLabel(tour.enquiry) }), "."] }), _jsx(TourPartySizeFields, { tour: tour, idPrefix: "quest-booking-group" }), selected ? (_jsxs("div", { className: "qt-party-total", children: [_jsx("span", { children: "Total" }), _jsx("strong", { children: selected.total }), _jsxs("small", { children: [selected.perPerson, " per person"] }), selected.vehicleCapacityLabel ? _jsx("small", { children: selected.vehicleCapacityLabel }) : null] })) : (_jsx("p", { className: "qt-form-error", children: "Pricing is not available for this group size. Choose another size or send an inquiry." })), _jsxs("p", { className: "qt-notice", children: [_jsx("strong", { children: "NB." }), " ", defaultPricingNotice("booking", baselineAdults)] }), _jsx("p", { className: "qt-muted qt-small", children: "Indicative price for the selected group size. Final quote confirmed by the operator." })] }));
10
+ }
11
+ export function BookingTripStep({ tour, showGroupFields, trip, onChange, }) {
12
+ return (_jsxs("section", { className: "qt-step-panel", children: [_jsx("h3", { className: "qt-step-title", children: "Trip details" }), _jsxs("label", { className: "qt-field", htmlFor: "quest-booking-start-date", children: [_jsx("span", { className: "qt-field-label", children: "Exact start date" }), _jsx("input", { id: "quest-booking-start-date", className: "qt-input", type: "date", required: true, min: todayIsoDate(), value: trip.startDate, onChange: (event) => onChange({ startDate: event.target.value }) })] }), showGroupFields ? _jsx(TourPartySizeFields, { tour: tour, idPrefix: "quest-booking" }) : null, trip.childAges.length > 0 ? (_jsxs("div", { className: "qt-child-ages", children: [_jsx("p", { className: "qt-field-label", children: "Child ages" }), _jsx("div", { className: "qt-form-grid", children: trip.childAges.map((age, index) => (_jsxs("label", { className: "qt-field", htmlFor: `quest-booking-child-age-${index}`, children: [_jsxs("span", { className: "qt-field-label", children: ["Child ", index + 1] }), _jsx("input", { id: `quest-booking-child-age-${index}`, className: "qt-input", type: "number", min: 0, max: 17, required: true, value: age, onChange: (event) => {
13
+ const next = [...trip.childAges];
14
+ next[index] = Number(event.target.value);
15
+ onChange({ childAges: next });
16
+ } })] }, index))) }), _jsx("p", { className: "qt-muted qt-small", children: "Some activities, such as gorilla trekking, require a minimum age of 15." })] })) : null, _jsxs("label", { className: "qt-field", htmlFor: "quest-booking-tier", children: [_jsx("span", { className: "qt-field-label", children: "Accommodation tier (optional)" }), _jsxs("select", { id: "quest-booking-tier", className: "qt-input", value: trip.accommodationTier, onChange: (event) => onChange({ accommodationTier: event.target.value }), children: [_jsx("option", { value: "", children: "No preference" }), _jsx("option", { value: "budget", children: "Budget" }), _jsx("option", { value: "mid_range", children: "Mid-range" }), _jsx("option", { value: "luxury", children: "Luxury" })] })] }), _jsxs("label", { className: "qt-check", htmlFor: "quest-booking-single", children: [_jsx("input", { id: "quest-booking-single", type: "checkbox", checked: trip.singleSupplement, onChange: (event) => onChange({ singleSupplement: event.target.checked }) }), _jsx("span", { children: "Request a private room (single supplement)" })] }), _jsxs("label", { className: "qt-field", htmlFor: "quest-booking-special", children: [_jsx("span", { className: "qt-field-label", children: "Special requirements (optional)" }), _jsx("textarea", { id: "quest-booking-special", className: "qt-input", rows: 3, maxLength: 2000, value: trip.specialRequirements, placeholder: "Dietary needs, mobility support, honeymoon or anniversary details...", onChange: (event) => onChange({ specialRequirements: event.target.value }) })] })] }));
17
+ }
18
+ export function BookingReviewStep({ tour, startDate, contact, termsAccepted, onTermsChange, }) {
19
+ const { adults, children, selected } = useTourPartySize();
20
+ const childCount = tour.enquiry.childrenAllowed ? children : 0;
21
+ const groupLabel = `${adults} adult${adults === 1 ? "" : "s"}${childCount > 0 ? `, ${childCount} child${childCount === 1 ? "" : "ren"}` : ""}`;
22
+ return (_jsxs("section", { className: "qt-step-panel", children: [_jsx("h3", { className: "qt-step-title", children: "Review your request" }), _jsx("p", { className: "qt-muted", children: "Check the details below, then submit your reservation request. No payment is required today." }), _jsxs("dl", { className: "qt-summary", children: [_jsxs("div", { children: [_jsx("dt", { children: "Travelers" }), _jsx("dd", { children: groupLabel })] }), _jsxs("div", { children: [_jsx("dt", { children: "Start date" }), _jsx("dd", { children: startDate || "Not set" })] }), selected ? (_jsxs("div", { children: [_jsx("dt", { children: "Indicative total" }), _jsx("dd", { children: selected.total })] })) : null, _jsxs("div", { children: [_jsx("dt", { children: "Contact" }), _jsxs("dd", { children: [contact.name, _jsx("br", {}), contact.email, _jsx("br", {}), contact.phone] })] })] }), _jsxs("label", { className: "qt-check", htmlFor: "quest-booking-terms", children: [_jsx("input", { id: "quest-booking-terms", type: "checkbox", checked: termsAccepted, onChange: (event) => onTermsChange(event.target.checked) }), _jsx("span", { children: "I agree to the cancellation policies and terms of service." })] }), _jsxs("p", { className: "qt-muted qt-small", children: [tour.operator.name, " will confirm availability and send your official itinerary invoice within 24 hours."] })] }));
23
+ }
@@ -0,0 +1,12 @@
1
+ import type { Tour, TourContactSuccess } from "../schema/index.js";
2
+ export type BookingTripDetails = {
3
+ startDate: string;
4
+ childAges: number[];
5
+ accommodationTier: "" | "budget" | "mid_range" | "luxury";
6
+ singleSupplement: boolean;
7
+ specialRequirements: string;
8
+ };
9
+ export declare function TourBookingWizard({ tour, onSuccess, }: {
10
+ tour: Tour;
11
+ onSuccess: (success: TourContactSuccess) => void;
12
+ }): import("react").JSX.Element;
@@ -0,0 +1,68 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useState } from "react";
4
+ import { ContactFields, ContactStepActions, ContactStepProgress, contactDetailsComplete, submitToQuest, } from "./contact-shared.js";
5
+ import { BookingGroupStep, BookingReviewStep, BookingTripStep } from "./booking-steps.js";
6
+ import { useTourPartySize } from "./party-size.js";
7
+ import { isPartySizeValid } from "./party-size-rules.js";
8
+ export function TourBookingWizard({ tour, onSuccess, }) {
9
+ const party = useTourPartySize();
10
+ const hasPartyPricing = tour.pricing.partySize !== null;
11
+ const steps = hasPartyPricing
12
+ ? ["Group size", "Trip details", "Your details", "Review"]
13
+ : ["Trip details", "Your details", "Review"];
14
+ const offset = hasPartyPricing ? 1 : 0;
15
+ const [tripStep, contactStep, reviewStep] = [offset, offset + 1, offset + 2];
16
+ const [step, setStep] = useState(0);
17
+ const [submitting, setSubmitting] = useState(false);
18
+ const [error, setError] = useState(null);
19
+ const [termsAccepted, setTermsAccepted] = useState(false);
20
+ const [contact, setContact] = useState({ name: "", email: "", phone: "", nationality: "" });
21
+ const [trip, setTrip] = useState({
22
+ startDate: "",
23
+ childAges: [],
24
+ accommodationTier: "",
25
+ singleSupplement: false,
26
+ specialRequirements: "",
27
+ });
28
+ const numberOfChildren = tour.enquiry.childrenAllowed ? party.children : 0;
29
+ const childAges = Array.from({ length: numberOfChildren }, (_, index) => trip.childAges[index] ?? 0);
30
+ const groupValid = isPartySizeValid(party.adults, numberOfChildren, tour.enquiry, tour.enquiry.childrenAllowed) &&
31
+ (!hasPartyPricing || party.selected !== null);
32
+ const canContinue = step === tripStep
33
+ ? trip.startDate.length > 0 && groupValid
34
+ : step === contactStep
35
+ ? contactDetailsComplete(contact)
36
+ : step === reviewStep
37
+ ? termsAccepted
38
+ : groupValid;
39
+ const submit = async () => {
40
+ setSubmitting(true);
41
+ setError(null);
42
+ const input = {
43
+ name: contact.name,
44
+ email: contact.email,
45
+ phone: contact.phone,
46
+ nationalityCode: contact.nationality,
47
+ startDate: trip.startDate,
48
+ numberOfAdults: party.adults,
49
+ numberOfChildren,
50
+ childAges,
51
+ accommodationTier: trip.accommodationTier || undefined,
52
+ singleSupplement: trip.singleSupplement,
53
+ specialRequirements: trip.specialRequirements || undefined,
54
+ termsAccepted: true,
55
+ indicativeTotal: party.selected?.total,
56
+ indicativePerPerson: party.selected?.perPerson,
57
+ indicativeCurrency: party.selected ? tour.pricing.currency : undefined,
58
+ };
59
+ const result = await submitToQuest(tour.enquiry.bookingEndpoint, input);
60
+ setSubmitting(false);
61
+ if (!result.ok) {
62
+ setError(result.error);
63
+ return;
64
+ }
65
+ onSuccess(result.success);
66
+ };
67
+ return (_jsxs("div", { className: "qt-wizard", children: [_jsx(ContactStepProgress, { steps: steps, currentStep: step }), error ? (_jsx("p", { className: "qt-form-error", role: "alert", children: error })) : null, hasPartyPricing && step === 0 ? _jsx(BookingGroupStep, { tour: tour }) : null, step === tripStep ? (_jsx(BookingTripStep, { tour: tour, showGroupFields: !hasPartyPricing, trip: { ...trip, childAges }, onChange: (patch) => setTrip((current) => ({ ...current, ...patch })) })) : null, step === contactStep ? (_jsxs("section", { className: "qt-step-panel", children: [_jsx("h3", { className: "qt-step-title", children: "Primary traveler details" }), _jsx(ContactFields, { idPrefix: "quest-booking", nameLabel: "Full name (as on passport / ID)", values: contact, onChange: (patch) => setContact((current) => ({ ...current, ...patch })) })] })) : null, step === reviewStep ? (_jsx(BookingReviewStep, { tour: tour, startDate: trip.startDate, contact: contact, termsAccepted: termsAccepted, onTermsChange: setTermsAccepted })) : null, _jsx(ContactStepActions, { step: step, totalSteps: steps.length, canContinue: canContinue, submitting: submitting, submitLabel: "Submit", onBack: () => setStep((current) => Math.max(0, current - 1)), onContinue: () => setStep((current) => Math.min(steps.length - 1, current + 1)), onSubmit: () => void submit() })] }));
68
+ }
@@ -0,0 +1,15 @@
1
+ import type { Tour } from "../schema/index.js";
2
+ import type { TourContactMode } from "./contact-shared.js";
3
+ export type TourContactDialogProps = {
4
+ tour: Tour;
5
+ mode: TourContactMode;
6
+ open: boolean;
7
+ onClose: () => void;
8
+ /** Extra class on the dialog, e.g. to scope site-specific theme variables. */
9
+ className?: string;
10
+ };
11
+ /**
12
+ * Book or inquire modal, matching the Quest website embed. Submissions go straight to Quest,
13
+ * where they appear as booking requests and inquiries for the operator.
14
+ */
15
+ export declare function TourContactDialog({ tour, mode, open, onClose, className }: TourContactDialogProps): import("react").JSX.Element;
@@ -0,0 +1,45 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useEffect, useRef, useState } from "react";
4
+ import { TourBookingWizard } from "./booking-wizard.js";
5
+ import { TourInquiryWizard } from "./inquiry-wizard.js";
6
+ import { TourPartySizeProvider, useOptionalTourPartySize } from "./party-size.js";
7
+ import { Icon } from "./shared.js";
8
+ /** Uses the page's shared group size when there is one (inside TourDetail), otherwise its own. */
9
+ function EnsurePartySize({ tour, children }) {
10
+ const shared = useOptionalTourPartySize();
11
+ return shared ? children : _jsx(TourPartySizeProvider, { tour: tour, children: children });
12
+ }
13
+ function ContactSuccess({ tour, mode, success, onClose, }) {
14
+ const { summary } = success;
15
+ const groupLabel = summary.adults != null
16
+ ? `${summary.adults} adult${summary.adults === 1 ? "" : "s"}${(summary.children ?? 0) > 0
17
+ ? `, ${summary.children} child${summary.children === 1 ? "" : "ren"}`
18
+ : ""}`
19
+ : null;
20
+ return (_jsxs("div", { className: "qt-contact-success", children: [_jsxs("h3", { className: "qt-step-title", children: ["Thank you, ", summary.contactName.split(" ")[0] || summary.contactName] }), _jsxs("p", { children: ["Your ", mode === "booking" ? "booking request" : "inquiry", " was received.", " ", _jsx("strong", { children: "No payment has been charged." }), " ", tour.operator.name, " is checking availability and will email your official quote or invoice within 24 hours."] }), _jsxs("dl", { className: "qt-summary", children: [_jsxs("div", { children: [_jsx("dt", { children: "Reference" }), _jsx("dd", { children: success.referenceNumber })] }), _jsxs("div", { children: [_jsx("dt", { children: "Package" }), _jsx("dd", { children: summary.packageTitle })] }), _jsxs("div", { children: [_jsx("dt", { children: "Travel dates" }), _jsx("dd", { children: summary.travelDatesLabel })] }), groupLabel ? (_jsxs("div", { children: [_jsx("dt", { children: "Travelers" }), _jsx("dd", { children: groupLabel })] })) : null] }), _jsx("p", { className: "qt-muted qt-small", children: "A confirmation email is on its way with next steps and contact details if you need to make changes." }), _jsx("button", { type: "button", className: "qt-button qt-button-block", onClick: onClose, children: "Close" })] }));
21
+ }
22
+ /**
23
+ * Book or inquire modal, matching the Quest website embed. Submissions go straight to Quest,
24
+ * where they appear as booking requests and inquiries for the operator.
25
+ */
26
+ export function TourContactDialog({ tour, mode, open, onClose, className }) {
27
+ const dialogRef = useRef(null);
28
+ const [success, setSuccess] = useState(null);
29
+ useEffect(() => {
30
+ const dialog = dialogRef.current;
31
+ if (!dialog)
32
+ return;
33
+ if (open && !dialog.open)
34
+ dialog.showModal();
35
+ if (!open && dialog.open)
36
+ dialog.close();
37
+ if (!open)
38
+ setSuccess(null);
39
+ }, [open]);
40
+ const title = success ? "Request received" : mode === "booking" ? "Request booking" : "Send inquiry";
41
+ return (_jsx("dialog", { ref: dialogRef, className: `quest-tours qt-contact-dialog ${className ?? ""}`.trim(), "aria-labelledby": "quest-contact-title", onClose: onClose, onClick: (event) => {
42
+ if (event.target === dialogRef.current)
43
+ onClose();
44
+ }, children: open ? (_jsxs("div", { className: "qt-contact-panel", children: [_jsxs("header", { className: "qt-contact-header", children: [_jsxs("div", { children: [_jsx("p", { className: "qt-contact-eyebrow", children: "No payment required today" }), _jsx("h2", { id: "quest-contact-title", className: "qt-contact-title", children: title }), success ? null : _jsx("p", { className: "qt-muted", children: tour.title })] }), _jsx("button", { type: "button", className: "qt-contact-close", "aria-label": "Close", onClick: onClose, children: _jsx(Icon.X, {}) })] }), _jsx("div", { className: "qt-contact-body", children: _jsx(EnsurePartySize, { tour: tour, children: success ? (_jsx(ContactSuccess, { tour: tour, mode: mode, success: success, onClose: onClose })) : mode === "booking" ? (_jsx(TourBookingWizard, { tour: tour, onSuccess: setSuccess }, "booking")) : (_jsx(TourInquiryWizard, { tour: tour, onSuccess: setSuccess }, "inquiry")) }) })] })) : null }));
45
+ }
@@ -0,0 +1,5 @@
1
+ import type { Tour } from "../schema/index.js";
2
+ /** "Book or Inquire" panel from the Quest website embed, opening the booking or inquiry modal. */
3
+ export declare function TourContactPanel({ tour }: {
4
+ tour: Tour;
5
+ }): import("react").JSX.Element;
@@ -0,0 +1,15 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useState } from "react";
4
+ import { TourContactDialog } from "./contact-dialog.js";
5
+ /** "Book or Inquire" panel from the Quest website embed, opening the booking or inquiry modal. */
6
+ export function TourContactPanel({ tour }) {
7
+ const [mode, setMode] = useState("booking");
8
+ const [open, setOpen] = useState(false);
9
+ const price = tour.price.fromPerPerson ?? tour.price.total;
10
+ return (_jsxs("div", { className: "qt-panel qt-contact-cta", id: "quest-enquiry", children: [_jsx("h2", { className: "qt-panel-title", children: "Book or Inquire" }), _jsxs("p", { className: "qt-muted qt-small", children: ["Reserve your dates or ask ", tour.operator.name, " about ", tour.title, "."] }), price ? (_jsxs("p", { className: "qt-price-chip", children: [_jsx("span", { children: "From" }), " ", _jsx("strong", { children: price.formatted }), " ", _jsx("small", { children: tour.price.fromPerPerson ? "per person" : "total" })] })) : null, _jsx("div", { className: "qt-tabs", role: "tablist", "aria-label": "Contact options", children: ["booking", "inquiry"].map((option) => (_jsx("button", { type: "button", role: "tab", "aria-selected": mode === option, className: mode === option ? "is-active" : undefined, onClick: () => setMode(option), children: option === "booking" ? "Book" : "Inquire" }, option))) }), _jsx("p", { className: "qt-small", children: mode === "booking"
11
+ ? "Share your travel dates and group details to request a provisional reservation."
12
+ : "Ask about dates, pricing, or custom changes before you commit." }), _jsx("button", { type: "button", className: "qt-button qt-button-block", onClick: () => setOpen(true), children: mode === "booking" ? "Request Booking" : "Send Inquiry" }), _jsx("p", { className: "qt-muted qt-small", children: mode === "booking"
13
+ ? "No payment is required today. Our team confirms availability within 24 hours."
14
+ : "No payment required. We reply with answers and a tailored quote within 24 hours." }), _jsx(TourContactDialog, { tour: tour, mode: mode, open: open, onClose: () => setOpen(false) })] }));
15
+ }
@@ -0,0 +1,55 @@
1
+ import { type TourContactSuccess } from "../schema/index.js";
2
+ export type TourContactMode = "booking" | "inquiry";
3
+ export type TourContactSubmitResult = {
4
+ ok: true;
5
+ success: TourContactSuccess;
6
+ } | {
7
+ ok: false;
8
+ error: string;
9
+ };
10
+ /** POST from the visitor's browser straight to Quest (rate limiting and origin checks apply there). */
11
+ export declare function submitToQuest(endpoint: string, body: unknown): Promise<TourContactSubmitResult>;
12
+ export declare function ContactStepProgress({ steps, currentStep, }: {
13
+ steps: readonly string[];
14
+ currentStep: number;
15
+ }): import("react").JSX.Element;
16
+ export declare function ContactStepActions({ step, totalSteps, canContinue, submitting, submitLabel, onBack, onContinue, onSubmit, }: {
17
+ step: number;
18
+ totalSteps: number;
19
+ canContinue: boolean;
20
+ submitting: boolean;
21
+ submitLabel: string;
22
+ onBack: () => void;
23
+ onContinue: () => void;
24
+ onSubmit: () => void;
25
+ }): import("react").JSX.Element;
26
+ export declare function NationalitySelect({ id, value, onChange, }: {
27
+ id: string;
28
+ value: string;
29
+ onChange: (code: string) => void;
30
+ }): import("react").JSX.Element;
31
+ /** Contact fields shared by both flows. */
32
+ export declare function ContactFields({ idPrefix, nameLabel, values, onChange, }: {
33
+ idPrefix: string;
34
+ nameLabel: string;
35
+ values: {
36
+ name: string;
37
+ email: string;
38
+ phone: string;
39
+ nationality: string;
40
+ };
41
+ onChange: (patch: Partial<{
42
+ name: string;
43
+ email: string;
44
+ phone: string;
45
+ nationality: string;
46
+ }>) => void;
47
+ }): import("react").JSX.Element;
48
+ export declare function contactDetailsComplete(values: {
49
+ name: string;
50
+ email: string;
51
+ phone: string;
52
+ nationality: string;
53
+ }): boolean;
54
+ /** Today's date as YYYY-MM-DD in the visitor's time zone, for date input minimums. */
55
+ export declare function todayIsoDate(): string;
@@ -0,0 +1,60 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useMemo } from "react";
4
+ import { tourContactSuccessSchema } from "../schema/index.js";
5
+ import { countryOptions } from "./countries.js";
6
+ import { Icon } from "./shared.js";
7
+ const SUBMIT_ERROR = "We could not send your request. Please try again.";
8
+ /** POST from the visitor's browser straight to Quest (rate limiting and origin checks apply there). */
9
+ export async function submitToQuest(endpoint, body) {
10
+ try {
11
+ const response = await fetch(endpoint, {
12
+ method: "POST",
13
+ headers: { "Content-Type": "application/json" },
14
+ body: JSON.stringify(body),
15
+ });
16
+ const payload = await response.json().catch(() => null);
17
+ const success = tourContactSuccessSchema.safeParse(payload);
18
+ if (response.ok && success.success)
19
+ return { ok: true, success: success.data };
20
+ if (response.status === 429) {
21
+ return { ok: false, error: "Too many requests. Please wait a few minutes and try again." };
22
+ }
23
+ const error = payload && typeof payload === "object" && "error" in payload && typeof payload.error === "string"
24
+ ? payload.error
25
+ : SUBMIT_ERROR;
26
+ return { ok: false, error };
27
+ }
28
+ catch {
29
+ return { ok: false, error: SUBMIT_ERROR };
30
+ }
31
+ }
32
+ export function ContactStepProgress({ steps, currentStep, }) {
33
+ return (_jsxs("nav", { className: "qt-steps", "aria-label": "Form progress", children: [_jsx("ol", { className: "qt-steps-list", children: steps.map((label, index) => {
34
+ const status = index < currentStep ? "complete" : index === currentStep ? "current" : "upcoming";
35
+ return (_jsxs("li", { className: `qt-step is-${status}`, "aria-current": status === "current" ? "step" : undefined, children: [_jsx("span", { className: "qt-step-marker", children: index + 1 }), _jsx("span", { className: "qt-step-label", children: label })] }, label));
36
+ }) }), _jsxs("p", { className: "qt-step-caption", children: ["Step ", currentStep + 1, " of ", steps.length, ": ", steps[currentStep]] })] }));
37
+ }
38
+ export function ContactStepActions({ step, totalSteps, canContinue, submitting, submitLabel, onBack, onContinue, onSubmit, }) {
39
+ const isLast = step >= totalSteps - 1;
40
+ return (_jsxs("div", { className: "qt-step-actions", children: [step > 0 ? (_jsxs("button", { type: "button", className: "qt-button qt-button-outline", onClick: onBack, disabled: submitting, children: [_jsx(Icon.ArrowLeft, {}), "Back"] })) : (_jsx("span", { "aria-hidden": "true" })), _jsxs("button", { type: "button", className: "qt-button", disabled: !canContinue || submitting, onClick: isLast ? onSubmit : onContinue, children: [isLast ? (submitting ? "Submitting..." : submitLabel) : "Continue", isLast ? null : _jsx(Icon.ArrowRight, {})] })] }));
41
+ }
42
+ export function NationalitySelect({ id, value, onChange, }) {
43
+ const countries = useMemo(() => countryOptions(), []);
44
+ return (_jsxs("select", { id: id, className: "qt-input", value: value, required: true, onChange: (event) => onChange(event.target.value), children: [_jsx("option", { value: "", disabled: true, children: "Passport nationality" }), countries.map((country) => (_jsx("option", { value: country.code, children: country.name }, country.code)))] }));
45
+ }
46
+ /** Contact fields shared by both flows. */
47
+ export function ContactFields({ idPrefix, nameLabel, values, onChange, }) {
48
+ return (_jsxs("div", { className: "qt-form-grid", children: [_jsxs("label", { className: "qt-field qt-field-span-2", htmlFor: `${idPrefix}-name`, children: [_jsx("span", { className: "qt-field-label", children: nameLabel }), _jsx("input", { id: `${idPrefix}-name`, className: "qt-input", autoComplete: "name", required: true, maxLength: 120, value: values.name, onChange: (event) => onChange({ name: event.target.value }) })] }), _jsxs("label", { className: "qt-field", htmlFor: `${idPrefix}-email`, children: [_jsx("span", { className: "qt-field-label", children: "Email address" }), _jsx("input", { id: `${idPrefix}-email`, className: "qt-input", type: "email", autoComplete: "email", required: true, maxLength: 200, value: values.email, onChange: (event) => onChange({ email: event.target.value }) })] }), _jsxs("label", { className: "qt-field", htmlFor: `${idPrefix}-phone`, children: [_jsx("span", { className: "qt-field-label", children: "Phone / WhatsApp" }), _jsx("input", { id: `${idPrefix}-phone`, className: "qt-input", type: "tel", autoComplete: "tel", required: true, maxLength: 50, value: values.phone, onChange: (event) => onChange({ phone: event.target.value }) })] }), _jsxs("label", { className: "qt-field qt-field-span-2", htmlFor: `${idPrefix}-nationality`, children: [_jsx("span", { className: "qt-field-label", children: "Nationality" }), _jsx(NationalitySelect, { id: `${idPrefix}-nationality`, value: values.nationality, onChange: (nationality) => onChange({ nationality }) })] })] }));
49
+ }
50
+ export function contactDetailsComplete(values) {
51
+ return (values.name.trim().length >= 2 &&
52
+ /.+@.+\..+/.test(values.email.trim()) &&
53
+ values.phone.trim().length >= 6 &&
54
+ values.nationality.length === 2);
55
+ }
56
+ /** Today's date as YYYY-MM-DD in the visitor's time zone, for date input minimums. */
57
+ export function todayIsoDate() {
58
+ const now = new Date();
59
+ return new Date(now.getTime() - now.getTimezoneOffset() * 60_000).toISOString().slice(0, 10);
60
+ }
@@ -0,0 +1,4 @@
1
+ export declare function countryOptions(locale?: string): {
2
+ code: string;
3
+ name: string;
4
+ }[];
@@ -0,0 +1,6 @@
1
+ /** ISO 3166-1 alpha-2 codes. Names come from `Intl.DisplayNames` at runtime to keep the bundle small. */
2
+ const ISO_COUNTRY_CODES = "AD AE AF AG AI AL AM AO AQ AR AS AT AU AW AX AZ BA BB BD BE BF BG BH BI BJ BL BM BN BO BQ BR BS BT BV BW BY BZ CA CC CD CF CG CH CI CK CL CM CN CO CR CU CV CW CX CY CZ DE DJ DK DM DO DZ EC EE EG EH ER ES ET FI FJ FK FM FO FR GA GB GD GE GF GG GH GI GL GM GN GP GQ GR GS GT GU GW GY HK HM HN HR HT HU ID IE IL IM IN IO IQ IR IS IT JE JM JO JP KE KG KH KI KM KN KP KR KW KY KZ LA LB LC LI LK LR LS LT LU LV LY MA MC MD ME MF MG MH MK ML MM MN MO MP MQ MR MS MT MU MV MW MX MY MZ NA NC NE NF NG NI NL NO NP NR NU NZ OM PA PE PF PG PH PK PL PM PN PR PS PT PW PY QA RE RO RS RU RW SA SB SC SD SE SG SH SI SJ SK SL SM SN SO SR SS ST SV SX SY SZ TC TD TF TG TH TJ TK TL TM TN TO TR TT TV TW TZ UA UG UM US UY UZ VA VC VE VG VI VN VU WF WS YE YT ZA ZM ZW".split(" ");
3
+ export function countryOptions(locale = "en") {
4
+ const names = new Intl.DisplayNames([locale], { type: "region" });
5
+ return ISO_COUNTRY_CODES.map((code) => ({ code, name: names.of(code) ?? code })).sort((a, b) => a.name.localeCompare(b.name, locale));
6
+ }
@@ -0,0 +1,12 @@
1
+ export { TourCard, TourCardPrice, TourGrid, type TourCardProps, type TourGridProps } from "./tour-card.js";
2
+ export { TourDetail, defaultTourSections, type TourDetailProps, type TourSectionComponents, type TourSectionKey, } from "./tour-detail.js";
3
+ export { TourAccommodation, TourHero, TourHighlights, TourInclusions, TourItinerary, TourOverview, TourPaymentTerms, TourRelated, TourVehicle, } from "./sections.js";
4
+ export { TourGallery } from "./tour-gallery.js";
5
+ export { TourPartySizeFields, TourPartySizeProvider, TourPricing, useTourPartySize, } from "./party-size.js";
6
+ export { TourContactDialog, type TourContactDialogProps } from "./contact-dialog.js";
7
+ export { TourContactPanel } from "./contact-panel.js";
8
+ export { TourBookingWizard } from "./booking-wizard.js";
9
+ export { TourInquiryWizard } from "./inquiry-wizard.js";
10
+ export type { TourContactMode } from "./contact-shared.js";
11
+ export { TourJsonLd } from "./tour-json-ld.js";
12
+ export { TourSection, type TourLinkComponent, type TourLinkProps, type TourSectionProps } from "./shared.js";
@@ -0,0 +1,11 @@
1
+ export { TourCard, TourCardPrice, TourGrid } from "./tour-card.js";
2
+ export { TourDetail, defaultTourSections, } from "./tour-detail.js";
3
+ export { TourAccommodation, TourHero, TourHighlights, TourInclusions, TourItinerary, TourOverview, TourPaymentTerms, TourRelated, TourVehicle, } from "./sections.js";
4
+ export { TourGallery } from "./tour-gallery.js";
5
+ export { TourPartySizeFields, TourPartySizeProvider, TourPricing, useTourPartySize, } from "./party-size.js";
6
+ export { TourContactDialog } from "./contact-dialog.js";
7
+ export { TourContactPanel } from "./contact-panel.js";
8
+ export { TourBookingWizard } from "./booking-wizard.js";
9
+ export { TourInquiryWizard } from "./inquiry-wizard.js";
10
+ export { TourJsonLd } from "./tour-json-ld.js";
11
+ export { TourSection } from "./shared.js";
@@ -0,0 +1,5 @@
1
+ import type { Tour, TourContactSuccess } from "../schema/index.js";
2
+ export declare function TourInquiryWizard({ tour, onSuccess, }: {
3
+ tour: Tour;
4
+ onSuccess: (success: TourContactSuccess) => void;
5
+ }): import("react").JSX.Element;
@@ -0,0 +1,43 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useState } from "react";
4
+ import { ContactFields, ContactStepActions, ContactStepProgress, contactDetailsComplete, submitToQuest, todayIsoDate, } from "./contact-shared.js";
5
+ import { defaultPricingNotice } from "./party-size-rules.js";
6
+ const STEPS = ["Travel plans", "Contact details"];
7
+ export function TourInquiryWizard({ tour, onSuccess, }) {
8
+ const [step, setStep] = useState(0);
9
+ const [submitting, setSubmitting] = useState(false);
10
+ const [error, setError] = useState(null);
11
+ const [travelDateMode, setTravelDateMode] = useState("flexible");
12
+ const [preferredMonth, setPreferredMonth] = useState("");
13
+ const [preferredStartDate, setPreferredStartDate] = useState("");
14
+ const [message, setMessage] = useState("");
15
+ const [contact, setContact] = useState({ name: "", email: "", phone: "", nationality: "" });
16
+ const canContinue = step === 0
17
+ ? travelDateMode === "flexible" ||
18
+ (travelDateMode === "specific_month" && preferredMonth.length > 0) ||
19
+ (travelDateMode === "specific_date" && preferredStartDate.length > 0)
20
+ : contactDetailsComplete(contact);
21
+ const submit = async () => {
22
+ setSubmitting(true);
23
+ setError(null);
24
+ const input = {
25
+ name: contact.name,
26
+ email: contact.email,
27
+ phone: contact.phone,
28
+ countryCode: contact.nationality,
29
+ travelDateMode,
30
+ preferredMonth: preferredMonth || undefined,
31
+ preferredStartDate: preferredStartDate || undefined,
32
+ message: message || undefined,
33
+ };
34
+ const result = await submitToQuest(tour.enquiry.endpoint, input);
35
+ setSubmitting(false);
36
+ if (!result.ok) {
37
+ setError(result.error);
38
+ return;
39
+ }
40
+ onSuccess(result.success);
41
+ };
42
+ return (_jsxs("div", { className: "qt-wizard", children: [_jsx(ContactStepProgress, { steps: STEPS, currentStep: step }), error ? (_jsx("p", { className: "qt-form-error", role: "alert", children: error })) : null, step === 0 ? (_jsxs("section", { className: "qt-step-panel", children: [_jsx("h3", { className: "qt-step-title", children: "When would you like to travel?" }), _jsxs("label", { className: "qt-field", htmlFor: "quest-inquiry-date-mode", children: [_jsx("span", { className: "qt-field-label", children: "Estimated travel dates" }), _jsxs("select", { id: "quest-inquiry-date-mode", className: "qt-input", value: travelDateMode, onChange: (event) => setTravelDateMode(event.target.value), children: [_jsx("option", { value: "flexible", children: "Flexible / still planning" }), _jsx("option", { value: "specific_month", children: "Specific month" }), _jsx("option", { value: "specific_date", children: "Specific date" })] })] }), travelDateMode === "specific_month" ? (_jsxs("label", { className: "qt-field", htmlFor: "quest-inquiry-month", children: [_jsx("span", { className: "qt-field-label", children: "Travel month" }), _jsx("input", { id: "quest-inquiry-month", className: "qt-input", type: "month", required: true, min: todayIsoDate().slice(0, 7), value: preferredMonth, onChange: (event) => setPreferredMonth(event.target.value) })] })) : null, travelDateMode === "specific_date" ? (_jsxs("label", { className: "qt-field", htmlFor: "quest-inquiry-date", children: [_jsx("span", { className: "qt-field-label", children: "Estimated start date" }), _jsx("input", { id: "quest-inquiry-date", className: "qt-input", type: "date", required: true, min: todayIsoDate(), value: preferredStartDate, onChange: (event) => setPreferredStartDate(event.target.value) })] })) : null, tour.pricing.partySize ? (_jsxs("p", { className: "qt-notice", children: [_jsx("strong", { children: "NB." }), " ", defaultPricingNotice("inquiry", tour.pricing.partySize.baselineAdults)] })) : null, _jsxs("label", { className: "qt-field", htmlFor: "quest-inquiry-message", children: [_jsx("span", { className: "qt-field-label", children: "Message or questions (optional)" }), _jsx("textarea", { id: "quest-inquiry-message", className: "qt-input", rows: 4, maxLength: 2000, value: message, placeholder: "Preferred destinations, celebrations, or anything you'd like us to know...", onChange: (event) => setMessage(event.target.value) })] })] })) : (_jsxs("section", { className: "qt-step-panel", children: [_jsx("h3", { className: "qt-step-title", children: "How can we reach you?" }), _jsx(ContactFields, { idPrefix: "quest-inquiry", nameLabel: "Full name", values: contact, onChange: (patch) => setContact((current) => ({ ...current, ...patch })) }), _jsxs("p", { className: "qt-muted qt-small", children: ["No payment required. ", tour.operator.name, " will reply with answers and a tailored quote within 24 hours."] })] })), _jsx(ContactStepActions, { step: step, totalSteps: STEPS.length, canContinue: canContinue, submitting: submitting, submitLabel: "Send Inquiry", onBack: () => setStep((current) => Math.max(0, current - 1)), onContinue: () => setStep((current) => Math.min(STEPS.length - 1, current + 1)), onSubmit: () => void submit() })] }));
43
+ }
@@ -0,0 +1,18 @@
1
+ import type { Tour } from "../schema/index.js";
2
+ /**
3
+ * Party size rules, ported from Quest's price matrix helpers so the forms accept exactly the
4
+ * groups Quest's booking and inquiry endpoints accept.
5
+ */
6
+ type Bounds = {
7
+ minTravellers: number;
8
+ maxTravellers: number;
9
+ };
10
+ export declare function childrenCountOptions(adults: number, bounds: Bounds): number[];
11
+ export declare function adultCountOptions(bounds: Bounds, childrenAllowed: boolean): number[];
12
+ export declare function isPartySizeValid(adults: number, children: number, bounds: Bounds, childrenAllowed: boolean): boolean;
13
+ export declare function partySizeRangeLabel(bounds: Bounds): string;
14
+ type PartyPriceOption = NonNullable<Tour["pricing"]["partySize"]>["options"][number];
15
+ export declare function findPartyPriceOption(tour: Tour, adults: number, children: number): PartyPriceOption | null;
16
+ /** Same wording as the Quest embed's default pricing notice. */
17
+ export declare function defaultPricingNotice(variant: "booking" | "inquiry", baselineAdults: number): string;
18
+ export {};
@@ -0,0 +1,52 @@
1
+ function normalizeBounds({ minTravellers, maxTravellers }) {
2
+ const min = Math.max(1, Math.trunc(minTravellers));
3
+ return { min, max: Math.max(min, Math.trunc(maxTravellers)) };
4
+ }
5
+ export function childrenCountOptions(adults, bounds) {
6
+ const { min, max } = normalizeBounds(bounds);
7
+ const safeAdults = Math.max(1, Math.trunc(adults));
8
+ const minChildren = Math.max(0, min - safeAdults);
9
+ const maxChildren = Math.max(0, max - safeAdults);
10
+ if (maxChildren < minChildren)
11
+ return [0];
12
+ return Array.from({ length: maxChildren - minChildren + 1 }, (_, index) => minChildren + index);
13
+ }
14
+ export function adultCountOptions(bounds, childrenAllowed) {
15
+ const { min, max } = normalizeBounds(bounds);
16
+ const firstAdult = childrenAllowed ? 1 : min;
17
+ const options = [];
18
+ for (let adults = firstAdult; adults <= max; adults += 1) {
19
+ if (!childrenAllowed || childrenCountOptions(adults, bounds).length > 0)
20
+ options.push(adults);
21
+ }
22
+ return options.length > 0 ? options : [min];
23
+ }
24
+ export function isPartySizeValid(adults, children, bounds, childrenAllowed) {
25
+ const { min, max } = normalizeBounds(bounds);
26
+ const safeAdults = Math.max(1, Math.trunc(adults));
27
+ const safeChildren = Math.max(0, Math.trunc(children));
28
+ if (!childrenAllowed && safeChildren > 0)
29
+ return false;
30
+ const total = safeAdults + safeChildren;
31
+ if (total < min || total > max)
32
+ return false;
33
+ if (!childrenAllowed)
34
+ return true;
35
+ return childrenCountOptions(safeAdults, bounds).includes(safeChildren);
36
+ }
37
+ export function partySizeRangeLabel(bounds) {
38
+ const { min, max } = normalizeBounds(bounds);
39
+ return min === max ? `${min} travelers` : `${min} to ${max} travelers`;
40
+ }
41
+ export function findPartyPriceOption(tour, adults, children) {
42
+ return (tour.pricing.partySize?.options.find((option) => option.adults === adults && option.children === children) ?? null);
43
+ }
44
+ const FNR_LABEL = "Foreign Non-Resident (FNR)";
45
+ const FNR_EXPLAINER = "FNR means international visitors who are not legal residents of the destination country. This is the standard rate band for overseas safari guests.";
46
+ /** Same wording as the Quest embed's default pricing notice. */
47
+ export function defaultPricingNotice(variant, baselineAdults) {
48
+ const count = `${baselineAdults} ${baselineAdults === 1 ? "person" : "people"}`;
49
+ return variant === "booking"
50
+ ? `Default pricing is for ${count}, ${FNR_LABEL}. ${FNR_EXPLAINER} Change the number of travelers here, or use Inquire for destination and accommodation changes.`
51
+ : `This package is priced for ${count}, ${FNR_LABEL} by default. Tell us in your message if you need a different group size, destinations, or accommodation. ${FNR_EXPLAINER}`;
52
+ }
@@ -0,0 +1,27 @@
1
+ import { type ReactNode } from "react";
2
+ import type { Tour } from "../schema/index.js";
3
+ import { findPartyPriceOption } from "./party-size-rules.js";
4
+ type TourPartySizeState = {
5
+ adults: number;
6
+ children: number;
7
+ setAdults: (adults: number) => void;
8
+ setChildren: (children: number) => void;
9
+ /** Indicative price for the selected group, when the package has party pricing. */
10
+ selected: ReturnType<typeof findPartyPriceOption>;
11
+ };
12
+ /** Shares the chosen group size between the price panel and the booking and inquiry forms. */
13
+ export declare function TourPartySizeProvider({ tour, children: content }: {
14
+ tour: Tour;
15
+ children: ReactNode;
16
+ }): import("react").JSX.Element;
17
+ export declare function useOptionalTourPartySize(): TourPartySizeState | null;
18
+ export declare function useTourPartySize(): TourPartySizeState;
19
+ /** Adults and (when allowed) children selects bound to the shared group size. */
20
+ export declare function TourPartySizeFields({ tour, idPrefix }: {
21
+ tour: Tour;
22
+ idPrefix: string;
23
+ }): import("react").JSX.Element;
24
+ export declare function TourPricing({ tour }: {
25
+ tour: Tour;
26
+ }): import("react").JSX.Element;
27
+ export {};