@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.
- package/README.md +266 -0
- package/dist/booking-widget.css +2 -0
- package/dist/booking-widget.js +4359 -0
- package/dist/booking-widget.umd.cjs +2 -0
- package/package.json +48 -0
- package/src/lib/AvailabilityCalendar.svelte +222 -0
- package/src/lib/CartBar.svelte +32 -0
- package/src/lib/CartBarView.svelte +60 -0
- package/src/lib/CartOverview.svelte +20 -0
- package/src/lib/CartOverviewButton.svelte +120 -0
- package/src/lib/Checkout.svelte +55 -0
- package/src/lib/CheckoutModal.svelte +510 -0
- package/src/lib/ConsentSection.svelte +45 -0
- package/src/lib/ContactForm.svelte +67 -0
- package/src/lib/CountdownTimer.svelte +30 -0
- package/src/lib/EditBookingView.svelte +73 -0
- package/src/lib/OptionCard.svelte +41 -0
- package/src/lib/PaymentPage.svelte +155 -0
- package/src/lib/PickupPointPicker.svelte +76 -0
- package/src/lib/ResultView.svelte +84 -0
- package/src/lib/SelectableCard.svelte +71 -0
- package/src/lib/TicketConfigurator.svelte +78 -0
- package/src/lib/TimeSlotPicker.svelte +45 -0
- package/src/lib/UnitCounter.svelte +91 -0
- package/src/lib/WizardPage.svelte +561 -0
- package/src/lib/api.ts +204 -0
- package/src/lib/app.css +246 -0
- package/src/lib/cart-manager.ts +76 -0
- package/src/lib/client-types.ts +52 -0
- package/src/lib/config.ts +92 -0
- package/src/lib/currency.ts +18 -0
- package/src/lib/elements/bw-cart.svelte +44 -0
- package/src/lib/elements/bw-checkout.svelte +99 -0
- package/src/lib/elements/bw-configurator.svelte +66 -0
- package/src/lib/elements/env.ts +11 -0
- package/src/lib/elements/register.ts +149 -0
- package/src/lib/elements/shared.ts +14 -0
- package/src/lib/elements/theme.css +193 -0
- package/src/lib/fake-api.ts +282 -0
- package/src/lib/generated-types.ts +828 -0
- package/src/lib/index.ts +189 -0
- package/src/lib/messages.ts +3 -0
- package/src/lib/mock-data.ts +241 -0
- package/src/lib/session-manager.ts +75 -0
- package/src/lib/utils.ts +25 -0
package/src/lib/api.ts
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import createClient, { type Middleware } from 'openapi-fetch';
|
|
2
|
+
import type { paths } from './generated-types';
|
|
3
|
+
import type {
|
|
4
|
+
CheckoutSessionDto,
|
|
5
|
+
CheckoutProductDto,
|
|
6
|
+
CheckoutAvailabilityCalendarDto,
|
|
7
|
+
CheckoutAvailabilityDto,
|
|
8
|
+
CheckoutCartResult,
|
|
9
|
+
CheckoutCartDetailDto,
|
|
10
|
+
CheckoutAddCartItemDto,
|
|
11
|
+
CheckoutCartItemAddedDto,
|
|
12
|
+
CheckoutCartPaymentInitiationDto,
|
|
13
|
+
CheckoutCartConfirmResultDto,
|
|
14
|
+
OctoContact,
|
|
15
|
+
} from './client-types';
|
|
16
|
+
import { dateKey } from './utils';
|
|
17
|
+
|
|
18
|
+
export interface BookingApi {
|
|
19
|
+
sessionToken: string;
|
|
20
|
+
|
|
21
|
+
// Session
|
|
22
|
+
startSession(checkoutKey: string): Promise<CheckoutSessionDto>;
|
|
23
|
+
refreshSession(): Promise<CheckoutSessionDto>;
|
|
24
|
+
|
|
25
|
+
// Catalog
|
|
26
|
+
getProduct(productId: string): Promise<CheckoutProductDto>;
|
|
27
|
+
getAvailabilityCalendar(
|
|
28
|
+
productId: string,
|
|
29
|
+
optionId: string,
|
|
30
|
+
): Promise<Record<string, CheckoutAvailabilityCalendarDto>>;
|
|
31
|
+
getAvailability(
|
|
32
|
+
productId: string,
|
|
33
|
+
optionId: string,
|
|
34
|
+
localDate: string,
|
|
35
|
+
): Promise<CheckoutAvailabilityDto[]>;
|
|
36
|
+
|
|
37
|
+
// Cart
|
|
38
|
+
createCart(): Promise<CheckoutCartResult>;
|
|
39
|
+
getCart(): Promise<CheckoutCartDetailDto>;
|
|
40
|
+
addCartItem(request: CheckoutAddCartItemDto): Promise<CheckoutCartItemAddedDto>;
|
|
41
|
+
updateCartItem(itemId: string, request: CheckoutAddCartItemDto): Promise<CheckoutCartItemAddedDto>;
|
|
42
|
+
removeCartItem(itemId: string): Promise<void>;
|
|
43
|
+
payCart(contact: OctoContact): Promise<CheckoutCartPaymentInitiationDto>;
|
|
44
|
+
confirmCart(): Promise<CheckoutCartConfirmResultDto>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export class ApiClient implements BookingApi {
|
|
48
|
+
sessionToken = '';
|
|
49
|
+
private cartToken = '';
|
|
50
|
+
private client;
|
|
51
|
+
|
|
52
|
+
constructor(baseUrl: string) {
|
|
53
|
+
this.client = createClient<paths>({ baseUrl });
|
|
54
|
+
|
|
55
|
+
const authMiddleware: Middleware = {
|
|
56
|
+
onRequest: ({ request }) => {
|
|
57
|
+
request.headers.set('Origin', window.location.origin);
|
|
58
|
+
if (this.sessionToken) {
|
|
59
|
+
request.headers.set('Morii-Checkout-Session', this.sessionToken);
|
|
60
|
+
}
|
|
61
|
+
if (this.cartToken) {
|
|
62
|
+
request.headers.set('Morii-Checkout-Cart', this.cartToken);
|
|
63
|
+
}
|
|
64
|
+
return request;
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
this.client.use(authMiddleware);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
private unwrap<T>(result: { data?: T; error?: unknown; response: Response }): T {
|
|
71
|
+
if (result.data === undefined) {
|
|
72
|
+
throw new Error(`API error ${result.response.status}`);
|
|
73
|
+
}
|
|
74
|
+
return result.data;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// --- Session ---
|
|
78
|
+
|
|
79
|
+
async startSession(checkoutKey: string): Promise<CheckoutSessionDto> {
|
|
80
|
+
const session = this.unwrap(
|
|
81
|
+
await this.client.POST('/v1/checkout/session', {
|
|
82
|
+
body: { checkoutKey },
|
|
83
|
+
}),
|
|
84
|
+
);
|
|
85
|
+
this.sessionToken = session.sessionToken ?? '';
|
|
86
|
+
return session;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async refreshSession(): Promise<CheckoutSessionDto> {
|
|
90
|
+
const session = this.unwrap(
|
|
91
|
+
await this.client.POST('/v1/checkout/session/refresh'),
|
|
92
|
+
);
|
|
93
|
+
this.sessionToken = session.sessionToken ?? '';
|
|
94
|
+
return session;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// --- Catalog ---
|
|
98
|
+
|
|
99
|
+
async getProduct(productId: string): Promise<CheckoutProductDto> {
|
|
100
|
+
return this.unwrap(
|
|
101
|
+
await this.client.GET('/v1/checkout/products/{productId}', {
|
|
102
|
+
params: { path: { productId } },
|
|
103
|
+
}),
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async getAvailabilityCalendar(
|
|
108
|
+
productId: string,
|
|
109
|
+
optionId: string,
|
|
110
|
+
): Promise<Record<string, CheckoutAvailabilityCalendarDto>> {
|
|
111
|
+
const now = new Date();
|
|
112
|
+
const end = new Date(now);
|
|
113
|
+
end.setDate(end.getDate() + 90);
|
|
114
|
+
|
|
115
|
+
const list = this.unwrap(
|
|
116
|
+
await this.client.POST('/v1/checkout/availability/calendar', {
|
|
117
|
+
body: {
|
|
118
|
+
productId,
|
|
119
|
+
optionId,
|
|
120
|
+
localDateStart: dateKey(now),
|
|
121
|
+
localDateEnd: dateKey(end),
|
|
122
|
+
},
|
|
123
|
+
}),
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
const result: Record<string, CheckoutAvailabilityCalendarDto> = {};
|
|
127
|
+
for (const item of list) {
|
|
128
|
+
result[item.localDate] = item;
|
|
129
|
+
}
|
|
130
|
+
return result;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async getAvailability(
|
|
134
|
+
productId: string,
|
|
135
|
+
optionId: string,
|
|
136
|
+
localDate: string,
|
|
137
|
+
): Promise<CheckoutAvailabilityDto[]> {
|
|
138
|
+
return this.unwrap(
|
|
139
|
+
await this.client.POST('/v1/checkout/availability', {
|
|
140
|
+
body: {
|
|
141
|
+
productId,
|
|
142
|
+
optionId,
|
|
143
|
+
localDate,
|
|
144
|
+
},
|
|
145
|
+
}),
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// --- Cart ---
|
|
150
|
+
|
|
151
|
+
async createCart(): Promise<CheckoutCartResult> {
|
|
152
|
+
const cart = this.unwrap(
|
|
153
|
+
await this.client.POST('/v1/checkout/cart'),
|
|
154
|
+
);
|
|
155
|
+
this.cartToken = cart.cartToken ?? '';
|
|
156
|
+
return cart;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async getCart(): Promise<CheckoutCartDetailDto> {
|
|
160
|
+
return this.unwrap(
|
|
161
|
+
await this.client.GET('/v1/checkout/cart'),
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async addCartItem(request: CheckoutAddCartItemDto): Promise<CheckoutCartItemAddedDto> {
|
|
166
|
+
return this.unwrap(
|
|
167
|
+
await this.client.POST('/v1/checkout/cart/items', {
|
|
168
|
+
body: request,
|
|
169
|
+
}),
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async updateCartItem(itemId: string, request: CheckoutAddCartItemDto): Promise<CheckoutCartItemAddedDto> {
|
|
174
|
+
return this.unwrap(
|
|
175
|
+
await this.client.PATCH('/v1/checkout/cart/items/{itemId}', {
|
|
176
|
+
params: { path: { itemId } },
|
|
177
|
+
body: request,
|
|
178
|
+
}),
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async removeCartItem(itemId: string): Promise<void> {
|
|
183
|
+
const result = await this.client.DELETE('/v1/checkout/cart/items/{itemId}', {
|
|
184
|
+
params: { path: { itemId } },
|
|
185
|
+
});
|
|
186
|
+
if (result.response.status >= 400) {
|
|
187
|
+
throw new Error(`API error ${result.response.status}`);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async payCart(contact: OctoContact): Promise<CheckoutCartPaymentInitiationDto> {
|
|
192
|
+
return this.unwrap(
|
|
193
|
+
await this.client.POST('/v1/checkout/cart/pay', {
|
|
194
|
+
body: { contact },
|
|
195
|
+
}),
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async confirmCart(): Promise<CheckoutCartConfirmResultDto> {
|
|
200
|
+
return this.unwrap(
|
|
201
|
+
await this.client.POST('/v1/checkout/cart/confirm'),
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
}
|
package/src/lib/app.css
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/* ── Design tokens ─────────────────────────────────────── */
|
|
2
|
+
/* Host sites override --bw-* variables to theme the widget.
|
|
3
|
+
The Ventrata site sets --header-background, --footer-background, --primary (HSL),
|
|
4
|
+
--primary-light (HSL), --primary-dark (HSL) inline on the root element per supplier.
|
|
5
|
+
We fall back to those so the widget auto-themes to the host's branding. */
|
|
6
|
+
:root {
|
|
7
|
+
--bw-color-primary: var(--header-background, #E30613);
|
|
8
|
+
--bw-color-primary-dark: var(--footer-background, #C00510);
|
|
9
|
+
--bw-color-primary-light: rgba(227, 6, 19, 0.08);
|
|
10
|
+
--bw-color-text: #212121;
|
|
11
|
+
--bw-color-text-secondary: #757575;
|
|
12
|
+
--bw-color-border: #E0E0E0;
|
|
13
|
+
--bw-color-bg: #FFFFFF;
|
|
14
|
+
--bw-color-surface: #F5F5F5;
|
|
15
|
+
--bw-color-success: #4CAF50;
|
|
16
|
+
--bw-color-error: var(--header-background, #E30613);
|
|
17
|
+
--bw-font-family: var(--default-font-family, 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif);
|
|
18
|
+
--bw-radius-sm: 4px;
|
|
19
|
+
--bw-radius-md: var(--radius, 8px);
|
|
20
|
+
--bw-radius-lg: 12px;
|
|
21
|
+
--bw-radius-pill: 9999px;
|
|
22
|
+
--bw-transition: 0.15s ease;
|
|
23
|
+
--bw-shadow-1: 0 1px 3px rgba(0,0,0,0.10), 0 1px 2px rgba(0,0,0,0.06);
|
|
24
|
+
--bw-shadow-2: 0 4px 12px rgba(0,0,0,0.08), 0 2px 4px rgba(0,0,0,0.04);
|
|
25
|
+
--bw-shadow-3: 0 10px 24px rgba(0,0,0,0.10), 0 4px 8px rgba(0,0,0,0.04);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/* ── Reset ─────────────────────────────────────────────── */
|
|
29
|
+
* {
|
|
30
|
+
margin: 0;
|
|
31
|
+
padding: 0;
|
|
32
|
+
box-sizing: border-box;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
body {
|
|
36
|
+
font-family: var(--bw-font-family);
|
|
37
|
+
font-size: 14px;
|
|
38
|
+
line-height: 1.5;
|
|
39
|
+
color: var(--bw-color-text);
|
|
40
|
+
background: var(--bw-color-bg);
|
|
41
|
+
-webkit-font-smoothing: antialiased;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
button { cursor: pointer; font-family: inherit; }
|
|
45
|
+
input, select, textarea { font-family: inherit; }
|
|
46
|
+
|
|
47
|
+
/* ── Spinner ───────────────────────────────────────────── */
|
|
48
|
+
@keyframes spin { to { transform: rotate(360deg); } }
|
|
49
|
+
|
|
50
|
+
.spinner {
|
|
51
|
+
width: 36px;
|
|
52
|
+
height: 36px;
|
|
53
|
+
border: 3px solid var(--bw-color-border);
|
|
54
|
+
border-top-color: var(--bw-color-primary);
|
|
55
|
+
border-radius: 50%;
|
|
56
|
+
animation: spin 0.7s linear infinite;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
.loading-center {
|
|
60
|
+
display: flex;
|
|
61
|
+
align-items: center;
|
|
62
|
+
justify-content: center;
|
|
63
|
+
flex: 1;
|
|
64
|
+
min-height: 120px;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/* ── Page header bar ───────────────────────────────────── */
|
|
68
|
+
.page-header {
|
|
69
|
+
display: flex;
|
|
70
|
+
align-items: center;
|
|
71
|
+
gap: 8px;
|
|
72
|
+
padding: 12px 16px;
|
|
73
|
+
border-bottom: 1px solid var(--bw-color-border);
|
|
74
|
+
background: var(--bw-color-bg);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.page-header h2 {
|
|
78
|
+
font-size: 18px;
|
|
79
|
+
font-weight: 700;
|
|
80
|
+
letter-spacing: -0.01em;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
.page-header .spacer { flex: 1; }
|
|
84
|
+
|
|
85
|
+
/* ── Icon / nav button ─────────────────────────────────── */
|
|
86
|
+
.icon-btn {
|
|
87
|
+
display: flex;
|
|
88
|
+
align-items: center;
|
|
89
|
+
justify-content: center;
|
|
90
|
+
width: 36px;
|
|
91
|
+
height: 36px;
|
|
92
|
+
background: none;
|
|
93
|
+
border: none;
|
|
94
|
+
border-radius: var(--bw-radius-pill);
|
|
95
|
+
font-size: 20px;
|
|
96
|
+
color: var(--bw-color-text);
|
|
97
|
+
transition: background var(--bw-transition);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
.icon-btn:hover {
|
|
101
|
+
background: var(--bw-color-surface);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/* ── Cards ─────────────────────────────────────────────── */
|
|
105
|
+
.card {
|
|
106
|
+
background: var(--bw-color-bg);
|
|
107
|
+
border: 1px solid var(--bw-color-border);
|
|
108
|
+
border-radius: var(--bw-radius-lg);
|
|
109
|
+
padding: 16px;
|
|
110
|
+
transition: border-color var(--bw-transition);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
.card-selectable {
|
|
114
|
+
cursor: pointer;
|
|
115
|
+
text-align: left;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
.card-selectable:hover {
|
|
119
|
+
border-color: var(--bw-color-text);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
.card-selected {
|
|
123
|
+
border-color: var(--bw-color-text);
|
|
124
|
+
border-width: 2px;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/* ── Action bar (full-width bottom button) ─────────────── */
|
|
128
|
+
.action-bar {
|
|
129
|
+
display: flex;
|
|
130
|
+
gap: 10px;
|
|
131
|
+
flex-shrink: 0;
|
|
132
|
+
padding: 12px 16px;
|
|
133
|
+
border-top: 1px solid var(--bw-color-border);
|
|
134
|
+
background: var(--bw-color-bg);
|
|
135
|
+
position: sticky;
|
|
136
|
+
bottom: 0;
|
|
137
|
+
z-index: 10;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
.action-bar .btn-secondary {
|
|
141
|
+
height: 48px;
|
|
142
|
+
padding: 0 20px;
|
|
143
|
+
border: 1px solid var(--bw-color-border);
|
|
144
|
+
background: var(--bw-color-bg);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
.action-bar .btn-primary {
|
|
148
|
+
flex: 1;
|
|
149
|
+
height: 48px;
|
|
150
|
+
font-size: 16px;
|
|
151
|
+
font-weight: 700;
|
|
152
|
+
position: relative;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
.action-bar .btn-primary .arrow {
|
|
156
|
+
position: absolute;
|
|
157
|
+
right: 16px;
|
|
158
|
+
font-size: 20px;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/* ── Buttons ───────────────────────────────────────────── */
|
|
162
|
+
.btn {
|
|
163
|
+
display: inline-flex;
|
|
164
|
+
align-items: center;
|
|
165
|
+
justify-content: center;
|
|
166
|
+
gap: 8px;
|
|
167
|
+
font-size: 14px;
|
|
168
|
+
font-weight: 600;
|
|
169
|
+
border: none;
|
|
170
|
+
border-radius: var(--bw-radius-md);
|
|
171
|
+
padding: 10px 20px;
|
|
172
|
+
transition: background var(--bw-transition), box-shadow var(--bw-transition), opacity var(--bw-transition);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
.btn:disabled {
|
|
176
|
+
opacity: 0.45;
|
|
177
|
+
cursor: default;
|
|
178
|
+
pointer-events: none;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
.btn-primary {
|
|
182
|
+
background: var(--bw-color-primary);
|
|
183
|
+
color: white;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
.btn-primary:hover:not(:disabled) {
|
|
187
|
+
background: var(--bw-color-primary-dark);
|
|
188
|
+
box-shadow: var(--bw-shadow-1);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
.btn-secondary {
|
|
192
|
+
background: var(--bw-color-bg);
|
|
193
|
+
color: var(--bw-color-text);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
.btn-secondary:hover:not(:disabled) {
|
|
197
|
+
background: var(--bw-color-border);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
.btn-outline {
|
|
201
|
+
background: transparent;
|
|
202
|
+
border: 1px solid var(--bw-color-primary);
|
|
203
|
+
color: var(--bw-color-primary);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
.btn-outline:hover:not(:disabled) {
|
|
207
|
+
background: var(--bw-color-primary-light);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
.btn-split {
|
|
211
|
+
justify-content: space-between;
|
|
212
|
+
padding: 0 20px;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/* ── Form inputs ───────────────────────────────────────── */
|
|
216
|
+
.input {
|
|
217
|
+
width: 100%;
|
|
218
|
+
padding: 10px 12px;
|
|
219
|
+
border: 1px solid var(--bw-color-border);
|
|
220
|
+
border-radius: var(--bw-radius-md);
|
|
221
|
+
font-size: 14px;
|
|
222
|
+
outline: none;
|
|
223
|
+
transition: border-color var(--bw-transition), box-shadow var(--bw-transition);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
.input:focus {
|
|
227
|
+
border-color: var(--bw-color-primary);
|
|
228
|
+
box-shadow: 0 0 0 3px var(--bw-color-primary-light);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
.input-error {
|
|
232
|
+
border-color: var(--bw-color-error);
|
|
233
|
+
background: #FEF2F2;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
.input-error:focus {
|
|
237
|
+
box-shadow: 0 0 0 3px rgba(227, 6, 19, 0.12);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/* ── Section titles ────────────────────────────────────── */
|
|
241
|
+
.section-title {
|
|
242
|
+
font-size: 14px;
|
|
243
|
+
font-weight: 400;
|
|
244
|
+
color: var(--bw-color-text-secondary);
|
|
245
|
+
margin-bottom: 12px;
|
|
246
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { CheckoutCartResult } from './client-types';
|
|
2
|
+
import type { BookingApi } from './api';
|
|
3
|
+
|
|
4
|
+
const STORAGE_KEY = 'morii-checkout-cart';
|
|
5
|
+
|
|
6
|
+
interface StoredCart {
|
|
7
|
+
cartToken: string;
|
|
8
|
+
absoluteExpiresAt: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export class CartManager {
|
|
12
|
+
private cart: CheckoutCartResult | null = null;
|
|
13
|
+
|
|
14
|
+
constructor(private api: BookingApi) {
|
|
15
|
+
this.loadFromStorage();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
get cartToken(): string {
|
|
19
|
+
return this.cart?.cartToken ?? '';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
get hasCart(): boolean {
|
|
23
|
+
return this.cart !== null && !this.isExpired();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async ensureCart(): Promise<CheckoutCartResult> {
|
|
27
|
+
if (this.cart && !this.isExpired()) {
|
|
28
|
+
return this.cart;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const cart = await this.api.createCart();
|
|
32
|
+
this.cart = cart;
|
|
33
|
+
this.saveToStorage();
|
|
34
|
+
return cart;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
private isExpired(): boolean {
|
|
38
|
+
if (!this.cart) return true;
|
|
39
|
+
return new Date(this.cart.absoluteExpiresAt ?? 0).getTime() <= Date.now();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
private saveToStorage(): void {
|
|
43
|
+
if (!this.cart) return;
|
|
44
|
+
const stored: StoredCart = {
|
|
45
|
+
cartToken: this.cart.cartToken ?? '',
|
|
46
|
+
absoluteExpiresAt: this.cart.absoluteExpiresAt ?? '',
|
|
47
|
+
};
|
|
48
|
+
try {
|
|
49
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(stored));
|
|
50
|
+
} catch {
|
|
51
|
+
// localStorage unavailable (e.g. iframe sandbox)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
private loadFromStorage(): void {
|
|
56
|
+
try {
|
|
57
|
+
const raw = localStorage.getItem(STORAGE_KEY);
|
|
58
|
+
if (!raw) return;
|
|
59
|
+
|
|
60
|
+
const stored: StoredCart = JSON.parse(raw);
|
|
61
|
+
if (new Date(stored.absoluteExpiresAt).getTime() <= Date.now()) {
|
|
62
|
+
localStorage.removeItem(STORAGE_KEY);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
this.cart = {
|
|
67
|
+
cartToken: stored.cartToken,
|
|
68
|
+
issuedAt: '',
|
|
69
|
+
idleExpiresAt: '',
|
|
70
|
+
absoluteExpiresAt: stored.absoluteExpiresAt,
|
|
71
|
+
};
|
|
72
|
+
} catch {
|
|
73
|
+
// corrupt or unavailable
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { components } from './generated-types';
|
|
2
|
+
|
|
3
|
+
type S = components['schemas'];
|
|
4
|
+
|
|
5
|
+
// Generated API types — re-exported so consumers use direct imports
|
|
6
|
+
// instead of bracket access (S['CheckoutProductDto']) throughout the codebase.
|
|
7
|
+
export type CheckoutSessionDto = S['CheckoutSessionDto'];
|
|
8
|
+
export type CheckoutProductDto = S['CheckoutProductDto'];
|
|
9
|
+
export type CheckoutOptionDto = S['CheckoutOptionDto'];
|
|
10
|
+
export type CheckoutUnitDto = S['CheckoutUnitDto'];
|
|
11
|
+
export type CheckoutUnitPricingDto = S['CheckoutUnitPricingDto'];
|
|
12
|
+
export type CheckoutPickupLocationDto = S['CheckoutPickupLocationDto'];
|
|
13
|
+
export type CheckoutAvailabilityCalendarDto = S['CheckoutAvailabilityCalendarDto'];
|
|
14
|
+
export type CheckoutAvailabilityDto = S['CheckoutAvailabilityDto'];
|
|
15
|
+
export type CheckoutCartResult = S['CheckoutCartResult'];
|
|
16
|
+
export type CheckoutCartDetailDto = S['CheckoutCartDetailDto'];
|
|
17
|
+
export type CheckoutCartItemDetailDto = S['CheckoutCartItemDetailDto'];
|
|
18
|
+
export type CheckoutAddCartItemDto = S['CheckoutAddCartItemDto'];
|
|
19
|
+
export type CheckoutCartItemAddedDto = S['CheckoutCartItemAddedDto'];
|
|
20
|
+
export type CheckoutCartPaymentInitiationDto = S['CheckoutCartPaymentInitiationDto'];
|
|
21
|
+
export type CheckoutCartConfirmResultDto = S['CheckoutCartConfirmResultDto'];
|
|
22
|
+
export type CheckoutCartConfirmItemResultDto = S['CheckoutCartConfirmItemResultDto'];
|
|
23
|
+
export type OctoContact = S['OctoContact'];
|
|
24
|
+
|
|
25
|
+
// Client-side types
|
|
26
|
+
export type PaymentOutcome = 'successful' | 'pending' | 'failed' | 'cancelled';
|
|
27
|
+
|
|
28
|
+
export interface PaymentStatus {
|
|
29
|
+
outcome: PaymentOutcome;
|
|
30
|
+
resultCode?: string;
|
|
31
|
+
resultDescription?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface CartItem {
|
|
35
|
+
productId: string;
|
|
36
|
+
optionId: string;
|
|
37
|
+
optionTitle: string;
|
|
38
|
+
availabilityId: string;
|
|
39
|
+
localDate?: string;
|
|
40
|
+
pickupPointId?: string;
|
|
41
|
+
units: CartUnitItem[];
|
|
42
|
+
totalPrice: number;
|
|
43
|
+
currency: string;
|
|
44
|
+
currencyPrecision: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface CartUnitItem {
|
|
48
|
+
unitId: string;
|
|
49
|
+
quantity: number;
|
|
50
|
+
title: string;
|
|
51
|
+
pricePerUnit: number;
|
|
52
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
export type WidgetMode = 'configurator' | 'checkout' | 'cart-overview';
|
|
2
|
+
|
|
3
|
+
export type CartOverviewDisplay = 'bar' | 'button';
|
|
4
|
+
|
|
5
|
+
export type WizardWidgetType = 'option' | 'age-category' | 'date' | 'time' | 'pickup' | 'addon';
|
|
6
|
+
|
|
7
|
+
export type WizardPages = WizardWidgetType[][];
|
|
8
|
+
|
|
9
|
+
export const DEFAULT_WIZARD_PAGES: WizardPages = [
|
|
10
|
+
['option', 'age-category'],
|
|
11
|
+
['date', 'time'],
|
|
12
|
+
['pickup'],
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
export interface AppConfig {
|
|
16
|
+
apiBaseUrl: string;
|
|
17
|
+
productId: string;
|
|
18
|
+
checkoutKey: string;
|
|
19
|
+
currency: string;
|
|
20
|
+
useFakeApi: boolean;
|
|
21
|
+
mode: WidgetMode;
|
|
22
|
+
cartDisplay: CartOverviewDisplay;
|
|
23
|
+
orderId: string;
|
|
24
|
+
wizardPages: WizardPages;
|
|
25
|
+
autoSelectSingleTimeSlot: boolean;
|
|
26
|
+
name: string;
|
|
27
|
+
cancelable: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function readConfig(): AppConfig {
|
|
31
|
+
const params = new URLSearchParams(window.location.search);
|
|
32
|
+
|
|
33
|
+
if (params.has('product') || params.has('mode')) {
|
|
34
|
+
const apiBaseUrl = params.get('apiBaseUrl') ?? '';
|
|
35
|
+
const productId = params.get('product') ?? '';
|
|
36
|
+
return {
|
|
37
|
+
apiBaseUrl,
|
|
38
|
+
productId,
|
|
39
|
+
checkoutKey: params.get('checkoutKey') ?? deriveCheckoutKey(productId),
|
|
40
|
+
currency: params.get('currency') ?? 'ZAR',
|
|
41
|
+
useFakeApi: params.get('useFakeApi') === 'true' || !apiBaseUrl,
|
|
42
|
+
mode: parseMode(params.get('mode')),
|
|
43
|
+
cartDisplay: parseCartDisplay(params.get('cartDisplay')),
|
|
44
|
+
orderId: params.get('orderId') ?? '',
|
|
45
|
+
wizardPages: parseWizardPages(params.get('wizard')),
|
|
46
|
+
autoSelectSingleTimeSlot: params.get('autoSelectSingleTimeSlot') === 'true',
|
|
47
|
+
name: params.get('name') ?? '',
|
|
48
|
+
cancelable: params.get('cancelable') === 'true',
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
apiBaseUrl: '',
|
|
54
|
+
productId: '',
|
|
55
|
+
checkoutKey: 'morii',
|
|
56
|
+
currency: 'ZAR',
|
|
57
|
+
useFakeApi: true,
|
|
58
|
+
mode: 'configurator',
|
|
59
|
+
cartDisplay: 'bar',
|
|
60
|
+
orderId: '',
|
|
61
|
+
wizardPages: DEFAULT_WIZARD_PAGES,
|
|
62
|
+
autoSelectSingleTimeSlot: false,
|
|
63
|
+
name: '',
|
|
64
|
+
cancelable: false,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function parseMode(value: string | null): WidgetMode {
|
|
69
|
+
if (value === 'checkout' || value === 'cart-overview') return value;
|
|
70
|
+
return 'configurator';
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function parseCartDisplay(value: string | null): CartOverviewDisplay {
|
|
74
|
+
if (value === 'button') return 'button';
|
|
75
|
+
return 'bar';
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Demo only — products whose id starts with a-j use "morii", the rest use "csbs".
|
|
79
|
+
// This will be replaced with a real checkout key lookup.
|
|
80
|
+
function deriveCheckoutKey(productId: string): string {
|
|
81
|
+
const first = productId[0]?.toLowerCase() ?? '';
|
|
82
|
+
return first >= 'a' && first <= 'j' ? 'morii' : 'csbs';
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function parseWizardPages(value: string | null): WizardPages {
|
|
86
|
+
if (!value) return DEFAULT_WIZARD_PAGES;
|
|
87
|
+
try {
|
|
88
|
+
const parsed = JSON.parse(value);
|
|
89
|
+
if (Array.isArray(parsed) && parsed.every(Array.isArray)) return parsed;
|
|
90
|
+
} catch { /* use default */ }
|
|
91
|
+
return DEFAULT_WIZARD_PAGES;
|
|
92
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
const symbols: Record<string, string> = {
|
|
2
|
+
ZAR: 'R',
|
|
3
|
+
USD: '$',
|
|
4
|
+
EUR: '\u20AC',
|
|
5
|
+
GBP: '\u00A3',
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export function formatCurrency(
|
|
9
|
+
amountInMinorUnits: number,
|
|
10
|
+
currency: string,
|
|
11
|
+
currencyPrecision: number,
|
|
12
|
+
decimalPlaces = 0,
|
|
13
|
+
): string {
|
|
14
|
+
const divisor = 10 ** currencyPrecision;
|
|
15
|
+
const amount = amountInMinorUnits / divisor;
|
|
16
|
+
const symbol = symbols[currency] ?? currency;
|
|
17
|
+
return `${symbol}\u00A0${amount.toFixed(decimalPlaces)}`;
|
|
18
|
+
}
|