@nuitee/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/LICENSE +21 -0
- package/README.md +268 -0
- package/USAGE.md +289 -0
- package/dist/booking-widget-standalone.js +1848 -0
- package/dist/booking-widget.css +1711 -0
- package/dist/booking-widget.js +1256 -0
- package/dist/core/booking-api.js +755 -0
- package/dist/core/stripe-config.js +8 -0
- package/dist/core/styles.css +1711 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.esm.js +5 -0
- package/dist/index.js +5 -0
- package/dist/react/BookingWidget.jsx +1192 -0
- package/dist/react/index.d.ts +1 -0
- package/dist/react/index.js +3 -0
- package/dist/react/styles.css +1711 -0
- package/dist/vue/BookingWidget.vue +1062 -0
- package/dist/vue/index.d.ts +1 -0
- package/dist/vue/index.js +3 -0
- package/dist/vue/styles.css +1711 -0
- package/package.json +98 -0
|
@@ -0,0 +1,1062 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div v-if="isOpen || isClosing">
|
|
3
|
+
<div :class="['booking-widget-overlay', { active: isVisible }]" @click="requestClose"></div>
|
|
4
|
+
<div
|
|
5
|
+
ref="widgetRef"
|
|
6
|
+
:class="['booking-widget-modal', { active: isVisible }]"
|
|
7
|
+
:style="widgetStyles"
|
|
8
|
+
@transitionend="handleTransitionEnd"
|
|
9
|
+
>
|
|
10
|
+
<button class="booking-widget-close" @click="requestClose">
|
|
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
|
+
</button>
|
|
13
|
+
<div v-if="state.step !== 'confirmation'" class="booking-widget-step-indicator">
|
|
14
|
+
<template v-for="(step, i) in STEPS" :key="step.key">
|
|
15
|
+
<div class="step-item">
|
|
16
|
+
<span
|
|
17
|
+
:class="['step-circle', getStepClass(i)]"
|
|
18
|
+
@click="getStepClass(i) === 'past' ? goToStep(step.key) : null"
|
|
19
|
+
>
|
|
20
|
+
<span v-if="i < stepIndex(state.step)" style="display:inline-flex;align-items:center;justify-content:center;">
|
|
21
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>
|
|
22
|
+
</span>
|
|
23
|
+
<span v-else>{{ step.num }}</span>
|
|
24
|
+
</span>
|
|
25
|
+
<span
|
|
26
|
+
:class="['step-label', getStepClass(i)]"
|
|
27
|
+
@click="getStepClass(i) === 'past' ? goToStep(step.key) : null"
|
|
28
|
+
>
|
|
29
|
+
{{ step.label }}
|
|
30
|
+
</span>
|
|
31
|
+
</div>
|
|
32
|
+
<span v-if="i < STEPS.length - 1" class="step-line">
|
|
33
|
+
<span :class="['step-line-fill', { filled: i < stepIndex(state.step) }]"></span>
|
|
34
|
+
</span>
|
|
35
|
+
</template>
|
|
36
|
+
</div>
|
|
37
|
+
<div class="booking-widget-step-content">
|
|
38
|
+
<!-- Dates Step -->
|
|
39
|
+
<div v-if="state.step === 'dates'">
|
|
40
|
+
<h2 class="step-title">Plan Your Stay</h2>
|
|
41
|
+
<p class="step-subtitle">Select your dates and guests to begin</p>
|
|
42
|
+
<div style="max-width:32em;margin:0 auto;">
|
|
43
|
+
<label class="form-label">Dates</label>
|
|
44
|
+
<div class="date-wrapper">
|
|
45
|
+
<div class="date-trigger" @click="calendarOpen = !calendarOpen" style="display:flex;align-items:center;gap:0.5em;">
|
|
46
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect><line x1="16" y1="2" x2="16" y2="6"></line><line x1="8" y1="2" x2="8" y2="6"></line><line x1="3" y1="10" x2="21" y2="10"></line></svg>
|
|
47
|
+
<span :class="{placeholder: !state.checkIn}">{{ state.checkIn ? fmt(state.checkIn) : 'Check-in' }}</span>
|
|
48
|
+
→
|
|
49
|
+
<span :class="{placeholder: !state.checkOut}">{{ state.checkOut ? fmt(state.checkOut) : 'Check-out' }}</span>
|
|
50
|
+
</div>
|
|
51
|
+
<div v-if="calendarOpen" class="calendar-popup open">
|
|
52
|
+
<div class="cal-header">
|
|
53
|
+
<button @click="calNav(-1)">‹</button>
|
|
54
|
+
<span class="cal-title">{{ monthName(calendarMonth) }} {{ calendarYear }}</span>
|
|
55
|
+
<span class="cal-title">{{ monthName(calendarMonth === 11 ? 0 : calendarMonth + 1) }} {{ calendarMonth === 11 ? calendarYear + 1 : calendarYear }}</span>
|
|
56
|
+
<button @click="calNav(1)">›</button>
|
|
57
|
+
</div>
|
|
58
|
+
<div class="cal-months">
|
|
59
|
+
<div class="cal-grid">
|
|
60
|
+
<div v-for="n in buildMonth(calendarYear, calendarMonth).names" :key="n" class="cal-day-name">{{ n }}</div>
|
|
61
|
+
<template v-for="(day, idx) in buildMonth(calendarYear, calendarMonth).daysArray" :key="idx">
|
|
62
|
+
<button v-if="day" :class="day.cls" :disabled="day.disabled" @click="!day.disabled && pickDate(calendarYear, calendarMonth, day.day)">{{ day.day }}</button>
|
|
63
|
+
<div v-else class="cal-day empty"></div>
|
|
64
|
+
</template>
|
|
65
|
+
</div>
|
|
66
|
+
<div class="cal-grid">
|
|
67
|
+
<div v-for="n in buildMonth(calendarYear, calendarMonth === 11 ? 0 : calendarMonth + 1).names" :key="n" class="cal-day-name">{{ n }}</div>
|
|
68
|
+
<template v-for="(day, idx) in buildMonth(calendarYear, calendarMonth === 11 ? 0 : calendarMonth + 1).daysArray" :key="idx">
|
|
69
|
+
<button v-if="day" :class="day.cls" :disabled="day.disabled" @click="!day.disabled && pickDate(calendarMonth === 11 ? calendarYear + 1 : calendarYear, calendarMonth === 11 ? 0 : calendarMonth + 1, day.day)">{{ day.day }}</button>
|
|
70
|
+
<div v-else class="cal-day empty"></div>
|
|
71
|
+
</template>
|
|
72
|
+
</div>
|
|
73
|
+
</div>
|
|
74
|
+
</div>
|
|
75
|
+
</div>
|
|
76
|
+
<label class="form-label" style="margin-top:1.5em;display:flex;align-items:center;gap:0.5em;">
|
|
77
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M22 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path></svg>
|
|
78
|
+
Guests & Rooms
|
|
79
|
+
</label>
|
|
80
|
+
<div class="guests-rooms-section">
|
|
81
|
+
<div v-for="(occ, roomIdx) in (state.occupancies || []).slice(0, state.rooms)" :key="roomIdx" class="room-card">
|
|
82
|
+
<div class="room-card-header">
|
|
83
|
+
<span class="room-card-title">Room {{ roomIdx + 1 }}</span>
|
|
84
|
+
<button v-if="state.rooms > 1" type="button" class="remove-room-btn" @click="removeRoom(roomIdx)" aria-label="Remove room">Remove</button>
|
|
85
|
+
</div>
|
|
86
|
+
<div class="counter-row">
|
|
87
|
+
<span class="counter-label">Adults</span>
|
|
88
|
+
<div class="counter-controls">
|
|
89
|
+
<button class="counter-btn" :disabled="(occ.adults ?? 1) <= 1" @click="changeCounter('adults', -1, roomIdx)">−</button>
|
|
90
|
+
<span class="counter-val">{{ occ.adults ?? 1 }}</span>
|
|
91
|
+
<button class="counter-btn" :disabled="(occ.adults ?? 1) >= 8" @click="changeCounter('adults', 1, roomIdx)">+</button>
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
94
|
+
<div class="counter-row">
|
|
95
|
+
<span class="counter-label">Children</span>
|
|
96
|
+
<div class="counter-controls">
|
|
97
|
+
<button class="counter-btn" :disabled="(occ.children ?? 0) <= 0" @click="changeCounter('children', -1, roomIdx)">−</button>
|
|
98
|
+
<span class="counter-val">{{ occ.children ?? 0 }}</span>
|
|
99
|
+
<button class="counter-btn" :disabled="(occ.children ?? 0) >= 6" @click="changeCounter('children', 1, roomIdx)">+</button>
|
|
100
|
+
</div>
|
|
101
|
+
</div>
|
|
102
|
+
<div v-if="(occ.children ?? 0) > 0" class="child-ages-section" style="display:flex;flex-wrap:wrap;gap:0.5em 1em;">
|
|
103
|
+
<div v-for="i in (occ.children ?? 0)" :key="i" class="child-age-row" style="flex:0 1 calc(50% - 0.5em);min-width:0;margin-top:0.5em;">
|
|
104
|
+
<label class="form-label" style="font-size:0.75em;color:var(--muted);">Child {{ i }} age</label>
|
|
105
|
+
<select
|
|
106
|
+
class="form-input"
|
|
107
|
+
style="width:100%;margin-top:0.25em;"
|
|
108
|
+
:value="(occ.childrenAges || [])[i - 1] ?? 0"
|
|
109
|
+
@change="updateChildAge(roomIdx, i - 1, parseInt($event.target.value, 10))"
|
|
110
|
+
>
|
|
111
|
+
<option v-for="a in 18" :key="a - 1" :value="a - 1">{{ a - 1 }} year{{ (a - 1) !== 1 ? 's' : '' }}</option>
|
|
112
|
+
</select>
|
|
113
|
+
</div>
|
|
114
|
+
</div>
|
|
115
|
+
</div>
|
|
116
|
+
<button type="button" class="add-room-btn" :disabled="state.rooms >= 5" @click="changeCounter('rooms', 1)">+ Add room</button>
|
|
117
|
+
</div>
|
|
118
|
+
<button class="btn-primary" :disabled="!state.checkIn || !state.checkOut" @click="goToStep('rooms')">Select Room</button>
|
|
119
|
+
</div>
|
|
120
|
+
</div>
|
|
121
|
+
|
|
122
|
+
<!-- Rooms Step -->
|
|
123
|
+
<div v-if="state.step === 'rooms'">
|
|
124
|
+
<h2 class="step-title">Choose Your Room</h2>
|
|
125
|
+
<p v-if="apiError" class="step-subtitle" style="color: var(--destructive, #ef4444);">{{ apiError }}</p>
|
|
126
|
+
<p v-else class="step-subtitle">Each space is crafted for an unforgettable experience</p>
|
|
127
|
+
<div v-if="loadingRooms" class="room-grid-wrapper">
|
|
128
|
+
<div class="room-grid">
|
|
129
|
+
<div v-for="i in 4" :key="i" class="room-skeleton">
|
|
130
|
+
<div class="room-skeleton-img"></div>
|
|
131
|
+
<div class="room-skeleton-body">
|
|
132
|
+
<div class="room-skeleton-line title"></div>
|
|
133
|
+
<div class="room-skeleton-line price"></div>
|
|
134
|
+
<div class="room-skeleton-line desc"></div>
|
|
135
|
+
<div class="room-skeleton-line desc"></div>
|
|
136
|
+
<div class="room-skeleton-line desc"></div>
|
|
137
|
+
<div class="room-skeleton-meta">
|
|
138
|
+
<div class="room-skeleton-line meta"></div>
|
|
139
|
+
<div class="room-skeleton-line meta"></div>
|
|
140
|
+
</div>
|
|
141
|
+
<div class="room-skeleton-tags">
|
|
142
|
+
<div class="room-skeleton-tag"></div>
|
|
143
|
+
<div class="room-skeleton-tag"></div>
|
|
144
|
+
<div class="room-skeleton-tag"></div>
|
|
145
|
+
</div>
|
|
146
|
+
</div>
|
|
147
|
+
</div>
|
|
148
|
+
</div>
|
|
149
|
+
</div>
|
|
150
|
+
<template v-else-if="apiError">
|
|
151
|
+
<button class="btn-secondary" @click="goToStep('rooms')">Try again</button>
|
|
152
|
+
</template>
|
|
153
|
+
<template v-else-if="roomsList.length === 0">
|
|
154
|
+
<p class="step-subtitle">No rooms available for the selected dates.</p>
|
|
155
|
+
<div class="rooms-empty-state">
|
|
156
|
+
<p class="rooms-empty-message">Try different dates or check back later.</p>
|
|
157
|
+
<button class="btn-secondary" @click="goToStep('dates')">Change dates</button>
|
|
158
|
+
</div>
|
|
159
|
+
</template>
|
|
160
|
+
<div v-else class="room-grid-wrapper">
|
|
161
|
+
<button class="room-nav-btn room-nav-prev" @click="scrollRooms(-1)" aria-label="Previous rooms">
|
|
162
|
+
<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"><polyline points="15 18 9 12 15 6"></polyline></svg>
|
|
163
|
+
</button>
|
|
164
|
+
<div class="room-grid" ref="roomGridRef">
|
|
165
|
+
<div v-for="r in roomsList" :key="r.id" :class="['room-card', {selected: state.selectedRoom?.id === r.id}]" @click="selectRoom(r.id)" style="flex: 0 0 280px; min-width: 280px;">
|
|
166
|
+
<div class="room-card-img-wrap">
|
|
167
|
+
<img class="room-card-img" :src="r.image" :alt="r.name" @error="$event.target.src='https://images.unsplash.com/photo-1618773928121-c32242e63f39?w=800&q=80'" />
|
|
168
|
+
<div v-if="state.selectedRoom?.id === r.id" class="room-card-check">
|
|
169
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>
|
|
170
|
+
</div>
|
|
171
|
+
</div>
|
|
172
|
+
<div class="room-card-body">
|
|
173
|
+
<div class="room-card-top">
|
|
174
|
+
<span class="room-card-name">{{ r.name }}</span>
|
|
175
|
+
<div class="room-card-price"><strong>{{ formatPrice(nights > 0 ? r.basePrice / nights : r.basePrice, r.currency) }}</strong><small>/ night</small></div>
|
|
176
|
+
</div>
|
|
177
|
+
<p class="room-card-desc">{{ r.description }}</p>
|
|
178
|
+
<div class="room-card-meta">
|
|
179
|
+
<span style="display:flex;align-items:center;gap:0.25em;">
|
|
180
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect></svg>
|
|
181
|
+
{{ formatRoomSize(r.size) }}
|
|
182
|
+
</span>
|
|
183
|
+
<span style="display:flex;align-items:center;gap:0.25em;">
|
|
184
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path><circle cx="12" cy="7" r="4"></circle></svg>
|
|
185
|
+
Up to {{ r.maxGuests }}
|
|
186
|
+
</span>
|
|
187
|
+
</div>
|
|
188
|
+
<div class="amenity-tags"><span v-for="a in (r.amenities || []).slice(0, 5)" :key="a" class="amenity-tag">{{ a }}</span></div>
|
|
189
|
+
</div>
|
|
190
|
+
</div>
|
|
191
|
+
</div>
|
|
192
|
+
<button class="room-nav-btn room-nav-next" @click="scrollRooms(1)" aria-label="Next rooms">
|
|
193
|
+
<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"><polyline points="9 18 15 12 9 6"></polyline></svg>
|
|
194
|
+
</button>
|
|
195
|
+
</div>
|
|
196
|
+
<button class="btn-primary" :disabled="!state.selectedRoom" @click="goToStep('rates')">Select Rate</button>
|
|
197
|
+
</div>
|
|
198
|
+
|
|
199
|
+
<!-- Rates Step -->
|
|
200
|
+
<div v-if="state.step === 'rates'">
|
|
201
|
+
<p v-if="loadingRates" class="step-subtitle">Loading rates...</p>
|
|
202
|
+
<p v-else-if="apiError" class="step-subtitle" style="color: var(--destructive, #ef4444);">{{ apiError }}</p>
|
|
203
|
+
<div v-if="loadingRates" style="padding:2em;text-align:center;color:var(--muted);">Please wait.</div>
|
|
204
|
+
<template v-else-if="apiError">
|
|
205
|
+
<button class="btn-secondary" @click="goToStep('rates')">Try again</button>
|
|
206
|
+
</template>
|
|
207
|
+
<template v-else>
|
|
208
|
+
<div class="rate-step-card">
|
|
209
|
+
<div v-if="state.selectedRoom" class="rate-step-room-summary">
|
|
210
|
+
<img class="rate-step-room-summary-image" :src="state.selectedRoom.image" alt="" @error="$event.target.src='https://images.unsplash.com/photo-1618773928121-c32242e63f39?w=800&q=80'" />
|
|
211
|
+
<div class="rate-step-room-summary-body">
|
|
212
|
+
<h3 class="rate-step-room-summary-name">{{ state.selectedRoom.name }}</h3>
|
|
213
|
+
<p v-if="state.selectedRoom.description" class="rate-step-room-summary-desc">{{ state.selectedRoom.description }}</p>
|
|
214
|
+
<div class="rate-step-room-summary-meta">
|
|
215
|
+
<span v-if="state.selectedRoom.size">{{ formatRoomSize(state.selectedRoom.size) }}</span>
|
|
216
|
+
<span v-if="state.selectedRoom.maxGuests != null">Up to {{ state.selectedRoom.maxGuests }} guests</span>
|
|
217
|
+
</div>
|
|
218
|
+
</div>
|
|
219
|
+
</div>
|
|
220
|
+
<template v-if="ratesList.length === 0">
|
|
221
|
+
<p style="color:var(--muted);font-size:0.9em;margin:0;padding:1em;">No rates available for this room.</p>
|
|
222
|
+
</template>
|
|
223
|
+
<div v-else class="rate-list">
|
|
224
|
+
<div v-for="r in ratesList" :key="r.id" :class="['rate-card', {selected: state.selectedRate?.id === r.id}]" @click="selectRate(r.id)">
|
|
225
|
+
<div v-if="r.recommended" class="rate-badge">
|
|
226
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon></svg>
|
|
227
|
+
Recommended
|
|
228
|
+
</div>
|
|
229
|
+
<div class="rate-top">
|
|
230
|
+
<div class="rate-top-left">
|
|
231
|
+
<div class="rate-radio"><div class="rate-radio-dot"></div></div>
|
|
232
|
+
<span class="rate-name">{{ [r.policy, ...(r.benefits || [])].filter(Boolean).join(' ') }}</span>
|
|
233
|
+
<div class="rate-benefits"><span class="amenity-tag">{{ r.rate_code ?? r.name }}</span></div>
|
|
234
|
+
</div>
|
|
235
|
+
<div class="rate-price"><strong>{{ formatPrice(Math.round(state.selectedRoom?.basePrice * r.priceModifier * state.rooms), state.selectedRoom?.currency) }}</strong><small>total</small></div>
|
|
236
|
+
</div>
|
|
237
|
+
<p v-if="r.policy" class="rate-policy" style="font-size:0.8em;color:var(--muted);margin-top:0.5em;display:flex;align-items:center;gap:0.35em;">
|
|
238
|
+
<span>{{ r.policy }}</span>
|
|
239
|
+
<span class="policy-info-icon" :title="r.policyDetail || r.policy" aria-label="Policy details">
|
|
240
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4M12 8h.01"/></svg>
|
|
241
|
+
</span>
|
|
242
|
+
</p>
|
|
243
|
+
</div>
|
|
244
|
+
</div>
|
|
245
|
+
</div>
|
|
246
|
+
<button v-if="ratesList.length > 0" class="btn-primary" :disabled="!state.selectedRate" @click="goToStep('summary')">Proceed to Summary</button>
|
|
247
|
+
<button v-else class="btn-secondary" @click="goToStep('rooms')">Choose another room</button>
|
|
248
|
+
</template>
|
|
249
|
+
</div>
|
|
250
|
+
|
|
251
|
+
<!-- Summary Step -->
|
|
252
|
+
<div v-if="state.step === 'summary'">
|
|
253
|
+
<h2 class="step-title">Review Your Booking</h2>
|
|
254
|
+
<p class="step-subtitle">Confirm your details before payment</p>
|
|
255
|
+
<div class="checkout-grid">
|
|
256
|
+
<div>
|
|
257
|
+
<h3 style="font-size:0.65em;text-transform:uppercase;letter-spacing:0.2em;color:var(--muted);padding-bottom:0.5em;border-bottom:1px solid var(--border);margin-bottom:1em;font-family:var(--font-sans);font-weight:500;">Guest Information</h3>
|
|
258
|
+
<div class="form-row">
|
|
259
|
+
<div class="form-group">
|
|
260
|
+
<label class="form-label">First Name</label>
|
|
261
|
+
<input class="form-input" :value="state.guest.firstName" @input="updateGuest('firstName', $event.target.value)" placeholder="James" />
|
|
262
|
+
</div>
|
|
263
|
+
<div class="form-group">
|
|
264
|
+
<label class="form-label">Last Name</label>
|
|
265
|
+
<input class="form-input" :value="state.guest.lastName" @input="updateGuest('lastName', $event.target.value)" placeholder="Bond" />
|
|
266
|
+
</div>
|
|
267
|
+
</div>
|
|
268
|
+
<div class="form-group">
|
|
269
|
+
<label class="form-label">Email</label>
|
|
270
|
+
<input class="form-input" type="email" :value="state.guest.email" @input="updateGuest('email', $event.target.value)" placeholder="james@example.com" />
|
|
271
|
+
</div>
|
|
272
|
+
<div class="form-group">
|
|
273
|
+
<label class="form-label">Phone</label>
|
|
274
|
+
<input class="form-input" type="tel" :value="state.guest.phone" @input="updateGuest('phone', $event.target.value)" placeholder="+1 (555) 000-0000" />
|
|
275
|
+
</div>
|
|
276
|
+
<div class="form-group">
|
|
277
|
+
<label class="form-label">Special Requests</label>
|
|
278
|
+
<textarea class="form-textarea" rows="3" :value="state.guest.specialRequests" @input="updateGuest('specialRequests', $event.target.value)" placeholder="Any special requests..."></textarea>
|
|
279
|
+
</div>
|
|
280
|
+
</div>
|
|
281
|
+
<div>
|
|
282
|
+
<div class="summary-box">
|
|
283
|
+
<h3>Booking Summary</h3>
|
|
284
|
+
<img v-if="state.selectedRoom" :src="state.selectedRoom.image" alt="" style="width:100%;height:8em;object-fit:cover;border-radius:0.5em;margin-bottom:0.75em;" @error="$event.target.src='https://images.unsplash.com/photo-1618773928121-c32242e63f39?w=800&q=80'" />
|
|
285
|
+
<p v-if="state.selectedRoom" style="font-family:var(--font-serif);margin-bottom:1em;">{{ state.selectedRoom.name }}</p>
|
|
286
|
+
<div class="summary-row"><span>Check-in</span><span>{{ fmt(state.checkIn) }}</span></div>
|
|
287
|
+
<div class="summary-row"><span>Check-out</span><span>{{ fmt(state.checkOut) }}</span></div>
|
|
288
|
+
<div class="summary-row"><span>Guests</span><span>{{ totalGuests.adults }} adults{{ totalGuests.children ? ', ' + totalGuests.children + ' children' : '' }}</span></div>
|
|
289
|
+
<div class="summary-row"><span>Rate</span><span>{{ state.selectedRate ? [state.selectedRate.policy, ...(state.selectedRate.benefits || [])].filter(Boolean).join(' ') : '—' }}</span></div>
|
|
290
|
+
<template v-if="checkoutFeeRows.length">
|
|
291
|
+
<div class="summary-row">
|
|
292
|
+
<span>{{ checkoutFeeRows[0].label }}</span>
|
|
293
|
+
<span>{{ formatPrice(checkoutFeeRows[0].amount, state.selectedRoom?.currency) }}</span>
|
|
294
|
+
</div>
|
|
295
|
+
<div class="summary-fees">
|
|
296
|
+
<p class="summary-fees-heading">Fees & taxes</p>
|
|
297
|
+
<p v-if="checkoutFeesSection && checkoutFeesSection.allIncluded" class="summary-fees-note">Included in your rate</p>
|
|
298
|
+
<div class="summary-fees-list">
|
|
299
|
+
<div v-for="(row, i) in checkoutFeeRows.slice(1)" :key="i" class="summary-row summary-row--fee">
|
|
300
|
+
<span class="summary-fee-label">
|
|
301
|
+
{{ row.name }}
|
|
302
|
+
<span v-if="row.name !== 'VAT' || row.amount !== 0" :class="['summary-fee-badge', { 'summary-fee-badge--excluded': !row.included }]">
|
|
303
|
+
{{ row.included ? 'Included' : 'Excluded' }}
|
|
304
|
+
</span>
|
|
305
|
+
</span>
|
|
306
|
+
<span>{{ formatPrice(row.amount, state.selectedRoom?.currency) }}</span>
|
|
307
|
+
</div>
|
|
308
|
+
</div>
|
|
309
|
+
</div>
|
|
310
|
+
</template>
|
|
311
|
+
<div class="summary-total">
|
|
312
|
+
<span class="summary-total-label">Total</span>
|
|
313
|
+
<span class="summary-total-price">{{ formatPrice(checkoutTotal, state.selectedRoom?.currency) }}</span>
|
|
314
|
+
</div>
|
|
315
|
+
<p v-if="apiError" style="color:var(--destructive, #ef4444);font-size:0.85em;margin-top:0.5em;">{{ apiError }}</p>
|
|
316
|
+
<button
|
|
317
|
+
class="btn-primary"
|
|
318
|
+
style="max-width:100%;margin-top:1em;display:flex;align-items:center;justify-content:center;gap:0.5em;"
|
|
319
|
+
:disabled="!canSubmit"
|
|
320
|
+
@click="confirmReservation"
|
|
321
|
+
>
|
|
322
|
+
{{ hasStripe ? 'Proceed to Payment' : 'Confirm Reservation' }}
|
|
323
|
+
</button>
|
|
324
|
+
<p class="secure-note" style="display:flex;align-items:center;justify-content:center;gap:0.5em;">
|
|
325
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" 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>
|
|
326
|
+
Secure & encrypted booking
|
|
327
|
+
</p>
|
|
328
|
+
</div>
|
|
329
|
+
</div>
|
|
330
|
+
</div>
|
|
331
|
+
</div>
|
|
332
|
+
|
|
333
|
+
<!-- Payment Step (Stripe) -->
|
|
334
|
+
<div v-if="state.step === 'payment'" class="payment-step">
|
|
335
|
+
<button type="button" class="payment-step-back" aria-label="Back to summary" @click="goToStep('summary')">
|
|
336
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"></polyline></svg>
|
|
337
|
+
Back to summary
|
|
338
|
+
</button>
|
|
339
|
+
<h2 class="step-title">Payment</h2>
|
|
340
|
+
<p class="step-subtitle">Enter your payment details to confirm your reservation</p>
|
|
341
|
+
<div class="checkout-grid">
|
|
342
|
+
<div class="payment-step-form">
|
|
343
|
+
<div class="checkout-payment-section">
|
|
344
|
+
<h3 style="font-size:0.65em;text-transform:uppercase;letter-spacing:0.2em;color:var(--muted);margin-bottom:0.5em;font-family:var(--font-sans);font-weight:500;">Card details</h3>
|
|
345
|
+
<p v-if="!paymentElementReady && !apiError" class="payment-loading-placeholder" style="font-size:0.85em;color:var(--muted);margin:0;">Loading payment form…</p>
|
|
346
|
+
<div id="booking-widget-payment-element" class="booking-widget-payment-element" v-once></div>
|
|
347
|
+
<p v-if="apiError" class="payment-setup-error" style="font-size:0.85em;color:var(--destructive, #ef4444);margin:0;">{{ apiError }}</p>
|
|
348
|
+
</div>
|
|
349
|
+
</div>
|
|
350
|
+
<div class="payment-step-summary">
|
|
351
|
+
<h3>Amount due</h3>
|
|
352
|
+
<div class="payment-total-row">
|
|
353
|
+
<span class="payment-total-label">Total</span>
|
|
354
|
+
<span class="payment-total-amount">{{ formatPrice(checkoutTotal, state.selectedRoom?.currency) }}</span>
|
|
355
|
+
</div>
|
|
356
|
+
<button
|
|
357
|
+
class="btn-primary"
|
|
358
|
+
:disabled="!paymentElementReady"
|
|
359
|
+
@click="confirmReservation"
|
|
360
|
+
>
|
|
361
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="1" y="4" width="22" height="16" rx="2" ry="2"></rect><line x1="1" y1="10" x2="23" y2="10"></line></svg>
|
|
362
|
+
{{ !paymentElementReady ? 'Loading payment…' : 'Confirm Reservation' }}
|
|
363
|
+
</button>
|
|
364
|
+
<p class="secure-note">
|
|
365
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" 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>
|
|
366
|
+
Secure & encrypted booking
|
|
367
|
+
</p>
|
|
368
|
+
</div>
|
|
369
|
+
</div>
|
|
370
|
+
</div>
|
|
371
|
+
|
|
372
|
+
<!-- Confirmation Step -->
|
|
373
|
+
<div v-if="state.step === 'confirmation'" class="confirmation">
|
|
374
|
+
<div v-if="!confirmationDetails" class="confirmation-loader">
|
|
375
|
+
<div class="confirmation-loader-spinner"></div>
|
|
376
|
+
<h2 class="step-title">Finalizing Reservation</h2>
|
|
377
|
+
<p class="step-subtitle" style="margin:0;color:var(--muted);font-size:0.9em;">We're confirming your booking. This usually takes a few seconds.</p>
|
|
378
|
+
<span v-if="confirmationStatus" style="font-size:0.8em;color:var(--muted);">Status: {{ confirmationStatus }}</span>
|
|
379
|
+
</div>
|
|
380
|
+
<template v-else>
|
|
381
|
+
<div class="confirm-icon">
|
|
382
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="56" height="56" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>
|
|
383
|
+
</div>
|
|
384
|
+
<h2 class="step-title">Reservation Confirmed</h2>
|
|
385
|
+
<p class="step-subtitle">Thank you, {{ state.guest.firstName }}. We look forward to welcoming you.</p>
|
|
386
|
+
<div class="confirm-box">
|
|
387
|
+
<div v-if="confirmationBookingId" class="confirm-header">
|
|
388
|
+
<div class="confirm-booking-id">
|
|
389
|
+
<span class="confirm-booking-id-label">Booking ID</span>
|
|
390
|
+
<span class="confirm-booking-id-value">{{ confirmationBookingId }}</span>
|
|
391
|
+
</div>
|
|
392
|
+
</div>
|
|
393
|
+
<div class="confirm-detail">
|
|
394
|
+
<span class="confirm-detail-icon">
|
|
395
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect><line x1="16" y1="2" x2="16" y2="6"></line><line x1="8" y1="2" x2="8" y2="6"></line><line x1="3" y1="10" x2="21" y2="10"></line></svg>
|
|
396
|
+
</span>
|
|
397
|
+
<div class="confirm-detail-content">
|
|
398
|
+
<span>{{ fmtLong(confirmationCheckIn) }} to {{ fmtLong(confirmationCheckOut) }}</span>
|
|
399
|
+
<small>{{ confirmationNights }} {{ confirmationNights === 1 ? 'night' : 'nights' }}</small>
|
|
400
|
+
</div>
|
|
401
|
+
</div>
|
|
402
|
+
<div v-if="confirmationHotelName" class="confirm-detail">
|
|
403
|
+
<span class="confirm-detail-icon">
|
|
404
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"></path><circle cx="12" cy="10" r="3"></circle></svg>
|
|
405
|
+
</span>
|
|
406
|
+
<div class="confirm-detail-content"><span>{{ confirmationHotelName }}</span></div>
|
|
407
|
+
</div>
|
|
408
|
+
<div class="confirm-detail">
|
|
409
|
+
<span class="confirm-detail-icon">
|
|
410
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"></path><circle cx="12" cy="10" r="3"></circle></svg>
|
|
411
|
+
</span>
|
|
412
|
+
<div class="confirm-detail-content">
|
|
413
|
+
<span>{{ confirmationRoomType }}</span>
|
|
414
|
+
<span v-if="state.selectedRate" class="confirm-detail-rate-line">{{ [state.selectedRate.policy, ...(state.selectedRate.benefits || [])].filter(Boolean).join(' · ') }}</span>
|
|
415
|
+
</div>
|
|
416
|
+
</div>
|
|
417
|
+
<div class="confirm-detail">
|
|
418
|
+
<span class="confirm-detail-icon">
|
|
419
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"></path></svg>
|
|
420
|
+
</span>
|
|
421
|
+
<div class="confirm-detail-content">
|
|
422
|
+
<span>{{ state.guest.firstName }} {{ state.guest.lastName }}</span>
|
|
423
|
+
<small>{{ state.guest.email }}</small>
|
|
424
|
+
</div>
|
|
425
|
+
</div>
|
|
426
|
+
<div class="summary-total">
|
|
427
|
+
<span class="summary-total-label">Total Charged</span>
|
|
428
|
+
<span class="summary-total-price">{{ formatPrice(confirmationTotal, confirmationCurrency) }}</span>
|
|
429
|
+
</div>
|
|
430
|
+
</div>
|
|
431
|
+
<p style="font-size:0.75em;color:var(--muted);margin-bottom:1.5em;">A confirmation email has been sent to {{ state.guest.email }}</p>
|
|
432
|
+
<button class="btn-secondary" @click="resetBooking">Book Another Stay</button>
|
|
433
|
+
</template>
|
|
434
|
+
</div>
|
|
435
|
+
</div>
|
|
436
|
+
</div>
|
|
437
|
+
</div>
|
|
438
|
+
</template>
|
|
439
|
+
|
|
440
|
+
<script>
|
|
441
|
+
import { loadStripe } from '@stripe/stripe-js';
|
|
442
|
+
import '../core/styles.css';
|
|
443
|
+
import { createBookingApi, formatPrice, buildCheckoutPayload, buildPaymentIntentPayload } from '../core/booking-api.js';
|
|
444
|
+
import { STRIPE_PUBLISHABLE_KEY, API_BASE_URL } from '../core/stripe-config.js';
|
|
445
|
+
|
|
446
|
+
const BASE_STEPS = [
|
|
447
|
+
{ key: 'dates', label: 'Dates & Guests', num: '01' },
|
|
448
|
+
{ key: 'rooms', label: 'Room', num: '02' },
|
|
449
|
+
{ key: 'rates', label: 'Rate', num: '03' },
|
|
450
|
+
{ key: 'summary', label: 'Summary', num: '04' },
|
|
451
|
+
];
|
|
452
|
+
|
|
453
|
+
export default {
|
|
454
|
+
name: 'BookingWidget',
|
|
455
|
+
props: {
|
|
456
|
+
isOpen: { type: Boolean, default: false },
|
|
457
|
+
onClose: Function,
|
|
458
|
+
onComplete: Function,
|
|
459
|
+
onOpen: Function,
|
|
460
|
+
/** API base URL (e.g. 'https://api.example.com'). Uses shared booking-api when set. */
|
|
461
|
+
apiBaseUrl: { type: String, default: '' },
|
|
462
|
+
/** Pre-created API client from createBookingApi(). Overrides apiBaseUrl + apiSecret/propertyId/etc. if set. */
|
|
463
|
+
bookingApi: { type: Object, default: null },
|
|
464
|
+
/** API secret for X-API-Key header (pass from your app instead of env). */
|
|
465
|
+
apiSecret: { type: String, default: '' },
|
|
466
|
+
/** Property ID for the booking engine (pass from your app instead of env). */
|
|
467
|
+
propertyId: { type: [String, Number], default: '' },
|
|
468
|
+
/** Property key/hash for decrypt/pref (pass from your app instead of env). */
|
|
469
|
+
propertyKey: { type: String, default: '' },
|
|
470
|
+
availabilityBaseUrl: { type: String, default: '' },
|
|
471
|
+
propertyBaseUrl: { type: String, default: '' },
|
|
472
|
+
s3BaseUrl: { type: String, default: '' },
|
|
473
|
+
colors: {
|
|
474
|
+
type: Object,
|
|
475
|
+
default: () => null
|
|
476
|
+
},
|
|
477
|
+
/** If set, called with checkout payload (Stripe + external_booking + internal_booking) instead of createBooking. Return { confirmationCode } or resolve to go to confirmation. */
|
|
478
|
+
onBeforeConfirm: { type: Function, default: null },
|
|
479
|
+
/** Stripe: publishable key. Defaults to package .env (STRIPE_PUBLICED_KEY) at build time. */
|
|
480
|
+
stripePublishableKey: { type: String, default: () => STRIPE_PUBLISHABLE_KEY },
|
|
481
|
+
/** Stripe: optional override. If not set, package uses .env (VITE_API_BASE_URL or API_BASE_URL) + '/proxy/create-payment-intent'. */
|
|
482
|
+
createPaymentIntent: { type: Function, default: null },
|
|
483
|
+
/** Stripe: deprecated (booking completion is fetched from confirmation endpoint instead). */
|
|
484
|
+
onBookingComplete: { type: Function, default: null },
|
|
485
|
+
},
|
|
486
|
+
emits: ['close', 'complete', 'open'],
|
|
487
|
+
data() {
|
|
488
|
+
return {
|
|
489
|
+
roomsList: [],
|
|
490
|
+
ratesList: [],
|
|
491
|
+
loadingRooms: false,
|
|
492
|
+
loadingRates: false,
|
|
493
|
+
apiError: null,
|
|
494
|
+
confirmationCode: null,
|
|
495
|
+
calendarMonth: new Date().getMonth(),
|
|
496
|
+
calendarYear: new Date().getFullYear(),
|
|
497
|
+
pickState: 0,
|
|
498
|
+
calendarOpen: false,
|
|
499
|
+
stripeInstance: null,
|
|
500
|
+
elementsInstance: null,
|
|
501
|
+
paymentElementReady: false,
|
|
502
|
+
checkoutShowPaymentForm: false,
|
|
503
|
+
paymentIntentConfirmationToken: null,
|
|
504
|
+
confirmationStatus: null,
|
|
505
|
+
confirmationDetails: null,
|
|
506
|
+
confirmationPollTimer: null,
|
|
507
|
+
isClosing: false,
|
|
508
|
+
isReadyForOpen: false,
|
|
509
|
+
state: {
|
|
510
|
+
step: 'dates',
|
|
511
|
+
checkIn: null,
|
|
512
|
+
checkOut: null,
|
|
513
|
+
rooms: 1,
|
|
514
|
+
occupancies: [{ adults: 2, children: 0, childrenAges: [] }],
|
|
515
|
+
selectedRoom: null,
|
|
516
|
+
selectedRate: null,
|
|
517
|
+
guest: { firstName: '', lastName: '', email: '', phone: '', specialRequests: '' },
|
|
518
|
+
},
|
|
519
|
+
};
|
|
520
|
+
},
|
|
521
|
+
computed: {
|
|
522
|
+
// Use package .env when props not passed (e.g. examples with envDir pointing at root)
|
|
523
|
+
effectiveApiBaseUrl() {
|
|
524
|
+
return this.apiBaseUrl || (typeof import.meta !== 'undefined' && import.meta.env && import.meta.env.VITE_API_URL) || '';
|
|
525
|
+
},
|
|
526
|
+
effectiveAvailabilityBaseUrl() {
|
|
527
|
+
const env = typeof import.meta !== 'undefined' && import.meta.env ? import.meta.env : {};
|
|
528
|
+
const apiBase = (env.VITE_API_BASE_URL || API_BASE_URL || '').replace(/\/$/, '');
|
|
529
|
+
if (this.availabilityBaseUrl !== '') return this.availabilityBaseUrl;
|
|
530
|
+
return apiBase;
|
|
531
|
+
},
|
|
532
|
+
effectivePropertyBaseUrl() {
|
|
533
|
+
return this.propertyBaseUrl || (typeof import.meta !== 'undefined' && import.meta.env && import.meta.env.VITE_PROPERTY_BASE_URL) || '';
|
|
534
|
+
},
|
|
535
|
+
effectiveS3BaseUrl() {
|
|
536
|
+
return this.s3BaseUrl || (typeof import.meta !== 'undefined' && import.meta.env && import.meta.env.VITE_AWS_S3_PATH) || '';
|
|
537
|
+
},
|
|
538
|
+
effectivePaymentIntentUrl() {
|
|
539
|
+
const env = typeof import.meta !== 'undefined' && import.meta.env ? import.meta.env : {};
|
|
540
|
+
const apiBase = (env.VITE_API_BASE_URL || API_BASE_URL || '').replace(/\/$/, '');
|
|
541
|
+
return (apiBase ? apiBase + '/proxy/create-payment-intent' : '').trim();
|
|
542
|
+
},
|
|
543
|
+
effectiveConfirmationBaseUrl() {
|
|
544
|
+
const env = typeof import.meta !== 'undefined' && import.meta.env ? import.meta.env : {};
|
|
545
|
+
const base = env.VITE_API_BASE_URL || API_BASE_URL || 'https://ai.thehotelplanet.com';
|
|
546
|
+
return String(base).replace(/\/$/, '');
|
|
547
|
+
},
|
|
548
|
+
hasStripe() {
|
|
549
|
+
return !!(this.stripePublishableKey && typeof this.effectiveCreatePaymentIntent === 'function');
|
|
550
|
+
},
|
|
551
|
+
STEPS() {
|
|
552
|
+
return this.hasStripe ? [...BASE_STEPS, { key: 'payment', label: 'Payment', num: '05' }] : BASE_STEPS;
|
|
553
|
+
},
|
|
554
|
+
effectiveCreatePaymentIntent() {
|
|
555
|
+
if (typeof this.createPaymentIntent === 'function') return this.createPaymentIntent;
|
|
556
|
+
const url = this.effectivePaymentIntentUrl;
|
|
557
|
+
if (!url) return null;
|
|
558
|
+
return async (payload) => {
|
|
559
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
560
|
+
if (url.includes('ngrok')) headers['ngrok-skip-browser-warning'] = 'true';
|
|
561
|
+
const res = await fetch(url, { method: 'POST', headers, body: JSON.stringify(payload) });
|
|
562
|
+
if (!res.ok) throw new Error(await res.text());
|
|
563
|
+
const data = await res.json();
|
|
564
|
+
const clientSecret = data.clientSecret ?? data.client_secret ?? data.data?.clientSecret ?? data.data?.client_secret ?? data.paymentIntent?.client_secret;
|
|
565
|
+
const confirmationToken = data.confirmationToken ?? data.confirmation_token ?? data.data?.confirmationToken ?? data.data?.confirmation_token;
|
|
566
|
+
return { clientSecret, confirmationToken };
|
|
567
|
+
};
|
|
568
|
+
},
|
|
569
|
+
bookingApiRef() {
|
|
570
|
+
if (this.bookingApi && typeof this.bookingApi.fetchRooms === 'function') return this.bookingApi;
|
|
571
|
+
if ((this.effectiveApiBaseUrl || this.propertyKey) && typeof createBookingApi === 'function') {
|
|
572
|
+
return createBookingApi({
|
|
573
|
+
baseUrl: this.effectiveApiBaseUrl || this.effectiveAvailabilityBaseUrl || '',
|
|
574
|
+
availabilityBaseUrl: this.effectiveAvailabilityBaseUrl === '' ? '' : (this.effectiveAvailabilityBaseUrl || undefined),
|
|
575
|
+
propertyBaseUrl: this.effectivePropertyBaseUrl === '' ? '' : (this.effectivePropertyBaseUrl || undefined),
|
|
576
|
+
s3BaseUrl: this.effectiveS3BaseUrl || undefined,
|
|
577
|
+
propertyId: this.propertyId != null && this.propertyId !== '' ? String(this.propertyId) : undefined,
|
|
578
|
+
propertyKey: this.propertyKey || undefined,
|
|
579
|
+
headers: this.apiSecret ? { 'X-API-Key': this.apiSecret } : undefined,
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
return null;
|
|
583
|
+
},
|
|
584
|
+
widgetStyles() {
|
|
585
|
+
if (!this.colors) return {};
|
|
586
|
+
const styles = {};
|
|
587
|
+
if (this.colors.background) {
|
|
588
|
+
styles['--bg'] = this.colors.background;
|
|
589
|
+
styles['--card'] = this.colors.background;
|
|
590
|
+
}
|
|
591
|
+
if (this.colors.text) {
|
|
592
|
+
styles['--fg'] = this.colors.text;
|
|
593
|
+
styles['--card-fg'] = this.colors.text;
|
|
594
|
+
}
|
|
595
|
+
if (this.colors.primary) {
|
|
596
|
+
styles['--primary'] = this.colors.primary;
|
|
597
|
+
}
|
|
598
|
+
if (this.colors.primaryText) {
|
|
599
|
+
styles['--primary-fg'] = this.colors.primaryText;
|
|
600
|
+
}
|
|
601
|
+
return styles;
|
|
602
|
+
},
|
|
603
|
+
nights() {
|
|
604
|
+
if (!this.state.checkIn || !this.state.checkOut) return 0;
|
|
605
|
+
return Math.max(1, Math.round((this.state.checkOut - this.state.checkIn) / 86400000));
|
|
606
|
+
},
|
|
607
|
+
checkoutTotal() {
|
|
608
|
+
if (!this.state.selectedRoom || !this.state.selectedRate) return 0;
|
|
609
|
+
const nights = this.nights;
|
|
610
|
+
const rooms = this.state.rooms;
|
|
611
|
+
const roomTotal = Math.round(this.state.selectedRoom.basePrice * this.state.selectedRate.priceModifier * rooms);
|
|
612
|
+
const fees = this.state.selectedRate.fees ?? [];
|
|
613
|
+
let add = 0;
|
|
614
|
+
fees.forEach(f => { if (!f.included) add += f.perNight ? f.amount * nights * rooms : f.amount; });
|
|
615
|
+
const vat = this.state.selectedRate.vat;
|
|
616
|
+
if (vat && !vat.included) add += vat.value || 0;
|
|
617
|
+
return Math.round(roomTotal + add);
|
|
618
|
+
},
|
|
619
|
+
confirmationTotal() {
|
|
620
|
+
if (this.confirmationDetails && (this.confirmationDetails.totalAmount != null && this.confirmationDetails.totalAmount !== '')) {
|
|
621
|
+
return Number(this.confirmationDetails.totalAmount);
|
|
622
|
+
}
|
|
623
|
+
return this.checkoutTotal;
|
|
624
|
+
},
|
|
625
|
+
confirmationCurrency() {
|
|
626
|
+
return (this.confirmationDetails && this.confirmationDetails.currency) ? this.confirmationDetails.currency : (this.state.selectedRoom?.currency || 'USD');
|
|
627
|
+
},
|
|
628
|
+
confirmationNights() {
|
|
629
|
+
if (this.confirmationDetails && this.confirmationDetails.nights != null) return this.confirmationDetails.nights;
|
|
630
|
+
return this.nights;
|
|
631
|
+
},
|
|
632
|
+
confirmationBookingId() {
|
|
633
|
+
return this.confirmationDetails?.bookingId ?? this.confirmationDetails?.booking_id ?? null;
|
|
634
|
+
},
|
|
635
|
+
confirmationHotelName() {
|
|
636
|
+
return this.confirmationDetails?.hotelName ?? this.confirmationDetails?.hotel_name ?? null;
|
|
637
|
+
},
|
|
638
|
+
confirmationRoomType() {
|
|
639
|
+
const raw = this.confirmationDetails?.roomType ?? this.confirmationDetails?.room_type ?? this.state.selectedRoom?.name ?? '';
|
|
640
|
+
return raw.replace(/\s*Rate\s*\d+\s*$/i, '').trim();
|
|
641
|
+
},
|
|
642
|
+
confirmationCheckIn() {
|
|
643
|
+
if (!this.confirmationDetails?.checkIn) return this.state.checkIn;
|
|
644
|
+
const d = this.confirmationDetails.checkIn;
|
|
645
|
+
return typeof d === 'string' ? new Date(d) : d;
|
|
646
|
+
},
|
|
647
|
+
confirmationCheckOut() {
|
|
648
|
+
if (!this.confirmationDetails?.checkOut) return this.state.checkOut;
|
|
649
|
+
const d = this.confirmationDetails.checkOut;
|
|
650
|
+
return typeof d === 'string' ? new Date(d) : d;
|
|
651
|
+
},
|
|
652
|
+
checkoutFeeRows() {
|
|
653
|
+
const rate = this.state.selectedRate;
|
|
654
|
+
if (!rate || (!rate.fees?.length && !rate.vat)) return [];
|
|
655
|
+
const nights = this.nights;
|
|
656
|
+
const rooms = this.state.rooms;
|
|
657
|
+
const roomTotal = Math.round(this.state.selectedRoom.basePrice * rate.priceModifier * rooms);
|
|
658
|
+
const rows = [{ label: 'Room total', amount: roomTotal }];
|
|
659
|
+
(rate.fees ?? []).forEach(f => rows.push({ name: f.name, amount: f.perNight ? f.amount * nights * rooms : f.amount, included: f.included }));
|
|
660
|
+
if (rate.vat) rows.push({ name: 'VAT', amount: rate.vat.value, included: rate.vat.included });
|
|
661
|
+
return rows;
|
|
662
|
+
},
|
|
663
|
+
checkoutFeesSection() {
|
|
664
|
+
const rate = this.state.selectedRate;
|
|
665
|
+
if (!rate || (!rate.fees?.length && !rate.vat)) return null;
|
|
666
|
+
const fees = rate.fees ?? [];
|
|
667
|
+
const allIncluded = fees.every(f => f.included) && (!rate.vat || rate.vat.included);
|
|
668
|
+
return { allIncluded, fees: rate.fees, vat: rate.vat };
|
|
669
|
+
},
|
|
670
|
+
canSubmit() {
|
|
671
|
+
return this.state.guest.firstName && this.state.guest.lastName && this.state.guest.email;
|
|
672
|
+
},
|
|
673
|
+
displayConfirmationCode() {
|
|
674
|
+
return this.confirmationCode || (this.confirmationDetails?.confirmationCode ?? this.confirmationDetails?.confirmation_code ?? this.confirmationDetails?.id ?? ('LX' + Date.now().toString(36).toUpperCase().slice(-6)));
|
|
675
|
+
},
|
|
676
|
+
totalGuests() {
|
|
677
|
+
const occ = this.state.occupancies || [];
|
|
678
|
+
return {
|
|
679
|
+
adults: occ.reduce((s, o) => s + (o.adults || 0), 0),
|
|
680
|
+
children: occ.reduce((s, o) => s + (o.children || 0), 0),
|
|
681
|
+
};
|
|
682
|
+
},
|
|
683
|
+
isVisible() {
|
|
684
|
+
return this.isOpen && !this.isClosing && this.isReadyForOpen;
|
|
685
|
+
},
|
|
686
|
+
},
|
|
687
|
+
watch: {
|
|
688
|
+
isOpen: {
|
|
689
|
+
handler(open) {
|
|
690
|
+
if (open && this.onOpen) this.onOpen();
|
|
691
|
+
if (!open || this.isClosing) {
|
|
692
|
+
this.isReadyForOpen = false;
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
this.isReadyForOpen = false;
|
|
696
|
+
this.$nextTick(() => {
|
|
697
|
+
requestAnimationFrame(() => {
|
|
698
|
+
requestAnimationFrame(() => { this.isReadyForOpen = true; });
|
|
699
|
+
});
|
|
700
|
+
});
|
|
701
|
+
},
|
|
702
|
+
immediate: true,
|
|
703
|
+
},
|
|
704
|
+
'state.step': function(step) {
|
|
705
|
+
if (step !== 'summary' && step !== 'payment') {
|
|
706
|
+
this.checkoutShowPaymentForm = false;
|
|
707
|
+
this.paymentElementReady = false;
|
|
708
|
+
this.stripeInstance = null;
|
|
709
|
+
if (this.elementsInstance) {
|
|
710
|
+
const el = document.getElementById('booking-widget-payment-element');
|
|
711
|
+
if (el) el.innerHTML = '';
|
|
712
|
+
this.elementsInstance = null;
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
},
|
|
716
|
+
checkoutShowPaymentForm: function(show) {
|
|
717
|
+
if (show && this.state.step === 'payment') this.loadStripePaymentElement();
|
|
718
|
+
},
|
|
719
|
+
},
|
|
720
|
+
methods: {
|
|
721
|
+
async fetchConfirmationDetails(token) {
|
|
722
|
+
const t = String(token || '').trim();
|
|
723
|
+
if (!t) throw new Error('Missing confirmation token');
|
|
724
|
+
const url = `${this.effectiveConfirmationBaseUrl}/proxy/confirmation/${encodeURIComponent(t)}`;
|
|
725
|
+
const res = await fetch(url, { method: 'POST' });
|
|
726
|
+
if (!res.ok) throw new Error(await res.text());
|
|
727
|
+
return await res.json();
|
|
728
|
+
},
|
|
729
|
+
startConfirmationPolling(token) {
|
|
730
|
+
this.confirmationDetails = null;
|
|
731
|
+
this.confirmationStatus = 'pending';
|
|
732
|
+
this.apiError = null;
|
|
733
|
+
this.state.step = 'confirmation';
|
|
734
|
+
if (this.confirmationPollTimer) clearTimeout(this.confirmationPollTimer);
|
|
735
|
+
const pollOnce = async () => {
|
|
736
|
+
try {
|
|
737
|
+
const data = await this.fetchConfirmationDetails(token);
|
|
738
|
+
const status = data?.status != null ? String(data.status) : '';
|
|
739
|
+
this.confirmationStatus = status || this.confirmationStatus || 'pending';
|
|
740
|
+
if (status === 'confirmed') {
|
|
741
|
+
this.confirmationDetails = data;
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
} catch (err) {
|
|
745
|
+
this.apiError = (err && err.message) || err || 'Failed to fetch confirmation';
|
|
746
|
+
}
|
|
747
|
+
this.confirmationPollTimer = setTimeout(pollOnce, 2000);
|
|
748
|
+
};
|
|
749
|
+
pollOnce();
|
|
750
|
+
},
|
|
751
|
+
async loadStripePaymentElement() {
|
|
752
|
+
if (this.state.step !== 'payment' || !this.checkoutShowPaymentForm || !this.stripePublishableKey || typeof this.effectiveCreatePaymentIntent !== 'function') return;
|
|
753
|
+
const paymentIntentPayload = buildPaymentIntentPayload(this.state, { propertyKey: this.propertyKey || undefined });
|
|
754
|
+
this.paymentElementReady = false;
|
|
755
|
+
try {
|
|
756
|
+
await this.$nextTick();
|
|
757
|
+
const result = await this.effectiveCreatePaymentIntent(paymentIntentPayload);
|
|
758
|
+
const clientSecret = result?.clientSecret ?? result?.client_secret ?? result?.data?.clientSecret ?? result?.data?.client_secret ?? result?.paymentIntent?.client_secret;
|
|
759
|
+
this.paymentIntentConfirmationToken = result?.confirmationToken ?? result?.confirmation_token ?? result?.data?.confirmationToken ?? result?.data?.confirmation_token;
|
|
760
|
+
if (!clientSecret || this.state.step !== 'payment') {
|
|
761
|
+
this.apiError = 'Payment setup failed: no client secret returned';
|
|
762
|
+
return;
|
|
763
|
+
}
|
|
764
|
+
const stripe = await loadStripe(this.stripePublishableKey);
|
|
765
|
+
if (!stripe || this.state.step !== 'payment') return;
|
|
766
|
+
this.stripeInstance = stripe;
|
|
767
|
+
const elements = stripe.elements({ clientSecret, appearance: { theme: 'flat', variables: { borderRadius: '8px' } } });
|
|
768
|
+
this.elementsInstance = elements;
|
|
769
|
+
const paymentElement = elements.create('payment');
|
|
770
|
+
await this.$nextTick();
|
|
771
|
+
const container = document.getElementById('booking-widget-payment-element');
|
|
772
|
+
if (!container || this.state.step !== 'payment') {
|
|
773
|
+
this.apiError = 'Payment form container not found';
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
this.apiError = null;
|
|
777
|
+
container.innerHTML = '';
|
|
778
|
+
paymentElement.mount(container);
|
|
779
|
+
this.paymentElementReady = true;
|
|
780
|
+
} catch (err) {
|
|
781
|
+
this.apiError = (err && err.message) || err || 'Payment setup failed';
|
|
782
|
+
}
|
|
783
|
+
},
|
|
784
|
+
stepIndex(key) {
|
|
785
|
+
return this.STEPS.findIndex(s => s.key === key);
|
|
786
|
+
},
|
|
787
|
+
getStepClass(i) {
|
|
788
|
+
const ci = this.stepIndex(this.state.step);
|
|
789
|
+
return i === ci ? 'active' : i < ci ? 'past' : 'future';
|
|
790
|
+
},
|
|
791
|
+
goToStep(step) {
|
|
792
|
+
if (step !== 'summary' && step !== 'payment') this.checkoutShowPaymentForm = false;
|
|
793
|
+
if (step === 'payment') this.checkoutShowPaymentForm = true;
|
|
794
|
+
this.state.step = step;
|
|
795
|
+
this.apiError = null;
|
|
796
|
+
const api = this.bookingApiRef;
|
|
797
|
+
if (step === 'rooms' && api) {
|
|
798
|
+
this.loadingRooms = true;
|
|
799
|
+
const occupancies = (this.state.occupancies || []).slice(0, this.state.rooms).map((occ, i) => ({
|
|
800
|
+
adults: occ.adults ?? 1,
|
|
801
|
+
children: (occ.childrenAges || []).slice(0, occ.children || 0).map(a => Number(a) || 0),
|
|
802
|
+
occupancy_index: i + 1,
|
|
803
|
+
}));
|
|
804
|
+
const params = { checkIn: this.state.checkIn, checkOut: this.state.checkOut, rooms: this.state.rooms, occupancies };
|
|
805
|
+
api.fetchRooms(params).then((rooms) => {
|
|
806
|
+
this.roomsList = Array.isArray(rooms) ? rooms : [];
|
|
807
|
+
this.loadingRooms = false;
|
|
808
|
+
}).catch((err) => {
|
|
809
|
+
this.apiError = err.message || 'Failed to load rooms';
|
|
810
|
+
this.loadingRooms = false;
|
|
811
|
+
});
|
|
812
|
+
return;
|
|
813
|
+
}
|
|
814
|
+
if (step === 'rates' && this.state.selectedRoom) {
|
|
815
|
+
this.loadingRates = false;
|
|
816
|
+
this.ratesList = Array.isArray(this.state.selectedRoom.rates) && this.state.selectedRoom.rates.length
|
|
817
|
+
? this.state.selectedRoom.rates
|
|
818
|
+
: [];
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
},
|
|
822
|
+
updateState(newState) {
|
|
823
|
+
this.state = { ...this.state, ...newState };
|
|
824
|
+
},
|
|
825
|
+
requestClose() {
|
|
826
|
+
if (this.isClosing) return;
|
|
827
|
+
this.isClosing = true;
|
|
828
|
+
},
|
|
829
|
+
handleTransitionEnd(e) {
|
|
830
|
+
if (e.target !== this.$refs.widgetRef || e.propertyName !== 'transform') return;
|
|
831
|
+
if (!this.isClosing) return;
|
|
832
|
+
this.handleClose();
|
|
833
|
+
this.isClosing = false;
|
|
834
|
+
},
|
|
835
|
+
handleClose() {
|
|
836
|
+
this.checkoutShowPaymentForm = false;
|
|
837
|
+
this.paymentIntentConfirmationToken = null;
|
|
838
|
+
this.confirmationStatus = null;
|
|
839
|
+
this.confirmationDetails = null;
|
|
840
|
+
if (this.confirmationPollTimer) clearTimeout(this.confirmationPollTimer);
|
|
841
|
+
this.confirmationPollTimer = null;
|
|
842
|
+
// Reset state so next open starts from step 1 with no selection
|
|
843
|
+
Object.assign(this.state, {
|
|
844
|
+
step: 'dates',
|
|
845
|
+
checkIn: null,
|
|
846
|
+
checkOut: null,
|
|
847
|
+
rooms: 1,
|
|
848
|
+
occupancies: [{ adults: 2, children: 0, childrenAges: [] }],
|
|
849
|
+
selectedRoom: null,
|
|
850
|
+
selectedRate: null,
|
|
851
|
+
guest: { firstName: '', lastName: '', email: '', phone: '', specialRequests: '' },
|
|
852
|
+
});
|
|
853
|
+
this.confirmationCode = null;
|
|
854
|
+
this.apiError = null;
|
|
855
|
+
this.roomsList = [];
|
|
856
|
+
this.ratesList = [];
|
|
857
|
+
if (this.onClose) this.onClose();
|
|
858
|
+
this.$emit('close');
|
|
859
|
+
},
|
|
860
|
+
confirmReservation() {
|
|
861
|
+
if (!this.canSubmit) return;
|
|
862
|
+
const payload = buildCheckoutPayload(this.state, { propertyId: this.propertyId != null && this.propertyId !== '' ? this.propertyId : undefined, propertyKey: this.propertyKey || undefined });
|
|
863
|
+
// Summary step with Stripe: go to payment step (form loads there)
|
|
864
|
+
if (this.state.step === 'summary' && this.stripePublishableKey && typeof this.effectiveCreatePaymentIntent === 'function') {
|
|
865
|
+
this.apiError = null;
|
|
866
|
+
this.goToStep('payment');
|
|
867
|
+
return;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
// Payment step: confirm payment
|
|
871
|
+
if (this.state.step === 'payment' && this.stripePublishableKey && typeof this.effectiveCreatePaymentIntent === 'function' && this.stripeInstance && this.elementsInstance) {
|
|
872
|
+
this.apiError = null;
|
|
873
|
+
this.stripeInstance.confirmPayment({
|
|
874
|
+
elements: this.elementsInstance,
|
|
875
|
+
confirmParams: { return_url: typeof window !== 'undefined' ? window.location.origin + (window.location.pathname || '') : '' },
|
|
876
|
+
redirect: 'if_required',
|
|
877
|
+
}).then(({ error, paymentIntent }) => {
|
|
878
|
+
if (error) {
|
|
879
|
+
this.apiError = error?.message || 'Payment failed';
|
|
880
|
+
return;
|
|
881
|
+
}
|
|
882
|
+
if (paymentIntent?.status === 'succeeded' || paymentIntent?.status === 'requires_capture') {
|
|
883
|
+
if (!this.paymentIntentConfirmationToken) {
|
|
884
|
+
this.apiError = 'Missing confirmation token from payment intent';
|
|
885
|
+
return;
|
|
886
|
+
}
|
|
887
|
+
this.startConfirmationPolling(this.paymentIntentConfirmationToken);
|
|
888
|
+
}
|
|
889
|
+
}).catch((err) => { this.apiError = (err && err.message) || err || 'Payment failed'; });
|
|
890
|
+
return;
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
if (typeof this.onBeforeConfirm === 'function') {
|
|
894
|
+
this.apiError = null;
|
|
895
|
+
Promise.resolve(this.onBeforeConfirm(payload))
|
|
896
|
+
.then((res) => {
|
|
897
|
+
const code = res && (res.confirmationCode != null ? res.confirmationCode : res.confirmation_code);
|
|
898
|
+
this.confirmationCode = code || ('LX' + Date.now().toString(36).toUpperCase().slice(-6));
|
|
899
|
+
this.state.step = 'confirmation';
|
|
900
|
+
})
|
|
901
|
+
.catch((err) => {
|
|
902
|
+
this.apiError = (err && err.message) || err || 'Booking failed';
|
|
903
|
+
});
|
|
904
|
+
return;
|
|
905
|
+
}
|
|
906
|
+
// No Stripe flow and no onBeforeConfirm: only log payload
|
|
907
|
+
return;
|
|
908
|
+
const api = this.bookingApiRef;
|
|
909
|
+
if (api) {
|
|
910
|
+
this.apiError = null;
|
|
911
|
+
api.createBooking(this.state).then((res) => {
|
|
912
|
+
this.confirmationCode = res.confirmationCode || ('LX' + Date.now().toString(36).toUpperCase().slice(-6));
|
|
913
|
+
this.state.step = 'confirmation';
|
|
914
|
+
}).catch((err) => {
|
|
915
|
+
this.apiError = err.message || 'Booking failed';
|
|
916
|
+
});
|
|
917
|
+
return;
|
|
918
|
+
}
|
|
919
|
+
this.confirmationCode = 'LX' + Date.now().toString(36).toUpperCase().slice(-6);
|
|
920
|
+
this.state.step = 'confirmation';
|
|
921
|
+
},
|
|
922
|
+
resetBooking() {
|
|
923
|
+
this.checkoutShowPaymentForm = false;
|
|
924
|
+
this.paymentElementReady = false;
|
|
925
|
+
this.stripeInstance = null;
|
|
926
|
+
const el = document.getElementById('booking-widget-payment-element');
|
|
927
|
+
if (el) el.innerHTML = '';
|
|
928
|
+
this.elementsInstance = null;
|
|
929
|
+
this.paymentIntentConfirmationToken = null;
|
|
930
|
+
this.confirmationStatus = null;
|
|
931
|
+
this.confirmationDetails = null;
|
|
932
|
+
if (this.confirmationPollTimer) clearTimeout(this.confirmationPollTimer);
|
|
933
|
+
this.confirmationPollTimer = null;
|
|
934
|
+
this.state = {
|
|
935
|
+
step: 'dates',
|
|
936
|
+
checkIn: null,
|
|
937
|
+
checkOut: null,
|
|
938
|
+
rooms: 1,
|
|
939
|
+
occupancies: [{ adults: 2, children: 0, childrenAges: [] }],
|
|
940
|
+
selectedRoom: null,
|
|
941
|
+
selectedRate: null,
|
|
942
|
+
guest: { firstName: '', lastName: '', email: '', phone: '', specialRequests: '' },
|
|
943
|
+
};
|
|
944
|
+
this.confirmationCode = null;
|
|
945
|
+
this.apiError = null;
|
|
946
|
+
if (!this.bookingApiRef) {
|
|
947
|
+
this.roomsList = [];
|
|
948
|
+
this.ratesList = [];
|
|
949
|
+
}
|
|
950
|
+
if (this.onComplete) this.onComplete(this.state);
|
|
951
|
+
this.$emit('complete', this.state);
|
|
952
|
+
},
|
|
953
|
+
formatPrice,
|
|
954
|
+
fmt(date) {
|
|
955
|
+
if (!date) return '';
|
|
956
|
+
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
|
957
|
+
},
|
|
958
|
+
formatRoomSize(size) {
|
|
959
|
+
if (size == null || typeof size !== 'string') return size ?? '';
|
|
960
|
+
return size.replace(/\bsquare_?meter(s)?\b/gi, 'm²').trim();
|
|
961
|
+
},
|
|
962
|
+
fmtLong(date) {
|
|
963
|
+
if (!date) return '';
|
|
964
|
+
return date.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' });
|
|
965
|
+
},
|
|
966
|
+
monthName(m) {
|
|
967
|
+
return ['January','February','March','April','May','June','July','August','September','October','November','December'][m];
|
|
968
|
+
},
|
|
969
|
+
changeCounter(field, delta, roomIndex = 0) {
|
|
970
|
+
const limits = { adults: [1,8], children: [0,6], rooms: [1,5] };
|
|
971
|
+
const occ = [...(this.state.occupancies || [{ adults: 2, children: 0, childrenAges: [] }])];
|
|
972
|
+
if (field === 'rooms') {
|
|
973
|
+
const next = Math.max(limits.rooms[0], Math.min(limits.rooms[1], this.state.rooms + delta));
|
|
974
|
+
while (occ.length < next) occ.push({ adults: 1, children: 0, childrenAges: [] });
|
|
975
|
+
occ.length = next;
|
|
976
|
+
this.updateState({ rooms: next, occupancies: occ });
|
|
977
|
+
return;
|
|
978
|
+
}
|
|
979
|
+
if (roomIndex < 0 || roomIndex >= occ.length) return;
|
|
980
|
+
const next = Math.max(limits[field][0], Math.min(limits[field][1], (occ[roomIndex][field] ?? (field === 'adults' ? 1 : 0)) + delta));
|
|
981
|
+
occ[roomIndex] = { ...occ[roomIndex], [field]: next };
|
|
982
|
+
if (field === 'children') {
|
|
983
|
+
const ages = (occ[roomIndex].childrenAges || []).slice(0, next);
|
|
984
|
+
while (ages.length < next) ages.push(0);
|
|
985
|
+
occ[roomIndex].childrenAges = ages;
|
|
986
|
+
}
|
|
987
|
+
this.updateState({ occupancies: occ });
|
|
988
|
+
},
|
|
989
|
+
updateChildAge(roomIndex, childIndex, value) {
|
|
990
|
+
const age = Math.max(0, Math.min(17, Number(value) || 0));
|
|
991
|
+
const occ = [...(this.state.occupancies || [])];
|
|
992
|
+
if (roomIndex < 0 || roomIndex >= occ.length) return;
|
|
993
|
+
const ages = [...(occ[roomIndex].childrenAges || [])];
|
|
994
|
+
if (childIndex >= 0 && childIndex < ages.length) ages[childIndex] = age;
|
|
995
|
+
occ[roomIndex] = { ...occ[roomIndex], childrenAges: ages };
|
|
996
|
+
this.updateState({ occupancies: occ });
|
|
997
|
+
},
|
|
998
|
+
removeRoom(roomIndex) {
|
|
999
|
+
if (this.state.rooms <= 1) return;
|
|
1000
|
+
const occ = [...(this.state.occupancies || [])];
|
|
1001
|
+
if (roomIndex < 0 || roomIndex >= occ.length) return;
|
|
1002
|
+
occ.splice(roomIndex, 1);
|
|
1003
|
+
this.updateState({ occupancies: occ, rooms: occ.length });
|
|
1004
|
+
},
|
|
1005
|
+
pickDate(y, m, d) {
|
|
1006
|
+
const date = new Date(y, m, d);
|
|
1007
|
+
if (this.pickState === 0 || (this.state.checkIn && date <= this.state.checkIn)) {
|
|
1008
|
+
this.updateState({ checkIn: date, checkOut: null });
|
|
1009
|
+
this.pickState = 1;
|
|
1010
|
+
} else {
|
|
1011
|
+
this.updateState({ checkOut: date });
|
|
1012
|
+
this.pickState = 0;
|
|
1013
|
+
this.calendarOpen = false;
|
|
1014
|
+
}
|
|
1015
|
+
},
|
|
1016
|
+
calNav(dir) {
|
|
1017
|
+
this.calendarMonth += dir;
|
|
1018
|
+
if (this.calendarMonth > 11) { this.calendarMonth = 0; this.calendarYear++; }
|
|
1019
|
+
if (this.calendarMonth < 0) { this.calendarMonth = 11; this.calendarYear--; }
|
|
1020
|
+
},
|
|
1021
|
+
buildMonth(year, month) {
|
|
1022
|
+
const today = new Date(); today.setHours(0,0,0,0);
|
|
1023
|
+
const first = new Date(year, month, 1);
|
|
1024
|
+
const days = new Date(year, month + 1, 0).getDate();
|
|
1025
|
+
const startDay = first.getDay();
|
|
1026
|
+
const names = ['Su','Mo','Tu','We','Th','Fr','Sa'];
|
|
1027
|
+
let daysArray = [];
|
|
1028
|
+
for (let i = 0; i < startDay; i++) daysArray.push(null);
|
|
1029
|
+
for (let d = 1; d <= days; d++) {
|
|
1030
|
+
const date = new Date(year, month, d);
|
|
1031
|
+
const disabled = date < today;
|
|
1032
|
+
let cls = 'cal-day';
|
|
1033
|
+
if (disabled) cls += ' disabled';
|
|
1034
|
+
if (date.getTime() === today.getTime()) cls += ' today';
|
|
1035
|
+
if (this.state.checkIn && date.getTime() === this.state.checkIn.getTime()) cls += ' selected';
|
|
1036
|
+
if (this.state.checkOut && date.getTime() === this.state.checkOut.getTime()) cls += ' selected';
|
|
1037
|
+
if (this.state.checkIn && this.state.checkOut && date > this.state.checkIn && date < this.state.checkOut) cls += ' in-range';
|
|
1038
|
+
daysArray.push({ date, disabled, cls, day: d });
|
|
1039
|
+
}
|
|
1040
|
+
return { daysArray, names };
|
|
1041
|
+
},
|
|
1042
|
+
scrollRooms(direction) {
|
|
1043
|
+
if (this.$refs.roomGridRef) {
|
|
1044
|
+
const scrollAmount = 300;
|
|
1045
|
+
this.$refs.roomGridRef.scrollBy({
|
|
1046
|
+
left: direction * scrollAmount,
|
|
1047
|
+
behavior: 'smooth'
|
|
1048
|
+
});
|
|
1049
|
+
}
|
|
1050
|
+
},
|
|
1051
|
+
selectRoom(id) {
|
|
1052
|
+
this.updateState({ selectedRoom: this.roomsList.find(r => r.id === id) });
|
|
1053
|
+
},
|
|
1054
|
+
selectRate(id) {
|
|
1055
|
+
this.updateState({ selectedRate: this.ratesList.find(r => r.id === id) });
|
|
1056
|
+
},
|
|
1057
|
+
updateGuest(field, value) {
|
|
1058
|
+
this.updateState({ guest: { ...this.state.guest, [field]: value } });
|
|
1059
|
+
},
|
|
1060
|
+
},
|
|
1061
|
+
};
|
|
1062
|
+
</script>
|