@code-collective/booking-widget 1.0.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 (45) hide show
  1. package/README.md +266 -0
  2. package/dist/booking-widget.css +2 -0
  3. package/dist/booking-widget.js +4359 -0
  4. package/dist/booking-widget.umd.cjs +2 -0
  5. package/package.json +48 -0
  6. package/src/lib/AvailabilityCalendar.svelte +222 -0
  7. package/src/lib/CartBar.svelte +32 -0
  8. package/src/lib/CartBarView.svelte +60 -0
  9. package/src/lib/CartOverview.svelte +20 -0
  10. package/src/lib/CartOverviewButton.svelte +120 -0
  11. package/src/lib/Checkout.svelte +55 -0
  12. package/src/lib/CheckoutModal.svelte +510 -0
  13. package/src/lib/ConsentSection.svelte +45 -0
  14. package/src/lib/ContactForm.svelte +67 -0
  15. package/src/lib/CountdownTimer.svelte +30 -0
  16. package/src/lib/EditBookingView.svelte +73 -0
  17. package/src/lib/OptionCard.svelte +41 -0
  18. package/src/lib/PaymentPage.svelte +155 -0
  19. package/src/lib/PickupPointPicker.svelte +76 -0
  20. package/src/lib/ResultView.svelte +84 -0
  21. package/src/lib/SelectableCard.svelte +71 -0
  22. package/src/lib/TicketConfigurator.svelte +78 -0
  23. package/src/lib/TimeSlotPicker.svelte +45 -0
  24. package/src/lib/UnitCounter.svelte +91 -0
  25. package/src/lib/WizardPage.svelte +561 -0
  26. package/src/lib/api.ts +204 -0
  27. package/src/lib/app.css +246 -0
  28. package/src/lib/cart-manager.ts +76 -0
  29. package/src/lib/client-types.ts +52 -0
  30. package/src/lib/config.ts +92 -0
  31. package/src/lib/currency.ts +18 -0
  32. package/src/lib/elements/bw-cart.svelte +44 -0
  33. package/src/lib/elements/bw-checkout.svelte +99 -0
  34. package/src/lib/elements/bw-configurator.svelte +66 -0
  35. package/src/lib/elements/env.ts +11 -0
  36. package/src/lib/elements/register.ts +149 -0
  37. package/src/lib/elements/shared.ts +14 -0
  38. package/src/lib/elements/theme.css +193 -0
  39. package/src/lib/fake-api.ts +282 -0
  40. package/src/lib/generated-types.ts +828 -0
  41. package/src/lib/index.ts +189 -0
  42. package/src/lib/messages.ts +3 -0
  43. package/src/lib/mock-data.ts +241 -0
  44. package/src/lib/session-manager.ts +75 -0
  45. package/src/lib/utils.ts +25 -0
@@ -0,0 +1,189 @@
1
+ import './app.css';
2
+ import { mount, unmount } from 'svelte';
3
+ import type { WizardPages, CartOverviewDisplay } from './config';
4
+ import type { BookingApi } from './api';
5
+ import { ApiClient } from './api';
6
+ import { FakeApiClient } from './fake-api';
7
+ import { SessionManager } from './session-manager';
8
+ import { CartManager } from './cart-manager';
9
+ import TicketConfiguratorComponent from './TicketConfigurator.svelte';
10
+ import CheckoutComponent from './Checkout.svelte';
11
+ import CartOverviewComponent from './CartOverview.svelte';
12
+
13
+ // Re-export components for direct Svelte usage
14
+ export { default as TicketConfigurator } from './TicketConfigurator.svelte';
15
+ export { default as Checkout } from './Checkout.svelte';
16
+ export { default as CartOverview } from './CartOverview.svelte';
17
+ export { default as CartBar } from './CartBar.svelte';
18
+ export { default as CartOverviewButton } from './CartOverviewButton.svelte';
19
+
20
+ // Re-export utilities
21
+ export { ApiClient, FakeApiClient, SessionManager, CartManager };
22
+ export type { BookingApi } from './api';
23
+ export type { WizardPages, CartOverviewDisplay, WidgetMode, WizardWidgetType } from './config';
24
+ export type { components, operations, paths } from './generated-types';
25
+ export type * from './client-types';
26
+
27
+ // --- Mount function configs ---
28
+
29
+ interface BaseConfig {
30
+ apiBaseUrl?: string;
31
+ checkoutKey?: string;
32
+ useFakeApi?: boolean;
33
+ }
34
+
35
+ export interface ConfiguratorConfig extends BaseConfig {
36
+ productId: string;
37
+ currency?: string;
38
+ wizardPages?: WizardPages;
39
+ autoSelectSingleTimeSlot?: boolean;
40
+ cancelable?: boolean;
41
+ onCartChange?: (detail: { itemCount: number; cartItemId: string; totalFormatted: string }) => void;
42
+ onCancel?: () => void;
43
+ }
44
+
45
+ export interface CheckoutConfig extends BaseConfig {
46
+ wizardPages?: WizardPages;
47
+ onClose?: () => void;
48
+ onOrderConfirmed?: (detail: { cartToken: string; value: number; currency: string }) => void;
49
+ }
50
+
51
+ export interface CartOverviewConfig extends BaseConfig {
52
+ display?: CartOverviewDisplay;
53
+ onCheckout?: () => void;
54
+ }
55
+
56
+ export interface MountedWidget {
57
+ destroy: () => void;
58
+ }
59
+
60
+ // --- Shared bootstrap ---
61
+
62
+ function bootstrap(config: BaseConfig): { api: BookingApi; sessionManager: SessionManager; cartManager: CartManager } {
63
+ const api: BookingApi = config.useFakeApi
64
+ ? new FakeApiClient()
65
+ : new ApiClient(config.apiBaseUrl ?? '');
66
+
67
+ const sessionManager = new SessionManager(api);
68
+ const cartManager = new CartManager(api);
69
+ sessionManager.startBackgroundRefresh();
70
+
71
+ return { api, sessionManager, cartManager };
72
+ }
73
+
74
+ // --- Mount functions ---
75
+
76
+ export async function mountConfigurator(target: HTMLElement, config: ConfiguratorConfig): Promise<MountedWidget> {
77
+ const { api, sessionManager, cartManager } = bootstrap(config);
78
+
79
+ await sessionManager.ensureSession(config.checkoutKey ?? '');
80
+
81
+ const component = mount(TicketConfiguratorComponent, {
82
+ target,
83
+ props: {
84
+ api,
85
+ cartManager,
86
+ productId: config.productId,
87
+ wizardPages: config.wizardPages,
88
+ autoSelectSingleTimeSlot: config.autoSelectSingleTimeSlot ?? false,
89
+ onCancel: config.onCancel,
90
+ },
91
+ });
92
+
93
+ // Listen for postMessage events and forward to callbacks
94
+ function messageHandler(e: MessageEvent) {
95
+ let d: Record<string, unknown>;
96
+ try { d = typeof e.data === 'string' ? JSON.parse(e.data) : e.data; }
97
+ catch { return; }
98
+ if (!d?.type) return;
99
+
100
+ if (d.type === 'cart:change' && config.onCartChange) {
101
+ config.onCartChange({
102
+ itemCount: (d.itemCount as number) ?? 0,
103
+ cartItemId: (d.cartItemId as string) ?? '',
104
+ totalFormatted: (d.totalFormatted as string) ?? '',
105
+ });
106
+ }
107
+ }
108
+ window.addEventListener('message', messageHandler);
109
+
110
+ return {
111
+ destroy() {
112
+ window.removeEventListener('message', messageHandler);
113
+ sessionManager.stop();
114
+ unmount(component);
115
+ },
116
+ };
117
+ }
118
+
119
+ export async function mountCheckout(target: HTMLElement, config: CheckoutConfig): Promise<MountedWidget> {
120
+ const { api, sessionManager } = bootstrap(config);
121
+
122
+ await sessionManager.ensureSession(config.checkoutKey ?? '');
123
+
124
+ const component = mount(CheckoutComponent, {
125
+ target,
126
+ props: {
127
+ api,
128
+ wizardPages: config.wizardPages,
129
+ },
130
+ });
131
+
132
+ function messageHandler(e: MessageEvent) {
133
+ let d: Record<string, unknown>;
134
+ try { d = typeof e.data === 'string' ? JSON.parse(e.data) : e.data; }
135
+ catch { return; }
136
+ if (!d?.type) return;
137
+
138
+ if (d.type === 'modal:close' && config.onClose) config.onClose();
139
+ if (d.type === 'order:complete' && config.onOrderConfirmed) {
140
+ config.onOrderConfirmed({
141
+ cartToken: (d.cartToken as string) ?? '',
142
+ value: (d.value as number) ?? 0,
143
+ currency: (d.currency as string) ?? 'ZAR',
144
+ });
145
+ }
146
+ }
147
+ window.addEventListener('message', messageHandler);
148
+
149
+ return {
150
+ destroy() {
151
+ window.removeEventListener('message', messageHandler);
152
+ sessionManager.stop();
153
+ unmount(component);
154
+ },
155
+ };
156
+ }
157
+
158
+ export async function mountCartOverview(target: HTMLElement, config: CartOverviewConfig): Promise<MountedWidget> {
159
+ const { api, sessionManager, cartManager } = bootstrap(config);
160
+
161
+ await sessionManager.ensureSession(config.checkoutKey ?? '');
162
+
163
+ const component = mount(CartOverviewComponent, {
164
+ target,
165
+ props: {
166
+ api,
167
+ cartManager,
168
+ display: config.display ?? 'bar',
169
+ },
170
+ });
171
+
172
+ function messageHandler(e: MessageEvent) {
173
+ let d: Record<string, unknown>;
174
+ try { d = typeof e.data === 'string' ? JSON.parse(e.data) : e.data; }
175
+ catch { return; }
176
+ if (!d?.type) return;
177
+
178
+ if (d.type === 'modal:open' && config.onCheckout) config.onCheckout();
179
+ }
180
+ window.addEventListener('message', messageHandler);
181
+
182
+ return {
183
+ destroy() {
184
+ window.removeEventListener('message', messageHandler);
185
+ sessionManager.stop();
186
+ unmount(component);
187
+ },
188
+ };
189
+ }
@@ -0,0 +1,3 @@
1
+ export function postMessage(data: Record<string, unknown>): void {
2
+ window.parent?.postMessage(JSON.stringify(data), '*');
3
+ }
@@ -0,0 +1,241 @@
1
+ import type {
2
+ CheckoutUnitDto,
3
+ CheckoutPickupLocationDto,
4
+ CheckoutOptionDto,
5
+ CheckoutProductDto,
6
+ CheckoutUnitPricingDto,
7
+ CheckoutAvailabilityCalendarDto,
8
+ CheckoutAvailabilityDto,
9
+ } from './client-types';
10
+
11
+ const adultUnit: CheckoutUnitDto = {
12
+ id: 'unit_adult',
13
+ type: 'ADULT',
14
+ title: 'Adult',
15
+ restrictions: { minAge: 12, maxAge: 100 },
16
+ pricing: [{ retail: 76500, currency: 'ZAR', currencyPrecision: 2 }],
17
+ };
18
+
19
+ const childUnit: CheckoutUnitDto = {
20
+ id: 'unit_child',
21
+ type: 'CHILD',
22
+ title: 'Junior',
23
+ restrictions: { minAge: 2, maxAge: 11 },
24
+ pricing: [{ retail: 49500, currency: 'ZAR', currencyPrecision: 2 }],
25
+ };
26
+
27
+ const pickupLocations: CheckoutPickupLocationDto[] = [
28
+ {
29
+ id: 'pickup_long_street',
30
+ title: 'Long Street',
31
+ directions: 'Corner of Long and Kloof Street, outside the Engen garage',
32
+ address: '186 Long St, Cape Town City Centre, 8001',
33
+ latitude: -33.9249,
34
+ longitude: 18.4167,
35
+ },
36
+ {
37
+ id: 'pickup_camps_bay',
38
+ title: 'Camps Bay',
39
+ directions: 'At the main bus stop on Victoria Road',
40
+ address: 'Victoria Rd, Camps Bay, Cape Town, 8005',
41
+ latitude: -33.9506,
42
+ longitude: 18.3782,
43
+ },
44
+ {
45
+ id: 'pickup_sea_point_1',
46
+ title: 'Sea Point Stop 1',
47
+ directions: 'Outside Winchester Mansions, Beach Road',
48
+ address: '221 Beach Rd, Sea Point, Cape Town, 8005',
49
+ latitude: -33.9171,
50
+ longitude: 18.3889,
51
+ },
52
+ {
53
+ id: 'pickup_va_waterfront',
54
+ title: 'V&A Waterfront',
55
+ directions: 'Two Oceans Aquarium bus bay, Dock Road',
56
+ address: 'Dock Rd, V&A Waterfront, Cape Town, 8001',
57
+ latitude: -33.908,
58
+ longitude: 18.4178,
59
+ },
60
+ ];
61
+
62
+ function makeOption(id: string, internalName: string, title: string, description: string): CheckoutOptionDto {
63
+ return {
64
+ id,
65
+ internalName,
66
+ title,
67
+ description,
68
+ units: [adultUnit, childUnit],
69
+ pickupAvailable: true,
70
+ pickupRequired: true,
71
+ pickupLocations,
72
+ };
73
+ }
74
+
75
+ export const sampleProductData: CheckoutProductDto = {
76
+ id: '241c39c3-9c76-4a6f-bb38-3a4f18d08a61',
77
+ internalName: 'Franschhoek Wine Tram Xplorer Tour',
78
+ title: 'Franschhoek Wine Tram Xplorer Tour',
79
+ description: 'Explore the Franschhoek Wine Valley by heritage tram and open-top bus.',
80
+ options: [
81
+ makeOption(
82
+ 'option_blue_tram',
83
+ 'BLUE Line - Tram then Bus',
84
+ 'BLUE Line - Tram then Bus',
85
+ 'Start your journey on the heritage tram through the vineyards, then transfer to the bus.',
86
+ ),
87
+ makeOption(
88
+ 'option_blue_bus',
89
+ 'BLUE Line - Bus then Tram',
90
+ 'BLUE Line - Bus then Tram',
91
+ 'Begin with the hop-on bus route, then switch to the heritage tram.',
92
+ ),
93
+ makeOption(
94
+ 'option_pink_tram',
95
+ 'PINK Line - Tram then Bus',
96
+ 'PINK Line - Tram then Bus',
97
+ 'Explore the PINK route starting on the tram through the winelands.',
98
+ ),
99
+ makeOption(
100
+ 'option_pink_bus',
101
+ 'PINK Line - Bus then Tram',
102
+ 'PINK Line - Bus then Tram',
103
+ 'Explore the PINK route starting on the bus before switching to the tram.',
104
+ ),
105
+ makeOption(
106
+ 'option_red_tram',
107
+ 'RED Line - Tram then Bus',
108
+ 'RED Line - Tram then Bus',
109
+ 'Ride the RED route beginning with the scenic tram leg.',
110
+ ),
111
+ makeOption(
112
+ 'option_red_bus',
113
+ 'RED Line - Bus then Tram',
114
+ 'RED Line - Bus then Tram',
115
+ 'Take the RED route starting on the bus, then enjoy the tram.',
116
+ ),
117
+ makeOption(
118
+ 'option_green_bus',
119
+ 'GREEN Line - Bus then Tram',
120
+ 'GREEN Line - Bus then Tram',
121
+ 'The GREEN route begins with a comfortable bus ride before the tram.',
122
+ ),
123
+ makeOption(
124
+ 'option_green_tram',
125
+ 'GREEN Line - Tram then Bus',
126
+ 'GREEN Line - Tram then Bus',
127
+ 'The GREEN route begins with the heritage tram through the valley.',
128
+ ),
129
+ ],
130
+ };
131
+
132
+ function formatDate(d: Date): string {
133
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
134
+ }
135
+
136
+ function isWeekend(d: Date): boolean {
137
+ const day = d.getDay();
138
+ return day === 0 || day === 6;
139
+ }
140
+
141
+ function isHalloween(d: Date): boolean {
142
+ return d.getMonth() === 9 && d.getDate() === 31 && d.getFullYear() === 2026;
143
+ }
144
+
145
+ function makeUnitPricing(): CheckoutUnitPricingDto[] {
146
+ return [
147
+ { unitId: 'unit_adult', original: 88500, retail: 76500, net: 65000, currency: 'ZAR', currencyPrecision: 2 },
148
+ { unitId: 'unit_child', original: 49500, retail: 49500, net: 42000, currency: 'ZAR', currencyPrecision: 2 },
149
+ ];
150
+ }
151
+
152
+ export function generateSampleAvailability(): Record<string, CheckoutAvailabilityCalendarDto> {
153
+ const result: Record<string, CheckoutAvailabilityCalendarDto> = {};
154
+ const today = new Date();
155
+ today.setHours(0, 0, 0, 0);
156
+
157
+ let soldOutCounter = 0;
158
+
159
+ for (let i = 0; i < 90; i++) {
160
+ const d = new Date(today);
161
+ d.setDate(d.getDate() + i);
162
+ const key = formatDate(d);
163
+
164
+ if (isWeekend(d)) {
165
+ result[key] = { localDate: key, available: false, status: 'CLOSED', openingHours: null };
166
+ continue;
167
+ }
168
+
169
+ soldOutCounter++;
170
+
171
+ if (soldOutCounter % 5 === 0) {
172
+ result[key] = {
173
+ localDate: key,
174
+ available: false,
175
+ status: 'SOLD_OUT',
176
+ openingHours: [{ from: '08:45', to: '17:00' }],
177
+ unitPricing: makeUnitPricing(),
178
+ };
179
+ continue;
180
+ }
181
+
182
+ if (isHalloween(d)) {
183
+ result[key] = {
184
+ localDate: key,
185
+ available: true,
186
+ status: 'AVAILABLE',
187
+ openingHours: null,
188
+ unitPricing: makeUnitPricing(),
189
+ };
190
+ continue;
191
+ }
192
+
193
+ result[key] = {
194
+ localDate: key,
195
+ available: true,
196
+ status: 'AVAILABLE',
197
+ openingHours: [{ from: '08:45', to: '17:00' }],
198
+ unitPricing: makeUnitPricing(),
199
+ };
200
+ }
201
+
202
+ return result;
203
+ }
204
+
205
+ export function generateSampleTimeSlots(date: Date): CheckoutAvailabilityDto[] {
206
+ const dateStr = formatDate(date);
207
+
208
+ if (isHalloween(date)) {
209
+ return [
210
+ {
211
+ id: `avail_${dateStr}_allday`,
212
+ localDateTimeStart: `${dateStr}T00:00:00`,
213
+ localDateTimeEnd: `${dateStr}T23:59:59`,
214
+ allDay: true,
215
+ available: true,
216
+ status: 'AVAILABLE',
217
+ vacancies: 50,
218
+ utcCutoffAt: `${dateStr}T22:00:00Z`,
219
+ openingHours: null,
220
+ unitPricing: makeUnitPricing(),
221
+ },
222
+ ];
223
+ }
224
+
225
+ return [
226
+ {
227
+ id: `avail_${dateStr}_0845`,
228
+ localDateTimeStart: `${dateStr}T08:45:00`,
229
+ localDateTimeEnd: `${dateStr}T17:00:00`,
230
+ allDay: false,
231
+ available: true,
232
+ status: 'AVAILABLE',
233
+ vacancies: 24,
234
+ utcCutoffAt: `${dateStr}T06:45:00Z`,
235
+ openingHours: [{ from: '08:45', to: '17:00' }],
236
+ pickupAvailable: true,
237
+ pickupRequired: true,
238
+ unitPricing: makeUnitPricing(),
239
+ },
240
+ ];
241
+ }
@@ -0,0 +1,75 @@
1
+ import type { CheckoutSessionDto } from './client-types';
2
+ import type { BookingApi } from './api';
3
+
4
+ const REFRESH_INTERVAL_MS = 5 * 60_000;
5
+ const EXPIRY_BUFFER_MS = 2 * 60_000;
6
+
7
+ export class SessionManager {
8
+ private sessions = new Map<string, CheckoutSessionDto>();
9
+ private intervalId: ReturnType<typeof setInterval> | null = null;
10
+ private activeCheckoutKey = '';
11
+
12
+ constructor(private api: BookingApi) {}
13
+
14
+ async ensureSession(checkoutKey: string): Promise<CheckoutSessionDto> {
15
+ const cached = this.sessions.get(checkoutKey);
16
+
17
+ if (cached && !this.isExpired(cached)) {
18
+ this.activateSession(checkoutKey, cached);
19
+ return cached;
20
+ }
21
+
22
+ const session = await this.api.startSession(checkoutKey);
23
+ this.sessions.set(checkoutKey, session);
24
+ this.activeCheckoutKey = checkoutKey;
25
+ return session;
26
+ }
27
+
28
+ startBackgroundRefresh(): void {
29
+ if (this.intervalId) return;
30
+ this.intervalId = setInterval(() => this.refreshExpiringSessions(), REFRESH_INTERVAL_MS);
31
+ }
32
+
33
+ stop(): void {
34
+ if (this.intervalId) {
35
+ clearInterval(this.intervalId);
36
+ this.intervalId = null;
37
+ }
38
+ }
39
+
40
+ private activateSession(checkoutKey: string, session: CheckoutSessionDto): void {
41
+ this.activeCheckoutKey = checkoutKey;
42
+ this.api.sessionToken = session.sessionToken ?? '';
43
+ }
44
+
45
+ private isExpired(session: CheckoutSessionDto): boolean {
46
+ return new Date(session.expiresAt ?? 0).getTime() <= Date.now();
47
+ }
48
+
49
+ private isExpiringSoon(session: CheckoutSessionDto): boolean {
50
+ return new Date(session.refreshAfter ?? 0).getTime() <= Date.now() + EXPIRY_BUFFER_MS;
51
+ }
52
+
53
+ private async refreshExpiringSessions(): Promise<void> {
54
+ for (const [key, session] of this.sessions) {
55
+ if (this.isExpired(session)) {
56
+ this.sessions.delete(key);
57
+ continue;
58
+ }
59
+
60
+ if (!this.isExpiringSoon(session)) continue;
61
+
62
+ try {
63
+ this.api.sessionToken = session.sessionToken ?? '';
64
+ const refreshed = await this.api.refreshSession();
65
+ this.sessions.set(key, refreshed);
66
+
67
+ if (key === this.activeCheckoutKey) {
68
+ this.api.sessionToken = refreshed.sessionToken ?? '';
69
+ }
70
+ } catch {
71
+ this.sessions.delete(key);
72
+ }
73
+ }
74
+ }
75
+ }
@@ -0,0 +1,25 @@
1
+ import type { CartUnitItem } from './client-types';
2
+
3
+ export function dateKey(d: Date): string {
4
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
5
+ }
6
+
7
+ export function formatTime(isoDateTime: string): string {
8
+ const dt = new Date(isoDateTime);
9
+ return `${String(dt.getHours()).padStart(2, '0')}:${String(dt.getMinutes()).padStart(2, '0')}`;
10
+ }
11
+
12
+ export function parseDateFromAvailabilityId(id: string): string | null {
13
+ const match = id.match(/\d{4}-\d{2}-\d{2}/);
14
+ return match ? match[0] : null;
15
+ }
16
+
17
+ export function expandUnitItems(units: CartUnitItem[]): { unitId: string }[] {
18
+ const items: { unitId: string }[] = [];
19
+ for (const u of units) {
20
+ for (let i = 0; i < u.quantity; i++) {
21
+ items.push({ unitId: u.unitId });
22
+ }
23
+ }
24
+ return items;
25
+ }