@nuitee/booking-widget 1.0.2 → 1.0.4

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.
@@ -4,6 +4,7 @@ import { Calendar, Users, User, Check, MapPin, Phone, Square, CreditCard, Lock,
4
4
  import '../core/styles.css';
5
5
  import { createBookingApi, formatPrice, buildCheckoutPayload, buildPaymentIntentPayload } from '../core/booking-api.js';
6
6
  import { STRIPE_PUBLISHABLE_KEY, API_BASE_URL } from '../core/stripe-config.js';
7
+ import { fetchRuntimeConfig } from '../utils/config-service.js';
7
8
 
8
9
  const BASE_STEPS = [
9
10
  { key: 'dates', label: 'Dates & Guests', num: '01' },
@@ -98,6 +99,11 @@ const BookingWidget = ({
98
99
  const [checkoutShowPaymentForm, setCheckoutShowPaymentForm] = useState(false);
99
100
  const [isClosing, setIsClosing] = useState(false);
100
101
  const [isReadyForOpen, setIsReadyForOpen] = useState(false);
102
+ const [configLoading, setConfigLoading] = useState(false);
103
+ const [configLoaded, setConfigLoaded] = useState(false);
104
+ const [configError, setConfigError] = useState(null);
105
+ const [runtimeWidgetStyles, setRuntimeWidgetStyles] = useState({});
106
+ const [configRetryCount, setConfigRetryCount] = useState(0);
101
107
  const widgetRef = useRef(null);
102
108
  const stripeRef = useRef(null);
103
109
  const elementsRef = useRef(null);
@@ -220,26 +226,43 @@ const BookingWidget = ({
220
226
  };
221
227
  }, [state.step, confirmationToken, effectiveConfirmationBaseUrl, isSandbox]);
222
228
 
223
- // Apply custom colors
229
+ // Fetch runtime config (colors) from /load-config on mount and when propertyKey/colors change.
230
+ // The config service caches API results so re-renders triggered by other prop changes are cheap.
224
231
  useEffect(() => {
225
- if (widgetRef.current && colors) {
226
- const style = widgetRef.current.style;
227
- if (colors.background) {
228
- style.setProperty('--bg', colors.background);
229
- style.setProperty('--card', colors.background);
230
- }
231
- if (colors.text) {
232
- style.setProperty('--fg', colors.text);
233
- style.setProperty('--card-fg', colors.text);
234
- }
235
- if (colors.primary) {
236
- style.setProperty('--primary', colors.primary);
237
- }
238
- if (colors.primaryText) {
239
- style.setProperty('--primary-fg', colors.primaryText);
240
- }
232
+ if (!propertyKey || !String(propertyKey).trim()) {
233
+ setConfigError('propertyKey is required to initialize the booking widget.');
234
+ setConfigLoading(false);
235
+ setConfigLoaded(false);
236
+ setRuntimeWidgetStyles({});
237
+ return;
241
238
  }
242
- }, [colors, isOpen]);
239
+ let cancelled = false;
240
+ setConfigLoading(true);
241
+ setConfigError(null);
242
+ setConfigLoaded(false);
243
+ fetchRuntimeConfig(propertyKey, colors, mode)
244
+ .then(({ widgetStyles }) => {
245
+ if (cancelled) return;
246
+ setRuntimeWidgetStyles(widgetStyles);
247
+ setConfigLoaded(true);
248
+ setConfigLoading(false);
249
+ })
250
+ .catch((err) => {
251
+ if (cancelled) return;
252
+ setConfigError(err?.message || 'Failed to load widget configuration.');
253
+ setConfigLoading(false);
254
+ });
255
+ return () => { cancelled = true; };
256
+ }, [propertyKey, colors, mode, configRetryCount]);
257
+
258
+ // Apply resolved CSS custom properties to the widget element.
259
+ useEffect(() => {
260
+ if (!widgetRef.current) return;
261
+ const style = widgetRef.current.style;
262
+ Object.entries(runtimeWidgetStyles).forEach(([key, val]) => {
263
+ if (val != null && val !== '') style.setProperty(key, val);
264
+ });
265
+ }, [runtimeWidgetStyles, isOpen]);
243
266
 
244
267
  const getNights = () => {
245
268
  if (!state.checkIn || !state.checkOut) return 0;
@@ -278,6 +301,8 @@ const BookingWidget = ({
278
301
  const stepIndex = (key) => STEPS.findIndex(s => s.key === key);
279
302
 
280
303
  const goToStep = (step) => {
304
+ // Block room/rate navigation until runtime config (colors) has been loaded.
305
+ if ((step === 'rooms' || step === 'rates') && !configLoaded) return;
281
306
  setApiError(null);
282
307
  if (step !== 'summary' && step !== 'payment') setCheckoutShowPaymentForm(false);
283
308
  if (step === 'payment') setCheckoutShowPaymentForm(true);
@@ -1181,20 +1206,58 @@ const BookingWidget = ({
1181
1206
  onTransitionEnd={handleTransitionEnd}
1182
1207
  >
1183
1208
  <button className="booking-widget-close" onClick={requestClose}><X size={24} /></button>
1184
- {hasPropertyKey && renderStepIndicator()}
1209
+ {configLoaded && hasPropertyKey && renderStepIndicator()}
1185
1210
  {(!propertyKey || (typeof propertyKey === 'string' && !propertyKey.trim())) ? (
1186
- <div className="booking-widget-property-key-message" role="alert" style={{ margin: '1.5em', padding: '1em', background: 'var(--destructive, #ef4444)', color: '#fff', borderRadius: '8px', fontSize: '0.9em' }}>
1187
- The propertyKey is missing. Please provide it as a prop to load rooms from the API.
1211
+ <div className="booking-widget-config-error" role="alert">
1212
+ <div className="booking-widget-config-error__icon-wrap">
1213
+ <Lock size={22} strokeWidth={1.5} />
1214
+ </div>
1215
+ <span className="booking-widget-config-error__badge">
1216
+ <Lock size={10} strokeWidth={2} /> Missing Configuration
1217
+ </span>
1218
+ <h3 className="booking-widget-config-error__title">Widget Not Configured</h3>
1219
+ <p className="booking-widget-config-error__desc">
1220
+ A <code>propertyKey</code> prop is required to initialize this booking widget. Please provide it to load rooms and availability.
1221
+ </p>
1222
+ <div className="booking-widget-config-error__divider" />
1223
+ <p className="booking-widget-config-error__hint">
1224
+ Contact the site administrator to configure this widget.
1225
+ </p>
1226
+ </div>
1227
+ ) : configError ? (
1228
+ <div className="booking-widget-config-error" role="alert">
1229
+ <div className="booking-widget-config-error__icon-wrap">
1230
+ <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
1231
+ </div>
1232
+ <span className="booking-widget-config-error__badge">Configuration Error</span>
1233
+ <h3 className="booking-widget-config-error__title">Could Not Load Config</h3>
1234
+ <p className="booking-widget-config-error__desc">{configError}</p>
1235
+ <div className="booking-widget-config-error__divider" />
1236
+ <p className="booking-widget-config-error__hint">
1237
+ Please try again or contact support.
1238
+ </p>
1239
+ <button
1240
+ className="booking-widget-config-error__retry"
1241
+ onClick={() => setConfigRetryCount(c => c + 1)}
1242
+ >
1243
+ <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M23 4v6h-6"/><path d="M1 20v-6h6"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>
1244
+ Try Again
1245
+ </button>
1246
+ </div>
1247
+ ) : configLoading ? (
1248
+ <div className="booking-widget-config-loading">
1249
+ <div className="booking-widget-config-loading__spinner" />
1250
+ <span className="booking-widget-config-loading__text">Loading configuration…</span>
1188
1251
  </div>
1189
1252
  ) : (
1190
- <div className="booking-widget-step-content">
1191
- {state.step === 'dates' && renderDatesStep()}
1192
- {state.step === 'rooms' && renderRoomsStep()}
1193
- {state.step === 'rates' && renderRatesStep()}
1194
- {state.step === 'summary' && renderSummaryStep()}
1195
- {state.step === 'payment' && renderPaymentStep()}
1196
- {state.step === 'confirmation' && renderConfirmationStep()}
1197
- </div>
1253
+ <div className="booking-widget-step-content">
1254
+ {state.step === 'dates' && renderDatesStep()}
1255
+ {state.step === 'rooms' && renderRoomsStep()}
1256
+ {state.step === 'rates' && renderRatesStep()}
1257
+ {state.step === 'summary' && renderSummaryStep()}
1258
+ {state.step === 'payment' && renderPaymentStep()}
1259
+ {state.step === 'confirmation' && renderConfirmationStep()}
1260
+ </div>
1198
1261
  )}
1199
1262
  </div>
1200
1263
  </>
@@ -81,6 +81,7 @@
81
81
  --bg: hsl(30, 10%, 8%);
82
82
  --fg: hsl(40, 20%, 90%);
83
83
  --card: hsl(30, 8%, 12%);
84
+ --card-solid: hsl(30, 8%, 12%);
84
85
  --card-fg: hsl(40, 20%, 90%);
85
86
  --primary: hsl(38, 60%, 55%);
86
87
  --primary-fg: hsl(30, 10%, 8%);
@@ -106,6 +107,145 @@
106
107
  font-weight: 500;
107
108
  }
108
109
 
110
+ /* ===== Config Error State ===== */
111
+ .booking-widget-config-error {
112
+ display: flex;
113
+ flex-direction: column;
114
+ align-items: center;
115
+ justify-content: center;
116
+ gap: 1.1em;
117
+ padding: 4em 2em;
118
+ text-align: center;
119
+ min-height: 300px;
120
+ }
121
+
122
+ .booking-widget-config-error__icon-wrap {
123
+ width: 5em;
124
+ height: 5em;
125
+ border-radius: 50%;
126
+ background: radial-gradient(circle, rgba(239,68,68,0.12) 0%, rgba(239,68,68,0.04) 100%);
127
+ border: 1.5px solid rgba(239, 68, 68, 0.22);
128
+ box-shadow: 0 0 0 6px rgba(239, 68, 68, 0.06);
129
+ display: flex;
130
+ align-items: center;
131
+ justify-content: center;
132
+ color: #f87171;
133
+ flex-shrink: 0;
134
+ margin-bottom: 0.25em;
135
+ }
136
+
137
+ .booking-widget-config-error__badge {
138
+ display: inline-flex;
139
+ align-items: center;
140
+ gap: 0.4em;
141
+ font-size: 0.7em;
142
+ font-weight: 600;
143
+ letter-spacing: 0.12em;
144
+ text-transform: uppercase;
145
+ color: #f87171;
146
+ background: rgba(239, 68, 68, 0.1);
147
+ border: 1px solid rgba(239, 68, 68, 0.18);
148
+ border-radius: 99em;
149
+ padding: 0.3em 0.85em;
150
+ }
151
+
152
+ .booking-widget-config-error__title {
153
+ font-family: var(--font-serif, 'Playfair Display', Georgia, serif);
154
+ font-size: 1.35em;
155
+ font-weight: 600;
156
+ color: var(--fg, #e8e0d5);
157
+ margin: 0;
158
+ letter-spacing: -0.01em;
159
+ }
160
+
161
+ .booking-widget-config-error__desc {
162
+ font-size: 0.875em;
163
+ color: var(--secondary-fg, #a09080);
164
+ max-width: 25em;
165
+ line-height: 1.7;
166
+ margin: 0;
167
+ }
168
+
169
+ .booking-widget-config-error__desc code {
170
+ font-family: 'SFMono-Regular', 'Consolas', 'Liberation Mono', Menlo, monospace;
171
+ font-size: 0.88em;
172
+ background: rgba(255, 255, 255, 0.06);
173
+ color: var(--primary, #f59e0b);
174
+ padding: 0.12em 0.45em;
175
+ border-radius: 0.3em;
176
+ border: 1px solid rgba(255, 255, 255, 0.09);
177
+ }
178
+
179
+ .booking-widget-config-error__divider {
180
+ width: 2.5em;
181
+ height: 1.5px;
182
+ background: var(--border, rgba(255,255,255,0.1));
183
+ border-radius: 1px;
184
+ }
185
+
186
+ .booking-widget-config-error__hint {
187
+ font-size: 0.78em;
188
+ color: var(--muted, #6b5f50);
189
+ max-width: 21em;
190
+ line-height: 1.6;
191
+ margin: 0;
192
+ }
193
+
194
+ .booking-widget-config-error__retry {
195
+ display: inline-flex;
196
+ align-items: center;
197
+ gap: 0.45em;
198
+ padding: 0.55em 1.4em;
199
+ background: transparent;
200
+ color: var(--secondary-fg, #a09080);
201
+ border: 1.5px solid var(--border, rgba(255,255,255,0.13));
202
+ border-radius: 99em;
203
+ font-size: 0.8em;
204
+ font-family: var(--font-sans, system-ui, sans-serif);
205
+ font-weight: 500;
206
+ cursor: pointer;
207
+ letter-spacing: 0.02em;
208
+ transition: border-color 0.2s, color 0.2s, background 0.2s;
209
+ margin-top: 0.25em;
210
+ }
211
+
212
+ .booking-widget-config-error__retry:hover {
213
+ border-color: var(--primary, #f59e0b);
214
+ color: var(--primary, #f59e0b);
215
+ background: rgba(245, 158, 11, 0.06);
216
+ }
217
+
218
+ /* ===== Config Loading State ===== */
219
+ .booking-widget-config-loading {
220
+ display: flex;
221
+ flex-direction: column;
222
+ align-items: center;
223
+ justify-content: center;
224
+ gap: 1.25em;
225
+ padding: 4em 2em;
226
+ text-align: center;
227
+ min-height: 300px;
228
+ }
229
+
230
+ .booking-widget-config-loading__spinner {
231
+ width: 2.75em;
232
+ height: 2.75em;
233
+ border: 2px solid var(--border, rgba(255,255,255,0.1));
234
+ border-top-color: var(--primary, hsl(38,60%,55%));
235
+ border-radius: 50%;
236
+ animation: bw-spin 0.75s linear infinite;
237
+ }
238
+
239
+ @keyframes bw-spin {
240
+ to { transform: rotate(360deg); }
241
+ }
242
+
243
+ .booking-widget-config-loading__text {
244
+ font-size: 0.875em;
245
+ color: var(--muted, #888);
246
+ letter-spacing: 0.01em;
247
+ }
248
+
109
249
  /* ===== Step Indicator ===== */
110
250
  .booking-widget-step-indicator {
111
251
  display: flex;
@@ -448,7 +588,7 @@
448
588
  }
449
589
 
450
590
  .booking-widget-modal .date-trigger:hover {
451
- border-color: hsl(38, 60%, 55%, 0.5);
591
+ border-color: var(--primary);
452
592
  }
453
593
 
454
594
  .booking-widget-modal .date-trigger .placeholder {
@@ -472,7 +612,7 @@
472
612
  max-width: calc(100vw - 2em);
473
613
  box-sizing: border-box;
474
614
  z-index: 10;
475
- background: var(--card);
615
+ background: var(--card-solid);
476
616
  border: 1px solid var(--border);
477
617
  border-radius: var(--radius);
478
618
  padding: 1em;
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Runtime configuration service for the booking widget.
3
+ *
4
+ * Fetches styling config from /load-config keyed by propertyKey.
5
+ * Results are cached in memory so subsequent calls for the same key are instant.
6
+ * Throws (or rejects) when propertyKey is missing — callers must handle the
7
+ * "locked" state and display an error UI rather than attempting to fall back.
8
+ */
9
+
10
+ import { DEFAULT_COLORS } from '../core/stripe-config.js';
11
+ import { deriveWidgetStyles } from '../core/color-utils.js';
12
+
13
+ const CONFIG_BASE_URL = 'https://ai.thehotelplanet.com/load-config';
14
+
15
+ /** In-memory cache: propertyKey → raw API color object */
16
+ const _configCache = new Map();
17
+
18
+ /**
19
+ * Map the /load-config response field names to the internal color keys used
20
+ * throughout the widget.
21
+ *
22
+ * API field → internal key
23
+ * widgetBackground → background
24
+ * widgetTextColor → text
25
+ * primaryColor → primary
26
+ * buttonTextColor → primaryText
27
+ * widgetCardColor → card
28
+ */
29
+ function mapApiColors(data) {
30
+ const mapped = {};
31
+ if (data.widgetBackground) mapped.background = data.widgetBackground;
32
+ if (data.widgetTextColor) mapped.text = data.widgetTextColor;
33
+ if (data.primaryColor) mapped.primary = data.primaryColor;
34
+ if (data.buttonTextColor) mapped.primaryText = data.buttonTextColor;
35
+ if (data.widgetCardColor) mapped.card = data.widgetCardColor;
36
+ return mapped;
37
+ }
38
+
39
+ /**
40
+ * Merge colors following priority (highest → lowest):
41
+ * 1. installerColors (explicit values in the `colors` prop win over everything)
42
+ * 2. mappedApiColors (values fetched from /load-config override package defaults)
43
+ * 3. DEFAULT_COLORS (built-in fallback)
44
+ *
45
+ * @param {object} mappedApiColors - Colors mapped from the API response.
46
+ * @param {object|null} installerColors - Colors passed via the `colors` prop.
47
+ * @returns {object} Resolved color set with all five standard keys.
48
+ */
49
+ export function mergeColors(mappedApiColors, installerColors) {
50
+ const base = { ...DEFAULT_COLORS, ...(mappedApiColors || {}) };
51
+ if (!installerColors || typeof installerColors !== 'object') return base;
52
+ return {
53
+ background: installerColors.background ?? base.background,
54
+ text: installerColors.text ?? base.text,
55
+ primary: installerColors.primary ?? base.primary,
56
+ primaryText: installerColors.primaryText ?? base.primaryText,
57
+ card: installerColors.card ?? installerColors.background ?? base.card,
58
+ };
59
+ }
60
+
61
+ /**
62
+ * Fetch runtime styling configuration from the /load-config endpoint.
63
+ *
64
+ * - Throws synchronously (or rejects) when propertyKey is null, undefined, or
65
+ * an empty string. Callers must catch this and render a "Missing Configuration"
66
+ * error state rather than attempting to render the normal widget UI.
67
+ * - Caches the raw API color payload by propertyKey + mode. Subsequent calls for
68
+ * the same key+mode re-use the cache and only re-merge installer overrides.
69
+ *
70
+ * @param {string} propertyKey - The hotel property key / API key.
71
+ * @param {object|null} installerColors - Optional installer color overrides.
72
+ * @param {string} [mode] - Pass 'sandbox' to append &mode=sandbox to the request.
73
+ * @returns {Promise<{ apiColors: object, colors: object, widgetStyles: object }>}
74
+ */
75
+ export async function fetchRuntimeConfig(propertyKey, installerColors = null, mode = null) {
76
+ if (!propertyKey || !String(propertyKey).trim()) {
77
+ throw new Error('propertyKey is required to initialize the booking widget.');
78
+ }
79
+
80
+ const key = String(propertyKey).trim();
81
+ const isSandbox = mode === 'sandbox';
82
+ // Cache separately for sandbox vs live so switching modes gets a fresh fetch.
83
+ const cacheKey = isSandbox ? `${key}:sandbox` : key;
84
+
85
+ if (_configCache.has(cacheKey)) {
86
+ const apiColors = _configCache.get(cacheKey);
87
+ const colors = mergeColors(apiColors, installerColors);
88
+ return { apiColors, colors, widgetStyles: deriveWidgetStyles(colors) };
89
+ }
90
+
91
+ const url = `${CONFIG_BASE_URL}?apikey=${encodeURIComponent(key)}${isSandbox ? '&mode=sandbox' : ''}`;
92
+ const res = await fetch(url);
93
+ if (!res.ok) {
94
+ throw new Error(`Failed to load widget configuration (HTTP ${res.status}).`);
95
+ }
96
+
97
+ const data = await res.json();
98
+ const apiColors = mapApiColors(data);
99
+ _configCache.set(cacheKey, apiColors);
100
+
101
+ const colors = mergeColors(apiColors, installerColors);
102
+ return { apiColors, colors, widgetStyles: deriveWidgetStyles(colors) };
103
+ }
@@ -10,7 +10,7 @@
10
10
  <button class="booking-widget-close" @click="requestClose">
11
11
  <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
12
12
  </button>
13
- <div v-if="hasPropertyKey && state.step !== 'confirmation'" class="booking-widget-step-indicator">
13
+ <div v-if="hasPropertyKey && configLoaded && state.step !== 'confirmation'" class="booking-widget-step-indicator">
14
14
  <template v-for="(step, i) in STEPS" :key="step.key">
15
15
  <div class="step-item">
16
16
  <span
@@ -34,9 +34,45 @@
34
34
  </span>
35
35
  </template>
36
36
  </div>
37
- <div v-if="!propertyKey || !String(propertyKey).trim()" class="booking-widget-property-key-message" role="alert" style="margin: 1.5em; padding: 1em; background: var(--destructive, #ef4444); color: #fff; border-radius: 8px; font-size: 0.9em;">
38
- The propertyKey is missing. Please provide it as a prop to load rooms from the API.
37
+ <!-- Missing propertyKey error -->
38
+ <div v-if="!propertyKey || !String(propertyKey).trim()" class="booking-widget-config-error" role="alert">
39
+ <div class="booking-widget-config-error__icon-wrap">
40
+ <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect><path d="M7 11V7a5 5 0 0 1 10 0v4"></path></svg>
41
+ </div>
42
+ <span class="booking-widget-config-error__badge">
43
+ <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect><path d="M7 11V7a5 5 0 0 1 10 0v4"></path></svg>
44
+ Missing Configuration
45
+ </span>
46
+ <h3 class="booking-widget-config-error__title">Widget Not Configured</h3>
47
+ <p class="booking-widget-config-error__desc">
48
+ A <code>propertyKey</code> prop is required to initialize this booking widget. Please provide it to load rooms and availability.
49
+ </p>
50
+ <div class="booking-widget-config-error__divider"></div>
51
+ <p class="booking-widget-config-error__hint">Contact the site administrator to configure this widget.</p>
52
+ </div>
53
+
54
+ <!-- Config fetch error -->
55
+ <div v-else-if="configError" class="booking-widget-config-error" role="alert">
56
+ <div class="booking-widget-config-error__icon-wrap">
57
+ <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path><line x1="12" y1="9" x2="12" y2="13"></line><line x1="12" y1="17" x2="12.01" y2="17"></line></svg>
58
+ </div>
59
+ <span class="booking-widget-config-error__badge">Configuration Error</span>
60
+ <h3 class="booking-widget-config-error__title">Could Not Load Config</h3>
61
+ <p class="booking-widget-config-error__desc">{{ configError }}</p>
62
+ <div class="booking-widget-config-error__divider"></div>
63
+ <p class="booking-widget-config-error__hint">Please try again or contact support.</p>
64
+ <button class="booking-widget-config-error__retry" @click="_initRuntimeConfig()">
65
+ <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M23 4v6h-6"></path><path d="M1 20v-6h6"></path><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path></svg>
66
+ Try Again
67
+ </button>
68
+ </div>
69
+
70
+ <!-- Config loading -->
71
+ <div v-else-if="configLoading" class="booking-widget-config-loading">
72
+ <div class="booking-widget-config-loading__spinner"></div>
73
+ <span class="booking-widget-config-loading__text">Loading configuration…</span>
39
74
  </div>
75
+
40
76
  <div v-else class="booking-widget-step-content">
41
77
  <!-- Dates Step -->
42
78
  <div v-if="state.step === 'dates'">
@@ -445,6 +481,8 @@ import { loadStripe } from '@stripe/stripe-js';
445
481
  import '../core/styles.css';
446
482
  import { createBookingApi, formatPrice, buildCheckoutPayload, buildPaymentIntentPayload } from '../core/booking-api.js';
447
483
  import { STRIPE_PUBLISHABLE_KEY, API_BASE_URL } from '../core/stripe-config.js';
484
+ import { fetchRuntimeConfig } from '../utils/config-service.js';
485
+
448
486
 
449
487
  const BASE_STEPS = [
450
488
  { key: 'dates', label: 'Dates & Guests', num: '01' },
@@ -497,6 +535,10 @@ export default {
497
535
  loadingRates: false,
498
536
  apiError: null,
499
537
  confirmationCode: null,
538
+ configLoading: false,
539
+ configLoaded: false,
540
+ configError: null,
541
+ runtimeWidgetStyles: {},
500
542
  calendarMonth: new Date().getMonth(),
501
543
  calendarYear: new Date().getFullYear(),
502
544
  pickState: 0,
@@ -595,23 +637,7 @@ export default {
595
637
  return null;
596
638
  },
597
639
  widgetStyles() {
598
- if (!this.colors) return {};
599
- const styles = {};
600
- if (this.colors.background) {
601
- styles['--bg'] = this.colors.background;
602
- styles['--card'] = this.colors.background;
603
- }
604
- if (this.colors.text) {
605
- styles['--fg'] = this.colors.text;
606
- styles['--card-fg'] = this.colors.text;
607
- }
608
- if (this.colors.primary) {
609
- styles['--primary'] = this.colors.primary;
610
- }
611
- if (this.colors.primaryText) {
612
- styles['--primary-fg'] = this.colors.primaryText;
613
- }
614
- return styles;
640
+ return this.runtimeWidgetStyles;
615
641
  },
616
642
  nights() {
617
643
  if (!this.state.checkIn || !this.state.checkOut) return 0;
@@ -697,7 +723,20 @@ export default {
697
723
  return this.isOpen && !this.isClosing && this.isReadyForOpen;
698
724
  },
699
725
  },
726
+ created() {
727
+ this._initRuntimeConfig();
728
+ },
700
729
  watch: {
730
+ propertyKey() {
731
+ this._initRuntimeConfig();
732
+ },
733
+ mode() {
734
+ this._initRuntimeConfig();
735
+ },
736
+ colors: {
737
+ deep: true,
738
+ handler() { this._initRuntimeConfig(); },
739
+ },
701
740
  isOpen: {
702
741
  handler(open) {
703
742
  if (open && this.onOpen) this.onOpen();
@@ -731,6 +770,27 @@ export default {
731
770
  },
732
771
  },
733
772
  methods: {
773
+ async _initRuntimeConfig() {
774
+ if (!this.propertyKey || !String(this.propertyKey).trim()) {
775
+ this.configError = 'propertyKey is required to initialize the booking widget.';
776
+ this.configLoading = false;
777
+ this.configLoaded = false;
778
+ this.runtimeWidgetStyles = {};
779
+ return;
780
+ }
781
+ this.configLoading = true;
782
+ this.configError = null;
783
+ this.configLoaded = false;
784
+ try {
785
+ const { widgetStyles } = await fetchRuntimeConfig(this.propertyKey, this.colors, this.mode);
786
+ this.runtimeWidgetStyles = widgetStyles;
787
+ this.configLoaded = true;
788
+ } catch (err) {
789
+ this.configError = err?.message || 'Failed to load widget configuration.';
790
+ } finally {
791
+ this.configLoading = false;
792
+ }
793
+ },
734
794
  async fetchConfirmationDetails(token) {
735
795
  const t = String(token || '').trim();
736
796
  if (!t) throw new Error('Missing confirmation token');
@@ -802,6 +862,8 @@ export default {
802
862
  return i === ci ? 'active' : i < ci ? 'past' : 'future';
803
863
  },
804
864
  goToStep(step) {
865
+ // Block navigation to room/rate steps until runtime config is loaded.
866
+ if ((step === 'rooms' || step === 'rates') && !this.configLoaded) return;
805
867
  if (step !== 'summary' && step !== 'payment') this.checkoutShowPaymentForm = false;
806
868
  if (step === 'payment') this.checkoutShowPaymentForm = true;
807
869
  this.state.step = step;