Changeset 3730301
- Timestamp:
- 10/06/2026 08:35:08 AM (3 weeks ago)
- Location:
- bookminto
- Files:
-
- 12 added
- 48 edited
- 1 copied
-
tags/wp-1.0.5 (copied) (copied from bookminto/trunk )
-
tags/wp-1.0.5/assets/src/api.js (modified) (5 diffs)
-
tags/wp-1.0.5/assets/src/availability.js (modified) (7 diffs)
-
tags/wp-1.0.5/assets/src/availabilitySlotEngine.js (added)
-
tags/wp-1.0.5/assets/src/bookingBar.js (modified) (34 diffs)
-
tags/wp-1.0.5/assets/src/bookingBarModalStyles.js (modified) (1 diff)
-
tags/wp-1.0.5/assets/src/bookingBarStyles.js (modified) (1 diff)
-
tags/wp-1.0.5/assets/src/bookingModal.js (modified) (8 diffs)
-
tags/wp-1.0.5/assets/src/commerce.js (modified) (1 diff)
-
tags/wp-1.0.5/assets/src/config.js (modified) (17 diffs)
-
tags/wp-1.0.5/assets/src/copy.js (modified) (4 diffs)
-
tags/wp-1.0.5/assets/src/extensionsStyles.js (added)
-
tags/wp-1.0.5/assets/src/floatingStyles.js (modified) (3 diffs)
-
tags/wp-1.0.5/assets/src/formatters.js (modified) (4 diffs)
-
tags/wp-1.0.5/assets/src/modalRender.js (modified) (35 diffs)
-
tags/wp-1.0.5/assets/src/mount.js (modified) (10 diffs)
-
tags/wp-1.0.5/assets/src/nextTimes.js (added)
-
tags/wp-1.0.5/assets/src/nextTimesStyles.js (added)
-
tags/wp-1.0.5/assets/src/payment.js (modified) (7 diffs)
-
tags/wp-1.0.5/assets/src/phoneNumber.js (added)
-
tags/wp-1.0.5/assets/src/previewMount.js (modified) (2 diffs)
-
tags/wp-1.0.5/assets/src/render.js (modified) (41 diffs)
-
tags/wp-1.0.5/assets/src/servicePhotos.js (added)
-
tags/wp-1.0.5/assets/src/slotEngine.js (modified) (1 diff)
-
tags/wp-1.0.5/assets/src/styles.js (modified) (2 diffs)
-
tags/wp-1.0.5/assets/widget.js (modified) (13 diffs)
-
tags/wp-1.0.5/bookminto.php (modified) (2 diffs)
-
tags/wp-1.0.5/includes/woocommerce.php (modified) (5 diffs)
-
tags/wp-1.0.5/languages/bookminto.pot (modified) (26 diffs)
-
tags/wp-1.0.5/readme.txt (modified) (5 diffs)
-
tags/wp-1.0.5/uninstall.php (modified) (1 diff)
-
trunk/assets/src/api.js (modified) (5 diffs)
-
trunk/assets/src/availability.js (modified) (7 diffs)
-
trunk/assets/src/availabilitySlotEngine.js (added)
-
trunk/assets/src/bookingBar.js (modified) (34 diffs)
-
trunk/assets/src/bookingBarModalStyles.js (modified) (1 diff)
-
trunk/assets/src/bookingBarStyles.js (modified) (1 diff)
-
trunk/assets/src/bookingModal.js (modified) (8 diffs)
-
trunk/assets/src/commerce.js (modified) (1 diff)
-
trunk/assets/src/config.js (modified) (17 diffs)
-
trunk/assets/src/copy.js (modified) (4 diffs)
-
trunk/assets/src/extensionsStyles.js (added)
-
trunk/assets/src/floatingStyles.js (modified) (3 diffs)
-
trunk/assets/src/formatters.js (modified) (4 diffs)
-
trunk/assets/src/modalRender.js (modified) (35 diffs)
-
trunk/assets/src/mount.js (modified) (10 diffs)
-
trunk/assets/src/nextTimes.js (added)
-
trunk/assets/src/nextTimesStyles.js (added)
-
trunk/assets/src/payment.js (modified) (7 diffs)
-
trunk/assets/src/phoneNumber.js (added)
-
trunk/assets/src/previewMount.js (modified) (2 diffs)
-
trunk/assets/src/render.js (modified) (41 diffs)
-
trunk/assets/src/servicePhotos.js (added)
-
trunk/assets/src/slotEngine.js (modified) (1 diff)
-
trunk/assets/src/styles.js (modified) (2 diffs)
-
trunk/assets/widget.js (modified) (13 diffs)
-
trunk/bookminto.php (modified) (2 diffs)
-
trunk/includes/woocommerce.php (modified) (5 diffs)
-
trunk/languages/bookminto.pot (modified) (26 diffs)
-
trunk/readme.txt (modified) (5 diffs)
-
trunk/uninstall.php (modified) (1 diff)
Legend:
- Unmodified
- Added
- Removed
-
bookminto/tags/wp-1.0.5/assets/src/api.js
r3723811 r3730301 1 1 import { apiUrl as defaultApiUrl } from './constants.js' 2 import { getWidgetCopy } from './copy.js' 2 3 import { 3 4 addBookingToShopifyCart, … … 49 50 commerceCheckout: commerce || undefined, 50 51 publicKey: config.workspace, 52 proposalSlug: config.proposalSlug || undefined, 51 53 shopifyProductGid: shopify ? config.shopifyProduct || undefined : undefined, 52 54 shopifyShopDomain: shopify ? config.shopifyShop || undefined : undefined, … … 65 67 if (!response.ok) { 66 68 const result = await response.json().catch(() => ({})) 67 throw new Error(result.error || 'Booking could not be created') 69 const error = new Error(result.error || 'Booking could not be created') 70 error.code = result.code 71 throw error 68 72 } 69 73 70 74 const result = await response.json() 75 if (result.ok !== true || result.holdExpired || ['cancelled', 'expired'].includes(result.status)) { 76 throw new Error(result.error || getWidgetCopy(config.language).closedAttempt) 77 } 71 78 72 if (shopify && (result.needsCheckout || result.bookingId)) {79 if (shopify && result.needsCheckout === true) { 73 80 await addBookingToShopifyCart({ 74 81 bookingId: result.bookingId, … … 81 88 } 82 89 83 if (woo && (result.needsCheckout || result.bookingId)) {90 if (woo && result.needsCheckout === true) { 84 91 await addBookingToWooCommerceCart({ 85 92 bookingId: result.bookingId, … … 97 104 } 98 105 106 /** 107 * Extension D: join the waitlist for a service. Public endpoint — the server 108 * re-checks the waitlist entitlement against the published config. 109 */ 110 export async function submitWaitlistEntry(config, payload) { 111 if ( 112 config.previewMode || 113 !config.workspace || 114 typeof fetch !== 'function' || 115 (isLocalSite() && usesDefaultApi(config)) 116 ) { 117 return { ok: true, localOnly: true } 118 } 119 120 const response = await fetch(`${bookingApiUrl(config)}/waitlist`, { 121 body: JSON.stringify({ 122 bookingId: payload.bookingId || undefined, 123 customerEmail: payload.customerEmail, 124 customerName: payload.customerName, 125 dateFrom: payload.dateFrom, 126 dateTo: payload.dateTo, 127 language: config.language, 128 publicKey: config.workspace, 129 serviceId: payload.serviceId, 130 source: isShopifyCommerce(config) ? 'shopify' : isWooCommerceCommerce(config) ? 'woocommerce' : undefined, 131 shopifyShopDomain: isShopifyCommerce(config) ? config.shopifyShop : undefined, 132 shopifyProductGid: isShopifyCommerce(config) ? config.shopifyProduct : undefined, 133 shopifyVariantId: isShopifyCommerce(config) ? String(config.shopifyVariant) : undefined, 134 wooProductId: isWooCommerceCommerce(config) ? String(config.wooProductId) : undefined, 135 siteUrl: window.location.href, 136 }), 137 headers: { 'Content-Type': 'application/json' }, 138 method: 'POST', 139 }).catch(() => null) 140 141 if (!response) { 142 throw new Error('Booking service unavailable. Please try again.') 143 } 144 145 const result = await response.json().catch(() => ({})) 146 147 if (!response.ok) { 148 throw new Error(result.error || 'Could not join the list') 149 } 150 151 return result 152 } 153 99 154 export { 100 155 addBookingToShopifyCart, -
bookminto/tags/wp-1.0.5/assets/src/availability.js
r3723811 r3730301 1 1 import { slotDateFormatter } from './constants.js' 2 import { widgetLocale } from './copy.js' 2 3 import { text } from './formatters.js' 3 4 import { … … 154 155 } 155 156 156 function getZonedDateFormatter(timeZone) { 157 if (!timeZone) { 157 function getZonedDateFormatter(timeZone, language) { 158 const locale = widgetLocale(language) 159 160 if (!timeZone && locale === 'en-US') { 158 161 return slotDateFormatter 159 162 } 160 163 161 164 try { 162 return new Intl.DateTimeFormat( 'en-US', {165 return new Intl.DateTimeFormat(locale, { 163 166 timeZone, 164 167 day: 'numeric', … … 171 174 } 172 175 173 function getBooked StartsForService(config, service) {176 function getBookedRanges(config) { 174 177 if (!Array.isArray(config?.bookedSlots)) { 175 178 return [] 176 179 } 177 180 178 const serviceId = service?.id 179 181 // Every occupied booking blocks its staff member's calendar; a slot stays 182 // bookable while at least one qualified member is free (union). Newer 183 // configs send footprint bounds (blockedStart/blockedEnd incl. buffers); 184 // older payloads fall back to the raw start. 180 185 return config.bookedSlots 181 .filter((slot) => { 182 if (!slot || typeof slot !== 'object') { 183 return false 184 } 185 186 // A booking with no serviceId, or one matching this service, occupies the 187 // single-provider calendar for this start time. 188 return !slot.serviceId || !serviceId || slot.serviceId === serviceId 189 }) 190 .map((slot) => slot.startsAt) 191 .filter(Boolean) 192 } 193 194 export function createAvailabilitySlots(config, service, limit = 6) { 186 .filter((slot) => slot && typeof slot === 'object' && slot.startsAt) 187 .map((slot) => ({ 188 end: slot.blockedEnd || slot.endsAt || slot.startsAt, 189 staffId: slot.staffId || null, 190 start: slot.blockedStart || slot.startsAt, 191 })) 192 } 193 194 // Team picker: members the customer may choose between for a service. The 195 // publication only puts displayable member objects in config.staff when 196 // staffDisplayMode is 'customer_choice'; service.staffIds narrows the 197 // qualified subset (null/undefined = all members, [] = explicitly nobody). 198 export function qualifiedStaffForService(config, service) { 199 const members = (Array.isArray(config?.staff) ? config.staff : []) 200 .map((member) => (typeof member === 'string' ? { id: member } : member)) 201 .filter((member) => member && typeof member.id === 'string' && member.id) 202 203 if (!Array.isArray(service?.staffIds)) { 204 return members 205 } 206 207 const allowed = new Set(service.staffIds) 208 return members.filter((member) => allowed.has(member.id)) 209 } 210 211 // A service is unbookable when its qualified set is explicitly empty. Only 212 // decidable once a staff list is published — legacy configs ship none and 213 // keep the union path. 214 export function serviceBlockedByStaff(config, service) { 215 return ( 216 Array.isArray(config?.staff) && 217 config.staff.length > 0 && 218 Array.isArray(service?.staffIds) && 219 qualifiedStaffForService(config, service).length === 0 220 ) 221 } 222 223 export function createAvailabilitySlots(config, service, limit = 6, staffId) { 195 224 if (!service) { 196 225 return [] … … 200 229 const rules = flattenAvailabilityRules(availability) 201 230 202 if (!rules.length ) {231 if (!rules.length && !service.bookingDates?.some(day => day.startTime && day.endTime)) { 203 232 return [] 204 233 } 205 234 206 235 const timeZone = config.timezone || undefined 207 const dateFormatter = getZonedDateFormatter(timeZone) 236 const dateFormatter = getZonedDateFormatter(timeZone, config.language) 237 const daysAhead = Math.min( 238 Math.max(Number(config.bookingHorizonDays) || 28, 1), 239 366, 240 ) 208 241 209 242 return buildSlots({ 210 243 availability, 211 booked Starts: getBookedStartsForService(config, service),244 bookedRanges: getBookedRanges(config), 212 245 exceptions: config.exceptions || [], 213 246 formatLabel: (startsAt) => { … … 221 254 }, 222 255 limit, 256 minNoticeMinutes: Math.max(Number(config.minNoticeMinutes) || 0, 0), 257 preferCompactSchedule: config.preferCompactSchedule === true, 258 staff: Array.isArray(config.staff) ? config.staff : [], 259 staffAvailability: config.staffAvailability || undefined, 260 staffId: staffId || undefined, 223 261 timeZone, 224 daysAhead : 28,262 daysAhead, 225 263 service: { 264 bookingDates: Array.isArray(service.bookingDates) ? service.bookingDates : [], 226 265 bufferAfterMinutes: Number(service.bufferAfterMinutes) || 0, 227 266 bufferBeforeMinutes: Number(service.bufferBeforeMinutes) || 0, … … 229 268 id: service.id, 230 269 name: service.name, 270 staffIds: Array.isArray(service.staffIds) ? service.staffIds : service.staffIds === null ? null : undefined, 231 271 }, 232 272 }) 233 273 } 234 274 235 function getSlotDateKey(startsAt, timeZone) {275 export function getSlotDateKey(startsAt, timeZone) { 236 276 const date = new Date(startsAt) 237 277 … … 258 298 } 259 299 260 export function createDatedAvailabilitySlots(config, service ) {261 const slots = createAvailabilitySlots(config, service, 200 )300 export function createDatedAvailabilitySlots(config, service, staffId) { 301 const slots = createAvailabilitySlots(config, service, 200, staffId) 262 302 const timeZone = config.timezone || undefined 263 303 const byDate = new Map() -
bookminto/tags/wp-1.0.5/assets/src/bookingBar.js
r3723811 r3730301 1 import { createAvailabilitySlots } from './availability.js' 1 import { servicePhotoMarkup, bindServicePhotoErrors } from './servicePhotos.js' 2 import { createAvailabilitySlots, serviceBlockedByStaff } from './availability.js' 2 3 import { submitBooking } from './api.js' 3 4 import { buildIcsDataUrl } from './calendar.js' … … 5 6 import { confirmBookingPayment, getDepositPaymentSession, mountDepositPayment, showPaymentLoadError } from './payment.js' 6 7 import { getContrastText } from './theme.js' 7 8 function panelSummary(service, slot) { 8 import { getWidgetCopy } from './copy.js' 9 import { DIAL_CODES, PHONE_COUNTRIES, normalizePhoneE164 } from './phoneNumber.js' 10 11 function panelSummary(service, slot, copy, language) { 9 12 return service && slot 10 ? `${slot.label} · ${serviceSummary(service )}`11 : 'Select a service and time to continue.'13 ? `${slot.label} · ${serviceSummary(service, language)}` 14 : copy.selectServiceAndTime 12 15 } 13 16 14 function stepperMarkup(currentStep ) {17 function stepperMarkup(currentStep, copy) { 15 18 const steps = [ 16 ['service', 'Service'],17 ['time', 'Time'],18 ['details', 'Details'],19 ['confirmed', 'Done'],19 ['service', copy.widgetStepLabels[0]], 20 ['time', copy.widgetStepLabels[1]], 21 ['details', copy.widgetStepLabels[2]], 22 ['confirmed', copy.widgetStepLabels[3]], 20 23 ] 21 24 … … 27 30 class="bw-booking-modal__stepper" 28 31 role="progressbar" 29 aria-label=" Booking progress"32 aria-label="${escapeHtml(copy.progressLabel)}" 30 33 aria-valuemin="1" 31 34 aria-valuemax="${steps.length}" 32 35 aria-valuenow="${currentIndex + 1}" 33 aria-valuetext=" Step ${currentIndex + 1} of ${steps.length}: ${activeLabel}"36 aria-valuetext="${escapeHtml(copy.stepLabel)} ${currentIndex + 1} ${escapeHtml(copy.stepOf)} ${steps.length}: ${escapeHtml(activeLabel)}" 34 37 > 35 38 <div class="bw-booking-modal__step-meta" aria-hidden="true"> 36 <span> Step ${currentIndex + 1} of${steps.length}</span>39 <span>${escapeHtml(copy.stepLabel)} ${currentIndex + 1} ${escapeHtml(copy.stepOf)} ${steps.length}</span> 37 40 <strong>${activeLabel}</strong> 38 41 </div> … … 46 49 } 47 50 48 function serviceRowsMarkup(services, activeIndex ) {51 function serviceRowsMarkup(services, activeIndex, previewMode, copy, language) { 49 52 if (!services.length) { 50 return '<div class="bw-booking-bar-panel__empty">No services are published for this widget yet.</div>'53 return `<div class="bw-booking-bar-panel__empty">${escapeHtml(copy.emptyServices)}</div>` 51 54 } 52 55 … … 56 59 <button type="button" class="bw-booking-modal__service" data-panel-service-index="${index}" data-active="${index === activeIndex}" aria-pressed="${index === activeIndex}"> 57 60 <span class="bw-booking-modal__service-body"> 58 <strong>${escapeHtml(service.name)}</strong> 59 <em>${escapeHtml(serviceDetail(service))}</em> 61 ${servicePhotoMarkup(service.imageUrl, previewMode)} 62 <span class="bw-booking-modal__service-copy"> 63 <strong>${escapeHtml(service.name)}</strong> 64 <em>${escapeHtml(serviceDetail(service, language))}</em> 65 </span> 60 66 </span> 61 <span class="bw-booking-modal__service-price">${escapeHtml(servicePriceSummary(service ))}</span>67 <span class="bw-booking-modal__service-price">${escapeHtml(servicePriceSummary(service, language))}</span> 62 68 </button> 63 69 `, … … 66 72 } 67 73 74 // crypto.randomUUID is secure-context only — plain-HTTP embeds (common for 75 // WordPress hosts) must fall back or the bar never mounts. 76 function newClientRequestId() { 77 try { 78 if (typeof crypto?.randomUUID === 'function') { 79 return crypto.randomUUID() 80 } 81 } catch { 82 // fall through to the manual id 83 } 84 return `bmr-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}` 85 } 86 68 87 export function createBookingBar(config, mountId, target = document.body) { 88 const copy = getWidgetCopy(config.language) 89 let clientRequestId = newClientRequestId() 90 let terminalPaymentReplacementAvailable = false 91 let answers = {} 69 92 const shell = document.createElement('div') 70 93 const spacer = document.createElement('div') … … 79 102 let paymentController = null 80 103 let isSubmitting = false 81 let form = { customerName: '', customerEmail: '', customerPhone: '', notes: '' }104 let form = { customerName: '', customerEmail: '', customerPhone: '', customerPhoneCountry: '', notes: '' } 82 105 83 106 shell.className = 'bw-booking-bar-shell' … … 149 172 function timeMarkup(services, selectedService, slots) { 150 173 if (config.loadError) { 151 return '<div class="bw-booking-bar-panel__empty">Booking setup could not be loaded.</div>'174 return `<div class="bw-booking-bar-panel__empty">${escapeHtml(copy.loadError)}</div>` 152 175 } 153 176 154 177 if (!selectedService) { 155 return '<div class="bw-booking-bar-panel__empty">No services are published for this widget yet.</div>'156 } 157 158 const serviceCountLabel = `${services.length} ${services.length === 1 ? 'service' : 'services'}`178 return `<div class="bw-booking-bar-panel__empty">${escapeHtml(copy.emptyServices)}</div>` 179 } 180 181 const serviceCountLabel = `${services.length} ${services.length === 1 ? copy.serviceSingular : copy.servicePlural}` 159 182 const slotMarkup = slots.length 160 183 ? slots … … 166 189 <em>${escapeHtml(slot.capacity)}</em> 167 190 </span> 168 <span>${ index === selectedSlotIndex ? 'Selected' : 'Choose'}</span>191 <span>${escapeHtml(index === selectedSlotIndex ? copy.selected : copy.choose)}</span> 169 192 </button> 170 193 `, 171 194 ) 172 195 .join('') 173 : '<div class="bw-booking-bar-panel__empty">No times are available yet.</div>'196 : `<div class="bw-booking-bar-panel__empty">${escapeHtml(copy.emptySlots)}</div>` 174 197 175 198 return ` 176 199 <div class="bw-booking-modal__time"> 177 <aside class="bw-booking-modal__services" aria-label=" Services">200 <aside class="bw-booking-modal__services" aria-label="${escapeHtml(copy.servicesLabel)}"> 178 201 <div class="bw-booking-modal__section-title"> 179 <strong> Services</strong>202 <strong>${escapeHtml(copy.servicesLabel)}</strong> 180 203 <em>${escapeHtml(serviceCountLabel)}</em> 181 204 </div> 182 205 <div class="bw-booking-modal__service-list"> 183 ${serviceRowsMarkup(services, selectedServiceIndex )}206 ${serviceRowsMarkup(services, selectedServiceIndex, config.previewMode, copy, config.language)} 184 207 </div> 185 208 </aside> 186 <section class="bw-booking-modal__times" aria-label=" Available times">209 <section class="bw-booking-modal__times" aria-label="${escapeHtml(copy.availableTimes)}"> 187 210 <div class="bw-booking-modal__section-title"> 188 <strong> Times</strong><span>${escapeHtml(timezoneLabel(config))}</span>211 <strong>${escapeHtml(copy.timesLabel)}</strong><span>${escapeHtml(timezoneLabel(config))}</span> 189 212 <em>${escapeHtml(selectedService.name)}</em> 190 213 </div> … … 199 222 function detailsMarkup(selectedService, selectedSlot) { 200 223 if (!selectedService || !selectedSlot) { 201 return '<div class="bw-booking-modal__pane"><div class="bw-booking-bar-panel__empty">Select a service and time first.</div></div>' 202 } 224 return `<div class="bw-booking-modal__pane"><div class="bw-booking-bar-panel__empty">${escapeHtml(copy.selectServiceAndTime)}</div></div>` 225 } 226 const depositUnavailable = Number(selectedService.depositCents) > 0 && config.features?.deposits !== true 203 227 204 228 return ` … … 207 231 <div class="bw-booking-bar-panel__summary-card"> 208 232 <strong>${escapeHtml(selectedService.name)}</strong> 209 <span>${escapeHtml(panelSummary(selectedService, selectedSlot ))}</span>233 <span>${escapeHtml(panelSummary(selectedService, selectedSlot, copy, config.language))}</span> 210 234 </div> 211 <p class="bw-booking-bar-panel__section-title">Your details</p> 235 <p class="bw-booking-bar-panel__section-title">${escapeHtml(copy.detailsHeading)}</p> 236 ${depositUnavailable ? `<p role="note">${escapeHtml(copy.depositUnavailable)}</p>` : ''} 212 237 <div class="bw-booking-bar-panel__form-row"> 213 238 <label> 214 <span> Name</span>215 <input name="customerName" autocomplete="name" required placeholder=" Full name" value="${escapeHtml(form.customerName)}" />239 <span>${escapeHtml(copy.nameLabel)}</span> 240 <input name="customerName" autocomplete="name" required placeholder="${escapeHtml(copy.namePlaceholder)}" value="${escapeHtml(form.customerName)}" /> 216 241 </label> 242 ${ 243 config.sms?.available === true 244 ? ` 245 <label> 246 <span>${escapeHtml(copy.phoneCountryLabel)}</span> 247 <select name="customerPhoneCountry" autocomplete="country"> 248 <option value="">${escapeHtml(copy.chooseOption)}</option> 249 ${PHONE_COUNTRIES.map( 250 (iso) => 251 `<option value="${iso}"${form.customerPhoneCountry === iso ? ' selected' : ''}>${iso} (+${escapeHtml(DIAL_CODES[iso])})</option>`, 252 ).join('')} 253 </select> 254 </label> 255 ` 256 : '' 257 } 217 258 <label> 218 <span> Phone <small>(optional)</small></span>219 <input name="customerPhone" autocomplete="tel" type="tel" placeholder=" +1 555 0100" value="${escapeHtml(form.customerPhone)}" />259 <span>${escapeHtml(copy.phoneLabel)}</span> 260 <input name="customerPhone" autocomplete="tel" type="tel" placeholder="${escapeHtml(copy.phonePlaceholder)}" value="${escapeHtml(form.customerPhone)}" /> 220 261 221 262 </label> 222 263 </div> 223 264 <label> 224 <span> Email</span>225 <input name="customerEmail" autocomplete="email" type="email" required placeholder=" you@example.com" value="${escapeHtml(form.customerEmail)}" />265 <span>${escapeHtml(copy.emailLabel)}</span> 266 <input name="customerEmail" autocomplete="email" type="email" required placeholder="${escapeHtml(copy.emailPlaceholder)}" value="${escapeHtml(form.customerEmail)}" /> 226 267 </label> 227 268 <label> 228 <span> Notes <small>(optional)</small></span>229 <textarea name="notes" rows="3" maxlength="500" placeholder=" Anything we should know?">${escapeHtml(form.notes)}</textarea>269 <span>${escapeHtml(copy.notesLabel)}</span> 270 <textarea name="notes" rows="3" maxlength="500" placeholder="${escapeHtml(copy.notesPlaceholder)}">${escapeHtml(form.notes)}</textarea> 230 271 </label> 272 ${(selectedService.bookingQuestions || []).map(question => { 273 const value = String(answers[question.id] || '') 274 const name = `answer-${escapeHtml(question.id)}` 275 const required = question.required ? ' required' : '' 276 const options = question.type === 'yesno' ? ['yes', 'no'] : question.options || [] 277 const control = question.type === 'text' 278 ? `<input name="${name}" maxlength="500"${required} value="${escapeHtml(value)}">` 279 : `<select name="${name}"${required}><option value="">${escapeHtml(copy.chooseOption)}</option>${options.map(option => `<option value="${escapeHtml(option)}"${value === option ? ' selected' : ''}>${escapeHtml(question.type === 'yesno' ? (option === 'yes' ? copy.yesLabel : copy.noLabel) : option)}</option>`).join('')}</select>` 280 return `<label><span>${escapeHtml(question.label)}${question.required ? ' *' : ''}</span>${control}</label>` 281 }).join('')} 282 ${ 283 config.sms?.available === true 284 ? ` 285 <label class="bw-booking-bar-panel__optin"> 286 <input type="checkbox" name="smsOptIn" /> 287 <span>${escapeHtml(copy.smsOptInLabel)}</span> 288 </label> 289 ` 290 : '' 291 } 292 ${ 293 config.whatsapp?.available === true 294 ? ` 295 <label class="bw-booking-bar-panel__optin"> 296 <input type="checkbox" name="whatsappOptIn" /> 297 <span>${escapeHtml(copy.whatsappOptInLabel)}</span> 298 </label> 299 ` 300 : '' 301 } 231 302 ${message ? `<div class="bw-booking-bar-panel__message" role="status">${escapeHtml(message)}</div>` : ''} 232 303 <div class="bw-booking-bar-panel__actions"> 233 <button type="button" class="bw-booking-bar-panel__back"> Back</button>234 <button type="submit" class="bw-booking-bar-panel__continue" >Confirm booking</button>304 <button type="button" class="bw-booking-bar-panel__back">${escapeHtml(copy.back)}</button> 305 <button type="submit" class="bw-booking-bar-panel__continue" ${depositUnavailable ? 'disabled' : ''}>${escapeHtml(terminalPaymentReplacementAvailable ? copy.replacePaymentCta : copy.confirmCta)}</button> 235 306 </div> 236 307 </form> … … 241 312 function paymentMarkup(selectedService, selectedSlot) { 242 313 return `<div class="bw-booking-modal__pane"> 243 <div class="bw-booking-bar-panel__summary-card"><strong>${escapeHtml(selectedService?.name || 'Appointment')}</strong><span>${escapeHtml(panelSummary(selectedService, selectedSlot))}</span></div>244 <p class="bw-booking-bar-panel__section-title"> Pay ${escapeHtml(servicePriceSummary({ price: paymentSession.depositCents / 100 }))} deposit</p>245 <p> Your booking is confirmed after payment.</p>246 < div data-payment-element></div>247 <p data-payment-status role="status"> Loading secure payment…</p>314 <div class="bw-booking-bar-panel__summary-card"><strong>${escapeHtml(selectedService?.name || copy.appointment)}</strong><span>${escapeHtml(panelSummary(selectedService, selectedSlot, copy, config.language))}</span></div> 315 <p class="bw-booking-bar-panel__section-title">${escapeHtml(copy.depositHeading)} · ${escapeHtml(servicePriceSummary({ price: paymentSession.depositCents / 100, currency: selectedService?.currency }, config.language))}</p> 316 <p>${escapeHtml(copy.depositNote)}</p> 317 <slot name="bm-payment-element" data-payment-element></slot> 318 <p data-payment-status role="status">${escapeHtml(copy.paymentLoading)}</p> 248 319 ${message ? `<p role="alert">${escapeHtml(message)}</p>` : ''} 249 <button type="button" class="bw-booking-bar-panel__continue" data-pay-deposit disabled> Pay deposit and confirm</button>320 <button type="button" class="bw-booking-bar-panel__continue" data-pay-deposit disabled>${escapeHtml(copy.payDepositCta)}</button> 250 321 </div>` 251 322 } … … 265 336 const reference = 266 337 confirmation?.bookingId && !testMode 267 ? `<span class="bw-booking-bar-panel__ref"> Booking reference: <code>${escapeHtml(338 ? `<span class="bw-booking-bar-panel__ref">${escapeHtml(copy.bookingReference)}: <code>${escapeHtml( 268 339 String(confirmation.bookingId).slice(0, 8), 269 340 )}</code></span>` 270 341 : '' 271 342 const calendarLink = icsUrl 272 ? `<a class="bw-booking-bar-panel__calendar" href="${icsUrl}" download="booking.ics"> Add to calendar</a>`343 ? `<a class="bw-booking-bar-panel__calendar" href="${icsUrl}" download="booking.ics">${escapeHtml(copy.addToCalendar)}</a>` 273 344 : '' 274 345 … … 277 348 <div class="bw-booking-bar-panel__confirmed"> 278 349 <div class="bw-booking-bar-panel__confirm-icon" aria-hidden="true">✓</div> 279 <strong>${ testMode ? 'Test mode — booking not saved' : 'Request received'}</strong>350 <strong>${escapeHtml(testMode ? copy.testModeHeading : copy.successHeading)}</strong> 280 351 <span>${ 281 352 testMode 282 ? 'Nothing was sent — this site runs on a local or test address.'283 : 'A confirmation follows by email.'353 ? escapeHtml(copy.testModeNote) 354 : escapeHtml(copy.successNote) 284 355 }</span> 285 356 ${reference} 286 357 ${calendarLink} 287 <button type="button" class="bw-booking-bar-panel__continue" data-bar-done> Done</button>358 <button type="button" class="bw-booking-bar-panel__continue" data-bar-done>${escapeHtml(copy.done)}</button> 288 359 </div> 289 360 </div> … … 310 381 <em>${escapeHtml( 311 382 bookingStep === 'details' 312 ? 'Enter your details'383 ? copy.detailsHeading 313 384 : bookingStep === 'confirmed' 314 ? 'Request received'315 : 'Choose a time',385 ? copy.successHeading 386 : copy.chooseTime, 316 387 )}</em> 317 388 </span> 318 389 </span> 319 <button type="button" class="bw-booking-bar-panel__close" aria-label=" Close booking bar">×</button>390 <button type="button" class="bw-booking-bar-panel__close" aria-label="${escapeHtml(copy.close)}">×</button> 320 391 </div> 321 ${stepperMarkup(bookingStep )}392 ${stepperMarkup(bookingStep, copy)} 322 393 <div class="bw-booking-modal__body">${content}</div> 323 394 ${ 324 395 config.showBranding 325 ? '<div class="bw-booking-modal__powered">Powered by <strong>Bookminto</strong></div>'396 ? `<div class="bw-booking-modal__powered">${escapeHtml(copy.widgetFooterPrefix)} <strong>Bookminto</strong></div>` 326 397 : '' 327 398 } … … 330 401 ? `<div class="bw-booking-modal__footer"> 331 402 <span> 332 <strong>${escapeHtml(selectedService?.name || 'No service selected')}</strong>333 <span>${escapeHtml(panelSummary(selectedService, selectedSlot ))}</span>403 <strong>${escapeHtml(selectedService?.name || copy.noServiceSelected)}</strong> 404 <span>${escapeHtml(panelSummary(selectedService, selectedSlot, copy, config.language))}</span> 334 405 </span> 335 <button type="button" class="bw-booking-bar-panel__continue" ${canContinue ? '' : 'disabled'}>${ canContinue ? 'Continue booking' : 'No time selected'}</button>406 <button type="button" class="bw-booking-bar-panel__continue" ${canContinue ? '' : 'disabled'}>${escapeHtml(canContinue ? copy.continueDefault : copy.noTimeSelected)}</button> 336 407 </div>` 337 408 : '' … … 346 417 const services = config.services 347 418 const selectedService = services[selectedServiceIndex] 348 const slots = createAvailabilitySlots(config, selectedService, 6) 419 // Union availability still scopes this surface; an explicitly empty 420 // qualified set means nobody can perform the service. 421 const slots = serviceBlockedByStaff(config, selectedService) 422 ? [] 423 : createAvailabilitySlots(config, selectedService, 6) 349 424 const selectedSlot = slots[selectedSlotIndex] 350 425 const canContinue = Boolean(selectedService && selectedSlot) 351 426 const invitation = config.loadError 352 ? 'Booking setup unavailable'427 ? copy.loadError 353 428 : services.length > 1 354 ? 'Choose a service and time'429 ? copy.selectServiceAndTime 355 430 : selectedService 356 ? serviceSummary(selectedService )357 : 'No services available yet'431 ? serviceSummary(selectedService, config.language) 432 : copy.emptyServices 358 433 359 434 shell.dataset.open = String(isOpen) … … 369 444 </button> 370 445 <button type="button" class="bw-booking-bar__cta">${ 371 isOpen ? 'Close' : selectedService ? 'Choose time' : 'View setup'446 escapeHtml(isOpen ? copy.close : selectedService ? copy.chooseTime : copy.viewSetup) 372 447 }</button> 373 448 </div> … … 377 452 panel.className = 'bw-booking-bar-panel bw-booking-bar-panel--booking' 378 453 panel.innerHTML = bookingMarkup(selectedService, selectedSlot, slots, canContinue) 454 bindServicePhotoErrors(panel) 379 455 380 456 shell.querySelector('.bw-booking-bar__main')?.addEventListener('click', () => { … … 382 458 bookingStep = 'time' 383 459 render() 460 if (isOpen) panel.querySelector('.bw-booking-bar-panel__close')?.focus() 461 else shell.querySelector('.bw-booking-bar__cta')?.focus() 384 462 }) 385 463 shell.querySelector('.bw-booking-bar__cta')?.addEventListener('click', () => { … … 387 465 bookingStep = 'time' 388 466 render() 467 if (isOpen) panel.querySelector('.bw-booking-bar-panel__close')?.focus() 468 else shell.querySelector('.bw-booking-bar__cta')?.focus() 389 469 }) 390 470 panel.querySelector('.bw-booking-bar-panel__close')?.addEventListener('click', () => { … … 392 472 bookingStep = 'time' 393 473 render() 474 shell.querySelector('.bw-booking-bar__cta')?.focus() 394 475 }) 395 476 panel.querySelectorAll('[data-panel-service-index]').forEach((button) => { … … 397 478 button.addEventListener('click', () => { 398 479 selectedServiceIndex = Number(button.getAttribute('data-panel-service-index')) || 0 480 clientRequestId = newClientRequestId() 481 terminalPaymentReplacementAvailable = false 482 answers = {} 399 483 selectedSlotIndex = 0 400 484 message = '' … … 407 491 button.addEventListener('click', () => { 408 492 selectedSlotIndex = Number(button.getAttribute('data-slot-index')) || 0 493 clientRequestId = newClientRequestId() 494 terminalPaymentReplacementAvailable = false 409 495 message = '' 410 496 render() … … 423 509 }) 424 510 panel.querySelector('[data-bar-done]')?.addEventListener('click', () => { 511 clientRequestId = newClientRequestId() 512 terminalPaymentReplacementAvailable = false 513 answers = {} 425 514 isOpen = false 426 515 bookingStep = 'time' … … 431 520 const button = panel.querySelector('[data-pay-deposit]') 432 521 const session = paymentSession 433 void mountDepositPayment(host, { ...session, accent: config.color }).then((controller) => {522 void mountDepositPayment(host, { ...session, accent: config.color, language: config.language }).then((controller) => { 434 523 if (!host.isConnected || paymentSession !== session) { controller.destroy(); return } 435 524 paymentController = controller … … 437 526 panel.querySelector('[data-payment-status]').textContent = '' 438 527 }).catch((error) => { 439 if (host.isConnected && paymentSession === session) { panel.querySelector('[data-payment-status]').textContent = ''; showPaymentLoadError(host, error, render ) }528 if (host.isConnected && paymentSession === session) { panel.querySelector('[data-payment-status]').textContent = ''; showPaymentLoadError(host, error, render, config.language) } 440 529 }) 441 530 button.addEventListener('click', async () => { … … 445 534 const controller = paymentController 446 535 try { 447 const paymentIntentId = await controller.confirm() 448 await confirmBookingPayment(config, { bookingId: session.bookingId, paymentIntentId }) 536 const paymentIntentId = session.confirmedPaymentIntentId || await controller.confirm() 537 if (paymentSession !== session || paymentController !== controller) return 538 session.confirmedPaymentIntentId = paymentIntentId 539 const result = await confirmBookingPayment(config, { bookingId: session.bookingId, paymentIntentId }) 540 if (paymentSession !== session || paymentController !== controller) return 541 if (result.processing || !(result.ok || result.alreadyConfirmed)) throw new Error(copy.paymentError) 449 542 confirmation = { bookingId: session.bookingId, serviceName: selectedService?.name, durationMinutes: selectedService?.durationMinutes, startsAt: selectedSlot?.startsAt } 450 543 paymentSession = null … … 452 545 message = '' 453 546 } catch (error) { 454 message = error instanceof Error ? error.message : 'Payment could not be confirmed. Try again.' 547 message = error instanceof Error ? error.message : copy.paymentError 548 if (host.isConnected) panel.querySelector('[data-payment-status]').textContent = message 455 549 } finally { 456 550 isSubmitting = false 457 render() 551 if (paymentSession === session) button.disabled = false 552 else render() 458 553 } 459 554 }) … … 462 557 event.preventDefault() 463 558 if (isSubmitting) return 559 if (Number(selectedService?.depositCents) > 0 && config.features?.deposits !== true) return 464 560 isSubmitting = true 465 561 const formData = new FormData(event.currentTarget) 466 562 form = Object.fromEntries(Object.keys(form).map((key) => [key, String(formData.get(key) || '')])) 467 468 message = 'Sending request...' 563 const smsOptIn = formData.get('smsOptIn') === 'on' 564 const whatsappOptIn = formData.get('whatsappOptIn') === 'on' 565 answers = Object.fromEntries((selectedService.bookingQuestions || []).map(question => [question.id, String(formData.get(`answer-${question.id}`) || '')])) 566 567 // The opt-in only counts with a routable number — the worker re-checks. 568 if ((smsOptIn || whatsappOptIn) && !normalizePhoneE164(form.customerPhone, form.customerPhoneCountry || undefined)) { 569 isSubmitting = false 570 message = whatsappOptIn && !smsOptIn ? copy.phoneRequiredForWhatsapp : copy.phoneRequiredForSms 571 render() 572 return 573 } 574 575 message = copy.processing 469 576 render() 470 577 const bookedService = selectedService … … 472 579 473 580 submitBooking(config, { 581 answers, 582 clientRequestId, 583 replaceTerminalPayment: terminalPaymentReplacementAvailable || undefined, 584 publicationRevision: config.publicationRevision, 585 language: config.language || 'en', 474 586 customerEmail: formData.get('customerEmail'), 475 587 customerName: formData.get('customerName'), 476 588 customerPhone: formData.get('customerPhone'), 589 customerPhoneCountry: form.customerPhoneCountry || undefined, 477 590 notes: formData.get('notes'), 478 591 serviceId: selectedService?.id || selectedService?.name, 479 592 serviceName: selectedService?.name, 593 smsOptIn: config.sms?.available === true ? smsOptIn || undefined : undefined, 480 594 startsAt: selectedSlot?.startsAt, 595 whatsappOptIn: config.whatsapp?.available === true ? whatsappOptIn || undefined : undefined, 481 596 }) 482 597 .then((result) => { … … 484 599 return 485 600 } 486 const depositSession = getDepositPaymentSession(result )601 const depositSession = getDepositPaymentSession(result, config.language) 487 602 if (depositSession) { 603 terminalPaymentReplacementAvailable = false 488 604 paymentSession = depositSession 489 605 message = '' … … 495 611 durationMinutes: bookedService?.durationMinutes, 496 612 localOnly: result?.localOnly === true, 497 serviceName: bookedService?.name || 'Appointment',613 serviceName: bookedService?.name || copy.appointment, 498 614 startsAt: bookedSlot?.startsAt, 499 615 } … … 503 619 }) 504 620 .catch((error) => { 505 message = error instanceof Error ? error.message : 'Booking could not be created' 621 terminalPaymentReplacementAvailable = error?.code === 'terminal_payment_replacement_available' 622 message = terminalPaymentReplacementAvailable ? copy.replacePaymentNote : error instanceof Error ? error.message : copy.genericError 506 623 bookingStep = 'details' 507 624 render() -
bookminto/tags/wp-1.0.5/assets/src/bookingBarModalStyles.js
r3723811 r3730301 194 194 } 195 195 196 .bw-booking-modal__service .bw-booking-modal__service-body { 197 display: flex; 198 align-items: center; 199 gap: 10px; 200 min-width: 0; 201 } 202 203 .bw-booking-modal__service .bw-booking-modal__service-copy { 204 display: flex; 205 flex-direction: column; 206 min-width: 0; 207 } 208 196 209 .bw-booking-modal__service-body strong { 197 210 overflow: hidden; -
bookminto/tags/wp-1.0.5/assets/src/bookingBarStyles.js
r3723811 r3730301 224 224 225 225 @media (max-width: 760px) { 226 /* The mobile panel fills the viewport. Its own close button stays visible; 227 * the bottom launcher would otherwise cover the booking action. */ 228 .bw-booking-bar-shell[data-open="true"] { 229 display: none; 230 } 231 226 232 .bw-booking-bar-spacer { 227 233 height: 0; -
bookminto/tags/wp-1.0.5/assets/src/bookingModal.js
r3723811 r3730301 1 import { widgetCopy as copy } from './copy.js'1 import { getWidgetCopy } from './copy.js' 2 2 import { createModalPanel } from './modalRender.js' 3 3 … … 14 14 */ 15 15 export function createBookingModal(config, mountId, root, options = {}) { 16 const copy = getWidgetCopy(config.language) 16 17 const triggerSelector = 17 18 typeof config.trigger === 'string' … … 20 21 ? config.triggerSelector.trim() 21 22 : '' 23 // The trigger selector comes from the published remote config — a malformed 24 // value would make closest() throw on every click on the host page. 25 let safeTriggerSelector = '' 26 if (triggerSelector && typeof document.querySelector === 'function') { 27 try { 28 document.querySelector(triggerSelector) 29 safeTriggerSelector = triggerSelector 30 } catch { 31 // Invalid selector — fall back to the inline CTA below. 32 } 33 } 22 34 const buttonAlign = normalizeButtonAlign(config.buttonAlign || config.buttonPosition) 23 35 const openByDefault = Boolean(options.openByDefault) … … 68 80 } 69 81 70 if (! triggerSelector) {82 if (!safeTriggerSelector && !options.suppressCta) { 71 83 ctaWrap = document.createElement('div') 72 84 cta = document.createElement('button') … … 102 114 function onExternalClick(event) { 103 115 const target = event.target 104 if (! triggerSelector || !target || typeof target.closest !== 'function') {116 if (!safeTriggerSelector || !target || typeof target.closest !== 'function') { 105 117 return 106 118 } 107 119 108 if (target.closest( triggerSelector)) {120 if (target.closest(safeTriggerSelector)) { 109 121 event.preventDefault() 110 122 setOpen(true) … … 114 126 if (typeof document.addEventListener === 'function') { 115 127 document.addEventListener('keydown', onKeyDown) 116 if ( triggerSelector) {128 if (safeTriggerSelector) { 117 129 document.addEventListener('click', onExternalClick, true) 118 130 } … … 127 139 if (typeof document.removeEventListener === 'function') { 128 140 document.removeEventListener('keydown', onKeyDown) 129 if ( triggerSelector) {141 if (safeTriggerSelector) { 130 142 document.removeEventListener('click', onExternalClick, true) 131 143 } … … 150 162 } 151 163 }, 164 panel, 152 165 setOpen, 153 166 } -
bookminto/tags/wp-1.0.5/assets/src/commerce.js
r3723811 r3730301 174 174 } 175 175 176 const checkoutUrl = result?.data?.checkoutUrl|| '/checkout/'176 const checkoutUrl = safeNavigationUrl(result?.data?.checkoutUrl) || '/checkout/' 177 177 window.location.assign(checkoutUrl) 178 178 return { redirected: true } 179 179 } 180 181 // The WooCommerce AJAX response controls the navigation target — restrict it 182 // to same-origin paths or http(s) URLs so a `javascript:`/`data:` value can 183 // never execute. 184 function safeNavigationUrl(value) { 185 if (typeof value !== 'string' || !value) { 186 return '' 187 } 188 try { 189 const url = new URL(value, window.location.href) 190 return url.protocol === 'https:' || url.protocol === 'http:' ? url.href : '' 191 } catch { 192 return '' 193 } 194 } -
bookminto/tags/wp-1.0.5/assets/src/config.js
r3723811 r3730301 2 2 import { readAvailability, readExceptions } from './availability.js' 3 3 import { normalizeColor, normalizeTimeFormat, text } from './formatters.js' 4 import { getWidgetCopy } from './copy.js' 4 5 5 6 const localApiUrl = 'http://127.0.0.1:5174/v1' … … 57 58 } 58 59 59 return { serviceId: text(slot.serviceId, ''), startsAt } 60 return { 61 blockedEnd: text(slot.blockedEnd, ''), 62 blockedStart: text(slot.blockedStart, ''), 63 serviceId: text(slot.serviceId, ''), 64 staffId: text(slot.staffId, '') || null, 65 startsAt, 66 } 60 67 }) 61 68 .filter(Boolean) … … 64 71 function checkoutServices(services, commerce) { 65 72 return commerce === 'shopify' || commerce === 'woocommerce' ? services.map((service) => ({ ...service, depositCents: 0, depositEuros: 0 })) : services 73 } 74 75 export function normalizeLanguage(value) { 76 // Accept regional tags too (data-language="de-AT" → 'de') — browsers report 77 // full BCP-47 tags and the dictionaries live at the base language. 78 const language = text(value, '').toLowerCase().split(/[-_]/)[0] 79 return ['de', 'en', 'es', 'fr', 'it', 'nl'].includes(language) ? language : 'en' 80 } 81 82 // service.staffIds: null/undefined = every member qualified, [] = nobody. 83 function readStaffIds(value) { 84 if (value === null) { 85 return null 86 } 87 88 if (!Array.isArray(value)) { 89 return undefined 90 } 91 92 return value.map((id) => text(id, '')).filter(Boolean).slice(0, 100) 93 } 94 95 // Public team list — displayable member objects only arrive when the 96 // publication uses 'customer_choice' display mode. 97 function readStaffList(value) { 98 if (!Array.isArray(value)) { 99 return [] 100 } 101 102 return value 103 .map((member) => { 104 if (!member || typeof member !== 'object') { 105 return null 106 } 107 108 const id = text(member.id, '') 109 110 if (!id) { 111 return null 112 } 113 114 const color = text(member.color, '') 115 116 return { 117 avatarUrl: safeServiceImageUrl(member.avatarUrl), 118 color: /^#[0-9a-fA-F]{6}$/.test(color) ? color : '', 119 id, 120 name: text(member.name, ''), 121 } 122 }) 123 .filter(Boolean) 124 .slice(0, 50) 125 } 126 127 function readStaffAvailability(value) { 128 if (!value || typeof value !== 'object' || Array.isArray(value)) { 129 return undefined 130 } 131 132 const entries = {} 133 134 for (const [key, entry] of Object.entries(value)) { 135 const memberId = text(key, '') 136 137 if (!memberId || !entry || typeof entry !== 'object') { 138 continue 139 } 140 141 entries[memberId] = { 142 availability: readAvailability(entry.availability), 143 exceptions: readExceptions(entry.exceptions), 144 } 145 } 146 147 return entries 148 } 149 150 // Booking questions the owner configured on a service (max 5, three types). 151 function readBookingQuestions(value) { 152 if (!Array.isArray(value)) { 153 return [] 154 } 155 156 return value 157 .map((question, index) => { 158 if (!question || typeof question !== 'object') { 159 return null 160 } 161 162 const label = text(question.label, '') 163 const type = ['text', 'choice', 'yesno'].includes(question.type) 164 ? question.type 165 : 'text' 166 167 if (!label) { 168 return null 169 } 170 171 return { 172 id: text(question.id, `q${index + 1}`), 173 label, 174 options: 175 type === 'choice' && Array.isArray(question.options) 176 ? question.options.map((option) => text(option, '')).filter(Boolean).slice(0, 8) 177 : [], 178 required: question.required === true, 179 type, 180 } 181 }) 182 .filter(Boolean) 183 .slice(0, 5) 66 184 } 67 185 … … 72 190 apiUrl: runtimeApiUrl(config.apiUrl), 73 191 bookedSlots: Array.isArray(config.bookedSlots) ? config.bookedSlots : [], 192 bookingHorizonDays: Number(config.bookingHorizonDays) || 0, 74 193 color: normalizeColor(config.color), 194 features: 195 config.features && typeof config.features === 'object' ? config.features : {}, 196 publicationRevision: String(config.publicationRevision || ''), 197 language: normalizeLanguage(config.language), 198 minNoticeMinutes: Number(config.minNoticeMinutes) || 0, 199 nextTimes: optionalBoolean(config.nextTimes, false), 200 preferCompactSchedule: optionalBoolean(config.preferCompactSchedule, false), 201 selfServiceCutoffHours: Number(config.selfServiceCutoffHours) || 0, 75 202 commerce, 76 203 services: checkoutServices(config.services, commerce), … … 78 205 position: text(config.position, 'bottom-right'), 79 206 showBranding: optionalBoolean(config.showBranding, false), 80 title: text(config.title, 'Schedule a booking'),207 title: text(config.title, getWidgetCopy(config.language).title), 81 208 timeFormat: normalizeTimeFormat(config.timeFormat), 82 209 timezone: text(config.timezone, ''), … … 90 217 } 91 218 219 export function readServicePrice(service) { 220 if (Object.hasOwn(service, 'priceCents')) { 221 const cents = service.priceCents 222 if (typeof cents !== 'number' || !Number.isInteger(cents) || cents < 0 || cents > 2147483647) { 223 throw new Error('Service price unavailable') 224 } 225 return cents / 100 226 } 227 // Older published configs use EUR major units. Absence is never a free price. 228 if (typeof service.price !== 'number' || !Number.isFinite(service.price) || service.price < 0) { 229 throw new Error('Service price unavailable') 230 } 231 return Math.round((service.price + Number.EPSILON) * 100) / 100 232 } 233 92 234 export function readServices(value) { 93 235 if (!value) { … … 105 247 .map((service) => ({ 106 248 description: text(service.description, ''), 249 imageUrl: safeServiceImageUrl(service.imageUrl), 107 250 durationMinutes: Number(service.durationMinutes) || 30, 108 251 id: text(service.id, ''), 109 252 name: text(service.name, ''), 110 price: Number(service.price) || 0,253 price: readServicePrice(service), 111 254 depositCents: Math.max(0, Number(service.depositCents ?? Number(service.depositEuros || 0) * 100) || 0), 112 255 bufferAfterMinutes: Number(service.bufferAfterMinutes) || 0, … … 114 257 useCustomAvailability: Boolean(service.useCustomAvailability), 115 258 availability: readAvailability(service.availability), 259 bookingDates: Array.isArray(service.bookingDates) 260 ? service.bookingDates 261 .map((entry) => ({ 262 date: text(entry && entry.date, ''), 263 endTime: text(entry && entry.endTime, ''), 264 startTime: text(entry && entry.startTime, ''), 265 })) 266 .filter((entry) => /^\d{4}-\d{2}-\d{2}$/.test(entry.date)) 267 .slice(0, 60) 268 : [], 269 bookingQuestions: readBookingQuestions(service.bookingQuestions), 270 rebookingIntervalWeeks: Math.min(52, Math.max(0, Number(service.rebookingIntervalWeeks) || 0)), 271 staffIds: readStaffIds(service.staffIds), 116 272 })) 117 273 .filter((service) => service.name) … … 146 302 commerce: text(config.commerce, ''), 147 303 exceptions: readExceptions(config.exceptions), 304 // Extension A: `data-next-times` renders the compact upcoming-times strip. 305 nextTimes: optionalBoolean(config.nextTimes, false), 148 306 position: text(config.position, ''), 307 proposalSlug: text(config.proposalSlug, ''), 149 308 service: serviceFilter, 150 309 services: filterServicesById( … … 152 311 serviceFilter, 153 312 ), 313 staff: readStaffList(config.staff), 314 staffAvailability: readStaffAvailability(config.staffAvailability), 315 staffDisplayMode: 316 text(config.staffDisplayMode, '') === 'customer_choice' ? 'customer_choice' : 'hidden', 154 317 shopifyProduct: text(config.shopifyProduct, ''), 155 318 shopifyShop: text( … … 164 327 wooCartNonce: text(config.wooCartNonce || config.wooCartnonce, ''), 165 328 showBranding: optionalBoolean(config.showBranding, false), 329 hostedPage: optionalBoolean(config.hostedPage, false), 166 330 title: text(config.title, ''), 167 331 timeFormat: text(config.timeFormat, ''), … … 204 368 bufferBeforeMinutes: Number(service.bufferBeforeMinutes) || 0, 205 369 description: text(service.description, ''), 370 imageUrl: safeServiceImageUrl(service.imageUrl), 206 371 durationMinutes: Number(service.durationMinutes) || 30, 207 372 id: text(service.id, ''), 208 373 name: text(service.name, ''), 209 price: Number(service.price) || 0,374 price: readServicePrice(service), 210 375 depositCents: Math.max(0, Number(service.depositCents ?? Number(service.depositEuros || 0) * 100) || 0), 211 376 useCustomAvailability: Boolean(service.useCustomAvailability), 377 bookingDates: Array.isArray(service.bookingDates) 378 ? service.bookingDates 379 .map((entry) => ({ 380 date: text(entry && entry.date, ''), 381 endTime: text(entry && entry.endTime, ''), 382 startTime: text(entry && entry.startTime, ''), 383 })) 384 .filter((entry) => /^\d{4}-\d{2}-\d{2}$/.test(entry.date)) 385 .slice(0, 60) 386 : [], 387 bookingQuestions: readBookingQuestions(service.bookingQuestions), 388 rebookingIntervalWeeks: Math.min( 389 52, 390 Math.max(0, Number(service.rebookingIntervalWeeks) || 0), 391 ), 392 staffIds: readStaffIds(service.staffIds), 212 393 })) 213 394 .filter((service) => service.name) … … 222 403 223 404 const commerce = text(config.commerce, '') 224 // Platform checkout booking must stay inline on the product page. 225 // Never inherit published floating/bar placement for commerce mounts. 405 // Platform checkout booking must stay inline on the product page, and 406 // hosted /book/<slug> pages always mount inline — a published floating 407 // placement must never escape the branded shell. 226 408 const variant = 227 commerce === 'shopify' || commerce === 'woocommerce' 409 commerce === 'shopify' || commerce === 'woocommerce' || config.hostedPage === true 228 410 ? 'inline' 229 411 : text(remoteConfig.variant || config.variant, 'inline') 412 const language = normalizeLanguage(remoteConfig.language || config.language) 230 413 231 414 return { … … 233 416 apiUrl: runtimeApiUrl(config.apiUrl), 234 417 bookedSlots: readBookedSlots(remoteConfig.bookedSlots), 418 calendarVerification: text(remoteConfig.calendarVerification, 'none'), 419 bookingHorizonDays: Number(remoteConfig.bookingHorizonDays) || 0, 235 420 color: normalizeColor(remoteConfig.color || config.color), 421 features: 422 remoteConfig.features && typeof remoteConfig.features === 'object' 423 ? remoteConfig.features 424 : {}, 425 language, 426 publicationRevision: text(remoteConfig.publicationRevision, ''), 427 minNoticeMinutes: Number(remoteConfig.minNoticeMinutes) || 0, 428 nextTimes: optionalBoolean(config.nextTimes, false), 429 preferCompactSchedule: optionalBoolean(remoteConfig.preferCompactSchedule, false), 430 selfServiceCutoffHours: Number(remoteConfig.selfServiceCutoffHours) || 0, 236 431 commerce, 237 432 exceptions: remoteExceptions, 238 433 position: text(remoteConfig.position || config.position, 'bottom-right'), 434 proposalSlug: text(config.proposalSlug, ''), 239 435 service: text(config.service, ''), 240 436 services: checkoutServices(services, commerce), … … 247 443 // Public credits are controlled locally. Remote SaaS configuration cannot enable them. 248 444 showBranding: config.showBranding, 249 title: text(remoteConfig.title || config.title, 'Schedule a booking'), 445 sms: readChannelOptin(remoteConfig.sms), 446 staff: readStaffList(remoteConfig.staff), 447 staffAvailability: readStaffAvailability(remoteConfig.staffAvailability), 448 staffDisplayMode: 449 text(remoteConfig.staffDisplayMode, '') === 'customer_choice' 450 ? 'customer_choice' 451 : 'hidden', 452 title: text(remoteConfig.title || config.title, getWidgetCopy(remoteConfig.language || config.language).title), 250 453 timeFormat: normalizeTimeFormat(remoteConfig.timeFormat || config.timeFormat), 251 454 timezone: text(remoteConfig.timezone || config.timezone, ''), … … 259 462 ), 260 463 variant, 464 whatsapp: readChannelOptin(remoteConfig.whatsapp), 261 465 workspace: config.workspace, 262 466 } … … 271 475 272 476 export { withRenderDefaults } 477 478 // Mirrors readChannelOptin in src/lib/bookingPage/config.ts — the published 479 // config carries sms/whatsapp capability blocks that the embedded widget 480 // needs for its opt-in UI. 481 function readChannelOptin(value) { 482 const raw = value && typeof value === 'object' ? value : {} 483 const events = raw.events && typeof raw.events === 'object' ? raw.events : {} 484 return { 485 available: raw.available === true, 486 events: { 487 cancellation: events.cancellation === true, 488 confirmation: events.confirmation === true, 489 reminder24h: events.reminder24h === true, 490 reminder2h: events.reminder2h === true, 491 reschedule: events.reschedule === true, 492 }, 493 textVersion: text(raw.textVersion, '') || undefined, 494 } 495 } 496 497 export function safeServiceImageUrl(value) { 498 if (typeof value !== 'string' || value.length > 2048) return '' 499 try { 500 const url = new URL(value) 501 return url.protocol === 'https:' && !url.username && !url.password ? url.href : '' 502 } catch { return '' } 503 } -
bookminto/tags/wp-1.0.5/assets/src/copy.js
r3723811 r3730301 1 1 export const DATE_WINDOW_SIZE = 5 2 2 3 export const widgetCopy = {3 const englishCopy = { 4 4 title: 'Schedule a booking', 5 selectServiceAndTime: 'Select a service and time to continue.', 6 servicesLabel: 'Services', 7 serviceSingular: 'service', 8 servicePlural: 'services', 9 selected: 'Selected', 10 choose: 'Choose', 11 timesLabel: 'Times', 12 appointment: 'Appointment', 13 bookingReference: 'Booking reference', 14 done: 'Done', 15 noServiceSelected: 'No service selected', 16 noTimeSelected: 'No time selected', 17 chooseTime: 'Choose a time', 18 viewSetup: 'View setup', 19 paymentLoading: 'Loading secure payment…', 20 paymentError: 'Payment could not be confirmed. Try again.', 21 progressLabel: 'Booking progress', 22 stepLabel: 'Step', 23 stepOf: 'of', 5 24 widgetStepLabels: ['Service', 'Date', 'Details', 'Confirmed'], 6 25 chooseService: 'Choose an offering', … … 27 46 checkoutCta: 'Continue to checkout', 28 47 confirmCta: 'Confirm booking', 48 replacePaymentCta: 'Start a new payment', 49 replacePaymentNote: 'The previous payment was canceled. Start a new payment to continue with this same appointment.', 29 50 payDepositCta: 'Pay deposit', 30 51 depositHeading: 'Pay deposit to confirm', 52 depositLabel: 'deposit', 31 53 depositNote: 'Your time is held while you complete payment.', 54 depositUnavailable: 'A deposit is required for this service, but online payment is temporarily unavailable. Please try again later.', 32 55 processing: 'Processing...', 33 56 redirectingToCheckout: 'Redirecting to checkout…', … … 42 65 loading: 'Loading…', 43 66 genericError: 'Something went wrong. Please try again.', 67 closedAttempt: 'This booking attempt is closed. Choose a new time to start again.', 44 68 loadError: 'Booking setup could not be loaded.', 45 69 previewOnlyNote: 'Preview only — no booking was saved and no email was sent.', … … 47 71 testModeNote: 48 72 'This site is running on a local or test address, so nothing was sent to Bookminto and no confirmation email will arrive.', 49 } 73 questionsHeading: 'A few questions', 74 requiredHint: 'required', 75 yesLabel: 'Yes', 76 noLabel: 'No', 77 chooseOption: 'Choose…', 78 nextTimesLabel: 'Next available', 79 seeAllTimes: 'See all times', 80 noNextTimes: 'No upcoming times right now — check all dates.', 81 recommendedBadge: 'Recommended', 82 waitlistCta: 'Notify me when a time opens up', 83 waitlistHeading: 'Join the waitlist', 84 waitlistNote: 'We will email you as soon as a spot opens. First to claim gets it.', 85 waitlistFrom: 'From', 86 waitlistTo: 'Until', 87 waitlistSubmit: 'Keep me posted', 88 waitlistJoined: 'You are on the list. Watch your inbox — a spot can go fast.', 89 waitlistError: 'Could not add you to the list. Please try again.', 90 rebookingOptInLabel: 'Email me an invitation to book again', 91 phoneCountryLabel: 'Country', 92 phoneRequiredForSms: 'Enter a valid phone number to receive text messages.', 93 phoneRequiredForWhatsapp: 'Enter a valid phone number to receive WhatsApp messages.', 94 smsOptInLabel: 'Send me appointment confirmations and reminders by text message.', 95 whatsappOptInLabel: 'Send me appointment confirmations and reminders on WhatsApp.', 96 depositLoadError: 'A deposit is required, but secure payment could not load. Please try again or contact the business.', 97 paymentLoadFailed: 'Secure payment could not load.', 98 retryCta: 'Try again', 99 staffPickerLabel: 'Choose a team member', 100 anyStaff: 'Any team member', 101 withStaff: 'with {name}', 102 staffUnavailable: 'That team member is no longer available at this time. Pick another slot or choose a different member.', 103 serviceNotBookable: 'This service cannot be booked online right now.', 104 } 105 106 const dutchCopy = { 107 ...englishCopy, 108 title: 'Plan een afspraak', 109 selectServiceAndTime: 'Kies een dienst en tijdstip om verder te gaan.', 110 servicesLabel: 'Diensten', 111 serviceSingular: 'dienst', 112 servicePlural: 'diensten', 113 selected: 'Gekozen', 114 choose: 'Kies', 115 timesLabel: 'Tijdstippen', 116 appointment: 'Afspraak', 117 bookingReference: 'Boekingsreferentie', 118 done: 'Klaar', 119 noServiceSelected: 'Geen dienst gekozen', 120 noTimeSelected: 'Geen tijdstip gekozen', 121 chooseTime: 'Kies een tijdstip', 122 viewSetup: 'Bekijk de boekingsopties', 123 paymentLoading: 'Veilig betalen laden…', 124 paymentError: 'De betaling kon niet worden bevestigd. Probeer opnieuw.', 125 progressLabel: 'Voortgang van je boeking', 126 stepLabel: 'Stap', 127 stepOf: 'van', 128 widgetStepLabels: ['Dienst', 'Datum', 'Gegevens', 'Bevestigd'], 129 chooseService: 'Kies een dienst', 130 chooseDate: 'Kies een datum', 131 availableTimes: 'Beschikbare tijden', 132 summaryLabel: 'Jouw keuze', 133 totalLabel: 'Prijs', 134 detailsHeading: 'Jouw gegevens', 135 previousDates: 'Eerdere datums', 136 nextDates: 'Latere datums', 137 successHeading: 'Afspraak bevestigd!', 138 successNote: 'Je ontvangt zo een bevestigingsmail met de details van je afspraak.', 139 emptySlots: 'Geen tijden beschikbaar op deze datum.', 140 emptyServices: 'Er zijn momenteel geen diensten beschikbaar.', 141 bookNow: 'Boek nu', 142 back: 'Terug', 143 at: 'om', 144 nameLabel: 'Naam', 145 phoneLabel: 'Telefoon', 146 notesLabel: 'Opmerkingen', 147 emailLabel: 'E-mail', 148 continueFromService: 'Kies en ga verder', 149 continueDefault: 'Verder', 150 checkoutCta: 'Verder naar afrekenen', 151 confirmCta: 'Bevestig afspraak', 152 replacePaymentCta: 'Start een nieuwe betaling', 153 replacePaymentNote: 'De vorige betaling is geannuleerd. Start een nieuwe betaling om dezelfde afspraak af te ronden.', 154 payDepositCta: 'Betaal voorschot', 155 depositHeading: 'Betaal het voorschot om te bevestigen', 156 depositLabel: 'voorschot', 157 depositNote: 'Je tijdstip wordt vastgehouden terwijl je betaalt.', 158 depositUnavailable: 'Voor deze dienst is een voorschot vereist, maar online betalen is tijdelijk niet beschikbaar. Probeer het later opnieuw.', 159 processing: 'Bezig…', 160 redirectingToCheckout: 'Doorsturen naar afrekenen…', 161 close: 'Sluiten', 162 addToCalendar: 'Zet in agenda', 163 widgetFooterPrefix: 'Mogelijk gemaakt door', 164 namePlaceholder: 'Jan Jansen', 165 emailPlaceholder: 'jan@voorbeeld.be', 166 notesPlaceholder: 'Specifieke vragen of context…', 167 loading: 'Laden…', 168 genericError: 'Er ging iets mis. Probeer het opnieuw.', 169 closedAttempt: 'Deze boekingspoging is afgesloten. Kies een nieuw tijdstip om opnieuw te beginnen.', 170 loadError: 'De boekingssetup kon niet geladen worden.', 171 previewOnlyNote: 'Alleen voorbeeld — er is geen boeking opgeslagen en geen e-mail verstuurd.', 172 testModeHeading: 'Testmodus — boeking niet opgeslagen', 173 testModeNote: 174 'Deze site draait op een lokale of testomgeving, dus er is niets naar Bookminto verstuurd en er komt geen bevestigingsmail.', 175 questionsHeading: 'Nog een paar vragen', 176 requiredHint: 'verplicht', 177 yesLabel: 'Ja', 178 noLabel: 'Nee', 179 chooseOption: 'Kies…', 180 nextTimesLabel: 'Eerstvolgende', 181 seeAllTimes: 'Alle tijden', 182 noNextTimes: 'Momenteel geen vrije tijden — bekijk alle datums.', 183 recommendedBadge: 'Aanbevolen', 184 waitlistCta: 'Laat het weten als er een plekje vrijkomt', 185 waitlistHeading: 'Zet me op de wachtlijst', 186 waitlistNote: 'We mailen je zodra er een plekje vrijkomt. Wie het eerst bevestigt, krijgt het.', 187 waitlistFrom: 'Vanaf', 188 waitlistTo: 'Tot', 189 waitlistSubmit: 'Houd me op de hoogte', 190 waitlistJoined: 'Je staat op de lijst. Houd je inbox in de gaten — een plekje kan snel weg zijn.', 191 waitlistError: 'Dat lukte niet. Probeer het opnieuw.', 192 rebookingOptInLabel: 'Mail me een uitnodiging om opnieuw te boeken', 193 phoneCountryLabel: 'Land', 194 phoneRequiredForSms: 'Vul een geldig telefoonnummer in om sms’en te ontvangen.', 195 phoneRequiredForWhatsapp: 'Vul een geldig telefoonnummer in om WhatsApp-berichten te ontvangen.', 196 smsOptInLabel: 'Stuur mij bevestigingen en herinneringen van mijn afspraak per sms.', 197 whatsappOptInLabel: 'Stuur mij bevestigingen en herinneringen van mijn afspraak via WhatsApp.', 198 depositLoadError: 'Er is een voorschot vereist, maar de beveiligde betaling kon niet laden. Probeer het opnieuw of neem contact op met het bedrijf.', 199 paymentLoadFailed: 'De beveiligde betaling kon niet laden.', 200 retryCta: 'Opnieuw proberen', 201 staffPickerLabel: 'Kies een teamlid', 202 anyStaff: 'Geen voorkeur', 203 withStaff: 'met {name}', 204 staffUnavailable: 'Dit teamlid is niet meer beschikbaar op dit tijdstip. Kies een ander tijdstip of een ander teamlid.', 205 serviceNotBookable: 'Deze dienst kan momenteel niet online geboekt worden.', 206 } 207 208 const frenchCopy = { 209 ...englishCopy, 210 title: 'Prendre rendez-vous', 211 selectServiceAndTime: 'Choisissez une prestation et un horaire pour continuer.', 212 servicesLabel: 'Prestations', 213 serviceSingular: 'prestation', 214 servicePlural: 'prestations', 215 selected: 'Sélectionné', 216 choose: 'Choisir', 217 timesLabel: 'Horaires', 218 appointment: 'Rendez-vous', 219 bookingReference: 'Référence de réservation', 220 done: 'Terminé', 221 noServiceSelected: 'Aucune prestation sélectionnée', 222 noTimeSelected: 'Aucun horaire sélectionné', 223 chooseTime: 'Choisir un horaire', 224 viewSetup: 'Voir les options de réservation', 225 paymentLoading: 'Chargement du paiement sécurisé…', 226 paymentError: 'Le paiement n’a pas pu être confirmé. Réessayez.', 227 progressLabel: 'Progression de la réservation', 228 stepLabel: 'Étape', 229 stepOf: 'sur', 230 widgetStepLabels: ['Prestation', 'Date', 'Coordonnées', 'Confirmé'], 231 chooseService: 'Choisissez une prestation', 232 chooseDate: 'Choisissez une date', 233 availableTimes: 'Horaires disponibles', 234 summaryLabel: 'Votre sélection', 235 totalLabel: 'Prix', 236 detailsHeading: 'Vos coordonnées', 237 previousDates: 'Dates précédentes', 238 nextDates: 'Dates suivantes', 239 successHeading: 'Rendez-vous confirmé !', 240 successNote: 'Vous recevrez sous peu un e-mail de confirmation avec les détails.', 241 emptySlots: 'Aucun horaire disponible à cette date.', 242 emptyServices: 'Aucune prestation disponible pour le moment.', 243 bookNow: 'Réserver', 244 back: 'Retour', 245 at: 'à', 246 nameLabel: 'Nom', 247 phoneLabel: 'Téléphone', 248 notesLabel: 'Remarques', 249 emailLabel: 'E-mail', 250 continueFromService: 'Choisir et continuer', 251 continueDefault: 'Continuer', 252 checkoutCta: 'Passer au paiement', 253 confirmCta: 'Confirmer le rendez-vous', 254 replacePaymentCta: 'Commencer un nouveau paiement', 255 replacePaymentNote: 'Le paiement précédent a été annulé. Commencez un nouveau paiement pour terminer ce même rendez-vous.', 256 payDepositCta: "Payer l'acompte", 257 depositHeading: "Payez l'acompte pour confirmer", 258 depositLabel: 'acompte', 259 depositNote: 'Votre créneau est réservé pendant le paiement.', 260 depositUnavailable: "Un acompte est requis pour ce service, mais le paiement en ligne est temporairement indisponible. Veuillez réessayer plus tard.", 261 processing: 'Traitement…', 262 redirectingToCheckout: 'Redirection vers le paiement…', 263 close: 'Fermer', 264 addToCalendar: 'Ajouter au calendrier', 265 widgetFooterPrefix: 'Propulsé par', 266 namePlaceholder: 'Marie Dupont', 267 emailPlaceholder: 'marie@exemple.fr', 268 notesPlaceholder: 'Questions ou contexte particulier…', 269 loading: 'Chargement…', 270 genericError: 'Une erreur est survenue. Veuillez réessayer.', 271 closedAttempt: 'Cette tentative de réservation est terminée. Choisissez un nouvel horaire pour recommencer.', 272 loadError: "La configuration de réservation n'a pas pu être chargée.", 273 previewOnlyNote: 'Aperçu uniquement — aucune réservation enregistrée, aucun e-mail envoyé.', 274 testModeHeading: 'Mode test — réservation non enregistrée', 275 testModeNote: 276 "Ce site fonctionne sur une adresse locale ou de test : rien n'a été envoyé à Bookminto et aucun e-mail de confirmation n'arrivera.", 277 questionsHeading: 'Quelques questions', 278 requiredHint: 'obligatoire', 279 yesLabel: 'Oui', 280 noLabel: 'Non', 281 chooseOption: 'Choisir…', 282 nextTimesLabel: 'Prochaines disponibilités', 283 seeAllTimes: 'Tous les horaires', 284 noNextTimes: 'Aucun horaire pour le moment — voir toutes les dates.', 285 recommendedBadge: 'Recommandé', 286 waitlistCta: 'Prévenez-moi quand un créneau se libère', 287 waitlistHeading: 'Rejoindre la liste d’attente', 288 waitlistNote: 'Nous vous écrivons dès qu’un créneau se libère. Le premier à confirmer l’obtient.', 289 waitlistFrom: 'À partir du', 290 waitlistTo: 'Jusqu’au', 291 waitlistSubmit: 'Me tenir informé', 292 waitlistJoined: 'Vous êtes sur la liste. Surveillez votre boîte — un créneau part vite.', 293 waitlistError: 'Impossible de vous ajouter. Réessayez.', 294 rebookingOptInLabel: 'M’envoyer une invitation pour réserver à nouveau', 295 phoneCountryLabel: 'Pays', 296 phoneRequiredForSms: 'Indiquez un numéro de téléphone valide pour recevoir des SMS.', 297 phoneRequiredForWhatsapp: 'Indiquez un numéro de téléphone valide pour recevoir des messages WhatsApp.', 298 smsOptInLabel: 'Recevoir les confirmations et rappels de mon rendez-vous par SMS.', 299 whatsappOptInLabel: 'Recevoir les confirmations et rappels de mon rendez-vous par WhatsApp.', 300 depositLoadError: 'Un acompte est requis, mais le paiement sécurisé n’a pas pu se charger. Réessayez ou contactez l’entreprise.', 301 paymentLoadFailed: 'Le paiement sécurisé n’a pas pu se charger.', 302 retryCta: 'Réessayer', 303 staffPickerLabel: 'Choisissez un membre de l’équipe', 304 anyStaff: 'Sans préférence', 305 withStaff: 'avec {name}', 306 staffUnavailable: 'Ce membre de l’équipe n’est plus disponible à cet horaire. Choisissez un autre horaire ou un autre membre.', 307 serviceNotBookable: 'Cette prestation n’est pas réservable en ligne pour le moment.', 308 } 309 310 const germanCopy = { 311 ...englishCopy, 312 title: 'Termin buchen', 313 selectServiceAndTime: 'Wählen Sie eine Leistung und eine Uhrzeit, um fortzufahren.', 314 servicesLabel: 'Leistungen', 315 serviceSingular: 'Leistung', 316 servicePlural: 'Leistungen', 317 selected: 'Ausgewählt', 318 choose: 'Wählen', 319 timesLabel: 'Uhrzeiten', 320 appointment: 'Termin', 321 bookingReference: 'Buchungsnummer', 322 done: 'Fertig', 323 noServiceSelected: 'Keine Leistung ausgewählt', 324 noTimeSelected: 'Keine Uhrzeit ausgewählt', 325 chooseTime: 'Uhrzeit wählen', 326 viewSetup: 'Buchungsoptionen ansehen', 327 paymentLoading: 'Sichere Zahlung wird geladen…', 328 paymentError: 'Die Zahlung konnte nicht bestätigt werden. Bitte erneut versuchen.', 329 progressLabel: 'Buchungsfortschritt', 330 stepLabel: 'Schritt', 331 stepOf: 'von', 332 widgetStepLabels: ['Leistung', 'Datum', 'Angaben', 'Bestätigt'], 333 chooseService: 'Leistung wählen', 334 chooseDate: 'Datum wählen', 335 availableTimes: 'Verfügbare Zeiten', 336 summaryLabel: 'Ihre Auswahl', 337 totalLabel: 'Preis', 338 detailsHeading: 'Ihre Angaben', 339 previousDates: 'Vorherige Daten', 340 nextDates: 'Nächste Daten', 341 successHeading: 'Buchung bestätigt!', 342 successNote: 'Sie erhalten in Kürze eine Bestätigungs-E-Mail mit Ihren Buchungsdetails.', 343 emptySlots: 'An diesem Datum sind keine Zeiten verfügbar.', 344 emptyServices: 'Derzeit sind keine Leistungen verfügbar.', 345 bookNow: 'Jetzt buchen', 346 back: 'Zurück', 347 at: 'um', 348 nameLabel: 'Name', 349 phoneLabel: 'Telefon', 350 notesLabel: 'Anmerkungen', 351 emailLabel: 'E-Mail', 352 continueFromService: 'Auswählen und weiter', 353 continueDefault: 'Weiter', 354 checkoutCta: 'Weiter zur Kasse', 355 confirmCta: 'Buchung bestätigen', 356 replacePaymentCta: 'Neue Zahlung starten', 357 replacePaymentNote: 'Die vorherige Zahlung wurde abgebrochen. Starten Sie eine neue Zahlung, um denselben Termin abzuschließen.', 358 payDepositCta: 'Anzahlung zahlen', 359 depositHeading: 'Anzahlung zur Bestätigung zahlen', 360 depositLabel: 'Anzahlung', 361 depositNote: 'Ihr Termin bleibt während der Zahlung reserviert.', 362 depositUnavailable: 'Für diese Leistung ist eine Anzahlung erforderlich, Online-Zahlung ist derzeit nicht verfügbar. Bitte versuchen Sie es später erneut.', 363 processing: 'Wird verarbeitet…', 364 redirectingToCheckout: 'Weiterleitung zur Kasse…', 365 close: 'Schließen', 366 addToCalendar: 'Zum Kalender hinzufügen', 367 widgetFooterPrefix: 'Bereitgestellt von', 368 namePlaceholder: 'Maria Müller', 369 phonePlaceholder: '+49 170 1234567', 370 emailPlaceholder: 'sie@beispiel.de', 371 notesPlaceholder: 'Spezifische Fragen oder Kontext…', 372 loading: 'Wird geladen…', 373 genericError: 'Etwas ist schiefgelaufen. Bitte versuchen Sie es erneut.', 374 closedAttempt: 'Dieser Buchungsversuch ist abgeschlossen. Wählen Sie eine neue Uhrzeit, um erneut zu beginnen.', 375 loadError: 'Die Buchungseinstellungen konnten nicht geladen werden.', 376 previewOnlyNote: 'Nur Vorschau — es wurde keine Buchung gespeichert und keine E-Mail gesendet.', 377 testModeHeading: 'Testmodus — Buchung nicht gespeichert', 378 testModeNote: 379 'Diese Seite läuft auf einer lokalen oder Testadresse, daher wurde nichts an Bookminto gesendet und es kommt keine Bestätigungs-E-Mail.', 380 questionsHeading: 'Ein paar Fragen', 381 requiredHint: 'Pflichtfeld', 382 yesLabel: 'Ja', 383 noLabel: 'Nein', 384 chooseOption: 'Wählen…', 385 nextTimesLabel: 'Nächste freie Zeiten', 386 seeAllTimes: 'Alle Zeiten', 387 noNextTimes: 'Derzeit keine freien Zeiten — alle Daten ansehen.', 388 recommendedBadge: 'Empfohlen', 389 waitlistCta: 'Benachrichtigen Sie mich, wenn ein Termin frei wird', 390 waitlistHeading: 'Auf die Warteliste', 391 waitlistNote: 'Wir schreiben Ihnen, sobald ein Platz frei wird. Wer zuerst bestätigt, bekommt ihn.', 392 waitlistFrom: 'Von', 393 waitlistTo: 'Bis', 394 waitlistSubmit: 'Auf dem Laufenden halten', 395 waitlistJoined: 'Sie stehen auf der Liste. Behalten Sie Ihr Postfach im Blick — ein Platz ist schnell vergeben.', 396 waitlistError: 'Das hat nicht geklappt. Bitte versuchen Sie es erneut.', 397 rebookingOptInLabel: 'Mir eine Einladung zur erneuten Buchung mailen', 398 phoneCountryLabel: 'Land', 399 phoneRequiredForSms: 'Geben Sie eine gültige Telefonnummer ein, um SMS zu erhalten.', 400 phoneRequiredForWhatsapp: 'Geben Sie eine gültige Telefonnummer ein, um WhatsApp-Nachrichten zu erhalten.', 401 smsOptInLabel: 'Senden Sie mir Terminbestätigungen und -erinnerungen per SMS.', 402 whatsappOptInLabel: 'Senden Sie mir Terminbestätigungen und -erinnerungen per WhatsApp.', 403 depositLoadError: 'Eine Anzahlung ist erforderlich, aber die sichere Zahlung konnte nicht geladen werden. Bitte versuchen Sie es erneut oder kontaktieren Sie den Betrieb.', 404 paymentLoadFailed: 'Die sichere Zahlung konnte nicht geladen werden.', 405 retryCta: 'Erneut versuchen', 406 staffPickerLabel: 'Teammitglied wählen', 407 anyStaff: 'Keine Präferenz', 408 withStaff: 'mit {name}', 409 staffUnavailable: 'Dieses Teammitglied ist zu dieser Zeit nicht mehr verfügbar. Wählen Sie eine andere Uhrzeit oder ein anderes Mitglied.', 410 serviceNotBookable: 'Diese Leistung ist derzeit nicht online buchbar.', 411 } 412 413 const spanishCopy = { 414 ...englishCopy, 415 title: 'Reserva una cita', 416 selectServiceAndTime: 'Elige un servicio y una hora para continuar.', 417 servicesLabel: 'Servicios', 418 serviceSingular: 'servicio', 419 servicePlural: 'servicios', 420 selected: 'Seleccionado', 421 choose: 'Elegir', 422 timesLabel: 'Horarios', 423 appointment: 'Cita', 424 bookingReference: 'Referencia de reserva', 425 done: 'Listo', 426 noServiceSelected: 'Ningún servicio seleccionado', 427 noTimeSelected: 'Ninguna hora seleccionada', 428 chooseTime: 'Elige una hora', 429 viewSetup: 'Ver opciones de reserva', 430 paymentLoading: 'Cargando pago seguro…', 431 paymentError: 'No se pudo confirmar el pago. Inténtalo de nuevo.', 432 progressLabel: 'Progreso de la reserva', 433 stepLabel: 'Paso', 434 stepOf: 'de', 435 widgetStepLabels: ['Servicio', 'Fecha', 'Datos', 'Confirmado'], 436 chooseService: 'Elige un servicio', 437 chooseDate: 'Elige una fecha', 438 availableTimes: 'Horarios disponibles', 439 summaryLabel: 'Tu selección', 440 totalLabel: 'Precio', 441 detailsHeading: 'Tus datos', 442 previousDates: 'Fechas anteriores', 443 nextDates: 'Fechas siguientes', 444 successHeading: '¡Reserva confirmada!', 445 successNote: 'Recibirás en breve un correo de confirmación con los detalles de tu reserva.', 446 emptySlots: 'No hay horarios disponibles en esta fecha.', 447 emptyServices: 'No hay servicios disponibles ahora mismo.', 448 bookNow: 'Reservar ahora', 449 back: 'Volver', 450 at: 'a las', 451 nameLabel: 'Nombre', 452 phoneLabel: 'Teléfono', 453 notesLabel: 'Notas', 454 emailLabel: 'Correo electrónico', 455 continueFromService: 'Elegir y continuar', 456 continueDefault: 'Continuar', 457 checkoutCta: 'Ir al pago', 458 confirmCta: 'Confirmar reserva', 459 replacePaymentCta: 'Iniciar un nuevo pago', 460 replacePaymentNote: 'El pago anterior se canceló. Inicia un nuevo pago para terminar esta misma cita.', 461 payDepositCta: 'Pagar depósito', 462 depositHeading: 'Paga el depósito para confirmar', 463 depositLabel: 'depósito', 464 depositNote: 'Tu horario queda reservado mientras completas el pago.', 465 depositUnavailable: 'Este servicio requiere un depósito, pero el pago online no está disponible ahora. Inténtalo más tarde.', 466 processing: 'Procesando…', 467 redirectingToCheckout: 'Redirigiendo al pago…', 468 close: 'Cerrar', 469 addToCalendar: 'Añadir al calendario', 470 widgetFooterPrefix: 'Con tecnología de', 471 namePlaceholder: 'María García', 472 phonePlaceholder: '+34 600 123 456', 473 emailPlaceholder: 'tu@ejemplo.es', 474 notesPlaceholder: 'Preguntas o contexto específico…', 475 loading: 'Cargando…', 476 genericError: 'Algo salió mal. Inténtalo de nuevo.', 477 closedAttempt: 'Este intento de reserva está cerrado. Elige una nueva hora para empezar de nuevo.', 478 loadError: 'No se pudo cargar la configuración de reserva.', 479 previewOnlyNote: 'Solo vista previa — no se guardó ninguna reserva ni se envió correo.', 480 testModeHeading: 'Modo prueba — reserva no guardada', 481 testModeNote: 482 'Este sitio funciona en una dirección local o de prueba, así que no se envió nada a Bookminto y no llegará correo de confirmación.', 483 questionsHeading: 'Unas preguntas', 484 requiredHint: 'obligatorio', 485 yesLabel: 'Sí', 486 noLabel: 'No', 487 chooseOption: 'Elegir…', 488 nextTimesLabel: 'Próximos horarios', 489 seeAllTimes: 'Todos los horarios', 490 noNextTimes: 'No hay horarios próximos ahora — revisa todas las fechas.', 491 recommendedBadge: 'Recomendado', 492 waitlistCta: 'Avísame cuando se libere un hueco', 493 waitlistHeading: 'Únete a la lista de espera', 494 waitlistNote: 'Te escribiremos en cuanto se libere un hueco. El primero en confirmar se lo queda.', 495 waitlistFrom: 'Desde', 496 waitlistTo: 'Hasta', 497 waitlistSubmit: 'Mantenme al tanto', 498 waitlistJoined: 'Ya estás en la lista. Revisa tu correo — un hueco vuela.', 499 waitlistError: 'No se pudo añadirte a la lista. Inténtalo de nuevo.', 500 rebookingOptInLabel: 'Enviarme una invitación por correo para volver a reservar', 501 phoneCountryLabel: 'País', 502 phoneRequiredForSms: 'Introduce un número de teléfono válido para recibir SMS.', 503 phoneRequiredForWhatsapp: 'Introduce un número de teléfono válido para recibir mensajes de WhatsApp.', 504 smsOptInLabel: 'Enviarme confirmaciones y recordatorios de mi cita por SMS.', 505 whatsappOptInLabel: 'Enviarme confirmaciones y recordatorios de mi cita por WhatsApp.', 506 depositLoadError: 'Se requiere un depósito, pero el pago seguro no se pudo cargar. Inténtalo de nuevo o contacta con el negocio.', 507 paymentLoadFailed: 'No se pudo cargar el pago seguro.', 508 retryCta: 'Reintentar', 509 staffPickerLabel: 'Elige un miembro del equipo', 510 anyStaff: 'Sin preferencia', 511 withStaff: 'con {name}', 512 staffUnavailable: 'Ese miembro del equipo ya no está disponible a esa hora. Elige otro horario u otro miembro.', 513 serviceNotBookable: 'Este servicio no se puede reservar online ahora mismo.', 514 } 515 516 const italianCopy = { 517 ...englishCopy, 518 title: 'Prenota un appuntamento', 519 selectServiceAndTime: 'Scegli un servizio e un orario per continuare.', 520 servicesLabel: 'Servizi', 521 serviceSingular: 'servizio', 522 servicePlural: 'servizi', 523 selected: 'Selezionato', 524 choose: 'Scegli', 525 timesLabel: 'Orari', 526 appointment: 'Appuntamento', 527 bookingReference: 'Riferimento prenotazione', 528 done: 'Fatto', 529 noServiceSelected: 'Nessun servizio selezionato', 530 noTimeSelected: 'Nessun orario selezionato', 531 chooseTime: 'Scegli un orario', 532 viewSetup: 'Vedi le opzioni di prenotazione', 533 paymentLoading: 'Caricamento pagamento sicuro…', 534 paymentError: 'Il pagamento non è stato confermato. Riprova.', 535 progressLabel: 'Avanzamento della prenotazione', 536 stepLabel: 'Passaggio', 537 stepOf: 'di', 538 widgetStepLabels: ['Servizio', 'Data', 'Dati', 'Confermato'], 539 chooseService: 'Scegli un servizio', 540 chooseDate: 'Scegli una data', 541 availableTimes: 'Orari disponibili', 542 summaryLabel: 'La tua selezione', 543 totalLabel: 'Prezzo', 544 detailsHeading: 'I tuoi dati', 545 previousDates: 'Date precedenti', 546 nextDates: 'Date successive', 547 successHeading: 'Prenotazione confermata!', 548 successNote: 'Riceverai a breve un’e-mail di conferma con i dettagli della prenotazione.', 549 emptySlots: 'Nessun orario disponibile in questa data.', 550 emptyServices: 'Nessun servizio disponibile al momento.', 551 bookNow: 'Prenota ora', 552 back: 'Indietro', 553 at: 'alle', 554 nameLabel: 'Nome', 555 phoneLabel: 'Telefono', 556 notesLabel: 'Note', 557 emailLabel: 'E-mail', 558 continueFromService: 'Scegli e continua', 559 continueDefault: 'Continua', 560 checkoutCta: 'Vai al pagamento', 561 confirmCta: 'Conferma prenotazione', 562 replacePaymentCta: 'Avvia un nuovo pagamento', 563 replacePaymentNote: 'Il pagamento precedente è stato annullato. Avvia un nuovo pagamento per completare lo stesso appuntamento.', 564 payDepositCta: 'Paga l’acconto', 565 depositHeading: 'Paga l’acconto per confermare', 566 depositLabel: 'acconto', 567 depositNote: 'Il tuo orario resta riservato mentre completi il pagamento.', 568 depositUnavailable: 'Questo servizio richiede un acconto, ma il pagamento online non è disponibile al momento. Riprova più tardi.', 569 processing: 'Elaborazione…', 570 redirectingToCheckout: 'Reindirizzamento al pagamento…', 571 close: 'Chiudi', 572 addToCalendar: 'Aggiungi al calendario', 573 widgetFooterPrefix: 'Offerto da', 574 namePlaceholder: 'Maria Rossi', 575 phonePlaceholder: '+39 333 123 4567', 576 emailPlaceholder: 'tu@esempio.it', 577 notesPlaceholder: 'Domande o contesto specifico…', 578 loading: 'Caricamento…', 579 genericError: 'Qualcosa è andato storto. Riprova.', 580 closedAttempt: 'Questo tentativo di prenotazione è chiuso. Scegli un nuovo orario per ricominciare.', 581 loadError: 'Impossibile caricare la configurazione di prenotazione.', 582 previewOnlyNote: 'Solo anteprima — nessuna prenotazione salvata e nessuna e-mail inviata.', 583 testModeHeading: 'Modalità test — prenotazione non salvata', 584 testModeNote: 585 'Questo sito gira su un indirizzo locale o di test, quindi non è stato inviato nulla a Bookminto e non arriverà alcuna e-mail di conferma.', 586 questionsHeading: 'Qualche domanda', 587 requiredHint: 'obbligatorio', 588 yesLabel: 'Sì', 589 noLabel: 'No', 590 chooseOption: 'Scegli…', 591 nextTimesLabel: 'Prossimi disponibili', 592 seeAllTimes: 'Tutti gli orari', 593 noNextTimes: 'Nessun orario in arrivo al momento — vedi tutte le date.', 594 recommendedBadge: 'Consigliato', 595 waitlistCta: 'Avvisami quando si libera un posto', 596 waitlistHeading: 'Entra in lista d’attesa', 597 waitlistNote: 'Ti scriveremo appena si libera un posto. Chi conferma per primo lo ottiene.', 598 waitlistFrom: 'Dal', 599 waitlistTo: 'Al', 600 waitlistSubmit: 'Tienimi aggiornato', 601 waitlistJoined: 'Sei in lista. Tieni d’occhio la casella — un posto fila via in fretta.', 602 waitlistError: 'Impossibile aggiungerti alla lista. Riprova.', 603 rebookingOptInLabel: 'Inviami un’e-mail con un invito a prenotare di nuovo', 604 phoneCountryLabel: 'Paese', 605 phoneRequiredForSms: 'Inserisci un numero di telefono valido per ricevere gli SMS.', 606 phoneRequiredForWhatsapp: 'Inserisci un numero di telefono valido per ricevere i messaggi WhatsApp.', 607 smsOptInLabel: 'Inviami conferme e promemoria del mio appuntamento via SMS.', 608 whatsappOptInLabel: 'Inviami conferme e promemoria del mio appuntamento su WhatsApp.', 609 depositLoadError: 'È richiesto un acconto, ma il pagamento sicuro non è stato caricato. Riprova o contatta l’attività.', 610 paymentLoadFailed: 'Impossibile caricare il pagamento sicuro.', 611 retryCta: 'Riprova', 612 staffPickerLabel: 'Scegli un membro del team', 613 anyStaff: 'Nessuna preferenza', 614 withStaff: 'con {name}', 615 staffUnavailable: 'Questo membro del team non è più disponibile a quell’orario. Scegli un altro orario o un altro membro.', 616 serviceNotBookable: 'Questo servizio non è prenotabile online al momento.', 617 } 618 619 const dictionaries = { 620 de: germanCopy, 621 en: englishCopy, 622 es: spanishCopy, 623 fr: frenchCopy, 624 it: italianCopy, 625 nl: dutchCopy, 626 } 627 628 export function normalizeWidgetLanguage(value) { 629 const language = String(value || '').trim().toLowerCase() 630 return Object.hasOwn(dictionaries, language) ? language : 'en' 631 } 632 633 // Copy is per widget instance: callers resolve strings from their own 634 // config.language — a global mutable dictionary would leak between two mounts. 635 export function getWidgetCopy(value) { 636 return dictionaries[normalizeWidgetLanguage(value)] 637 } 638 639 const widgetLocales = { 640 de: 'de-DE', 641 en: 'en-US', 642 es: 'es-ES', 643 fr: 'fr-BE', 644 it: 'it-IT', 645 nl: 'nl-BE', 646 } 647 648 export function widgetLocale(value) { 649 return widgetLocales[normalizeWidgetLanguage(value)] || 'en-US' 650 } -
bookminto/tags/wp-1.0.5/assets/src/floatingStyles.js
r3723811 r3730301 299 299 } 300 300 301 .service-photo { 302 display: block; 303 width: 44px; 304 height: 44px; 305 flex: 0 0 44px; 306 object-fit: cover; 307 border-radius: 6px; 308 } 309 301 310 .service-info { 311 min-width: 0; 302 312 flex: 1; 303 313 text-align: left; … … 454 464 } 455 465 466 .staff-row { 467 display: flex; 468 flex-wrap: wrap; 469 gap: 6px; 470 margin-bottom: 14px; 471 } 472 473 .staff-chip { 474 display: inline-flex; 475 align-items: center; 476 gap: 7px; 477 padding: 5px 12px; 478 border: 1.5px solid #e2e8f0; 479 border-radius: 999px; 480 background: white; 481 cursor: pointer; 482 font-family: inherit; 483 font-size: 12px; 484 font-weight: 500; 485 color: #475569; 486 transition: border-color 0.15s, background 0.15s, color 0.15s; 487 } 488 489 .staff-chip:hover { 490 border-color: var(--bm-accent-border); 491 background: var(--bm-accent-soft-hover); 492 } 493 494 .staff-chip.selected { 495 border-color: var(--bm-accent); 496 background: var(--bm-accent-soft); 497 color: var(--bm-accent-deep); 498 } 499 500 .staff-chip-dot { 501 position: relative; 502 display: inline-flex; 503 align-items: center; 504 justify-content: center; 505 width: 22px; 506 height: 22px; 507 flex: 0 0 22px; 508 border-radius: 999px; 509 background: #cbd5e1; 510 color: #fff; 511 font-size: 9px; 512 font-weight: 700; 513 letter-spacing: 0.02em; 514 overflow: hidden; 515 } 516 517 .staff-chip-avatar { 518 position: absolute; 519 inset: 0; 520 display: block; 521 width: 100%; 522 height: 100%; 523 object-fit: cover; 524 } 525 526 .staff-chip-name { 527 max-width: 140px; 528 white-space: nowrap; 529 overflow: hidden; 530 text-overflow: ellipsis; 531 } 532 456 533 .form-field { 457 534 margin-bottom: 0; … … 489 566 min-height: 74px; 490 567 resize: vertical; 568 } 569 570 .form-required { 571 color: var(--bm-accent-deep); 572 font-weight: 700; 573 } 574 575 .sr-only { 576 position: absolute; 577 width: 1px; 578 height: 1px; 579 padding: 0; 580 margin: -1px; 581 overflow: hidden; 582 clip: rect(0 0 0 0); 583 white-space: nowrap; 584 border: 0; 491 585 } 492 586 -
bookminto/tags/wp-1.0.5/assets/src/formatters.js
r3723811 r3730301 14 14 } 15 15 16 export function formatDuration(minutes) { 16 const durationHourUnits = { 17 de: 'Std.', 18 en: 'hr', 19 es: 'h', 20 fr: 'h', 21 it: 'h', 22 nl: 'u', 23 } 24 25 const freePriceLabels = { 26 de: 'Kostenlos', 27 en: 'Free', 28 es: 'Gratis', 29 fr: 'Gratuit', 30 it: 'Gratuito', 31 nl: 'Gratis', 32 } 33 34 const priceLocales = { 35 de: 'de-DE', 36 en: 'en-US', 37 es: 'es-ES', 38 fr: 'fr-BE', 39 it: 'it-IT', 40 nl: 'nl-BE', 41 } 42 43 const depositConfirmLabels = { 44 de: 'Anzahlung zur Bestätigung', 45 en: 'deposit to confirm', 46 es: 'depósito para confirmar', 47 fr: 'acompte pour confirmer', 48 it: 'acconto per confermare', 49 nl: 'voorschot om te bevestigen', 50 } 51 52 const timezoneLabels = { 53 de: ['Zeiten in', 'Zeiten in Ihrer lokalen Zeitzone'], 54 en: ['Times in', 'Times in your local timezone'], 55 es: ['Horarios en', 'Horarios en tu zona horaria'], 56 fr: ['Horaires en', 'Horaires dans votre fuseau horaire'], 57 it: ['Orari in', 'Orari nel tuo fuso orario'], 58 nl: ['Tijden in', 'Tijden in je lokale tijdzone'], 59 } 60 61 export function formatDuration(minutes, language = 'en') { 17 62 const safeMinutes = Number.isFinite(Number(minutes)) ? Number(minutes) : 30 18 63 … … 24 69 const remainder = safeMinutes % 60 25 70 26 return remainder === 0 ? `${hours} hr` : `${hours}h ${remainder}m` 71 const unit = durationHourUnits[language] || 'hr' 72 return remainder === 0 ? `${hours} ${unit}` : `${hours}${language === 'nl' ? 'u' : 'h'} ${remainder}m` 27 73 } 28 74 29 export function formatPrice(price ) {75 export function formatPrice(price, currency = 'EUR', language = 'en') { 30 76 const safePrice = Number(price) 31 77 32 78 if (!Number.isFinite(safePrice) || safePrice === 0) { 33 return 'Free'79 return freePriceLabels[language] || 'Free' 34 80 } 35 81 36 return new Intl.NumberFormat( 'en-US', {37 currency : 'EUR',82 return new Intl.NumberFormat(priceLocales[language] || 'en-US', { 83 currency, 38 84 maximumFractionDigits: 2, 39 85 minimumFractionDigits: Number.isInteger(safePrice) ? 0 : 2, … … 42 88 } 43 89 44 export function serviceDetail(service ) {90 export function serviceDetail(service, language = 'en') { 45 91 const description = text(service && service.description, '') 46 92 47 93 return description 48 ? `${formatDuration(service.durationMinutes )} · ${description}`49 : formatDuration(service.durationMinutes )94 ? `${formatDuration(service.durationMinutes, language)} · ${description}` 95 : formatDuration(service.durationMinutes, language) 50 96 } 51 97 … … 59 105 } 60 106 61 export function serviceSummary(service ) {107 export function serviceSummary(service, language = 'en') { 62 108 if (!service) { 63 109 return 'No services available yet' 64 110 } 65 111 66 return `${formatDuration(service.durationMinutes )} · ${servicePriceSummary(service)}`112 return `${formatDuration(service.durationMinutes, language)} · ${servicePriceSummary(service, language)}` 67 113 } 68 114 69 export function servicePriceSummary(service ) {70 const price = formatPrice(service?.price )115 export function servicePriceSummary(service, language = 'en') { 116 const price = formatPrice(service?.price, service?.currency || 'EUR', language) 71 117 const deposit = Number(service?.depositEuros ?? Number(service?.depositCents) / 100) 72 return Number.isFinite(deposit) && deposit > 0 ? `${price} · ${formatPrice(deposit)} deposit to confirm` : price 118 const label = depositConfirmLabels[language] || 'deposit to confirm' 119 return Number.isFinite(deposit) && deposit > 0 ? `${price} · ${formatPrice(deposit, service?.currency || 'EUR', language)} ${label}` : price 73 120 } 74 121 75 122 export function timezoneLabel(config) { 76 return config.timezone ? `Times in ${String(config.timezone).replaceAll('_', ' ')}` : 'Times in your local timezone' 123 const labels = timezoneLabels[config.language] || timezoneLabels.en 124 return config.timezone ? `${labels[0]} ${String(config.timezone).replaceAll('_', ' ')}` : labels[1] 77 125 } -
bookminto/tags/wp-1.0.5/assets/src/modalRender.js
r3723811 r3730301 1 import { createDatedAvailabilitySlots } from './availability.js' 2 import { submitBooking } from './api.js' 1 import { servicePhotoMarkup, bindServicePhotoErrors } from './servicePhotos.js' 2 import { createDatedAvailabilitySlots, getSlotDateKey, qualifiedStaffForService, serviceBlockedByStaff } from './availability.js' 3 import { submitBooking, submitWaitlistEntry } from './api.js' 3 4 import { buildIcsDataUrl } from './calendar.js' 4 import { DATE_WINDOW_SIZE, widgetCopy as copy} from './copy.js'5 import { escapeHtml, formatDuration, formatPrice, servicePriceSummary, t imezoneLabel } from './formatters.js'5 import { DATE_WINDOW_SIZE, getWidgetCopy, widgetLocale } from './copy.js' 6 import { escapeHtml, formatDuration, formatPrice, servicePriceSummary, text, timezoneLabel } from './formatters.js' 6 7 import { confirmBookingPayment, getDepositPaymentSession, mountDepositPayment, showPaymentLoadError } from './payment.js' 7 8 import { scheduleRevealContinue } from './scroll.js' … … 19 20 '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"></polyline></svg>' 20 21 21 const modalStepLabels = ['Service', 'Date & time', 'Details', 'Confirmed'] 22 23 function formatDateBadge(dateKey) { 22 23 function formatDateBadge(dateKey, locale) { 24 24 const value = new Date(`${dateKey}T12:00:00`) 25 25 return { 26 26 day: String(value.getDate()), 27 month: new Intl.DateTimeFormat( 'en-US', { month: 'short' }).format(value).replace('.', ''),28 weekday: new Intl.DateTimeFormat( 'en-US', { weekday: 'short' }).format(value).replace('.', ''),27 month: new Intl.DateTimeFormat(locale || 'en-US', { month: 'short' }).format(value).replace('.', ''), 28 weekday: new Intl.DateTimeFormat(locale || 'en-US', { weekday: 'short' }).format(value).replace('.', ''), 29 29 } 30 30 } 31 31 32 function formatSummaryDate(dateKey) { 32 function staffFirstName(name) { 33 return text(name, '').split(/\s+/).filter(Boolean)[0] || '' 34 } 35 36 function staffInitials(name) { 37 return text(name, '') 38 .split(/\s+/) 39 .filter(Boolean) 40 .slice(0, 2) 41 .map((part) => part.charAt(0)) 42 .join('') 43 .toUpperCase() 44 } 45 46 function staffChipColor(value) { 47 const color = text(value, '') 48 return /^#[0-9a-fA-F]{6}$/.test(color) ? color : '' 49 } 50 51 function staffAvatarUrl(value, previewMode) { 52 const url = text(value, '') 53 if (url.startsWith('https://')) { 54 return url 55 } 56 return previewMode && /^data:image\/(?:png|jpe?g|webp|gif);base64,/i.test(url) ? url : '' 57 } 58 59 function formatSummaryDate(dateKey, locale) { 33 60 if (!dateKey) return '' 34 return new Intl.DateTimeFormat( 'en-US', {61 return new Intl.DateTimeFormat(locale || 'en-US', { 35 62 weekday: 'short', 36 63 day: 'numeric', … … 39 66 } 40 67 41 function formatVisibleDateRange(dates ) {68 function formatVisibleDateRange(dates, locale) { 42 69 if (!dates.length) return '' 43 const formatter = new Intl.DateTimeFormat( 'en-US', { day: 'numeric', month: 'short' })70 const formatter = new Intl.DateTimeFormat(locale || 'en-US', { day: 'numeric', month: 'short' }) 44 71 return `${formatter.format(new Date(`${dates[0]}T12:00:00`))} - ${formatter.format( 45 72 new Date(`${dates[dates.length - 1]}T12:00:00`), … … 47 74 } 48 75 49 function stepperMarkup(step) { 76 function stepperMarkup(step, copy) { 77 const modalStepLabels = copy.widgetStepLabels 50 78 const currentStep = Math.min(step + 1, modalStepLabels.length) 51 79 const activeLabel = modalStepLabels[currentStep - 1] … … 55 83 class="stepper" 56 84 role="progressbar" 57 aria-label=" Booking progress"85 aria-label="${escapeHtml(copy.progressLabel)}" 58 86 aria-valuemin="1" 59 87 aria-valuemax="${modalStepLabels.length}" 60 88 aria-valuenow="${currentStep}" 61 aria-valuetext=" Step ${currentStep} of${modalStepLabels.length}: ${escapeHtml(activeLabel)}"89 aria-valuetext="${escapeHtml(copy.stepLabel)} ${currentStep} ${escapeHtml(copy.stepOf)} ${modalStepLabels.length}: ${escapeHtml(activeLabel)}" 62 90 > 63 91 <div class="stepper-meta" aria-hidden="true"> 64 <span> Step ${currentStep} of${modalStepLabels.length}</span>92 <span>${escapeHtml(copy.stepLabel)} ${currentStep} ${escapeHtml(copy.stepOf)} ${modalStepLabels.length}</span> 65 93 <strong>${escapeHtml(activeLabel)}</strong> 66 94 </div> … … 78 106 */ 79 107 export function createModalPanel(config) { 108 const copy = getWidgetCopy(config.language) 109 const locale = widgetLocale(config.language) 80 110 // <dialog> + open attribute often fails to paint inside preview shadow roots. 81 111 // Use a plain overlay div in preview so Appearance always shows the modal. … … 93 123 let selectedDate = null 94 124 let selectedSlotId = null 125 let selectedStaffId = null 95 126 let datePageStart = 0 96 let message = '' 127 let message = config.calendarVerification === 'external_unverified' 128 ? ({ de: 'Die Kalenderverfügbarkeit kann derzeit nicht geprüft werden. Bitte versuchen Sie es später erneut.', en: 'Calendar availability cannot currently be verified. Please try again later.', es: 'No se puede verificar la disponibilidad del calendario ahora mismo. Inténtalo más tarde.', fr: 'La disponibilité du calendrier ne peut pas être vérifiée. Réessayez plus tard.', it: 'La disponibilità del calendario non può essere verificata al momento. Riprova più tardi.', nl: 'De beschikbaarheid van de agenda kan nu niet worden gecontroleerd. Probeer het later opnieuw.' }[config.language] || 'Calendar availability cannot currently be verified. Please try again later.') 129 : '' 97 130 let isSubmitting = false 98 131 let confirmation = null 99 132 let paymentSession = null 100 133 let paymentController = null 134 let clientRequestId = newClientRequestId() 135 let terminalPaymentReplacementAvailable = false 101 136 let form = { 137 answers: {}, 102 138 customerEmail: '', 103 139 customerName: '', 104 140 customerPhone: '', 105 141 notes: '', 142 rebookingOptIn: false, 143 } 144 // Extension D (waitlist): shown inside the date panel when no slot fits. 145 let waitlistOpen = false 146 let waitlistBusy = false 147 let waitlistJoined = false 148 let waitlistForm = { customerEmail: '', customerName: '', dateFrom: '', dateTo: '' } 149 150 function newClientRequestId() { 151 try { 152 if (typeof crypto?.randomUUID === 'function') { 153 return crypto.randomUUID() 154 } 155 } catch { 156 // fall through to the manual id 157 } 158 159 return `bmr-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}` 106 160 } 107 161 … … 114 168 } 115 169 170 // Team members the customer may pick between for the current service. Real 171 // names only ship in 'customer_choice' mode — otherwise the picker stays 172 // hidden and availability stays a union across qualified members. 173 function staffMembers() { 174 return config.staffDisplayMode === 'customer_choice' 175 ? qualifiedStaffForService(config, selectedService()) 176 : [] 177 } 178 179 // The pinned member, or undefined for "Any" (union availability). 180 function pinnedStaffId() { 181 if (!selectedStaffId) { 182 return undefined 183 } 184 185 return staffMembers().some((member) => member.id === selectedStaffId) 186 ? selectedStaffId 187 : undefined 188 } 189 190 function selectedStaffName() { 191 const pinned = pinnedStaffId() 192 if (!pinned) { 193 return '' 194 } 195 196 const member = staffMembers().find((entry) => entry.id === pinned) 197 return member ? text(member.name, '') : '' 198 } 199 116 200 function datedSlots() { 117 return createDatedAvailabilitySlots(config, selectedService() )201 return createDatedAvailabilitySlots(config, selectedService(), pinnedStaffId()) 118 202 } 119 203 … … 130 214 if (step === 0) return Boolean(selectedService()) 131 215 if (step === 1) return Boolean(selectedSlot(slotsByDate)) 132 if (step === 2) return Boolean(form.customerName.trim() && form.customerEmail.trim()) 216 if (step === 2) { 217 if (Number(selectedService()?.depositCents) > 0 && config.features?.deposits !== true) return false 218 if (!form.customerName.trim() || !form.customerEmail.trim()) return false 219 const questions = selectedService()?.bookingQuestions || [] 220 return questions.every( 221 (question) => 222 !question.required || Boolean(String(form.answers?.[question.id] || '').trim()), 223 ) 224 } 133 225 return false 134 226 } … … 138 230 if (step === 0) return copy.continueFromService 139 231 if (paymentSession) return copy.payDepositCta 140 if (step === 2) return copy.confirmCta232 if (step === 2) return terminalPaymentReplacementAvailable ? copy.replacePaymentCta : copy.confirmCta 141 233 return copy.continueDefault 142 234 } 143 235 144 236 function formatDeposit(cents) { 145 return new Intl.NumberFormat( 'en-US', { style: 'currency', currency: 'EUR' }).format(237 return new Intl.NumberFormat(locale, { style: 'currency', currency: 'EUR' }).format( 146 238 (Number(cents) || 0) / 100, 147 239 ) … … 152 244 } 153 245 246 function questionsMarkup(service) { 247 const questions = Array.isArray(service?.bookingQuestions) ? service.bookingQuestions : [] 248 249 if (!questions.length) { 250 return '' 251 } 252 253 return ` 254 <span class="slabel slabel--with-top-gap">${escapeHtml(copy.questionsHeading)}</span> 255 ${questions 256 .map((question) => { 257 const inputId = `bm-modal-question-${escapeHtml(question.id)}` 258 const value = String(form.answers?.[question.id] || '') 259 const requiredMark = question.required 260 ? ` <span class="form-required" aria-hidden="true">*</span><span class="sr-only"> ${escapeHtml(copy.requiredHint)}</span>` 261 : '' 262 const label = `<label class="flabel" for="${inputId}">${escapeHtml(question.label)}${requiredMark}</label>` 263 264 if (question.type === 'yesno') { 265 return ` 266 <div class="form-field"> 267 ${label} 268 <select class="finput" id="${inputId}" data-question-id="${escapeHtml(question.id)}"> 269 <option value="">${escapeHtml(copy.chooseOption)}</option> 270 <option value="yes"${value === 'yes' ? ' selected' : ''}>${escapeHtml(copy.yesLabel)}</option> 271 <option value="no"${value === 'no' ? ' selected' : ''}>${escapeHtml(copy.noLabel)}</option> 272 </select> 273 </div> 274 ` 275 } 276 277 if (question.type === 'choice') { 278 return ` 279 <div class="form-field"> 280 ${label} 281 <select class="finput" id="${inputId}" data-question-id="${escapeHtml(question.id)}"> 282 <option value="">${escapeHtml(copy.chooseOption)}</option> 283 ${(question.options || []) 284 .map( 285 (option) => 286 `<option value="${escapeHtml(option)}"${value === option ? ' selected' : ''}>${escapeHtml(option)}</option>`, 287 ) 288 .join('')} 289 </select> 290 </div> 291 ` 292 } 293 294 return ` 295 <div class="form-field"> 296 ${label} 297 <input class="finput" id="${inputId}" data-question-id="${escapeHtml(question.id)}" maxlength="300" value="${escapeHtml(value)}" /> 298 </div> 299 ` 300 }) 301 .join('')} 302 ` 303 } 304 154 305 function syncFormFromDom() { 306 // `?.value || form.x` kept the stale value when a customer cleared a 307 // pre-filled field — the empty string is the real value. 308 const readField = (name) => { 309 const input = dialog.querySelector(`[name="${name}"]`) 310 return input ? input.value : undefined 311 } 155 312 form = { 156 customerEmail: dialog.querySelector('[name="customerEmail"]')?.value || form.customerEmail, 157 customerName: dialog.querySelector('[name="customerName"]')?.value || form.customerName, 158 customerPhone: dialog.querySelector('[name="customerPhone"]')?.value || form.customerPhone, 159 notes: dialog.querySelector('[name="notes"]')?.value || form.notes, 313 ...form, 314 customerEmail: readField('customerEmail') ?? form.customerEmail, 315 customerName: readField('customerName') ?? form.customerName, 316 customerPhone: readField('customerPhone') ?? form.customerPhone, 317 notes: readField('notes') ?? form.notes, 318 } 319 320 dialog.querySelectorAll('[data-question-id]').forEach((input) => { 321 const id = input.getAttribute('data-question-id') 322 if (id) { 323 form.answers[id] = input.value 324 } 325 }) 326 327 const optIn = dialog.querySelector('[data-rebooking-optin]') 328 if (optIn) { 329 form.rebookingOptIn = optIn.checked === true 160 330 } 161 331 } … … 165 335 selectedDate = null 166 336 selectedSlotId = null 337 selectedStaffId = null 167 338 datePageStart = 0 168 339 message = '' … … 174 345 paymentController = null 175 346 } 176 form = { customerEmail: '', customerName: '', customerPhone: '', notes: '' } 347 clientRequestId = newClientRequestId() 348 terminalPaymentReplacementAvailable = false 349 waitlistOpen = false 350 waitlistBusy = false 351 waitlistJoined = false 352 waitlistForm = { customerEmail: '', customerName: '', dateFrom: '', dateTo: '' } 353 form = { answers: {}, customerEmail: '', customerName: '', customerPhone: '', notes: '', rebookingOptIn: false } 177 354 render() 178 355 } … … 207 384 return ` 208 385 <button type="button" class="svc${selected}" data-service-id="${escapeHtml(id)}"> 386 ${servicePhotoMarkup(item.imageUrl, config.previewMode)} 209 387 <div class="svc-info"> 210 388 <div class="svc-name">${escapeHtml(item.name)}</div><div class="service-price">${escapeHtml(servicePriceSummary(item))}</div> … … 235 413 selectedDate && slot 236 414 ? `<div class="sumbox sumbox--compact"> 237 <div class="sumrow">${escapeHtml(formatSummaryDate(selectedDate ))}</div>415 <div class="sumrow">${escapeHtml(formatSummaryDate(selectedDate, locale))}</div> 238 416 <div class="sumrow">${escapeHtml(slot.timeLabel)} · ${escapeHtml(formatPrice(service?.price))}</div> 417 ${ 418 selectedStaffName() 419 ? `<div class="sumrow">${escapeHtml(copy.withStaff.replace('{name}', selectedStaffName()))}</div>` 420 : '' 421 } 239 422 </div>` 240 423 : '' 241 424 } 425 ` 426 } 427 428 // Compact member chips above the date grid — "Any" keeps union 429 // availability, a member chip pins slots to that calendar. 430 function staffPickerMarkup() { 431 const members = staffMembers() 432 433 if (members.length < 2) { 434 return '' 435 } 436 437 const pinned = pinnedStaffId() 438 const memberChips = members 439 .map((member) => { 440 const name = text(member.name, '') 441 const color = staffChipColor(member.color) 442 const avatar = staffAvatarUrl(member.avatarUrl, config.previewMode) 443 const selected = member.id === pinned 444 const avatarMarkup = avatar 445 ? `<img class="staff-chip-avatar" src="${escapeHtml(avatar)}" alt="" loading="lazy" decoding="async" referrerpolicy="no-referrer">` 446 : '' 447 return ` 448 <button type="button" class="staff-chip${selected ? ' selected' : ''}" data-staff-id="${escapeHtml(member.id)}" aria-pressed="${selected}"> 449 <span class="staff-chip-dot"${color ? ` style="background:${escapeHtml(color)}"` : ''}>${escapeHtml(staffInitials(name))}${avatarMarkup}</span> 450 <span class="staff-chip-name">${escapeHtml(staffFirstName(name) || name)}</span> 451 </button> 452 ` 453 }) 454 .join('') 455 456 return ` 457 <span class="slabel">${escapeHtml(copy.staffPickerLabel)}</span> 458 <div class="staff-row" role="group" aria-label="${escapeHtml(copy.staffPickerLabel)}"> 459 <button type="button" class="staff-chip${pinned ? '' : ' selected'}" data-staff-id="" aria-pressed="${!pinned}"> 460 <span class="staff-chip-name">${escapeHtml(copy.anyStaff)}</span> 461 </button> 462 ${memberChips} 463 </div> 464 ` 465 } 466 467 // Extension D: entry point when the current view has no fitting times. 468 function waitlistMarkup() { 469 const service = selectedService() 470 if (config.features?.waitlist !== true || !service) { 471 return '' 472 } 473 474 if (waitlistJoined) { 475 return `<div class="runtime-state waitlist-joined" role="status">${escapeHtml(copy.waitlistJoined)}</div>` 476 } 477 478 if (!waitlistOpen) { 479 return `<button type="button" class="waitlist-cta" data-action="waitlist-open">${escapeHtml(copy.waitlistCta)}</button>` 480 } 481 482 return ` 483 <div class="waitlist-form"> 484 <span class="slabel slabel--with-top-gap">${escapeHtml(copy.waitlistHeading)}</span> 485 <p class="waitlist-note">${escapeHtml(copy.waitlistNote)}</p> 486 <div class="form-field"> 487 <label class="flabel" for="bm-wl-name">${escapeHtml(copy.nameLabel)}</label> 488 <input class="finput" id="bm-wl-name" data-waitlist-field="customerName" autocomplete="name" value="${escapeHtml(waitlistForm.customerName)}" /> 489 </div> 490 <div class="form-field"> 491 <label class="flabel" for="bm-wl-email">${escapeHtml(copy.emailLabel)}</label> 492 <input class="finput" id="bm-wl-email" data-waitlist-field="customerEmail" autocomplete="email" type="email" value="${escapeHtml(waitlistForm.customerEmail)}" /> 493 </div> 494 <div class="form-row"> 495 <div class="form-field"> 496 <label class="flabel" for="bm-wl-from">${escapeHtml(copy.waitlistFrom)}</label> 497 <input class="finput" id="bm-wl-from" data-waitlist-field="dateFrom" type="date" value="${escapeHtml(waitlistForm.dateFrom)}" /> 498 </div> 499 <div class="form-field"> 500 <label class="flabel" for="bm-wl-to">${escapeHtml(copy.waitlistTo)}</label> 501 <input class="finput" id="bm-wl-to" data-waitlist-field="dateTo" type="date" value="${escapeHtml(waitlistForm.dateTo)}" /> 502 </div> 503 </div> 504 <button type="button" class="cta" data-action="waitlist-submit" ${waitlistBusy ? 'disabled' : ''}> 505 <span>${escapeHtml(waitlistBusy ? copy.processing : copy.waitlistSubmit)}</span> 506 </button> 507 </div> 242 508 ` 243 509 } … … 250 516 <div class="modal-intro-copy">${escapeHtml(copy.depositNote)}</div> 251 517 <div class="sumbox"> 252 <div class="sumrow"><strong>${escapeHtml(formatDeposit(paymentSession.depositCents))}</strong> deposit</div>518 <div class="sumrow"><strong>${escapeHtml(formatDeposit(paymentSession.depositCents))}</strong> ${escapeHtml(copy.depositLabel)}</div> 253 519 </div> 254 < div class="bw-payment-element" data-payment-element></div>520 <slot name="bm-payment-element" data-payment-element><div class="bw-payment-element"></div></slot> 255 521 ${message ? `<div class="runtime-state runtime-state--danger">${escapeHtml(message)}</div>` : ''} 256 522 `, … … 271 537 272 538 if (step === 1) { 539 if (serviceBlockedByStaff(config, service)) { 540 return rightFlow( 541 ` 542 <button class="back-btn" type="button" data-action="back">← ${escapeHtml(copy.back)}</button> 543 ${runtimeState(copy.serviceNotBookable, 'danger')} 544 `, 545 continueButton(slotsByDate), 546 ) 547 } 548 273 549 const dates = visibleDates(availableDates) 274 550 const daySlots = selectedDate ? slotsByDate.get(selectedDate) || [] : [] … … 278 554 ` 279 555 <button class="back-btn" type="button" data-action="back">← ${escapeHtml(copy.back)}</button> 556 ${staffPickerMarkup()} 280 557 <span class="slabel">${escapeHtml(copy.chooseDate)}</span><p class="booking-timezone">${escapeHtml(timezoneLabel(config))}</p> 281 558 <div class="date-nav"> 282 <span class="date-nav-copy">${escapeHtml(formatVisibleDateRange(dates ))}</span>559 <span class="date-nav-copy">${escapeHtml(formatVisibleDateRange(dates, locale))}</span> 283 560 <div class="date-nav-buttons"> 284 561 <button type="button" class="date-nav-btn" data-action="prev-dates" ${canPrev ? '' : 'disabled'}>${chevronLeftIcon}</button> … … 291 568 ? dates 292 569 .map((dateKey) => { 293 const badge = formatDateBadge(dateKey )570 const badge = formatDateBadge(dateKey, locale) 294 571 const selected = dateKey === selectedDate ? ' sel' : '' 295 572 return ` … … 315 592 .map((item) => { 316 593 const selected = item.id === selectedSlotId ? ' sel' : '' 317 return `<button type="button" class="tbtn${selected}" data-slot-id="${escapeHtml(item.id)}">${escapeHtml(item.timeLabel)}</button>` 594 const recommended = item.recommended ? ' rec' : '' 595 return `<button type="button" class="tbtn${selected}${recommended}" data-slot-id="${escapeHtml(item.id)}">${escapeHtml(item.timeLabel)}${item.recommended ? `<em class="rec-badge">${escapeHtml(copy.recommendedBadge)}</em>` : ''}</button>` 318 596 }) 319 597 .join('')} … … 324 602 : '' 325 603 } 604 ${!dates.length || !daySlots.length ? waitlistMarkup() : ''} 605 ${message ? `<div class="runtime-state runtime-state--danger">${escapeHtml(message)}</div>` : ''} 326 606 `, 327 607 continueButton(slotsByDate), … … 334 614 <button class="back-btn" type="button" data-action="back">← ${escapeHtml(copy.back)}</button> 335 615 <span class="slabel">${escapeHtml(copy.detailsHeading)}</span> 616 ${Number(service?.depositCents) > 0 && config.features?.deposits !== true ? `<p class="modal-intro-copy" role="note">${escapeHtml(copy.depositUnavailable)}</p>` : ''} 336 617 <div class="form-field"> 337 618 <label class="flabel" for="bm-modal-name">${escapeHtml(copy.nameLabel)}</label> … … 352 633 <textarea class="finput finput--textarea" id="bm-modal-notes" name="notes" placeholder="${escapeHtml(copy.notesPlaceholder)}">${escapeHtml(form.notes)}</textarea> 353 634 </div> 635 ${questionsMarkup(service)} 636 ${ 637 Number(service?.rebookingIntervalWeeks) > 0 638 ? `<label class="rebooking-optin"> 639 <input type="checkbox" data-rebooking-optin ${form.rebookingOptIn ? 'checked' : ''} /> 640 <span>${escapeHtml(copy.rebookingOptInLabel)}</span> 641 </label>` 642 : '' 643 } 354 644 ${message ? `<div class="runtime-state runtime-state--danger">${escapeHtml(message)}</div>` : ''} 355 645 `, … … 429 719 <div class="topbar"> 430 720 <span class="topbar-title">${escapeHtml(config.title || copy.title)}</span> 431 ${stepperMarkup(activeStep )}721 ${stepperMarkup(activeStep, copy)} 432 722 <button class="close-btn" type="button" data-action="close" aria-label="${escapeHtml(copy.close)}">${closeIcon}</button> 433 723 </div> … … 458 748 accent: config.color, 459 749 clientSecret: paymentSession.clientSecret, 750 language: config.language, 460 751 publishableKey: paymentSession.publishableKey, 461 752 }) … … 466 757 }) 467 758 .catch((error) => { 468 if (host.isConnected && paymentSession === session) showPaymentLoadError(host, error, render )759 if (host.isConnected && paymentSession === session) showPaymentLoadError(host, error, render, config.language) 469 760 }) 470 761 } … … 473 764 474 765 function bindEvents(availableDates, slotsByDate) { 766 bindServicePhotoErrors(dialog) 475 767 dialog.querySelectorAll('[data-service-id]').forEach((button) => { 476 768 button.addEventListener('click', () => { … … 478 770 selectedDate = null 479 771 selectedSlotId = null 772 selectedStaffId = null 480 773 datePageStart = 0 481 774 message = '' 482 775 render() 483 776 }) 777 }) 778 779 // Member chips: pinning re-scopes availability to that calendar; "Any" 780 // (data-staff-id="") restores the union. 781 dialog.querySelectorAll('[data-staff-id]').forEach((button) => { 782 button.addEventListener('click', () => { 783 const next = button.getAttribute('data-staff-id') || null 784 if (next === selectedStaffId) { 785 return 786 } 787 788 selectedStaffId = next 789 message = '' 790 datePageStart = 0 791 // A different member is a different booking attempt — never replay 792 // the previous request id against another member's fingerprint. 793 clientRequestId = newClientRequestId() 794 795 const fresh = datedSlots() 796 if (selectedDate && !fresh.availableDates.includes(selectedDate)) { 797 selectedDate = null 798 selectedSlotId = null 799 } else if ( 800 selectedSlotId && 801 !(fresh.slotsByDate.get(selectedDate) || []).some((slot) => slot.id === selectedSlotId) 802 ) { 803 selectedSlotId = null 804 } 805 806 render() 807 scheduleRevealContinue(dialog, { 808 containerSelector: '.panel-right-scroll', 809 margins: { top: 8, bottom: 12 }, 810 }) 811 }) 812 }) 813 814 dialog.querySelectorAll('.staff-chip-avatar').forEach((image) => { 815 const hide = () => image.remove() 816 image.addEventListener('error', hide, { once: true }) 817 if (image.complete && !image.naturalWidth) hide() 484 818 }) 485 819 … … 546 880 }) 547 881 882 dialog.querySelector('[data-action="waitlist-open"]')?.addEventListener('click', () => { 883 waitlistOpen = true 884 message = '' 885 const base = selectedDate || new Date().toISOString().slice(0, 10) 886 const end = new Date(`${base}T12:00:00`) 887 end.setDate(end.getDate() + 14) 888 waitlistForm = { 889 customerEmail: form.customerEmail, 890 customerName: form.customerName, 891 dateFrom: waitlistForm.dateFrom || base, 892 dateTo: waitlistForm.dateTo || end.toISOString().slice(0, 10), 893 } 894 render() 895 scheduleRevealContinue(dialog, { 896 containerSelector: '.panel-right-scroll', 897 margins: { top: 8, bottom: 12 }, 898 }) 899 }) 900 901 dialog.querySelectorAll('[data-waitlist-field]').forEach((input) => { 902 input.addEventListener('input', () => { 903 const field = input.getAttribute('data-waitlist-field') 904 if (field) { 905 waitlistForm[field] = input.value 906 } 907 }) 908 }) 909 910 dialog.querySelector('[data-action="waitlist-submit"]')?.addEventListener('click', () => { 911 if (waitlistBusy) return 912 const service = selectedService() 913 waitlistBusy = true 914 message = '' 915 render() 916 submitWaitlistEntry(config, { 917 customerEmail: waitlistForm.customerEmail, 918 customerName: waitlistForm.customerName, 919 dateFrom: waitlistForm.dateFrom, 920 dateTo: waitlistForm.dateTo, 921 serviceId: service?.id || service?.name || '', 922 }) 923 .then(() => { 924 waitlistBusy = false 925 waitlistJoined = true 926 waitlistOpen = false 927 render() 928 }) 929 .catch((error) => { 930 waitlistBusy = false 931 message = error instanceof Error ? error.message : copy.waitlistError 932 render() 933 }) 934 }) 935 548 936 dialog.querySelector('[data-action="continue"]')?.addEventListener('click', () => { 549 937 syncFormFromDom() … … 583 971 if (cta) { cta.disabled = true; cta.textContent = copy.processing } 584 972 try { 585 const paymentIntentId = await controller.confirm() 586 await confirmBookingPayment(config, { 973 const paymentIntentId = session.confirmedPaymentIntentId || await controller.confirm() 974 if (paymentSession !== session || paymentController !== controller) return 975 session.confirmedPaymentIntentId = paymentIntentId 976 const result = await confirmBookingPayment(config, { 587 977 bookingId: session.bookingId, 588 978 paymentIntentId, 589 979 }) 980 if (paymentSession !== session || paymentController !== controller) return 981 if (result.processing || !(result.ok || result.alreadyConfirmed)) throw new Error(copy.paymentError) 590 982 confirmation = { bookingId: session.bookingId, id: session.bookingId } 591 983 paymentSession = null 592 984 step = 3 593 985 } catch (error) { 986 if (paymentSession !== session || paymentController !== controller) return 594 987 message = error instanceof Error ? error.message : copy.genericError 988 let note = dialog.querySelector('.runtime-state--danger') 989 if (!note) { 990 note = document.createElement('p') 991 note.className = 'runtime-state runtime-state--danger' 992 note.setAttribute('role', 'alert') 993 dialog.querySelector('[data-payment-element]')?.after(note) 994 } 995 note.textContent = message 595 996 } finally { 596 isSubmitting = false 597 render() 997 if (paymentSession === session && paymentController === controller) { 998 isSubmitting = false 999 if (cta) { cta.disabled = false; cta.textContent = copy.payDepositCta } 1000 } else if (!paymentSession) { isSubmitting = false; render() } 598 1001 } 599 1002 } … … 602 1005 const service = selectedService() 603 1006 const slot = selectedSlot(slotsByDate) 604 if (!service || !slot) return 1007 if (!service || !slot || isSubmitting) return 1008 if (!canContinue(slotsByDate)) return 605 1009 606 1010 isSubmitting = true … … 610 1014 try { 611 1015 const result = await submitBooking(config, { 1016 answers: Object.keys(form.answers || {}).length ? form.answers : undefined, 1017 clientRequestId, 1018 replaceTerminalPayment: terminalPaymentReplacementAvailable || undefined, 1019 publicationRevision: config.publicationRevision, 612 1020 customerEmail: form.customerEmail.trim(), 613 1021 customerName: form.customerName.trim(), 614 1022 customerPhone: form.customerPhone.trim(), 1023 language: config.language || 'en', 615 1024 notes: form.notes.trim(), 1025 rebookingOptIn: form.rebookingOptIn || undefined, 616 1026 serviceId: service.id || service.name, 617 1027 serviceName: service.name, 1028 staffId: pinnedStaffId(), 618 1029 startsAt: slot.startsAt, 619 1030 }) … … 623 1034 } 624 1035 625 const depositSession = getDepositPaymentSession(result )1036 const depositSession = getDepositPaymentSession(result, config.language) 626 1037 if (depositSession) { 1038 terminalPaymentReplacementAvailable = false 627 1039 paymentSession = depositSession 628 1040 } else { … … 631 1043 } 632 1044 } catch (error) { 633 message = error instanceof Error ? error.message : copy.genericError 1045 terminalPaymentReplacementAvailable = error?.code === 'terminal_payment_replacement_available' 1046 if (error?.code === 'staff_unavailable') { 1047 // The pinned member was just booked (or is unqualified) — send the 1048 // customer back to a freshly computed slot list. 1049 step = 1 1050 selectedSlotId = null 1051 message = copy.staffUnavailable 1052 } else { 1053 message = terminalPaymentReplacementAvailable ? copy.replacePaymentNote : error instanceof Error ? error.message : copy.genericError 1054 } 634 1055 } finally { 635 1056 isSubmitting = false … … 644 1065 dialog.dispatchEvent(new CustomEvent('booking-widget-close', { bubbles: true })) 645 1066 }) 1067 1068 // Extension A: open the flow with service and slot preselected. Skips only 1069 // steps that are already settled — if the slot disappeared the customer is 1070 // left on the time step with the service preselected. 1071 dialog.preselectBooking = (selection) => { 1072 if (!selection || typeof selection !== 'object') { 1073 return 1074 } 1075 1076 if (selection.serviceId) { 1077 if (selection.serviceId !== selectedServiceId) { 1078 selectedStaffId = null 1079 } 1080 selectedServiceId = selection.serviceId 1081 } 1082 1083 if (selection.startsAt) { 1084 const dateKey = getSlotDateKey(selection.startsAt, config.timezone) 1085 const match = (datedSlots().slotsByDate.get(dateKey) || []).find( 1086 (slot) => slot.startsAt === selection.startsAt, 1087 ) 1088 if (match) { 1089 selectedDate = dateKey 1090 selectedSlotId = match.id 1091 step = 2 1092 } else { 1093 step = Math.max(step, 1) 1094 } 1095 } else if (selection.serviceId) { 1096 step = Math.max(step, 1) 1097 } 1098 1099 render() 1100 } 646 1101 647 1102 dialog.resetFlow = resetFlow -
bookminto/tags/wp-1.0.5/assets/src/mount.js
r3723811 r3730301 2 2 import { createBookingBar } from './bookingBar.js' 3 3 import { createBookingModal } from './bookingModal.js' 4 import { widgetCopy as copy } from './copy.js'4 import { getWidgetCopy } from './copy.js' 5 5 import { ensureFloatingPopoverChrome } from './floatingPopoverStyles.js' 6 6 import { elevateBarHost, elevateOverlayHost, ensureHostOnTop, ensureOverlayHostStyles } from './overlayHost.js' 7 7 import { escapeHtml as escapeLauncherLabel } from './formatters.js' 8 import { createNextTimes } from './nextTimes.js' 8 9 import { createWidget } from './render.js' 9 10 import { injectStyles } from './styles.js' … … 87 88 88 89 export function renderMountedWidget(element, config) { 90 const copy = getWidgetCopy(config.language) 89 91 const mountId = 90 92 element.dataset.bookingWidgetMountId || … … 92 94 element.dataset.bookingWidgetMountId = mountId 93 95 94 findWidgetOwners(mountId).forEach((node) => node.remove()) 96 findWidgetOwners(mountId).forEach((node) => { 97 node.__bookingWidgetDestroy?.() 98 node.remove() 99 }) 95 100 96 101 element 97 102 .querySelectorAll('[data-booking-widget-runtime], .bw-widget-bar, .bw-widget-shadow-host') 98 .forEach((node) => node.remove()) 103 .forEach((node) => { 104 node.__bookingWidgetDestroy?.() 105 node.remove() 106 }) 99 107 100 108 // Product-page commerce must never inherit published modal/floating chrome. 101 109 if (config.commerce === 'shopify' || config.commerce === 'woocommerce') { 102 110 config = { ...config, variant: 'inline' } 111 } 112 113 // Extension A: data-next-times strip. Only rendered when the workspace plan 114 // advertises the feature (`features.nextTimes` in the published config). 115 // Falls back to a regular modal CTA so the element is never a dead end. 116 if (config.nextTimes) { 117 const entitled = 118 config.previewMode || 119 !config.features || 120 typeof config.features !== 'object' || 121 Object.keys(config.features).length === 0 || 122 config.features.nextTimes === true 123 124 if (!entitled) { 125 config = { ...config, nextTimes: false, variant: 'modal' } 126 } else { 127 const root = prepareShadowRoot(element) 128 applyThemeProperties(element, config.color) 129 130 const modalHost = document.createElement('div') 131 const modalRoot = prepareShadowRoot(modalHost) 132 modalHost.className = 'bw-widget-shadow-host' 133 modalHost.dataset.bookingWidgetOwner = mountId 134 modalHost.dataset.variant = 'modal' 135 modalHost.dataset.open = 'false' 136 applyThemeProperties(modalHost, config.color) 137 elevateOverlayHost(modalHost) 138 document.body.append(modalHost) 139 ensureHostOnTop(modalHost) 140 141 const modal = createBookingModal(config, mountId, modalRoot, { 142 suppressCta: true, 143 }) 144 modalHost.__bookingWidgetDestroy = modal.destroy 145 146 root.appendChild( 147 createNextTimes(config, { 148 onOpen: () => modal.setOpen(true), 149 onSelectSlot: (serviceId, startsAt) => { 150 modal.panel?.preselectBooking?.({ serviceId, startsAt }) 151 modal.setOpen(true) 152 }, 153 }), 154 ) 155 return element 156 } 103 157 } 104 158 … … 116 170 let open = false 117 171 let closeTimer = 0 172 let released = false 118 173 119 174 ensureFloatingPopoverChrome() … … 144 199 145 200 function setOpen(isOpen) { 201 // Once the host is gone (remount cleanup or the merchant page removed 202 // it), nothing may resurrect the floating chrome via ensureHostOnTop. 203 if (released || host.isConnected === false) { 204 return 205 } 146 206 open = isOpen 147 207 setClassToken(launcher, 'expanded', isOpen) … … 213 273 214 274 function onKeyDown(event) { 275 // The host page removed our chrome (SPA navigation, theme re-render) — 276 // drop the document listener instead of accumulating it. 277 if (host.isConnected === false) { 278 released = true 279 if (typeof document.removeEventListener === 'function') { 280 document.removeEventListener('keydown', onKeyDown) 281 } 282 return 283 } 215 284 if (event.key === 'Escape' && open) { 216 285 setOpen(false) … … 220 289 if (typeof document.addEventListener === 'function') { 221 290 document.addEventListener('keydown', onKeyDown) 291 } 292 293 host.__bookingWidgetDestroy = () => { 294 released = true 295 if (typeof document.removeEventListener === 'function') { 296 document.removeEventListener('keydown', onKeyDown) 297 } 298 if (typeof window.clearTimeout === 'function') { 299 window.clearTimeout(closeTimer) 300 } 222 301 } 223 302 … … 287 366 } 288 367 289 createBookingModal(config, mountId, root) 368 const modal = createBookingModal(config, mountId, root) 369 host.__bookingWidgetDestroy = modal.destroy 290 370 return host 291 371 } … … 312 392 if (config.workspace) { 313 393 loadRemoteConfig(config).then((remoteConfig) => { 394 // The embed element may be gone by the time the config arrives — a 395 // floating/bar mount must not append to a page that no longer embeds. 396 // `=== false` (not falsy): minimal DOM shims may omit isConnected. 397 if (element.isConnected === false) { 398 return 399 } 400 314 401 if (shouldSkipAutoInlineWidget(element, remoteConfig)) { 315 402 return … … 366 453 .forEach((element) => { 367 454 if (element.tagName === 'SCRIPT') { 455 // Each scan used to mint a fresh placeholder after the script tag — 456 // a second scan() (loader + DOMContentLoaded) mounted duplicates. 457 if (element.dataset.bookingWidgetScanned === 'true') { 458 return 459 } 460 element.dataset.bookingWidgetScanned = 'true' 368 461 const placeholder = document.createElement('div') 369 462 placeholder.setAttribute('data-booking-widget', '') -
bookminto/tags/wp-1.0.5/assets/src/payment.js
r3723811 r3730301 1 export function getDepositPaymentSession(result) { 1 import { getWidgetCopy } from './copy.js' 2 3 export function getDepositPaymentSession(result, language) { 4 const copy = getWidgetCopy(language) 2 5 const payment = result?.payment || result 3 6 if (!result?.needsPayment && !result?.payment) return null 4 7 if (!payment?.clientSecret || !payment?.publishableKey || !(result.bookingId || result.id)) { 5 throw new Error( 'A deposit is required, but secure payment could not load. Please try again or contact the business.')8 throw new Error(copy.depositLoadError) 6 9 } 7 10 return { … … 13 16 } 14 17 15 export function showPaymentLoadError(host, error, onRetry) { 18 export function showPaymentLoadError(host, error, onRetry, language) { 19 const copy = getWidgetCopy(language) 16 20 const message = document.createElement('p') 17 21 message.setAttribute('role', 'alert') 18 message.textContent = error instanceof Error ? error.message : 'Secure payment could not load.'22 message.textContent = error instanceof Error ? error.message : copy.paymentLoadFailed 19 23 const retry = document.createElement('button') 20 24 retry.type = 'button' 21 25 retry.className = 'back-btn' 22 retry.textContent = 'Try again'26 retry.textContent = copy.retryCta 23 27 retry.addEventListener('click', onRetry) 24 28 host.replaceChildren(message, retry) … … 53 57 54 58 export async function mountDepositPayment(host, input) { 59 const copy = getWidgetCopy(input.language) 55 60 const StripeFactory = await loadStripeJs() 56 61 const stripe = StripeFactory(input.publishableKey) … … 65 70 }, 66 71 clientSecret: input.clientSecret, 72 locale: input.language || undefined, 67 73 }) 68 74 const paymentElement = elements.create('payment') 69 paymentElement.mount(host) 75 76 // Stripe Elements cannot mount inside a shadow root: the Payment Element 77 // never emits `ready` there and confirmPayment() then always fails with 78 // "the Element ... is mounted and the ready event has been emitted". The 79 // widget mounts everything inside attachShadow(), so the element is mounted 80 // into a light-DOM node that is slotted into the shadow markup — visually in 81 // place, technically in the main document where Stripe works. 82 const hostRoot = typeof host.getRootNode === 'function' ? host.getRootNode() : null 83 const inShadow = typeof ShadowRoot !== 'undefined' && hostRoot instanceof ShadowRoot && hostRoot.host 84 let slottedHost = null 85 if (inShadow) { 86 slottedHost = document.createElement('div') 87 slottedHost.setAttribute('slot', 'bm-payment-element') 88 // contain:paint + clip-path clip the Element iframe at this node's bounds — 89 // plain overflow on a slotted node does not contain Stripe's iframe for hit 90 // testing, so it would otherwise intercept clicks on the pay button below. 91 slottedHost.style.cssText = 'position: relative; margin: 8px 0 4px; min-height: 120px; contain: paint; clip-path: inset(0);' 92 93 // Slotted content renders at the slot position but is not clipped by the 94 // widget's internal scroll container — a tall Payment Element would paint 95 // over the pay button below it. Cap it to the space left in the nearest 96 // clipping ancestor and let the element scroll internally instead. 97 let ancestor = host.parentElement 98 while (ancestor && ancestor !== hostRoot) { 99 const overflowY = getComputedStyle(ancestor).overflowY 100 if (overflowY === 'auto' || overflowY === 'scroll' || overflowY === 'hidden') { 101 const available = ancestor.getBoundingClientRect().bottom - host.getBoundingClientRect().top 102 slottedHost.style.maxHeight = `${Math.max(140, Math.floor(available))}px` 103 slottedHost.style.overflowY = 'auto' 104 break 105 } 106 ancestor = ancestor.parentElement 107 } 108 109 hostRoot.host.appendChild(slottedHost) 110 } 111 const mountHost = slottedHost || host 112 const cleanupSlotted = () => { 113 if (slottedHost) { 114 slottedHost.remove() 115 slottedHost = null 116 } 117 } 118 119 try { 120 paymentElement.mount(mountHost) 121 122 // confirmPayment() throws "Element ... ready event has been emitted" while 123 // the Payment Element iframe is still initialising. mount() returns before 124 // `ready` fires, so callers keep their CTA disabled until this resolves. 125 if (typeof paymentElement.on === 'function') { 126 await new Promise((resolve, reject) => { 127 const cleanup = () => { 128 try { 129 paymentElement.off('ready', onReady) 130 paymentElement.off('loaderror', onError) 131 } catch { 132 // ignore 133 } 134 } 135 const onReady = () => { 136 cleanup() 137 resolve() 138 } 139 const onError = (event) => { 140 cleanup() 141 reject(new Error(event?.error?.message || copy.paymentLoadFailed)) 142 } 143 paymentElement.on('ready', onReady) 144 paymentElement.on('loaderror', onError) 145 }) 146 } 147 } catch (error) { 148 cleanupSlotted() 149 throw error 150 } 70 151 71 152 return { … … 80 161 81 162 if (result.error) { 82 throw new Error(result.error.message || 'Payment failed')163 throw new Error(result.error.message || copy.paymentError) 83 164 } 84 165 85 166 const intent = result.paymentIntent 86 167 if (!intent || intent.status !== 'succeeded') { 87 throw new Error( 'Payment is not completed yet')168 throw new Error(copy.paymentError) 88 169 } 89 170 … … 96 177 // ignore 97 178 } 179 cleanupSlotted() 98 180 }, 99 181 } … … 113 195 if (!response.ok) { 114 196 const result = await response.json().catch(() => ({})) 115 throw new Error(result.error || 'Payment confirmation failed')197 throw new Error(result.error || getWidgetCopy(config.language).paymentError) 116 198 } 117 199 -
bookminto/tags/wp-1.0.5/assets/src/previewMount.js
r3723811 r3730301 1 1 import { createBookingBar } from './bookingBar.js' 2 2 import { createBookingModal } from './bookingModal.js' 3 import { widgetCopy as copy } from './copy.js'3 import { getWidgetCopy } from './copy.js' 4 4 import { createWidget } from './render.js' 5 5 import { injectStyles } from './styles.js' … … 309 309 */ 310 310 export function mountPreviewWidget(container, config, options = {}) { 311 const copy = getWidgetCopy(config.language) 311 312 if (!container) { 312 313 return () => undefined -
bookminto/tags/wp-1.0.5/assets/src/render.js
r3723811 r3730301 1 import { createDatedAvailabilitySlots } from './availability.js' 2 import { isCommerceCheckout, isShopifyCommerce, isWooCommerceCommerce, submitBooking } from './api.js' 1 import { servicePhotoMarkup, bindServicePhotoErrors } from './servicePhotos.js' 2 import { createDatedAvailabilitySlots, qualifiedStaffForService, serviceBlockedByStaff } from './availability.js' 3 import { isCommerceCheckout, isShopifyCommerce, isWooCommerceCommerce, submitBooking, submitWaitlistEntry } from './api.js' 3 4 import { buildIcsDataUrl } from './calendar.js' 4 import { DATE_WINDOW_SIZE, widgetCopy as copy} from './copy.js'5 import { escapeHtml, formatDuration, formatPrice, servicePriceSummary, t imezoneLabel } from './formatters.js'5 import { DATE_WINDOW_SIZE, getWidgetCopy, widgetLocale } from './copy.js' 6 import { escapeHtml, formatDuration, formatPrice, servicePriceSummary, text, timezoneLabel } from './formatters.js' 6 7 import { confirmBookingPayment, getDepositPaymentSession, mountDepositPayment, showPaymentLoadError } from './payment.js' 7 8 import { scheduleRevealContinue } from './scroll.js' 8 9 import { applyThemeProperties } from './theme.js' 10 import { DIAL_CODES, PHONE_COUNTRIES, normalizePhoneE164 } from './phoneNumber.js' 9 11 10 12 const SHOPIFY_HOLD_NAME = 'Shopify checkout' … … 22 24 '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"></polyline></svg>' 23 25 24 function formatDateBadge(dateKey ) {26 function formatDateBadge(dateKey, locale) { 25 27 const value = new Date(`${dateKey}T12:00:00`) 26 28 return { 27 29 day: String(value.getDate()), 28 month: new Intl.DateTimeFormat( 'en-US', { month: 'short' }).format(value).replace('.', ''),29 weekday: new Intl.DateTimeFormat( 'en-US', { weekday: 'short' }).format(value).replace('.', ''),30 month: new Intl.DateTimeFormat(locale || 'en-US', { month: 'short' }).format(value).replace('.', ''), 31 weekday: new Intl.DateTimeFormat(locale || 'en-US', { weekday: 'short' }).format(value).replace('.', ''), 30 32 } 31 33 } 32 34 33 function formatSummaryDate(dateKey) { 35 function staffFirstName(name) { 36 return text(name, '').split(/\s+/).filter(Boolean)[0] || '' 37 } 38 39 function staffInitials(name) { 40 return text(name, '') 41 .split(/\s+/) 42 .filter(Boolean) 43 .slice(0, 2) 44 .map((part) => part.charAt(0)) 45 .join('') 46 .toUpperCase() 47 } 48 49 function staffChipColor(value) { 50 const color = text(value, '') 51 return /^#[0-9a-fA-F]{6}$/.test(color) ? color : '' 52 } 53 54 function staffAvatarUrl(value, previewMode) { 55 const url = text(value, '') 56 if (url.startsWith('https://')) { 57 return url 58 } 59 return previewMode && /^data:image\/(?:png|jpe?g|webp|gif);base64,/i.test(url) ? url : '' 60 } 61 62 function formatSummaryDate(dateKey, locale) { 34 63 if (!dateKey) { 35 64 return '' 36 65 } 37 66 38 return new Intl.DateTimeFormat( 'en-US', {67 return new Intl.DateTimeFormat(locale || 'en-US', { 39 68 weekday: 'short', 40 69 day: 'numeric', … … 43 72 } 44 73 45 function formatVisibleDateRange(dates ) {74 function formatVisibleDateRange(dates, locale) { 46 75 if (!dates.length) { 47 76 return '' 48 77 } 49 78 50 const formatter = new Intl.DateTimeFormat( 'en-US', {79 const formatter = new Intl.DateTimeFormat(locale || 'en-US', { 51 80 day: 'numeric', 52 81 month: 'short', … … 58 87 } 59 88 60 function stepperMarkup(step ) {89 function stepperMarkup(step, copy) { 61 90 const currentStep = Math.min(step + 1, copy.widgetStepLabels.length) 62 91 const activeLabel = copy.widgetStepLabels[currentStep - 1] … … 66 95 class="stepper" 67 96 role="progressbar" 68 aria-label=" Booking progress"97 aria-label="${escapeHtml(copy.progressLabel)}" 69 98 aria-valuemin="1" 70 99 aria-valuemax="${copy.widgetStepLabels.length}" 71 100 aria-valuenow="${currentStep}" 72 aria-valuetext=" Step ${currentStep} of${copy.widgetStepLabels.length}: ${escapeHtml(activeLabel)}"101 aria-valuetext="${escapeHtml(copy.stepLabel)} ${currentStep} ${escapeHtml(copy.stepOf)} ${copy.widgetStepLabels.length}: ${escapeHtml(activeLabel)}" 73 102 > 74 103 <div class="stepper-meta" aria-hidden="true"> 75 <span> Step ${currentStep} of${copy.widgetStepLabels.length}</span>104 <span>${escapeHtml(copy.stepLabel)} ${currentStep} ${escapeHtml(copy.stepOf)} ${copy.widgetStepLabels.length}</span> 76 105 <strong>${escapeHtml(activeLabel)}</strong> 77 106 </div> … … 101 130 102 131 export function createWidget(config) { 132 const copy = getWidgetCopy(config.language) 133 const locale = widgetLocale(config.language) 103 134 const widget = document.createElement('section') 104 135 const isFloating = config.variant === 'floating' 105 136 const isOverlay = isFloating 106 137 const isCommerce = isCommerceCheckout(config) 107 const skipServiceStep = isCommerce && config.services.length <= 1 138 // Skip the pick-a-service step when the mount is already scoped to one 139 // service (commerce, or a ?book=&service= share/rebooking link). 140 const skipServiceStep = (isCommerce || Boolean(config.service)) && config.services.length <= 1 108 141 let step = skipServiceStep ? 1 : 0 109 142 let selectedServiceId = config.services[0]?.id || config.services[0]?.name || '' 110 143 let selectedDate = null 111 144 let selectedSlotId = null 145 let selectedStaffId = null 112 146 let datePageStart = 0 113 let message = '' 147 let message = config.calendarVerification === 'external_unverified' 148 ? ({ de: 'Die Kalenderverfügbarkeit kann derzeit nicht geprüft werden. Bitte versuchen Sie es später erneut.', en: 'Calendar availability cannot currently be verified. Please try again later.', es: 'No se puede verificar la disponibilidad del calendario ahora mismo. Inténtalo más tarde.', fr: 'La disponibilité du calendrier ne peut pas être vérifiée. Réessayez plus tard.', it: 'La disponibilità del calendario non può essere verificata al momento. Riprova più tardi.', nl: 'De beschikbaarheid van de agenda kan nu niet worden gecontroleerd. Probeer het later opnieuw.' }[config.language] || 'Calendar availability cannot currently be verified. Please try again later.') 149 : '' 114 150 let messageTone = 'error' 115 151 let isSubmitting = false … … 117 153 let paymentSession = null 118 154 let paymentController = null 155 // Idempotent submit key: retries of the same attempt replay server-side 156 // instead of double-booking. Regenerated whenever the flow resets. 157 let clientRequestId = newClientRequestId() 158 let terminalPaymentReplacementAvailable = false 119 159 let form = { 160 answers: {}, 120 161 customerEmail: '', 121 162 customerName: '', 122 163 customerPhone: '', 164 customerPhoneCountry: '', 123 165 notes: '', 166 rebookingOptIn: false, 167 smsOptIn: false, 168 whatsappOptIn: false, 169 } 170 // Extension D (waitlist): entry point inside the date step when no slot fits. 171 let waitlistOpen = false 172 let waitlistBusy = false 173 let waitlistJoined = false 174 let waitlistForm = { customerEmail: '', customerName: '', dateFrom: '', dateTo: '' } 175 176 function newClientRequestId() { 177 try { 178 if (typeof crypto?.randomUUID === 'function') { 179 return crypto.randomUUID() 180 } 181 } catch { 182 // fall through to the manual id 183 } 184 185 return `bmr-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}` 124 186 } 125 187 … … 140 202 } 141 203 204 // Team members the customer may pick between for the current service. Real 205 // names only ship in 'customer_choice' mode — otherwise the picker stays 206 // hidden and availability stays a union across qualified members. 207 function staffMembers() { 208 return config.staffDisplayMode === 'customer_choice' 209 ? qualifiedStaffForService(config, selectedService()) 210 : [] 211 } 212 213 // The pinned member, or undefined for "Any" (union availability). 214 function pinnedStaffId() { 215 if (!selectedStaffId) { 216 return undefined 217 } 218 219 return staffMembers().some((member) => member.id === selectedStaffId) 220 ? selectedStaffId 221 : undefined 222 } 223 224 function selectedStaffName() { 225 const pinned = pinnedStaffId() 226 if (!pinned) { 227 return '' 228 } 229 230 const member = staffMembers().find((entry) => entry.id === pinned) 231 return member ? text(member.name, '') : '' 232 } 233 142 234 function datedSlots() { 143 return createDatedAvailabilitySlots(config, selectedService() )235 return createDatedAvailabilitySlots(config, selectedService(), pinnedStaffId()) 144 236 } 145 237 … … 166 258 167 259 if (step === 2) { 168 return Boolean(form.customerName.trim() && form.customerEmail.trim()) 260 // A stored deposit requirement is never relaxed silently: when the 261 // deposits feature or the payment link is unavailable, the booking 262 // pauses instead of confirming for free. 263 const selected = selectedService() 264 if (Number(selected?.depositCents) > 0 && config.features?.deposits !== true) { 265 return false 266 } 267 268 if (!form.customerName.trim() || !form.customerEmail.trim()) { 269 return false 270 } 271 272 const questions = selectedService()?.bookingQuestions || [] 273 return questions.every( 274 (question) => 275 !question.required || Boolean(String(form.answers?.[question.id] || '').trim()), 276 ) 169 277 } 170 278 … … 190 298 191 299 if (step === 2) { 300 if (terminalPaymentReplacementAvailable) return copy.replacePaymentCta 192 301 return copy.confirmCta 193 302 } … … 198 307 function formatDeposit(cents) { 199 308 const amount = (Number(cents) || 0) / 100 200 return new Intl.NumberFormat( 'en-US', {309 return new Intl.NumberFormat(locale, { 201 310 style: 'currency', 202 311 currency: 'EUR', … … 210 319 <div class="confirm-sub" style="text-align:left;margin-bottom:12px">${escapeHtml(copy.depositNote)}</div> 211 320 <div class="summary-box"> 212 <div class="summary-row"><span><strong>${escapeHtml(formatDeposit(paymentSession.depositCents))}</strong> deposit</span></div>321 <div class="summary-row"><span><strong>${escapeHtml(formatDeposit(paymentSession.depositCents))}</strong> ${escapeHtml(copy.depositLabel)}</span></div> 213 322 </div> 214 < div class="bw-payment-element" data-payment-element></div>323 <slot name="bm-payment-element" data-payment-element><div class="bw-payment-element"></div></slot> 215 324 </div> 216 325 ` … … 223 332 224 333 const toneClass = messageTone === 'info' ? ' widget-message--info' : '' 225 return `<div role="alert" class="widget-message${toneClass}">${escapeHtml(message)}</div>`334 return `<div role="alert" tabindex="-1" class="widget-message${toneClass}">${escapeHtml(message)}</div>` 226 335 } 227 336 … … 245 354 return ` 246 355 <button type="button" class="service-card${selected}" data-service-id="${escapeHtml(id)}"> 356 ${servicePhotoMarkup(service.imageUrl, config.previewMode)} 247 357 <div class="service-info"> 248 358 <div class="service-name">${escapeHtml(service.name)}</div><div class="service-price">${escapeHtml(servicePriceSummary(service))}</div> … … 256 366 } 257 367 368 // Extension D: shown when the selected service is on a waitlist-enabled plan 369 // and the current view has no fitting times. 370 function waitlistMarkup(service, daySlots) { 371 if (config.features?.waitlist !== true || !service) { 372 return '' 373 } 374 375 if (waitlistJoined) { 376 return `<div class="runtime-state waitlist-joined" role="status">${escapeHtml(copy.waitlistJoined)}</div>` 377 } 378 379 if (!waitlistOpen) { 380 return ` 381 <button type="button" class="waitlist-cta" data-action="waitlist-open"> 382 ${escapeHtml(copy.waitlistCta)} 383 </button> 384 ` 385 } 386 387 return ` 388 <div class="waitlist-form"> 389 <p class="section-title">${escapeHtml(copy.waitlistHeading)}</p> 390 <p class="waitlist-note">${escapeHtml(copy.waitlistNote)}</p> 391 <div class="form-field"> 392 <label class="form-label" for="bm-wl-name">${escapeHtml(copy.nameLabel)}</label> 393 <input class="form-input" id="bm-wl-name" data-waitlist-field="customerName" autocomplete="name" value="${escapeHtml(waitlistForm.customerName)}" /> 394 </div> 395 <div class="form-field"> 396 <label class="form-label" for="bm-wl-email">${escapeHtml(copy.emailLabel)}</label> 397 <input class="form-input" id="bm-wl-email" data-waitlist-field="customerEmail" autocomplete="email" type="email" value="${escapeHtml(waitlistForm.customerEmail)}" /> 398 </div> 399 <div class="form-row"> 400 <div class="form-field"> 401 <label class="form-label" for="bm-wl-from">${escapeHtml(copy.waitlistFrom)}</label> 402 <input class="form-input" id="bm-wl-from" data-waitlist-field="dateFrom" type="date" value="${escapeHtml(waitlistForm.dateFrom)}" /> 403 </div> 404 <div class="form-field"> 405 <label class="form-label" for="bm-wl-to">${escapeHtml(copy.waitlistTo)}</label> 406 <input class="form-input" id="bm-wl-to" data-waitlist-field="dateTo" type="date" value="${escapeHtml(waitlistForm.dateTo)}" /> 407 </div> 408 </div> 409 <button type="button" class="cta-btn" data-action="waitlist-submit" ${waitlistBusy ? 'disabled' : ''}> 410 <span>${escapeHtml(waitlistBusy ? copy.processing : copy.waitlistSubmit)}</span> 411 </button> 412 </div> 413 ` 414 } 415 416 // Compact member chips above the date grid — "Any" keeps union 417 // availability, a member chip pins slots to that calendar. 418 function staffPickerMarkup() { 419 const members = staffMembers() 420 421 if (members.length < 2) { 422 return '' 423 } 424 425 const pinned = pinnedStaffId() 426 const memberChips = members 427 .map((member) => { 428 const name = text(member.name, '') 429 const color = staffChipColor(member.color) 430 const avatar = staffAvatarUrl(member.avatarUrl, config.previewMode) 431 const selected = member.id === pinned 432 const avatarMarkup = avatar 433 ? `<img class="staff-chip-avatar" src="${escapeHtml(avatar)}" alt="" loading="lazy" decoding="async" referrerpolicy="no-referrer">` 434 : '' 435 return ` 436 <button type="button" class="staff-chip${selected ? ' selected' : ''}" data-staff-id="${escapeHtml(member.id)}" aria-pressed="${selected}"> 437 <span class="staff-chip-dot"${color ? ` style="background:${escapeHtml(color)}"` : ''}>${escapeHtml(staffInitials(name))}${avatarMarkup}</span> 438 <span class="staff-chip-name">${escapeHtml(staffFirstName(name) || name)}</span> 439 </button> 440 ` 441 }) 442 .join('') 443 444 return ` 445 <p class="section-title">${escapeHtml(copy.staffPickerLabel)}</p> 446 <div class="staff-row" role="group" aria-label="${escapeHtml(copy.staffPickerLabel)}"> 447 <button type="button" class="staff-chip${pinned ? '' : ' selected'}" data-staff-id="" aria-pressed="${!pinned}"> 448 <span class="staff-chip-name">${escapeHtml(copy.anyStaff)}</span> 449 </button> 450 ${memberChips} 451 </div> 452 ` 453 } 454 258 455 function dateStep(availableDates, slotsByDate) { 259 456 const dates = visibleDates(availableDates) … … 261 458 const canPrev = datePageStart > 0 262 459 const canNext = datePageStart + DATE_WINDOW_SIZE < availableDates.length 460 const service = selectedService() 263 461 const backBtn = 264 462 skipServiceStep … … 266 464 : `<button class="back-btn" type="button" data-action="back">← ${escapeHtml(copy.back)}</button>` 267 465 466 if (serviceBlockedByStaff(config, service)) { 467 return ` 468 ${backBtn} 469 ${runtimeState(copy.serviceNotBookable, 'danger')} 470 ` 471 } 472 268 473 return ` 269 474 ${backBtn} 475 ${staffPickerMarkup()} 270 476 <p class="section-title">${escapeHtml(copy.chooseDate)}</p><p class="booking-timezone">${escapeHtml(timezoneLabel(config))}</p> 271 477 <div class="date-nav"> 272 <span class="date-nav-copy">${escapeHtml(formatVisibleDateRange(dates ))}</span>478 <span class="date-nav-copy">${escapeHtml(formatVisibleDateRange(dates, locale))}</span> 273 479 <div class="date-nav-buttons"> 274 480 <button type="button" class="date-nav-btn" data-action="prev-dates" aria-label="${escapeHtml(copy.previousDates)}" ${canPrev ? '' : 'disabled'}> … … 285 491 ? dates 286 492 .map((dateKey) => { 287 const badge = formatDateBadge(dateKey )493 const badge = formatDateBadge(dateKey, locale) 288 494 const selected = dateKey === selectedDate ? ' selected' : '' 289 495 return ` … … 309 515 .map((slot) => { 310 516 const selected = slot.id === selectedSlotId ? ' selected' : '' 517 const recommended = slot.recommended ? ' recommended' : '' 311 518 return ` 312 <button type="button" class="time-btn${selected} " data-slot-id="${escapeHtml(slot.id)}">519 <button type="button" class="time-btn${selected}${recommended}" data-slot-id="${escapeHtml(slot.id)}"> 313 520 ${escapeHtml(slot.timeLabel)} 521 ${slot.recommended ? `<em class="rec-badge">${escapeHtml(copy.recommendedBadge)}</em>` : ''} 314 522 </button> 315 523 ` … … 322 530 : '' 323 531 } 532 ${!dates.length || !daySlots.length ? waitlistMarkup(service, daySlots) : ''} 324 533 ` 325 534 } … … 332 541 <div class="summary-box"> 333 542 <div class="summary-row"> 334 <span>${escapeHtml(formatSummaryDate(selectedDate ))} ${escapeHtml(copy.at)} ${escapeHtml(slot?.timeLabel || '')}</span>543 <span>${escapeHtml(formatSummaryDate(selectedDate, locale))} ${escapeHtml(copy.at)} ${escapeHtml(slot?.timeLabel || '')}</span> 335 544 </div> 336 545 <div class="summary-row"> 337 546 <span>${escapeHtml(service?.name || '')} · ${escapeHtml(formatDuration(service?.durationMinutes))}</span> 338 547 </div> 548 ${ 549 selectedStaffName() 550 ? `<div class="summary-row"><span>${escapeHtml(copy.withStaff.replace('{name}', selectedStaffName()))}</span></div>` 551 : '' 552 } 339 553 ${ 340 554 price !== 'Free' … … 349 563 <input class="form-input" id="bm-runtime-customerName" name="customerName" autocomplete="name" required placeholder="${escapeHtml(copy.namePlaceholder)}" value="${escapeHtml(form.customerName)}" /> 350 564 </div> 565 ${ 566 config.sms?.available === true 567 ? ` 568 <div class="form-field"> 569 <label class="form-label" for="bm-runtime-customerPhoneCountry">${escapeHtml(copy.phoneCountryLabel)}</label> 570 <select class="form-input" id="bm-runtime-customerPhoneCountry" name="customerPhoneCountry" autocomplete="country"> 571 <option value="">${escapeHtml(copy.chooseOption)}</option> 572 ${PHONE_COUNTRIES.map( 573 (iso) => 574 `<option value="${iso}"${form.customerPhoneCountry === iso ? ' selected' : ''}>${iso} (+${escapeHtml(DIAL_CODES[iso])})</option>`, 575 ).join('')} 576 </select> 577 </div> 578 ` 579 : '' 580 } 351 581 <div class="form-field"> 352 582 <label class="form-label" for="bm-runtime-customerPhone">${escapeHtml(copy.phoneLabel)}</label> … … 362 592 <textarea class="form-input form-input--textarea" id="bm-runtime-notes" name="notes" rows="2" maxlength="500" placeholder="${escapeHtml(copy.notesPlaceholder)}">${escapeHtml(form.notes)}</textarea> 363 593 </div> 594 ${questionsMarkup(service)} 595 ${ 596 Number(service?.depositCents) > 0 && config.features?.deposits !== true 597 ? `<p class="form-note deposit-unavailable-note" role="note">${escapeHtml(copy.depositUnavailable)}</p>` 598 : '' 599 } 600 ${ 601 Number(service?.rebookingIntervalWeeks) > 0 602 ? ` 603 <label class="rebooking-optin"> 604 <input type="checkbox" data-rebooking-optin ${form.rebookingOptIn ? 'checked' : ''} /> 605 <span>${escapeHtml(copy.rebookingOptInLabel)}</span> 606 </label> 607 ` 608 : '' 609 } 610 ${ 611 config.sms?.available === true 612 ? ` 613 <label class="rebooking-optin"> 614 <input type="checkbox" data-sms-optin ${form.smsOptIn ? 'checked' : ''} /> 615 <span>${escapeHtml(copy.smsOptInLabel)}</span> 616 </label> 617 ` 618 : '' 619 } 620 ${ 621 config.whatsapp?.available === true 622 ? ` 623 <label class="rebooking-optin"> 624 <input type="checkbox" data-whatsapp-optin ${form.whatsappOptIn ? 'checked' : ''} /> 625 <span>${escapeHtml(copy.whatsappOptInLabel)}</span> 626 </label> 627 ` 628 : '' 629 } 364 630 </div> 631 ` 632 } 633 634 function questionsMarkup(service) { 635 const questions = Array.isArray(service?.bookingQuestions) ? service.bookingQuestions : [] 636 637 if (!questions.length) { 638 return '' 639 } 640 641 return ` 642 <p class="section-title section-title--with-top-gap">${escapeHtml(copy.questionsHeading)}</p> 643 ${questions 644 .map((question) => { 645 const inputId = `bm-runtime-question-${escapeHtml(question.id)}` 646 const value = String(form.answers?.[question.id] || '') 647 const requiredMark = question.required 648 ? ` <span class="form-required" aria-hidden="true">*</span><span class="sr-only"> ${escapeHtml(copy.requiredHint)}</span>` 649 : '' 650 const label = `<label class="form-label" for="${inputId}">${escapeHtml(question.label)}${requiredMark}</label>` 651 652 if (question.type === 'yesno') { 653 return ` 654 <div class="form-field"> 655 ${label} 656 <select class="form-input" id="${inputId}" data-question-id="${escapeHtml(question.id)}"${question.required ? ' required' : ''}> 657 <option value="">${escapeHtml(copy.chooseOption)}</option> 658 <option value="yes"${value === 'yes' ? ' selected' : ''}>${escapeHtml(copy.yesLabel)}</option> 659 <option value="no"${value === 'no' ? ' selected' : ''}>${escapeHtml(copy.noLabel)}</option> 660 </select> 661 </div> 662 ` 663 } 664 665 if (question.type === 'choice') { 666 return ` 667 <div class="form-field"> 668 ${label} 669 <select class="form-input" id="${inputId}" data-question-id="${escapeHtml(question.id)}"${question.required ? ' required' : ''}> 670 <option value="">${escapeHtml(copy.chooseOption)}</option> 671 ${(question.options || []) 672 .map( 673 (option) => 674 `<option value="${escapeHtml(option)}"${value === option ? ' selected' : ''}>${escapeHtml(option)}</option>`, 675 ) 676 .join('')} 677 </select> 678 </div> 679 ` 680 } 681 682 return ` 683 <div class="form-field"> 684 ${label} 685 <input class="form-input" id="${inputId}" data-question-id="${escapeHtml(question.id)}" maxlength="300"${question.required ? ' required' : ''} value="${escapeHtml(value)}" /> 686 </div> 687 ` 688 }) 689 .join('')} 365 690 ` 366 691 } … … 401 726 <div class="summary-box"> 402 727 <div class="summary-row"><span><strong>${escapeHtml(confirmation?.customerName || form.customerName)}</strong></span></div> 403 <div class="summary-row"><span>${escapeHtml(formatSummaryDate(selectedDate ))} ${escapeHtml(copy.at)} ${escapeHtml(slot?.timeLabel || '')}</span></div>728 <div class="summary-row"><span>${escapeHtml(formatSummaryDate(selectedDate, locale))} ${escapeHtml(copy.at)} ${escapeHtml(slot?.timeLabel || '')}</span></div> 404 729 <div class="summary-row"><span>${escapeHtml(service?.name || '')} · ${escapeHtml(formatDuration(service?.durationMinutes))}</span></div> 730 ${ 731 selectedStaffName() 732 ? `<div class="summary-row"><span>${escapeHtml(copy.withStaff.replace('{name}', selectedStaffName()))}</span></div>` 733 : '' 734 } 405 735 ${ 406 736 price !== 'Free' … … 419 749 const emailInput = widget.querySelector('[name="customerEmail"]') 420 750 const phoneInput = widget.querySelector('[name="customerPhone"]') 751 const phoneCountryInput = widget.querySelector('[name="customerPhoneCountry"]') 421 752 const notesInput = widget.querySelector('[name="notes"]') 422 753 … … 424 755 if (emailInput) form.customerEmail = emailInput.value 425 756 if (phoneInput) form.customerPhone = phoneInput.value 757 if (phoneCountryInput) form.customerPhoneCountry = phoneCountryInput.value 426 758 if (notesInput) form.notes = notesInput.value 759 760 widget.querySelectorAll('[data-question-id]').forEach((input) => { 761 const id = input.getAttribute('data-question-id') 762 if (id) { 763 form.answers[id] = input.value 764 } 765 }) 766 767 const optIn = widget.querySelector('[data-rebooking-optin]') 768 if (optIn) { 769 form.rebookingOptIn = optIn.checked === true 770 } 771 const smsOptIn = widget.querySelector('[data-sms-optin]') 772 if (smsOptIn) { 773 form.smsOptIn = smsOptIn.checked === true 774 } 775 const whatsappOptIn = widget.querySelector('[data-whatsapp-optin]') 776 if (whatsappOptIn) { 777 form.whatsappOptIn = whatsappOptIn.checked === true 778 } 427 779 } 428 780 … … 437 789 selectedDate = null 438 790 selectedSlotId = null 791 selectedStaffId = null 439 792 datePageStart = 0 440 793 message = '' … … 442 795 isSubmitting = false 443 796 confirmation = null 797 clientRequestId = newClientRequestId() 798 terminalPaymentReplacementAvailable = false 799 waitlistOpen = false 800 waitlistBusy = false 801 waitlistJoined = false 802 waitlistForm = { customerEmail: '', customerName: '', dateFrom: '', dateTo: '' } 444 803 form = { 804 answers: {}, 445 805 customerEmail: '', 446 806 customerName: '', 447 807 customerPhone: '', 808 customerPhoneCountry: '', 448 809 notes: '', 810 rebookingOptIn: false, 811 smsOptIn: false, 812 whatsappOptIn: false, 449 813 } 450 814 render() … … 502 866 </div> 503 867 </div> 504 ${stepperMarkup(paymentSession ? 2 : step )}868 ${stepperMarkup(paymentSession ? 2 : step, copy)} 505 869 ` 506 870 871 const staffHint = selectedStaffName() 507 872 const serviceHint = 508 873 isCommerce && service && step >= 1 && step < 3 509 ? `<p class="commerce-service-hint">${escapeHtml(service.name)} · ${escapeHtml(formatDuration(service.durationMinutes))} </p>`874 ? `<p class="commerce-service-hint">${escapeHtml(service.name)} · ${escapeHtml(formatDuration(service.durationMinutes))}${staffHint ? ` · ${escapeHtml(copy.withStaff.replace('{name}', staffHint))}` : ''}</p>` 510 875 : '' 511 876 … … 545 910 accent: config.color, 546 911 clientSecret: paymentSession.clientSecret, 912 language: config.language, 547 913 publishableKey: paymentSession.publishableKey, 548 914 }) … … 553 919 }) 554 920 .catch((error) => { 555 if (host.isConnected && paymentSession === session) showPaymentLoadError(host, error, render )921 if (host.isConnected && paymentSession === session) showPaymentLoadError(host, error, render, config.language) 556 922 }) 557 923 } … … 560 926 561 927 function bindEvents(availableDates, slotsByDate) { 928 bindServicePhotoErrors(widget) 562 929 widget.querySelectorAll('[data-service-id]').forEach((button) => { 563 930 button.addEventListener('click', () => { … … 565 932 selectedDate = null 566 933 selectedSlotId = null 934 selectedStaffId = null 567 935 datePageStart = 0 568 936 message = '' 569 937 render() 570 938 }) 939 }) 940 941 // Member chips: pinning re-scopes availability to that calendar; "Any" 942 // (data-staff-id="") restores the union. 943 widget.querySelectorAll('[data-staff-id]').forEach((button) => { 944 button.addEventListener('click', () => { 945 const next = button.getAttribute('data-staff-id') || null 946 if (next === selectedStaffId) { 947 return 948 } 949 950 selectedStaffId = next 951 message = '' 952 messageTone = 'error' 953 datePageStart = 0 954 // A different member is a different booking attempt — never replay 955 // the previous request id against another member's fingerprint. 956 clientRequestId = newClientRequestId() 957 958 const fresh = datedSlots() 959 if (selectedDate && !fresh.availableDates.includes(selectedDate)) { 960 selectedDate = null 961 selectedSlotId = null 962 } else if ( 963 selectedSlotId && 964 !(fresh.slotsByDate.get(selectedDate) || []).some((slot) => slot.id === selectedSlotId) 965 ) { 966 selectedSlotId = null 967 } 968 969 render() 970 scheduleRevealContinue(widget, { 971 containerSelector: '.widget-body-scroll', 972 margins: { top: 8, bottom: 16 }, 973 }) 974 }) 975 }) 976 977 widget.querySelectorAll('.staff-chip-avatar').forEach((image) => { 978 const hide = () => image.remove() 979 image.addEventListener('error', hide, { once: true }) 980 if (image.complete && !image.naturalWidth) hide() 571 981 }) 572 982 … … 633 1043 }) 634 1044 1045 widget.querySelector('[data-action="waitlist-open"]')?.addEventListener('click', () => { 1046 waitlistOpen = true 1047 message = '' 1048 const base = selectedDate || new Date().toISOString().slice(0, 10) 1049 const end = new Date(`${base}T12:00:00`) 1050 end.setDate(end.getDate() + 14) 1051 waitlistForm = { 1052 customerEmail: form.customerEmail, 1053 customerName: form.customerName, 1054 dateFrom: waitlistForm.dateFrom || base, 1055 dateTo: waitlistForm.dateTo || end.toISOString().slice(0, 10), 1056 } 1057 render() 1058 scheduleRevealContinue(widget, { 1059 containerSelector: '.widget-body-scroll', 1060 margins: { top: 8, bottom: 16 }, 1061 }) 1062 }) 1063 1064 widget.querySelectorAll('[data-waitlist-field]').forEach((input) => { 1065 input.addEventListener('input', () => { 1066 const field = input.getAttribute('data-waitlist-field') 1067 if (field) { 1068 waitlistForm[field] = input.value 1069 } 1070 }) 1071 }) 1072 1073 widget.querySelector('[data-action="waitlist-submit"]')?.addEventListener('click', () => { 1074 if (waitlistBusy) return 1075 const service = selectedService() 1076 waitlistBusy = true 1077 message = '' 1078 messageTone = 'error' 1079 render() 1080 submitWaitlistEntry(config, { 1081 customerEmail: waitlistForm.customerEmail, 1082 customerName: waitlistForm.customerName, 1083 dateFrom: waitlistForm.dateFrom, 1084 dateTo: waitlistForm.dateTo, 1085 serviceId: service?.id || service?.name || '', 1086 }) 1087 .then(() => { 1088 waitlistBusy = false 1089 waitlistJoined = true 1090 waitlistOpen = false 1091 render() 1092 }) 1093 .catch((error) => { 1094 waitlistBusy = false 1095 message = error instanceof Error ? error.message : copy.waitlistError 1096 messageTone = 'error' 1097 render() 1098 }) 1099 }) 1100 635 1101 widget.querySelector('[data-action="continue"]')?.addEventListener('click', () => { 636 1102 if (isSubmitting) { … … 695 1161 } 696 1162 1163 const session = paymentSession 1164 const controller = paymentController 697 1165 try { 698 const paymentIntentId = await paymentController.confirm() 699 await confirmBookingPayment(config, { 700 bookingId: paymentSession.bookingId, 1166 const paymentIntentId = session.confirmedPaymentIntentId || await controller.confirm() 1167 if (paymentSession !== session || paymentController !== controller) return 1168 session.confirmedPaymentIntentId = paymentIntentId 1169 const result = await confirmBookingPayment(config, { 1170 bookingId: session.bookingId, 701 1171 paymentIntentId, 702 1172 }) 1173 if (paymentSession !== session || paymentController !== controller) return 1174 if (result.processing || !(result.ok || result.alreadyConfirmed)) throw new Error(copy.paymentError) 703 1175 704 1176 confirmation = { … … 716 1188 render() 717 1189 } catch (error) { 1190 if (paymentSession !== session || paymentController !== controller) return 718 1191 isSubmitting = false 719 1192 message = error instanceof Error ? error.message : copy.genericError … … 739 1212 const service = selectedService() 740 1213 const slot = selectedSlot(slotsByDate) 1214 if (Number(service?.depositCents) > 0 && config.features?.deposits !== true) return 741 1215 742 1216 if (isShopifyCommerce(config)) { … … 751 1225 752 1226 if (!service || !slot || !form.customerName.trim() || !form.customerEmail.trim()) { 1227 return 1228 } 1229 1230 // The opt-in only counts with a number the server can route — the worker 1231 // re-checks this; the early return keeps the error local and free. 1232 if ( 1233 (form.smsOptIn || form.whatsappOptIn) && 1234 !normalizePhoneE164(form.customerPhone, form.customerPhoneCountry || undefined) 1235 ) { 1236 message = 1237 form.whatsappOptIn && !form.smsOptIn 1238 ? copy.phoneRequiredForWhatsapp 1239 : copy.phoneRequiredForSms 1240 messageTone = 'error' 1241 render() 753 1242 return 754 1243 } … … 765 1254 try { 766 1255 const result = await submitBooking(config, { 1256 answers: Object.keys(form.answers || {}).length ? form.answers : undefined, 1257 clientRequestId, 1258 replaceTerminalPayment: terminalPaymentReplacementAvailable || undefined, 1259 publicationRevision: config.publicationRevision, 767 1260 customerEmail: form.customerEmail, 768 1261 customerName: form.customerName, 769 1262 customerPhone: form.customerPhone, 1263 customerPhoneCountry: form.customerPhoneCountry || undefined, 1264 language: config.language || 'en', 770 1265 notes: form.notes, 1266 rebookingOptIn: form.rebookingOptIn || undefined, 771 1267 serviceId: service.id || service.name, 772 1268 serviceName: service.name, 1269 smsOptIn: config.sms?.available === true ? form.smsOptIn || undefined : undefined, 1270 staffId: pinnedStaffId(), 773 1271 startsAt: slot.startsAt, 1272 whatsappOptIn: config.whatsapp?.available === true ? form.whatsappOptIn || undefined : undefined, 774 1273 }) 775 1274 … … 791 1290 } 792 1291 793 const depositSession = getDepositPaymentSession(result )1292 const depositSession = getDepositPaymentSession(result, config.language) 794 1293 if (depositSession) { 1294 terminalPaymentReplacementAvailable = false 795 1295 paymentSession = depositSession 796 1296 confirmation = { … … 819 1319 } catch (error) { 820 1320 isSubmitting = false 821 message = error instanceof Error ? error.message : copy.genericError 1321 terminalPaymentReplacementAvailable = error?.code === 'terminal_payment_replacement_available' 1322 const staffUnavailable = error?.code === 'staff_unavailable' 1323 if (staffUnavailable) { 1324 // The pinned member was just booked (or is unqualified) — send the 1325 // customer back to a freshly computed slot list. 1326 step = 1 1327 selectedSlotId = null 1328 } 1329 message = staffUnavailable 1330 ? copy.staffUnavailable 1331 : terminalPaymentReplacementAvailable 1332 ? copy.replacePaymentNote 1333 : error instanceof Error 1334 ? error.message 1335 : copy.genericError 822 1336 messageTone = 'error' 823 1337 render() 1338 widget.querySelector('.widget-message')?.focus() 824 1339 } 825 1340 } -
bookminto/tags/wp-1.0.5/assets/src/slotEngine.js
r3723811 r3730301 1 const MIN_SLOT_STEP_MINUTES = 15 2 3 function getTimeZoneOffsetMs(utcMs, timeZone) { 4 const formatter = new Intl.DateTimeFormat('en-US', { 5 timeZone, 6 hourCycle: 'h23', 7 year: 'numeric', 8 month: '2-digit', 9 day: '2-digit', 10 hour: '2-digit', 11 minute: '2-digit', 12 second: '2-digit', 13 }) 14 const parts = formatter.formatToParts(new Date(utcMs)) 15 const lookup = {} 16 17 for (const part of parts) { 18 if (part.type !== 'literal') { 19 lookup[part.type] = part.value 20 } 21 } 22 23 const asUtc = Date.UTC( 24 Number(lookup.year), 25 Number(lookup.month) - 1, 26 Number(lookup.day), 27 Number(lookup.hour), 28 Number(lookup.minute), 29 Number(lookup.second), 30 ) 31 32 return asUtc - utcMs 33 } 34 35 function zonedWallTimeToUtc(dateKey, minutes, timeZone) { 36 const [year, month, day] = dateKey.split('-').map(Number) 37 const hour = Math.floor(minutes / 60) 38 const minute = minutes % 60 39 const guess = Date.UTC(year, month - 1, day, hour, minute, 0) 40 const offset = getTimeZoneOffsetMs(guess, timeZone) 41 let utc = guess - offset 42 const correctedOffset = getTimeZoneOffsetMs(utc, timeZone) 43 44 if (correctedOffset !== offset) { 45 utc = guess - correctedOffset 46 } 47 48 return new Date(utc) 49 } 50 51 export function makeWallInstant(dateKey, minutes, timeZone) { 52 if (timeZone) { 53 return zonedWallTimeToUtc(dateKey, minutes, timeZone) 54 } 55 56 const hours = String(Math.floor(minutes / 60)).padStart(2, '0') 57 const mins = String(minutes % 60).padStart(2, '0') 58 59 return new Date(`${dateKey}T${hours}:${mins}:00`) 60 } 61 62 export function timeToMinutes(value) { 63 const match = String(value || '').match(/^(\d{2}):(\d{2})$/) 64 65 if (!match) { 66 return Number.NaN 67 } 68 69 return Number(match[1]) * 60 + Number(match[2]) 70 } 71 72 export function toNonNegativeInteger(value) { 73 if (!Number.isFinite(value)) { 74 return 0 75 } 76 77 return Math.max(0, Math.round(Number(value))) 78 } 79 80 export function getServiceBlockMinutes(service) { 81 return Math.max( 82 1, 83 toNonNegativeInteger(service?.durationMinutes) + 84 toNonNegativeInteger(service?.bufferBeforeMinutes) + 85 toNonNegativeInteger(service?.bufferAfterMinutes), 86 ) 87 } 88 89 export function getSlotStepMinutes(serviceBlockMinutes) { 90 return Math.max(MIN_SLOT_STEP_MINUTES, toNonNegativeInteger(serviceBlockMinutes)) 91 } 92 93 export function flattenAvailabilityRules(availability) { 94 if (!Array.isArray(availability)) { 95 return [] 96 } 97 98 return availability.flatMap((rule) => { 99 if (!rule?.enabled) { 100 return [] 101 } 102 103 const blocks = Array.isArray(rule.blocks) ? rule.blocks : [] 104 105 return blocks 106 .filter((block) => { 107 const start = timeToMinutes(block.startTime ?? block.start) 108 const end = timeToMinutes(block.endTime ?? block.end) 109 110 return Number.isFinite(start) && Number.isFinite(end) && end > start 111 }) 112 .map((block) => ({ 113 endTime: block.endTime ?? block.end, 114 startTime: block.startTime ?? block.start, 115 weekday: Number(rule.weekday), 116 })) 117 }) 118 } 119 120 export function getDateKey(date) { 121 const year = date.getFullYear() 122 const month = String(date.getMonth() + 1).padStart(2, '0') 123 const day = String(date.getDate()).padStart(2, '0') 124 125 return `${year}-${month}-${day}` 126 } 127 128 function buildWindow(dateKey, startTime, endTime, timeZone) { 129 const startMinutes = timeToMinutes(startTime) 130 const endMinutes = timeToMinutes(endTime) 131 132 if ( 133 startMinutes === null || 134 Number.isNaN(startMinutes) || 135 endMinutes === null || 136 Number.isNaN(endMinutes) || 137 endMinutes <= startMinutes 138 ) { 139 return null 140 } 141 142 const start = makeWallInstant(dateKey, startMinutes, timeZone) 143 const end = makeWallInstant(dateKey, endMinutes, timeZone) 144 145 if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime()) || start >= end) { 146 return null 147 } 148 149 return { end, endMinutes, start, startMinutes } 150 } 151 152 function getExceptionsForDate(exceptions, dateKey) { 153 return (exceptions || []).filter((exception) => exception?.date === dateKey) 154 } 155 156 function hasFullDayBlock(exceptions) { 157 return exceptions.some( 158 (exception) => 159 exception.exceptionType === 'block' && 160 (!exception.startTime || !exception.endTime), 161 ) 162 } 163 164 function getDailyWindows(dateKey, weekday, rules, exceptions, timeZone) { 165 const windows = rules 166 .filter((rule) => rule.weekday === weekday) 167 .map((rule) => buildWindow(dateKey, rule.startTime, rule.endTime, timeZone)) 168 .filter(Boolean) 169 170 for (const exception of exceptions) { 171 if (exception.exceptionType !== 'extra_open') { 172 continue 173 } 174 175 const window = buildWindow(dateKey, exception.startTime, exception.endTime, timeZone) 176 177 if (window) { 178 windows.push(window) 179 } 180 } 181 182 return windows.sort((left, right) => left.start.getTime() - right.start.getTime()) 183 } 184 185 function getPartialBlockWindows(dateKey, exceptions, timeZone) { 186 return exceptions 187 .filter( 188 (exception) => 189 exception.exceptionType === 'block' && 190 exception.startTime && 191 exception.endTime, 192 ) 193 .map((exception) => buildWindow(dateKey, exception.startTime, exception.endTime, timeZone)) 194 .filter(Boolean) 195 } 196 197 function isBlockedByWindow(start, end, blockWindows) { 198 return blockWindows.some( 199 (window) => start.getTime() < window.end.getTime() && end.getTime() > window.start.getTime(), 200 ) 201 } 202 203 function fitsInsideAvailability(start, end, availabilityWindows) { 204 return availabilityWindows.some( 205 (window) => 206 start.getTime() >= window.start.getTime() && end.getTime() <= window.end.getTime(), 207 ) 208 } 209 210 function isSlotStartAligned(slotStartMinutes, ruleStartMinutes, stepMinutes) { 211 if (slotStartMinutes < ruleStartMinutes || stepMinutes <= 0) { 212 return false 213 } 214 215 return (slotStartMinutes - ruleStartMinutes) % stepMinutes === 0 216 } 217 218 function toBookedStartSet(bookedStarts) { 219 const set = new Set() 220 221 for (const value of Array.isArray(bookedStarts) ? bookedStarts : []) { 222 const date = value instanceof Date ? value : new Date(value) 223 224 if (!Number.isNaN(date.getTime())) { 225 set.add(date.toISOString()) 226 } 227 } 228 229 return set 230 } 231 232 export function createAvailabilitySlots(input) { 233 const { 234 availability, 235 exceptions = [], 236 limit = 6, 237 now = new Date(), 238 service, 239 daysAhead = 28, 240 formatLabel, 241 bookedStarts = [], 242 timeZone, 243 } = input 244 245 const rules = flattenAvailabilityRules(availability) 246 247 if (!rules.length || !service) { 248 return [] 249 } 250 251 const bookedStartSet = toBookedStartSet(bookedStarts) 252 253 const blockMinutes = getServiceBlockMinutes(service) 254 const stepMinutes = getSlotStepMinutes(blockMinutes) 255 const durationMinutes = toNonNegativeInteger(service.durationMinutes) 256 const bufferBeforeMinutes = toNonNegativeInteger(service.bufferBeforeMinutes) 257 const bufferAfterMinutes = toNonNegativeInteger(service.bufferAfterMinutes) 258 const slots = [] 259 const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()) 260 261 for (let offset = 0; offset < daysAhead && slots.length < limit; offset += 1) { 262 const date = new Date(today) 263 date.setDate(today.getDate() + offset) 264 const dateKey = getDateKey(date) 265 const weekday = date.getDay() 266 const dayExceptions = getExceptionsForDate(exceptions, dateKey) 267 268 if (hasFullDayBlock(dayExceptions)) { 269 continue 270 } 271 272 const availabilityWindows = getDailyWindows(dateKey, weekday, rules, dayExceptions, timeZone) 273 const blockWindows = getPartialBlockWindows(dateKey, dayExceptions, timeZone) 274 275 if (!availabilityWindows.length) { 276 continue 277 } 278 279 for (const window of availabilityWindows) { 280 const windowStartMinutes = window.startMinutes 281 const windowEndMinutes = window.endMinutes 282 const latestStartMinutes = windowEndMinutes - blockMinutes 283 284 for ( 285 let slotStartMinutes = windowStartMinutes; 286 slotStartMinutes <= latestStartMinutes && slots.length < limit; 287 slotStartMinutes += stepMinutes 288 ) { 289 if (!isSlotStartAligned(slotStartMinutes, windowStartMinutes, stepMinutes)) { 290 continue 291 } 292 293 const slotStart = makeWallInstant(dateKey, slotStartMinutes, timeZone) 294 295 const appointmentStart = new Date(slotStart.getTime() + bufferBeforeMinutes * 60_000) 296 const appointmentEnd = new Date( 297 appointmentStart.getTime() + durationMinutes * 60_000, 298 ) 299 const blockEnd = new Date(appointmentEnd.getTime() + bufferAfterMinutes * 60_000) 300 301 if (appointmentStart.getTime() <= now.getTime()) { 302 continue 303 } 304 305 if (bookedStartSet.has(appointmentStart.toISOString())) { 306 continue 307 } 308 309 if ( 310 !fitsInsideAvailability(slotStart, blockEnd, availabilityWindows) || 311 isBlockedByWindow(slotStart, blockEnd, blockWindows) 312 ) { 313 continue 314 } 315 316 slots.push({ 317 capacity: `${durationMinutes} min`, 318 id: `${dateKey}-${slotStartMinutes}-${service.id || service.name || 'service'}`, 319 label: formatLabel(appointmentStart, dateKey), 320 startsAt: appointmentStart.toISOString(), 321 }) 322 } 323 } 324 } 325 326 return slots 327 } 328 329 export function isValidBookingStart(input) { 330 const { availability, exceptions = [], service, startsAt, now = new Date(), timeZone } = input 331 const start = startsAt instanceof Date ? startsAt : new Date(startsAt) 332 333 if (Number.isNaN(start.getTime())) { 334 return false 335 } 336 337 const slots = createAvailabilitySlots({ 338 availability, 339 daysAhead: 1, 340 exceptions, 341 formatLabel: () => '', 342 limit: 500, 343 now, 344 service, 345 timeZone, 346 }) 347 348 return slots.some((slot) => slot.startsAt === start.toISOString()) 349 } 1 // Canonical implementation lives in shared/availabilitySlotEngine.js — the 2 // widget previously carried a verbatim copy that could silently drift. The 3 // WordPress sync vendors the shared file and rewrites this specifier. 4 export * from './availabilitySlotEngine.js' -
bookminto/tags/wp-1.0.5/assets/src/styles.js
r3723811 r3730301 2 2 import { bookingBarModalCss } from './bookingBarModalStyles.js' 3 3 import { bookingBarCss } from './bookingBarStyles.js' 4 import { extensionsCss } from './extensionsStyles.js' 4 5 import { floatingChromeCss } from './floatingChromeStyles.js' 5 6 import { floatingWidgetCss } from './floatingStyles.js' 6 7 import { modalChromeCss } from './modalStyles.js' 8 import { nextTimesCss } from './nextTimesStyles.js' 7 9 8 10 export const widgetCss = … … 12 14 bookingBarCss + 13 15 bookingBarModalCss + 14 bookingBarFlowCss 16 bookingBarFlowCss + 17 nextTimesCss + 18 extensionsCss 15 19 16 20 export function injectStyles(target = document.head) { -
bookminto/tags/wp-1.0.5/assets/widget.js
r3723811 r3730301 1 (function(){let e=`https://api.bookminto.com/v1`,t=[],n=new Intl.DateTimeFormat(`en-US`,{day:`numeric`,month:`short`,weekday:`short`}) ;function r(e){return/^#[0-9a-fA-F]{6}$/.test(String(e||``).trim())?String(e).trim():`#E85D4C`}function i(e,t){return String(e||``).trim()||t}function a(e){return e===`24h`?`24h`:`12h`}function o(e){let t=Number.isFinite(Number(e))?Number(e):30;if(t<60)return`${t} min`;let n=Math.floor(t/60),r=t%60;return r===0?`${n} hr`:`${n}h ${r}m`}function s(e){let t=Number(e);return!Number.isFinite(t)||t===0?`Free`:new Intl.NumberFormat(`en-US`,{currency:`EUR`,maximumFractionDigits:2,minimumFractionDigits:Number.isInteger(t)?0:2,style:`currency`}).format(t)}function c(e){let t=i(e&&e.description,``);return t?`${o(e.durationMinutes)} · ${t}`:o(e.durationMinutes)}function l(e){return String(e).replace(/&/g,`&`).replace(/</g,`<`).replace(/>/g,`>`).replace(/"/g,`"`).replace(/'/g,`'`)}function u(e){return e?`${o(e.durationMinutes)} · ${d(e)}`:`No services available yet`}function d(e){let t=s(e?.price),n=Number(e?.depositEuros??Number(e?.depositCents)/100);return Number.isFinite(n)&&n>0?`${t} · ${s(n)} deposit to confirm`:t}function f(e){return e.timezone?`Times in ${String(e.timezone).replaceAll(`_`,` `)}`:`Times in your local timezone`}function p(e,t){let n=new Intl.DateTimeFormat(`en-US`,{timeZone:t,hourCycle:`h23`,year:`numeric`,month:`2-digit`,day:`2-digit`,hour:`2-digit`,minute:`2-digit`,second:`2-digit`}).formatToParts(new Date(e)),r={};for(let e of n)e.type!==`literal`&&(r[e.type]=e.value);return Date.UTC(Number(r.year),Number(r.month)-1,Number(r.day),Number(r.hour),Number(r.minute),Number(r.second))-e}function m(e,t,n){let[r,i,a]=e.split(`-`).map(Number),o=Math.floor(t/60),s=t%60,c=Date.UTC(r,i-1,a,o,s,0),l=p(c,n),u=c-l,d=p(u,n);return d!==l&&(u=c-d),new Date(u)}function h(e,t,n){if(n)return m(e,t,n);let r=String(Math.floor(t/60)).padStart(2,`0`),i=String(t%60).padStart(2,`0`);return new Date(`${e}T${r}:${i}:00`)}function g(e){let t=String(e||``).match(/^(\d{2}):(\d{2})$/);return t?Number(t[1])*60+Number(t[2]):NaN}function _(e){return Number.isFinite(e)?Math.max(0,Math.round(Number(e))):0}function v(e){return Math.max(1,_(e?.durationMinutes)+_(e?.bufferBeforeMinutes)+_(e?.bufferAfterMinutes))}function y(e){return Math.max(15,_(e))}function b(e){return Array.isArray(e)?e.flatMap(e=>e?.enabled?(Array.isArray(e.blocks)?e.blocks:[]).filter(e=>{let t=g(e.startTime??e.start),n=g(e.endTime??e.end);return Number.isFinite(t)&&Number.isFinite(n)&&n>t}).map(t=>({endTime:t.endTime??t.end,startTime:t.startTime??t.start,weekday:Number(e.weekday)})):[]):[]}function x(e){return`${e.getFullYear()}-${String(e.getMonth()+1).padStart(2,`0`)}-${String(e.getDate()).padStart(2,`0`)}`}function S(e,t,n,r){let i=g(t),a=g(n);if(i===null||Number.isNaN(i)||a===null||Number.isNaN(a)||a<=i)return null;let o=h(e,i,r),s=h(e,a,r);return Number.isNaN(o.getTime())||Number.isNaN(s.getTime())||o>=s?null:{end:s,endMinutes:a,start:o,startMinutes:i}}function C(e,t){return(e||[]).filter(e=>e?.date===t)}function w(e){return e.some(e=>e.exceptionType===`block`&&(!e.startTime||!e.endTime))}function T(e,t,n,r,i){let a=n.filter(e=>e.weekday===t).map(t=>S(e,t.startTime,t.endTime,i)).filter(Boolean);for(let t of r){if(t.exceptionType!==`extra_open`)continue;let n=S(e,t.startTime,t.endTime,i);n&&a.push(n)}return a.sort((e,t)=>e.start.getTime()-t.start.getTime())}function E(e,t,n){return t.filter(e=>e.exceptionType===`block`&&e.startTime&&e.endTime).map(t=>S(e,t.startTime,t.endTime,n)).filter(Boolean)}function D(e,t,n){return n.some(n=>e.getTime()<n.end.getTime()&&t.getTime()>n.start.getTime())}function O(e,t,n){return n.some(n=>e.getTime()>=n.start.getTime()&&t.getTime()<=n.end.getTime())}function k(e,t,n){return e<t||n<=0?!1:(e-t)%n===0}function ee(e){let t=new Set;for(let n of Array.isArray(e)?e:[]){let e=n instanceof Date?n:new Date(n);Number.isNaN(e.getTime())||t.add(e.toISOString())}return t}function A(e){let{availability:t,exceptions:n=[],limit:r=6,now:i=new Date,service:a,daysAhead:o=28,formatLabel:s,bookedStarts:c=[],timeZone:l}=e,u=b(t);if(!u.length||!a)return[];let d=ee(c),f=v(a),p=y(f),m=_(a.durationMinutes),g=_(a.bufferBeforeMinutes),S=_(a.bufferAfterMinutes),A=[],j=new Date(i.getFullYear(),i.getMonth(),i.getDate());for(let e=0;e<o&&A.length<r;e+=1){let t=new Date(j);t.setDate(j.getDate()+e);let o=x(t),c=t.getDay(),_=C(n,o);if(w(_))continue;let v=T(o,c,u,_,l),y=E(o,_,l);if(v.length)for(let e of v){let t=e.startMinutes,n=e.endMinutes-f;for(let e=t;e<=n&&A.length<r;e+=p){if(!k(e,t,p))continue;let n=h(o,e,l),r=new Date(n.getTime()+g*6e4),c=new Date(r.getTime()+m*6e4),u=new Date(c.getTime()+S*6e4);r.getTime()<=i.getTime()||d.has(r.toISOString())||!O(n,u,v)||D(n,u,y)||A.push({capacity:`${m} min`,id:`${o}-${e}-${a.id||a.name||`service`}`,label:s(r,o),startsAt:r.toISOString()})}}}return A}function j(e){let t=String(e||``).match(/^(\d{2}):(\d{2})$/);return t?Number(t[1])*60+Number(t[2]):NaN}function M(e,t){if(t===`24h`)return e;let n=j(e);if(!Number.isFinite(n))return e;let r=Math.floor(n/60),i=n%60,a=r%12||12,o=r>=12?`pm`:`am`;return i===0?`${a}${o}`:`${a}:${String(i).padStart(2,`0`)}${o}`}function N(e){if(!e)return[];try{let t=typeof e==`string`?JSON.parse(e):e;return Array.isArray(t)?t.flatMap(e=>(Array.isArray(e.blocks)?e.blocks:[e]).map((t,n)=>({blocks:[{endTime:i(t.endTime||e.endTime,``),id:i(t.id,`availability-${Number(e.weekday)||0}-${n}`),startTime:i(t.startTime||e.startTime,``)}],enabled:!!e.enabled,id:i(e.id,`availability-${Number(e.weekday)||0}`),weekday:Number(e.weekday)}))).filter(e=>e.enabled&&e.blocks.length>0):[]}catch{return[]}}function P(e){if(!e)return[];try{let t=typeof e==`string`?JSON.parse(e):e;return Array.isArray(t)?t.map(e=>({date:i(e.date,``),endTime:i(e.endTime,``)||void 0,exceptionType:e.exceptionType===`extra_open`?`extra_open`:`block`,id:i(e.id,`exception-${e.date}`),note:i(e.note,``)||void 0,startTime:i(e.startTime,``)||void 0})).filter(e=>e.date):[]}catch{return[]}}function te(e,t){return t?.useCustomAvailability&&Array.isArray(t.availability)&&t.availability.length?N(t.availability):e.availability}function F(e,t){if(!t)return`${String(e.getHours()).padStart(2,`0`)}:${String(e.getMinutes()).padStart(2,`0`)}`;let n=new Intl.DateTimeFormat(`en-US`,{timeZone:t,hourCycle:`h23`,hour:`2-digit`,minute:`2-digit`}).formatToParts(e),r={};for(let e of n)e.type!==`literal`&&(r[e.type]=e.value);return`${r.hour||`00`}:${r.minute||`00`}`}function ne(e,t){try{return new Intl.DateTimeFormat(`en-US`,{timeZone:t,timeZoneName:`short`}).formatToParts(e).find(e=>e.type===`timeZoneName`)?.value||``}catch{return``}}function I(e){if(!e)return n;try{return new Intl.DateTimeFormat(`en-US`,{timeZone:e,day:`numeric`,month:`short`,weekday:`short`})}catch{return n}}function re(e,t){if(!Array.isArray(e?.bookedSlots))return[];let n=t?.id;return e.bookedSlots.filter(e=>!e||typeof e!=`object`?!1:!e.serviceId||!n||e.serviceId===n).map(e=>e.startsAt).filter(Boolean)}function ie(e,t,n=6){if(!t)return[];let r=te(e,t);if(!b(r).length)return[];let i=e.timezone||void 0,a=I(i);return A({availability:r,bookedStarts:re(e,t),exceptions:e.exceptions||[],formatLabel:t=>{let n=i?ne(t,i):``,r=`${a.format(t)} · ${M(F(t,i),e.timeFormat)}`;return n?`${r} ${n}`:r},limit:n,timeZone:i,daysAhead:28,service:{bufferAfterMinutes:Number(t.bufferAfterMinutes)||0,bufferBeforeMinutes:Number(t.bufferBeforeMinutes)||0,durationMinutes:Number(t.durationMinutes)||30,id:t.id,name:t.name}})}function ae(e,t){let n=new Date(e);if(!Number.isFinite(n.getTime()))return``;if(!t)return`${n.getFullYear()}-${String(n.getMonth()+1).padStart(2,`0`)}-${String(n.getDate()).padStart(2,`0`)}`;try{let e=new Intl.DateTimeFormat(`en-CA`,{timeZone:t,year:`numeric`,month:`2-digit`,day:`2-digit`}).formatToParts(n),r=Object.fromEntries(e.filter(e=>e.type!==`literal`).map(e=>[e.type,e.value]));return`${r.year}-${r.month}-${r.day}`}catch{return`${n.getFullYear()}-${String(n.getMonth()+1).padStart(2,`0`)}-${String(n.getDate()).padStart(2,`0`)}`}}function oe(e,t){let n=ie(e,t,200),r=e.timezone||void 0,i=new Map;for(let e of n){let t=ae(e.startsAt,r);t&&(i.has(t)||i.set(t,[]),i.get(t).push({...e,dateKey:t,timeLabel:M(F(new Date(e.startsAt),r),`24h`)}))}return{availableDates:Array.from(i.keys()),slotsByDate:i}}function se(){let e=globalThis.location;if(!e||typeof e!=`object`)return!1;let t=e.hostname,n=e.port;return(t===`localhost`||t===`127.0.0.1`||t===`::1`)&&(n===`5173`||n===`5174`)}function ce(){return se()?`http://127.0.0.1:5174/v1`:e}function L(e){return i(e,ce())}function le(e){return/^#[0-9a-fA-F]{6}$/.test(String(e||``).trim())?String(e).trim():``}function ue(e,t){return e===!0||e===`true`?!0:e===!1||e===`false`?!1:t}function de(e){return Array.isArray(e)?e.map(e=>{if(!e||typeof e!=`object`)return null;let t=i(e.startsAt,``);return t?{serviceId:i(e.serviceId,``),startsAt:t}:null}).filter(Boolean):[]}function fe(e,t){return t===`shopify`||t===`woocommerce`?e.map(e=>({...e,depositCents:0,depositEuros:0})):e}function R(e,t){let n=i(e.commerce,``);return{...e,apiUrl:L(e.apiUrl),bookedSlots:Array.isArray(e.bookedSlots)?e.bookedSlots:[],color:r(e.color),commerce:n,services:fe(e.services,n),loadError:t||``,position:i(e.position,`bottom-right`),showBranding:ue(e.showBranding,!1),title:i(e.title,`Schedule a booking`),timeFormat:a(e.timeFormat),timezone:i(e.timezone,``),trigger:i(e.trigger||e.triggerSelector,``),buttonAlign:i(e.buttonAlign||e.buttonPosition,`left`),variant:n===`shopify`||n===`woocommerce`?`inline`:i(e.variant,`inline`)}}function pe(e){if(!e)return t;try{let n=JSON.parse(e);if(!Array.isArray(n))return t;let r=n.map(e=>({description:i(e.description,``),durationMinutes:Number(e.durationMinutes)||30,id:i(e.id,``),name:i(e.name,``),price:Number(e.price)||0,depositCents:Math.max(0,Number(e.depositCents??Number(e.depositEuros||0)*100)||0),bufferAfterMinutes:Number(e.bufferAfterMinutes)||0,bufferBeforeMinutes:Number(e.bufferBeforeMinutes)||0,useCustomAvailability:!!e.useCustomAvailability,availability:N(e.availability)})).filter(e=>e.name);return r.length>0?r:t}catch{return t}}function me(e,t){let n=i(t,``);if(!n||!Array.isArray(e)||e.length===0)return e;let r=e.filter(e=>i(e.id,``)===n||i(e.name,``)===n);return r.length>0?r:e}function he(e,t){let n=e?e.dataset:{},r=Object.assign({},n,t||{}),a=i(r.service||r.serviceId,``);return{apiUrl:L(r.apiUrl),availability:N(r.availability),color:le(r.color),commerce:i(r.commerce,``),exceptions:P(r.exceptions),position:i(r.position,``),service:a,services:me(Array.isArray(r.services)?r.services:pe(r.services),a),shopifyProduct:i(r.shopifyProduct,``),shopifyShop:i(r.shopifyShop||typeof window<`u`&&window.Shopify&&window.Shopify.shop||``,``),shopifyVariant:i(r.shopifyVariant,``),wooProductId:i(r.wooProductId||r.wooProductid,``),wooAddToCartUrl:i(r.wooAddToCartUrl||r.wooAddToCarturl,``),wooCartNonce:i(r.wooCartNonce||r.wooCartnonce,``),showBranding:ue(r.showBranding,!1),title:i(r.title,``),timeFormat:i(r.timeFormat,``),timezone:i(r.timezone,``),trigger:i(r.trigger||r.triggerSelector,``),buttonAlign:i(r.buttonAlign||r.buttonPosition,``),variant:i(r.variant,``),workspace:i(r.workspace,``)}}function ge(e){let t=e.services.length>0||e.availability.length>0;if(!e.workspace||t||typeof fetch!=`function`)return Promise.resolve(R(e));let n=L(e.apiUrl).replace(/\/$/,``);return fetch(`${n}/widget/${encodeURIComponent(e.workspace)}`,{headers:{Accept:`application/json`}}).then(e=>{if(!e.ok)throw Error(`Widget config unavailable (${e.status})`);return e.json()}).then(t=>{if(t.bookingAllowed===!1)return R(e,`Online booking is temporarily unavailable. Please contact the business directly.`);let n=Array.isArray(t.services)?t.services.map(e=>({availability:N(e.availability),bufferAfterMinutes:Number(e.bufferAfterMinutes)||0,bufferBeforeMinutes:Number(e.bufferBeforeMinutes)||0,description:i(e.description,``),durationMinutes:Number(e.durationMinutes)||30,id:i(e.id,``),name:i(e.name,``),price:Number(e.price)||0,depositCents:Math.max(0,Number(e.depositCents??Number(e.depositEuros||0)*100)||0),useCustomAvailability:!!e.useCustomAvailability})).filter(e=>e.name):[],o=N(t.availability),s=P(t.exceptions),c=me(e.services.length?e.services:n,e.service),l=i(e.commerce,``),u=l===`shopify`||l===`woocommerce`?`inline`:i(t.variant||e.variant,`inline`);return{availability:e.availability.length?e.availability:o,apiUrl:L(e.apiUrl),bookedSlots:de(t.bookedSlots),color:r(t.color||e.color),commerce:l,exceptions:s,position:i(t.position||e.position,`bottom-right`),service:i(e.service,``),services:fe(c,l),shopifyProduct:i(e.shopifyProduct,``),shopifyShop:i(e.shopifyShop,``),shopifyVariant:i(e.shopifyVariant,``),wooProductId:i(e.wooProductId,``),wooAddToCartUrl:i(e.wooAddToCartUrl,``),wooCartNonce:i(e.wooCartNonce,``),showBranding:e.showBranding,title:i(t.title||e.title,`Schedule a booking`),timeFormat:a(t.timeFormat||e.timeFormat),timezone:i(t.timezone||e.timezone,``),trigger:i(t.trigger||e.trigger,``),buttonAlign:i(t.buttonAlign||t.buttonPosition||e.buttonAlign||e.buttonPosition,`left`),variant:u,workspace:e.workspace}}).catch(t=>R(e,t instanceof Error?t.message:`Widget config unavailable`))}function z(e){return!e||typeof e!=`object`?!1:(e.commerce,!!(e.shopifyProduct&&e.shopifyShop&&e.shopifyVariant))}function B(e){return!e||typeof e!=`object`?!1:(e.commerce,!!(e.wooProductId&&e.wooAddToCartUrl&&e.wooCartNonce))}function _e(e){return z(e)||B(e)}function ve(e){try{return new Date(e).toISOString()}catch{return String(e||``)}}function ye(e,{locale:t,timeZone:n}={}){let r=new Date(e);if(Number.isNaN(r.getTime()))return String(e||``);try{return new Intl.DateTimeFormat(t||void 0,{day:`numeric`,hour:`2-digit`,minute:`2-digit`,month:`short`,timeZone:n||void 0,timeZoneName:`short`,weekday:`short`,year:`numeric`}).format(r)}catch{return ve(e)}}async function be({bookingId:e,serviceName:t,startsAt:n,timeZone:r,variantId:i}){let a=Number(String(i).replace(/\D/g,``));if(!Number.isFinite(a)||a<=0)throw Error(`This product is missing a Shopify variant for checkout.`);if(!e)throw Error(`Booking hold could not be created for checkout.`);let o;try{o=await fetch(`/cart/add.js`,{method:`POST`,headers:{Accept:`application/json`,"Content-Type":`application/json`},body:JSON.stringify({items:[{id:a,quantity:1,properties:{_bookminto_booking_id:String(e),_bookminto_starts_at:ve(n),Appointment:ye(n,{timeZone:r}),Service:String(t||`Appointment`)}}]})})}catch{throw Error(`Could not add booking to cart. Please try again.`)}if(!o.ok){let e=await o.json().catch(()=>({}));throw Error(e.description||e.message||`Could not add booking to cart.`)}return window.location.assign(`/checkout`),{redirected:!0}}async function xe({bookingId:e,serviceName:t,startsAt:n,timeZone:r,productId:i,addToCartUrl:a,cartNonce:o}){if(!e)throw Error(`Booking hold could not be created for checkout.`);let s=Number(i);if(!Number.isFinite(s)||s<=0)throw Error(`This product is missing a WooCommerce product id.`);let c=String(a||``).trim();if(!c)throw Error(`WooCommerce cart endpoint is missing.`);let l=new URLSearchParams;l.set(`action`,`bookminto_add_to_cart`),l.set(`nonce`,String(o||``)),l.set(`product_id`,String(s)),l.set(`booking_id`,String(e)),l.set(`appointment`,ye(n,{timeZone:r})),l.set(`service_name`,String(t||`Appointment`));let u;try{u=await fetch(c,{method:`POST`,headers:{Accept:`application/json`,"Content-Type":`application/x-www-form-urlencoded; charset=UTF-8`},body:l,credentials:`same-origin`})}catch{throw Error(`Could not add booking to cart. Please try again.`)}let d=await u.json().catch(()=>({}));if(!u.ok||!d?.success){let e=d?.data?.message||d?.message||`Could not add booking to cart.`;throw Error(e)}let f=d?.data?.checkoutUrl||`/checkout/`;return window.location.assign(f),{redirected:!0}}function Se(e){return String(e.apiUrl||`https://api.bookminto.com/v1`).replace(/\/$/,``)}function Ce(){let e=window.location.hostname;return e===`localhost`||e===`127.0.0.1`||e===`::1`}function we(t){return Se(t)===e.replace(/\/$/,``)}async function Te(e,t){let n=z(e),r=B(e),i=n||r;if(e.previewMode||!e.workspace||typeof fetch!=`function`||Ce()&&we(e)&&!i)return{bookingId:e.previewMode?`preview`:void 0,localOnly:!0,ok:!0};let a;try{a=await fetch(`${Se(e)}/bookings`,{body:JSON.stringify({...t,commerceCheckout:i||void 0,publicKey:e.workspace,shopifyProductGid:n&&e.shopifyProduct||void 0,shopifyShopDomain:n&&e.shopifyShop||void 0,shopifyVariantId:n&&e.shopifyVariant||void 0,wooProductId:r&&e.wooProductId||void 0,siteUrl:window.location.href,source:n?`shopify`:r?`woocommerce`:void 0}),headers:{"Content-Type":`application/json`},method:`POST`})}catch{throw Error(`Booking service unavailable. Please try again.`)}if(!a.ok){let e=await a.json().catch(()=>({}));throw Error(e.error||`Booking could not be created`)}let o=await a.json();return n&&(o.needsCheckout||o.bookingId)?(await be({bookingId:o.bookingId,serviceName:t.serviceName||t.serviceId,startsAt:t.startsAt,timeZone:e.timezone,variantId:e.shopifyVariant}),{...o,redirectedToCheckout:!0}):r&&(o.needsCheckout||o.bookingId)?(await xe({bookingId:o.bookingId,serviceName:t.serviceName||t.serviceId,startsAt:t.startsAt,timeZone:e.timezone,productId:e.wooProductId,addToCartUrl:e.wooAddToCartUrl,cartNonce:e.wooCartNonce}),{...o,redirectedToCheckout:!0}):o}function V(e){return`${e.getUTCFullYear()}${String(e.getUTCMonth()+1).padStart(2,`0`)}${String(e.getUTCDate()).padStart(2,`0`)}T${String(e.getUTCHours()).padStart(2,`0`)}${String(e.getUTCMinutes()).padStart(2,`0`)}${String(e.getUTCSeconds()).padStart(2,`0`)}Z`}function H(e){return String(e||``).replace(/\\/g,`\\\\`).replace(/;/g,`\\;`).replace(/,/g,`\\,`).replace(/\r?\n/g,`\\n`)}function Ee(e){let t=e.startsAt instanceof Date?e.startsAt:new Date(e.startsAt);if(Number.isNaN(t.getTime()))return``;let n=Math.max(1,Number(e.durationMinutes)||30),r=new Date(t.getTime()+n*6e4),i=[`BEGIN:VCALENDAR`,`VERSION:2.0`,`PRODID:-//Bookminto//Booking//EN`,`CALSCALE:GREGORIAN`,`METHOD:PUBLISH`,`BEGIN:VEVENT`,`UID:${`${e.id||Math.random().toString(36).slice(2)}@bookminto`}`,`DTSTAMP:${V(new Date)}`,`DTSTART:${V(t)}`,`DTEND:${V(r)}`,`SUMMARY:${H(e.title)}`];return e.description&&i.push(`DESCRIPTION:${H(e.description)}`),e.location&&i.push(`LOCATION:${H(e.location)}`),i.push(`END:VEVENT`,`END:VCALENDAR`),`data:text/calendar;charset=utf-8,${encodeURIComponent(i.join(`\r2 `))}`}function De(e){let t=e?.payment||e;if(!e?.needsPayment&&!e?.payment)return null;if(!t?.clientSecret||!t?.publishableKey||!(e.bookingId||e.id))throw Error(`A deposit is required, but secure payment could not load. Please try again or contact the business.`);return{bookingId:e.bookingId||e.id,clientSecret:t.clientSecret,depositCents:t.depositCents,publishableKey:t.publishableKey}}function Oe(e,t,n){let r=document.createElement(`p`);r.setAttribute(`role`,`alert`),r.textContent=t instanceof Error?t.message:`Secure payment could not load.`;let i=document.createElement(`button`);i.type=`button`,i.className=`back-btn`,i.textContent=`Try again`,i.addEventListener(`click`,n),e.replaceChildren(r,i)}function ke(){return typeof window>`u`?Promise.reject(Error(`Stripe is unavailable`)):window.Stripe?Promise.resolve(window.Stripe):new Promise((e,t)=>{let n=document.querySelector(`script[data-bookminto-stripe]`);if(n){n.addEventListener(`load`,()=>e(window.Stripe)),n.addEventListener(`error`,()=>t(Error(`Stripe.js failed to load`)));return}let r=document.createElement(`script`);r.src=`https://js.stripe.com/v3/`,r.async=!0,r.dataset.bookmintoStripe=`true`,r.onload=()=>e(window.Stripe),r.onerror=()=>t(Error(`Stripe.js failed to load`)),document.head.appendChild(r)})}async function Ae(e,t){let n=(await ke())(t.publishableKey),r=n.elements({appearance:{theme:`stripe`,variables:{colorPrimary:t.accent||`#E85D4C`,borderRadius:`8px`,fontFamily:`Sora, ui-sans-serif, system-ui, sans-serif`}},clientSecret:t.clientSecret}),i=r.create(`payment`);return i.mount(e),{async confirm(){let e=await n.confirmPayment({elements:r,redirect:`if_required`,confirmParams:{return_url:window.location.href}});if(e.error)throw Error(e.error.message||`Payment failed`);let t=e.paymentIntent;if(!t||t.status!==`succeeded`)throw Error(`Payment is not completed yet`);return t.id},destroy(){try{i.unmount()}catch{}}}}async function je(e,t){let n=String(e.apiUrl||``).replace(/\/$/,``),r=await fetch(`${n}/bookings/confirm-payment`,{body:JSON.stringify({...t,publicKey:e.workspace}),headers:{"Content-Type":`application/json`},method:`POST`});if(!r.ok){let e=await r.json().catch(()=>({}));throw Error(e.error||`Payment confirmation failed`)}return r.json()}function U(e){let t=String(e||``).replace(`#`,``);return/^[0-9a-fA-F]{6}$/.test(t)?{r:Number.parseInt(t.slice(0,2),16),g:Number.parseInt(t.slice(2,4),16),b:Number.parseInt(t.slice(4,6),16)}:null}function Me(e){return Math.max(0,Math.min(255,Math.round(e)))}function W(e,t,n){let r=U(e),i=U(t);if(!r||!i)return e;let a=(e,t)=>Me(e+(t-e)*n);return`#${[a(r.r,i.r),a(r.g,i.g),a(r.b,i.b)].map(e=>e.toString(16).padStart(2,`0`)).join(``)}`}function Ne(e,t){let n=U(e);return n?`rgba(${n.r}, ${n.g}, ${n.b}, ${t})`:`rgba(232, 93, 76, ${t})`}function Pe(e){let t=U(e);if(!t)return`#ffffff`;let n=e=>{let t=e/255;return t<=.04045?t/12.92:((t+.055)/1.055)**2.4};return .2126*n(t.r)+.7152*n(t.g)+.0722*n(t.b)>.42?`#172033`:`#ffffff`}function G(e,t){if(!e)return;if(!t){[`--bm-accent`,`--bm-accent-dark`,`--bm-accent-deep`,`--bm-accent-soft`,`--bm-accent-soft-hover`,`--bm-accent-border`,`--bm-accent-muted`,`--bm-accent-ring`,`--bm-accent-shadow`,`--bm-accent-shadow-hover`,`--bm-accent-contrast`].forEach(t=>e.style.removeProperty(t));return}let n=t,r=W(n,`#000000`,.16),i=W(n,`#000000`,.28),a=W(n,`#ffffff`,.92),o=W(n,`#ffffff`,.97),s=W(n,`#ffffff`,.62),c=W(n,`#ffffff`,.2);e.style.setProperty(`--bm-accent`,n),e.style.setProperty(`--bm-accent-dark`,r),e.style.setProperty(`--bm-accent-deep`,i),e.style.setProperty(`--bm-accent-soft`,a),e.style.setProperty(`--bm-accent-soft-hover`,o),e.style.setProperty(`--bm-accent-border`,s),e.style.setProperty(`--bm-accent-muted`,c),e.style.setProperty(`--bm-accent-ring`,Ne(n,.2)),e.style.setProperty(`--bm-accent-shadow`,Ne(n,.5)),e.style.setProperty(`--bm-accent-shadow-hover`,Ne(n,.65)),e.style.setProperty(`--bm-accent-contrast`,Pe(n))}function Fe(e,t){return e&&t?`${t.label} · ${u(e)}`:`Select a service and time to continue.`}function Ie(e){let t=[[`service`,`Service`],[`time`,`Time`],[`details`,`Details`],[`confirmed`,`Done`]],n=Math.max(0,t.findIndex(([t])=>t===e)),r=t[n][1];return`1 (function(){let e=`https://api.bookminto.com/v1`,t=[],n=new Intl.DateTimeFormat(`en-US`,{day:`numeric`,month:`short`,weekday:`short`}),r={title:`Schedule a booking`,selectServiceAndTime:`Select a service and time to continue.`,servicesLabel:`Services`,serviceSingular:`service`,servicePlural:`services`,selected:`Selected`,choose:`Choose`,timesLabel:`Times`,appointment:`Appointment`,bookingReference:`Booking reference`,done:`Done`,noServiceSelected:`No service selected`,noTimeSelected:`No time selected`,chooseTime:`Choose a time`,viewSetup:`View setup`,paymentLoading:`Loading secure payment…`,paymentError:`Payment could not be confirmed. Try again.`,progressLabel:`Booking progress`,stepLabel:`Step`,stepOf:`of`,widgetStepLabels:[`Service`,`Date`,`Details`,`Confirmed`],chooseService:`Choose an offering`,chooseDate:`Choose a date`,availableTimes:`Available times`,summaryLabel:`Your selection`,totalLabel:`Price`,detailsHeading:`Your details`,previousDates:`Previous dates`,nextDates:`Next dates`,successHeading:`Booking confirmed!`,successNote:`You will receive a confirmation email shortly with your booking details.`,emptySlots:`No times available for this date.`,emptyServices:`No offerings are available right now.`,bookNow:`Book now`,back:`Back`,at:`at`,nameLabel:`Name`,phoneLabel:`Phone`,notesLabel:`Notes`,emailLabel:`Email`,continueFromService:`Select and continue`,continueDefault:`Continue`,checkoutCta:`Continue to checkout`,confirmCta:`Confirm booking`,replacePaymentCta:`Start a new payment`,replacePaymentNote:`The previous payment was canceled. Start a new payment to continue with this same appointment.`,payDepositCta:`Pay deposit`,depositHeading:`Pay deposit to confirm`,depositLabel:`deposit`,depositNote:`Your time is held while you complete payment.`,depositUnavailable:`A deposit is required for this service, but online payment is temporarily unavailable. Please try again later.`,processing:`Processing...`,redirectingToCheckout:`Redirecting to checkout…`,close:`Close`,addToCalendar:`Add to calendar`,widgetFooterPrefix:`Powered by`,widgetFooterBrand:`bookminto`,namePlaceholder:`Jane Smith`,phonePlaceholder:`+1 555 0100`,emailPlaceholder:`jane@company.com`,notesPlaceholder:`Specific questions or context…`,loading:`Loading…`,genericError:`Something went wrong. Please try again.`,closedAttempt:`This booking attempt is closed. Choose a new time to start again.`,loadError:`Booking setup could not be loaded.`,previewOnlyNote:`Preview only — no booking was saved and no email was sent.`,testModeHeading:`Test mode — booking not saved`,testModeNote:`This site is running on a local or test address, so nothing was sent to Bookminto and no confirmation email will arrive.`,questionsHeading:`A few questions`,requiredHint:`required`,yesLabel:`Yes`,noLabel:`No`,chooseOption:`Choose…`,nextTimesLabel:`Next available`,seeAllTimes:`See all times`,noNextTimes:`No upcoming times right now — check all dates.`,recommendedBadge:`Recommended`,waitlistCta:`Notify me when a time opens up`,waitlistHeading:`Join the waitlist`,waitlistNote:`We will email you as soon as a spot opens. First to claim gets it.`,waitlistFrom:`From`,waitlistTo:`Until`,waitlistSubmit:`Keep me posted`,waitlistJoined:`You are on the list. Watch your inbox — a spot can go fast.`,waitlistError:`Could not add you to the list. Please try again.`,rebookingOptInLabel:`Email me an invitation to book again`,phoneCountryLabel:`Country`,phoneRequiredForSms:`Enter a valid phone number to receive text messages.`,phoneRequiredForWhatsapp:`Enter a valid phone number to receive WhatsApp messages.`,smsOptInLabel:`Send me appointment confirmations and reminders by text message.`,whatsappOptInLabel:`Send me appointment confirmations and reminders on WhatsApp.`,depositLoadError:`A deposit is required, but secure payment could not load. Please try again or contact the business.`,paymentLoadFailed:`Secure payment could not load.`,retryCta:`Try again`,staffPickerLabel:`Choose a team member`,anyStaff:`Any team member`,withStaff:`with {name}`,staffUnavailable:`That team member is no longer available at this time. Pick another slot or choose a different member.`,serviceNotBookable:`This service cannot be booked online right now.`},i={...r,title:`Plan een afspraak`,selectServiceAndTime:`Kies een dienst en tijdstip om verder te gaan.`,servicesLabel:`Diensten`,serviceSingular:`dienst`,servicePlural:`diensten`,selected:`Gekozen`,choose:`Kies`,timesLabel:`Tijdstippen`,appointment:`Afspraak`,bookingReference:`Boekingsreferentie`,done:`Klaar`,noServiceSelected:`Geen dienst gekozen`,noTimeSelected:`Geen tijdstip gekozen`,chooseTime:`Kies een tijdstip`,viewSetup:`Bekijk de boekingsopties`,paymentLoading:`Veilig betalen laden…`,paymentError:`De betaling kon niet worden bevestigd. Probeer opnieuw.`,progressLabel:`Voortgang van je boeking`,stepLabel:`Stap`,stepOf:`van`,widgetStepLabels:[`Dienst`,`Datum`,`Gegevens`,`Bevestigd`],chooseService:`Kies een dienst`,chooseDate:`Kies een datum`,availableTimes:`Beschikbare tijden`,summaryLabel:`Jouw keuze`,totalLabel:`Prijs`,detailsHeading:`Jouw gegevens`,previousDates:`Eerdere datums`,nextDates:`Latere datums`,successHeading:`Afspraak bevestigd!`,successNote:`Je ontvangt zo een bevestigingsmail met de details van je afspraak.`,emptySlots:`Geen tijden beschikbaar op deze datum.`,emptyServices:`Er zijn momenteel geen diensten beschikbaar.`,bookNow:`Boek nu`,back:`Terug`,at:`om`,nameLabel:`Naam`,phoneLabel:`Telefoon`,notesLabel:`Opmerkingen`,emailLabel:`E-mail`,continueFromService:`Kies en ga verder`,continueDefault:`Verder`,checkoutCta:`Verder naar afrekenen`,confirmCta:`Bevestig afspraak`,replacePaymentCta:`Start een nieuwe betaling`,replacePaymentNote:`De vorige betaling is geannuleerd. Start een nieuwe betaling om dezelfde afspraak af te ronden.`,payDepositCta:`Betaal voorschot`,depositHeading:`Betaal het voorschot om te bevestigen`,depositLabel:`voorschot`,depositNote:`Je tijdstip wordt vastgehouden terwijl je betaalt.`,depositUnavailable:`Voor deze dienst is een voorschot vereist, maar online betalen is tijdelijk niet beschikbaar. Probeer het later opnieuw.`,processing:`Bezig…`,redirectingToCheckout:`Doorsturen naar afrekenen…`,close:`Sluiten`,addToCalendar:`Zet in agenda`,widgetFooterPrefix:`Mogelijk gemaakt door`,namePlaceholder:`Jan Jansen`,emailPlaceholder:`jan@voorbeeld.be`,notesPlaceholder:`Specifieke vragen of context…`,loading:`Laden…`,genericError:`Er ging iets mis. Probeer het opnieuw.`,closedAttempt:`Deze boekingspoging is afgesloten. Kies een nieuw tijdstip om opnieuw te beginnen.`,loadError:`De boekingssetup kon niet geladen worden.`,previewOnlyNote:`Alleen voorbeeld — er is geen boeking opgeslagen en geen e-mail verstuurd.`,testModeHeading:`Testmodus — boeking niet opgeslagen`,testModeNote:`Deze site draait op een lokale of testomgeving, dus er is niets naar Bookminto verstuurd en er komt geen bevestigingsmail.`,questionsHeading:`Nog een paar vragen`,requiredHint:`verplicht`,yesLabel:`Ja`,noLabel:`Nee`,chooseOption:`Kies…`,nextTimesLabel:`Eerstvolgende`,seeAllTimes:`Alle tijden`,noNextTimes:`Momenteel geen vrije tijden — bekijk alle datums.`,recommendedBadge:`Aanbevolen`,waitlistCta:`Laat het weten als er een plekje vrijkomt`,waitlistHeading:`Zet me op de wachtlijst`,waitlistNote:`We mailen je zodra er een plekje vrijkomt. Wie het eerst bevestigt, krijgt het.`,waitlistFrom:`Vanaf`,waitlistTo:`Tot`,waitlistSubmit:`Houd me op de hoogte`,waitlistJoined:`Je staat op de lijst. Houd je inbox in de gaten — een plekje kan snel weg zijn.`,waitlistError:`Dat lukte niet. Probeer het opnieuw.`,rebookingOptInLabel:`Mail me een uitnodiging om opnieuw te boeken`,phoneCountryLabel:`Land`,phoneRequiredForSms:`Vul een geldig telefoonnummer in om sms’en te ontvangen.`,phoneRequiredForWhatsapp:`Vul een geldig telefoonnummer in om WhatsApp-berichten te ontvangen.`,smsOptInLabel:`Stuur mij bevestigingen en herinneringen van mijn afspraak per sms.`,whatsappOptInLabel:`Stuur mij bevestigingen en herinneringen van mijn afspraak via WhatsApp.`,depositLoadError:`Er is een voorschot vereist, maar de beveiligde betaling kon niet laden. Probeer het opnieuw of neem contact op met het bedrijf.`,paymentLoadFailed:`De beveiligde betaling kon niet laden.`,retryCta:`Opnieuw proberen`,staffPickerLabel:`Kies een teamlid`,anyStaff:`Geen voorkeur`,withStaff:`met {name}`,staffUnavailable:`Dit teamlid is niet meer beschikbaar op dit tijdstip. Kies een ander tijdstip of een ander teamlid.`,serviceNotBookable:`Deze dienst kan momenteel niet online geboekt worden.`},a={...r,title:`Prendre rendez-vous`,selectServiceAndTime:`Choisissez une prestation et un horaire pour continuer.`,servicesLabel:`Prestations`,serviceSingular:`prestation`,servicePlural:`prestations`,selected:`Sélectionné`,choose:`Choisir`,timesLabel:`Horaires`,appointment:`Rendez-vous`,bookingReference:`Référence de réservation`,done:`Terminé`,noServiceSelected:`Aucune prestation sélectionnée`,noTimeSelected:`Aucun horaire sélectionné`,chooseTime:`Choisir un horaire`,viewSetup:`Voir les options de réservation`,paymentLoading:`Chargement du paiement sécurisé…`,paymentError:`Le paiement n’a pas pu être confirmé. Réessayez.`,progressLabel:`Progression de la réservation`,stepLabel:`Étape`,stepOf:`sur`,widgetStepLabels:[`Prestation`,`Date`,`Coordonnées`,`Confirmé`],chooseService:`Choisissez une prestation`,chooseDate:`Choisissez une date`,availableTimes:`Horaires disponibles`,summaryLabel:`Votre sélection`,totalLabel:`Prix`,detailsHeading:`Vos coordonnées`,previousDates:`Dates précédentes`,nextDates:`Dates suivantes`,successHeading:`Rendez-vous confirmé !`,successNote:`Vous recevrez sous peu un e-mail de confirmation avec les détails.`,emptySlots:`Aucun horaire disponible à cette date.`,emptyServices:`Aucune prestation disponible pour le moment.`,bookNow:`Réserver`,back:`Retour`,at:`à`,nameLabel:`Nom`,phoneLabel:`Téléphone`,notesLabel:`Remarques`,emailLabel:`E-mail`,continueFromService:`Choisir et continuer`,continueDefault:`Continuer`,checkoutCta:`Passer au paiement`,confirmCta:`Confirmer le rendez-vous`,replacePaymentCta:`Commencer un nouveau paiement`,replacePaymentNote:`Le paiement précédent a été annulé. Commencez un nouveau paiement pour terminer ce même rendez-vous.`,payDepositCta:`Payer l'acompte`,depositHeading:`Payez l'acompte pour confirmer`,depositLabel:`acompte`,depositNote:`Votre créneau est réservé pendant le paiement.`,depositUnavailable:`Un acompte est requis pour ce service, mais le paiement en ligne est temporairement indisponible. Veuillez réessayer plus tard.`,processing:`Traitement…`,redirectingToCheckout:`Redirection vers le paiement…`,close:`Fermer`,addToCalendar:`Ajouter au calendrier`,widgetFooterPrefix:`Propulsé par`,namePlaceholder:`Marie Dupont`,emailPlaceholder:`marie@exemple.fr`,notesPlaceholder:`Questions ou contexte particulier…`,loading:`Chargement…`,genericError:`Une erreur est survenue. Veuillez réessayer.`,closedAttempt:`Cette tentative de réservation est terminée. Choisissez un nouvel horaire pour recommencer.`,loadError:`La configuration de réservation n'a pas pu être chargée.`,previewOnlyNote:`Aperçu uniquement — aucune réservation enregistrée, aucun e-mail envoyé.`,testModeHeading:`Mode test — réservation non enregistrée`,testModeNote:`Ce site fonctionne sur une adresse locale ou de test : rien n'a été envoyé à Bookminto et aucun e-mail de confirmation n'arrivera.`,questionsHeading:`Quelques questions`,requiredHint:`obligatoire`,yesLabel:`Oui`,noLabel:`Non`,chooseOption:`Choisir…`,nextTimesLabel:`Prochaines disponibilités`,seeAllTimes:`Tous les horaires`,noNextTimes:`Aucun horaire pour le moment — voir toutes les dates.`,recommendedBadge:`Recommandé`,waitlistCta:`Prévenez-moi quand un créneau se libère`,waitlistHeading:`Rejoindre la liste d’attente`,waitlistNote:`Nous vous écrivons dès qu’un créneau se libère. Le premier à confirmer l’obtient.`,waitlistFrom:`À partir du`,waitlistTo:`Jusqu’au`,waitlistSubmit:`Me tenir informé`,waitlistJoined:`Vous êtes sur la liste. Surveillez votre boîte — un créneau part vite.`,waitlistError:`Impossible de vous ajouter. Réessayez.`,rebookingOptInLabel:`M’envoyer une invitation pour réserver à nouveau`,phoneCountryLabel:`Pays`,phoneRequiredForSms:`Indiquez un numéro de téléphone valide pour recevoir des SMS.`,phoneRequiredForWhatsapp:`Indiquez un numéro de téléphone valide pour recevoir des messages WhatsApp.`,smsOptInLabel:`Recevoir les confirmations et rappels de mon rendez-vous par SMS.`,whatsappOptInLabel:`Recevoir les confirmations et rappels de mon rendez-vous par WhatsApp.`,depositLoadError:`Un acompte est requis, mais le paiement sécurisé n’a pas pu se charger. Réessayez ou contactez l’entreprise.`,paymentLoadFailed:`Le paiement sécurisé n’a pas pu se charger.`,retryCta:`Réessayer`,staffPickerLabel:`Choisissez un membre de l’équipe`,anyStaff:`Sans préférence`,withStaff:`avec {name}`,staffUnavailable:`Ce membre de l’équipe n’est plus disponible à cet horaire. Choisissez un autre horaire ou un autre membre.`,serviceNotBookable:`Cette prestation n’est pas réservable en ligne pour le moment.`},o={de:{...r,title:`Termin buchen`,selectServiceAndTime:`Wählen Sie eine Leistung und eine Uhrzeit, um fortzufahren.`,servicesLabel:`Leistungen`,serviceSingular:`Leistung`,servicePlural:`Leistungen`,selected:`Ausgewählt`,choose:`Wählen`,timesLabel:`Uhrzeiten`,appointment:`Termin`,bookingReference:`Buchungsnummer`,done:`Fertig`,noServiceSelected:`Keine Leistung ausgewählt`,noTimeSelected:`Keine Uhrzeit ausgewählt`,chooseTime:`Uhrzeit wählen`,viewSetup:`Buchungsoptionen ansehen`,paymentLoading:`Sichere Zahlung wird geladen…`,paymentError:`Die Zahlung konnte nicht bestätigt werden. Bitte erneut versuchen.`,progressLabel:`Buchungsfortschritt`,stepLabel:`Schritt`,stepOf:`von`,widgetStepLabels:[`Leistung`,`Datum`,`Angaben`,`Bestätigt`],chooseService:`Leistung wählen`,chooseDate:`Datum wählen`,availableTimes:`Verfügbare Zeiten`,summaryLabel:`Ihre Auswahl`,totalLabel:`Preis`,detailsHeading:`Ihre Angaben`,previousDates:`Vorherige Daten`,nextDates:`Nächste Daten`,successHeading:`Buchung bestätigt!`,successNote:`Sie erhalten in Kürze eine Bestätigungs-E-Mail mit Ihren Buchungsdetails.`,emptySlots:`An diesem Datum sind keine Zeiten verfügbar.`,emptyServices:`Derzeit sind keine Leistungen verfügbar.`,bookNow:`Jetzt buchen`,back:`Zurück`,at:`um`,nameLabel:`Name`,phoneLabel:`Telefon`,notesLabel:`Anmerkungen`,emailLabel:`E-Mail`,continueFromService:`Auswählen und weiter`,continueDefault:`Weiter`,checkoutCta:`Weiter zur Kasse`,confirmCta:`Buchung bestätigen`,replacePaymentCta:`Neue Zahlung starten`,replacePaymentNote:`Die vorherige Zahlung wurde abgebrochen. Starten Sie eine neue Zahlung, um denselben Termin abzuschließen.`,payDepositCta:`Anzahlung zahlen`,depositHeading:`Anzahlung zur Bestätigung zahlen`,depositLabel:`Anzahlung`,depositNote:`Ihr Termin bleibt während der Zahlung reserviert.`,depositUnavailable:`Für diese Leistung ist eine Anzahlung erforderlich, Online-Zahlung ist derzeit nicht verfügbar. Bitte versuchen Sie es später erneut.`,processing:`Wird verarbeitet…`,redirectingToCheckout:`Weiterleitung zur Kasse…`,close:`Schließen`,addToCalendar:`Zum Kalender hinzufügen`,widgetFooterPrefix:`Bereitgestellt von`,namePlaceholder:`Maria Müller`,phonePlaceholder:`+49 170 1234567`,emailPlaceholder:`sie@beispiel.de`,notesPlaceholder:`Spezifische Fragen oder Kontext…`,loading:`Wird geladen…`,genericError:`Etwas ist schiefgelaufen. Bitte versuchen Sie es erneut.`,closedAttempt:`Dieser Buchungsversuch ist abgeschlossen. Wählen Sie eine neue Uhrzeit, um erneut zu beginnen.`,loadError:`Die Buchungseinstellungen konnten nicht geladen werden.`,previewOnlyNote:`Nur Vorschau — es wurde keine Buchung gespeichert und keine E-Mail gesendet.`,testModeHeading:`Testmodus — Buchung nicht gespeichert`,testModeNote:`Diese Seite läuft auf einer lokalen oder Testadresse, daher wurde nichts an Bookminto gesendet und es kommt keine Bestätigungs-E-Mail.`,questionsHeading:`Ein paar Fragen`,requiredHint:`Pflichtfeld`,yesLabel:`Ja`,noLabel:`Nein`,chooseOption:`Wählen…`,nextTimesLabel:`Nächste freie Zeiten`,seeAllTimes:`Alle Zeiten`,noNextTimes:`Derzeit keine freien Zeiten — alle Daten ansehen.`,recommendedBadge:`Empfohlen`,waitlistCta:`Benachrichtigen Sie mich, wenn ein Termin frei wird`,waitlistHeading:`Auf die Warteliste`,waitlistNote:`Wir schreiben Ihnen, sobald ein Platz frei wird. Wer zuerst bestätigt, bekommt ihn.`,waitlistFrom:`Von`,waitlistTo:`Bis`,waitlistSubmit:`Auf dem Laufenden halten`,waitlistJoined:`Sie stehen auf der Liste. Behalten Sie Ihr Postfach im Blick — ein Platz ist schnell vergeben.`,waitlistError:`Das hat nicht geklappt. Bitte versuchen Sie es erneut.`,rebookingOptInLabel:`Mir eine Einladung zur erneuten Buchung mailen`,phoneCountryLabel:`Land`,phoneRequiredForSms:`Geben Sie eine gültige Telefonnummer ein, um SMS zu erhalten.`,phoneRequiredForWhatsapp:`Geben Sie eine gültige Telefonnummer ein, um WhatsApp-Nachrichten zu erhalten.`,smsOptInLabel:`Senden Sie mir Terminbestätigungen und -erinnerungen per SMS.`,whatsappOptInLabel:`Senden Sie mir Terminbestätigungen und -erinnerungen per WhatsApp.`,depositLoadError:`Eine Anzahlung ist erforderlich, aber die sichere Zahlung konnte nicht geladen werden. Bitte versuchen Sie es erneut oder kontaktieren Sie den Betrieb.`,paymentLoadFailed:`Die sichere Zahlung konnte nicht geladen werden.`,retryCta:`Erneut versuchen`,staffPickerLabel:`Teammitglied wählen`,anyStaff:`Keine Präferenz`,withStaff:`mit {name}`,staffUnavailable:`Dieses Teammitglied ist zu dieser Zeit nicht mehr verfügbar. Wählen Sie eine andere Uhrzeit oder ein anderes Mitglied.`,serviceNotBookable:`Diese Leistung ist derzeit nicht online buchbar.`},en:r,es:{...r,title:`Reserva una cita`,selectServiceAndTime:`Elige un servicio y una hora para continuar.`,servicesLabel:`Servicios`,serviceSingular:`servicio`,servicePlural:`servicios`,selected:`Seleccionado`,choose:`Elegir`,timesLabel:`Horarios`,appointment:`Cita`,bookingReference:`Referencia de reserva`,done:`Listo`,noServiceSelected:`Ningún servicio seleccionado`,noTimeSelected:`Ninguna hora seleccionada`,chooseTime:`Elige una hora`,viewSetup:`Ver opciones de reserva`,paymentLoading:`Cargando pago seguro…`,paymentError:`No se pudo confirmar el pago. Inténtalo de nuevo.`,progressLabel:`Progreso de la reserva`,stepLabel:`Paso`,stepOf:`de`,widgetStepLabels:[`Servicio`,`Fecha`,`Datos`,`Confirmado`],chooseService:`Elige un servicio`,chooseDate:`Elige una fecha`,availableTimes:`Horarios disponibles`,summaryLabel:`Tu selección`,totalLabel:`Precio`,detailsHeading:`Tus datos`,previousDates:`Fechas anteriores`,nextDates:`Fechas siguientes`,successHeading:`¡Reserva confirmada!`,successNote:`Recibirás en breve un correo de confirmación con los detalles de tu reserva.`,emptySlots:`No hay horarios disponibles en esta fecha.`,emptyServices:`No hay servicios disponibles ahora mismo.`,bookNow:`Reservar ahora`,back:`Volver`,at:`a las`,nameLabel:`Nombre`,phoneLabel:`Teléfono`,notesLabel:`Notas`,emailLabel:`Correo electrónico`,continueFromService:`Elegir y continuar`,continueDefault:`Continuar`,checkoutCta:`Ir al pago`,confirmCta:`Confirmar reserva`,replacePaymentCta:`Iniciar un nuevo pago`,replacePaymentNote:`El pago anterior se canceló. Inicia un nuevo pago para terminar esta misma cita.`,payDepositCta:`Pagar depósito`,depositHeading:`Paga el depósito para confirmar`,depositLabel:`depósito`,depositNote:`Tu horario queda reservado mientras completas el pago.`,depositUnavailable:`Este servicio requiere un depósito, pero el pago online no está disponible ahora. Inténtalo más tarde.`,processing:`Procesando…`,redirectingToCheckout:`Redirigiendo al pago…`,close:`Cerrar`,addToCalendar:`Añadir al calendario`,widgetFooterPrefix:`Con tecnología de`,namePlaceholder:`María García`,phonePlaceholder:`+34 600 123 456`,emailPlaceholder:`tu@ejemplo.es`,notesPlaceholder:`Preguntas o contexto específico…`,loading:`Cargando…`,genericError:`Algo salió mal. Inténtalo de nuevo.`,closedAttempt:`Este intento de reserva está cerrado. Elige una nueva hora para empezar de nuevo.`,loadError:`No se pudo cargar la configuración de reserva.`,previewOnlyNote:`Solo vista previa — no se guardó ninguna reserva ni se envió correo.`,testModeHeading:`Modo prueba — reserva no guardada`,testModeNote:`Este sitio funciona en una dirección local o de prueba, así que no se envió nada a Bookminto y no llegará correo de confirmación.`,questionsHeading:`Unas preguntas`,requiredHint:`obligatorio`,yesLabel:`Sí`,noLabel:`No`,chooseOption:`Elegir…`,nextTimesLabel:`Próximos horarios`,seeAllTimes:`Todos los horarios`,noNextTimes:`No hay horarios próximos ahora — revisa todas las fechas.`,recommendedBadge:`Recomendado`,waitlistCta:`Avísame cuando se libere un hueco`,waitlistHeading:`Únete a la lista de espera`,waitlistNote:`Te escribiremos en cuanto se libere un hueco. El primero en confirmar se lo queda.`,waitlistFrom:`Desde`,waitlistTo:`Hasta`,waitlistSubmit:`Mantenme al tanto`,waitlistJoined:`Ya estás en la lista. Revisa tu correo — un hueco vuela.`,waitlistError:`No se pudo añadirte a la lista. Inténtalo de nuevo.`,rebookingOptInLabel:`Enviarme una invitación por correo para volver a reservar`,phoneCountryLabel:`País`,phoneRequiredForSms:`Introduce un número de teléfono válido para recibir SMS.`,phoneRequiredForWhatsapp:`Introduce un número de teléfono válido para recibir mensajes de WhatsApp.`,smsOptInLabel:`Enviarme confirmaciones y recordatorios de mi cita por SMS.`,whatsappOptInLabel:`Enviarme confirmaciones y recordatorios de mi cita por WhatsApp.`,depositLoadError:`Se requiere un depósito, pero el pago seguro no se pudo cargar. Inténtalo de nuevo o contacta con el negocio.`,paymentLoadFailed:`No se pudo cargar el pago seguro.`,retryCta:`Reintentar`,staffPickerLabel:`Elige un miembro del equipo`,anyStaff:`Sin preferencia`,withStaff:`con {name}`,staffUnavailable:`Ese miembro del equipo ya no está disponible a esa hora. Elige otro horario u otro miembro.`,serviceNotBookable:`Este servicio no se puede reservar online ahora mismo.`},fr:a,it:{...r,title:`Prenota un appuntamento`,selectServiceAndTime:`Scegli un servizio e un orario per continuare.`,servicesLabel:`Servizi`,serviceSingular:`servizio`,servicePlural:`servizi`,selected:`Selezionato`,choose:`Scegli`,timesLabel:`Orari`,appointment:`Appuntamento`,bookingReference:`Riferimento prenotazione`,done:`Fatto`,noServiceSelected:`Nessun servizio selezionato`,noTimeSelected:`Nessun orario selezionato`,chooseTime:`Scegli un orario`,viewSetup:`Vedi le opzioni di prenotazione`,paymentLoading:`Caricamento pagamento sicuro…`,paymentError:`Il pagamento non è stato confermato. Riprova.`,progressLabel:`Avanzamento della prenotazione`,stepLabel:`Passaggio`,stepOf:`di`,widgetStepLabels:[`Servizio`,`Data`,`Dati`,`Confermato`],chooseService:`Scegli un servizio`,chooseDate:`Scegli una data`,availableTimes:`Orari disponibili`,summaryLabel:`La tua selezione`,totalLabel:`Prezzo`,detailsHeading:`I tuoi dati`,previousDates:`Date precedenti`,nextDates:`Date successive`,successHeading:`Prenotazione confermata!`,successNote:`Riceverai a breve un’e-mail di conferma con i dettagli della prenotazione.`,emptySlots:`Nessun orario disponibile in questa data.`,emptyServices:`Nessun servizio disponibile al momento.`,bookNow:`Prenota ora`,back:`Indietro`,at:`alle`,nameLabel:`Nome`,phoneLabel:`Telefono`,notesLabel:`Note`,emailLabel:`E-mail`,continueFromService:`Scegli e continua`,continueDefault:`Continua`,checkoutCta:`Vai al pagamento`,confirmCta:`Conferma prenotazione`,replacePaymentCta:`Avvia un nuovo pagamento`,replacePaymentNote:`Il pagamento precedente è stato annullato. Avvia un nuovo pagamento per completare lo stesso appuntamento.`,payDepositCta:`Paga l’acconto`,depositHeading:`Paga l’acconto per confermare`,depositLabel:`acconto`,depositNote:`Il tuo orario resta riservato mentre completi il pagamento.`,depositUnavailable:`Questo servizio richiede un acconto, ma il pagamento online non è disponibile al momento. Riprova più tardi.`,processing:`Elaborazione…`,redirectingToCheckout:`Reindirizzamento al pagamento…`,close:`Chiudi`,addToCalendar:`Aggiungi al calendario`,widgetFooterPrefix:`Offerto da`,namePlaceholder:`Maria Rossi`,phonePlaceholder:`+39 333 123 4567`,emailPlaceholder:`tu@esempio.it`,notesPlaceholder:`Domande o contesto specifico…`,loading:`Caricamento…`,genericError:`Qualcosa è andato storto. Riprova.`,closedAttempt:`Questo tentativo di prenotazione è chiuso. Scegli un nuovo orario per ricominciare.`,loadError:`Impossibile caricare la configurazione di prenotazione.`,previewOnlyNote:`Solo anteprima — nessuna prenotazione salvata e nessuna e-mail inviata.`,testModeHeading:`Modalità test — prenotazione non salvata`,testModeNote:`Questo sito gira su un indirizzo locale o di test, quindi non è stato inviato nulla a Bookminto e non arriverà alcuna e-mail di conferma.`,questionsHeading:`Qualche domanda`,requiredHint:`obbligatorio`,yesLabel:`Sì`,noLabel:`No`,chooseOption:`Scegli…`,nextTimesLabel:`Prossimi disponibili`,seeAllTimes:`Tutti gli orari`,noNextTimes:`Nessun orario in arrivo al momento — vedi tutte le date.`,recommendedBadge:`Consigliato`,waitlistCta:`Avvisami quando si libera un posto`,waitlistHeading:`Entra in lista d’attesa`,waitlistNote:`Ti scriveremo appena si libera un posto. Chi conferma per primo lo ottiene.`,waitlistFrom:`Dal`,waitlistTo:`Al`,waitlistSubmit:`Tienimi aggiornato`,waitlistJoined:`Sei in lista. Tieni d’occhio la casella — un posto fila via in fretta.`,waitlistError:`Impossibile aggiungerti alla lista. Riprova.`,rebookingOptInLabel:`Inviami un’e-mail con un invito a prenotare di nuovo`,phoneCountryLabel:`Paese`,phoneRequiredForSms:`Inserisci un numero di telefono valido per ricevere gli SMS.`,phoneRequiredForWhatsapp:`Inserisci un numero di telefono valido per ricevere i messaggi WhatsApp.`,smsOptInLabel:`Inviami conferme e promemoria del mio appuntamento via SMS.`,whatsappOptInLabel:`Inviami conferme e promemoria del mio appuntamento su WhatsApp.`,depositLoadError:`È richiesto un acconto, ma il pagamento sicuro non è stato caricato. Riprova o contatta l’attività.`,paymentLoadFailed:`Impossibile caricare il pagamento sicuro.`,retryCta:`Riprova`,staffPickerLabel:`Scegli un membro del team`,anyStaff:`Nessuna preferenza`,withStaff:`con {name}`,staffUnavailable:`Questo membro del team non è più disponibile a quell’orario. Scegli un altro orario o un altro membro.`,serviceNotBookable:`Questo servizio non è prenotabile online al momento.`},nl:i};function s(e){let t=String(e||``).trim().toLowerCase();return Object.hasOwn(o,t)?t:`en`}function c(e){return o[s(e)]}let l={de:`de-DE`,en:`en-US`,es:`es-ES`,fr:`fr-BE`,it:`it-IT`,nl:`nl-BE`};function u(e){return l[s(e)]||`en-US`}function d(e){return/^#[0-9a-fA-F]{6}$/.test(String(e||``).trim())?String(e).trim():`#E85D4C`}function f(e,t){return String(e||``).trim()||t}function p(e){return e===`24h`?`24h`:`12h`}let m={de:`Std.`,en:`hr`,es:`h`,fr:`h`,it:`h`,nl:`u`},h={de:`Kostenlos`,en:`Free`,es:`Gratis`,fr:`Gratuit`,it:`Gratuito`,nl:`Gratis`},g={de:`de-DE`,en:`en-US`,es:`es-ES`,fr:`fr-BE`,it:`it-IT`,nl:`nl-BE`},_={de:`Anzahlung zur Bestätigung`,en:`deposit to confirm`,es:`depósito para confirmar`,fr:`acompte pour confirmer`,it:`acconto per confermare`,nl:`voorschot om te bevestigen`},v={de:[`Zeiten in`,`Zeiten in Ihrer lokalen Zeitzone`],en:[`Times in`,`Times in your local timezone`],es:[`Horarios en`,`Horarios en tu zona horaria`],fr:[`Horaires en`,`Horaires dans votre fuseau horaire`],it:[`Orari in`,`Orari nel tuo fuso orario`],nl:[`Tijden in`,`Tijden in je lokale tijdzone`]};function y(e,t=`en`){let n=Number.isFinite(Number(e))?Number(e):30;if(n<60)return`${n} min`;let r=Math.floor(n/60),i=n%60,a=m[t]||`hr`;return i===0?`${r} ${a}`:`${r}${t===`nl`?`u`:`h`} ${i}m`}function b(e,t=`EUR`,n=`en`){let r=Number(e);return!Number.isFinite(r)||r===0?h[n]||`Free`:new Intl.NumberFormat(g[n]||`en-US`,{currency:t,maximumFractionDigits:2,minimumFractionDigits:Number.isInteger(r)?0:2,style:`currency`}).format(r)}function x(e,t=`en`){let n=f(e&&e.description,``);return n?`${y(e.durationMinutes,t)} · ${n}`:y(e.durationMinutes,t)}function S(e){return String(e).replace(/&/g,`&`).replace(/</g,`<`).replace(/>/g,`>`).replace(/"/g,`"`).replace(/'/g,`'`)}function C(e,t=`en`){return e?`${y(e.durationMinutes,t)} · ${w(e,t)}`:`No services available yet`}function w(e,t=`en`){let n=b(e?.price,e?.currency||`EUR`,t),r=Number(e?.depositEuros??Number(e?.depositCents)/100),i=_[t]||`deposit to confirm`;return Number.isFinite(r)&&r>0?`${n} · ${b(r,e?.currency||`EUR`,t)} ${i}`:n}function ee(e){let t=v[e.language]||v.en;return e.timezone?`${t[0]} ${String(e.timezone).replaceAll(`_`,` `)}`:t[1]}function T(e,t){let n=new Intl.DateTimeFormat(`en-US`,{timeZone:t,hourCycle:`h23`,year:`numeric`,month:`2-digit`,day:`2-digit`,hour:`2-digit`,minute:`2-digit`,second:`2-digit`}).formatToParts(new Date(e)),r={};for(let e of n)e.type!==`literal`&&(r[e.type]=e.value);return Date.UTC(Number(r.year),Number(r.month)-1,Number(r.day),Number(r.hour),Number(r.minute),Number(r.second))-e}function E(e,t){try{let n=new Intl.DateTimeFormat(`en-US`,{timeZone:t,hourCycle:`h23`,year:`numeric`,month:`2-digit`,day:`2-digit`,hour:`2-digit`,minute:`2-digit`}).formatToParts(e),r={};for(let e of n)e.type!==`literal`&&(r[e.type]=e.value);let i=Number(r.hour)*60+Number(r.minute);return!r.year||!r.month||!r.day||!Number.isFinite(i)?null:{dateKey:`${r.year}-${r.month}-${r.day}`,minutes:i}}catch{return null}}function D(e,t,n){let[r,i,a]=e.split(`-`).map(Number),o=Math.floor(t/60),s=t%60,c=Date.UTC(r,i-1,a,o,s,0),l=T(c,n),u=c-l,d=T(u,n);d!==l&&(u=c-d);let f=E(new Date(u),n),p=L(e,Math.floor(t/1440)),m=t%1440;return!f||f.dateKey!==p||f.minutes!==m?new Date(NaN):new Date(u)}function O(e,t,n){if(n)return D(e,t,n);let r=String(Math.floor(t/60)).padStart(2,`0`),i=String(t%60).padStart(2,`0`);return new Date(`${e}T${r}:${i}:00`)}function k(e){let t=String(e||``).match(/^(\d{2}):(\d{2})$/);return t?Number(t[1])*60+Number(t[2]):NaN}function A(e){return Number.isFinite(e)?Math.max(0,Math.round(Number(e))):0}function j(e){return Math.max(1,A(e?.durationMinutes)+A(e?.bufferBeforeMinutes)+A(e?.bufferAfterMinutes))}function M(e){return Math.max(15,A(e))}function N(e){return Array.isArray(e)?e.flatMap(e=>e?.enabled?(Array.isArray(e.blocks)?e.blocks:[]).filter(e=>{let t=k(e.startTime??e.start),n=k(e.endTime??e.end);return Number.isFinite(t)&&Number.isFinite(n)&&n>t}).map(t=>({endTime:t.endTime??t.end,startTime:t.startTime??t.start,weekday:Number(e.weekday)})):[]):[]}function P(e){return`${e.getFullYear()}-${String(e.getMonth()+1).padStart(2,`0`)}-${String(e.getDate()).padStart(2,`0`)}`}function F(e,t){if(!t)return P(e);try{let n=new Intl.DateTimeFormat(`en-CA`,{timeZone:t,year:`numeric`,month:`2-digit`,day:`2-digit`}).formatToParts(e),r={};for(let e of n)e.type!==`literal`&&(r[e.type]=e.value);return!r.year||!r.month||!r.day?P(e):`${r.year}-${r.month}-${r.day}`}catch{return P(e)}}function I(e){let[t,n,r]=e.split(`-`).map(Number);return new Date(Date.UTC(t,n-1,r,12)).getUTCDay()}function L(e,t){let[n,r,i]=e.split(`-`).map(Number),a=new Date(Date.UTC(n,r-1,i+t,12));return`${a.getUTCFullYear()}-${String(a.getUTCMonth()+1).padStart(2,`0`)}-${String(a.getUTCDate()).padStart(2,`0`)}`}function R(e,t,n,r){let i=k(t),a=k(n);if(i===null||Number.isNaN(i)||a===null||Number.isNaN(a)||a<=i)return null;let o=O(e,i,r),s=O(e,a,r);return Number.isNaN(o.getTime())||Number.isNaN(s.getTime())||o>=s?null:{end:s,endMinutes:a,start:o,startMinutes:i}}function te(e,t){return(e||[]).filter(e=>e?.date===t)}function z(e){return e.some(e=>e.exceptionType===`block`&&(!e.startTime||!e.endTime))}function B(e,t,n,r,i){let a=n.filter(e=>e.weekday===t).map(t=>R(e,t.startTime,t.endTime,i)).filter(Boolean);for(let t of r){if(t.exceptionType!==`extra_open`)continue;let n=R(e,t.startTime,t.endTime,i);n&&a.push(n)}return a.sort((e,t)=>e.start.getTime()-t.start.getTime())}function V(e,t,n){return t.filter(e=>e.exceptionType===`block`&&e.startTime&&e.endTime).map(t=>R(e,t.startTime,t.endTime,n)).filter(Boolean)}function ne(e,t,n){return n.some(n=>e.getTime()<n.end.getTime()&&t.getTime()>n.start.getTime())}function H(e,t,n){return n.some(n=>e.getTime()>=n.start.getTime()&&t.getTime()<=n.end.getTime())}function U(e,t,n){return e<t||n<=0?!1:(e-t)%n===0}function re(e){let t=new Set;for(let n of Array.isArray(e)?e:[]){let e=n instanceof Date?n:new Date(n);Number.isNaN(e.getTime())||t.add(e.toISOString())}return t}function W(e){let t=[];for(let n of Array.isArray(e)?e:[]){let e=new Date(n?.start??n?.startsAt),r=new Date(n?.end??n?.endsAt??n?.start??n?.startsAt);!Number.isNaN(e.getTime())&&!Number.isNaN(r.getTime())&&t.push({end:r.getTime(),staffId:typeof n?.staffId==`string`&&n.staffId?n.staffId:null,start:e.getTime()})}return t}function ie({exceptions:e,occupiedRanges:t,rules:n,service:r,staff:i,staffAvailability:a,staffId:o}){let s=(Array.isArray(i)?i:[]).map(e=>typeof e==`string`?e:e?.id).filter(e=>typeof e==`string`&&e),c=Array.isArray(r?.staffIds)?new Set(r.staffIds):null,l=c?s.filter(e=>c.has(e)):s,u=typeof o==`string`&&o?o:null;return u?l.includes(u)?[G(u,e,t,n,a)]:[]:s.length?l.length?l.map(r=>G(r,e,t,n,a)):[]:[{exceptions:e,id:null,occupied:t,rules:n,staffExceptions:[],staffRules:null}]}function G(e,t,n,r,i){let a=i&&typeof i==`object`?i[e]:null;return{exceptions:t,id:e,occupied:n.filter(t=>!t.staffId||t.staffId===e),rules:r,staffExceptions:a&&Array.isArray(a.exceptions)?a.exceptions:[],staffRules:a&&Array.isArray(a.availability)?N(a.availability):null}}function K(e,t){let n=[];for(let r of e)for(let e of t){let t=Math.max(r.startMinutes,e.startMinutes),i=Math.min(r.endMinutes,e.endMinutes);t<i&&n.push({end:r.endMinutes<=e.endMinutes?r.end:e.end,endMinutes:i,start:r.startMinutes>=e.startMinutes?r.start:e.start,startMinutes:t})}return n.sort((e,t)=>e.start.getTime()-t.start.getTime())}function ae(e,t,n,r=3,i=null){let a=new Map;e.forEach(e=>{let t=a.get(e.windowStartMs)||[];t.push(e),a.set(e.windowStartMs,t)});let o=[];for(let e of a.values()){let r=e[0].windowStartMs,i=e[0].windowEndMs,a=t.filter(e=>e.end>r&&e.start<i).sort((e,t)=>e.start-t.start);if(!a.length)continue;let s=a[0].start,c=a[a.length-1].end;for(let t of e){let e=a.reduce((e,n)=>n.end<=t.footprintStartMs&&n.end>e?n.end:e,0),r=a.reduce((e,n)=>n.start>=t.footprintEndMs&&n.start<e?n.start:e,1/0),i=Math.max(c,t.footprintEndMs)-Math.min(s,t.footprintStartMs),l=0;if(e>0){let r=t.footprintStartMs-e;r>0&&r<n&&(l+=1)}if(Number.isFinite(r)){let e=r-t.footprintEndMs;e>0&&e<n&&(l+=1)}o.push({gapPenalty:l,meta:t,spread:i})}}o.sort((e,t)=>e.spread-t.spread||e.gapPenalty-t.gapPenalty||e.meta.footprintStartMs-t.meta.footprintStartMs).slice(0,r).forEach(({meta:e})=>{e.slot.recommended=!0,typeof i==`function`&&i(e)})}function oe(e){return Array.isArray(e?.bookingDates)?e.bookingDates.map(e=>{if(!e||typeof e!=`object`||typeof e.date!=`string`||!/^\d{4}-\d{2}-\d{2}$/.test(e.date))return null;let[t,n,r]=e.date.split(`-`).map(Number),i=new Date(Date.UTC(t,n-1,r,12));if(i.getUTCFullYear()!==t||i.getUTCMonth()!==n-1||i.getUTCDate()!==r)return null;let a=e=>{let t=k(e);return Number.isFinite(t)&&t>=0&&t<=1440?String(e).padStart(5,`0`):null},o=a(e.startTime),s=a(e.endTime);return o===null==(s===null)?{date:e.date,endTime:s,hasExplicitTimes:!!(o&&s),startTime:o}:null}).filter(Boolean):[]}function se(e){let{availability:t,exceptions:n=[],limit:r=6,now:i=new Date,service:a,daysAhead:o,formatLabel:s,bookedStarts:c=[],bookedRanges:l=[],minNoticeMinutes:u=0,preferCompactSchedule:d=!1,staff:f=[],staffAvailability:p,staffId:m,timeZone:h}=e,g=N(t),_=oe(a),v=_.length>0;if(!a||!v&&!g.length)return[];let y=Math.max(1,A(o)||28),b=re(c),x=ie({exceptions:n,occupiedRanges:W(l),rules:g,service:a,staff:f,staffAvailability:p,staffId:m});if(!x.length)return[];let S=j(a),C=M(S),w=A(a.durationMinutes),ee=A(a.bufferBeforeMinutes),T=A(a.bufferAfterMinutes),E=i.getTime()+A(u)*6e4,D=[],k=new Map,P=[],G=F(i,h);for(let e=0;e<y&&D.length<r;e+=1){let e=I(G),t=_.find(e=>e.date===G);for(let n of x){let i=te(n.exceptions,G),o=te(n.staffExceptions,G);if(z(i)||z(o))continue;let c=v?t?t.hasExplicitTimes?[R(G,t.startTime,t.endTime,h)].filter(Boolean):B(G,e,n.rules,i,h):[]:B(G,e,n.rules,i,h);n.staffRules&&(c=K(c,B(G,e,n.staffRules,o,h)));let l=[...V(G,i,h),...V(G,o,h)];if(c.length)for(let e of c){let t=e.startMinutes,i=e.endMinutes,o=O(G,t,h).getTime(),u=O(G,i,h).getTime(),d=i-S;for(let e=t;e<=d&&D.length<r;e+=C){if(!U(e,t,C))continue;let r=O(G,e,h);if(Number.isNaN(r.getTime()))continue;let i=new Date(r.getTime()+ee*6e4),d=new Date(i.getTime()+w*6e4),f=new Date(d.getTime()+T*6e4);if(i.getTime()<=E||n.id===null&&b.has(i.toISOString())||n.occupied.some(e=>r.getTime()<e.end&&f.getTime()>e.start)||!H(r,f,c)||ne(r,f,l))continue;let p=i.toISOString(),m=k.get(p);if(m){n.id&&!m.staffCandidates?.includes(n.id)&&(m.staffCandidates=[...m.staffCandidates||[],n.id]),P.push({footprintEndMs:f.getTime(),footprintStartMs:r.getTime(),memberId:n.id,slot:m,windowEndMs:u,windowStartMs:o});continue}let g={capacity:`${w} min`,id:`${G}-${e}-${a.id||a.name||`service`}`,label:s(i,G),startsAt:p,...n.id?{staffCandidates:[n.id]}:{}};D.push(g),k.set(p,g),P.push({footprintEndMs:f.getTime(),footprintStartMs:r.getTime(),memberId:n.id,slot:g,windowEndMs:u,windowStartMs:o})}}}G=L(G,1)}if(d)for(let e of x)ae(P.filter(t=>t.memberId===e.id),e.occupied,S*6e4,3,t=>{e.id&&(t.slot.compactStaffIds=[...t.slot.compactStaffIds||[],e.id])});return D}function ce(e){let t=String(e||``).match(/^(\d{2}):(\d{2})$/);return t?Number(t[1])*60+Number(t[2]):NaN}function le(e,t){if(t===`24h`)return e;let n=ce(e);if(!Number.isFinite(n))return e;let r=Math.floor(n/60),i=n%60,a=r%12||12,o=r>=12?`pm`:`am`;return i===0?`${a}${o}`:`${a}:${String(i).padStart(2,`0`)}${o}`}function q(e){if(!e)return[];try{let t=typeof e==`string`?JSON.parse(e):e;return Array.isArray(t)?t.flatMap(e=>(Array.isArray(e.blocks)?e.blocks:[e]).map((t,n)=>({blocks:[{endTime:f(t.endTime||e.endTime,``),id:f(t.id,`availability-${Number(e.weekday)||0}-${n}`),startTime:f(t.startTime||e.startTime,``)}],enabled:!!e.enabled,id:f(e.id,`availability-${Number(e.weekday)||0}`),weekday:Number(e.weekday)}))).filter(e=>e.enabled&&e.blocks.length>0):[]}catch{return[]}}function J(e){if(!e)return[];try{let t=typeof e==`string`?JSON.parse(e):e;return Array.isArray(t)?t.map(e=>({date:f(e.date,``),endTime:f(e.endTime,``)||void 0,exceptionType:e.exceptionType===`extra_open`?`extra_open`:`block`,id:f(e.id,`exception-${e.date}`),note:f(e.note,``)||void 0,startTime:f(e.startTime,``)||void 0})).filter(e=>e.date):[]}catch{return[]}}function ue(e,t){return t?.useCustomAvailability&&Array.isArray(t.availability)&&t.availability.length?q(t.availability):e.availability}function de(e,t){if(!t)return`${String(e.getHours()).padStart(2,`0`)}:${String(e.getMinutes()).padStart(2,`0`)}`;let n=new Intl.DateTimeFormat(`en-US`,{timeZone:t,hourCycle:`h23`,hour:`2-digit`,minute:`2-digit`}).formatToParts(e),r={};for(let e of n)e.type!==`literal`&&(r[e.type]=e.value);return`${r.hour||`00`}:${r.minute||`00`}`}function fe(e,t){try{return new Intl.DateTimeFormat(`en-US`,{timeZone:t,timeZoneName:`short`}).formatToParts(e).find(e=>e.type===`timeZoneName`)?.value||``}catch{return``}}function pe(e,t){let r=u(t);if(!e&&r===`en-US`)return n;try{return new Intl.DateTimeFormat(r,{timeZone:e,day:`numeric`,month:`short`,weekday:`short`})}catch{return n}}function me(e){return Array.isArray(e?.bookedSlots)?e.bookedSlots.filter(e=>e&&typeof e==`object`&&e.startsAt).map(e=>({end:e.blockedEnd||e.endsAt||e.startsAt,staffId:e.staffId||null,start:e.blockedStart||e.startsAt})):[]}function he(e,t){let n=(Array.isArray(e?.staff)?e.staff:[]).map(e=>typeof e==`string`?{id:e}:e).filter(e=>e&&typeof e.id==`string`&&e.id);if(!Array.isArray(t?.staffIds))return n;let r=new Set(t.staffIds);return n.filter(e=>r.has(e.id))}function ge(e,t){return Array.isArray(e?.staff)&&e.staff.length>0&&Array.isArray(t?.staffIds)&&he(e,t).length===0}function _e(e,t,n=6,r){if(!t)return[];let i=ue(e,t);if(!N(i).length&&!t.bookingDates?.some(e=>e.startTime&&e.endTime))return[];let a=e.timezone||void 0,o=pe(a,e.language),s=Math.min(Math.max(Number(e.bookingHorizonDays)||28,1),366);return se({availability:i,bookedRanges:me(e),exceptions:e.exceptions||[],formatLabel:t=>{let n=a?fe(t,a):``,r=`${o.format(t)} · ${le(de(t,a),e.timeFormat)}`;return n?`${r} ${n}`:r},limit:n,minNoticeMinutes:Math.max(Number(e.minNoticeMinutes)||0,0),preferCompactSchedule:e.preferCompactSchedule===!0,staff:Array.isArray(e.staff)?e.staff:[],staffAvailability:e.staffAvailability||void 0,staffId:r||void 0,timeZone:a,daysAhead:s,service:{bookingDates:Array.isArray(t.bookingDates)?t.bookingDates:[],bufferAfterMinutes:Number(t.bufferAfterMinutes)||0,bufferBeforeMinutes:Number(t.bufferBeforeMinutes)||0,durationMinutes:Number(t.durationMinutes)||30,id:t.id,name:t.name,staffIds:Array.isArray(t.staffIds)?t.staffIds:t.staffIds===null?null:void 0}})}function ve(e,t){let n=new Date(e);if(!Number.isFinite(n.getTime()))return``;if(!t)return`${n.getFullYear()}-${String(n.getMonth()+1).padStart(2,`0`)}-${String(n.getDate()).padStart(2,`0`)}`;try{let e=new Intl.DateTimeFormat(`en-CA`,{timeZone:t,year:`numeric`,month:`2-digit`,day:`2-digit`}).formatToParts(n),r=Object.fromEntries(e.filter(e=>e.type!==`literal`).map(e=>[e.type,e.value]));return`${r.year}-${r.month}-${r.day}`}catch{return`${n.getFullYear()}-${String(n.getMonth()+1).padStart(2,`0`)}-${String(n.getDate()).padStart(2,`0`)}`}}function ye(e,t,n){let r=_e(e,t,200,n),i=e.timezone||void 0,a=new Map;for(let e of r){let t=ve(e.startsAt,i);t&&(a.has(t)||a.set(t,[]),a.get(t).push({...e,dateKey:t,timeLabel:le(de(new Date(e.startsAt),i),`24h`)}))}return{availableDates:Array.from(a.keys()),slotsByDate:a}}function be(){let e=globalThis.location;if(!e||typeof e!=`object`)return!1;let t=e.hostname,n=e.port;return(t===`localhost`||t===`127.0.0.1`||t===`::1`)&&(n===`5173`||n===`5174`)}function xe(){return be()?`http://127.0.0.1:5174/v1`:e}function Se(e){return f(e,xe())}function Ce(e){return/^#[0-9a-fA-F]{6}$/.test(String(e||``).trim())?String(e).trim():``}function Y(e,t){return e===!0||e===`true`?!0:e===!1||e===`false`?!1:t}function we(e){return Array.isArray(e)?e.map(e=>{if(!e||typeof e!=`object`)return null;let t=f(e.startsAt,``);return t?{blockedEnd:f(e.blockedEnd,``),blockedStart:f(e.blockedStart,``),serviceId:f(e.serviceId,``),staffId:f(e.staffId,``)||null,startsAt:t}:null}).filter(Boolean):[]}function Te(e,t){return t===`shopify`||t===`woocommerce`?e.map(e=>({...e,depositCents:0,depositEuros:0})):e}function Ee(e){let t=f(e,``).toLowerCase().split(/[-_]/)[0];return[`de`,`en`,`es`,`fr`,`it`,`nl`].includes(t)?t:`en`}function De(e){if(e===null)return null;if(Array.isArray(e))return e.map(e=>f(e,``)).filter(Boolean).slice(0,100)}function Oe(e){return Array.isArray(e)?e.map(e=>{if(!e||typeof e!=`object`)return null;let t=f(e.id,``);if(!t)return null;let n=f(e.color,``);return{avatarUrl:Re(e.avatarUrl),color:/^#[0-9a-fA-F]{6}$/.test(n)?n:``,id:t,name:f(e.name,``)}}).filter(Boolean).slice(0,50):[]}function ke(e){if(!e||typeof e!=`object`||Array.isArray(e))return;let t={};for(let[n,r]of Object.entries(e)){let e=f(n,``);!e||!r||typeof r!=`object`||(t[e]={availability:q(r.availability),exceptions:J(r.exceptions)})}return t}function Ae(e){return Array.isArray(e)?e.map((e,t)=>{if(!e||typeof e!=`object`)return null;let n=f(e.label,``),r=[`text`,`choice`,`yesno`].includes(e.type)?e.type:`text`;return n?{id:f(e.id,`q${t+1}`),label:n,options:r===`choice`&&Array.isArray(e.options)?e.options.map(e=>f(e,``)).filter(Boolean).slice(0,8):[],required:e.required===!0,type:r}:null}).filter(Boolean).slice(0,5):[]}function je(e,t){let n=f(e.commerce,``);return{...e,apiUrl:Se(e.apiUrl),bookedSlots:Array.isArray(e.bookedSlots)?e.bookedSlots:[],bookingHorizonDays:Number(e.bookingHorizonDays)||0,color:d(e.color),features:e.features&&typeof e.features==`object`?e.features:{},publicationRevision:String(e.publicationRevision||``),language:Ee(e.language),minNoticeMinutes:Number(e.minNoticeMinutes)||0,nextTimes:Y(e.nextTimes,!1),preferCompactSchedule:Y(e.preferCompactSchedule,!1),selfServiceCutoffHours:Number(e.selfServiceCutoffHours)||0,commerce:n,services:Te(e.services,n),loadError:t||``,position:f(e.position,`bottom-right`),showBranding:Y(e.showBranding,!1),title:f(e.title,c(e.language).title),timeFormat:p(e.timeFormat),timezone:f(e.timezone,``),trigger:f(e.trigger||e.triggerSelector,``),buttonAlign:f(e.buttonAlign||e.buttonPosition,`left`),variant:n===`shopify`||n===`woocommerce`?`inline`:f(e.variant,`inline`)}}function Me(e){if(Object.hasOwn(e,`priceCents`)){let t=e.priceCents;if(typeof t!=`number`||!Number.isInteger(t)||t<0||t>2147483647)throw Error(`Service price unavailable`);return t/100}if(typeof e.price!=`number`||!Number.isFinite(e.price)||e.price<0)throw Error(`Service price unavailable`);return Math.round((e.price+2**-52)*100)/100}function Ne(e){if(!e)return t;try{let n=JSON.parse(e);if(!Array.isArray(n))return t;let r=n.map(e=>({description:f(e.description,``),imageUrl:Re(e.imageUrl),durationMinutes:Number(e.durationMinutes)||30,id:f(e.id,``),name:f(e.name,``),price:Me(e),depositCents:Math.max(0,Number(e.depositCents??Number(e.depositEuros||0)*100)||0),bufferAfterMinutes:Number(e.bufferAfterMinutes)||0,bufferBeforeMinutes:Number(e.bufferBeforeMinutes)||0,useCustomAvailability:!!e.useCustomAvailability,availability:q(e.availability),bookingDates:Array.isArray(e.bookingDates)?e.bookingDates.map(e=>({date:f(e&&e.date,``),endTime:f(e&&e.endTime,``),startTime:f(e&&e.startTime,``)})).filter(e=>/^\d{4}-\d{2}-\d{2}$/.test(e.date)).slice(0,60):[],bookingQuestions:Ae(e.bookingQuestions),rebookingIntervalWeeks:Math.min(52,Math.max(0,Number(e.rebookingIntervalWeeks)||0)),staffIds:De(e.staffIds)})).filter(e=>e.name);return r.length>0?r:t}catch{return t}}function Pe(e,t){let n=f(t,``);if(!n||!Array.isArray(e)||e.length===0)return e;let r=e.filter(e=>f(e.id,``)===n||f(e.name,``)===n);return r.length>0?r:e}function Fe(e,t){let n=e?e.dataset:{},r=Object.assign({},n,t||{}),i=f(r.service||r.serviceId,``);return{apiUrl:Se(r.apiUrl),availability:q(r.availability),color:Ce(r.color),commerce:f(r.commerce,``),exceptions:J(r.exceptions),nextTimes:Y(r.nextTimes,!1),position:f(r.position,``),proposalSlug:f(r.proposalSlug,``),service:i,services:Pe(Array.isArray(r.services)?r.services:Ne(r.services),i),staff:Oe(r.staff),staffAvailability:ke(r.staffAvailability),staffDisplayMode:f(r.staffDisplayMode,``)===`customer_choice`?`customer_choice`:`hidden`,shopifyProduct:f(r.shopifyProduct,``),shopifyShop:f(r.shopifyShop||typeof window<`u`&&window.Shopify&&window.Shopify.shop||``,``),shopifyVariant:f(r.shopifyVariant,``),wooProductId:f(r.wooProductId||r.wooProductid,``),wooAddToCartUrl:f(r.wooAddToCartUrl||r.wooAddToCarturl,``),wooCartNonce:f(r.wooCartNonce||r.wooCartnonce,``),showBranding:Y(r.showBranding,!1),hostedPage:Y(r.hostedPage,!1),title:f(r.title,``),timeFormat:f(r.timeFormat,``),timezone:f(r.timezone,``),trigger:f(r.trigger||r.triggerSelector,``),buttonAlign:f(r.buttonAlign||r.buttonPosition,``),variant:f(r.variant,``),workspace:f(r.workspace,``)}}function Ie(e){let t=e.services.length>0||e.availability.length>0;if(!e.workspace||t||typeof fetch!=`function`)return Promise.resolve(je(e));let n=Se(e.apiUrl).replace(/\/$/,``);return fetch(`${n}/widget/${encodeURIComponent(e.workspace)}`,{headers:{Accept:`application/json`}}).then(e=>{if(!e.ok)throw Error(`Widget config unavailable (${e.status})`);return e.json()}).then(t=>{if(t.bookingAllowed===!1)return je(e,`Online booking is temporarily unavailable. Please contact the business directly.`);let n=Array.isArray(t.services)?t.services.map(e=>({availability:q(e.availability),bufferAfterMinutes:Number(e.bufferAfterMinutes)||0,bufferBeforeMinutes:Number(e.bufferBeforeMinutes)||0,description:f(e.description,``),imageUrl:Re(e.imageUrl),durationMinutes:Number(e.durationMinutes)||30,id:f(e.id,``),name:f(e.name,``),price:Me(e),depositCents:Math.max(0,Number(e.depositCents??Number(e.depositEuros||0)*100)||0),useCustomAvailability:!!e.useCustomAvailability,bookingDates:Array.isArray(e.bookingDates)?e.bookingDates.map(e=>({date:f(e&&e.date,``),endTime:f(e&&e.endTime,``),startTime:f(e&&e.startTime,``)})).filter(e=>/^\d{4}-\d{2}-\d{2}$/.test(e.date)).slice(0,60):[],bookingQuestions:Ae(e.bookingQuestions),rebookingIntervalWeeks:Math.min(52,Math.max(0,Number(e.rebookingIntervalWeeks)||0)),staffIds:De(e.staffIds)})).filter(e=>e.name):[],r=q(t.availability),i=J(t.exceptions),a=Pe(e.services.length?e.services:n,e.service),o=f(e.commerce,``),s=o===`shopify`||o===`woocommerce`||e.hostedPage===!0?`inline`:f(t.variant||e.variant,`inline`),l=Ee(t.language||e.language);return{availability:e.availability.length?e.availability:r,apiUrl:Se(e.apiUrl),bookedSlots:we(t.bookedSlots),calendarVerification:f(t.calendarVerification,`none`),bookingHorizonDays:Number(t.bookingHorizonDays)||0,color:d(t.color||e.color),features:t.features&&typeof t.features==`object`?t.features:{},language:l,publicationRevision:f(t.publicationRevision,``),minNoticeMinutes:Number(t.minNoticeMinutes)||0,nextTimes:Y(e.nextTimes,!1),preferCompactSchedule:Y(t.preferCompactSchedule,!1),selfServiceCutoffHours:Number(t.selfServiceCutoffHours)||0,commerce:o,exceptions:i,position:f(t.position||e.position,`bottom-right`),proposalSlug:f(e.proposalSlug,``),service:f(e.service,``),services:Te(a,o),shopifyProduct:f(e.shopifyProduct,``),shopifyShop:f(e.shopifyShop,``),shopifyVariant:f(e.shopifyVariant,``),wooProductId:f(e.wooProductId,``),wooAddToCartUrl:f(e.wooAddToCartUrl,``),wooCartNonce:f(e.wooCartNonce,``),showBranding:e.showBranding,sms:Le(t.sms),staff:Oe(t.staff),staffAvailability:ke(t.staffAvailability),staffDisplayMode:f(t.staffDisplayMode,``)===`customer_choice`?`customer_choice`:`hidden`,title:f(t.title||e.title,c(t.language||e.language).title),timeFormat:p(t.timeFormat||e.timeFormat),timezone:f(t.timezone||e.timezone,``),trigger:f(t.trigger||e.trigger,``),buttonAlign:f(t.buttonAlign||t.buttonPosition||e.buttonAlign||e.buttonPosition,`left`),variant:s,whatsapp:Le(t.whatsapp),workspace:e.workspace}}).catch(t=>je(e,t instanceof Error?t.message:`Widget config unavailable`))}function Le(e){let t=e&&typeof e==`object`?e:{},n=t.events&&typeof t.events==`object`?t.events:{};return{available:t.available===!0,events:{cancellation:n.cancellation===!0,confirmation:n.confirmation===!0,reminder24h:n.reminder24h===!0,reminder2h:n.reminder2h===!0,reschedule:n.reschedule===!0},textVersion:f(t.textVersion,``)||void 0}}function Re(e){if(typeof e!=`string`||e.length>2048)return``;try{let t=new URL(e);return t.protocol===`https:`&&!t.username&&!t.password?t.href:``}catch{return``}}function ze(e){if(typeof e!=`string`||!e.startsWith(`data:image/webp;base64,`)||e.length>65559)return``;let t=e.slice(23);if(!/^[A-Za-z0-9+/]+={0,2}$/.test(t)||t.length%4!=0)return``;try{let n=atob(t);return n.length<30||n.slice(0,4)!==`RIFF`||n.slice(8,12)!==`WEBP`?``:((n.charCodeAt(4)|n.charCodeAt(5)<<8|n.charCodeAt(6)<<16|n.charCodeAt(7)<<24)>>>0)+8===n.length?e:``}catch{return``}}function Be(e,t=!1){let n=Re(e)||(t===!0?ze(e):``);return n?`<img class="service-photo" src="${S(n)}" width="44" height="44" alt="" loading="lazy" decoding="async" referrerpolicy="no-referrer">`:``}function Ve(e){e.querySelectorAll(`.service-photo`).forEach(e=>{let t=()=>e.remove();e.addEventListener(`error`,t,{once:!0}),e.complete&&!e.naturalWidth&&t()})}function X(e){return!e||typeof e!=`object`?!1:(e.commerce,!!(e.shopifyProduct&&e.shopifyShop&&e.shopifyVariant))}function Z(e){return!e||typeof e!=`object`?!1:(e.commerce,!!(e.wooProductId&&e.wooAddToCartUrl&&e.wooCartNonce))}function He(e){return X(e)||Z(e)}function Ue(e){try{return new Date(e).toISOString()}catch{return String(e||``)}}function We(e,{locale:t,timeZone:n}={}){let r=new Date(e);if(Number.isNaN(r.getTime()))return String(e||``);try{return new Intl.DateTimeFormat(t||void 0,{day:`numeric`,hour:`2-digit`,minute:`2-digit`,month:`short`,timeZone:n||void 0,timeZoneName:`short`,weekday:`short`,year:`numeric`}).format(r)}catch{return Ue(e)}}async function Ge({bookingId:e,serviceName:t,startsAt:n,timeZone:r,variantId:i}){let a=Number(String(i).replace(/\D/g,``));if(!Number.isFinite(a)||a<=0)throw Error(`This product is missing a Shopify variant for checkout.`);if(!e)throw Error(`Booking hold could not be created for checkout.`);let o;try{o=await fetch(`/cart/add.js`,{method:`POST`,headers:{Accept:`application/json`,"Content-Type":`application/json`},body:JSON.stringify({items:[{id:a,quantity:1,properties:{_bookminto_booking_id:String(e),_bookminto_starts_at:Ue(n),Appointment:We(n,{timeZone:r}),Service:String(t||`Appointment`)}}]})})}catch{throw Error(`Could not add booking to cart. Please try again.`)}if(!o.ok){let e=await o.json().catch(()=>({}));throw Error(e.description||e.message||`Could not add booking to cart.`)}return window.location.assign(`/checkout`),{redirected:!0}}async function Ke({bookingId:e,serviceName:t,startsAt:n,timeZone:r,productId:i,addToCartUrl:a,cartNonce:o}){if(!e)throw Error(`Booking hold could not be created for checkout.`);let s=Number(i);if(!Number.isFinite(s)||s<=0)throw Error(`This product is missing a WooCommerce product id.`);let c=String(a||``).trim();if(!c)throw Error(`WooCommerce cart endpoint is missing.`);let l=new URLSearchParams;l.set(`action`,`bookminto_add_to_cart`),l.set(`nonce`,String(o||``)),l.set(`product_id`,String(s)),l.set(`booking_id`,String(e)),l.set(`appointment`,We(n,{timeZone:r})),l.set(`service_name`,String(t||`Appointment`));let u;try{u=await fetch(c,{method:`POST`,headers:{Accept:`application/json`,"Content-Type":`application/x-www-form-urlencoded; charset=UTF-8`},body:l,credentials:`same-origin`})}catch{throw Error(`Could not add booking to cart. Please try again.`)}let d=await u.json().catch(()=>({}));if(!u.ok||!d?.success){let e=d?.data?.message||d?.message||`Could not add booking to cart.`;throw Error(e)}let f=qe(d?.data?.checkoutUrl)||`/checkout/`;return window.location.assign(f),{redirected:!0}}function qe(e){if(typeof e!=`string`||!e)return``;try{let t=new URL(e,window.location.href);return t.protocol===`https:`||t.protocol===`http:`?t.href:``}catch{return``}}function Je(e){return String(e.apiUrl||`https://api.bookminto.com/v1`).replace(/\/$/,``)}function Ye(){let e=window.location.hostname;return e===`localhost`||e===`127.0.0.1`||e===`::1`}function Xe(t){return Je(t)===e.replace(/\/$/,``)}async function Ze(e,t){let n=X(e),r=Z(e),i=n||r;if(e.previewMode||!e.workspace||typeof fetch!=`function`||Ye()&&Xe(e)&&!i)return{bookingId:e.previewMode?`preview`:void 0,localOnly:!0,ok:!0};let a;try{a=await fetch(`${Je(e)}/bookings`,{body:JSON.stringify({...t,commerceCheckout:i||void 0,publicKey:e.workspace,proposalSlug:e.proposalSlug||void 0,shopifyProductGid:n&&e.shopifyProduct||void 0,shopifyShopDomain:n&&e.shopifyShop||void 0,shopifyVariantId:n&&e.shopifyVariant||void 0,wooProductId:r&&e.wooProductId||void 0,siteUrl:window.location.href,source:n?`shopify`:r?`woocommerce`:void 0}),headers:{"Content-Type":`application/json`},method:`POST`})}catch{throw Error(`Booking service unavailable. Please try again.`)}if(!a.ok){let e=await a.json().catch(()=>({})),t=Error(e.error||`Booking could not be created`);throw t.code=e.code,t}let o=await a.json();if(o.ok!==!0||o.holdExpired||[`cancelled`,`expired`].includes(o.status))throw Error(o.error||c(e.language).closedAttempt);return n&&o.needsCheckout===!0?(await Ge({bookingId:o.bookingId,serviceName:t.serviceName||t.serviceId,startsAt:t.startsAt,timeZone:e.timezone,variantId:e.shopifyVariant}),{...o,redirectedToCheckout:!0}):r&&o.needsCheckout===!0?(await Ke({bookingId:o.bookingId,serviceName:t.serviceName||t.serviceId,startsAt:t.startsAt,timeZone:e.timezone,productId:e.wooProductId,addToCartUrl:e.wooAddToCartUrl,cartNonce:e.wooCartNonce}),{...o,redirectedToCheckout:!0}):o}async function Qe(e,t){if(e.previewMode||!e.workspace||typeof fetch!=`function`||Ye()&&Xe(e))return{ok:!0,localOnly:!0};let n=await fetch(`${Je(e)}/waitlist`,{body:JSON.stringify({bookingId:t.bookingId||void 0,customerEmail:t.customerEmail,customerName:t.customerName,dateFrom:t.dateFrom,dateTo:t.dateTo,language:e.language,publicKey:e.workspace,serviceId:t.serviceId,source:X(e)?`shopify`:Z(e)?`woocommerce`:void 0,shopifyShopDomain:X(e)?e.shopifyShop:void 0,shopifyProductGid:X(e)?e.shopifyProduct:void 0,shopifyVariantId:X(e)?String(e.shopifyVariant):void 0,wooProductId:Z(e)?String(e.wooProductId):void 0,siteUrl:window.location.href}),headers:{"Content-Type":`application/json`},method:`POST`}).catch(()=>null);if(!n)throw Error(`Booking service unavailable. Please try again.`);let r=await n.json().catch(()=>({}));if(!n.ok)throw Error(r.error||`Could not join the list`);return r}function $e(e){return`${e.getUTCFullYear()}${String(e.getUTCMonth()+1).padStart(2,`0`)}${String(e.getUTCDate()).padStart(2,`0`)}T${String(e.getUTCHours()).padStart(2,`0`)}${String(e.getUTCMinutes()).padStart(2,`0`)}${String(e.getUTCSeconds()).padStart(2,`0`)}Z`}function et(e){return String(e||``).replace(/\\/g,`\\\\`).replace(/;/g,`\\;`).replace(/,/g,`\\,`).replace(/\r?\n/g,`\\n`)}function tt(e){let t=e.startsAt instanceof Date?e.startsAt:new Date(e.startsAt);if(Number.isNaN(t.getTime()))return``;let n=Math.max(1,Number(e.durationMinutes)||30),r=new Date(t.getTime()+n*6e4),i=[`BEGIN:VCALENDAR`,`VERSION:2.0`,`PRODID:-//Bookminto//Booking//EN`,`CALSCALE:GREGORIAN`,`METHOD:PUBLISH`,`BEGIN:VEVENT`,`UID:${`${e.id||Math.random().toString(36).slice(2)}@bookminto`}`,`DTSTAMP:${$e(new Date)}`,`DTSTART:${$e(t)}`,`DTEND:${$e(r)}`,`SUMMARY:${et(e.title)}`];return e.description&&i.push(`DESCRIPTION:${et(e.description)}`),e.location&&i.push(`LOCATION:${et(e.location)}`),i.push(`END:VEVENT`,`END:VCALENDAR`),`data:text/calendar;charset=utf-8,${encodeURIComponent(i.join(`\r 2 `))}`}function nt(e,t){let n=c(t),r=e?.payment||e;if(!e?.needsPayment&&!e?.payment)return null;if(!r?.clientSecret||!r?.publishableKey||!(e.bookingId||e.id))throw Error(n.depositLoadError);return{bookingId:e.bookingId||e.id,clientSecret:r.clientSecret,depositCents:r.depositCents,publishableKey:r.publishableKey}}function rt(e,t,n,r){let i=c(r),a=document.createElement(`p`);a.setAttribute(`role`,`alert`),a.textContent=t instanceof Error?t.message:i.paymentLoadFailed;let o=document.createElement(`button`);o.type=`button`,o.className=`back-btn`,o.textContent=i.retryCta,o.addEventListener(`click`,n),e.replaceChildren(a,o)}function it(){return typeof window>`u`?Promise.reject(Error(`Stripe is unavailable`)):window.Stripe?Promise.resolve(window.Stripe):new Promise((e,t)=>{let n=document.querySelector(`script[data-bookminto-stripe]`);if(n){n.addEventListener(`load`,()=>e(window.Stripe)),n.addEventListener(`error`,()=>t(Error(`Stripe.js failed to load`)));return}let r=document.createElement(`script`);r.src=`https://js.stripe.com/v3/`,r.async=!0,r.dataset.bookmintoStripe=`true`,r.onload=()=>e(window.Stripe),r.onerror=()=>t(Error(`Stripe.js failed to load`)),document.head.appendChild(r)})}async function at(e,t){let n=c(t.language),r=(await it())(t.publishableKey),i=r.elements({appearance:{theme:`stripe`,variables:{colorPrimary:t.accent||`#E85D4C`,borderRadius:`8px`,fontFamily:`Sora, ui-sans-serif, system-ui, sans-serif`}},clientSecret:t.clientSecret,locale:t.language||void 0}),a=i.create(`payment`),o=typeof e.getRootNode==`function`?e.getRootNode():null,s=typeof ShadowRoot<`u`&&o instanceof ShadowRoot&&o.host,l=null;if(s){l=document.createElement(`div`),l.setAttribute(`slot`,`bm-payment-element`),l.style.cssText=`position: relative; margin: 8px 0 4px; min-height: 120px; contain: paint; clip-path: inset(0);`;let t=e.parentElement;for(;t&&t!==o;){let n=getComputedStyle(t).overflowY;if(n===`auto`||n===`scroll`||n===`hidden`){let n=t.getBoundingClientRect().bottom-e.getBoundingClientRect().top;l.style.maxHeight=`${Math.max(140,Math.floor(n))}px`,l.style.overflowY=`auto`;break}t=t.parentElement}o.host.appendChild(l)}let u=l||e,d=()=>{l&&=(l.remove(),null)};try{a.mount(u),typeof a.on==`function`&&await new Promise((e,t)=>{let r=()=>{try{a.off(`ready`,i),a.off(`loaderror`,o)}catch{}},i=()=>{r(),e()},o=e=>{r(),t(Error(e?.error?.message||n.paymentLoadFailed))};a.on(`ready`,i),a.on(`loaderror`,o)})}catch(e){throw d(),e}return{async confirm(){let e=await r.confirmPayment({elements:i,redirect:`if_required`,confirmParams:{return_url:window.location.href}});if(e.error)throw Error(e.error.message||n.paymentError);let t=e.paymentIntent;if(!t||t.status!==`succeeded`)throw Error(n.paymentError);return t.id},destroy(){try{a.unmount()}catch{}d()}}}async function ot(e,t){let n=String(e.apiUrl||``).replace(/\/$/,``),r=await fetch(`${n}/bookings/confirm-payment`,{body:JSON.stringify({...t,publicKey:e.workspace}),headers:{"Content-Type":`application/json`},method:`POST`});if(!r.ok){let t=await r.json().catch(()=>({}));throw Error(t.error||c(e.language).paymentError)}return r.json()}function st(e){let t=String(e||``).replace(`#`,``);return/^[0-9a-fA-F]{6}$/.test(t)?{r:Number.parseInt(t.slice(0,2),16),g:Number.parseInt(t.slice(2,4),16),b:Number.parseInt(t.slice(4,6),16)}:null}function ct(e){return Math.max(0,Math.min(255,Math.round(e)))}function lt(e,t,n){let r=st(e),i=st(t);if(!r||!i)return e;let a=(e,t)=>ct(e+(t-e)*n);return`#${[a(r.r,i.r),a(r.g,i.g),a(r.b,i.b)].map(e=>e.toString(16).padStart(2,`0`)).join(``)}`}function ut(e,t){let n=st(e);return n?`rgba(${n.r}, ${n.g}, ${n.b}, ${t})`:`rgba(232, 93, 76, ${t})`}function dt(e){let t=st(e);if(!t)return`#ffffff`;let n=e=>{let t=e/255;return t<=.04045?t/12.92:((t+.055)/1.055)**2.4};return .2126*n(t.r)+.7152*n(t.g)+.0722*n(t.b)>.42?`#172033`:`#ffffff`}function Q(e,t){if(!e)return;if(!t){[`--bm-accent`,`--bm-accent-dark`,`--bm-accent-deep`,`--bm-accent-soft`,`--bm-accent-soft-hover`,`--bm-accent-border`,`--bm-accent-muted`,`--bm-accent-ring`,`--bm-accent-shadow`,`--bm-accent-shadow-hover`,`--bm-accent-contrast`].forEach(t=>e.style.removeProperty(t));return}let n=t,r=lt(n,`#000000`,.16),i=lt(n,`#000000`,.28),a=lt(n,`#ffffff`,.92),o=lt(n,`#ffffff`,.97),s=lt(n,`#ffffff`,.62),c=lt(n,`#ffffff`,.2);e.style.setProperty(`--bm-accent`,n),e.style.setProperty(`--bm-accent-dark`,r),e.style.setProperty(`--bm-accent-deep`,i),e.style.setProperty(`--bm-accent-soft`,a),e.style.setProperty(`--bm-accent-soft-hover`,o),e.style.setProperty(`--bm-accent-border`,s),e.style.setProperty(`--bm-accent-muted`,c),e.style.setProperty(`--bm-accent-ring`,ut(n,.2)),e.style.setProperty(`--bm-accent-shadow`,ut(n,.5)),e.style.setProperty(`--bm-accent-shadow-hover`,ut(n,.65)),e.style.setProperty(`--bm-accent-contrast`,dt(n))}let ft={AT:`43`,BE:`32`,CH:`41`,DE:`49`,ES:`34`,FR:`33`,GB:`44`,IE:`353`,IT:`39`,LU:`352`,NL:`31`,PT:`351`},pt=Object.keys(ft).sort(),mt=/^\+[1-9]\d{6,14}$/;function ht(e){return String(e||``).replace(/[^\d]/g,``)}function gt(e,t){let n=String(e||``).trim();if(!n)return null;let r;if(n.startsWith(`+`))r=ht(n);else if(n.startsWith(`00`))r=ht(n.slice(2));else{let e=ft[String(t||``).toUpperCase()];if(!e)return null;let i=ht(n);if(i.startsWith(`0`)&&(i=i.slice(1)),!i)return null;r=e+i}let i=`+${r}`;return mt.test(i)?i:null}function _t(e,t,n,r){return e&&t?`${t.label} · ${C(e,r)}`:n.selectServiceAndTime}function vt(e,t){let n=[[`service`,t.widgetStepLabels[0]],[`time`,t.widgetStepLabels[1]],[`details`,t.widgetStepLabels[2]],[`confirmed`,t.widgetStepLabels[3]]],r=Math.max(0,n.findIndex(([t])=>t===e)),i=n[r][1];return` 3 3 <div 4 4 class="bw-booking-modal__stepper" 5 5 role="progressbar" 6 aria-label=" Booking progress"6 aria-label="${S(t.progressLabel)}" 7 7 aria-valuemin="1" 8 aria-valuemax="${ t.length}"9 aria-valuenow="${ n+1}"10 aria-valuetext=" Step ${n+1} of ${t.length}: ${r}"8 aria-valuemax="${n.length}" 9 aria-valuenow="${r+1}" 10 aria-valuetext="${S(t.stepLabel)} ${r+1} ${S(t.stepOf)} ${n.length}: ${S(i)}" 11 11 > 12 12 <div class="bw-booking-modal__step-meta" aria-hidden="true"> 13 <span> Step ${n+1} of ${t.length}</span>14 <strong>${ r}</strong>13 <span>${S(t.stepLabel)} ${r+1} ${S(t.stepOf)} ${n.length}</span> 14 <strong>${i}</strong> 15 15 </div> 16 16 <div class="bw-booking-modal__step-track" aria-hidden="true"> 17 ${ t.map((e,t)=>`<span class="bw-booking-modal__step-segment${t<=n?` is-complete`:``}"></span>`).join(``)}17 ${n.map((e,t)=>`<span class="bw-booking-modal__step-segment${t<=r?` is-complete`:``}"></span>`).join(``)} 18 18 </div> 19 19 </div> 20 `}function Le(e,t){return e.length?e.map((e,n)=>`21 <button type="button" class="bw-booking-modal__service" data-panel-service-index="${ n}" data-active="${n===t}" aria-pressed="${n===t}">20 `}function yt(e,t,n,r,i){return e.length?e.map((e,r)=>` 21 <button type="button" class="bw-booking-modal__service" data-panel-service-index="${r}" data-active="${r===t}" aria-pressed="${r===t}"> 22 22 <span class="bw-booking-modal__service-body"> 23 <strong>${l(e.name)}</strong> 24 <em>${l(c(e))}</em> 23 ${Be(e.imageUrl,n)} 24 <span class="bw-booking-modal__service-copy"> 25 <strong>${S(e.name)}</strong> 26 <em>${S(x(e,i))}</em> 27 </span> 25 28 </span> 26 <span class="bw-booking-modal__service-price">${ l(d(e))}</span>29 <span class="bw-booking-modal__service-price">${S(w(e,i))}</span> 27 30 </button> 28 `).join(``):`<div class="bw-booking-bar-panel__empty"> No services are published for this widget yet.</div>`}function Re(e,t,n=document.body){let r=document.createElement(`div`),i=document.createElement(`div`),a=document.createElement(`div`),o=!1,s=0,c=0,p=`time`,m=``,h=null,g=null,_=null,v=!1,y={customerName:``,customerEmail:``,customerPhone:``,notes:``};r.className=`bw-booking-bar-shell`,r.dataset.bookingWidgetOwner=t,r.style.setProperty(`--bw-color`,e.color),r.style.setProperty(`--bw-contrast`,Pe(e.color)),i.className=`bw-booking-bar-spacer`,i.dataset.bookingWidgetOwner=t,a.className=`bw-booking-bar-panel`,a.dataset.bookingWidgetOwner=t,a.style.setProperty(`--bw-color`,e.color),a.style.setProperty(`--bw-contrast`,Pe(e.color));function b(){if(n.host?.style?.setProperty){if(n.host.dataset.open=String(o),o){n.host.style.setProperty(`inset`,`0`,`important`),n.host.style.setProperty(`top`,`0`,`important`),n.host.style.setProperty(`left`,`0`,`important`),n.host.style.setProperty(`right`,`0`,`important`),n.host.style.setProperty(`bottom`,`0`,`important`),n.host.style.setProperty(`width`,`100%`,`important`),n.host.style.setProperty(`height`,`100%`,`important`),n.host.style.setProperty(`pointer-events`,`auto`,`important`);return}n.host.style.setProperty(`inset`,`auto 0 0 0`,`important`),n.host.style.setProperty(`top`,`auto`,`important`),n.host.style.setProperty(`left`,`0`,`important`),n.host.style.setProperty(`right`,`0`,`important`),n.host.style.setProperty(`bottom`,`0`,`important`),n.host.style.setProperty(`width`,`100%`,`important`),n.host.style.setProperty(`height`,`auto`,`important`),n.host.style.setProperty(`pointer-events`,`auto`,`important`)}}if(n.host){let e=document.getElementById(`wpadminbar`),t=e?window.getComputedStyle(e):null,i=e&&t?.position===`fixed`?Math.ceil(e.getBoundingClientRect().height):0;n.host.style.setProperty(`--bw-top-offset`,`${i}px`),i>0&&(n.host.style.setProperty(`--bw-bar-z-index`,`90000`),n.host.style.setProperty(`--bw-panel-z-index`,`89999`));let o=e=>{e.stopPropagation()};r.addEventListener(`pointerdown`,o),r.addEventListener(`click`,o),a.addEventListener(`pointerdown`,o),a.addEventListener(`click`,o)}function x(t,n,r){if(e.loadError)return`<div class="bw-booking-bar-panel__empty">Booking setup could not be loaded.</div>`;if(!n)return`<div class="bw-booking-bar-panel__empty">No services are published for this widget yet.</div>`;let i=`${t.length} ${t.length===1?`service`:`services`}`,a=r.length?r.map((e,t)=>`29 <button type="button" class="bw-booking-modal__slot" data-slot-index="${t}" data-active="${t=== c}">31 `).join(``):`<div class="bw-booking-bar-panel__empty">${S(r.emptyServices)}</div>`}function bt(){try{if(typeof crypto?.randomUUID==`function`)return crypto.randomUUID()}catch{}return`bmr-${Date.now().toString(36)}-${Math.random().toString(36).slice(2,12)}`}function xt(e,t,n=document.body){let r=c(e.language),i=bt(),a=!1,o={},s=document.createElement(`div`),l=document.createElement(`div`),u=document.createElement(`div`),d=!1,f=0,p=0,m=`time`,h=``,g=null,_=null,v=null,y=!1,b={customerName:``,customerEmail:``,customerPhone:``,customerPhoneCountry:``,notes:``};s.className=`bw-booking-bar-shell`,s.dataset.bookingWidgetOwner=t,s.style.setProperty(`--bw-color`,e.color),s.style.setProperty(`--bw-contrast`,dt(e.color)),l.className=`bw-booking-bar-spacer`,l.dataset.bookingWidgetOwner=t,u.className=`bw-booking-bar-panel`,u.dataset.bookingWidgetOwner=t,u.style.setProperty(`--bw-color`,e.color),u.style.setProperty(`--bw-contrast`,dt(e.color));function x(){if(n.host?.style?.setProperty){if(n.host.dataset.open=String(d),d){n.host.style.setProperty(`inset`,`0`,`important`),n.host.style.setProperty(`top`,`0`,`important`),n.host.style.setProperty(`left`,`0`,`important`),n.host.style.setProperty(`right`,`0`,`important`),n.host.style.setProperty(`bottom`,`0`,`important`),n.host.style.setProperty(`width`,`100%`,`important`),n.host.style.setProperty(`height`,`100%`,`important`),n.host.style.setProperty(`pointer-events`,`auto`,`important`);return}n.host.style.setProperty(`inset`,`auto 0 0 0`,`important`),n.host.style.setProperty(`top`,`auto`,`important`),n.host.style.setProperty(`left`,`0`,`important`),n.host.style.setProperty(`right`,`0`,`important`),n.host.style.setProperty(`bottom`,`0`,`important`),n.host.style.setProperty(`width`,`100%`,`important`),n.host.style.setProperty(`height`,`auto`,`important`),n.host.style.setProperty(`pointer-events`,`auto`,`important`)}}if(n.host){let e=document.getElementById(`wpadminbar`),t=e?window.getComputedStyle(e):null,r=e&&t?.position===`fixed`?Math.ceil(e.getBoundingClientRect().height):0;n.host.style.setProperty(`--bw-top-offset`,`${r}px`),r>0&&(n.host.style.setProperty(`--bw-bar-z-index`,`90000`),n.host.style.setProperty(`--bw-panel-z-index`,`89999`));let i=e=>{e.stopPropagation()};s.addEventListener(`pointerdown`,i),s.addEventListener(`click`,i),u.addEventListener(`pointerdown`,i),u.addEventListener(`click`,i)}function T(t,n,i){if(e.loadError)return`<div class="bw-booking-bar-panel__empty">${S(r.loadError)}</div>`;if(!n)return`<div class="bw-booking-bar-panel__empty">${S(r.emptyServices)}</div>`;let a=`${t.length} ${t.length===1?r.serviceSingular:r.servicePlural}`,o=i.length?i.map((e,t)=>` 32 <button type="button" class="bw-booking-modal__slot" data-slot-index="${t}" data-active="${t===p}"> 30 33 <span> 31 <strong>${ l(e.label)}</strong>32 <em>${ l(e.capacity)}</em>34 <strong>${S(e.label)}</strong> 35 <em>${S(e.capacity)}</em> 33 36 </span> 34 <span>${ t===c?`Selected`:`Choose`}</span>37 <span>${S(t===p?r.selected:r.choose)}</span> 35 38 </button> 36 `).join(``):`<div class="bw-booking-bar-panel__empty"> No times are available yet.</div>`;return`39 `).join(``):`<div class="bw-booking-bar-panel__empty">${S(r.emptySlots)}</div>`;return` 37 40 <div class="bw-booking-modal__time"> 38 <aside class="bw-booking-modal__services" aria-label=" Services">41 <aside class="bw-booking-modal__services" aria-label="${S(r.servicesLabel)}"> 39 42 <div class="bw-booking-modal__section-title"> 40 <strong> Services</strong>41 <em>${ l(i)}</em>43 <strong>${S(r.servicesLabel)}</strong> 44 <em>${S(a)}</em> 42 45 </div> 43 46 <div class="bw-booking-modal__service-list"> 44 ${ Le(t,s)}47 ${yt(t,f,e.previewMode,r,e.language)} 45 48 </div> 46 49 </aside> 47 <section class="bw-booking-modal__times" aria-label=" Available times">50 <section class="bw-booking-modal__times" aria-label="${S(r.availableTimes)}"> 48 51 <div class="bw-booking-modal__section-title"> 49 <strong> Times</strong><span>${l(f(e))}</span>50 <em>${ l(n.name)}</em>52 <strong>${S(r.timesLabel)}</strong><span>${S(ee(e))}</span> 53 <em>${S(n.name)}</em> 51 54 </div> 52 55 <div class="bw-booking-modal__slots"> 53 ${ a}56 ${o} 54 57 </div> 55 58 </section> 56 59 </div> 57 `}function S(e,t){return!e||!t?`<div class="bw-booking-modal__pane"><div class="bw-booking-bar-panel__empty">Select a service and time first.</div></div>`:`60 `}function E(t,n){if(!t||!n)return`<div class="bw-booking-modal__pane"><div class="bw-booking-bar-panel__empty">${S(r.selectServiceAndTime)}</div></div>`;let i=Number(t.depositCents)>0&&e.features?.deposits!==!0;return` 58 61 <div class="bw-booking-modal__pane"> 59 62 <form class="bw-booking-bar-panel__form"> 60 63 <div class="bw-booking-bar-panel__summary-card"> 61 <strong>${ l(e.name)}</strong>62 <span>${ l(Fe(e,t))}</span>64 <strong>${S(t.name)}</strong> 65 <span>${S(_t(t,n,r,e.language))}</span> 63 66 </div> 64 <p class="bw-booking-bar-panel__section-title">Your details</p> 67 <p class="bw-booking-bar-panel__section-title">${S(r.detailsHeading)}</p> 68 ${i?`<p role="note">${S(r.depositUnavailable)}</p>`:``} 65 69 <div class="bw-booking-bar-panel__form-row"> 66 70 <label> 67 <span> Name</span>68 <input name="customerName" autocomplete="name" required placeholder=" Full name" value="${l(y.customerName)}" />71 <span>${S(r.nameLabel)}</span> 72 <input name="customerName" autocomplete="name" required placeholder="${S(r.namePlaceholder)}" value="${S(b.customerName)}" /> 69 73 </label> 74 ${e.sms?.available===!0?` 75 <label> 76 <span>${S(r.phoneCountryLabel)}</span> 77 <select name="customerPhoneCountry" autocomplete="country"> 78 <option value="">${S(r.chooseOption)}</option> 79 ${pt.map(e=>`<option value="${e}"${b.customerPhoneCountry===e?` selected`:``}>${e} (+${S(ft[e])})</option>`).join(``)} 80 </select> 81 </label> 82 `:``} 70 83 <label> 71 <span> Phone <small>(optional)</small></span>72 <input name="customerPhone" autocomplete="tel" type="tel" placeholder=" +1 555 0100" value="${l(y.customerPhone)}" />84 <span>${S(r.phoneLabel)}</span> 85 <input name="customerPhone" autocomplete="tel" type="tel" placeholder="${S(r.phonePlaceholder)}" value="${S(b.customerPhone)}" /> 73 86 74 87 </label> 75 88 </div> 76 89 <label> 77 <span> Email</span>78 <input name="customerEmail" autocomplete="email" type="email" required placeholder=" you@example.com" value="${l(y.customerEmail)}" />90 <span>${S(r.emailLabel)}</span> 91 <input name="customerEmail" autocomplete="email" type="email" required placeholder="${S(r.emailPlaceholder)}" value="${S(b.customerEmail)}" /> 79 92 </label> 80 93 <label> 81 <span> Notes <small>(optional)</small></span>82 <textarea name="notes" rows="3" maxlength="500" placeholder=" Anything we should know?">${l(y.notes)}</textarea>94 <span>${S(r.notesLabel)}</span> 95 <textarea name="notes" rows="3" maxlength="500" placeholder="${S(r.notesPlaceholder)}">${S(b.notes)}</textarea> 83 96 </label> 84 ${m?`<div class="bw-booking-bar-panel__message" role="status">${l(m)}</div>`:``} 97 ${(t.bookingQuestions||[]).map(e=>{let t=String(o[e.id]||``),n=`answer-${S(e.id)}`,i=e.required?` required`:``,a=e.type===`yesno`?[`yes`,`no`]:e.options||[],s=e.type===`text`?`<input name="${n}" maxlength="500"${i} value="${S(t)}">`:`<select name="${n}"${i}><option value="">${S(r.chooseOption)}</option>${a.map(n=>`<option value="${S(n)}"${t===n?` selected`:``}>${S(e.type===`yesno`?n===`yes`?r.yesLabel:r.noLabel:n)}</option>`).join(``)}</select>`;return`<label><span>${S(e.label)}${e.required?` *`:``}</span>${s}</label>`}).join(``)} 98 ${e.sms?.available===!0?` 99 <label class="bw-booking-bar-panel__optin"> 100 <input type="checkbox" name="smsOptIn" /> 101 <span>${S(r.smsOptInLabel)}</span> 102 </label> 103 `:``} 104 ${e.whatsapp?.available===!0?` 105 <label class="bw-booking-bar-panel__optin"> 106 <input type="checkbox" name="whatsappOptIn" /> 107 <span>${S(r.whatsappOptInLabel)}</span> 108 </label> 109 `:``} 110 ${h?`<div class="bw-booking-bar-panel__message" role="status">${S(h)}</div>`:``} 85 111 <div class="bw-booking-bar-panel__actions"> 86 <button type="button" class="bw-booking-bar-panel__back"> Back</button>87 <button type="submit" class="bw-booking-bar-panel__continue" >Confirm booking</button>112 <button type="button" class="bw-booking-bar-panel__back">${S(r.back)}</button> 113 <button type="submit" class="bw-booking-bar-panel__continue" ${i?`disabled`:``}>${S(a?r.replacePaymentCta:r.confirmCta)}</button> 88 114 </div> 89 115 </form> 90 116 </div> 91 `}function C(e,t){return`<div class="bw-booking-modal__pane">92 <div class="bw-booking-bar-panel__summary-card"><strong>${ l(e?.name||`Appointment`)}</strong><span>${l(Fe(e,t))}</span></div>93 <p class="bw-booking-bar-panel__section-title"> Pay ${l(d({price:g.depositCents/100}))} deposit</p>94 <p> Your booking is confirmed after payment.</p>95 < div data-payment-element></div>96 <p data-payment-status role="status"> Loading secure payment…</p>97 ${ m?`<p role="alert">${l(m)}</p>`:``}98 <button type="button" class="bw-booking-bar-panel__continue" data-pay-deposit disabled> Pay deposit and confirm</button>99 </div>`}function w(){let e=h?.localOnly===!0,t=h&&!e?Ee({description:h.serviceName,durationMinutes:h.durationMinutes,id:h.bookingId,startsAt:h.startsAt,title:h.serviceName}):``,n=h?.bookingId&&!e?`<span class="bw-booking-bar-panel__ref">Booking reference: <code>${l(String(h.bookingId).slice(0,8))}</code></span>`:``,r=t?`<a class="bw-booking-bar-panel__calendar" href="${t}" download="booking.ics">Add to calendar</a>`:``;return`117 `}function D(t,n){return`<div class="bw-booking-modal__pane"> 118 <div class="bw-booking-bar-panel__summary-card"><strong>${S(t?.name||r.appointment)}</strong><span>${S(_t(t,n,r,e.language))}</span></div> 119 <p class="bw-booking-bar-panel__section-title">${S(r.depositHeading)} · ${S(w({price:_.depositCents/100,currency:t?.currency},e.language))}</p> 120 <p>${S(r.depositNote)}</p> 121 <slot name="bm-payment-element" data-payment-element></slot> 122 <p data-payment-status role="status">${S(r.paymentLoading)}</p> 123 ${h?`<p role="alert">${S(h)}</p>`:``} 124 <button type="button" class="bw-booking-bar-panel__continue" data-pay-deposit disabled>${S(r.payDepositCta)}</button> 125 </div>`}function O(){let e=g?.localOnly===!0,t=g&&!e?tt({description:g.serviceName,durationMinutes:g.durationMinutes,id:g.bookingId,startsAt:g.startsAt,title:g.serviceName}):``,n=g?.bookingId&&!e?`<span class="bw-booking-bar-panel__ref">${S(r.bookingReference)}: <code>${S(String(g.bookingId).slice(0,8))}</code></span>`:``,i=t?`<a class="bw-booking-bar-panel__calendar" href="${t}" download="booking.ics">${S(r.addToCalendar)}</a>`:``;return` 100 126 <div class="bw-booking-modal__pane"> 101 127 <div class="bw-booking-bar-panel__confirmed"> 102 128 <div class="bw-booking-bar-panel__confirm-icon" aria-hidden="true">✓</div> 103 <strong>${ e?`Test mode — booking not saved`:`Request received`}</strong>104 <span>${ e?`Nothing was sent — this site runs on a local or test address.`:`A confirmation follows by email.`}</span>129 <strong>${S(e?r.testModeHeading:r.successHeading)}</strong> 130 <span>${S(e?r.testModeNote:r.successNote)}</span> 105 131 ${n} 106 ${ r}107 <button type="button" class="bw-booking-bar-panel__continue" data-bar-done> Done</button>132 ${i} 133 <button type="button" class="bw-booking-bar-panel__continue" data-bar-done>${S(r.done)}</button> 108 134 </div> 109 135 </div> 110 `}function T(t,n,r,i){let a=g?C(t,n):p===`details`?S(t,n):p===`confirmed`?w():x(e.services,t,r);return`136 `}function k(t,n,i,a){let o=_?D(t,n):m===`details`?E(t,n):m===`confirmed`?O():T(e.services,t,i);return` 111 137 <div class="bw-booking-modal"> 112 138 <div class="bw-booking-modal__topbar"> … … 114 140 <span class="bw-booking-modal__brand-mark">B</span> 115 141 <span> 116 <strong>${ l(e.title)}</strong>117 <em>${ l(p===`details`?`Enter your details`:p===`confirmed`?`Request received`:`Choose a time`)}</em>142 <strong>${S(e.title)}</strong> 143 <em>${S(m===`details`?r.detailsHeading:m===`confirmed`?r.successHeading:r.chooseTime)}</em> 118 144 </span> 119 145 </span> 120 <button type="button" class="bw-booking-bar-panel__close" aria-label=" Close booking bar">×</button>146 <button type="button" class="bw-booking-bar-panel__close" aria-label="${S(r.close)}">×</button> 121 147 </div> 122 ${ Ie(p)}123 <div class="bw-booking-modal__body">${ a}</div>124 ${e.showBranding?`<div class="bw-booking-modal__powered"> Powered by<strong>Bookminto</strong></div>`:``}125 ${ p===`time`?`<div class="bw-booking-modal__footer">148 ${vt(m,r)} 149 <div class="bw-booking-modal__body">${o}</div> 150 ${e.showBranding?`<div class="bw-booking-modal__powered">${S(r.widgetFooterPrefix)} <strong>Bookminto</strong></div>`:``} 151 ${m===`time`?`<div class="bw-booking-modal__footer"> 126 152 <span> 127 <strong>${ l(t?.name||`No service selected`)}</strong>128 <span>${ l(Fe(t,n))}</span>153 <strong>${S(t?.name||r.noServiceSelected)}</strong> 154 <span>${S(_t(t,n,r,e.language))}</span> 129 155 </span> 130 <button type="button" class="bw-booking-bar-panel__continue" ${ i?``:`disabled`}>${i?`Continue booking`:`No time selected`}</button>156 <button type="button" class="bw-booking-bar-panel__continue" ${a?``:`disabled`}>${S(a?r.continueDefault:r.noTimeSelected)}</button> 131 157 </div>`:``} 132 158 </div> 133 `}function E(){_?.destroy(),_=null;let t=e.services,n=t[s],i=ie(e,n,6),d=i[c],f=!!(n&&d),x=e.loadError?`Booking setup unavailable`:t.length>1?`Choose a service and time`:n?u(n):`No services available yet`;if(r.dataset.open=String(o),b(),r.innerHTML=`159 `}function A(){v?.destroy(),v=null;let t=e.services,n=t[f],c=ge(e,n)?[]:_e(e,n,6),l=c[p],w=!!(n&&l),ee=e.loadError?r.loadError:t.length>1?r.selectServiceAndTime:n?C(n,e.language):r.emptyServices;if(s.dataset.open=String(d),x(),s.innerHTML=` 134 160 <div class="bw-booking-bar"> 135 <button type="button" class="bw-booking-bar__main" aria-expanded="${ o}">161 <button type="button" class="bw-booking-bar__main" aria-expanded="${d}"> 136 162 <span class="bw-booking-bar__mark">B</span> 137 163 <span> 138 <strong>${ l(e.title)}</strong>139 <em>${ l(x)}</em>164 <strong>${S(e.title)}</strong> 165 <em>${S(ee)}</em> 140 166 </span> 141 167 </button> 142 <button type="button" class="bw-booking-bar__cta">${ o?`Close`:n?`Choose time`:`View setup`}</button>168 <button type="button" class="bw-booking-bar__cta">${S(d?r.close:n?r.chooseTime:r.viewSetup)}</button> 143 169 </div> 144 `, a.hidden=!o,a.className=`bw-booking-bar-panel bw-booking-bar-panel--booking`,a.innerHTML=T(n,d,i,f),r.querySelector(`.bw-booking-bar__main`)?.addEventListener(`click`,()=>{o=!o,p=`time`,E()}),r.querySelector(`.bw-booking-bar__cta`)?.addEventListener(`click`,()=>{o=!o,p=`time`,E()}),a.querySelector(`.bw-booking-bar-panel__close`)?.addEventListener(`click`,()=>{o=!1,p=`time`,E()}),a.querySelectorAll(`[data-panel-service-index]`).forEach(e=>{e.setAttribute(`data-active`,String(Number(e.getAttribute(`data-panel-service-index`))===s)),e.addEventListener(`click`,()=>{s=Number(e.getAttribute(`data-panel-service-index`))||0,c=0,m=``,o=!0,p=`time`,E()})}),a.querySelectorAll(`[data-slot-index]`).forEach(e=>{e.addEventListener(`click`,()=>{c=Number(e.getAttribute(`data-slot-index`))||0,m=``,E()})}),a.querySelector(`.bw-booking-bar-panel__continue`)?.addEventListener(`click`,()=>{p===`time`&&f&&(p=`details`,E())}),a.querySelector(`.bw-booking-bar-panel__back`)?.addEventListener(`click`,()=>{p=`time`,m=``,E()}),a.querySelector(`[data-bar-done]`)?.addEventListener(`click`,()=>{o=!1,p=`time`,E()}),g){let t=a.querySelector(`[data-payment-element]`),r=a.querySelector(`[data-pay-deposit]`),i=g;Ae(t,{...i,accent:e.color}).then(e=>{if(!t.isConnected||g!==i){e.destroy();return}_=e,r.disabled=!1,a.querySelector(`[data-payment-status]`).textContent=``}).catch(e=>{t.isConnected&&g===i&&(a.querySelector(`[data-payment-status]`).textContent=``,Oe(t,e,E))}),r.addEventListener(`click`,async()=>{if(!_||v)return;v=!0,r.disabled=!0;let t=_;try{let r=await t.confirm();await je(e,{bookingId:i.bookingId,paymentIntentId:r}),h={bookingId:i.bookingId,serviceName:n?.name,durationMinutes:n?.durationMinutes,startsAt:d?.startsAt},g=null,p=`confirmed`,m=``}catch(e){m=e instanceof Error?e.message:`Payment could not be confirmed. Try again.`}finally{v=!1,E()}})}a.querySelector(`.bw-booking-bar-panel__form`)?.addEventListener(`submit`,t=>{if(t.preventDefault(),v)return;v=!0;let r=new FormData(t.currentTarget);y=Object.fromEntries(Object.keys(y).map(e=>[e,String(r.get(e)||``)])),m=`Sending request...`,E();let i=n,a=d;Te(e,{customerEmail:r.get(`customerEmail`),customerName:r.get(`customerName`),customerPhone:r.get(`customerPhone`),notes:r.get(`notes`),serviceId:n?.id||n?.name,serviceName:n?.name,startsAt:d?.startsAt}).then(e=>{if(e?.redirectedToCheckout)return;let t=De(e);if(t){g=t,m=``,E();return}h={bookingId:e&&typeof e==`object`?e.bookingId:void 0,durationMinutes:i?.durationMinutes,localOnly:e?.localOnly===!0,serviceName:i?.name||`Appointment`,startsAt:a?.startsAt},p=`confirmed`,m=``,E()}).catch(e=>{m=e instanceof Error?e.message:`Booking could not be created`,p=`details`,E()}).finally(()=>{v=!1})})}return E(),n.prepend(i,r,a),r}let K={title:`Schedule a booking`,widgetStepLabels:[`Service`,`Date`,`Details`,`Confirmed`],chooseService:`Choose an offering`,chooseDate:`Choose a date`,availableTimes:`Available times`,summaryLabel:`Your selection`,totalLabel:`Price`,detailsHeading:`Your details`,previousDates:`Previous dates`,nextDates:`Next dates`,successHeading:`Booking confirmed!`,successNote:`You will receive a confirmation email shortly with your booking details.`,emptySlots:`No times available for this date.`,emptyServices:`No offerings are available right now.`,bookNow:`Book now`,back:`Back`,at:`at`,nameLabel:`Name`,phoneLabel:`Phone`,notesLabel:`Notes`,emailLabel:`Email`,continueFromService:`Select and continue`,continueDefault:`Continue`,checkoutCta:`Continue to checkout`,confirmCta:`Confirm booking`,payDepositCta:`Pay deposit`,depositHeading:`Pay deposit to confirm`,depositNote:`Your time is held while you complete payment.`,processing:`Processing...`,redirectingToCheckout:`Redirecting to checkout…`,close:`Close`,addToCalendar:`Add to calendar`,widgetFooterPrefix:`Powered by`,widgetFooterBrand:`bookminto`,namePlaceholder:`Jane Smith`,phonePlaceholder:`+1 555 0100`,emailPlaceholder:`jane@company.com`,notesPlaceholder:`Specific questions or context…`,loading:`Loading…`,genericError:`Something went wrong. Please try again.`,loadError:`Booking setup could not be loaded.`,previewOnlyNote:`Preview only — no booking was saved and no email was sent.`,testModeHeading:`Test mode — booking not saved`,testModeNote:`This site is running on a local or test address, so nothing was sent to Bookminto and no confirmation email will arrive.`};function ze(e,t,n={}){if(!e||!t||typeof e.scrollTo!=`function`)return 0;let r=Number(n.top)||0,i=Number(n.bottom)||16,a=e.getBoundingClientRect(),o=t.getBoundingClientRect(),s=a.top+r,c=a.bottom-i;if(o.top<s){let t=o.top-s;return e.scrollTo({top:Math.max(e.scrollTop+t,0),behavior:`smooth`}),Math.abs(t)}if(o.bottom>c){let t=o.bottom-c;return e.scrollTo({top:Math.max(e.scrollTop+t,0),behavior:`smooth`}),Math.abs(t)}return 0}function q(e,t={}){let n=t.ctaSelector||`[data-action="continue"]`,r=t.containerSelector||null,i=t.margins||{top:8,bottom:20},a=()=>{let t=e?.querySelector?.(n);if(!t)return;let a=r?e.querySelector(r):t.closest(`.widget-body-scroll, .widget-body, .panel-right-scroll, .panel-right, .bw-booking-modal__body`);if(a){if(!a.contains(t)){a.scrollTo({top:Math.max(a.scrollHeight-a.clientHeight,0),behavior:`smooth`});return}ze(a,t,i)}};if(typeof window.requestAnimationFrame==`function`){window.requestAnimationFrame(()=>{window.requestAnimationFrame(a)});return}if(typeof window.setTimeout==`function`){window.setTimeout(a,0);return}a()}let J=[`Service`,`Date & time`,`Details`,`Confirmed`];function Be(e){let t=new Date(`${e}T12:00:00`);return{day:String(t.getDate()),month:new Intl.DateTimeFormat(`en-US`,{month:`short`}).format(t).replace(`.`,``),weekday:new Intl.DateTimeFormat(`en-US`,{weekday:`short`}).format(t).replace(`.`,``)}}function Ve(e){return e?new Intl.DateTimeFormat(`en-US`,{weekday:`short`,day:`numeric`,month:`short`}).format(new Date(`${e}T12:00:00`)):``}function He(e){if(!e.length)return``;let t=new Intl.DateTimeFormat(`en-US`,{day:`numeric`,month:`short`});return`${t.format(new Date(`${e[0]}T12:00:00`))} - ${t.format(new Date(`${e[e.length-1]}T12:00:00`))}`}function Ue(e){let t=Math.min(e+1,J.length),n=J[t-1];return`170 `,u.hidden=!d,u.className=`bw-booking-bar-panel bw-booking-bar-panel--booking`,u.innerHTML=k(n,l,c,w),Ve(u),s.querySelector(`.bw-booking-bar__main`)?.addEventListener(`click`,()=>{d=!d,m=`time`,A(),d?u.querySelector(`.bw-booking-bar-panel__close`)?.focus():s.querySelector(`.bw-booking-bar__cta`)?.focus()}),s.querySelector(`.bw-booking-bar__cta`)?.addEventListener(`click`,()=>{d=!d,m=`time`,A(),d?u.querySelector(`.bw-booking-bar-panel__close`)?.focus():s.querySelector(`.bw-booking-bar__cta`)?.focus()}),u.querySelector(`.bw-booking-bar-panel__close`)?.addEventListener(`click`,()=>{d=!1,m=`time`,A(),s.querySelector(`.bw-booking-bar__cta`)?.focus()}),u.querySelectorAll(`[data-panel-service-index]`).forEach(e=>{e.setAttribute(`data-active`,String(Number(e.getAttribute(`data-panel-service-index`))===f)),e.addEventListener(`click`,()=>{f=Number(e.getAttribute(`data-panel-service-index`))||0,i=bt(),a=!1,o={},p=0,h=``,d=!0,m=`time`,A()})}),u.querySelectorAll(`[data-slot-index]`).forEach(e=>{e.addEventListener(`click`,()=>{p=Number(e.getAttribute(`data-slot-index`))||0,i=bt(),a=!1,h=``,A()})}),u.querySelector(`.bw-booking-bar-panel__continue`)?.addEventListener(`click`,()=>{m===`time`&&w&&(m=`details`,A())}),u.querySelector(`.bw-booking-bar-panel__back`)?.addEventListener(`click`,()=>{m=`time`,h=``,A()}),u.querySelector(`[data-bar-done]`)?.addEventListener(`click`,()=>{i=bt(),a=!1,o={},d=!1,m=`time`,A()}),_){let t=u.querySelector(`[data-payment-element]`),i=u.querySelector(`[data-pay-deposit]`),a=_;at(t,{...a,accent:e.color,language:e.language}).then(e=>{if(!t.isConnected||_!==a){e.destroy();return}v=e,i.disabled=!1,u.querySelector(`[data-payment-status]`).textContent=``}).catch(n=>{t.isConnected&&_===a&&(u.querySelector(`[data-payment-status]`).textContent=``,rt(t,n,A,e.language))}),i.addEventListener(`click`,async()=>{if(!v||y)return;y=!0,i.disabled=!0;let o=v;try{let t=a.confirmedPaymentIntentId||await o.confirm();if(_!==a||v!==o)return;a.confirmedPaymentIntentId=t;let i=await ot(e,{bookingId:a.bookingId,paymentIntentId:t});if(_!==a||v!==o)return;if(i.processing||!(i.ok||i.alreadyConfirmed))throw Error(r.paymentError);g={bookingId:a.bookingId,serviceName:n?.name,durationMinutes:n?.durationMinutes,startsAt:l?.startsAt},_=null,m=`confirmed`,h=``}catch(e){h=e instanceof Error?e.message:r.paymentError,t.isConnected&&(u.querySelector(`[data-payment-status]`).textContent=h)}finally{y=!1,_===a?i.disabled=!1:A()}})}u.querySelector(`.bw-booking-bar-panel__form`)?.addEventListener(`submit`,t=>{if(t.preventDefault(),y||Number(n?.depositCents)>0&&e.features?.deposits!==!0)return;y=!0;let s=new FormData(t.currentTarget);b=Object.fromEntries(Object.keys(b).map(e=>[e,String(s.get(e)||``)]));let c=s.get(`smsOptIn`)===`on`,u=s.get(`whatsappOptIn`)===`on`;if(o=Object.fromEntries((n.bookingQuestions||[]).map(e=>[e.id,String(s.get(`answer-${e.id}`)||``)])),(c||u)&&!gt(b.customerPhone,b.customerPhoneCountry||void 0)){y=!1,h=u&&!c?r.phoneRequiredForWhatsapp:r.phoneRequiredForSms,A();return}h=r.processing,A();let d=n,f=l;Ze(e,{answers:o,clientRequestId:i,replaceTerminalPayment:a||void 0,publicationRevision:e.publicationRevision,language:e.language||`en`,customerEmail:s.get(`customerEmail`),customerName:s.get(`customerName`),customerPhone:s.get(`customerPhone`),customerPhoneCountry:b.customerPhoneCountry||void 0,notes:s.get(`notes`),serviceId:n?.id||n?.name,serviceName:n?.name,smsOptIn:e.sms?.available===!0&&c||void 0,startsAt:l?.startsAt,whatsappOptIn:e.whatsapp?.available===!0&&u||void 0}).then(t=>{if(t?.redirectedToCheckout)return;let n=nt(t,e.language);if(n){a=!1,_=n,h=``,A();return}g={bookingId:t&&typeof t==`object`?t.bookingId:void 0,durationMinutes:d?.durationMinutes,localOnly:t?.localOnly===!0,serviceName:d?.name||r.appointment,startsAt:f?.startsAt},m=`confirmed`,h=``,A()}).catch(e=>{a=e?.code===`terminal_payment_replacement_available`,h=a?r.replacePaymentNote:e instanceof Error?e.message:r.genericError,m=`details`,A()}).finally(()=>{y=!1})})}return A(),n.prepend(l,s,u),s}function St(e,t,n={}){if(!e||!t||typeof e.scrollTo!=`function`)return 0;let r=Number(n.top)||0,i=Number(n.bottom)||16,a=e.getBoundingClientRect(),o=t.getBoundingClientRect(),s=a.top+r,c=a.bottom-i;if(o.top<s){let t=o.top-s;return e.scrollTo({top:Math.max(e.scrollTop+t,0),behavior:`smooth`}),Math.abs(t)}if(o.bottom>c){let t=o.bottom-c;return e.scrollTo({top:Math.max(e.scrollTop+t,0),behavior:`smooth`}),Math.abs(t)}return 0}function $(e,t={}){let n=t.ctaSelector||`[data-action="continue"]`,r=t.containerSelector||null,i=t.margins||{top:8,bottom:20},a=()=>{let t=e?.querySelector?.(n);if(!t)return;let a=r?e.querySelector(r):t.closest(`.widget-body-scroll, .widget-body, .panel-right-scroll, .panel-right, .bw-booking-modal__body`);if(a){if(!a.contains(t)){a.scrollTo({top:Math.max(a.scrollHeight-a.clientHeight,0),behavior:`smooth`});return}St(a,t,i)}};if(typeof window.requestAnimationFrame==`function`){window.requestAnimationFrame(()=>{window.requestAnimationFrame(a)});return}if(typeof window.setTimeout==`function`){window.setTimeout(a,0);return}a()}function Ct(e,t){let n=new Date(`${e}T12:00:00`);return{day:String(n.getDate()),month:new Intl.DateTimeFormat(t||`en-US`,{month:`short`}).format(n).replace(`.`,``),weekday:new Intl.DateTimeFormat(t||`en-US`,{weekday:`short`}).format(n).replace(`.`,``)}}function wt(e){return f(e,``).split(/\s+/).filter(Boolean)[0]||``}function Tt(e){return f(e,``).split(/\s+/).filter(Boolean).slice(0,2).map(e=>e.charAt(0)).join(``).toUpperCase()}function Et(e){let t=f(e,``);return/^#[0-9a-fA-F]{6}$/.test(t)?t:``}function Dt(e,t){let n=f(e,``);return n.startsWith(`https://`)||t&&/^data:image\/(?:png|jpe?g|webp|gif);base64,/i.test(n)?n:``}function Ot(e,t){return e?new Intl.DateTimeFormat(t||`en-US`,{weekday:`short`,day:`numeric`,month:`short`}).format(new Date(`${e}T12:00:00`)):``}function kt(e,t){if(!e.length)return``;let n=new Intl.DateTimeFormat(t||`en-US`,{day:`numeric`,month:`short`});return`${n.format(new Date(`${e[0]}T12:00:00`))} - ${n.format(new Date(`${e[e.length-1]}T12:00:00`))}`}function At(e,t){let n=t.widgetStepLabels,r=Math.min(e+1,n.length),i=n[r-1];return` 145 171 <div 146 172 class="stepper" 147 173 role="progressbar" 148 aria-label=" Booking progress"174 aria-label="${S(t.progressLabel)}" 149 175 aria-valuemin="1" 150 aria-valuemax="${ J.length}"151 aria-valuenow="${ t}"152 aria-valuetext=" Step ${t} of ${J.length}: ${l(n)}"176 aria-valuemax="${n.length}" 177 aria-valuenow="${r}" 178 aria-valuetext="${S(t.stepLabel)} ${r} ${S(t.stepOf)} ${n.length}: ${S(i)}" 153 179 > 154 180 <div class="stepper-meta" aria-hidden="true"> 155 <span> Step ${t} of ${J.length}</span>156 <strong>${ l(n)}</strong>181 <span>${S(t.stepLabel)} ${r} ${S(t.stepOf)} ${n.length}</span> 182 <strong>${S(i)}</strong> 157 183 </div> 158 184 <div class="stepper-track" aria-hidden="true"> 159 ${ J.map((t,n)=>`<span class="stepper-segment${n<=e?` is-complete`:``}"></span>`).join(``)}185 ${n.map((t,n)=>`<span class="stepper-segment${n<=e?` is-complete`:``}"></span>`).join(``)} 160 186 </div> 161 187 </div> 162 `}function We(e){let t=e.previewMode?document.createElement(`div`):document.createElement(`dialog`);t.className=`modal-layer`,t.setAttribute(`role`,`dialog`),t.setAttribute(`aria-modal`,`true`),t.setAttribute(`aria-label`,e.title||K.title),G(t,e.color);let n=0,r=e.services[0]?.id||e.services[0]?.name||``,i=null,a=null,c=0,u=``,p=!1,m=null,h=null,g=null,_={customerEmail:``,customerName:``,customerPhone:``,notes:``};function v(){return e.services.find(e=>(e.id||e.name)===r)||e.services[0]||null}function y(){return oe(e,v())}function b(e){return e.slice(c,c+5)}function x(e){return!i||!a?null:(e.get(i)||[]).find(e=>e.id===a)||null}function S(e){return n===0?!!v():n===1?!!x(e):n===2?!!(_.customerName.trim()&&_.customerEmail.trim()):!1}function C(){return p?K.processing:n===0?K.continueFromService:h?K.payDepositCta:n===2?K.confirmCta:K.continueDefault}function w(e){return new Intl.NumberFormat(`en-US`,{style:`currency`,currency:`EUR`}).format((Number(e)||0)/100)}function T(e,t){return`<div class="runtime-state${t===`danger`?` runtime-state--danger`:``}">${l(e)}</div>`}function E(){_={customerEmail:t.querySelector(`[name="customerEmail"]`)?.value||_.customerEmail,customerName:t.querySelector(`[name="customerName"]`)?.value||_.customerName,customerPhone:t.querySelector(`[name="customerPhone"]`)?.value||_.customerPhone,notes:t.querySelector(`[name="notes"]`)?.value||_.notes}}function D(){n=0,i=null,a=null,c=0,u=``,p=!1,m=null,h=null,g&&=(g.destroy(),null),_={customerEmail:``,customerName:``,customerPhone:``,notes:``},j()}function O(e,t=S(e)){return` 163 <button class="cta" type="button" data-action="continue" ${t&&!p?``:`disabled`}> 164 <span>${l(C())}</span><svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1 7h12M7 1l6 6-6 6"></path></svg> 188 `}function jt(e){let t=c(e.language),n=u(e.language),r=e.previewMode?document.createElement(`div`):document.createElement(`dialog`);r.className=`modal-layer`,r.setAttribute(`role`,`dialog`),r.setAttribute(`aria-modal`,`true`),r.setAttribute(`aria-label`,e.title||t.title),Q(r,e.color);let i=0,a=e.services[0]?.id||e.services[0]?.name||``,o=null,s=null,l=null,d=0,p=e.calendarVerification===`external_unverified`?{de:`Die Kalenderverfügbarkeit kann derzeit nicht geprüft werden. Bitte versuchen Sie es später erneut.`,en:`Calendar availability cannot currently be verified. Please try again later.`,es:`No se puede verificar la disponibilidad del calendario ahora mismo. Inténtalo más tarde.`,fr:`La disponibilité du calendrier ne peut pas être vérifiée. Réessayez plus tard.`,it:`La disponibilità del calendario non può essere verificata al momento. Riprova più tardi.`,nl:`De beschikbaarheid van de agenda kan nu niet worden gecontroleerd. Probeer het later opnieuw.`}[e.language]||`Calendar availability cannot currently be verified. Please try again later.`:``,m=!1,h=null,g=null,_=null,v=k(),x=!1,C={answers:{},customerEmail:``,customerName:``,customerPhone:``,notes:``,rebookingOptIn:!1},T=!1,E=!1,D=!1,O={customerEmail:``,customerName:``,dateFrom:``,dateTo:``};function k(){try{if(typeof crypto?.randomUUID==`function`)return crypto.randomUUID()}catch{}return`bmr-${Date.now().toString(36)}-${Math.random().toString(36).slice(2,12)}`}function A(){return e.services.find(e=>(e.id||e.name)===a)||e.services[0]||null}function j(){return e.staffDisplayMode===`customer_choice`?he(e,A()):[]}function M(){if(l)return j().some(e=>e.id===l)?l:void 0}function N(){let e=M();if(!e)return``;let t=j().find(t=>t.id===e);return t?f(t.name,``):``}function P(){return ye(e,A(),M())}function F(e){return e.slice(d,d+5)}function I(e){return!o||!s?null:(e.get(o)||[]).find(e=>e.id===s)||null}function L(t){return i===0?!!A():i===1?!!I(t):i===2?Number(A()?.depositCents)>0&&e.features?.deposits!==!0||!C.customerName.trim()||!C.customerEmail.trim()?!1:(A()?.bookingQuestions||[]).every(e=>!e.required||!!String(C.answers?.[e.id]||``).trim()):!1}function R(){return m?t.processing:i===0?t.continueFromService:g?t.payDepositCta:i===2?x?t.replacePaymentCta:t.confirmCta:t.continueDefault}function te(e){return new Intl.NumberFormat(n,{style:`currency`,currency:`EUR`}).format((Number(e)||0)/100)}function z(e,t){return`<div class="runtime-state${t===`danger`?` runtime-state--danger`:``}">${S(e)}</div>`}function B(e){let n=Array.isArray(e?.bookingQuestions)?e.bookingQuestions:[];return n.length?` 189 <span class="slabel slabel--with-top-gap">${S(t.questionsHeading)}</span> 190 ${n.map(e=>{let n=`bm-modal-question-${S(e.id)}`,r=String(C.answers?.[e.id]||``),i=e.required?` <span class="form-required" aria-hidden="true">*</span><span class="sr-only"> ${S(t.requiredHint)}</span>`:``,a=`<label class="flabel" for="${n}">${S(e.label)}${i}</label>`;return e.type===`yesno`?` 191 <div class="form-field"> 192 ${a} 193 <select class="finput" id="${n}" data-question-id="${S(e.id)}"> 194 <option value="">${S(t.chooseOption)}</option> 195 <option value="yes"${r===`yes`?` selected`:``}>${S(t.yesLabel)}</option> 196 <option value="no"${r===`no`?` selected`:``}>${S(t.noLabel)}</option> 197 </select> 198 </div> 199 `:e.type===`choice`?` 200 <div class="form-field"> 201 ${a} 202 <select class="finput" id="${n}" data-question-id="${S(e.id)}"> 203 <option value="">${S(t.chooseOption)}</option> 204 ${(e.options||[]).map(e=>`<option value="${S(e)}"${r===e?` selected`:``}>${S(e)}</option>`).join(``)} 205 </select> 206 </div> 207 `:` 208 <div class="form-field"> 209 ${a} 210 <input class="finput" id="${n}" data-question-id="${S(e.id)}" maxlength="300" value="${S(r)}" /> 211 </div> 212 `}).join(``)} 213 `:``}function V(){let e=e=>{let t=r.querySelector(`[name="${e}"]`);return t?t.value:void 0};C={...C,customerEmail:e(`customerEmail`)??C.customerEmail,customerName:e(`customerName`)??C.customerName,customerPhone:e(`customerPhone`)??C.customerPhone,notes:e(`notes`)??C.notes},r.querySelectorAll(`[data-question-id]`).forEach(e=>{let t=e.getAttribute(`data-question-id`);t&&(C.answers[t]=e.value)});let t=r.querySelector(`[data-rebooking-optin]`);t&&(C.rebookingOptIn=t.checked===!0)}function ne(){i=0,o=null,s=null,l=null,d=0,p=``,m=!1,h=null,g=null,_&&=(_.destroy(),null),v=k(),x=!1,T=!1,E=!1,D=!1,O={customerEmail:``,customerName:``,dateFrom:``,dateTo:``},C={answers:{},customerEmail:``,customerName:``,customerPhone:``,notes:``,rebookingOptIn:!1},K()}function H(e,t=L(e)){return` 214 <button class="cta" type="button" data-action="continue" ${t&&!m?``:`disabled`}> 215 <span>${S(R())}</span><svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1 7h12M7 1l6 6-6 6"></path></svg> 165 216 </button> 166 `}function k(e,t){return`217 `}function U(e,t){return` 167 218 <div class="panel-right-flow"> 168 219 <div class="panel-right-scroll">${e}</div> 169 220 ${t} 170 221 </div> 171 `}function ee(t,a){return n===0?e.loadError?T(K.loadError,`danger`):e.services.length?` 172 <span class="slabel">${l(K.chooseService)}</span> 173 ${e.services.map(e=>{let t=e.id||e.name;return` 174 <button type="button" class="svc${t===r?` sel`:``}" data-service-id="${l(t)}"> 222 `}function re(r,s){return i===0?e.loadError?z(t.loadError,`danger`):e.services.length?` 223 <span class="slabel">${S(t.chooseService)}</span> 224 ${e.services.map(t=>{let n=t.id||t.name;return` 225 <button type="button" class="svc${n===a?` sel`:``}" data-service-id="${S(n)}"> 226 ${Be(t.imageUrl,e.previewMode)} 175 227 <div class="svc-info"> 176 <div class="svc-name">${ l(e.name)}</div><div class="service-price">${l(d(e))}</div>177 ${ e.description?`<div class="svc-desc">${l(e.description)}</div>`:``}228 <div class="svc-name">${S(t.name)}</div><div class="service-price">${S(w(t))}</div> 229 ${t.description?`<div class="svc-desc">${S(t.description)}</div>`:``} 178 230 </div> 179 <div class="svc-badge">${ l(o(e.durationMinutes))}</div>231 <div class="svc-badge">${S(y(t.durationMinutes))}</div> 180 232 </button> 181 233 `}).join(``)} 182 `: T(K.emptyServices):`183 <span class="slabel">${ l(K.summaryLabel)}</span>184 ${ t?`<div class="svc sel svc--static">234 `:z(t.emptyServices):` 235 <span class="slabel">${S(t.summaryLabel)}</span> 236 ${r?`<div class="svc sel svc--static"> 185 237 <div class="svc-info"> 186 <div class="svc-name">${ l(t.name)}</div>187 ${ t.description?`<div class="svc-desc">${l(t.description)}</div>`:``}238 <div class="svc-name">${S(r.name)}</div> 239 ${r.description?`<div class="svc-desc">${S(r.description)}</div>`:``} 188 240 </div> 189 <div class="svc-badge">${ l(o(t.durationMinutes))}</div>241 <div class="svc-badge">${S(y(r.durationMinutes))}</div> 190 242 </div>`:``} 191 ${i&&a?`<div class="sumbox sumbox--compact"> 192 <div class="sumrow">${l(Ve(i))}</div> 193 <div class="sumrow">${l(a.timeLabel)} · ${l(s(t?.price))}</div> 243 ${o&&s?`<div class="sumbox sumbox--compact"> 244 <div class="sumrow">${S(Ot(o,n))}</div> 245 <div class="sumrow">${S(s.timeLabel)} · ${S(b(r?.price))}</div> 246 ${N()?`<div class="sumrow">${S(t.withStaff.replace(`{name}`,N()))}</div>`:``} 194 247 </div>`:``} 195 `}function A(t,r,o,s){if(h)return k(` 196 <span class="slabel">${l(K.depositHeading)}</span> 197 <div class="modal-intro-copy">${l(K.depositNote)}</div> 248 `}function W(){let n=j();if(n.length<2)return``;let r=M(),i=n.map(t=>{let n=f(t.name,``),i=Et(t.color),a=Dt(t.avatarUrl,e.previewMode),o=t.id===r,s=a?`<img class="staff-chip-avatar" src="${S(a)}" alt="" loading="lazy" decoding="async" referrerpolicy="no-referrer">`:``;return` 249 <button type="button" class="staff-chip${o?` selected`:``}" data-staff-id="${S(t.id)}" aria-pressed="${o}"> 250 <span class="staff-chip-dot"${i?` style="background:${S(i)}"`:``}>${S(Tt(n))}${s}</span> 251 <span class="staff-chip-name">${S(wt(n)||n)}</span> 252 </button> 253 `}).join(``);return` 254 <span class="slabel">${S(t.staffPickerLabel)}</span> 255 <div class="staff-row" role="group" aria-label="${S(t.staffPickerLabel)}"> 256 <button type="button" class="staff-chip${r?``:` selected`}" data-staff-id="" aria-pressed="${!r}"> 257 <span class="staff-chip-name">${S(t.anyStaff)}</span> 258 </button> 259 ${i} 260 </div> 261 `}function ie(){let n=A();return e.features?.waitlist!==!0||!n?``:D?`<div class="runtime-state waitlist-joined" role="status">${S(t.waitlistJoined)}</div>`:T?` 262 <div class="waitlist-form"> 263 <span class="slabel slabel--with-top-gap">${S(t.waitlistHeading)}</span> 264 <p class="waitlist-note">${S(t.waitlistNote)}</p> 265 <div class="form-field"> 266 <label class="flabel" for="bm-wl-name">${S(t.nameLabel)}</label> 267 <input class="finput" id="bm-wl-name" data-waitlist-field="customerName" autocomplete="name" value="${S(O.customerName)}" /> 268 </div> 269 <div class="form-field"> 270 <label class="flabel" for="bm-wl-email">${S(t.emailLabel)}</label> 271 <input class="finput" id="bm-wl-email" data-waitlist-field="customerEmail" autocomplete="email" type="email" value="${S(O.customerEmail)}" /> 272 </div> 273 <div class="form-row"> 274 <div class="form-field"> 275 <label class="flabel" for="bm-wl-from">${S(t.waitlistFrom)}</label> 276 <input class="finput" id="bm-wl-from" data-waitlist-field="dateFrom" type="date" value="${S(O.dateFrom)}" /> 277 </div> 278 <div class="form-field"> 279 <label class="flabel" for="bm-wl-to">${S(t.waitlistTo)}</label> 280 <input class="finput" id="bm-wl-to" data-waitlist-field="dateTo" type="date" value="${S(O.dateTo)}" /> 281 </div> 282 </div> 283 <button type="button" class="cta" data-action="waitlist-submit" ${E?`disabled`:``}> 284 <span>${S(E?t.processing:t.waitlistSubmit)}</span> 285 </button> 286 </div> 287 `:`<button type="button" class="waitlist-cta" data-action="waitlist-open">${S(t.waitlistCta)}</button>`}function G(r,a,c,l){if(g)return U(` 288 <span class="slabel">${S(t.depositHeading)}</span> 289 <div class="modal-intro-copy">${S(t.depositNote)}</div> 198 290 <div class="sumbox"> 199 <div class="sumrow"><strong>${ l(w(h.depositCents))}</strong> deposit</div>291 <div class="sumrow"><strong>${S(te(g.depositCents))}</strong> ${S(t.depositLabel)}</div> 200 292 </div> 201 < div class="bw-payment-element" data-payment-element></div>202 ${ u?`<div class="runtime-state runtime-state--danger">${l(u)}</div>`:``}203 `, O(o,!p));if(n===0)return k(`293 <slot name="bm-payment-element" data-payment-element><div class="bw-payment-element"></div></slot> 294 ${p?`<div class="runtime-state runtime-state--danger">${S(p)}</div>`:``} 295 `,H(c,!m));if(i===0)return U(` 204 296 <div class="modal-intro-copy"> 205 297 Choose a service on the left, then continue to pick a date and time. 206 298 </div> 207 `,O(o));if(n===1){let t=b(r),n=i&&o.get(i)||[],s=c>0,u=c+5<r.length;return k(` 208 <button class="back-btn" type="button" data-action="back">← ${l(K.back)}</button> 209 <span class="slabel">${l(K.chooseDate)}</span><p class="booking-timezone">${l(f(e))}</p> 299 `,H(c));if(i===1){if(ge(e,r))return U(` 300 <button class="back-btn" type="button" data-action="back">← ${S(t.back)}</button> 301 ${z(t.serviceNotBookable,`danger`)} 302 `,H(c));let i=F(a),l=o&&c.get(o)||[],u=d>0,f=d+5<a.length;return U(` 303 <button class="back-btn" type="button" data-action="back">← ${S(t.back)}</button> 304 ${W()} 305 <span class="slabel">${S(t.chooseDate)}</span><p class="booking-timezone">${S(ee(e))}</p> 210 306 <div class="date-nav"> 211 <span class="date-nav-copy">${ l(He(t))}</span>307 <span class="date-nav-copy">${S(kt(i,n))}</span> 212 308 <div class="date-nav-buttons"> 213 <button type="button" class="date-nav-btn" data-action="prev-dates" ${ s?``:`disabled`}><svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9.5 2.5 5 7l4.5 4.5"></path></svg></button>214 <button type="button" class="date-nav-btn" data-action="next-dates" ${ u?``:`disabled`}><svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4.5 2.5 9 7l-4.5 4.5"></path></svg></button>309 <button type="button" class="date-nav-btn" data-action="prev-dates" ${u?``:`disabled`}><svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9.5 2.5 5 7l4.5 4.5"></path></svg></button> 310 <button type="button" class="date-nav-btn" data-action="next-dates" ${f?``:`disabled`}><svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4.5 2.5 9 7l-4.5 4.5"></path></svg></button> 215 311 </div> 216 312 </div> 217 313 <div class="date-grid"> 218 ${ t.length?t.map(e=>{let t=Be(e);return`219 <button type="button" class="dbtn${e=== i?` sel`:``}" data-date="${l(e)}">220 <div class="dbtn-dow">${ l(t.weekday)}</div>221 <div class="dbtn-day">${ l(t.day)}</div>222 <div class="dbtn-mon">${ l(t.month)}</div>314 ${i.length?i.map(e=>{let t=Ct(e,n);return` 315 <button type="button" class="dbtn${e===o?` sel`:``}" data-date="${S(e)}"> 316 <div class="dbtn-dow">${S(t.weekday)}</div> 317 <div class="dbtn-day">${S(t.day)}</div> 318 <div class="dbtn-mon">${S(t.month)}</div> 223 319 </button> 224 `}).join(``): T(K.emptySlots)}320 `}).join(``):z(t.emptySlots)} 225 321 </div> 226 ${ i?`227 <span class="slabel slabel--with-top-gap">${ l(K.availableTimes)}</span>228 ${ n.length?`<div class="time-grid">229 ${ n.map(e=>`<button type="button" class="tbtn${e.id===a?` sel`:``}" data-slot-id="${l(e.id)}">${l(e.timeLabel)}</button>`).join(``)}230 </div>`: T(K.emptySlots)}322 ${o?` 323 <span class="slabel slabel--with-top-gap">${S(t.availableTimes)}</span> 324 ${l.length?`<div class="time-grid"> 325 ${l.map(e=>`<button type="button" class="tbtn${e.id===s?` sel`:``}${e.recommended?` rec`:``}" data-slot-id="${S(e.id)}">${S(e.timeLabel)}${e.recommended?`<em class="rec-badge">${S(t.recommendedBadge)}</em>`:``}</button>`).join(``)} 326 </div>`:z(t.emptySlots)} 231 327 `:``} 232 `,O(o))}if(n===2)return k(` 233 <button class="back-btn" type="button" data-action="back">← ${l(K.back)}</button> 234 <span class="slabel">${l(K.detailsHeading)}</span> 328 ${!i.length||!l.length?ie():``} 329 ${p?`<div class="runtime-state runtime-state--danger">${S(p)}</div>`:``} 330 `,H(c))}if(i===2)return U(` 331 <button class="back-btn" type="button" data-action="back">← ${S(t.back)}</button> 332 <span class="slabel">${S(t.detailsHeading)}</span> 333 ${Number(r?.depositCents)>0&&e.features?.deposits!==!0?`<p class="modal-intro-copy" role="note">${S(t.depositUnavailable)}</p>`:``} 235 334 <div class="form-field"> 236 <label class="flabel" for="bm-modal-name">${ l(K.nameLabel)}</label>237 <input class="finput" id="bm-modal-name" name="customerName" value="${ l(_.customerName)}" placeholder="${l(K.namePlaceholder)}" autocomplete="name" />335 <label class="flabel" for="bm-modal-name">${S(t.nameLabel)}</label> 336 <input class="finput" id="bm-modal-name" name="customerName" value="${S(C.customerName)}" placeholder="${S(t.namePlaceholder)}" autocomplete="name" /> 238 337 </div> 239 338 <div class="form-row"> 240 339 <div class="form-field"> 241 <label class="flabel" for="bm-modal-email">${ l(K.emailLabel)}</label>242 <input class="finput" id="bm-modal-email" name="customerEmail" type="email" value="${ l(_.customerEmail)}" placeholder="${l(K.emailPlaceholder)}" autocomplete="email" />340 <label class="flabel" for="bm-modal-email">${S(t.emailLabel)}</label> 341 <input class="finput" id="bm-modal-email" name="customerEmail" type="email" value="${S(C.customerEmail)}" placeholder="${S(t.emailPlaceholder)}" autocomplete="email" /> 243 342 </div> 244 343 <div class="form-field"> 245 <label class="flabel" for="bm-modal-phone">${ l(K.phoneLabel)}</label>246 <input class="finput" id="bm-modal-phone" name="customerPhone" value="${ l(_.customerPhone)}" placeholder="${l(K.phonePlaceholder)}" autocomplete="tel" />344 <label class="flabel" for="bm-modal-phone">${S(t.phoneLabel)}</label> 345 <input class="finput" id="bm-modal-phone" name="customerPhone" value="${S(C.customerPhone)}" placeholder="${S(t.phonePlaceholder)}" autocomplete="tel" /> 247 346 </div> 248 347 </div> 249 348 <div class="form-field"> 250 <label class="flabel" for="bm-modal-notes">${ l(K.notesLabel)}</label>251 <textarea class="finput finput--textarea" id="bm-modal-notes" name="notes" placeholder="${ l(K.notesPlaceholder)}">${l(_.notes)}</textarea>349 <label class="flabel" for="bm-modal-notes">${S(t.notesLabel)}</label> 350 <textarea class="finput finput--textarea" id="bm-modal-notes" name="notes" placeholder="${S(t.notesPlaceholder)}">${S(C.notes)}</textarea> 252 351 </div> 253 ${u?`<div class="runtime-state runtime-state--danger">${l(u)}</div>`:``} 254 `,O(o));let d=e.previewMode||m?.localOnly===!0,g=m&&!d?Ee({description:m.notes||``,durationMinutes:t?.durationMinutes||30,id:m.bookingId||m.id,startsAt:s?.startsAt||i,title:`${t?.name||`Booking`} — ${e.title||K.title}`}):``;return` 352 ${B(r)} 353 ${Number(r?.rebookingIntervalWeeks)>0?`<label class="rebooking-optin"> 354 <input type="checkbox" data-rebooking-optin ${C.rebookingOptIn?`checked`:``} /> 355 <span>${S(t.rebookingOptInLabel)}</span> 356 </label>`:``} 357 ${p?`<div class="runtime-state runtime-state--danger">${S(p)}</div>`:``} 358 `,H(c));let u=e.previewMode||h?.localOnly===!0,f=h&&!u?tt({description:h.notes||``,durationMinutes:r?.durationMinutes||30,id:h.bookingId||h.id,startsAt:l?.startsAt||o,title:`${r?.name||`Booking`} — ${e.title||t.title}`}):``;return` 255 359 <div class="confirm-wrap"> 256 360 <div class="confirm-check"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"></polyline></svg></div> 257 <div class="confirm-title">${ l(m?.localOnly===!0&&!e.previewMode?K.testModeHeading:K.successHeading)}</div>258 <div class="confirm-sub">${ l(e.previewMode?K.previewOnlyNote:m?.localOnly===!0?K.testModeNote:K.successNote)}</div>259 ${ g?`<a class="cta cta--success" href="${g}" download="booking.ics">${l(K.addToCalendar)}</a>`:``}361 <div class="confirm-title">${S(h?.localOnly===!0&&!e.previewMode?t.testModeHeading:t.successHeading)}</div> 362 <div class="confirm-sub">${S(e.previewMode?t.previewOnlyNote:h?.localOnly===!0?t.testModeNote:t.successNote)}</div> 363 ${f?`<a class="cta cta--success" href="${f}" download="booking.ics">${S(t.addToCalendar)}</a>`:``} 260 364 <button class="cta cta--close-success" type="button" data-action="close-success"> 261 ${ l(K.close)}365 ${S(t.close)} 262 366 </button> 263 367 </div> 264 `}function j(){let r=v(),{availableDates:o,slotsByDate:s}=y();if(r&&o.length&&(!i||!o.includes(i))&&(i=o[0],a=null,c=0),i&&c>0){let e=o.indexOf(i);e>=0&&(e<c||e>=c+5)&&(c=Math.floor(e/5)*5)}let u=x(s),d=h?2:n;if(g&&=(g.destroy(),null),t.innerHTML=`368 `}function K(){let n=A(),{availableDates:a,slotsByDate:c}=P();if(n&&a.length&&(!o||!a.includes(o))&&(o=a[0],s=null,d=0),o&&d>0){let e=a.indexOf(o);e>=0&&(e<d||e>=d+5)&&(d=Math.floor(e/5)*5)}let l=I(c),u=g?2:i;if(_&&=(_.destroy(),null),r.innerHTML=` 265 369 <div class="modal-card"> 266 370 <div class="topbar"> 267 <span class="topbar-title">${ l(e.title||K.title)}</span>268 ${ Ue(d)}269 <button class="close-btn" type="button" data-action="close" aria-label="${ l(K.close)}"><svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M2 2l10 10M12 2 2 12"></path></svg></button>371 <span class="topbar-title">${S(e.title||t.title)}</span> 372 ${At(u,t)} 373 <button class="close-btn" type="button" data-action="close" aria-label="${S(t.close)}"><svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M2 2l10 10M12 2 2 12"></path></svg></button> 270 374 </div> 271 375 <div class="modal-body"> 272 <div class="panel-left">${ ee(r,u)}</div>273 <div class="panel-right">${ A(r,o,s,u)}</div>376 <div class="panel-left">${re(n,l)}</div> 377 <div class="panel-right">${G(n,a,c,l)}</div> 274 378 </div> 275 379 ${e.showBranding===!1?``:`<div class="modal-footer"> 276 <span class="p-text">${ l(K.widgetFooterPrefix)}</span>277 <span class="p-brand">${ l(K.widgetFooterBrand)}</span>380 <span class="p-text">${S(t.widgetFooterPrefix)}</span> 381 <span class="p-brand">${S(t.widgetFooterBrand)}</span> 278 382 </div>`} 279 383 </div> 280 `, M(o,s),h){let n=t.querySelector(`[data-payment-element]`),r=h,i=t.querySelector(`[data-action="continue"]`);i&&(i.disabled=!0),n&&r.publishableKey&&r.clientSecret&&Ae(n,{accent:e.color,clientSecret:h.clientSecret,publishableKey:h.publishableKey}).then(e=>{if(!n.isConnected||h!==r){e.destroy();return}g=e,i&&(i.disabled=p)}).catch(e=>{n.isConnected&&h===r&&Oe(n,e,j)})}}function M(e,o){t.querySelectorAll(`[data-service-id]`).forEach(e=>{e.addEventListener(`click`,()=>{r=e.getAttribute(`data-service-id`)||``,i=null,a=null,c=0,u=``,j()})}),t.querySelectorAll(`[data-date]`).forEach(e=>{e.addEventListener(`click`,()=>{i=e.getAttribute(`data-date`),a=null,u=``,j(),q(t,{containerSelector:`.panel-right-scroll`,margins:{top:8,bottom:12}})})}),t.querySelectorAll(`[data-slot-id]`).forEach(e=>{e.addEventListener(`click`,()=>{a=e.getAttribute(`data-slot-id`),u=``,j(),q(t,{containerSelector:`.panel-right-scroll`,margins:{top:8,bottom:12}})})}),t.querySelector(`[data-action="prev-dates"]`)?.addEventListener(`click`,()=>{c=Math.max(0,c-5),j()}),t.querySelector(`[data-action="next-dates"]`)?.addEventListener(`click`,()=>{c=Math.min(Math.max(0,e.length-5),c+5),j()}),t.querySelector(`[data-action="back"]`)?.addEventListener(`click`,()=>{E(),n=Math.max(0,n-1),u=``,j()}),t.querySelector(`[data-action="close"]`)?.addEventListener(`click`,()=>{t.dispatchEvent(new CustomEvent(`booking-widget-close`,{bubbles:!0}))}),t.querySelector(`[data-action="close-success"]`)?.addEventListener(`click`,()=>{t.dispatchEvent(new CustomEvent(`booking-widget-close`,{bubbles:!0})),window.setTimeout(()=>D(),300)}),t.querySelectorAll(`.finput`).forEach(e=>{e.addEventListener(`input`,()=>{E();let e=t.querySelector(`[data-action="continue"]`);e&&(e.disabled=!S(o)||p)})}),t.querySelector(`[data-action="continue"]`)?.addEventListener(`click`,()=>{if(E(),n===0&&v()){n=1,u=``,j();return}if(n===1&&x(o)){n=2,u=``,j();return}if(h){N();return}n===2&&P(o)})}async function N(){if(!h||!g){u=`Payment form is still loading. Please wait a moment.`,j();return}p=!0,u=``;let r=g,i=h,a=t.querySelector(`[data-action="continue"]`);a&&(a.disabled=!0,a.textContent=K.processing);try{let t=await r.confirm();await je(e,{bookingId:i.bookingId,paymentIntentId:t}),m={bookingId:i.bookingId,id:i.bookingId},h=null,n=3}catch(e){u=e instanceof Error?e.message:K.genericError}finally{p=!1,j()}}async function P(t){let r=v(),i=x(t);if(!(!r||!i)){p=!0,u=``,j();try{let t=await Te(e,{customerEmail:_.customerEmail.trim(),customerName:_.customerName.trim(),customerPhone:_.customerPhone.trim(),notes:_.notes.trim(),serviceId:r.id||r.name,serviceName:r.name,startsAt:i.startsAt});if(t?.redirectedToCheckout)return;let a=De(t);a?h=a:(m=t||{bookingId:`local`},n=3)}catch(e){u=e instanceof Error?e.message:K.genericError}finally{p=!1,j()}}}return j(),t.addEventListener(`cancel`,e=>{e.preventDefault(),t.dispatchEvent(new CustomEvent(`booking-widget-close`,{bubbles:!0}))}),t.resetFlow=D,t.openModal=()=>{if(e.previewMode){t.classList.add(`is-open`),t.setAttribute(`data-open`,`true`),t.setAttribute(`open`,``);return}typeof t.showModal==`function`&&!t.open?t.showModal():t.setAttribute(`open`,``)},t.closeModal=()=>{if(e.previewMode){t.classList.remove(`is-open`),t.removeAttribute(`data-open`),t.removeAttribute(`open`);return}typeof t.close==`function`&&t.open?t.close():t.removeAttribute(`open`)},t}function Ge(e){return e===`center`||e===`right`||e===`left`?e:`left`}function Ke(e,t,n,r={}){let i=typeof e.trigger==`string`?e.trigger.trim():typeof e.triggerSelector==`string`?e.triggerSelector.trim():``,a=Ge(e.buttonAlign||e.buttonPosition),o=!!r.openByDefault,s=r.ctaTarget||null,c=n.host||null,l=document.createElement(`div`),u=We(e),d=!1,f=0,p=null,m=null;function h(e){d=e,l.classList.toggle(`is-open`,e),c&&(c.dataset.open=e?`true`:`false`),e?(typeof window.clearTimeout==`function`&&window.clearTimeout(f),u.openModal?.()):(u.closeModal?.(),typeof window.clearTimeout==`function`&&window.clearTimeout(f),typeof window.setTimeout==`function`?f=window.setTimeout(()=>{typeof u.resetFlow==`function`&&u.resetFlow()},280):typeof u.resetFlow==`function`&&u.resetFlow())}l.className=`runtime-shell runtime-shell--modal`,l.dataset.bookingWidgetOwner=t,l.dataset.align=a,c&&(c.dataset.buttonPosition=a),i||(p=document.createElement(`div`),m=document.createElement(`button`),p.className=`bw-modal-cta-wrap`,p.dataset.align=a,m.type=`button`,m.className=`btn-book`,m.dataset.bookingWidgetOwner=t,m.innerHTML=`<span>${K.bookNow}</span>`,m.addEventListener(`click`,()=>h(!0)),p.append(m),s&&!n?s.replaceChildren(p):(l.append(p),s&&s.remove())),u.addEventListener(`booking-widget-close`,()=>h(!1));function g(e){e.key===`Escape`&&d&&h(!1)}function _(e){let t=e.target;!i||!t||typeof t.closest!=`function`||t.closest(i)&&(e.preventDefault(),h(!0))}return typeof document.addEventListener==`function`&&(document.addEventListener(`keydown`,g),i&&document.addEventListener(`click`,_,!0)),l.append(u),n.append(l),h(o),{destroy(){typeof document.removeEventListener==`function`&&(document.removeEventListener(`keydown`,g),i&&document.removeEventListener(`click`,_,!0)),typeof window.clearTimeout==`function`&&window.clearTimeout(f);try{u.closeModal?.()}catch{}s&&(s.replaceChildren(),s.isConnected&&s.childNodes.length===0&&s.remove()),l.remove(),c?.isConnected&&c.dataset.bookingWidgetOwner===t&&c.remove()},setOpen:h}}function qe(){if(typeof document>`u`||document.querySelector(`#bookminto-floating-popover-style`))return;let e=document.createElement(`style`);e.id=`bookminto-floating-popover-style`,e.textContent=`384 `,ae(a,c),g){let t=r.querySelector(`[data-payment-element]`),n=g,i=r.querySelector(`[data-action="continue"]`);i&&(i.disabled=!0),t&&n.publishableKey&&n.clientSecret&&at(t,{accent:e.color,clientSecret:g.clientSecret,language:e.language,publishableKey:g.publishableKey}).then(e=>{if(!t.isConnected||g!==n){e.destroy();return}_=e,i&&(i.disabled=m)}).catch(r=>{t.isConnected&&g===n&&rt(t,r,K,e.language)})}}function ae(n,c){Ve(r),r.querySelectorAll(`[data-service-id]`).forEach(e=>{e.addEventListener(`click`,()=>{a=e.getAttribute(`data-service-id`)||``,o=null,s=null,l=null,d=0,p=``,K()})}),r.querySelectorAll(`[data-staff-id]`).forEach(e=>{e.addEventListener(`click`,()=>{let t=e.getAttribute(`data-staff-id`)||null;if(t===l)return;l=t,p=``,d=0,v=k();let n=P();o&&!n.availableDates.includes(o)?(o=null,s=null):s&&!(n.slotsByDate.get(o)||[]).some(e=>e.id===s)&&(s=null),K(),$(r,{containerSelector:`.panel-right-scroll`,margins:{top:8,bottom:12}})})}),r.querySelectorAll(`.staff-chip-avatar`).forEach(e=>{let t=()=>e.remove();e.addEventListener(`error`,t,{once:!0}),e.complete&&!e.naturalWidth&&t()}),r.querySelectorAll(`[data-date]`).forEach(e=>{e.addEventListener(`click`,()=>{o=e.getAttribute(`data-date`),s=null,p=``,K(),$(r,{containerSelector:`.panel-right-scroll`,margins:{top:8,bottom:12}})})}),r.querySelectorAll(`[data-slot-id]`).forEach(e=>{e.addEventListener(`click`,()=>{s=e.getAttribute(`data-slot-id`),p=``,K(),$(r,{containerSelector:`.panel-right-scroll`,margins:{top:8,bottom:12}})})}),r.querySelector(`[data-action="prev-dates"]`)?.addEventListener(`click`,()=>{d=Math.max(0,d-5),K()}),r.querySelector(`[data-action="next-dates"]`)?.addEventListener(`click`,()=>{d=Math.min(Math.max(0,n.length-5),d+5),K()}),r.querySelector(`[data-action="back"]`)?.addEventListener(`click`,()=>{V(),i=Math.max(0,i-1),p=``,K()}),r.querySelector(`[data-action="close"]`)?.addEventListener(`click`,()=>{r.dispatchEvent(new CustomEvent(`booking-widget-close`,{bubbles:!0}))}),r.querySelector(`[data-action="close-success"]`)?.addEventListener(`click`,()=>{r.dispatchEvent(new CustomEvent(`booking-widget-close`,{bubbles:!0})),window.setTimeout(()=>ne(),300)}),r.querySelectorAll(`.finput`).forEach(e=>{e.addEventListener(`input`,()=>{V();let e=r.querySelector(`[data-action="continue"]`);e&&(e.disabled=!L(c)||m)})}),r.querySelector(`[data-action="waitlist-open"]`)?.addEventListener(`click`,()=>{T=!0,p=``;let e=o||new Date().toISOString().slice(0,10),t=new Date(`${e}T12:00:00`);t.setDate(t.getDate()+14),O={customerEmail:C.customerEmail,customerName:C.customerName,dateFrom:O.dateFrom||e,dateTo:O.dateTo||t.toISOString().slice(0,10)},K(),$(r,{containerSelector:`.panel-right-scroll`,margins:{top:8,bottom:12}})}),r.querySelectorAll(`[data-waitlist-field]`).forEach(e=>{e.addEventListener(`input`,()=>{let t=e.getAttribute(`data-waitlist-field`);t&&(O[t]=e.value)})}),r.querySelector(`[data-action="waitlist-submit"]`)?.addEventListener(`click`,()=>{if(E)return;let n=A();E=!0,p=``,K(),Qe(e,{customerEmail:O.customerEmail,customerName:O.customerName,dateFrom:O.dateFrom,dateTo:O.dateTo,serviceId:n?.id||n?.name||``}).then(()=>{E=!1,D=!0,T=!1,K()}).catch(e=>{E=!1,p=e instanceof Error?e.message:t.waitlistError,K()})}),r.querySelector(`[data-action="continue"]`)?.addEventListener(`click`,()=>{if(V(),i===0&&A()){i=1,p=``,K();return}if(i===1&&I(c)){i=2,p=``,K();return}if(g){oe();return}i===2&&se(c)})}async function oe(){if(!g||!_){p=`Payment form is still loading. Please wait a moment.`,K();return}m=!0,p=``;let n=_,a=g,o=r.querySelector(`[data-action="continue"]`);o&&(o.disabled=!0,o.textContent=t.processing);try{let r=a.confirmedPaymentIntentId||await n.confirm();if(g!==a||_!==n)return;a.confirmedPaymentIntentId=r;let o=await ot(e,{bookingId:a.bookingId,paymentIntentId:r});if(g!==a||_!==n)return;if(o.processing||!(o.ok||o.alreadyConfirmed))throw Error(t.paymentError);h={bookingId:a.bookingId,id:a.bookingId},g=null,i=3}catch(e){if(g!==a||_!==n)return;p=e instanceof Error?e.message:t.genericError;let i=r.querySelector(`.runtime-state--danger`);i||(i=document.createElement(`p`),i.className=`runtime-state runtime-state--danger`,i.setAttribute(`role`,`alert`),r.querySelector(`[data-payment-element]`)?.after(i)),i.textContent=p}finally{g===a&&_===n?(m=!1,o&&(o.disabled=!1,o.textContent=t.payDepositCta)):g||(m=!1,K())}}async function se(n){let r=A(),a=I(n);if(!(!r||!a||m)&&L(n)){m=!0,p=``,K();try{let t=await Ze(e,{answers:Object.keys(C.answers||{}).length?C.answers:void 0,clientRequestId:v,replaceTerminalPayment:x||void 0,publicationRevision:e.publicationRevision,customerEmail:C.customerEmail.trim(),customerName:C.customerName.trim(),customerPhone:C.customerPhone.trim(),language:e.language||`en`,notes:C.notes.trim(),rebookingOptIn:C.rebookingOptIn||void 0,serviceId:r.id||r.name,serviceName:r.name,staffId:M(),startsAt:a.startsAt});if(t?.redirectedToCheckout)return;let n=nt(t,e.language);n?(x=!1,g=n):(h=t||{bookingId:`local`},i=3)}catch(e){x=e?.code===`terminal_payment_replacement_available`,e?.code===`staff_unavailable`?(i=1,s=null,p=t.staffUnavailable):p=x?t.replacePaymentNote:e instanceof Error?e.message:t.genericError}finally{m=!1,K()}}}return K(),r.addEventListener(`cancel`,e=>{e.preventDefault(),r.dispatchEvent(new CustomEvent(`booking-widget-close`,{bubbles:!0}))}),r.preselectBooking=t=>{if(!(!t||typeof t!=`object`)){if(t.serviceId&&(t.serviceId!==a&&(l=null),a=t.serviceId),t.startsAt){let n=ve(t.startsAt,e.timezone),r=(P().slotsByDate.get(n)||[]).find(e=>e.startsAt===t.startsAt);r?(o=n,s=r.id,i=2):i=Math.max(i,1)}else t.serviceId&&(i=Math.max(i,1));K()}},r.resetFlow=ne,r.openModal=()=>{if(e.previewMode){r.classList.add(`is-open`),r.setAttribute(`data-open`,`true`),r.setAttribute(`open`,``);return}typeof r.showModal==`function`&&!r.open?r.showModal():r.setAttribute(`open`,``)},r.closeModal=()=>{if(e.previewMode){r.classList.remove(`is-open`),r.removeAttribute(`data-open`),r.removeAttribute(`open`);return}typeof r.close==`function`&&r.open?r.close():r.removeAttribute(`open`)},r}function Mt(e){return e===`center`||e===`right`||e===`left`?e:`left`}function Nt(e,t,n,r={}){let i=c(e.language),a=typeof e.trigger==`string`?e.trigger.trim():typeof e.triggerSelector==`string`?e.triggerSelector.trim():``,o=``;if(a&&typeof document.querySelector==`function`)try{document.querySelector(a),o=a}catch{}let s=Mt(e.buttonAlign||e.buttonPosition),l=!!r.openByDefault,u=r.ctaTarget||null,d=n.host||null,f=document.createElement(`div`),p=jt(e),m=!1,h=0,g=null,_=null;function v(e){m=e,f.classList.toggle(`is-open`,e),d&&(d.dataset.open=e?`true`:`false`),e?(typeof window.clearTimeout==`function`&&window.clearTimeout(h),p.openModal?.()):(p.closeModal?.(),typeof window.clearTimeout==`function`&&window.clearTimeout(h),typeof window.setTimeout==`function`?h=window.setTimeout(()=>{typeof p.resetFlow==`function`&&p.resetFlow()},280):typeof p.resetFlow==`function`&&p.resetFlow())}f.className=`runtime-shell runtime-shell--modal`,f.dataset.bookingWidgetOwner=t,f.dataset.align=s,d&&(d.dataset.buttonPosition=s),!o&&!r.suppressCta&&(g=document.createElement(`div`),_=document.createElement(`button`),g.className=`bw-modal-cta-wrap`,g.dataset.align=s,_.type=`button`,_.className=`btn-book`,_.dataset.bookingWidgetOwner=t,_.innerHTML=`<span>${i.bookNow}</span>`,_.addEventListener(`click`,()=>v(!0)),g.append(_),u&&!n?u.replaceChildren(g):(f.append(g),u&&u.remove())),p.addEventListener(`booking-widget-close`,()=>v(!1));function y(e){e.key===`Escape`&&m&&v(!1)}function b(e){let t=e.target;!o||!t||typeof t.closest!=`function`||t.closest(o)&&(e.preventDefault(),v(!0))}return typeof document.addEventListener==`function`&&(document.addEventListener(`keydown`,y),o&&document.addEventListener(`click`,b,!0)),f.append(p),n.append(f),v(l),{destroy(){typeof document.removeEventListener==`function`&&(document.removeEventListener(`keydown`,y),o&&document.removeEventListener(`click`,b,!0)),typeof window.clearTimeout==`function`&&window.clearTimeout(h);try{p.closeModal?.()}catch{}u&&(u.replaceChildren(),u.isConnected&&u.childNodes.length===0&&u.remove()),f.remove(),d?.isConnected&&d.dataset.bookingWidgetOwner===t&&d.remove()},panel:p,setOpen:v}}function Pt(){if(typeof document>`u`||document.querySelector(`#bookminto-floating-popover-style`))return;let e=document.createElement(`style`);e.id=`bookminto-floating-popover-style`,e.textContent=` 281 385 .bw-floating-popover { 282 386 position: fixed !important; … … 359 463 } 360 464 } 361 `,document.head.appendChild(e)}let Y=`2147483647`;function Je(){if(typeof document>`u`||document.querySelector(`#bookminto-overlay-host-style`))return;let e=document.createElement(`style`);e.id=`bookminto-overlay-host-style`,e.textContent=`465 `,document.head.appendChild(e)}let Ft=`2147483647`;function It(){if(typeof document>`u`||document.querySelector(`#bookminto-overlay-host-style`))return;let e=document.createElement(`style`);e.id=`bookminto-overlay-host-style`,e.textContent=` 362 466 .bw-widget-shadow-host[data-variant="floating"], 363 467 .bw-widget-shadow-host[data-variant="modal"][data-trigger-mode="external"] { … … 369 473 padding: 0 !important; 370 474 border: 0 !important; 371 z-index: ${ Y} !important;475 z-index: ${Ft} !important; 372 476 pointer-events: none !important; 373 477 transform: none !important; … … 406 510 border: 0 !important; 407 511 overflow: visible !important; 408 z-index: ${ Y} !important;512 z-index: ${Ft} !important; 409 513 pointer-events: auto !important; 410 514 transform: none !important; … … 437 541 box-sizing: border-box !important; 438 542 } 439 `,document.head.appendChild(e)}function Ye(){Je()}function Xe(e){return Je(),e?.style?.setProperty?(e.style.setProperty(`position`,`fixed`,`important`),e.style.setProperty(`inset`,`0`,`important`),e.style.setProperty(`width`,`auto`,`important`),e.style.setProperty(`height`,`auto`,`important`),e.style.setProperty(`margin`,`0`,`important`),e.style.setProperty(`padding`,`0`,`important`),e.style.setProperty(`border`,`0`,`important`),e.style.setProperty(`z-index`,Y,`important`),e.style.setProperty(`pointer-events`,`none`,`important`),e.style.setProperty(`transform`,`none`,`important`),e.style.setProperty(`filter`,`none`,`important`),e.style.setProperty(`isolation`,`isolate`,`important`),e.style.setProperty(`contain`,`none`,`important`),e.style.setProperty(`overflow`,`visible`,`important`),e):e}function Ze(e){return Je(),e?.style?.setProperty?(e.style.setProperty(`position`,`fixed`,`important`),e.style.setProperty(`left`,`0`,`important`),e.style.setProperty(`right`,`0`,`important`),e.style.setProperty(`bottom`,`0`,`important`),e.style.setProperty(`top`,`auto`,`important`),e.style.setProperty(`inset`,`auto 0 0 0`,`important`),e.style.setProperty(`width`,`100%`,`important`),e.style.setProperty(`height`,`auto`,`important`),e.style.setProperty(`max-height`,`100vh`,`important`),e.style.setProperty(`margin`,`0`,`important`),e.style.setProperty(`padding`,`0`,`important`),e.style.setProperty(`border`,`0`,`important`),e.style.setProperty(`overflow`,`visible`,`important`),e.style.setProperty(`z-index`,Y,`important`),e.style.setProperty(`pointer-events`,`auto`,`important`),e.style.setProperty(`transform`,`none`,`important`),e.style.setProperty(`filter`,`none`,`important`),e.style.setProperty(`isolation`,`isolate`,`important`),e.style.setProperty(`contain`,`none`,`important`),e):e}function X(e){if(!(!e||!document.body)){if(e.parentNode!==document.body){document.body.append(e);return}document.body.lastElementChild!==e&&document.body.append(e)}}function Qe(e){let t=new Date(`${e}T12:00:00`);return{day:String(t.getDate()),month:new Intl.DateTimeFormat(`en-US`,{month:`short`}).format(t).replace(`.`,``),weekday:new Intl.DateTimeFormat(`en-US`,{weekday:`short`}).format(t).replace(`.`,``)}}function $e(e){return e?new Intl.DateTimeFormat(`en-US`,{weekday:`short`,day:`numeric`,month:`short`}).format(new Date(`${e}T12:00:00`)):``}function et(e){if(!e.length)return``;let t=new Intl.DateTimeFormat(`en-US`,{day:`numeric`,month:`short`});return`${t.format(new Date(`${e[0]}T12:00:00`))} - ${t.format(new Date(`${e[e.length-1]}T12:00:00`))}`}function tt(e){let t=Math.min(e+1,K.widgetStepLabels.length),n=K.widgetStepLabels[t-1];return` 543 `,document.head.appendChild(e)}function Lt(){It()}function Rt(e){return It(),e?.style?.setProperty?(e.style.setProperty(`position`,`fixed`,`important`),e.style.setProperty(`inset`,`0`,`important`),e.style.setProperty(`width`,`auto`,`important`),e.style.setProperty(`height`,`auto`,`important`),e.style.setProperty(`margin`,`0`,`important`),e.style.setProperty(`padding`,`0`,`important`),e.style.setProperty(`border`,`0`,`important`),e.style.setProperty(`z-index`,Ft,`important`),e.style.setProperty(`pointer-events`,`none`,`important`),e.style.setProperty(`transform`,`none`,`important`),e.style.setProperty(`filter`,`none`,`important`),e.style.setProperty(`isolation`,`isolate`,`important`),e.style.setProperty(`contain`,`none`,`important`),e.style.setProperty(`overflow`,`visible`,`important`),e):e}function zt(e){return It(),e?.style?.setProperty?(e.style.setProperty(`position`,`fixed`,`important`),e.style.setProperty(`left`,`0`,`important`),e.style.setProperty(`right`,`0`,`important`),e.style.setProperty(`bottom`,`0`,`important`),e.style.setProperty(`top`,`auto`,`important`),e.style.setProperty(`inset`,`auto 0 0 0`,`important`),e.style.setProperty(`width`,`100%`,`important`),e.style.setProperty(`height`,`auto`,`important`),e.style.setProperty(`max-height`,`100vh`,`important`),e.style.setProperty(`margin`,`0`,`important`),e.style.setProperty(`padding`,`0`,`important`),e.style.setProperty(`border`,`0`,`important`),e.style.setProperty(`overflow`,`visible`,`important`),e.style.setProperty(`z-index`,Ft,`important`),e.style.setProperty(`pointer-events`,`auto`,`important`),e.style.setProperty(`transform`,`none`,`important`),e.style.setProperty(`filter`,`none`,`important`),e.style.setProperty(`isolation`,`isolate`,`important`),e.style.setProperty(`contain`,`none`,`important`),e):e}function Bt(e){if(!(!e||!document.body)){if(e.parentNode!==document.body){document.body.append(e);return}document.body.lastElementChild!==e&&document.body.append(e)}}function Vt(e,t,n){try{return new Intl.DateTimeFormat(n||`en-US`,{timeZone:t||void 0,weekday:`short`,day:`numeric`,month:`short`}).format(new Date(e))}catch{return``}}function Ht(e,t,n,r){try{return new Intl.DateTimeFormat(r||`en-US`,{hour:`numeric`,minute:`2-digit`,hour12:n!==`24h`,timeZone:t||void 0}).format(new Date(e))}catch{return``}}function Ut(e,t={}){let n=c(e.language),r=u(e.language),i=document.createElement(`div`);i.className=`bw-next-times`;function a(){let t=String(e.service||``).trim();return t?e.services.find(e=>e.id===t||e.name===t)||null:e.services[0]||null}function o(){let o=a();if(!o){i.innerHTML=``,i.hidden=!0;return}i.hidden=!1;let s=_e(e,o,3);if(!s.length){i.innerHTML=` 544 <span class="bw-next-times__label">${S(n.nextTimesLabel)}</span> 545 <span class="bw-next-times__empty">${S(n.noNextTimes)}</span> 546 <button type="button" class="bw-next-times__all">${S(n.seeAllTimes)}</button> 547 `,i.querySelector(`.bw-next-times__all`)?.addEventListener(`click`,()=>t.onOpen?.());return}i.innerHTML=` 548 <span class="bw-next-times__label">${S(n.nextTimesLabel)}</span> 549 <span class="bw-next-times__slots"> 550 ${s.map(t=>` 551 <button type="button" class="bw-next-times__slot" data-starts-at="${S(t.startsAt)}"> 552 <b>${S(Vt(t.startsAt,e.timezone,r))}</b> 553 <span>${S(Ht(t.startsAt,e.timezone,e.timeFormat,r))}</span> 554 </button>`).join(``)} 555 </span> 556 <button type="button" class="bw-next-times__all">${S(n.seeAllTimes)}</button> 557 `,i.querySelectorAll(`.bw-next-times__slot`).forEach(e=>{e.addEventListener(`click`,()=>{let n=e.getAttribute(`data-starts-at`);n&&t.onSelectSlot?.(o.id||o.name,n)})}),i.querySelector(`.bw-next-times__all`)?.addEventListener(`click`,()=>t.onOpen?.())}return o(),i}function Wt(e,t){let n=new Date(`${e}T12:00:00`);return{day:String(n.getDate()),month:new Intl.DateTimeFormat(t||`en-US`,{month:`short`}).format(n).replace(`.`,``),weekday:new Intl.DateTimeFormat(t||`en-US`,{weekday:`short`}).format(n).replace(`.`,``)}}function Gt(e){return f(e,``).split(/\s+/).filter(Boolean)[0]||``}function Kt(e){return f(e,``).split(/\s+/).filter(Boolean).slice(0,2).map(e=>e.charAt(0)).join(``).toUpperCase()}function qt(e){let t=f(e,``);return/^#[0-9a-fA-F]{6}$/.test(t)?t:``}function Jt(e,t){let n=f(e,``);return n.startsWith(`https://`)||t&&/^data:image\/(?:png|jpe?g|webp|gif);base64,/i.test(n)?n:``}function Yt(e,t){return e?new Intl.DateTimeFormat(t||`en-US`,{weekday:`short`,day:`numeric`,month:`short`}).format(new Date(`${e}T12:00:00`)):``}function Xt(e,t){if(!e.length)return``;let n=new Intl.DateTimeFormat(t||`en-US`,{day:`numeric`,month:`short`});return`${n.format(new Date(`${e[0]}T12:00:00`))} - ${n.format(new Date(`${e[e.length-1]}T12:00:00`))}`}function Zt(e,t){let n=Math.min(e+1,t.widgetStepLabels.length),r=t.widgetStepLabels[n-1];return` 440 558 <div 441 559 class="stepper" 442 560 role="progressbar" 443 aria-label=" Booking progress"561 aria-label="${S(t.progressLabel)}" 444 562 aria-valuemin="1" 445 aria-valuemax="${ K.widgetStepLabels.length}"446 aria-valuenow="${ t}"447 aria-valuetext=" Step ${t} of ${K.widgetStepLabels.length}: ${l(n)}"563 aria-valuemax="${t.widgetStepLabels.length}" 564 aria-valuenow="${n}" 565 aria-valuetext="${S(t.stepLabel)} ${n} ${S(t.stepOf)} ${t.widgetStepLabels.length}: ${S(r)}" 448 566 > 449 567 <div class="stepper-meta" aria-hidden="true"> 450 <span> Step ${t} of ${K.widgetStepLabels.length}</span>451 <strong>${ l(n)}</strong>568 <span>${S(t.stepLabel)} ${n} ${S(t.stepOf)} ${t.widgetStepLabels.length}</span> 569 <strong>${S(r)}</strong> 452 570 </div> 453 571 <div class="stepper-track" aria-hidden="true"> 454 ${ K.widgetStepLabels.map((t,n)=>`<span class="stepper-segment${n<=e?` is-complete`:``}"></span>`).join(``)}572 ${t.widgetStepLabels.map((t,n)=>`<span class="stepper-segment${n<=e?` is-complete`:``}"></span>`).join(``)} 455 573 </div> 456 574 </div> 457 `}function nt(e,t,n){if(e.classList&&typeof e.classList.toggle==`function`){e.classList.toggle(t,n);return}let r=new Set(String(e.className||``).split(/\s+/).filter(Boolean));n?r.add(t):r.delete(t),e.className=Array.from(r).join(` `)}function rt(e){let t=document.createElement(`section`),n=e.variant===`floating`,r=n,i=_e(e),a=i&&e.services.length<=1,c=+!!a,u=e.services[0]?.id||e.services[0]?.name||``,p=null,m=null,h=0,g=``,_=`error`,v=!1,y=null,b=null,x=null,S={customerEmail:``,customerName:``,customerPhone:``,notes:``};t.className=n?`widget closed`:i?`widget widget--inline widget--commerce open`:`widget widget--inline open`,t.setAttribute(`data-booking-widget-runtime`,``),G(t,e.color);function C(){return e.services.find(e=>(e.id||e.name)===u)||e.services[0]||null}function w(){return oe(e,C())}function T(e){return e.slice(h,h+5)}function E(e){return!p||!m?null:(e.get(p)||[]).find(e=>e.id===m)||null}function D(e){return c===0?!!C():c===1?!!E(e):c===2?!!(S.customerName.trim()&&S.customerEmail.trim()):!1}function O(){return v?K.processing:c===0?K.continueFromService:b?K.payDepositCta:(B(e)||z(e))&&c===1?K.checkoutCta:c===2?K.confirmCta:K.continueDefault}function k(e){let t=(Number(e)||0)/100;return new Intl.NumberFormat(`en-US`,{style:`currency`,currency:`EUR`}).format(t)}function ee(){return`575 `}function Qt(e,t,n){if(e.classList&&typeof e.classList.toggle==`function`){e.classList.toggle(t,n);return}let r=new Set(String(e.className||``).split(/\s+/).filter(Boolean));n?r.add(t):r.delete(t),e.className=Array.from(r).join(` `)}function $t(e){let t=c(e.language),n=u(e.language),r=document.createElement(`section`),i=e.variant===`floating`,a=i,o=He(e),s=(o||!!e.service)&&e.services.length<=1,l=+!!s,d=e.services[0]?.id||e.services[0]?.name||``,p=null,m=null,h=null,g=0,_=e.calendarVerification===`external_unverified`?{de:`Die Kalenderverfügbarkeit kann derzeit nicht geprüft werden. Bitte versuchen Sie es später erneut.`,en:`Calendar availability cannot currently be verified. Please try again later.`,es:`No se puede verificar la disponibilidad del calendario ahora mismo. Inténtalo más tarde.`,fr:`La disponibilité du calendrier ne peut pas être vérifiée. Réessayez plus tard.`,it:`La disponibilità del calendario non può essere verificata al momento. Riprova più tardi.`,nl:`De beschikbaarheid van de agenda kan nu niet worden gecontroleerd. Probeer het later opnieuw.`}[e.language]||`Calendar availability cannot currently be verified. Please try again later.`:``,v=`error`,x=!1,C=null,T=null,E=null,D=P(),O=!1,k={answers:{},customerEmail:``,customerName:``,customerPhone:``,customerPhoneCountry:``,notes:``,rebookingOptIn:!1,smsOptIn:!1,whatsappOptIn:!1},A=!1,j=!1,M=!1,N={customerEmail:``,customerName:``,dateFrom:``,dateTo:``};function P(){try{if(typeof crypto?.randomUUID==`function`)return crypto.randomUUID()}catch{}return`bmr-${Date.now().toString(36)}-${Math.random().toString(36).slice(2,12)}`}r.className=i?`widget closed`:o?`widget widget--inline widget--commerce open`:`widget widget--inline open`,r.setAttribute(`data-booking-widget-runtime`,``),Q(r,e.color);function F(){return e.services.find(e=>(e.id||e.name)===d)||e.services[0]||null}function I(){return e.staffDisplayMode===`customer_choice`?he(e,F()):[]}function L(){if(h)return I().some(e=>e.id===h)?h:void 0}function R(){let e=L();if(!e)return``;let t=I().find(t=>t.id===e);return t?f(t.name,``):``}function te(){return ye(e,F(),L())}function z(e){return e.slice(g,g+5)}function B(e){return!p||!m?null:(e.get(p)||[]).find(e=>e.id===m)||null}function V(t){if(l===0)return!!F();if(l===1)return!!B(t);if(l===2){let t=F();return Number(t?.depositCents)>0&&e.features?.deposits!==!0||!k.customerName.trim()||!k.customerEmail.trim()?!1:(F()?.bookingQuestions||[]).every(e=>!e.required||!!String(k.answers?.[e.id]||``).trim())}return!1}function ne(){return x?t.processing:l===0?t.continueFromService:T?t.payDepositCta:(Z(e)||X(e))&&l===1?t.checkoutCta:l===2?O?t.replacePaymentCta:t.confirmCta:t.continueDefault}function H(e){let t=(Number(e)||0)/100;return new Intl.NumberFormat(n,{style:`currency`,currency:`EUR`}).format(t)}function U(){return` 458 576 <div class="details-step"> 459 <p class="section-title">${ l(K.depositHeading)}</p>460 <div class="confirm-sub" style="text-align:left;margin-bottom:12px">${ l(K.depositNote)}</div>577 <p class="section-title">${S(t.depositHeading)}</p> 578 <div class="confirm-sub" style="text-align:left;margin-bottom:12px">${S(t.depositNote)}</div> 461 579 <div class="summary-box"> 462 <div class="summary-row"><span><strong>${ l(k(b.depositCents))}</strong> deposit</span></div>580 <div class="summary-row"><span><strong>${S(H(T.depositCents))}</strong> ${S(t.depositLabel)}</span></div> 463 581 </div> 464 < div class="bw-payment-element" data-payment-element></div>582 <slot name="bm-payment-element" data-payment-element><div class="bw-payment-element"></div></slot> 465 583 </div> 466 `}function A(){return g?`<div role="alert" class="widget-message${_===`info`?` widget-message--info`:``}">${l(g)}</div>`:``}function j(e,t){return`<div class="runtime-state${t===`danger`?` runtime-state--danger`:``}">${l(e)}</div>`}function M(){return e.loadError?j(K.loadError,`danger`):e.services.length?e.services.map(e=>{let t=e.id||e.name;return` 467 <button type="button" class="service-card${t===u?` selected`:``}" data-service-id="${l(t)}"> 584 `}function re(){return _?`<div role="alert" tabindex="-1" class="widget-message${v===`info`?` widget-message--info`:``}">${S(_)}</div>`:``}function W(e,t){return`<div class="runtime-state${t===`danger`?` runtime-state--danger`:``}">${S(e)}</div>`}function ie(){return e.loadError?W(t.loadError,`danger`):e.services.length?e.services.map(t=>{let n=t.id||t.name;return` 585 <button type="button" class="service-card${n===d?` selected`:``}" data-service-id="${S(n)}"> 586 ${Be(t.imageUrl,e.previewMode)} 468 587 <div class="service-info"> 469 <div class="service-name">${ l(e.name)}</div><div class="service-price">${l(d(e))}</div>470 ${ e.description?`<div class="service-desc">${l(e.description)}</div>`:``}588 <div class="service-name">${S(t.name)}</div><div class="service-price">${S(w(t))}</div> 589 ${t.description?`<div class="service-desc">${S(t.description)}</div>`:``} 471 590 </div> 472 <div class="service-duration">${ l(o(e.durationMinutes))}</div>591 <div class="service-duration">${S(y(t.durationMinutes))}</div> 473 592 </button> 474 `}).join(``):j(K.emptyServices)}function N(t,n){let r=T(t),i=p&&n.get(p)||[],o=h>0,s=h+5<t.length;return` 475 ${a?``:`<button class="back-btn" type="button" data-action="back">← ${l(K.back)}</button>`} 476 <p class="section-title">${l(K.chooseDate)}</p><p class="booking-timezone">${l(f(e))}</p> 593 `}).join(``):W(t.emptyServices)}function G(n,r){return e.features?.waitlist!==!0||!n?``:M?`<div class="runtime-state waitlist-joined" role="status">${S(t.waitlistJoined)}</div>`:A?` 594 <div class="waitlist-form"> 595 <p class="section-title">${S(t.waitlistHeading)}</p> 596 <p class="waitlist-note">${S(t.waitlistNote)}</p> 597 <div class="form-field"> 598 <label class="form-label" for="bm-wl-name">${S(t.nameLabel)}</label> 599 <input class="form-input" id="bm-wl-name" data-waitlist-field="customerName" autocomplete="name" value="${S(N.customerName)}" /> 600 </div> 601 <div class="form-field"> 602 <label class="form-label" for="bm-wl-email">${S(t.emailLabel)}</label> 603 <input class="form-input" id="bm-wl-email" data-waitlist-field="customerEmail" autocomplete="email" type="email" value="${S(N.customerEmail)}" /> 604 </div> 605 <div class="form-row"> 606 <div class="form-field"> 607 <label class="form-label" for="bm-wl-from">${S(t.waitlistFrom)}</label> 608 <input class="form-input" id="bm-wl-from" data-waitlist-field="dateFrom" type="date" value="${S(N.dateFrom)}" /> 609 </div> 610 <div class="form-field"> 611 <label class="form-label" for="bm-wl-to">${S(t.waitlistTo)}</label> 612 <input class="form-input" id="bm-wl-to" data-waitlist-field="dateTo" type="date" value="${S(N.dateTo)}" /> 613 </div> 614 </div> 615 <button type="button" class="cta-btn" data-action="waitlist-submit" ${j?`disabled`:``}> 616 <span>${S(j?t.processing:t.waitlistSubmit)}</span> 617 </button> 618 </div> 619 `:` 620 <button type="button" class="waitlist-cta" data-action="waitlist-open"> 621 ${S(t.waitlistCta)} 622 </button> 623 `}function K(){let n=I();if(n.length<2)return``;let r=L(),i=n.map(t=>{let n=f(t.name,``),i=qt(t.color),a=Jt(t.avatarUrl,e.previewMode),o=t.id===r,s=a?`<img class="staff-chip-avatar" src="${S(a)}" alt="" loading="lazy" decoding="async" referrerpolicy="no-referrer">`:``;return` 624 <button type="button" class="staff-chip${o?` selected`:``}" data-staff-id="${S(t.id)}" aria-pressed="${o}"> 625 <span class="staff-chip-dot"${i?` style="background:${S(i)}"`:``}>${S(Kt(n))}${s}</span> 626 <span class="staff-chip-name">${S(Gt(n)||n)}</span> 627 </button> 628 `}).join(``);return` 629 <p class="section-title">${S(t.staffPickerLabel)}</p> 630 <div class="staff-row" role="group" aria-label="${S(t.staffPickerLabel)}"> 631 <button type="button" class="staff-chip${r?``:` selected`}" data-staff-id="" aria-pressed="${!r}"> 632 <span class="staff-chip-name">${S(t.anyStaff)}</span> 633 </button> 634 ${i} 635 </div> 636 `}function ae(r,i){let a=z(r),o=p&&i.get(p)||[],c=g>0,l=g+5<r.length,u=F(),d=s?``:`<button class="back-btn" type="button" data-action="back">← ${S(t.back)}</button>`;return ge(e,u)?` 637 ${d} 638 ${W(t.serviceNotBookable,`danger`)} 639 `:` 640 ${d} 641 ${K()} 642 <p class="section-title">${S(t.chooseDate)}</p><p class="booking-timezone">${S(ee(e))}</p> 477 643 <div class="date-nav"> 478 <span class="date-nav-copy">${ l(et(r))}</span>644 <span class="date-nav-copy">${S(Xt(a,n))}</span> 479 645 <div class="date-nav-buttons"> 480 <button type="button" class="date-nav-btn" data-action="prev-dates" aria-label="${ l(K.previousDates)}" ${o?``:`disabled`}>646 <button type="button" class="date-nav-btn" data-action="prev-dates" aria-label="${S(t.previousDates)}" ${c?``:`disabled`}> 481 647 <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9.5 2.5 5 7l4.5 4.5"></path></svg> 482 648 </button> 483 <button type="button" class="date-nav-btn" data-action="next-dates" aria-label="${ l(K.nextDates)}" ${s?``:`disabled`}>649 <button type="button" class="date-nav-btn" data-action="next-dates" aria-label="${S(t.nextDates)}" ${l?``:`disabled`}> 484 650 <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4.5 2.5 9 7l-4.5 4.5"></path></svg> 485 651 </button> … … 487 653 </div> 488 654 <div class="date-grid"> 489 ${ r.length?r.map(e=>{let t=Qe(e);return`490 <button type="button" class="date-btn${e===p?` selected`:``}" data-date="${ l(e)}">491 <div class="date-dow">${ l(t.weekday)}</div>492 <div class="date-day">${ l(t.day)}</div>493 <div class="date-mon">${ l(t.month)}</div>655 ${a.length?a.map(e=>{let t=Wt(e,n);return` 656 <button type="button" class="date-btn${e===p?` selected`:``}" data-date="${S(e)}"> 657 <div class="date-dow">${S(t.weekday)}</div> 658 <div class="date-day">${S(t.day)}</div> 659 <div class="date-mon">${S(t.month)}</div> 494 660 </button> 495 `}).join(``): j(K.emptySlots)}661 `}).join(``):W(t.emptySlots)} 496 662 </div> 497 663 ${p?` 498 <p class="section-title section-title--with-top-gap">${l(K.availableTimes)}</p> 499 ${i.length?`<div class="time-grid"> 500 ${i.map(e=>` 501 <button type="button" class="time-btn${e.id===m?` selected`:``}" data-slot-id="${l(e.id)}"> 502 ${l(e.timeLabel)} 664 <p class="section-title section-title--with-top-gap">${S(t.availableTimes)}</p> 665 ${o.length?`<div class="time-grid"> 666 ${o.map(e=>` 667 <button type="button" class="time-btn${e.id===m?` selected`:``}${e.recommended?` recommended`:``}" data-slot-id="${S(e.id)}"> 668 ${S(e.timeLabel)} 669 ${e.recommended?`<em class="rec-badge">${S(t.recommendedBadge)}</em>`:``} 503 670 </button> 504 671 `).join(``)} 505 </div>`: j(K.emptySlots)}672 </div>`:W(t.emptySlots)} 506 673 `:``} 507 `}function P(e,t){let n=s(e?.price);return` 674 ${!a.length||!o.length?G(u,o):``} 675 `}function oe(r,i){let a=b(r?.price);return` 508 676 <div class="details-step"> 509 <button class="back-btn" type="button" data-action="back">← ${ l(K.back)}</button>677 <button class="back-btn" type="button" data-action="back">← ${S(t.back)}</button> 510 678 <div class="summary-box"> 511 679 <div class="summary-row"> 512 <span>${ l($e(p))} ${l(K.at)} ${l(t?.timeLabel||``)}</span>680 <span>${S(Yt(p,n))} ${S(t.at)} ${S(i?.timeLabel||``)}</span> 513 681 </div> 514 682 <div class="summary-row"> 515 <span>${ l(e?.name||``)} · ${l(o(e?.durationMinutes))}</span>683 <span>${S(r?.name||``)} · ${S(y(r?.durationMinutes))}</span> 516 684 </div> 517 ${n===`Free`?``:`<div class="summary-row"><span>${l(K.totalLabel)}: ${l(n)}</span></div>`} 685 ${R()?`<div class="summary-row"><span>${S(t.withStaff.replace(`{name}`,R()))}</span></div>`:``} 686 ${a===`Free`?``:`<div class="summary-row"><span>${S(t.totalLabel)}: ${S(a)}</span></div>`} 518 687 </div> 519 <p class="section-title">${ l(K.detailsHeading)}</p>688 <p class="section-title">${S(t.detailsHeading)}</p> 520 689 <div class="form-row"> 521 690 <div class="form-field"> 522 <label class="form-label" for="bm-runtime-customerName">${ l(K.nameLabel)}</label>523 <input class="form-input" id="bm-runtime-customerName" name="customerName" autocomplete="name" required placeholder="${ l(K.namePlaceholder)}" value="${l(S.customerName)}" />691 <label class="form-label" for="bm-runtime-customerName">${S(t.nameLabel)}</label> 692 <input class="form-input" id="bm-runtime-customerName" name="customerName" autocomplete="name" required placeholder="${S(t.namePlaceholder)}" value="${S(k.customerName)}" /> 524 693 </div> 694 ${e.sms?.available===!0?` 695 <div class="form-field"> 696 <label class="form-label" for="bm-runtime-customerPhoneCountry">${S(t.phoneCountryLabel)}</label> 697 <select class="form-input" id="bm-runtime-customerPhoneCountry" name="customerPhoneCountry" autocomplete="country"> 698 <option value="">${S(t.chooseOption)}</option> 699 ${pt.map(e=>`<option value="${e}"${k.customerPhoneCountry===e?` selected`:``}>${e} (+${S(ft[e])})</option>`).join(``)} 700 </select> 701 </div> 702 `:``} 525 703 <div class="form-field"> 526 <label class="form-label" for="bm-runtime-customerPhone">${ l(K.phoneLabel)}</label>527 <input class="form-input" id="bm-runtime-customerPhone" name="customerPhone" autocomplete="tel" type="tel" placeholder="${ l(K.phonePlaceholder)}" value="${l(S.customerPhone)}" />704 <label class="form-label" for="bm-runtime-customerPhone">${S(t.phoneLabel)}</label> 705 <input class="form-input" id="bm-runtime-customerPhone" name="customerPhone" autocomplete="tel" type="tel" placeholder="${S(t.phonePlaceholder)}" value="${S(k.customerPhone)}" /> 528 706 </div> 529 707 </div> 530 708 <div class="form-field"> 531 <label class="form-label" for="bm-runtime-customerEmail">${ l(K.emailLabel)}</label>532 <input class="form-input" id="bm-runtime-customerEmail" name="customerEmail" autocomplete="email" type="email" required placeholder="${ l(K.emailPlaceholder)}" value="${l(S.customerEmail)}" />709 <label class="form-label" for="bm-runtime-customerEmail">${S(t.emailLabel)}</label> 710 <input class="form-input" id="bm-runtime-customerEmail" name="customerEmail" autocomplete="email" type="email" required placeholder="${S(t.emailPlaceholder)}" value="${S(k.customerEmail)}" /> 533 711 </div> 534 712 <div class="form-field"> 535 <label class="form-label" for="bm-runtime-notes">${l(K.notesLabel)}</label> 536 <textarea class="form-input form-input--textarea" id="bm-runtime-notes" name="notes" rows="2" maxlength="500" placeholder="${l(K.notesPlaceholder)}">${l(S.notes)}</textarea> 713 <label class="form-label" for="bm-runtime-notes">${S(t.notesLabel)}</label> 714 <textarea class="form-input form-input--textarea" id="bm-runtime-notes" name="notes" rows="2" maxlength="500" placeholder="${S(t.notesPlaceholder)}">${S(k.notes)}</textarea> 715 </div> 716 ${se(r)} 717 ${Number(r?.depositCents)>0&&e.features?.deposits!==!0?`<p class="form-note deposit-unavailable-note" role="note">${S(t.depositUnavailable)}</p>`:``} 718 ${Number(r?.rebookingIntervalWeeks)>0?` 719 <label class="rebooking-optin"> 720 <input type="checkbox" data-rebooking-optin ${k.rebookingOptIn?`checked`:``} /> 721 <span>${S(t.rebookingOptInLabel)}</span> 722 </label> 723 `:``} 724 ${e.sms?.available===!0?` 725 <label class="rebooking-optin"> 726 <input type="checkbox" data-sms-optin ${k.smsOptIn?`checked`:``} /> 727 <span>${S(t.smsOptInLabel)}</span> 728 </label> 729 `:``} 730 ${e.whatsapp?.available===!0?` 731 <label class="rebooking-optin"> 732 <input type="checkbox" data-whatsapp-optin ${k.whatsappOptIn?`checked`:``} /> 733 <span>${S(t.whatsappOptInLabel)}</span> 734 </label> 735 `:``} 736 </div> 737 `}function se(e){let n=Array.isArray(e?.bookingQuestions)?e.bookingQuestions:[];return n.length?` 738 <p class="section-title section-title--with-top-gap">${S(t.questionsHeading)}</p> 739 ${n.map(e=>{let n=`bm-runtime-question-${S(e.id)}`,r=String(k.answers?.[e.id]||``),i=e.required?` <span class="form-required" aria-hidden="true">*</span><span class="sr-only"> ${S(t.requiredHint)}</span>`:``,a=`<label class="form-label" for="${n}">${S(e.label)}${i}</label>`;return e.type===`yesno`?` 740 <div class="form-field"> 741 ${a} 742 <select class="form-input" id="${n}" data-question-id="${S(e.id)}"${e.required?` required`:``}> 743 <option value="">${S(t.chooseOption)}</option> 744 <option value="yes"${r===`yes`?` selected`:``}>${S(t.yesLabel)}</option> 745 <option value="no"${r===`no`?` selected`:``}>${S(t.noLabel)}</option> 746 </select> 747 </div> 748 `:e.type===`choice`?` 749 <div class="form-field"> 750 ${a} 751 <select class="form-input" id="${n}" data-question-id="${S(e.id)}"${e.required?` required`:``}> 752 <option value="">${S(t.chooseOption)}</option> 753 ${(e.options||[]).map(e=>`<option value="${S(e)}"${r===e?` selected`:``}>${S(e)}</option>`).join(``)} 754 </select> 755 </div> 756 `:` 757 <div class="form-field"> 758 ${a} 759 <input class="form-input" id="${n}" data-question-id="${S(e.id)}" maxlength="300"${e.required?` required`:``} value="${S(r)}" /> 760 </div> 761 `}).join(``)} 762 `:``}function ce(r,i){let a=b(r?.price),o=C?.startsAt||i?.startsAt,s=!(e.previewMode||C?.localOnly===!0)&&o?tt({description:r?.name||``,durationMinutes:r?.durationMinutes||C?.durationMinutes,id:C?.bookingId||C?.id,startsAt:o,title:r?.name||t.successHeading}):``,c=s?`<a class="confirm-calendar-link" href="${s}" download="booking.ics">${S(t.addToCalendar)}</a>`:``;return` 763 <div class="widget-success-shell"> 764 <div class="confirm-icon"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"></polyline></svg></div> 765 <div class="confirm-title">${S(C?.localOnly===!0&&!e.previewMode?t.testModeHeading:t.successHeading)}</div> 766 <div class="confirm-sub">${S(e.previewMode?t.previewOnlyNote:C?.localOnly===!0?t.testModeNote:t.successNote)}</div> 767 <div class="summary-box"> 768 <div class="summary-row"><span><strong>${S(C?.customerName||k.customerName)}</strong></span></div> 769 <div class="summary-row"><span>${S(Yt(p,n))} ${S(t.at)} ${S(i?.timeLabel||``)}</span></div> 770 <div class="summary-row"><span>${S(r?.name||``)} · ${S(y(r?.durationMinutes))}</span></div> 771 ${R()?`<div class="summary-row"><span>${S(t.withStaff.replace(`{name}`,R()))}</span></div>`:``} 772 ${a===`Free`?``:`<div class="summary-row"><span>${S(t.totalLabel)}: ${S(a)}</span></div>`} 773 </div> 774 ${c} 775 <button class="cta-btn cta-btn--success" type="button" data-action="close-success">${S(t.close)}</button> 776 </div> 777 `}function le(){let e=r.querySelector(`[name="customerName"]`),t=r.querySelector(`[name="customerEmail"]`),n=r.querySelector(`[name="customerPhone"]`),i=r.querySelector(`[name="customerPhoneCountry"]`),a=r.querySelector(`[name="notes"]`);e&&(k.customerName=e.value),t&&(k.customerEmail=t.value),n&&(k.customerPhone=n.value),i&&(k.customerPhoneCountry=i.value),a&&(k.notes=a.value),r.querySelectorAll(`[data-question-id]`).forEach(e=>{let t=e.getAttribute(`data-question-id`);t&&(k.answers[t]=e.value)});let o=r.querySelector(`[data-rebooking-optin]`);o&&(k.rebookingOptIn=o.checked===!0);let s=r.querySelector(`[data-sms-optin]`);s&&(k.smsOptIn=s.checked===!0);let c=r.querySelector(`[data-whatsapp-optin]`);c&&(k.whatsappOptIn=c.checked===!0)}function q(){E&&=(E.destroy(),null),T=null,l=+!!s,d=e.services[0]?.id||e.services[0]?.name||``,p=null,m=null,h=null,g=0,_=``,v=`error`,x=!1,C=null,D=P(),O=!1,A=!1,j=!1,M=!1,N={customerEmail:``,customerName:``,dateFrom:``,dateTo:``},k={answers:{},customerEmail:``,customerName:``,customerPhone:``,customerPhoneCountry:``,notes:``,rebookingOptIn:!1,smsOptIn:!1,whatsappOptIn:!1},J()}function J(){let n=F(),{availableDates:i,slotsByDate:a}=te();if(n&&i.length&&(!p||!i.includes(p))&&(p=i[0],m=null,g=0),p&&g>0){let e=i.indexOf(p);e>=0&&(e<g||e>=g+5)&&(g=Math.floor(e/5)*5)}let s=B(a),c=l<3||!!T,u=(T||V(a))&&!x,d=``;d=T?U():l===0?ie():l===1?ae(i,a):l===2?oe(n,s):ce(n,s),E&&=(E.destroy(),null),Qt(r,`widget--confirmed`,!T&&l===3);let f=o?``:` 778 <div class="widget-header"> 779 <div class="widget-header-top"> 780 <span class="widget-header-title">${S(e.title||t.title)}</span> 537 781 </div> 538 782 </div> 539 `}function te(t,n){let r=s(t?.price),i=y?.startsAt||n?.startsAt,a=!(e.previewMode||y?.localOnly===!0)&&i?Ee({description:t?.name||``,durationMinutes:t?.durationMinutes||y?.durationMinutes,id:y?.bookingId||y?.id,startsAt:i,title:t?.name||K.successHeading}):``,c=a?`<a class="confirm-calendar-link" href="${a}" download="booking.ics">${l(K.addToCalendar)}</a>`:``;return` 540 <div class="widget-success-shell"> 541 <div class="confirm-icon"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"></polyline></svg></div> 542 <div class="confirm-title">${l(y?.localOnly===!0&&!e.previewMode?K.testModeHeading:K.successHeading)}</div> 543 <div class="confirm-sub">${l(e.previewMode?K.previewOnlyNote:y?.localOnly===!0?K.testModeNote:K.successNote)}</div> 544 <div class="summary-box"> 545 <div class="summary-row"><span><strong>${l(y?.customerName||S.customerName)}</strong></span></div> 546 <div class="summary-row"><span>${l($e(p))} ${l(K.at)} ${l(n?.timeLabel||``)}</span></div> 547 <div class="summary-row"><span>${l(t?.name||``)} · ${l(o(t?.durationMinutes))}</span></div> 548 ${r===`Free`?``:`<div class="summary-row"><span>${l(K.totalLabel)}: ${l(r)}</span></div>`} 549 </div> 550 ${c} 551 <button class="cta-btn cta-btn--success" type="button" data-action="close-success">${l(K.close)}</button> 552 </div> 553 `}function F(){let e=t.querySelector(`[name="customerName"]`),n=t.querySelector(`[name="customerEmail"]`),r=t.querySelector(`[name="customerPhone"]`),i=t.querySelector(`[name="notes"]`);e&&(S.customerName=e.value),n&&(S.customerEmail=n.value),r&&(S.customerPhone=r.value),i&&(S.notes=i.value)}function ne(){x&&=(x.destroy(),null),b=null,c=+!!a,u=e.services[0]?.id||e.services[0]?.name||``,p=null,m=null,h=0,g=``,_=`error`,v=!1,y=null,S={customerEmail:``,customerName:``,customerPhone:``,notes:``},I()}function I(){let n=C(),{availableDates:r,slotsByDate:a}=w();if(n&&r.length&&(!p||!r.includes(p))&&(p=r[0],m=null,h=0),p&&h>0){let e=r.indexOf(p);e>=0&&(e<h||e>=h+5)&&(h=Math.floor(e/5)*5)}let s=E(a),u=c<3||!!b,d=(b||D(a))&&!v,f=``;if(f=b?ee():c===0?M():c===1?N(r,a):c===2?P(n,s):te(n,s),x&&=(x.destroy(),null),nt(t,`widget--confirmed`,!b&&c===3),t.innerHTML=` 554 ${i?``:` 555 <div class="widget-header"> 556 <div class="widget-header-top"> 557 <span class="widget-header-title">${l(e.title||K.title)}</span> 558 </div> 559 </div> 560 ${tt(b?2:c)} 561 `} 783 ${Zt(T?2:l,t)} 784 `,h=R();if(r.innerHTML=` 785 ${f} 562 786 <div class="widget-body"> 563 787 <div class="widget-body-scroll"> 564 ${ i&&n&&c>=1&&c<3?`<p class="commerce-service-hint">${l(n.name)} · ${l(o(n.durationMinutes))}</p>`:``}565 ${ f}566 ${ A()}788 ${o&&n&&l>=1&&l<3?`<p class="commerce-service-hint">${S(n.name)} · ${S(y(n.durationMinutes))}${h?` · ${S(t.withStaff.replace(`{name}`,h))}`:``}</p>`:``} 789 ${d} 790 ${re()} 567 791 </div> 568 ${ u&&c<3?`<button class="cta-btn" type="button" data-action="continue" ${d?``:`disabled`}>569 <span>${ l(O())}</span>792 ${c&&l<3?`<button class="cta-btn" type="button" data-action="continue" ${u?``:`disabled`}> 793 <span>${S(ne())}</span> 570 794 <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1 7h12M7 1l6 6-6 6"></path></svg> 571 795 </button>`:``} 572 796 </div> 573 ${e.showBranding&&! i?`<div class="powered"><span>${l(K.widgetFooterPrefix)}</span> <strong>${l(K.widgetFooterBrand)}</strong></div>`:``}574 `, re(r,a),b){let n=t.querySelector(`[data-payment-element]`),r=b,i=t.querySelector(`[data-action="continue"]`);i&&(i.disabled=!0),n&&r.publishableKey&&r.clientSecret&&Ae(n,{accent:e.color,clientSecret:b.clientSecret,publishableKey:b.publishableKey}).then(e=>{if(!n.isConnected||b!==r){e.destroy();return}x=e,i&&(i.disabled=v)}).catch(e=>{n.isConnected&&b===r&&Oe(n,e,I)})}}function re(n,i){t.querySelectorAll(`[data-service-id]`).forEach(e=>{e.addEventListener(`click`,()=>{u=e.getAttribute(`data-service-id`)||``,p=null,m=null,h=0,g=``,I()})}),t.querySelectorAll(`[data-date]`).forEach(e=>{e.addEventListener(`click`,()=>{p=e.getAttribute(`data-date`),m=null,g=``,I(),q(t,{containerSelector:`.widget-body-scroll`,margins:{top:8,bottom:16}})})}),t.querySelectorAll(`[data-slot-id]`).forEach(e=>{e.addEventListener(`click`,()=>{m=e.getAttribute(`data-slot-id`),g=``,I(),q(t,{containerSelector:`.widget-body-scroll`,margins:{top:8,bottom:16}})})}),t.querySelector(`[data-action="prev-dates"]`)?.addEventListener(`click`,()=>{h=Math.max(0,h-5),I()}),t.querySelector(`[data-action="next-dates"]`)?.addEventListener(`click`,()=>{h=Math.min(Math.max(0,n.length-5),h+5),I()}),t.querySelector(`[data-action="back"]`)?.addEventListener(`click`,()=>{F(),c=Math.max(+!!a,c-1),g=``,_=`error`,I()}),t.querySelector(`[data-action="close-success"]`)?.addEventListener(`click`,()=>{t.dispatchEvent(new CustomEvent(`booking-widget-close`,{bubbles:!0})),window.setTimeout(()=>ne(),r?300:0)}),t.querySelectorAll(`.form-input`).forEach(e=>{e.addEventListener(`input`,()=>{F();let e=t.querySelector(`[data-action="continue"]`);e&&(e.disabled=!D(i)||v)})}),t.querySelector(`[data-action="continue"]`)?.addEventListener(`click`,()=>{if(!v){if(F(),c===0&&C()){c=1,g=``,_=`error`,I();return}if(c===1&&E(i)){if(z(e)||B(e)){ae(i);return}c=2,g=``,_=`error`,I();return}if(b){ie();return}c===2&&ae(i)}})}async function ie(){if(!b||!x){g=`Payment form is still loading. Please wait a moment.`;let e=t.querySelector(`.widget-message`);e?e.textContent=g:I();return}v=!0,g=``;let n=t.querySelector(`[data-action="continue"]`);if(n){n.disabled=!0;let e=n.querySelector(`span`);e&&(e.textContent=K.processing)}try{let t=await x.confirm();await je(e,{bookingId:b.bookingId,paymentIntentId:t}),y={bookingId:b.bookingId,customerName:S.customerName,durationMinutes:C()?.durationMinutes,startsAt:E(w().slotsByDate)?.startsAt},x.destroy(),x=null,b=null,c=3,v=!1,g=``,I()}catch(e){if(v=!1,g=e instanceof Error?e.message:K.genericError,n){n.disabled=!1;let e=n.querySelector(`span`);e&&(e.textContent=K.payDepositCta)}let r=t.querySelector(`.widget-message`);if(!r){let e=t.querySelector(`[data-payment-element]`);r=document.createElement(`div`),r.className=`widget-message`,e?.after(r)}r.textContent=g}}async function ae(t){let n=C(),r=E(t);if(z(e)&&(S.customerName=S.customerName.trim()||`Shopify checkout`,S.customerEmail=S.customerEmail.trim()||`shopify-hold@bookminto.hold`),B(e)&&(S.customerName=S.customerName.trim()||`WooCommerce checkout`,S.customerEmail=S.customerEmail.trim()||`woocommerce-hold@bookminto.hold`),!(!n||!r||!S.customerName.trim()||!S.customerEmail.trim())&&!v){v=!0,g=``,_=`error`,I();try{let t=await Te(e,{customerEmail:S.customerEmail,customerName:S.customerName,customerPhone:S.customerPhone,notes:S.notes,serviceId:n.id||n.name,serviceName:n.name,startsAt:r.startsAt});if(t?.redirectedToCheckout){g=K.redirectingToCheckout,_=`info`,I();return}if(t?.needsCheckout){v=!1,g=`Could not open checkout. Please try again.`,_=`error`,I();return}let i=De(t);if(i){b=i,y={bookingId:t.bookingId,customerName:S.customerName,durationMinutes:n.durationMinutes,startsAt:r.startsAt},v=!1,g=``,I();return}y={bookingId:t&&typeof t==`object`?t.bookingId:void 0,customerName:S.customerName,durationMinutes:n.durationMinutes,localOnly:t?.localOnly===!0,startsAt:r.startsAt},c=3,v=!1,g=``,I()}catch(e){v=!1,g=e instanceof Error?e.message:K.genericError,_=`error`,I()}}}return t.resetFlow=ne,I(),t}function it(e=document.head){if(e.querySelector(`#booking-widget-runtime-style`))return;let t=document.createElement(`style`);t.id=`booking-widget-runtime-style`,t.textContent=`797 ${e.showBranding&&!o?`<div class="powered"><span>${S(t.widgetFooterPrefix)}</span> <strong>${S(t.widgetFooterBrand)}</strong></div>`:``} 798 `,ue(i,a),T){let t=r.querySelector(`[data-payment-element]`),n=T,i=r.querySelector(`[data-action="continue"]`);i&&(i.disabled=!0),t&&n.publishableKey&&n.clientSecret&&at(t,{accent:e.color,clientSecret:T.clientSecret,language:e.language,publishableKey:T.publishableKey}).then(e=>{if(!t.isConnected||T!==n){e.destroy();return}E=e,i&&(i.disabled=x)}).catch(r=>{t.isConnected&&T===n&&rt(t,r,J,e.language)})}}function ue(n,i){Ve(r),r.querySelectorAll(`[data-service-id]`).forEach(e=>{e.addEventListener(`click`,()=>{d=e.getAttribute(`data-service-id`)||``,p=null,m=null,h=null,g=0,_=``,J()})}),r.querySelectorAll(`[data-staff-id]`).forEach(e=>{e.addEventListener(`click`,()=>{let t=e.getAttribute(`data-staff-id`)||null;if(t===h)return;h=t,_=``,v=`error`,g=0,D=P();let n=te();p&&!n.availableDates.includes(p)?(p=null,m=null):m&&!(n.slotsByDate.get(p)||[]).some(e=>e.id===m)&&(m=null),J(),$(r,{containerSelector:`.widget-body-scroll`,margins:{top:8,bottom:16}})})}),r.querySelectorAll(`.staff-chip-avatar`).forEach(e=>{let t=()=>e.remove();e.addEventListener(`error`,t,{once:!0}),e.complete&&!e.naturalWidth&&t()}),r.querySelectorAll(`[data-date]`).forEach(e=>{e.addEventListener(`click`,()=>{p=e.getAttribute(`data-date`),m=null,_=``,J(),$(r,{containerSelector:`.widget-body-scroll`,margins:{top:8,bottom:16}})})}),r.querySelectorAll(`[data-slot-id]`).forEach(e=>{e.addEventListener(`click`,()=>{m=e.getAttribute(`data-slot-id`),_=``,J(),$(r,{containerSelector:`.widget-body-scroll`,margins:{top:8,bottom:16}})})}),r.querySelector(`[data-action="prev-dates"]`)?.addEventListener(`click`,()=>{g=Math.max(0,g-5),J()}),r.querySelector(`[data-action="next-dates"]`)?.addEventListener(`click`,()=>{g=Math.min(Math.max(0,n.length-5),g+5),J()}),r.querySelector(`[data-action="back"]`)?.addEventListener(`click`,()=>{le(),l=Math.max(+!!s,l-1),_=``,v=`error`,J()}),r.querySelector(`[data-action="close-success"]`)?.addEventListener(`click`,()=>{r.dispatchEvent(new CustomEvent(`booking-widget-close`,{bubbles:!0})),window.setTimeout(()=>q(),a?300:0)}),r.querySelectorAll(`.form-input`).forEach(e=>{e.addEventListener(`input`,()=>{le();let e=r.querySelector(`[data-action="continue"]`);e&&(e.disabled=!V(i)||x)})}),r.querySelector(`[data-action="waitlist-open"]`)?.addEventListener(`click`,()=>{A=!0,_=``;let e=p||new Date().toISOString().slice(0,10),t=new Date(`${e}T12:00:00`);t.setDate(t.getDate()+14),N={customerEmail:k.customerEmail,customerName:k.customerName,dateFrom:N.dateFrom||e,dateTo:N.dateTo||t.toISOString().slice(0,10)},J(),$(r,{containerSelector:`.widget-body-scroll`,margins:{top:8,bottom:16}})}),r.querySelectorAll(`[data-waitlist-field]`).forEach(e=>{e.addEventListener(`input`,()=>{let t=e.getAttribute(`data-waitlist-field`);t&&(N[t]=e.value)})}),r.querySelector(`[data-action="waitlist-submit"]`)?.addEventListener(`click`,()=>{if(j)return;let n=F();j=!0,_=``,v=`error`,J(),Qe(e,{customerEmail:N.customerEmail,customerName:N.customerName,dateFrom:N.dateFrom,dateTo:N.dateTo,serviceId:n?.id||n?.name||``}).then(()=>{j=!1,M=!0,A=!1,J()}).catch(e=>{j=!1,_=e instanceof Error?e.message:t.waitlistError,v=`error`,J()})}),r.querySelector(`[data-action="continue"]`)?.addEventListener(`click`,()=>{if(!x){if(le(),l===0&&F()){l=1,_=``,v=`error`,J();return}if(l===1&&B(i)){if(X(e)||Z(e)){fe(i);return}l=2,_=``,v=`error`,J();return}if(T){de();return}l===2&&fe(i)}})}async function de(){if(!T||!E){_=`Payment form is still loading. Please wait a moment.`;let e=r.querySelector(`.widget-message`);e?e.textContent=_:J();return}x=!0,_=``;let n=r.querySelector(`[data-action="continue"]`);if(n){n.disabled=!0;let e=n.querySelector(`span`);e&&(e.textContent=t.processing)}let i=T,a=E;try{let n=i.confirmedPaymentIntentId||await a.confirm();if(T!==i||E!==a)return;i.confirmedPaymentIntentId=n;let r=await ot(e,{bookingId:i.bookingId,paymentIntentId:n});if(T!==i||E!==a)return;if(r.processing||!(r.ok||r.alreadyConfirmed))throw Error(t.paymentError);C={bookingId:T.bookingId,customerName:k.customerName,durationMinutes:F()?.durationMinutes,startsAt:B(te().slotsByDate)?.startsAt},E.destroy(),E=null,T=null,l=3,x=!1,_=``,J()}catch(e){if(T!==i||E!==a)return;if(x=!1,_=e instanceof Error?e.message:t.genericError,n){n.disabled=!1;let e=n.querySelector(`span`);e&&(e.textContent=t.payDepositCta)}let o=r.querySelector(`.widget-message`);if(!o){let e=r.querySelector(`[data-payment-element]`);o=document.createElement(`div`),o.className=`widget-message`,e?.after(o)}o.textContent=_}}async function fe(n){let i=F(),a=B(n);if(!(Number(i?.depositCents)>0&&e.features?.deposits!==!0)&&(X(e)&&(k.customerName=k.customerName.trim()||`Shopify checkout`,k.customerEmail=k.customerEmail.trim()||`shopify-hold@bookminto.hold`),Z(e)&&(k.customerName=k.customerName.trim()||`WooCommerce checkout`,k.customerEmail=k.customerEmail.trim()||`woocommerce-hold@bookminto.hold`),!(!i||!a||!k.customerName.trim()||!k.customerEmail.trim()))){if((k.smsOptIn||k.whatsappOptIn)&&!gt(k.customerPhone,k.customerPhoneCountry||void 0)){_=k.whatsappOptIn&&!k.smsOptIn?t.phoneRequiredForWhatsapp:t.phoneRequiredForSms,v=`error`,J();return}if(!x){x=!0,_=``,v=`error`,J();try{let n=await Ze(e,{answers:Object.keys(k.answers||{}).length?k.answers:void 0,clientRequestId:D,replaceTerminalPayment:O||void 0,publicationRevision:e.publicationRevision,customerEmail:k.customerEmail,customerName:k.customerName,customerPhone:k.customerPhone,customerPhoneCountry:k.customerPhoneCountry||void 0,language:e.language||`en`,notes:k.notes,rebookingOptIn:k.rebookingOptIn||void 0,serviceId:i.id||i.name,serviceName:i.name,smsOptIn:e.sms?.available===!0&&k.smsOptIn||void 0,staffId:L(),startsAt:a.startsAt,whatsappOptIn:e.whatsapp?.available===!0&&k.whatsappOptIn||void 0});if(n?.redirectedToCheckout){_=t.redirectingToCheckout,v=`info`,J();return}if(n?.needsCheckout){x=!1,_=`Could not open checkout. Please try again.`,v=`error`,J();return}let r=nt(n,e.language);if(r){O=!1,T=r,C={bookingId:n.bookingId,customerName:k.customerName,durationMinutes:i.durationMinutes,startsAt:a.startsAt},x=!1,_=``,J();return}C={bookingId:n&&typeof n==`object`?n.bookingId:void 0,customerName:k.customerName,durationMinutes:i.durationMinutes,localOnly:n?.localOnly===!0,startsAt:a.startsAt},l=3,x=!1,_=``,J()}catch(e){x=!1,O=e?.code===`terminal_payment_replacement_available`;let n=e?.code===`staff_unavailable`;n&&(l=1,m=null),_=n?t.staffUnavailable:O?t.replacePaymentNote:e instanceof Error?e.message:t.genericError,v=`error`,J(),r.querySelector(`.widget-message`)?.focus()}}}}return r.resetFlow=q,J(),r}function en(e=document.head){if(e.querySelector(`#booking-widget-runtime-style`))return;let t=document.createElement(`style`);t.id=`booking-widget-runtime-style`,t.textContent=` 575 799 :host { 576 800 color-scheme: light; … … 872 1096 } 873 1097 1098 .service-photo { 1099 display: block; 1100 width: 44px; 1101 height: 44px; 1102 flex: 0 0 44px; 1103 object-fit: cover; 1104 border-radius: 6px; 1105 } 1106 874 1107 .service-info { 1108 min-width: 0; 875 1109 flex: 1; 876 1110 text-align: left; … … 1027 1261 } 1028 1262 1263 .staff-row { 1264 display: flex; 1265 flex-wrap: wrap; 1266 gap: 6px; 1267 margin-bottom: 14px; 1268 } 1269 1270 .staff-chip { 1271 display: inline-flex; 1272 align-items: center; 1273 gap: 7px; 1274 padding: 5px 12px; 1275 border: 1.5px solid #e2e8f0; 1276 border-radius: 999px; 1277 background: white; 1278 cursor: pointer; 1279 font-family: inherit; 1280 font-size: 12px; 1281 font-weight: 500; 1282 color: #475569; 1283 transition: border-color 0.15s, background 0.15s, color 0.15s; 1284 } 1285 1286 .staff-chip:hover { 1287 border-color: var(--bm-accent-border); 1288 background: var(--bm-accent-soft-hover); 1289 } 1290 1291 .staff-chip.selected { 1292 border-color: var(--bm-accent); 1293 background: var(--bm-accent-soft); 1294 color: var(--bm-accent-deep); 1295 } 1296 1297 .staff-chip-dot { 1298 position: relative; 1299 display: inline-flex; 1300 align-items: center; 1301 justify-content: center; 1302 width: 22px; 1303 height: 22px; 1304 flex: 0 0 22px; 1305 border-radius: 999px; 1306 background: #cbd5e1; 1307 color: #fff; 1308 font-size: 9px; 1309 font-weight: 700; 1310 letter-spacing: 0.02em; 1311 overflow: hidden; 1312 } 1313 1314 .staff-chip-avatar { 1315 position: absolute; 1316 inset: 0; 1317 display: block; 1318 width: 100%; 1319 height: 100%; 1320 object-fit: cover; 1321 } 1322 1323 .staff-chip-name { 1324 max-width: 140px; 1325 white-space: nowrap; 1326 overflow: hidden; 1327 text-overflow: ellipsis; 1328 } 1329 1029 1330 .form-field { 1030 1331 margin-bottom: 0; … … 1062 1363 min-height: 74px; 1063 1364 resize: vertical; 1365 } 1366 1367 .form-required { 1368 color: var(--bm-accent-deep); 1369 font-weight: 700; 1370 } 1371 1372 .sr-only { 1373 position: absolute; 1374 width: 1px; 1375 height: 1px; 1376 padding: 0; 1377 margin: -1px; 1378 overflow: hidden; 1379 clip: rect(0 0 0 0); 1380 white-space: nowrap; 1381 border: 0; 1064 1382 } 1065 1383 … … 2394 2712 2395 2713 @media (max-width: 760px) { 2714 /* The mobile panel fills the viewport. Its own close button stays visible; 2715 * the bottom launcher would otherwise cover the booking action. */ 2716 .bw-booking-bar-shell[data-open="true"] { 2717 display: none; 2718 } 2719 2396 2720 .bw-booking-bar-spacer { 2397 2721 height: 0; … … 2605 2929 border-color: rgb(31 95 58 / 0.28); 2606 2930 background: var(--bm-widget-surface); 2931 } 2932 2933 .bw-booking-modal__service .bw-booking-modal__service-body { 2934 display: flex; 2935 align-items: center; 2936 gap: 10px; 2937 min-width: 0; 2938 } 2939 2940 .bw-booking-modal__service .bw-booking-modal__service-copy { 2941 display: flex; 2942 flex-direction: column; 2943 min-width: 0; 2607 2944 } 2608 2945 … … 2945 3282 } 2946 3283 } 2947 `,e.appendChild(t)}let at=`<svg class="launcher-icon visible" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2"></rect><path d="M16 2v4M8 2v4M3 10h18"></path></svg>`;function Z(e,t,n){if(!e)return;if(e.classList&&typeof e.classList.toggle==`function`){e.classList.toggle(t,n);return}let r=new Set(String(e.className||``).split(/\s+/).filter(Boolean));n?r.add(t):r.delete(t),e.className=Array.from(r).join(` `)}function Q(e){let t=e.shadowRoot||e.attachShadow({mode:`open`});return t.replaceChildren(),it(t),t}function ot(e){return e===`bottom-left`||e===`top-right`||e===`top-left`||e===`bottom-right`?e:`bottom-right`}function st(e){return typeof e?.integrationId==`string`?e.integrationId.trim():``}function ct(e){return e&&Array.from(document.querySelectorAll(`[data-booking-widget-integration-id]`)).find(t=>t.dataset?.bookingWidgetIntegrationId===e)||null}function lt(e){return Array.from(document.querySelectorAll(`[data-booking-widget-owner]`)).filter(t=>t.dataset?.bookingWidgetOwner===e)}function ut(e,t){return e?.dataset?.bookingWidgetPlacement===`auto`&&t.variant===`inline`}function dt(e,t){let n=e.dataset.bookingWidgetMountId||`bw-${Date.now()}-${Math.random().toString(16).slice(2)}`;if(e.dataset.bookingWidgetMountId=n,lt(n).forEach(e=>e.remove()),e.querySelectorAll(`[data-booking-widget-runtime], .bw-widget-bar, .bw-widget-shadow-host`).forEach(e=>e.remove()),(t.commerce===`shopify`||t.commerce===`woocommerce`)&&(t={...t,variant:`inline`}),t.variant===`floating`){let e=document.createElement(`div`),a=Q(e),o=document.createElement(`div`),s=document.createElement(`button`),c=ot(t.position),u=rt({...t,variant:`floating`}),d=document.createElement(`div`),f=document.createElement(`div`),p=f.attachShadow({mode:`open`}),m=typeof d.showPopover==`function`,h=!1,g=0;qe(),it(p),Z(u,`widget--floating-panel`,!0),Z(u,`open`,!0),Z(u,`closed`,!1),p.append(u),f.className=`bw-floating-popover__host`,f.style.cssText=`display:block;width:100%;height:100%;`,d.className=`bw-floating-popover`,d.id=`${n}-floating-panel`,d.dataset.bookingWidgetOwner=n,d.dataset.position=c,d.setAttribute(`data-position`,c),d.setAttribute(`role`,`dialog`),d.setAttribute(`aria-modal`,`false`),d.setAttribute(`aria-label`,t.title||K.bookNow),m?d.setAttribute(`popover`,`manual`):d.hidden=!0,G(d,t.color),G(f,t.color),d.append(f);function r(n){h=n,Z(s,`expanded`,n),s.setAttribute(`aria-expanded`,n?`true`:`false`),s.setAttribute(`aria-label`,n?K.close:K.bookNow),s.innerHTML=n?`<svg class="launcher-icon visible" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12"></path></svg>`:`${at}<span class="launcher-label">${l(t.title||K.bookNow)}</span>`,Z(o,`is-open`,n),X(e),X(d);try{m?n&&!d.matches(`:popover-open`)?d.showPopover():!n&&d.matches(`:popover-open`)&&d.hidePopover():d.hidden=!n}catch{d.hidden=!n,Z(d,`is-fallback-open`,n)}n?typeof window.clearTimeout==`function`&&window.clearTimeout(g):(typeof window.clearTimeout==`function`&&window.clearTimeout(g),typeof window.setTimeout==`function`?g=window.setTimeout(()=>{typeof u.resetFlow==`function`&&u.resetFlow()},300):typeof u.resetFlow==`function`&&u.resetFlow())}e.className=`bw-widget-shadow-host`,e.dataset.bookingWidgetOwner=n,e.dataset.variant=`floating`,e.dataset.position=c,e.setAttribute(`data-position`,c),Xe(e),G(e,t.color),o.className=`runtime-shell runtime-shell--widget`,o.dataset.bookingWidgetOwner=n,s.type=`button`,s.className=`launcher`,s.dataset.bookingWidgetOwner=n,s.setAttribute(`aria-label`,K.bookNow),s.setAttribute(`aria-expanded`,`false`),s.setAttribute(`aria-controls`,d.id),s.setAttribute(`aria-haspopup`,`dialog`),s.innerHTML=`${at}<span class="launcher-label">${l(t.title||K.bookNow)}</span>`,s.addEventListener(`click`,()=>r(!h)),u.addEventListener(`booking-widget-close`,()=>r(!1));function i(e){e.key===`Escape`&&h&&r(!1)}return typeof document.addEventListener==`function`&&document.addEventListener(`keydown`,i),r(!1),o.append(s),a.append(o),document.body.append(e,d),X(e),e}if(t.variant===`bar`){let e=document.createElement(`div`),r=Q(e);return e.className=`bw-widget-shadow-host`,e.dataset.bookingWidgetOwner=n,e.dataset.variant=`bar`,Ze(e),document.body.append(e),X(e),Re(t,n,r)}if(t.variant===`modal`){let r=typeof t.trigger==`string`?t.trigger.trim():typeof t.triggerSelector==`string`?t.triggerSelector.trim():``,i=t.buttonAlign===`center`||t.buttonAlign===`right`||t.buttonAlign===`left`?t.buttonAlign:t.buttonPosition===`center`||t.buttonPosition===`right`||t.buttonPosition===`left`?t.buttonPosition:`left`,a=document.createElement(`div`),o=Q(a);return a.className=`bw-widget-shadow-host`,a.dataset.bookingWidgetOwner=n,a.dataset.variant=`modal`,a.dataset.triggerMode=r?`external`:`inline`,a.dataset.buttonPosition=i,a.dataset.open=`false`,G(a,t.color),r?(Xe(a),document.body.append(a),X(a)):(Ye(),a.style.setProperty(`position`,`relative`,`important`),a.style.setProperty(`inset`,`auto`,`important`),a.style.setProperty(`display`,`block`,`important`),a.style.setProperty(`width`,`100%`,`important`),a.style.setProperty(`height`,`auto`,`important`),a.style.setProperty(`pointer-events`,`auto`,`important`),a.style.setProperty(`z-index`,`1`,`important`),e.append(a)),Ke(t,n,o),a}let a=Q(e);return G(e,t.color),a.appendChild(rt({...t,variant:`inline`})),e}function $(e,t){if(!e)return null;let n=he(e,t);return e.dataset.bookingWidgetMounted===`true`?e:(e.dataset.bookingWidgetMounted=`true`,n.workspace?(ge(n).then(t=>{ut(e,t)||dt(e,t)}),e):ut(e,n)?e:dt(e,R(n)))}function ft(e){let t=st(e),n=ct(t)||document.createElement(`div`);return n.setAttribute(`data-booking-widget`,``),t&&(n.dataset.bookingWidgetIntegrationId=t),n.parentNode||document.body.appendChild(n),$(n,e)}function pt(){if(Array.isArray(window.bookingWidgetQueue))for(;window.bookingWidgetQueue.length>0;)ft(window.bookingWidgetQueue.shift())}function mt(e){pt(),(e||document).querySelectorAll(`[data-booking-widget], script[data-workspace][src*="bookminto"], script[data-workspace][src*="bookonpage"], script[data-workspace][src*="bookingwidget"]`).forEach(e=>{if(e.tagName===`SCRIPT`){let t=document.createElement(`div`);t.setAttribute(`data-booking-widget`,``),e.after(t),$(t,e.dataset);return}$(e)})}window.BookingWidget={create:ft,mount:$,scan:mt},document.readyState===`loading`?document.addEventListener(`DOMContentLoaded`,()=>mt()):mt()})(); 3284 3285 .bw-next-times { 3286 display: flex; 3287 flex-wrap: wrap; 3288 align-items: center; 3289 gap: 8px 10px; 3290 padding: 10px 12px; 3291 border: 1px solid color-mix(in srgb, var(--bw-color, #14685f) 18%, #d8d7d0); 3292 border-radius: 12px; 3293 background: var(--bw-surface, #fff); 3294 font-family: var(--bw-font, 'Sora', system-ui, sans-serif); 3295 font-size: 13px; 3296 color: #3f3d3a; 3297 max-width: 100%; 3298 box-sizing: border-box; 3299 } 3300 3301 .bw-next-times__label { 3302 font-weight: 600; 3303 color: #26251f; 3304 white-space: nowrap; 3305 } 3306 3307 .bw-next-times__slots { 3308 display: flex; 3309 flex-wrap: wrap; 3310 gap: 6px; 3311 } 3312 3313 .bw-next-times__slot { 3314 display: inline-flex; 3315 align-items: baseline; 3316 gap: 6px; 3317 padding: 6px 10px; 3318 border: 1px solid color-mix(in srgb, var(--bw-color, #14685f) 40%, #d8d7d0); 3319 border-radius: 999px; 3320 background: color-mix(in srgb, var(--bw-color, #14685f) 7%, #fff); 3321 color: #26251f; 3322 font: inherit; 3323 font-size: 12.5px; 3324 cursor: pointer; 3325 transition: border-color 0.15s ease, background 0.15s ease; 3326 } 3327 3328 .bw-next-times__slot b { 3329 font-weight: 600; 3330 text-transform: capitalize; 3331 } 3332 3333 .bw-next-times__slot span { 3334 color: #5f5b52; 3335 } 3336 3337 .bw-next-times__slot:hover, 3338 .bw-next-times__slot:focus-visible { 3339 border-color: var(--bw-color, #14685f); 3340 background: color-mix(in srgb, var(--bw-color, #14685f) 14%, #fff); 3341 outline: none; 3342 } 3343 3344 .bw-next-times__all { 3345 padding: 6px 4px; 3346 border: 0; 3347 background: none; 3348 color: var(--bw-color, #14685f); 3349 font: inherit; 3350 font-size: 12.5px; 3351 font-weight: 600; 3352 cursor: pointer; 3353 text-decoration: underline; 3354 text-underline-offset: 2px; 3355 } 3356 3357 .bw-next-times__empty { 3358 color: #6b675d; 3359 font-size: 12.5px; 3360 } 3361 3362 @media (max-width: 480px) { 3363 .bw-next-times { 3364 flex-direction: column; 3365 align-items: flex-start; 3366 } 3367 3368 .bw-next-times__slots { 3369 width: 100%; 3370 } 3371 3372 .bw-next-times__slot { 3373 flex: 1 1 auto; 3374 justify-content: center; 3375 } 3376 } 3377 3378 /* Extension F — recommended compact-schedule slots */ 3379 .time-btn.recommended, 3380 .tbtn.rec { 3381 border-color: var(--bw-color, #14685f); 3382 position: relative; 3383 } 3384 3385 .time-btn.recommended .rec-badge, 3386 .tbtn.rec .rec-badge { 3387 display: block; 3388 font-style: normal; 3389 font-size: 9.5px; 3390 font-weight: 600; 3391 letter-spacing: 0.03em; 3392 color: var(--bw-color, #14685f); 3393 text-transform: uppercase; 3394 } 3395 3396 /* Extension D — waitlist entry point inside the date step */ 3397 .waitlist-cta { 3398 display: block; 3399 width: 100%; 3400 margin-top: 10px; 3401 padding: 10px 12px; 3402 border: 1px dashed color-mix(in srgb, var(--bw-color, #14685f) 45%, #d8d7d0); 3403 border-radius: 10px; 3404 background: color-mix(in srgb, var(--bw-color, #14685f) 6%, transparent); 3405 color: var(--bw-color, #14685f); 3406 font-family: inherit; 3407 font-size: 13px; 3408 font-weight: 600; 3409 cursor: pointer; 3410 transition: background 0.15s ease; 3411 } 3412 3413 .waitlist-cta:hover, 3414 .waitlist-cta:focus-visible { 3415 background: color-mix(in srgb, var(--bw-color, #14685f) 12%, transparent); 3416 outline: none; 3417 } 3418 3419 .waitlist-form { 3420 margin-top: 12px; 3421 padding: 12px; 3422 border: 1px solid color-mix(in srgb, var(--bw-color, #14685f) 18%, #d8d7d0); 3423 border-radius: 12px; 3424 display: flex; 3425 flex-direction: column; 3426 gap: 10px; 3427 } 3428 3429 .waitlist-note { 3430 margin: 0; 3431 font-size: 12.5px; 3432 line-height: 1.45; 3433 color: #6b6963; 3434 } 3435 3436 .waitlist-form .cta-btn, 3437 .waitlist-form .cta { 3438 margin-top: 2px; 3439 } 3440 3441 .waitlist-joined { 3442 margin-top: 10px; 3443 color: var(--bw-color, #14685f); 3444 } 3445 3446 /* Extension B — rebooking invitation opt-in */ 3447 .rebooking-optin { 3448 display: flex; 3449 align-items: flex-start; 3450 gap: 8px; 3451 margin-top: 14px; 3452 padding: 10px 12px; 3453 border-radius: 10px; 3454 background: color-mix(in srgb, var(--bw-color, #14685f) 6%, transparent); 3455 font-size: 13px; 3456 line-height: 1.4; 3457 color: #3f3d3a; 3458 cursor: pointer; 3459 } 3460 3461 .rebooking-optin input { 3462 margin-top: 1px; 3463 accent-color: var(--bw-color, #14685f); 3464 flex: none; 3465 } 3466 `,e.appendChild(t)}let tn=`<svg class="launcher-icon visible" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2"></rect><path d="M16 2v4M8 2v4M3 10h18"></path></svg>`;function nn(e,t,n){if(!e)return;if(e.classList&&typeof e.classList.toggle==`function`){e.classList.toggle(t,n);return}let r=new Set(String(e.className||``).split(/\s+/).filter(Boolean));n?r.add(t):r.delete(t),e.className=Array.from(r).join(` `)}function rn(e){let t=e.shadowRoot||e.attachShadow({mode:`open`});return t.replaceChildren(),en(t),t}function an(e){return e===`bottom-left`||e===`top-right`||e===`top-left`||e===`bottom-right`?e:`bottom-right`}function on(e){return typeof e?.integrationId==`string`?e.integrationId.trim():``}function sn(e){return e&&Array.from(document.querySelectorAll(`[data-booking-widget-integration-id]`)).find(t=>t.dataset?.bookingWidgetIntegrationId===e)||null}function cn(e){return Array.from(document.querySelectorAll(`[data-booking-widget-owner]`)).filter(t=>t.dataset?.bookingWidgetOwner===e)}function ln(e,t){return e?.dataset?.bookingWidgetPlacement===`auto`&&t.variant===`inline`}function un(e,t){let n=c(t.language),r=e.dataset.bookingWidgetMountId||`bw-${Date.now()}-${Math.random().toString(16).slice(2)}`;if(e.dataset.bookingWidgetMountId=r,cn(r).forEach(e=>{e.__bookingWidgetDestroy?.(),e.remove()}),e.querySelectorAll(`[data-booking-widget-runtime], .bw-widget-bar, .bw-widget-shadow-host`).forEach(e=>{e.__bookingWidgetDestroy?.(),e.remove()}),(t.commerce===`shopify`||t.commerce===`woocommerce`)&&(t={...t,variant:`inline`}),t.nextTimes)if(!(t.previewMode||!t.features||typeof t.features!=`object`||Object.keys(t.features).length===0||t.features.nextTimes===!0))t={...t,nextTimes:!1,variant:`modal`};else{let n=rn(e);Q(e,t.color);let i=document.createElement(`div`),a=rn(i);i.className=`bw-widget-shadow-host`,i.dataset.bookingWidgetOwner=r,i.dataset.variant=`modal`,i.dataset.open=`false`,Q(i,t.color),Rt(i),document.body.append(i),Bt(i);let o=Nt(t,r,a,{suppressCta:!0});return i.__bookingWidgetDestroy=o.destroy,n.appendChild(Ut(t,{onOpen:()=>o.setOpen(!0),onSelectSlot:(e,t)=>{o.panel?.preselectBooking?.({serviceId:e,startsAt:t}),o.setOpen(!0)}})),e}if(t.variant===`floating`){let e=document.createElement(`div`),o=rn(e),s=document.createElement(`div`),c=document.createElement(`button`),l=an(t.position),u=$t({...t,variant:`floating`}),d=document.createElement(`div`),f=document.createElement(`div`),p=f.attachShadow({mode:`open`}),m=typeof d.showPopover==`function`,h=!1,g=0,_=!1;Pt(),en(p),nn(u,`widget--floating-panel`,!0),nn(u,`open`,!0),nn(u,`closed`,!1),p.append(u),f.className=`bw-floating-popover__host`,f.style.cssText=`display:block;width:100%;height:100%;`,d.className=`bw-floating-popover`,d.id=`${r}-floating-panel`,d.dataset.bookingWidgetOwner=r,d.dataset.position=l,d.setAttribute(`data-position`,l),d.setAttribute(`role`,`dialog`),d.setAttribute(`aria-modal`,`false`),d.setAttribute(`aria-label`,t.title||n.bookNow),m?d.setAttribute(`popover`,`manual`):d.hidden=!0,Q(d,t.color),Q(f,t.color),d.append(f);function i(r){if(!(_||e.isConnected===!1)){h=r,nn(c,`expanded`,r),c.setAttribute(`aria-expanded`,r?`true`:`false`),c.setAttribute(`aria-label`,r?n.close:n.bookNow),c.innerHTML=r?`<svg class="launcher-icon visible" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12"></path></svg>`:`${tn}<span class="launcher-label">${S(t.title||n.bookNow)}</span>`,nn(s,`is-open`,r),Bt(e),Bt(d);try{m?r&&!d.matches(`:popover-open`)?d.showPopover():!r&&d.matches(`:popover-open`)&&d.hidePopover():d.hidden=!r}catch{d.hidden=!r,nn(d,`is-fallback-open`,r)}r?typeof window.clearTimeout==`function`&&window.clearTimeout(g):(typeof window.clearTimeout==`function`&&window.clearTimeout(g),typeof window.setTimeout==`function`?g=window.setTimeout(()=>{typeof u.resetFlow==`function`&&u.resetFlow()},300):typeof u.resetFlow==`function`&&u.resetFlow())}}e.className=`bw-widget-shadow-host`,e.dataset.bookingWidgetOwner=r,e.dataset.variant=`floating`,e.dataset.position=l,e.setAttribute(`data-position`,l),Rt(e),Q(e,t.color),s.className=`runtime-shell runtime-shell--widget`,s.dataset.bookingWidgetOwner=r,c.type=`button`,c.className=`launcher`,c.dataset.bookingWidgetOwner=r,c.setAttribute(`aria-label`,n.bookNow),c.setAttribute(`aria-expanded`,`false`),c.setAttribute(`aria-controls`,d.id),c.setAttribute(`aria-haspopup`,`dialog`),c.innerHTML=`${tn}<span class="launcher-label">${S(t.title||n.bookNow)}</span>`,c.addEventListener(`click`,()=>i(!h)),u.addEventListener(`booking-widget-close`,()=>i(!1));function a(t){if(e.isConnected===!1){_=!0,typeof document.removeEventListener==`function`&&document.removeEventListener(`keydown`,a);return}t.key===`Escape`&&h&&i(!1)}return typeof document.addEventListener==`function`&&document.addEventListener(`keydown`,a),e.__bookingWidgetDestroy=()=>{_=!0,typeof document.removeEventListener==`function`&&document.removeEventListener(`keydown`,a),typeof window.clearTimeout==`function`&&window.clearTimeout(g)},i(!1),s.append(c),o.append(s),document.body.append(e,d),Bt(e),e}if(t.variant===`bar`){let e=document.createElement(`div`),n=rn(e);return e.className=`bw-widget-shadow-host`,e.dataset.bookingWidgetOwner=r,e.dataset.variant=`bar`,zt(e),document.body.append(e),Bt(e),xt(t,r,n)}if(t.variant===`modal`){let n=typeof t.trigger==`string`?t.trigger.trim():typeof t.triggerSelector==`string`?t.triggerSelector.trim():``,i=t.buttonAlign===`center`||t.buttonAlign===`right`||t.buttonAlign===`left`?t.buttonAlign:t.buttonPosition===`center`||t.buttonPosition===`right`||t.buttonPosition===`left`?t.buttonPosition:`left`,a=document.createElement(`div`),o=rn(a);return a.className=`bw-widget-shadow-host`,a.dataset.bookingWidgetOwner=r,a.dataset.variant=`modal`,a.dataset.triggerMode=n?`external`:`inline`,a.dataset.buttonPosition=i,a.dataset.open=`false`,Q(a,t.color),n?(Rt(a),document.body.append(a),Bt(a)):(Lt(),a.style.setProperty(`position`,`relative`,`important`),a.style.setProperty(`inset`,`auto`,`important`),a.style.setProperty(`display`,`block`,`important`),a.style.setProperty(`width`,`100%`,`important`),a.style.setProperty(`height`,`auto`,`important`),a.style.setProperty(`pointer-events`,`auto`,`important`),a.style.setProperty(`z-index`,`1`,`important`),e.append(a)),a.__bookingWidgetDestroy=Nt(t,r,o).destroy,a}let o=rn(e);return Q(e,t.color),o.appendChild($t({...t,variant:`inline`})),e}function dn(e,t){if(!e)return null;let n=Fe(e,t);return e.dataset.bookingWidgetMounted===`true`?e:(e.dataset.bookingWidgetMounted=`true`,n.workspace?(Ie(n).then(t=>{e.isConnected!==!1&&(ln(e,t)||un(e,t))}),e):ln(e,n)?e:un(e,je(n)))}function fn(e){let t=on(e),n=sn(t)||document.createElement(`div`);return n.setAttribute(`data-booking-widget`,``),t&&(n.dataset.bookingWidgetIntegrationId=t),n.parentNode||document.body.appendChild(n),dn(n,e)}function pn(){if(Array.isArray(window.bookingWidgetQueue))for(;window.bookingWidgetQueue.length>0;)fn(window.bookingWidgetQueue.shift())}function mn(e){pn(),(e||document).querySelectorAll(`[data-booking-widget], script[data-workspace][src*="bookminto"], script[data-workspace][src*="bookonpage"], script[data-workspace][src*="bookingwidget"]`).forEach(e=>{if(e.tagName===`SCRIPT`){if(e.dataset.bookingWidgetScanned===`true`)return;e.dataset.bookingWidgetScanned=`true`;let t=document.createElement(`div`);t.setAttribute(`data-booking-widget`,``),e.after(t),dn(t,e.dataset);return}dn(e)})}window.BookingWidget={create:fn,mount:dn,scan:mn},document.readyState===`loading`?document.addEventListener(`DOMContentLoaded`,()=>mn()):mn()})(); -
bookminto/tags/wp-1.0.5/bookminto.php
r3723811 r3730301 3 3 * Plugin Name: Bookminto 4 4 * Description: Add Bookminto to WordPress pages with automatic display, a block, a shortcode, or WooCommerce product checkout booking. 5 * Version: 1.0. 05 * Version: 1.0.5 6 6 * Author: Bookminto 7 7 * Author URI: https://bookminto.com/ … … 23 23 const BOOKMINTO_OPTION = 'bookminto_options'; 24 24 const BOOKMINTO_OPTION_LEGACY = 'booking_widget_options'; 25 const BOOKMINTO_VERSION = '1.0. 0';25 const BOOKMINTO_VERSION = '1.0.5'; 26 26 const BOOKMINTO_API_URL = 'https://api.bookminto.com/v1'; 27 27 const BOOKMINTO_PLUGIN_DIR = __DIR__; -
bookminto/tags/wp-1.0.5/includes/woocommerce.php
r3723811 r3730301 50 50 add_action('wp_ajax_bookminto_add_to_cart', 'bookminto_woo_ajax_add_to_cart'); 51 51 add_action('wp_ajax_nopriv_bookminto_add_to_cart', 'bookminto_woo_ajax_add_to_cart'); 52 add_action('template_redirect', 'bookminto_woo_waitlist_checkout'); 52 53 53 54 add_filter('woocommerce_add_cart_item_data', 'bookminto_woo_add_cart_item_data', 10, 3); … … 933 934 } 934 935 935 // One booking line per request; clear prior bookable lines for this product. 936 $cart_key = bookminto_woo_put_booking_in_cart($product_id, $booking_id, $appointment, $service_name); 937 if (!$cart_key) { 938 if (function_exists('wc_clear_notices')) { 939 wc_clear_notices(); 940 } 941 wp_send_json_error(['message' => __('Could not add booking to cart.', 'bookminto')], 400); 942 } 943 944 wp_send_json_success(['checkoutUrl' => wc_get_checkout_url()]); 945 } 946 947 /** The normal planner and waitlist handoff use the same cart metadata path. */ 948 function bookminto_woo_put_booking_in_cart(int $product_id, string $booking_id, string $appointment, string $service_name): string 949 { 950 if (!function_exists('WC') || !WC()->cart) { 951 return ''; 952 } 953 // One booking line per product; reopening the same checkout keeps one id. 936 954 foreach (WC()->cart->get_cart() as $key => $item) { 937 955 if ((int) ($item['product_id'] ?? 0) === $product_id && !empty($item[BOOKMINTO_WOO_BOOKING_META])) { … … 944 962 $_REQUEST['bookminto_service_name'] = $service_name; 945 963 $_REQUEST['bookminto_ajax'] = '1'; 964 $_REQUEST['nonce'] = wp_create_nonce('bookminto_woo_cart'); 946 965 947 966 $cart_key = WC()->cart->add_to_cart($product_id, 1); … … 950 969 wc_clear_notices(); 951 970 } 952 wp_send_json_error(['message' => __('Could not add booking to cart.', 'bookminto')], 400);971 return ''; 953 972 } 954 973 … … 957 976 } 958 977 959 wp_send_json_success( 960 [ 961 'checkoutUrl' => wc_get_checkout_url(), 962 ] 963 ); 978 return (string) $cart_key; 979 } 980 981 /** GET only previews. The explicit nonce-protected POST attests this store's 982 * product/service mapping with its existing installation signature. */ 983 function bookminto_woo_waitlist_checkout(): void 984 { 985 $token = sanitize_text_field(wp_unslash($_REQUEST['bookminto_waitlist'] ?? '')); 986 if ($token === '' || !preg_match('/^bmwl_[A-Za-z0-9_-]{20,64}$/', $token)) { 987 return; 988 } 989 nocache_headers(); 990 header('Referrer-Policy: no-referrer'); 991 header('X-Robots-Tag: noindex, nofollow'); 992 if (!bookminto_woo_credentials()) { 993 wp_die(esc_html__('This store is not connected to Bookminto.', 'bookminto'), '', ['response' => 503]); 994 } 995 $response = wp_remote_get(rtrim(BOOKMINTO_API_URL, '/') . '/waitlist-claim/' . rawurlencode($token), ['timeout' => 15]); 996 $preview = !is_wp_error($response) ? json_decode((string) wp_remote_retrieve_body($response), true) : null; 997 if (!is_array($preview) || !in_array($preview['state'] ?? '', ['open', 'payment_hold', 'claimed'], true) || empty($preview['serviceId'])) { 998 wp_die(esc_html__('This offer is no longer available. Please contact the store.', 'bookminto'), '', ['response' => 410]); 999 } 1000 if (($preview['state'] ?? '') === 'claimed') { 1001 wp_die(esc_html__('This appointment is already confirmed.', 'bookminto')); 1002 } 1003 $products = get_posts(['post_type' => 'product', 'post_status' => 'publish', 'fields' => 'ids', 'posts_per_page' => 2, 1004 'meta_query' => [['key' => BOOKMINTO_WOO_SERVICE_META, 'value' => (string) $preview['serviceId']]]]); 1005 if (count($products) !== 1) { 1006 wp_die(esc_html__('The booking product cannot be determined. Please contact the store.', 'bookminto'), '', ['response' => 409]); 1007 } 1008 $product_id = (int) $products[0]; 1009 $product = wc_get_product($product_id); 1010 if (!$product || !$product->is_purchasable() || bookminto_woo_get_service_id($product) !== (string) $preview['serviceId']) { 1011 wp_die(esc_html__('This booking product is unavailable.', 'bookminto'), '', ['response' => 409]); 1012 } 1013 if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') { 1014 if (!wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['nonce'] ?? '')), 'bookminto_woo_waitlist')) { 1015 wp_die(esc_html__('Reload this offer before continuing.', 'bookminto'), '', ['response' => 403]); 1016 } 1017 $revision = sanitize_text_field(wp_unslash($_POST['publication_revision'] ?? '')); 1018 if ($revision !== ($preview['publicationRevision'] ?? '')) { 1019 wp_die(esc_html__('The booking conditions changed. Reload this offer before continuing.', 'bookminto'), '', ['response' => 409]); 1020 } 1021 $answers = []; 1022 foreach ((array) ($preview['questions'] ?? []) as $question) { 1023 $id = (string) ($question['id'] ?? ''); 1024 $value = $_POST['answers'][$id] ?? ''; 1025 if (is_string($value)) { 1026 $answers[$id] = sanitize_text_field(wp_unslash($value)); 1027 } 1028 } 1029 $result = bookminto_woo_signed_api_post('/waitlist/woocommerce-checkout', [ 1030 'token' => $token, 'productId' => (string) $product_id, 'serviceId' => (string) $preview['serviceId'], 1031 'publicationRevision' => $revision, 'answers' => (object) $answers, 1032 ]); 1033 $body = $result['body'] ?? []; 1034 if (empty($result['ok']) || empty($body['needsCheckout']) || empty($body['bookingId']) 1035 || ($body['commerceContext']['wooProductId'] ?? '') !== (string) $product_id) { 1036 wp_die(esc_html__('This offer could not be reserved. Reload it before trying again.', 'bookminto'), '', ['response' => 409]); 1037 } 1038 if (function_exists('wc_load_cart') && !WC()->cart) { 1039 wc_load_cart(); 1040 } 1041 $cart_key = bookminto_woo_put_booking_in_cart($product_id, (string) $body['bookingId'], 1042 (string) ($body['startsAt'] ?? ''), (string) ($preview['serviceName'] ?? '')); 1043 if (!$cart_key) { 1044 wp_die(esc_html__('The cart is unavailable. Reopen this same offer to continue.', 'bookminto'), '', ['response' => 503]); 1045 } 1046 wp_safe_redirect(wc_get_checkout_url()); 1047 exit; 1048 } 1049 get_header(); 1050 echo '<main class="bookminto-woo-waitlist"><h1>' . esc_html__('Complete your appointment', 'bookminto') . '</h1>'; 1051 $when = (string) ($preview['startsAt'] ?? ''); 1052 $timestamp = strtotime($when); 1053 if ($timestamp !== false) { 1054 try { 1055 $timezone = new DateTimeZone((string) ($preview['timezone'] ?? 'UTC')); 1056 $when = wp_date('j F Y H:i T', $timestamp, $timezone); 1057 } catch (Exception $error) { 1058 // Keep the explicit provider instant when its timezone is invalid. 1059 } 1060 } 1061 echo '<p>' . esc_html((string) ($preview['serviceName'] ?? '')) . '</p><p>' . esc_html($when) . '</p>'; 1062 echo '<p>' . wp_kses_post($product->get_price_html()) . '</p>'; 1063 echo '<p>' . esc_html__('Your appointment is confirmed after the shop payment has been verified.', 'bookminto') . '</p>'; 1064 echo '<form method="post"><input type="hidden" name="bookminto_waitlist" value="' . esc_attr($token) . '">'; 1065 echo '<input type="hidden" name="publication_revision" value="' . esc_attr((string) ($preview['publicationRevision'] ?? '')) . '">'; 1066 wp_nonce_field('bookminto_woo_waitlist', 'nonce'); 1067 foreach ((array) ($preview['questions'] ?? []) as $q) { 1068 $name = 'answers[' . (string) ($q['id'] ?? '') . ']'; 1069 $required = !empty($q['required']) ? ' required' : ''; 1070 echo '<p><label>' . esc_html((string) ($q['label'] ?? '')); 1071 $type = (string) ($q['type'] ?? ''); 1072 if (in_array($type, ['choice', 'select', 'yesno', 'yes_no'], true)) { 1073 $choices = in_array($type, ['yesno', 'yes_no'], true) ? ['yes' => __('Yes', 'bookminto'), 'no' => __('No', 'bookminto')] : array_combine((array) ($q['options'] ?? []), (array) ($q['options'] ?? [])); 1074 echo '<select name="' . esc_attr($name) . '"' . $required . '><option value="">—</option>'; 1075 foreach ((array) $choices as $value => $label) { 1076 echo '<option value="' . esc_attr((string) $value) . '">' . esc_html((string) $label) . '</option>'; 1077 } 1078 echo '</select>'; 1079 } else { 1080 echo '<input name="' . esc_attr($name) . '" maxlength="500"' . $required . '>'; 1081 } 1082 echo '</label></p>'; 1083 } 1084 echo '<button type="submit">' . esc_html__('Continue to checkout', 'bookminto') . '</button></form></main>'; 1085 get_footer(); 1086 exit; 964 1087 } 965 1088 -
bookminto/tags/wp-1.0.5/languages/bookminto.pot
r3723811 r3730301 1 1 msgid "" 2 2 msgstr "" 3 "Project-Id-Version: Bookminto 1.0. 0\n"3 "Project-Id-Version: Bookminto 1.0.5\n" 4 4 "Report-Msgid-Bugs-To: https://bookminto.com/\n" 5 "POT-Creation-Date: 2026-10-0 1\n"5 "POT-Creation-Date: 2026-10-06\n" 6 6 "MIME-Version: 1.0\n" 7 7 "Content-Type: text/plain; charset=UTF-8\n" … … 57 57 msgstr "" 58 58 59 #: includes/admin-ui.php60 msgid "Booking bar, buffers, and schedule exceptions"61 msgstr ""62 63 59 #: blocks/widget/editor.js bookminto.php includes/admin-menu.php includes/admin-menu.php includes/admin-menu.php 64 60 msgid "Bookminto" … … 101 97 msgstr "" 102 98 99 #: includes/admin-ui.php 100 msgid "Bookminto service access" 101 msgstr "" 102 103 103 #: includes/admin-menu.php 104 104 msgid "Bookminto services" … … 153 153 msgstr "" 154 154 155 #: includes/ admin-ui.php156 msgid "Comp are plans"155 #: includes/woocommerce.php 156 msgid "Complete your appointment" 157 157 msgstr "" 158 158 … … 177 177 msgstr "" 178 178 179 #: includes/woocommerce.php 180 msgid "Continue to checkout" 181 msgstr "" 182 179 183 #: includes/settings.php 180 184 msgid "Copy the key from Bookminto → Install → WordPress, then save it here." … … 206 210 207 211 #: includes/admin-ui.php 208 msgid "Create a Bookminto account , open Install, and paste the key under Settings. Bookminto is an external service with Free, Solo, and Pro plans."212 msgid "Create a Bookminto account" 209 213 msgstr "" 210 214 … … 253 257 msgstr "" 254 258 255 #: includes/admin-ui.php includes/admin-ui.php256 msgid "Deposits via Stripe Connect"257 msgstr ""258 259 259 #: includes/settings.php 260 260 msgid "Disconnect could not be confirmed. Your local credentials were kept. Retry, or revoke this connection under Bookminto → Install." … … 275 275 #: includes/admin-menu.php 276 276 msgid "Edit product" 277 msgstr ""278 279 #: includes/admin-ui.php includes/admin-ui.php280 msgid "For growing businesses that need deposits and automations."281 msgstr ""282 283 #: includes/admin-ui.php284 msgid "For independent professionals running their bookable business."285 277 msgstr "" 286 278 … … 298 290 299 291 #: includes/admin-ui.php 300 msgid "Get an installation key"301 msgstr ""302 303 #: includes/admin-ui.php includes/admin-ui.php304 msgid "Get Pro"305 msgstr ""306 307 #: includes/admin-ui.php308 msgid "Get Solo"309 msgstr ""310 311 #: includes/admin-ui.php312 292 msgid "Help & links" 313 293 msgstr "" … … 381 361 msgstr "" 382 362 363 #: includes/woocommerce.php 364 msgid "No" 365 msgstr "" 366 383 367 #: includes/admin-menu.php 384 368 msgid "No appointments yet." … … 473 457 msgstr "" 474 458 475 #: includes/admin-ui.php includes/admin-ui.php 476 msgid "Priority support" 477 msgstr "" 478 479 #: includes/admin-ui.php includes/admin-ui.php 459 #: includes/admin-ui.php 480 460 msgid "Pro" 481 461 msgstr "" … … 491 471 #: includes/admin-ui.php 492 472 msgid "Product overview" 493 msgstr ""494 495 #: includes/admin-ui.php496 msgid "Product-page checkout booking needs Solo or Pro. The block, shortcode, and site-wide widget work on Free."497 473 msgstr "" 498 474 … … 510 486 511 487 #: includes/woocommerce.php 488 msgid "Reload this offer before continuing." 489 msgstr "" 490 491 #: includes/woocommerce.php 512 492 msgid "Retry Bookminto order synchronization" 513 493 msgstr "" … … 535 515 #: includes/settings.php 536 516 msgid "Secure connection active" 537 msgstr ""538 539 #: includes/admin-ui.php540 msgid "See pricing on bookminto.com"541 517 msgstr "" 542 518 … … 569 545 msgstr "" 570 546 571 #: includes/admin-ui.php includes/admin-ui.php547 #: includes/admin-ui.php 572 548 msgid "Solo" 573 549 msgstr "" … … 575 551 #: includes/admin-menu.php 576 552 msgid "Source" 577 msgstr ""578 579 #: includes/admin-ui.php580 msgid "Start for free"581 553 msgstr "" 582 554 … … 609 581 msgstr "" 610 582 583 #: includes/woocommerce.php 584 msgid "The booking conditions changed. Reload this offer before continuing." 585 msgstr "" 586 587 #: includes/woocommerce.php 588 msgid "The booking product cannot be determined. Please contact the store." 589 msgstr "" 590 611 591 #: blocks/widget/editor.js 612 592 msgid "The booking widget appears here on the published page." … … 621 601 msgstr "" 622 602 603 #: includes/woocommerce.php 604 msgid "The cart is unavailable. Reopen this same offer to continue." 605 msgstr "" 606 623 607 #: includes/admin-menu.php 624 608 msgid "The product could not be created." 609 msgstr "" 610 611 #: includes/woocommerce.php 612 msgid "This appointment is already confirmed." 613 msgstr "" 614 615 #: includes/woocommerce.php 616 msgid "This booking product is unavailable." 617 msgstr "" 618 619 #: includes/admin-ui.php 620 msgid "This free WordPress plugin connects to the external Bookminto booking service. Account access, current prices, and beta terms are published on bookminto.com." 621 msgstr "" 622 623 #: includes/woocommerce.php 624 msgid "This offer could not be reserved. Reload it before trying again." 625 msgstr "" 626 627 #: includes/woocommerce.php 628 msgid "This offer is no longer available. Please contact the store." 625 629 msgstr "" 626 630 … … 641 645 msgstr "" 642 646 647 #: includes/woocommerce.php 648 msgid "This store is not connected to Bookminto." 649 msgstr "" 650 643 651 #: includes/settings.php 644 652 msgid "To connect or rotate credentials, create a one-time WordPress connection code under Bookminto → Install. The code expires after 10 minutes." … … 653 661 msgstr "" 654 662 655 #: includes/admin-ui.php656 msgid "Unlimited bookings"657 msgstr ""658 659 663 #: includes/admin-menu.php 660 664 msgid "Updated “%s” to match the Bookminto service." … … 665 669 msgstr "" 666 670 667 #: includes/admin-ui.php668 msgid "Upgrade to Pro"669 msgstr ""670 671 671 #: includes/settings.php 672 672 msgid "Use the checkbox above for a floating button, booking bar, or modal on every page." … … 675 675 #: includes/woocommerce.php 676 676 msgid "Used when this product creates or updates a Bookminto service." 677 msgstr "" 678 679 #: includes/admin-ui.php 680 msgid "View current plans and beta terms" 677 681 msgstr "" 678 682 … … 693 697 msgstr "" 694 698 695 #: includes/admin-ui.php696 msgid "WooCommerce checkout booking"697 msgstr ""698 699 699 #: includes/settings.php 700 700 msgid "WooCommerce is securely connected to Bookminto." … … 713 713 msgstr "" 714 714 715 #: includes/ admin-ui.php includes/settings.php715 #: includes/settings.php 716 716 msgid "WordPress install guide" 717 717 msgstr "" … … 719 719 #: includes/admin-menu.php 720 720 msgid "Workspace" 721 msgstr "" 722 723 #: includes/woocommerce.php 724 msgid "Yes" 721 725 msgstr "" 722 726 … … 729 733 msgstr "" 730 734 731 #: includes/admin-ui.php includes/admin-ui.php 732 msgid "Zapier, Make, and API webhooks" 733 msgstr "" 734 735 #: includes/admin-ui.php 736 msgid "Bookminto service access" 737 msgstr "" 738 739 #: includes/admin-ui.php 740 msgid "This free WordPress plugin connects to the external Bookminto booking service. Account access, current prices, and beta terms are published on bookminto.com." 741 msgstr "" 742 743 #: includes/admin-ui.php 744 msgid "Create a Bookminto account" 745 msgstr "" 746 747 #: includes/admin-ui.php 748 msgid "View current plans and beta terms" 749 msgstr "" 735 #: includes/woocommerce.php 736 msgid "Your appointment is confirmed after the shop payment has been verified." 737 msgstr "" 738 -
bookminto/tags/wp-1.0.5/readme.txt
r3724276 r3730301 5 5 Tested up to: 7.1 6 6 Requires PHP: 7.4 7 Stable tag: 1.0. 07 Stable tag: 1.0.5 8 8 License: GPLv2 or later 9 9 License URI: https://www.gnu.org/licenses/gpl-2.0.html … … 32 32 * **Popup booking** — open the booking flow when visitors are ready to book. 33 33 34 Customize the appearance to better match your website and brand. 34 Customize the appearance to better match your website and brand. You can optionally add a small photo to each service in Bookminto. Services without a photo keep the compact text layout. 35 35 36 36 Use the Bookminto block in the WordPress editor, or place the booking experience manually with a shortcode. … … 96 96 97 97 `https://api.bookminto.com/v1` 98 99 Optional service photos load from Bookminto-managed Supabase Storage: 100 101 `https://zmzskjwuiryxrwlitkqn.supabase.co/storage/v1/object/public/service-images/` 102 103 Image requests share the visitor’s IP and browser information with storage, without booking details. See Bookminto’s Privacy Policy below. 98 104 99 105 The Bookminto service is used to: … … 168 174 = Where can developers find the source and build instructions? = 169 175 170 The compiled widget runtime is included locally as `assets/widget.js`. Readable source is included in `assets/src`. From the repository root, run `npm ci`, then `npm run sync:wordpress-plugin` and `npm run package:integrations` to reproduce the runtime and plugin package with Node.js 22 and the pinned Rolldown dependency. The runtime is updated only when the plugin is updated and cannot be replaced by an external Bookminto script.176 The plugin bundles its runtime in `assets/widget.js` and readable source in `assets/src`. To reproduce it, use Node.js 22 and the repository lockfile: run `npm ci`, `npm run sync:wordpress-plugin`, then `npm run package:integrations`. Updates arrive through plugin releases. 171 177 172 178 = Can I place the widget manually? = 173 179 174 Yes. Use the Bookminto block in the editor, or `[bookminto]` / `[booking_widget]` in a page or post. When a block or shortcode is present on a singular page, automatic rendering is skipped for that page.180 Use the Bookminto block or `[bookminto]` / `[booking_widget]`. Automatic rendering is skipped on singular pages containing a block or shortcode. 175 181 176 182 = Does the plugin charge visitors? = … … 210 216 == Changelog == 211 217 218 = 1.0.5 = 219 * Fix deposit payments in the embedded widget: the Stripe Payment Element now mounts correctly and the pay button waits for Stripe to be ready. 220 * Improve widget lifecycle cleanup, trigger selector validation, and compatibility on sites served over plain HTTP. 221 * Pass SMS and WhatsApp opt-in availability through to the embedded widget. 222 * Safer WooCommerce checkout URL handling and a multisite-safe uninstall. 223 224 = 1.0.4 = 225 * Preserve the active proposal identity through the shared deposit booking flow. 226 227 = 1.0.3 = 228 * Read published service prices in integer cents, with compatibility for older EUR prices. 229 * Reject unavailable prices instead of displaying a free appointment. 230 231 = 1.0.2 = 232 * Preserve booking retry identity, published revision and calendar verification. 233 * Keep isolated widget languages and support explicit booking-day hours. 234 235 = 1.0.1 = 236 * Optional service photos in the booking widget. Compact thumbnails appear only when a service has a photo. 237 * Failed images fall back to the existing text-only service selection. 238 212 239 = 1.0.0 = 213 240 -
bookminto/tags/wp-1.0.5/uninstall.php
r3723811 r3730301 10 10 } 11 11 12 delete_option('bookminto_options'); 13 delete_option('booking_widget_options'); 14 delete_option('bookminto_woocommerce_credentials'); 12 function bookminto_uninstall_site(): void { 13 delete_option('bookminto_options'); 14 delete_option('booking_widget_options'); 15 delete_option('bookminto_woocommerce_credentials'); 15 16 16 if (function_exists('as_unschedule_all_actions')) { 17 as_unschedule_all_actions('bookminto_woo_retry_order', null, 'bookminto'); 17 if (function_exists('as_unschedule_all_actions')) { 18 as_unschedule_all_actions('bookminto_woo_retry_order', null, 'bookminto'); 19 } 20 wp_unschedule_hook('bookminto_woo_retry_order'); 21 22 foreach (['_bookminto_service_id', '_bookminto_forced_sold_individually', '_bookminto_catalog_sync', '_bookminto_duration_minutes'] as $bookminto_meta_key) { 23 delete_post_meta_by_key($bookminto_meta_key); 24 } 18 25 } 19 wp_unschedule_hook('bookminto_woo_retry_order');20 26 21 foreach (['_bookminto_service_id', '_bookminto_forced_sold_individually', '_bookminto_catalog_sync', '_bookminto_duration_minutes'] as $bookminto_meta_key) { 22 delete_post_meta_by_key($bookminto_meta_key); 27 // A network-activated install stores options per blog — clean every site or 28 // the data is orphaned on all but the current one. 29 if (is_multisite()) { 30 $bookminto_site_ids = get_sites(array('fields' => 'ids', 'number' => 0)); 31 foreach ($bookminto_site_ids as $bookminto_site_id) { 32 switch_to_blog((int) $bookminto_site_id); 33 bookminto_uninstall_site(); 34 restore_current_blog(); 35 } 36 } else { 37 bookminto_uninstall_site(); 23 38 } -
bookminto/trunk/assets/src/api.js
r3723811 r3730301 1 1 import { apiUrl as defaultApiUrl } from './constants.js' 2 import { getWidgetCopy } from './copy.js' 2 3 import { 3 4 addBookingToShopifyCart, … … 49 50 commerceCheckout: commerce || undefined, 50 51 publicKey: config.workspace, 52 proposalSlug: config.proposalSlug || undefined, 51 53 shopifyProductGid: shopify ? config.shopifyProduct || undefined : undefined, 52 54 shopifyShopDomain: shopify ? config.shopifyShop || undefined : undefined, … … 65 67 if (!response.ok) { 66 68 const result = await response.json().catch(() => ({})) 67 throw new Error(result.error || 'Booking could not be created') 69 const error = new Error(result.error || 'Booking could not be created') 70 error.code = result.code 71 throw error 68 72 } 69 73 70 74 const result = await response.json() 75 if (result.ok !== true || result.holdExpired || ['cancelled', 'expired'].includes(result.status)) { 76 throw new Error(result.error || getWidgetCopy(config.language).closedAttempt) 77 } 71 78 72 if (shopify && (result.needsCheckout || result.bookingId)) {79 if (shopify && result.needsCheckout === true) { 73 80 await addBookingToShopifyCart({ 74 81 bookingId: result.bookingId, … … 81 88 } 82 89 83 if (woo && (result.needsCheckout || result.bookingId)) {90 if (woo && result.needsCheckout === true) { 84 91 await addBookingToWooCommerceCart({ 85 92 bookingId: result.bookingId, … … 97 104 } 98 105 106 /** 107 * Extension D: join the waitlist for a service. Public endpoint — the server 108 * re-checks the waitlist entitlement against the published config. 109 */ 110 export async function submitWaitlistEntry(config, payload) { 111 if ( 112 config.previewMode || 113 !config.workspace || 114 typeof fetch !== 'function' || 115 (isLocalSite() && usesDefaultApi(config)) 116 ) { 117 return { ok: true, localOnly: true } 118 } 119 120 const response = await fetch(`${bookingApiUrl(config)}/waitlist`, { 121 body: JSON.stringify({ 122 bookingId: payload.bookingId || undefined, 123 customerEmail: payload.customerEmail, 124 customerName: payload.customerName, 125 dateFrom: payload.dateFrom, 126 dateTo: payload.dateTo, 127 language: config.language, 128 publicKey: config.workspace, 129 serviceId: payload.serviceId, 130 source: isShopifyCommerce(config) ? 'shopify' : isWooCommerceCommerce(config) ? 'woocommerce' : undefined, 131 shopifyShopDomain: isShopifyCommerce(config) ? config.shopifyShop : undefined, 132 shopifyProductGid: isShopifyCommerce(config) ? config.shopifyProduct : undefined, 133 shopifyVariantId: isShopifyCommerce(config) ? String(config.shopifyVariant) : undefined, 134 wooProductId: isWooCommerceCommerce(config) ? String(config.wooProductId) : undefined, 135 siteUrl: window.location.href, 136 }), 137 headers: { 'Content-Type': 'application/json' }, 138 method: 'POST', 139 }).catch(() => null) 140 141 if (!response) { 142 throw new Error('Booking service unavailable. Please try again.') 143 } 144 145 const result = await response.json().catch(() => ({})) 146 147 if (!response.ok) { 148 throw new Error(result.error || 'Could not join the list') 149 } 150 151 return result 152 } 153 99 154 export { 100 155 addBookingToShopifyCart, -
bookminto/trunk/assets/src/availability.js
r3723811 r3730301 1 1 import { slotDateFormatter } from './constants.js' 2 import { widgetLocale } from './copy.js' 2 3 import { text } from './formatters.js' 3 4 import { … … 154 155 } 155 156 156 function getZonedDateFormatter(timeZone) { 157 if (!timeZone) { 157 function getZonedDateFormatter(timeZone, language) { 158 const locale = widgetLocale(language) 159 160 if (!timeZone && locale === 'en-US') { 158 161 return slotDateFormatter 159 162 } 160 163 161 164 try { 162 return new Intl.DateTimeFormat( 'en-US', {165 return new Intl.DateTimeFormat(locale, { 163 166 timeZone, 164 167 day: 'numeric', … … 171 174 } 172 175 173 function getBooked StartsForService(config, service) {176 function getBookedRanges(config) { 174 177 if (!Array.isArray(config?.bookedSlots)) { 175 178 return [] 176 179 } 177 180 178 const serviceId = service?.id 179 181 // Every occupied booking blocks its staff member's calendar; a slot stays 182 // bookable while at least one qualified member is free (union). Newer 183 // configs send footprint bounds (blockedStart/blockedEnd incl. buffers); 184 // older payloads fall back to the raw start. 180 185 return config.bookedSlots 181 .filter((slot) => { 182 if (!slot || typeof slot !== 'object') { 183 return false 184 } 185 186 // A booking with no serviceId, or one matching this service, occupies the 187 // single-provider calendar for this start time. 188 return !slot.serviceId || !serviceId || slot.serviceId === serviceId 189 }) 190 .map((slot) => slot.startsAt) 191 .filter(Boolean) 192 } 193 194 export function createAvailabilitySlots(config, service, limit = 6) { 186 .filter((slot) => slot && typeof slot === 'object' && slot.startsAt) 187 .map((slot) => ({ 188 end: slot.blockedEnd || slot.endsAt || slot.startsAt, 189 staffId: slot.staffId || null, 190 start: slot.blockedStart || slot.startsAt, 191 })) 192 } 193 194 // Team picker: members the customer may choose between for a service. The 195 // publication only puts displayable member objects in config.staff when 196 // staffDisplayMode is 'customer_choice'; service.staffIds narrows the 197 // qualified subset (null/undefined = all members, [] = explicitly nobody). 198 export function qualifiedStaffForService(config, service) { 199 const members = (Array.isArray(config?.staff) ? config.staff : []) 200 .map((member) => (typeof member === 'string' ? { id: member } : member)) 201 .filter((member) => member && typeof member.id === 'string' && member.id) 202 203 if (!Array.isArray(service?.staffIds)) { 204 return members 205 } 206 207 const allowed = new Set(service.staffIds) 208 return members.filter((member) => allowed.has(member.id)) 209 } 210 211 // A service is unbookable when its qualified set is explicitly empty. Only 212 // decidable once a staff list is published — legacy configs ship none and 213 // keep the union path. 214 export function serviceBlockedByStaff(config, service) { 215 return ( 216 Array.isArray(config?.staff) && 217 config.staff.length > 0 && 218 Array.isArray(service?.staffIds) && 219 qualifiedStaffForService(config, service).length === 0 220 ) 221 } 222 223 export function createAvailabilitySlots(config, service, limit = 6, staffId) { 195 224 if (!service) { 196 225 return [] … … 200 229 const rules = flattenAvailabilityRules(availability) 201 230 202 if (!rules.length ) {231 if (!rules.length && !service.bookingDates?.some(day => day.startTime && day.endTime)) { 203 232 return [] 204 233 } 205 234 206 235 const timeZone = config.timezone || undefined 207 const dateFormatter = getZonedDateFormatter(timeZone) 236 const dateFormatter = getZonedDateFormatter(timeZone, config.language) 237 const daysAhead = Math.min( 238 Math.max(Number(config.bookingHorizonDays) || 28, 1), 239 366, 240 ) 208 241 209 242 return buildSlots({ 210 243 availability, 211 booked Starts: getBookedStartsForService(config, service),244 bookedRanges: getBookedRanges(config), 212 245 exceptions: config.exceptions || [], 213 246 formatLabel: (startsAt) => { … … 221 254 }, 222 255 limit, 256 minNoticeMinutes: Math.max(Number(config.minNoticeMinutes) || 0, 0), 257 preferCompactSchedule: config.preferCompactSchedule === true, 258 staff: Array.isArray(config.staff) ? config.staff : [], 259 staffAvailability: config.staffAvailability || undefined, 260 staffId: staffId || undefined, 223 261 timeZone, 224 daysAhead : 28,262 daysAhead, 225 263 service: { 264 bookingDates: Array.isArray(service.bookingDates) ? service.bookingDates : [], 226 265 bufferAfterMinutes: Number(service.bufferAfterMinutes) || 0, 227 266 bufferBeforeMinutes: Number(service.bufferBeforeMinutes) || 0, … … 229 268 id: service.id, 230 269 name: service.name, 270 staffIds: Array.isArray(service.staffIds) ? service.staffIds : service.staffIds === null ? null : undefined, 231 271 }, 232 272 }) 233 273 } 234 274 235 function getSlotDateKey(startsAt, timeZone) {275 export function getSlotDateKey(startsAt, timeZone) { 236 276 const date = new Date(startsAt) 237 277 … … 258 298 } 259 299 260 export function createDatedAvailabilitySlots(config, service ) {261 const slots = createAvailabilitySlots(config, service, 200 )300 export function createDatedAvailabilitySlots(config, service, staffId) { 301 const slots = createAvailabilitySlots(config, service, 200, staffId) 262 302 const timeZone = config.timezone || undefined 263 303 const byDate = new Map() -
bookminto/trunk/assets/src/bookingBar.js
r3723811 r3730301 1 import { createAvailabilitySlots } from './availability.js' 1 import { servicePhotoMarkup, bindServicePhotoErrors } from './servicePhotos.js' 2 import { createAvailabilitySlots, serviceBlockedByStaff } from './availability.js' 2 3 import { submitBooking } from './api.js' 3 4 import { buildIcsDataUrl } from './calendar.js' … … 5 6 import { confirmBookingPayment, getDepositPaymentSession, mountDepositPayment, showPaymentLoadError } from './payment.js' 6 7 import { getContrastText } from './theme.js' 7 8 function panelSummary(service, slot) { 8 import { getWidgetCopy } from './copy.js' 9 import { DIAL_CODES, PHONE_COUNTRIES, normalizePhoneE164 } from './phoneNumber.js' 10 11 function panelSummary(service, slot, copy, language) { 9 12 return service && slot 10 ? `${slot.label} · ${serviceSummary(service )}`11 : 'Select a service and time to continue.'13 ? `${slot.label} · ${serviceSummary(service, language)}` 14 : copy.selectServiceAndTime 12 15 } 13 16 14 function stepperMarkup(currentStep ) {17 function stepperMarkup(currentStep, copy) { 15 18 const steps = [ 16 ['service', 'Service'],17 ['time', 'Time'],18 ['details', 'Details'],19 ['confirmed', 'Done'],19 ['service', copy.widgetStepLabels[0]], 20 ['time', copy.widgetStepLabels[1]], 21 ['details', copy.widgetStepLabels[2]], 22 ['confirmed', copy.widgetStepLabels[3]], 20 23 ] 21 24 … … 27 30 class="bw-booking-modal__stepper" 28 31 role="progressbar" 29 aria-label=" Booking progress"32 aria-label="${escapeHtml(copy.progressLabel)}" 30 33 aria-valuemin="1" 31 34 aria-valuemax="${steps.length}" 32 35 aria-valuenow="${currentIndex + 1}" 33 aria-valuetext=" Step ${currentIndex + 1} of ${steps.length}: ${activeLabel}"36 aria-valuetext="${escapeHtml(copy.stepLabel)} ${currentIndex + 1} ${escapeHtml(copy.stepOf)} ${steps.length}: ${escapeHtml(activeLabel)}" 34 37 > 35 38 <div class="bw-booking-modal__step-meta" aria-hidden="true"> 36 <span> Step ${currentIndex + 1} of${steps.length}</span>39 <span>${escapeHtml(copy.stepLabel)} ${currentIndex + 1} ${escapeHtml(copy.stepOf)} ${steps.length}</span> 37 40 <strong>${activeLabel}</strong> 38 41 </div> … … 46 49 } 47 50 48 function serviceRowsMarkup(services, activeIndex ) {51 function serviceRowsMarkup(services, activeIndex, previewMode, copy, language) { 49 52 if (!services.length) { 50 return '<div class="bw-booking-bar-panel__empty">No services are published for this widget yet.</div>'53 return `<div class="bw-booking-bar-panel__empty">${escapeHtml(copy.emptyServices)}</div>` 51 54 } 52 55 … … 56 59 <button type="button" class="bw-booking-modal__service" data-panel-service-index="${index}" data-active="${index === activeIndex}" aria-pressed="${index === activeIndex}"> 57 60 <span class="bw-booking-modal__service-body"> 58 <strong>${escapeHtml(service.name)}</strong> 59 <em>${escapeHtml(serviceDetail(service))}</em> 61 ${servicePhotoMarkup(service.imageUrl, previewMode)} 62 <span class="bw-booking-modal__service-copy"> 63 <strong>${escapeHtml(service.name)}</strong> 64 <em>${escapeHtml(serviceDetail(service, language))}</em> 65 </span> 60 66 </span> 61 <span class="bw-booking-modal__service-price">${escapeHtml(servicePriceSummary(service ))}</span>67 <span class="bw-booking-modal__service-price">${escapeHtml(servicePriceSummary(service, language))}</span> 62 68 </button> 63 69 `, … … 66 72 } 67 73 74 // crypto.randomUUID is secure-context only — plain-HTTP embeds (common for 75 // WordPress hosts) must fall back or the bar never mounts. 76 function newClientRequestId() { 77 try { 78 if (typeof crypto?.randomUUID === 'function') { 79 return crypto.randomUUID() 80 } 81 } catch { 82 // fall through to the manual id 83 } 84 return `bmr-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}` 85 } 86 68 87 export function createBookingBar(config, mountId, target = document.body) { 88 const copy = getWidgetCopy(config.language) 89 let clientRequestId = newClientRequestId() 90 let terminalPaymentReplacementAvailable = false 91 let answers = {} 69 92 const shell = document.createElement('div') 70 93 const spacer = document.createElement('div') … … 79 102 let paymentController = null 80 103 let isSubmitting = false 81 let form = { customerName: '', customerEmail: '', customerPhone: '', notes: '' }104 let form = { customerName: '', customerEmail: '', customerPhone: '', customerPhoneCountry: '', notes: '' } 82 105 83 106 shell.className = 'bw-booking-bar-shell' … … 149 172 function timeMarkup(services, selectedService, slots) { 150 173 if (config.loadError) { 151 return '<div class="bw-booking-bar-panel__empty">Booking setup could not be loaded.</div>'174 return `<div class="bw-booking-bar-panel__empty">${escapeHtml(copy.loadError)}</div>` 152 175 } 153 176 154 177 if (!selectedService) { 155 return '<div class="bw-booking-bar-panel__empty">No services are published for this widget yet.</div>'156 } 157 158 const serviceCountLabel = `${services.length} ${services.length === 1 ? 'service' : 'services'}`178 return `<div class="bw-booking-bar-panel__empty">${escapeHtml(copy.emptyServices)}</div>` 179 } 180 181 const serviceCountLabel = `${services.length} ${services.length === 1 ? copy.serviceSingular : copy.servicePlural}` 159 182 const slotMarkup = slots.length 160 183 ? slots … … 166 189 <em>${escapeHtml(slot.capacity)}</em> 167 190 </span> 168 <span>${ index === selectedSlotIndex ? 'Selected' : 'Choose'}</span>191 <span>${escapeHtml(index === selectedSlotIndex ? copy.selected : copy.choose)}</span> 169 192 </button> 170 193 `, 171 194 ) 172 195 .join('') 173 : '<div class="bw-booking-bar-panel__empty">No times are available yet.</div>'196 : `<div class="bw-booking-bar-panel__empty">${escapeHtml(copy.emptySlots)}</div>` 174 197 175 198 return ` 176 199 <div class="bw-booking-modal__time"> 177 <aside class="bw-booking-modal__services" aria-label=" Services">200 <aside class="bw-booking-modal__services" aria-label="${escapeHtml(copy.servicesLabel)}"> 178 201 <div class="bw-booking-modal__section-title"> 179 <strong> Services</strong>202 <strong>${escapeHtml(copy.servicesLabel)}</strong> 180 203 <em>${escapeHtml(serviceCountLabel)}</em> 181 204 </div> 182 205 <div class="bw-booking-modal__service-list"> 183 ${serviceRowsMarkup(services, selectedServiceIndex )}206 ${serviceRowsMarkup(services, selectedServiceIndex, config.previewMode, copy, config.language)} 184 207 </div> 185 208 </aside> 186 <section class="bw-booking-modal__times" aria-label=" Available times">209 <section class="bw-booking-modal__times" aria-label="${escapeHtml(copy.availableTimes)}"> 187 210 <div class="bw-booking-modal__section-title"> 188 <strong> Times</strong><span>${escapeHtml(timezoneLabel(config))}</span>211 <strong>${escapeHtml(copy.timesLabel)}</strong><span>${escapeHtml(timezoneLabel(config))}</span> 189 212 <em>${escapeHtml(selectedService.name)}</em> 190 213 </div> … … 199 222 function detailsMarkup(selectedService, selectedSlot) { 200 223 if (!selectedService || !selectedSlot) { 201 return '<div class="bw-booking-modal__pane"><div class="bw-booking-bar-panel__empty">Select a service and time first.</div></div>' 202 } 224 return `<div class="bw-booking-modal__pane"><div class="bw-booking-bar-panel__empty">${escapeHtml(copy.selectServiceAndTime)}</div></div>` 225 } 226 const depositUnavailable = Number(selectedService.depositCents) > 0 && config.features?.deposits !== true 203 227 204 228 return ` … … 207 231 <div class="bw-booking-bar-panel__summary-card"> 208 232 <strong>${escapeHtml(selectedService.name)}</strong> 209 <span>${escapeHtml(panelSummary(selectedService, selectedSlot ))}</span>233 <span>${escapeHtml(panelSummary(selectedService, selectedSlot, copy, config.language))}</span> 210 234 </div> 211 <p class="bw-booking-bar-panel__section-title">Your details</p> 235 <p class="bw-booking-bar-panel__section-title">${escapeHtml(copy.detailsHeading)}</p> 236 ${depositUnavailable ? `<p role="note">${escapeHtml(copy.depositUnavailable)}</p>` : ''} 212 237 <div class="bw-booking-bar-panel__form-row"> 213 238 <label> 214 <span> Name</span>215 <input name="customerName" autocomplete="name" required placeholder=" Full name" value="${escapeHtml(form.customerName)}" />239 <span>${escapeHtml(copy.nameLabel)}</span> 240 <input name="customerName" autocomplete="name" required placeholder="${escapeHtml(copy.namePlaceholder)}" value="${escapeHtml(form.customerName)}" /> 216 241 </label> 242 ${ 243 config.sms?.available === true 244 ? ` 245 <label> 246 <span>${escapeHtml(copy.phoneCountryLabel)}</span> 247 <select name="customerPhoneCountry" autocomplete="country"> 248 <option value="">${escapeHtml(copy.chooseOption)}</option> 249 ${PHONE_COUNTRIES.map( 250 (iso) => 251 `<option value="${iso}"${form.customerPhoneCountry === iso ? ' selected' : ''}>${iso} (+${escapeHtml(DIAL_CODES[iso])})</option>`, 252 ).join('')} 253 </select> 254 </label> 255 ` 256 : '' 257 } 217 258 <label> 218 <span> Phone <small>(optional)</small></span>219 <input name="customerPhone" autocomplete="tel" type="tel" placeholder=" +1 555 0100" value="${escapeHtml(form.customerPhone)}" />259 <span>${escapeHtml(copy.phoneLabel)}</span> 260 <input name="customerPhone" autocomplete="tel" type="tel" placeholder="${escapeHtml(copy.phonePlaceholder)}" value="${escapeHtml(form.customerPhone)}" /> 220 261 221 262 </label> 222 263 </div> 223 264 <label> 224 <span> Email</span>225 <input name="customerEmail" autocomplete="email" type="email" required placeholder=" you@example.com" value="${escapeHtml(form.customerEmail)}" />265 <span>${escapeHtml(copy.emailLabel)}</span> 266 <input name="customerEmail" autocomplete="email" type="email" required placeholder="${escapeHtml(copy.emailPlaceholder)}" value="${escapeHtml(form.customerEmail)}" /> 226 267 </label> 227 268 <label> 228 <span> Notes <small>(optional)</small></span>229 <textarea name="notes" rows="3" maxlength="500" placeholder=" Anything we should know?">${escapeHtml(form.notes)}</textarea>269 <span>${escapeHtml(copy.notesLabel)}</span> 270 <textarea name="notes" rows="3" maxlength="500" placeholder="${escapeHtml(copy.notesPlaceholder)}">${escapeHtml(form.notes)}</textarea> 230 271 </label> 272 ${(selectedService.bookingQuestions || []).map(question => { 273 const value = String(answers[question.id] || '') 274 const name = `answer-${escapeHtml(question.id)}` 275 const required = question.required ? ' required' : '' 276 const options = question.type === 'yesno' ? ['yes', 'no'] : question.options || [] 277 const control = question.type === 'text' 278 ? `<input name="${name}" maxlength="500"${required} value="${escapeHtml(value)}">` 279 : `<select name="${name}"${required}><option value="">${escapeHtml(copy.chooseOption)}</option>${options.map(option => `<option value="${escapeHtml(option)}"${value === option ? ' selected' : ''}>${escapeHtml(question.type === 'yesno' ? (option === 'yes' ? copy.yesLabel : copy.noLabel) : option)}</option>`).join('')}</select>` 280 return `<label><span>${escapeHtml(question.label)}${question.required ? ' *' : ''}</span>${control}</label>` 281 }).join('')} 282 ${ 283 config.sms?.available === true 284 ? ` 285 <label class="bw-booking-bar-panel__optin"> 286 <input type="checkbox" name="smsOptIn" /> 287 <span>${escapeHtml(copy.smsOptInLabel)}</span> 288 </label> 289 ` 290 : '' 291 } 292 ${ 293 config.whatsapp?.available === true 294 ? ` 295 <label class="bw-booking-bar-panel__optin"> 296 <input type="checkbox" name="whatsappOptIn" /> 297 <span>${escapeHtml(copy.whatsappOptInLabel)}</span> 298 </label> 299 ` 300 : '' 301 } 231 302 ${message ? `<div class="bw-booking-bar-panel__message" role="status">${escapeHtml(message)}</div>` : ''} 232 303 <div class="bw-booking-bar-panel__actions"> 233 <button type="button" class="bw-booking-bar-panel__back"> Back</button>234 <button type="submit" class="bw-booking-bar-panel__continue" >Confirm booking</button>304 <button type="button" class="bw-booking-bar-panel__back">${escapeHtml(copy.back)}</button> 305 <button type="submit" class="bw-booking-bar-panel__continue" ${depositUnavailable ? 'disabled' : ''}>${escapeHtml(terminalPaymentReplacementAvailable ? copy.replacePaymentCta : copy.confirmCta)}</button> 235 306 </div> 236 307 </form> … … 241 312 function paymentMarkup(selectedService, selectedSlot) { 242 313 return `<div class="bw-booking-modal__pane"> 243 <div class="bw-booking-bar-panel__summary-card"><strong>${escapeHtml(selectedService?.name || 'Appointment')}</strong><span>${escapeHtml(panelSummary(selectedService, selectedSlot))}</span></div>244 <p class="bw-booking-bar-panel__section-title"> Pay ${escapeHtml(servicePriceSummary({ price: paymentSession.depositCents / 100 }))} deposit</p>245 <p> Your booking is confirmed after payment.</p>246 < div data-payment-element></div>247 <p data-payment-status role="status"> Loading secure payment…</p>314 <div class="bw-booking-bar-panel__summary-card"><strong>${escapeHtml(selectedService?.name || copy.appointment)}</strong><span>${escapeHtml(panelSummary(selectedService, selectedSlot, copy, config.language))}</span></div> 315 <p class="bw-booking-bar-panel__section-title">${escapeHtml(copy.depositHeading)} · ${escapeHtml(servicePriceSummary({ price: paymentSession.depositCents / 100, currency: selectedService?.currency }, config.language))}</p> 316 <p>${escapeHtml(copy.depositNote)}</p> 317 <slot name="bm-payment-element" data-payment-element></slot> 318 <p data-payment-status role="status">${escapeHtml(copy.paymentLoading)}</p> 248 319 ${message ? `<p role="alert">${escapeHtml(message)}</p>` : ''} 249 <button type="button" class="bw-booking-bar-panel__continue" data-pay-deposit disabled> Pay deposit and confirm</button>320 <button type="button" class="bw-booking-bar-panel__continue" data-pay-deposit disabled>${escapeHtml(copy.payDepositCta)}</button> 250 321 </div>` 251 322 } … … 265 336 const reference = 266 337 confirmation?.bookingId && !testMode 267 ? `<span class="bw-booking-bar-panel__ref"> Booking reference: <code>${escapeHtml(338 ? `<span class="bw-booking-bar-panel__ref">${escapeHtml(copy.bookingReference)}: <code>${escapeHtml( 268 339 String(confirmation.bookingId).slice(0, 8), 269 340 )}</code></span>` 270 341 : '' 271 342 const calendarLink = icsUrl 272 ? `<a class="bw-booking-bar-panel__calendar" href="${icsUrl}" download="booking.ics"> Add to calendar</a>`343 ? `<a class="bw-booking-bar-panel__calendar" href="${icsUrl}" download="booking.ics">${escapeHtml(copy.addToCalendar)}</a>` 273 344 : '' 274 345 … … 277 348 <div class="bw-booking-bar-panel__confirmed"> 278 349 <div class="bw-booking-bar-panel__confirm-icon" aria-hidden="true">✓</div> 279 <strong>${ testMode ? 'Test mode — booking not saved' : 'Request received'}</strong>350 <strong>${escapeHtml(testMode ? copy.testModeHeading : copy.successHeading)}</strong> 280 351 <span>${ 281 352 testMode 282 ? 'Nothing was sent — this site runs on a local or test address.'283 : 'A confirmation follows by email.'353 ? escapeHtml(copy.testModeNote) 354 : escapeHtml(copy.successNote) 284 355 }</span> 285 356 ${reference} 286 357 ${calendarLink} 287 <button type="button" class="bw-booking-bar-panel__continue" data-bar-done> Done</button>358 <button type="button" class="bw-booking-bar-panel__continue" data-bar-done>${escapeHtml(copy.done)}</button> 288 359 </div> 289 360 </div> … … 310 381 <em>${escapeHtml( 311 382 bookingStep === 'details' 312 ? 'Enter your details'383 ? copy.detailsHeading 313 384 : bookingStep === 'confirmed' 314 ? 'Request received'315 : 'Choose a time',385 ? copy.successHeading 386 : copy.chooseTime, 316 387 )}</em> 317 388 </span> 318 389 </span> 319 <button type="button" class="bw-booking-bar-panel__close" aria-label=" Close booking bar">×</button>390 <button type="button" class="bw-booking-bar-panel__close" aria-label="${escapeHtml(copy.close)}">×</button> 320 391 </div> 321 ${stepperMarkup(bookingStep )}392 ${stepperMarkup(bookingStep, copy)} 322 393 <div class="bw-booking-modal__body">${content}</div> 323 394 ${ 324 395 config.showBranding 325 ? '<div class="bw-booking-modal__powered">Powered by <strong>Bookminto</strong></div>'396 ? `<div class="bw-booking-modal__powered">${escapeHtml(copy.widgetFooterPrefix)} <strong>Bookminto</strong></div>` 326 397 : '' 327 398 } … … 330 401 ? `<div class="bw-booking-modal__footer"> 331 402 <span> 332 <strong>${escapeHtml(selectedService?.name || 'No service selected')}</strong>333 <span>${escapeHtml(panelSummary(selectedService, selectedSlot ))}</span>403 <strong>${escapeHtml(selectedService?.name || copy.noServiceSelected)}</strong> 404 <span>${escapeHtml(panelSummary(selectedService, selectedSlot, copy, config.language))}</span> 334 405 </span> 335 <button type="button" class="bw-booking-bar-panel__continue" ${canContinue ? '' : 'disabled'}>${ canContinue ? 'Continue booking' : 'No time selected'}</button>406 <button type="button" class="bw-booking-bar-panel__continue" ${canContinue ? '' : 'disabled'}>${escapeHtml(canContinue ? copy.continueDefault : copy.noTimeSelected)}</button> 336 407 </div>` 337 408 : '' … … 346 417 const services = config.services 347 418 const selectedService = services[selectedServiceIndex] 348 const slots = createAvailabilitySlots(config, selectedService, 6) 419 // Union availability still scopes this surface; an explicitly empty 420 // qualified set means nobody can perform the service. 421 const slots = serviceBlockedByStaff(config, selectedService) 422 ? [] 423 : createAvailabilitySlots(config, selectedService, 6) 349 424 const selectedSlot = slots[selectedSlotIndex] 350 425 const canContinue = Boolean(selectedService && selectedSlot) 351 426 const invitation = config.loadError 352 ? 'Booking setup unavailable'427 ? copy.loadError 353 428 : services.length > 1 354 ? 'Choose a service and time'429 ? copy.selectServiceAndTime 355 430 : selectedService 356 ? serviceSummary(selectedService )357 : 'No services available yet'431 ? serviceSummary(selectedService, config.language) 432 : copy.emptyServices 358 433 359 434 shell.dataset.open = String(isOpen) … … 369 444 </button> 370 445 <button type="button" class="bw-booking-bar__cta">${ 371 isOpen ? 'Close' : selectedService ? 'Choose time' : 'View setup'446 escapeHtml(isOpen ? copy.close : selectedService ? copy.chooseTime : copy.viewSetup) 372 447 }</button> 373 448 </div> … … 377 452 panel.className = 'bw-booking-bar-panel bw-booking-bar-panel--booking' 378 453 panel.innerHTML = bookingMarkup(selectedService, selectedSlot, slots, canContinue) 454 bindServicePhotoErrors(panel) 379 455 380 456 shell.querySelector('.bw-booking-bar__main')?.addEventListener('click', () => { … … 382 458 bookingStep = 'time' 383 459 render() 460 if (isOpen) panel.querySelector('.bw-booking-bar-panel__close')?.focus() 461 else shell.querySelector('.bw-booking-bar__cta')?.focus() 384 462 }) 385 463 shell.querySelector('.bw-booking-bar__cta')?.addEventListener('click', () => { … … 387 465 bookingStep = 'time' 388 466 render() 467 if (isOpen) panel.querySelector('.bw-booking-bar-panel__close')?.focus() 468 else shell.querySelector('.bw-booking-bar__cta')?.focus() 389 469 }) 390 470 panel.querySelector('.bw-booking-bar-panel__close')?.addEventListener('click', () => { … … 392 472 bookingStep = 'time' 393 473 render() 474 shell.querySelector('.bw-booking-bar__cta')?.focus() 394 475 }) 395 476 panel.querySelectorAll('[data-panel-service-index]').forEach((button) => { … … 397 478 button.addEventListener('click', () => { 398 479 selectedServiceIndex = Number(button.getAttribute('data-panel-service-index')) || 0 480 clientRequestId = newClientRequestId() 481 terminalPaymentReplacementAvailable = false 482 answers = {} 399 483 selectedSlotIndex = 0 400 484 message = '' … … 407 491 button.addEventListener('click', () => { 408 492 selectedSlotIndex = Number(button.getAttribute('data-slot-index')) || 0 493 clientRequestId = newClientRequestId() 494 terminalPaymentReplacementAvailable = false 409 495 message = '' 410 496 render() … … 423 509 }) 424 510 panel.querySelector('[data-bar-done]')?.addEventListener('click', () => { 511 clientRequestId = newClientRequestId() 512 terminalPaymentReplacementAvailable = false 513 answers = {} 425 514 isOpen = false 426 515 bookingStep = 'time' … … 431 520 const button = panel.querySelector('[data-pay-deposit]') 432 521 const session = paymentSession 433 void mountDepositPayment(host, { ...session, accent: config.color }).then((controller) => {522 void mountDepositPayment(host, { ...session, accent: config.color, language: config.language }).then((controller) => { 434 523 if (!host.isConnected || paymentSession !== session) { controller.destroy(); return } 435 524 paymentController = controller … … 437 526 panel.querySelector('[data-payment-status]').textContent = '' 438 527 }).catch((error) => { 439 if (host.isConnected && paymentSession === session) { panel.querySelector('[data-payment-status]').textContent = ''; showPaymentLoadError(host, error, render ) }528 if (host.isConnected && paymentSession === session) { panel.querySelector('[data-payment-status]').textContent = ''; showPaymentLoadError(host, error, render, config.language) } 440 529 }) 441 530 button.addEventListener('click', async () => { … … 445 534 const controller = paymentController 446 535 try { 447 const paymentIntentId = await controller.confirm() 448 await confirmBookingPayment(config, { bookingId: session.bookingId, paymentIntentId }) 536 const paymentIntentId = session.confirmedPaymentIntentId || await controller.confirm() 537 if (paymentSession !== session || paymentController !== controller) return 538 session.confirmedPaymentIntentId = paymentIntentId 539 const result = await confirmBookingPayment(config, { bookingId: session.bookingId, paymentIntentId }) 540 if (paymentSession !== session || paymentController !== controller) return 541 if (result.processing || !(result.ok || result.alreadyConfirmed)) throw new Error(copy.paymentError) 449 542 confirmation = { bookingId: session.bookingId, serviceName: selectedService?.name, durationMinutes: selectedService?.durationMinutes, startsAt: selectedSlot?.startsAt } 450 543 paymentSession = null … … 452 545 message = '' 453 546 } catch (error) { 454 message = error instanceof Error ? error.message : 'Payment could not be confirmed. Try again.' 547 message = error instanceof Error ? error.message : copy.paymentError 548 if (host.isConnected) panel.querySelector('[data-payment-status]').textContent = message 455 549 } finally { 456 550 isSubmitting = false 457 render() 551 if (paymentSession === session) button.disabled = false 552 else render() 458 553 } 459 554 }) … … 462 557 event.preventDefault() 463 558 if (isSubmitting) return 559 if (Number(selectedService?.depositCents) > 0 && config.features?.deposits !== true) return 464 560 isSubmitting = true 465 561 const formData = new FormData(event.currentTarget) 466 562 form = Object.fromEntries(Object.keys(form).map((key) => [key, String(formData.get(key) || '')])) 467 468 message = 'Sending request...' 563 const smsOptIn = formData.get('smsOptIn') === 'on' 564 const whatsappOptIn = formData.get('whatsappOptIn') === 'on' 565 answers = Object.fromEntries((selectedService.bookingQuestions || []).map(question => [question.id, String(formData.get(`answer-${question.id}`) || '')])) 566 567 // The opt-in only counts with a routable number — the worker re-checks. 568 if ((smsOptIn || whatsappOptIn) && !normalizePhoneE164(form.customerPhone, form.customerPhoneCountry || undefined)) { 569 isSubmitting = false 570 message = whatsappOptIn && !smsOptIn ? copy.phoneRequiredForWhatsapp : copy.phoneRequiredForSms 571 render() 572 return 573 } 574 575 message = copy.processing 469 576 render() 470 577 const bookedService = selectedService … … 472 579 473 580 submitBooking(config, { 581 answers, 582 clientRequestId, 583 replaceTerminalPayment: terminalPaymentReplacementAvailable || undefined, 584 publicationRevision: config.publicationRevision, 585 language: config.language || 'en', 474 586 customerEmail: formData.get('customerEmail'), 475 587 customerName: formData.get('customerName'), 476 588 customerPhone: formData.get('customerPhone'), 589 customerPhoneCountry: form.customerPhoneCountry || undefined, 477 590 notes: formData.get('notes'), 478 591 serviceId: selectedService?.id || selectedService?.name, 479 592 serviceName: selectedService?.name, 593 smsOptIn: config.sms?.available === true ? smsOptIn || undefined : undefined, 480 594 startsAt: selectedSlot?.startsAt, 595 whatsappOptIn: config.whatsapp?.available === true ? whatsappOptIn || undefined : undefined, 481 596 }) 482 597 .then((result) => { … … 484 599 return 485 600 } 486 const depositSession = getDepositPaymentSession(result )601 const depositSession = getDepositPaymentSession(result, config.language) 487 602 if (depositSession) { 603 terminalPaymentReplacementAvailable = false 488 604 paymentSession = depositSession 489 605 message = '' … … 495 611 durationMinutes: bookedService?.durationMinutes, 496 612 localOnly: result?.localOnly === true, 497 serviceName: bookedService?.name || 'Appointment',613 serviceName: bookedService?.name || copy.appointment, 498 614 startsAt: bookedSlot?.startsAt, 499 615 } … … 503 619 }) 504 620 .catch((error) => { 505 message = error instanceof Error ? error.message : 'Booking could not be created' 621 terminalPaymentReplacementAvailable = error?.code === 'terminal_payment_replacement_available' 622 message = terminalPaymentReplacementAvailable ? copy.replacePaymentNote : error instanceof Error ? error.message : copy.genericError 506 623 bookingStep = 'details' 507 624 render() -
bookminto/trunk/assets/src/bookingBarModalStyles.js
r3723811 r3730301 194 194 } 195 195 196 .bw-booking-modal__service .bw-booking-modal__service-body { 197 display: flex; 198 align-items: center; 199 gap: 10px; 200 min-width: 0; 201 } 202 203 .bw-booking-modal__service .bw-booking-modal__service-copy { 204 display: flex; 205 flex-direction: column; 206 min-width: 0; 207 } 208 196 209 .bw-booking-modal__service-body strong { 197 210 overflow: hidden; -
bookminto/trunk/assets/src/bookingBarStyles.js
r3723811 r3730301 224 224 225 225 @media (max-width: 760px) { 226 /* The mobile panel fills the viewport. Its own close button stays visible; 227 * the bottom launcher would otherwise cover the booking action. */ 228 .bw-booking-bar-shell[data-open="true"] { 229 display: none; 230 } 231 226 232 .bw-booking-bar-spacer { 227 233 height: 0; -
bookminto/trunk/assets/src/bookingModal.js
r3723811 r3730301 1 import { widgetCopy as copy } from './copy.js'1 import { getWidgetCopy } from './copy.js' 2 2 import { createModalPanel } from './modalRender.js' 3 3 … … 14 14 */ 15 15 export function createBookingModal(config, mountId, root, options = {}) { 16 const copy = getWidgetCopy(config.language) 16 17 const triggerSelector = 17 18 typeof config.trigger === 'string' … … 20 21 ? config.triggerSelector.trim() 21 22 : '' 23 // The trigger selector comes from the published remote config — a malformed 24 // value would make closest() throw on every click on the host page. 25 let safeTriggerSelector = '' 26 if (triggerSelector && typeof document.querySelector === 'function') { 27 try { 28 document.querySelector(triggerSelector) 29 safeTriggerSelector = triggerSelector 30 } catch { 31 // Invalid selector — fall back to the inline CTA below. 32 } 33 } 22 34 const buttonAlign = normalizeButtonAlign(config.buttonAlign || config.buttonPosition) 23 35 const openByDefault = Boolean(options.openByDefault) … … 68 80 } 69 81 70 if (! triggerSelector) {82 if (!safeTriggerSelector && !options.suppressCta) { 71 83 ctaWrap = document.createElement('div') 72 84 cta = document.createElement('button') … … 102 114 function onExternalClick(event) { 103 115 const target = event.target 104 if (! triggerSelector || !target || typeof target.closest !== 'function') {116 if (!safeTriggerSelector || !target || typeof target.closest !== 'function') { 105 117 return 106 118 } 107 119 108 if (target.closest( triggerSelector)) {120 if (target.closest(safeTriggerSelector)) { 109 121 event.preventDefault() 110 122 setOpen(true) … … 114 126 if (typeof document.addEventListener === 'function') { 115 127 document.addEventListener('keydown', onKeyDown) 116 if ( triggerSelector) {128 if (safeTriggerSelector) { 117 129 document.addEventListener('click', onExternalClick, true) 118 130 } … … 127 139 if (typeof document.removeEventListener === 'function') { 128 140 document.removeEventListener('keydown', onKeyDown) 129 if ( triggerSelector) {141 if (safeTriggerSelector) { 130 142 document.removeEventListener('click', onExternalClick, true) 131 143 } … … 150 162 } 151 163 }, 164 panel, 152 165 setOpen, 153 166 } -
bookminto/trunk/assets/src/commerce.js
r3723811 r3730301 174 174 } 175 175 176 const checkoutUrl = result?.data?.checkoutUrl|| '/checkout/'176 const checkoutUrl = safeNavigationUrl(result?.data?.checkoutUrl) || '/checkout/' 177 177 window.location.assign(checkoutUrl) 178 178 return { redirected: true } 179 179 } 180 181 // The WooCommerce AJAX response controls the navigation target — restrict it 182 // to same-origin paths or http(s) URLs so a `javascript:`/`data:` value can 183 // never execute. 184 function safeNavigationUrl(value) { 185 if (typeof value !== 'string' || !value) { 186 return '' 187 } 188 try { 189 const url = new URL(value, window.location.href) 190 return url.protocol === 'https:' || url.protocol === 'http:' ? url.href : '' 191 } catch { 192 return '' 193 } 194 } -
bookminto/trunk/assets/src/config.js
r3723811 r3730301 2 2 import { readAvailability, readExceptions } from './availability.js' 3 3 import { normalizeColor, normalizeTimeFormat, text } from './formatters.js' 4 import { getWidgetCopy } from './copy.js' 4 5 5 6 const localApiUrl = 'http://127.0.0.1:5174/v1' … … 57 58 } 58 59 59 return { serviceId: text(slot.serviceId, ''), startsAt } 60 return { 61 blockedEnd: text(slot.blockedEnd, ''), 62 blockedStart: text(slot.blockedStart, ''), 63 serviceId: text(slot.serviceId, ''), 64 staffId: text(slot.staffId, '') || null, 65 startsAt, 66 } 60 67 }) 61 68 .filter(Boolean) … … 64 71 function checkoutServices(services, commerce) { 65 72 return commerce === 'shopify' || commerce === 'woocommerce' ? services.map((service) => ({ ...service, depositCents: 0, depositEuros: 0 })) : services 73 } 74 75 export function normalizeLanguage(value) { 76 // Accept regional tags too (data-language="de-AT" → 'de') — browsers report 77 // full BCP-47 tags and the dictionaries live at the base language. 78 const language = text(value, '').toLowerCase().split(/[-_]/)[0] 79 return ['de', 'en', 'es', 'fr', 'it', 'nl'].includes(language) ? language : 'en' 80 } 81 82 // service.staffIds: null/undefined = every member qualified, [] = nobody. 83 function readStaffIds(value) { 84 if (value === null) { 85 return null 86 } 87 88 if (!Array.isArray(value)) { 89 return undefined 90 } 91 92 return value.map((id) => text(id, '')).filter(Boolean).slice(0, 100) 93 } 94 95 // Public team list — displayable member objects only arrive when the 96 // publication uses 'customer_choice' display mode. 97 function readStaffList(value) { 98 if (!Array.isArray(value)) { 99 return [] 100 } 101 102 return value 103 .map((member) => { 104 if (!member || typeof member !== 'object') { 105 return null 106 } 107 108 const id = text(member.id, '') 109 110 if (!id) { 111 return null 112 } 113 114 const color = text(member.color, '') 115 116 return { 117 avatarUrl: safeServiceImageUrl(member.avatarUrl), 118 color: /^#[0-9a-fA-F]{6}$/.test(color) ? color : '', 119 id, 120 name: text(member.name, ''), 121 } 122 }) 123 .filter(Boolean) 124 .slice(0, 50) 125 } 126 127 function readStaffAvailability(value) { 128 if (!value || typeof value !== 'object' || Array.isArray(value)) { 129 return undefined 130 } 131 132 const entries = {} 133 134 for (const [key, entry] of Object.entries(value)) { 135 const memberId = text(key, '') 136 137 if (!memberId || !entry || typeof entry !== 'object') { 138 continue 139 } 140 141 entries[memberId] = { 142 availability: readAvailability(entry.availability), 143 exceptions: readExceptions(entry.exceptions), 144 } 145 } 146 147 return entries 148 } 149 150 // Booking questions the owner configured on a service (max 5, three types). 151 function readBookingQuestions(value) { 152 if (!Array.isArray(value)) { 153 return [] 154 } 155 156 return value 157 .map((question, index) => { 158 if (!question || typeof question !== 'object') { 159 return null 160 } 161 162 const label = text(question.label, '') 163 const type = ['text', 'choice', 'yesno'].includes(question.type) 164 ? question.type 165 : 'text' 166 167 if (!label) { 168 return null 169 } 170 171 return { 172 id: text(question.id, `q${index + 1}`), 173 label, 174 options: 175 type === 'choice' && Array.isArray(question.options) 176 ? question.options.map((option) => text(option, '')).filter(Boolean).slice(0, 8) 177 : [], 178 required: question.required === true, 179 type, 180 } 181 }) 182 .filter(Boolean) 183 .slice(0, 5) 66 184 } 67 185 … … 72 190 apiUrl: runtimeApiUrl(config.apiUrl), 73 191 bookedSlots: Array.isArray(config.bookedSlots) ? config.bookedSlots : [], 192 bookingHorizonDays: Number(config.bookingHorizonDays) || 0, 74 193 color: normalizeColor(config.color), 194 features: 195 config.features && typeof config.features === 'object' ? config.features : {}, 196 publicationRevision: String(config.publicationRevision || ''), 197 language: normalizeLanguage(config.language), 198 minNoticeMinutes: Number(config.minNoticeMinutes) || 0, 199 nextTimes: optionalBoolean(config.nextTimes, false), 200 preferCompactSchedule: optionalBoolean(config.preferCompactSchedule, false), 201 selfServiceCutoffHours: Number(config.selfServiceCutoffHours) || 0, 75 202 commerce, 76 203 services: checkoutServices(config.services, commerce), … … 78 205 position: text(config.position, 'bottom-right'), 79 206 showBranding: optionalBoolean(config.showBranding, false), 80 title: text(config.title, 'Schedule a booking'),207 title: text(config.title, getWidgetCopy(config.language).title), 81 208 timeFormat: normalizeTimeFormat(config.timeFormat), 82 209 timezone: text(config.timezone, ''), … … 90 217 } 91 218 219 export function readServicePrice(service) { 220 if (Object.hasOwn(service, 'priceCents')) { 221 const cents = service.priceCents 222 if (typeof cents !== 'number' || !Number.isInteger(cents) || cents < 0 || cents > 2147483647) { 223 throw new Error('Service price unavailable') 224 } 225 return cents / 100 226 } 227 // Older published configs use EUR major units. Absence is never a free price. 228 if (typeof service.price !== 'number' || !Number.isFinite(service.price) || service.price < 0) { 229 throw new Error('Service price unavailable') 230 } 231 return Math.round((service.price + Number.EPSILON) * 100) / 100 232 } 233 92 234 export function readServices(value) { 93 235 if (!value) { … … 105 247 .map((service) => ({ 106 248 description: text(service.description, ''), 249 imageUrl: safeServiceImageUrl(service.imageUrl), 107 250 durationMinutes: Number(service.durationMinutes) || 30, 108 251 id: text(service.id, ''), 109 252 name: text(service.name, ''), 110 price: Number(service.price) || 0,253 price: readServicePrice(service), 111 254 depositCents: Math.max(0, Number(service.depositCents ?? Number(service.depositEuros || 0) * 100) || 0), 112 255 bufferAfterMinutes: Number(service.bufferAfterMinutes) || 0, … … 114 257 useCustomAvailability: Boolean(service.useCustomAvailability), 115 258 availability: readAvailability(service.availability), 259 bookingDates: Array.isArray(service.bookingDates) 260 ? service.bookingDates 261 .map((entry) => ({ 262 date: text(entry && entry.date, ''), 263 endTime: text(entry && entry.endTime, ''), 264 startTime: text(entry && entry.startTime, ''), 265 })) 266 .filter((entry) => /^\d{4}-\d{2}-\d{2}$/.test(entry.date)) 267 .slice(0, 60) 268 : [], 269 bookingQuestions: readBookingQuestions(service.bookingQuestions), 270 rebookingIntervalWeeks: Math.min(52, Math.max(0, Number(service.rebookingIntervalWeeks) || 0)), 271 staffIds: readStaffIds(service.staffIds), 116 272 })) 117 273 .filter((service) => service.name) … … 146 302 commerce: text(config.commerce, ''), 147 303 exceptions: readExceptions(config.exceptions), 304 // Extension A: `data-next-times` renders the compact upcoming-times strip. 305 nextTimes: optionalBoolean(config.nextTimes, false), 148 306 position: text(config.position, ''), 307 proposalSlug: text(config.proposalSlug, ''), 149 308 service: serviceFilter, 150 309 services: filterServicesById( … … 152 311 serviceFilter, 153 312 ), 313 staff: readStaffList(config.staff), 314 staffAvailability: readStaffAvailability(config.staffAvailability), 315 staffDisplayMode: 316 text(config.staffDisplayMode, '') === 'customer_choice' ? 'customer_choice' : 'hidden', 154 317 shopifyProduct: text(config.shopifyProduct, ''), 155 318 shopifyShop: text( … … 164 327 wooCartNonce: text(config.wooCartNonce || config.wooCartnonce, ''), 165 328 showBranding: optionalBoolean(config.showBranding, false), 329 hostedPage: optionalBoolean(config.hostedPage, false), 166 330 title: text(config.title, ''), 167 331 timeFormat: text(config.timeFormat, ''), … … 204 368 bufferBeforeMinutes: Number(service.bufferBeforeMinutes) || 0, 205 369 description: text(service.description, ''), 370 imageUrl: safeServiceImageUrl(service.imageUrl), 206 371 durationMinutes: Number(service.durationMinutes) || 30, 207 372 id: text(service.id, ''), 208 373 name: text(service.name, ''), 209 price: Number(service.price) || 0,374 price: readServicePrice(service), 210 375 depositCents: Math.max(0, Number(service.depositCents ?? Number(service.depositEuros || 0) * 100) || 0), 211 376 useCustomAvailability: Boolean(service.useCustomAvailability), 377 bookingDates: Array.isArray(service.bookingDates) 378 ? service.bookingDates 379 .map((entry) => ({ 380 date: text(entry && entry.date, ''), 381 endTime: text(entry && entry.endTime, ''), 382 startTime: text(entry && entry.startTime, ''), 383 })) 384 .filter((entry) => /^\d{4}-\d{2}-\d{2}$/.test(entry.date)) 385 .slice(0, 60) 386 : [], 387 bookingQuestions: readBookingQuestions(service.bookingQuestions), 388 rebookingIntervalWeeks: Math.min( 389 52, 390 Math.max(0, Number(service.rebookingIntervalWeeks) || 0), 391 ), 392 staffIds: readStaffIds(service.staffIds), 212 393 })) 213 394 .filter((service) => service.name) … … 222 403 223 404 const commerce = text(config.commerce, '') 224 // Platform checkout booking must stay inline on the product page. 225 // Never inherit published floating/bar placement for commerce mounts. 405 // Platform checkout booking must stay inline on the product page, and 406 // hosted /book/<slug> pages always mount inline — a published floating 407 // placement must never escape the branded shell. 226 408 const variant = 227 commerce === 'shopify' || commerce === 'woocommerce' 409 commerce === 'shopify' || commerce === 'woocommerce' || config.hostedPage === true 228 410 ? 'inline' 229 411 : text(remoteConfig.variant || config.variant, 'inline') 412 const language = normalizeLanguage(remoteConfig.language || config.language) 230 413 231 414 return { … … 233 416 apiUrl: runtimeApiUrl(config.apiUrl), 234 417 bookedSlots: readBookedSlots(remoteConfig.bookedSlots), 418 calendarVerification: text(remoteConfig.calendarVerification, 'none'), 419 bookingHorizonDays: Number(remoteConfig.bookingHorizonDays) || 0, 235 420 color: normalizeColor(remoteConfig.color || config.color), 421 features: 422 remoteConfig.features && typeof remoteConfig.features === 'object' 423 ? remoteConfig.features 424 : {}, 425 language, 426 publicationRevision: text(remoteConfig.publicationRevision, ''), 427 minNoticeMinutes: Number(remoteConfig.minNoticeMinutes) || 0, 428 nextTimes: optionalBoolean(config.nextTimes, false), 429 preferCompactSchedule: optionalBoolean(remoteConfig.preferCompactSchedule, false), 430 selfServiceCutoffHours: Number(remoteConfig.selfServiceCutoffHours) || 0, 236 431 commerce, 237 432 exceptions: remoteExceptions, 238 433 position: text(remoteConfig.position || config.position, 'bottom-right'), 434 proposalSlug: text(config.proposalSlug, ''), 239 435 service: text(config.service, ''), 240 436 services: checkoutServices(services, commerce), … … 247 443 // Public credits are controlled locally. Remote SaaS configuration cannot enable them. 248 444 showBranding: config.showBranding, 249 title: text(remoteConfig.title || config.title, 'Schedule a booking'), 445 sms: readChannelOptin(remoteConfig.sms), 446 staff: readStaffList(remoteConfig.staff), 447 staffAvailability: readStaffAvailability(remoteConfig.staffAvailability), 448 staffDisplayMode: 449 text(remoteConfig.staffDisplayMode, '') === 'customer_choice' 450 ? 'customer_choice' 451 : 'hidden', 452 title: text(remoteConfig.title || config.title, getWidgetCopy(remoteConfig.language || config.language).title), 250 453 timeFormat: normalizeTimeFormat(remoteConfig.timeFormat || config.timeFormat), 251 454 timezone: text(remoteConfig.timezone || config.timezone, ''), … … 259 462 ), 260 463 variant, 464 whatsapp: readChannelOptin(remoteConfig.whatsapp), 261 465 workspace: config.workspace, 262 466 } … … 271 475 272 476 export { withRenderDefaults } 477 478 // Mirrors readChannelOptin in src/lib/bookingPage/config.ts — the published 479 // config carries sms/whatsapp capability blocks that the embedded widget 480 // needs for its opt-in UI. 481 function readChannelOptin(value) { 482 const raw = value && typeof value === 'object' ? value : {} 483 const events = raw.events && typeof raw.events === 'object' ? raw.events : {} 484 return { 485 available: raw.available === true, 486 events: { 487 cancellation: events.cancellation === true, 488 confirmation: events.confirmation === true, 489 reminder24h: events.reminder24h === true, 490 reminder2h: events.reminder2h === true, 491 reschedule: events.reschedule === true, 492 }, 493 textVersion: text(raw.textVersion, '') || undefined, 494 } 495 } 496 497 export function safeServiceImageUrl(value) { 498 if (typeof value !== 'string' || value.length > 2048) return '' 499 try { 500 const url = new URL(value) 501 return url.protocol === 'https:' && !url.username && !url.password ? url.href : '' 502 } catch { return '' } 503 } -
bookminto/trunk/assets/src/copy.js
r3723811 r3730301 1 1 export const DATE_WINDOW_SIZE = 5 2 2 3 export const widgetCopy = {3 const englishCopy = { 4 4 title: 'Schedule a booking', 5 selectServiceAndTime: 'Select a service and time to continue.', 6 servicesLabel: 'Services', 7 serviceSingular: 'service', 8 servicePlural: 'services', 9 selected: 'Selected', 10 choose: 'Choose', 11 timesLabel: 'Times', 12 appointment: 'Appointment', 13 bookingReference: 'Booking reference', 14 done: 'Done', 15 noServiceSelected: 'No service selected', 16 noTimeSelected: 'No time selected', 17 chooseTime: 'Choose a time', 18 viewSetup: 'View setup', 19 paymentLoading: 'Loading secure payment…', 20 paymentError: 'Payment could not be confirmed. Try again.', 21 progressLabel: 'Booking progress', 22 stepLabel: 'Step', 23 stepOf: 'of', 5 24 widgetStepLabels: ['Service', 'Date', 'Details', 'Confirmed'], 6 25 chooseService: 'Choose an offering', … … 27 46 checkoutCta: 'Continue to checkout', 28 47 confirmCta: 'Confirm booking', 48 replacePaymentCta: 'Start a new payment', 49 replacePaymentNote: 'The previous payment was canceled. Start a new payment to continue with this same appointment.', 29 50 payDepositCta: 'Pay deposit', 30 51 depositHeading: 'Pay deposit to confirm', 52 depositLabel: 'deposit', 31 53 depositNote: 'Your time is held while you complete payment.', 54 depositUnavailable: 'A deposit is required for this service, but online payment is temporarily unavailable. Please try again later.', 32 55 processing: 'Processing...', 33 56 redirectingToCheckout: 'Redirecting to checkout…', … … 42 65 loading: 'Loading…', 43 66 genericError: 'Something went wrong. Please try again.', 67 closedAttempt: 'This booking attempt is closed. Choose a new time to start again.', 44 68 loadError: 'Booking setup could not be loaded.', 45 69 previewOnlyNote: 'Preview only — no booking was saved and no email was sent.', … … 47 71 testModeNote: 48 72 'This site is running on a local or test address, so nothing was sent to Bookminto and no confirmation email will arrive.', 49 } 73 questionsHeading: 'A few questions', 74 requiredHint: 'required', 75 yesLabel: 'Yes', 76 noLabel: 'No', 77 chooseOption: 'Choose…', 78 nextTimesLabel: 'Next available', 79 seeAllTimes: 'See all times', 80 noNextTimes: 'No upcoming times right now — check all dates.', 81 recommendedBadge: 'Recommended', 82 waitlistCta: 'Notify me when a time opens up', 83 waitlistHeading: 'Join the waitlist', 84 waitlistNote: 'We will email you as soon as a spot opens. First to claim gets it.', 85 waitlistFrom: 'From', 86 waitlistTo: 'Until', 87 waitlistSubmit: 'Keep me posted', 88 waitlistJoined: 'You are on the list. Watch your inbox — a spot can go fast.', 89 waitlistError: 'Could not add you to the list. Please try again.', 90 rebookingOptInLabel: 'Email me an invitation to book again', 91 phoneCountryLabel: 'Country', 92 phoneRequiredForSms: 'Enter a valid phone number to receive text messages.', 93 phoneRequiredForWhatsapp: 'Enter a valid phone number to receive WhatsApp messages.', 94 smsOptInLabel: 'Send me appointment confirmations and reminders by text message.', 95 whatsappOptInLabel: 'Send me appointment confirmations and reminders on WhatsApp.', 96 depositLoadError: 'A deposit is required, but secure payment could not load. Please try again or contact the business.', 97 paymentLoadFailed: 'Secure payment could not load.', 98 retryCta: 'Try again', 99 staffPickerLabel: 'Choose a team member', 100 anyStaff: 'Any team member', 101 withStaff: 'with {name}', 102 staffUnavailable: 'That team member is no longer available at this time. Pick another slot or choose a different member.', 103 serviceNotBookable: 'This service cannot be booked online right now.', 104 } 105 106 const dutchCopy = { 107 ...englishCopy, 108 title: 'Plan een afspraak', 109 selectServiceAndTime: 'Kies een dienst en tijdstip om verder te gaan.', 110 servicesLabel: 'Diensten', 111 serviceSingular: 'dienst', 112 servicePlural: 'diensten', 113 selected: 'Gekozen', 114 choose: 'Kies', 115 timesLabel: 'Tijdstippen', 116 appointment: 'Afspraak', 117 bookingReference: 'Boekingsreferentie', 118 done: 'Klaar', 119 noServiceSelected: 'Geen dienst gekozen', 120 noTimeSelected: 'Geen tijdstip gekozen', 121 chooseTime: 'Kies een tijdstip', 122 viewSetup: 'Bekijk de boekingsopties', 123 paymentLoading: 'Veilig betalen laden…', 124 paymentError: 'De betaling kon niet worden bevestigd. Probeer opnieuw.', 125 progressLabel: 'Voortgang van je boeking', 126 stepLabel: 'Stap', 127 stepOf: 'van', 128 widgetStepLabels: ['Dienst', 'Datum', 'Gegevens', 'Bevestigd'], 129 chooseService: 'Kies een dienst', 130 chooseDate: 'Kies een datum', 131 availableTimes: 'Beschikbare tijden', 132 summaryLabel: 'Jouw keuze', 133 totalLabel: 'Prijs', 134 detailsHeading: 'Jouw gegevens', 135 previousDates: 'Eerdere datums', 136 nextDates: 'Latere datums', 137 successHeading: 'Afspraak bevestigd!', 138 successNote: 'Je ontvangt zo een bevestigingsmail met de details van je afspraak.', 139 emptySlots: 'Geen tijden beschikbaar op deze datum.', 140 emptyServices: 'Er zijn momenteel geen diensten beschikbaar.', 141 bookNow: 'Boek nu', 142 back: 'Terug', 143 at: 'om', 144 nameLabel: 'Naam', 145 phoneLabel: 'Telefoon', 146 notesLabel: 'Opmerkingen', 147 emailLabel: 'E-mail', 148 continueFromService: 'Kies en ga verder', 149 continueDefault: 'Verder', 150 checkoutCta: 'Verder naar afrekenen', 151 confirmCta: 'Bevestig afspraak', 152 replacePaymentCta: 'Start een nieuwe betaling', 153 replacePaymentNote: 'De vorige betaling is geannuleerd. Start een nieuwe betaling om dezelfde afspraak af te ronden.', 154 payDepositCta: 'Betaal voorschot', 155 depositHeading: 'Betaal het voorschot om te bevestigen', 156 depositLabel: 'voorschot', 157 depositNote: 'Je tijdstip wordt vastgehouden terwijl je betaalt.', 158 depositUnavailable: 'Voor deze dienst is een voorschot vereist, maar online betalen is tijdelijk niet beschikbaar. Probeer het later opnieuw.', 159 processing: 'Bezig…', 160 redirectingToCheckout: 'Doorsturen naar afrekenen…', 161 close: 'Sluiten', 162 addToCalendar: 'Zet in agenda', 163 widgetFooterPrefix: 'Mogelijk gemaakt door', 164 namePlaceholder: 'Jan Jansen', 165 emailPlaceholder: 'jan@voorbeeld.be', 166 notesPlaceholder: 'Specifieke vragen of context…', 167 loading: 'Laden…', 168 genericError: 'Er ging iets mis. Probeer het opnieuw.', 169 closedAttempt: 'Deze boekingspoging is afgesloten. Kies een nieuw tijdstip om opnieuw te beginnen.', 170 loadError: 'De boekingssetup kon niet geladen worden.', 171 previewOnlyNote: 'Alleen voorbeeld — er is geen boeking opgeslagen en geen e-mail verstuurd.', 172 testModeHeading: 'Testmodus — boeking niet opgeslagen', 173 testModeNote: 174 'Deze site draait op een lokale of testomgeving, dus er is niets naar Bookminto verstuurd en er komt geen bevestigingsmail.', 175 questionsHeading: 'Nog een paar vragen', 176 requiredHint: 'verplicht', 177 yesLabel: 'Ja', 178 noLabel: 'Nee', 179 chooseOption: 'Kies…', 180 nextTimesLabel: 'Eerstvolgende', 181 seeAllTimes: 'Alle tijden', 182 noNextTimes: 'Momenteel geen vrije tijden — bekijk alle datums.', 183 recommendedBadge: 'Aanbevolen', 184 waitlistCta: 'Laat het weten als er een plekje vrijkomt', 185 waitlistHeading: 'Zet me op de wachtlijst', 186 waitlistNote: 'We mailen je zodra er een plekje vrijkomt. Wie het eerst bevestigt, krijgt het.', 187 waitlistFrom: 'Vanaf', 188 waitlistTo: 'Tot', 189 waitlistSubmit: 'Houd me op de hoogte', 190 waitlistJoined: 'Je staat op de lijst. Houd je inbox in de gaten — een plekje kan snel weg zijn.', 191 waitlistError: 'Dat lukte niet. Probeer het opnieuw.', 192 rebookingOptInLabel: 'Mail me een uitnodiging om opnieuw te boeken', 193 phoneCountryLabel: 'Land', 194 phoneRequiredForSms: 'Vul een geldig telefoonnummer in om sms’en te ontvangen.', 195 phoneRequiredForWhatsapp: 'Vul een geldig telefoonnummer in om WhatsApp-berichten te ontvangen.', 196 smsOptInLabel: 'Stuur mij bevestigingen en herinneringen van mijn afspraak per sms.', 197 whatsappOptInLabel: 'Stuur mij bevestigingen en herinneringen van mijn afspraak via WhatsApp.', 198 depositLoadError: 'Er is een voorschot vereist, maar de beveiligde betaling kon niet laden. Probeer het opnieuw of neem contact op met het bedrijf.', 199 paymentLoadFailed: 'De beveiligde betaling kon niet laden.', 200 retryCta: 'Opnieuw proberen', 201 staffPickerLabel: 'Kies een teamlid', 202 anyStaff: 'Geen voorkeur', 203 withStaff: 'met {name}', 204 staffUnavailable: 'Dit teamlid is niet meer beschikbaar op dit tijdstip. Kies een ander tijdstip of een ander teamlid.', 205 serviceNotBookable: 'Deze dienst kan momenteel niet online geboekt worden.', 206 } 207 208 const frenchCopy = { 209 ...englishCopy, 210 title: 'Prendre rendez-vous', 211 selectServiceAndTime: 'Choisissez une prestation et un horaire pour continuer.', 212 servicesLabel: 'Prestations', 213 serviceSingular: 'prestation', 214 servicePlural: 'prestations', 215 selected: 'Sélectionné', 216 choose: 'Choisir', 217 timesLabel: 'Horaires', 218 appointment: 'Rendez-vous', 219 bookingReference: 'Référence de réservation', 220 done: 'Terminé', 221 noServiceSelected: 'Aucune prestation sélectionnée', 222 noTimeSelected: 'Aucun horaire sélectionné', 223 chooseTime: 'Choisir un horaire', 224 viewSetup: 'Voir les options de réservation', 225 paymentLoading: 'Chargement du paiement sécurisé…', 226 paymentError: 'Le paiement n’a pas pu être confirmé. Réessayez.', 227 progressLabel: 'Progression de la réservation', 228 stepLabel: 'Étape', 229 stepOf: 'sur', 230 widgetStepLabels: ['Prestation', 'Date', 'Coordonnées', 'Confirmé'], 231 chooseService: 'Choisissez une prestation', 232 chooseDate: 'Choisissez une date', 233 availableTimes: 'Horaires disponibles', 234 summaryLabel: 'Votre sélection', 235 totalLabel: 'Prix', 236 detailsHeading: 'Vos coordonnées', 237 previousDates: 'Dates précédentes', 238 nextDates: 'Dates suivantes', 239 successHeading: 'Rendez-vous confirmé !', 240 successNote: 'Vous recevrez sous peu un e-mail de confirmation avec les détails.', 241 emptySlots: 'Aucun horaire disponible à cette date.', 242 emptyServices: 'Aucune prestation disponible pour le moment.', 243 bookNow: 'Réserver', 244 back: 'Retour', 245 at: 'à', 246 nameLabel: 'Nom', 247 phoneLabel: 'Téléphone', 248 notesLabel: 'Remarques', 249 emailLabel: 'E-mail', 250 continueFromService: 'Choisir et continuer', 251 continueDefault: 'Continuer', 252 checkoutCta: 'Passer au paiement', 253 confirmCta: 'Confirmer le rendez-vous', 254 replacePaymentCta: 'Commencer un nouveau paiement', 255 replacePaymentNote: 'Le paiement précédent a été annulé. Commencez un nouveau paiement pour terminer ce même rendez-vous.', 256 payDepositCta: "Payer l'acompte", 257 depositHeading: "Payez l'acompte pour confirmer", 258 depositLabel: 'acompte', 259 depositNote: 'Votre créneau est réservé pendant le paiement.', 260 depositUnavailable: "Un acompte est requis pour ce service, mais le paiement en ligne est temporairement indisponible. Veuillez réessayer plus tard.", 261 processing: 'Traitement…', 262 redirectingToCheckout: 'Redirection vers le paiement…', 263 close: 'Fermer', 264 addToCalendar: 'Ajouter au calendrier', 265 widgetFooterPrefix: 'Propulsé par', 266 namePlaceholder: 'Marie Dupont', 267 emailPlaceholder: 'marie@exemple.fr', 268 notesPlaceholder: 'Questions ou contexte particulier…', 269 loading: 'Chargement…', 270 genericError: 'Une erreur est survenue. Veuillez réessayer.', 271 closedAttempt: 'Cette tentative de réservation est terminée. Choisissez un nouvel horaire pour recommencer.', 272 loadError: "La configuration de réservation n'a pas pu être chargée.", 273 previewOnlyNote: 'Aperçu uniquement — aucune réservation enregistrée, aucun e-mail envoyé.', 274 testModeHeading: 'Mode test — réservation non enregistrée', 275 testModeNote: 276 "Ce site fonctionne sur une adresse locale ou de test : rien n'a été envoyé à Bookminto et aucun e-mail de confirmation n'arrivera.", 277 questionsHeading: 'Quelques questions', 278 requiredHint: 'obligatoire', 279 yesLabel: 'Oui', 280 noLabel: 'Non', 281 chooseOption: 'Choisir…', 282 nextTimesLabel: 'Prochaines disponibilités', 283 seeAllTimes: 'Tous les horaires', 284 noNextTimes: 'Aucun horaire pour le moment — voir toutes les dates.', 285 recommendedBadge: 'Recommandé', 286 waitlistCta: 'Prévenez-moi quand un créneau se libère', 287 waitlistHeading: 'Rejoindre la liste d’attente', 288 waitlistNote: 'Nous vous écrivons dès qu’un créneau se libère. Le premier à confirmer l’obtient.', 289 waitlistFrom: 'À partir du', 290 waitlistTo: 'Jusqu’au', 291 waitlistSubmit: 'Me tenir informé', 292 waitlistJoined: 'Vous êtes sur la liste. Surveillez votre boîte — un créneau part vite.', 293 waitlistError: 'Impossible de vous ajouter. Réessayez.', 294 rebookingOptInLabel: 'M’envoyer une invitation pour réserver à nouveau', 295 phoneCountryLabel: 'Pays', 296 phoneRequiredForSms: 'Indiquez un numéro de téléphone valide pour recevoir des SMS.', 297 phoneRequiredForWhatsapp: 'Indiquez un numéro de téléphone valide pour recevoir des messages WhatsApp.', 298 smsOptInLabel: 'Recevoir les confirmations et rappels de mon rendez-vous par SMS.', 299 whatsappOptInLabel: 'Recevoir les confirmations et rappels de mon rendez-vous par WhatsApp.', 300 depositLoadError: 'Un acompte est requis, mais le paiement sécurisé n’a pas pu se charger. Réessayez ou contactez l’entreprise.', 301 paymentLoadFailed: 'Le paiement sécurisé n’a pas pu se charger.', 302 retryCta: 'Réessayer', 303 staffPickerLabel: 'Choisissez un membre de l’équipe', 304 anyStaff: 'Sans préférence', 305 withStaff: 'avec {name}', 306 staffUnavailable: 'Ce membre de l’équipe n’est plus disponible à cet horaire. Choisissez un autre horaire ou un autre membre.', 307 serviceNotBookable: 'Cette prestation n’est pas réservable en ligne pour le moment.', 308 } 309 310 const germanCopy = { 311 ...englishCopy, 312 title: 'Termin buchen', 313 selectServiceAndTime: 'Wählen Sie eine Leistung und eine Uhrzeit, um fortzufahren.', 314 servicesLabel: 'Leistungen', 315 serviceSingular: 'Leistung', 316 servicePlural: 'Leistungen', 317 selected: 'Ausgewählt', 318 choose: 'Wählen', 319 timesLabel: 'Uhrzeiten', 320 appointment: 'Termin', 321 bookingReference: 'Buchungsnummer', 322 done: 'Fertig', 323 noServiceSelected: 'Keine Leistung ausgewählt', 324 noTimeSelected: 'Keine Uhrzeit ausgewählt', 325 chooseTime: 'Uhrzeit wählen', 326 viewSetup: 'Buchungsoptionen ansehen', 327 paymentLoading: 'Sichere Zahlung wird geladen…', 328 paymentError: 'Die Zahlung konnte nicht bestätigt werden. Bitte erneut versuchen.', 329 progressLabel: 'Buchungsfortschritt', 330 stepLabel: 'Schritt', 331 stepOf: 'von', 332 widgetStepLabels: ['Leistung', 'Datum', 'Angaben', 'Bestätigt'], 333 chooseService: 'Leistung wählen', 334 chooseDate: 'Datum wählen', 335 availableTimes: 'Verfügbare Zeiten', 336 summaryLabel: 'Ihre Auswahl', 337 totalLabel: 'Preis', 338 detailsHeading: 'Ihre Angaben', 339 previousDates: 'Vorherige Daten', 340 nextDates: 'Nächste Daten', 341 successHeading: 'Buchung bestätigt!', 342 successNote: 'Sie erhalten in Kürze eine Bestätigungs-E-Mail mit Ihren Buchungsdetails.', 343 emptySlots: 'An diesem Datum sind keine Zeiten verfügbar.', 344 emptyServices: 'Derzeit sind keine Leistungen verfügbar.', 345 bookNow: 'Jetzt buchen', 346 back: 'Zurück', 347 at: 'um', 348 nameLabel: 'Name', 349 phoneLabel: 'Telefon', 350 notesLabel: 'Anmerkungen', 351 emailLabel: 'E-Mail', 352 continueFromService: 'Auswählen und weiter', 353 continueDefault: 'Weiter', 354 checkoutCta: 'Weiter zur Kasse', 355 confirmCta: 'Buchung bestätigen', 356 replacePaymentCta: 'Neue Zahlung starten', 357 replacePaymentNote: 'Die vorherige Zahlung wurde abgebrochen. Starten Sie eine neue Zahlung, um denselben Termin abzuschließen.', 358 payDepositCta: 'Anzahlung zahlen', 359 depositHeading: 'Anzahlung zur Bestätigung zahlen', 360 depositLabel: 'Anzahlung', 361 depositNote: 'Ihr Termin bleibt während der Zahlung reserviert.', 362 depositUnavailable: 'Für diese Leistung ist eine Anzahlung erforderlich, Online-Zahlung ist derzeit nicht verfügbar. Bitte versuchen Sie es später erneut.', 363 processing: 'Wird verarbeitet…', 364 redirectingToCheckout: 'Weiterleitung zur Kasse…', 365 close: 'Schließen', 366 addToCalendar: 'Zum Kalender hinzufügen', 367 widgetFooterPrefix: 'Bereitgestellt von', 368 namePlaceholder: 'Maria Müller', 369 phonePlaceholder: '+49 170 1234567', 370 emailPlaceholder: 'sie@beispiel.de', 371 notesPlaceholder: 'Spezifische Fragen oder Kontext…', 372 loading: 'Wird geladen…', 373 genericError: 'Etwas ist schiefgelaufen. Bitte versuchen Sie es erneut.', 374 closedAttempt: 'Dieser Buchungsversuch ist abgeschlossen. Wählen Sie eine neue Uhrzeit, um erneut zu beginnen.', 375 loadError: 'Die Buchungseinstellungen konnten nicht geladen werden.', 376 previewOnlyNote: 'Nur Vorschau — es wurde keine Buchung gespeichert und keine E-Mail gesendet.', 377 testModeHeading: 'Testmodus — Buchung nicht gespeichert', 378 testModeNote: 379 'Diese Seite läuft auf einer lokalen oder Testadresse, daher wurde nichts an Bookminto gesendet und es kommt keine Bestätigungs-E-Mail.', 380 questionsHeading: 'Ein paar Fragen', 381 requiredHint: 'Pflichtfeld', 382 yesLabel: 'Ja', 383 noLabel: 'Nein', 384 chooseOption: 'Wählen…', 385 nextTimesLabel: 'Nächste freie Zeiten', 386 seeAllTimes: 'Alle Zeiten', 387 noNextTimes: 'Derzeit keine freien Zeiten — alle Daten ansehen.', 388 recommendedBadge: 'Empfohlen', 389 waitlistCta: 'Benachrichtigen Sie mich, wenn ein Termin frei wird', 390 waitlistHeading: 'Auf die Warteliste', 391 waitlistNote: 'Wir schreiben Ihnen, sobald ein Platz frei wird. Wer zuerst bestätigt, bekommt ihn.', 392 waitlistFrom: 'Von', 393 waitlistTo: 'Bis', 394 waitlistSubmit: 'Auf dem Laufenden halten', 395 waitlistJoined: 'Sie stehen auf der Liste. Behalten Sie Ihr Postfach im Blick — ein Platz ist schnell vergeben.', 396 waitlistError: 'Das hat nicht geklappt. Bitte versuchen Sie es erneut.', 397 rebookingOptInLabel: 'Mir eine Einladung zur erneuten Buchung mailen', 398 phoneCountryLabel: 'Land', 399 phoneRequiredForSms: 'Geben Sie eine gültige Telefonnummer ein, um SMS zu erhalten.', 400 phoneRequiredForWhatsapp: 'Geben Sie eine gültige Telefonnummer ein, um WhatsApp-Nachrichten zu erhalten.', 401 smsOptInLabel: 'Senden Sie mir Terminbestätigungen und -erinnerungen per SMS.', 402 whatsappOptInLabel: 'Senden Sie mir Terminbestätigungen und -erinnerungen per WhatsApp.', 403 depositLoadError: 'Eine Anzahlung ist erforderlich, aber die sichere Zahlung konnte nicht geladen werden. Bitte versuchen Sie es erneut oder kontaktieren Sie den Betrieb.', 404 paymentLoadFailed: 'Die sichere Zahlung konnte nicht geladen werden.', 405 retryCta: 'Erneut versuchen', 406 staffPickerLabel: 'Teammitglied wählen', 407 anyStaff: 'Keine Präferenz', 408 withStaff: 'mit {name}', 409 staffUnavailable: 'Dieses Teammitglied ist zu dieser Zeit nicht mehr verfügbar. Wählen Sie eine andere Uhrzeit oder ein anderes Mitglied.', 410 serviceNotBookable: 'Diese Leistung ist derzeit nicht online buchbar.', 411 } 412 413 const spanishCopy = { 414 ...englishCopy, 415 title: 'Reserva una cita', 416 selectServiceAndTime: 'Elige un servicio y una hora para continuar.', 417 servicesLabel: 'Servicios', 418 serviceSingular: 'servicio', 419 servicePlural: 'servicios', 420 selected: 'Seleccionado', 421 choose: 'Elegir', 422 timesLabel: 'Horarios', 423 appointment: 'Cita', 424 bookingReference: 'Referencia de reserva', 425 done: 'Listo', 426 noServiceSelected: 'Ningún servicio seleccionado', 427 noTimeSelected: 'Ninguna hora seleccionada', 428 chooseTime: 'Elige una hora', 429 viewSetup: 'Ver opciones de reserva', 430 paymentLoading: 'Cargando pago seguro…', 431 paymentError: 'No se pudo confirmar el pago. Inténtalo de nuevo.', 432 progressLabel: 'Progreso de la reserva', 433 stepLabel: 'Paso', 434 stepOf: 'de', 435 widgetStepLabels: ['Servicio', 'Fecha', 'Datos', 'Confirmado'], 436 chooseService: 'Elige un servicio', 437 chooseDate: 'Elige una fecha', 438 availableTimes: 'Horarios disponibles', 439 summaryLabel: 'Tu selección', 440 totalLabel: 'Precio', 441 detailsHeading: 'Tus datos', 442 previousDates: 'Fechas anteriores', 443 nextDates: 'Fechas siguientes', 444 successHeading: '¡Reserva confirmada!', 445 successNote: 'Recibirás en breve un correo de confirmación con los detalles de tu reserva.', 446 emptySlots: 'No hay horarios disponibles en esta fecha.', 447 emptyServices: 'No hay servicios disponibles ahora mismo.', 448 bookNow: 'Reservar ahora', 449 back: 'Volver', 450 at: 'a las', 451 nameLabel: 'Nombre', 452 phoneLabel: 'Teléfono', 453 notesLabel: 'Notas', 454 emailLabel: 'Correo electrónico', 455 continueFromService: 'Elegir y continuar', 456 continueDefault: 'Continuar', 457 checkoutCta: 'Ir al pago', 458 confirmCta: 'Confirmar reserva', 459 replacePaymentCta: 'Iniciar un nuevo pago', 460 replacePaymentNote: 'El pago anterior se canceló. Inicia un nuevo pago para terminar esta misma cita.', 461 payDepositCta: 'Pagar depósito', 462 depositHeading: 'Paga el depósito para confirmar', 463 depositLabel: 'depósito', 464 depositNote: 'Tu horario queda reservado mientras completas el pago.', 465 depositUnavailable: 'Este servicio requiere un depósito, pero el pago online no está disponible ahora. Inténtalo más tarde.', 466 processing: 'Procesando…', 467 redirectingToCheckout: 'Redirigiendo al pago…', 468 close: 'Cerrar', 469 addToCalendar: 'Añadir al calendario', 470 widgetFooterPrefix: 'Con tecnología de', 471 namePlaceholder: 'María García', 472 phonePlaceholder: '+34 600 123 456', 473 emailPlaceholder: 'tu@ejemplo.es', 474 notesPlaceholder: 'Preguntas o contexto específico…', 475 loading: 'Cargando…', 476 genericError: 'Algo salió mal. Inténtalo de nuevo.', 477 closedAttempt: 'Este intento de reserva está cerrado. Elige una nueva hora para empezar de nuevo.', 478 loadError: 'No se pudo cargar la configuración de reserva.', 479 previewOnlyNote: 'Solo vista previa — no se guardó ninguna reserva ni se envió correo.', 480 testModeHeading: 'Modo prueba — reserva no guardada', 481 testModeNote: 482 'Este sitio funciona en una dirección local o de prueba, así que no se envió nada a Bookminto y no llegará correo de confirmación.', 483 questionsHeading: 'Unas preguntas', 484 requiredHint: 'obligatorio', 485 yesLabel: 'Sí', 486 noLabel: 'No', 487 chooseOption: 'Elegir…', 488 nextTimesLabel: 'Próximos horarios', 489 seeAllTimes: 'Todos los horarios', 490 noNextTimes: 'No hay horarios próximos ahora — revisa todas las fechas.', 491 recommendedBadge: 'Recomendado', 492 waitlistCta: 'Avísame cuando se libere un hueco', 493 waitlistHeading: 'Únete a la lista de espera', 494 waitlistNote: 'Te escribiremos en cuanto se libere un hueco. El primero en confirmar se lo queda.', 495 waitlistFrom: 'Desde', 496 waitlistTo: 'Hasta', 497 waitlistSubmit: 'Mantenme al tanto', 498 waitlistJoined: 'Ya estás en la lista. Revisa tu correo — un hueco vuela.', 499 waitlistError: 'No se pudo añadirte a la lista. Inténtalo de nuevo.', 500 rebookingOptInLabel: 'Enviarme una invitación por correo para volver a reservar', 501 phoneCountryLabel: 'País', 502 phoneRequiredForSms: 'Introduce un número de teléfono válido para recibir SMS.', 503 phoneRequiredForWhatsapp: 'Introduce un número de teléfono válido para recibir mensajes de WhatsApp.', 504 smsOptInLabel: 'Enviarme confirmaciones y recordatorios de mi cita por SMS.', 505 whatsappOptInLabel: 'Enviarme confirmaciones y recordatorios de mi cita por WhatsApp.', 506 depositLoadError: 'Se requiere un depósito, pero el pago seguro no se pudo cargar. Inténtalo de nuevo o contacta con el negocio.', 507 paymentLoadFailed: 'No se pudo cargar el pago seguro.', 508 retryCta: 'Reintentar', 509 staffPickerLabel: 'Elige un miembro del equipo', 510 anyStaff: 'Sin preferencia', 511 withStaff: 'con {name}', 512 staffUnavailable: 'Ese miembro del equipo ya no está disponible a esa hora. Elige otro horario u otro miembro.', 513 serviceNotBookable: 'Este servicio no se puede reservar online ahora mismo.', 514 } 515 516 const italianCopy = { 517 ...englishCopy, 518 title: 'Prenota un appuntamento', 519 selectServiceAndTime: 'Scegli un servizio e un orario per continuare.', 520 servicesLabel: 'Servizi', 521 serviceSingular: 'servizio', 522 servicePlural: 'servizi', 523 selected: 'Selezionato', 524 choose: 'Scegli', 525 timesLabel: 'Orari', 526 appointment: 'Appuntamento', 527 bookingReference: 'Riferimento prenotazione', 528 done: 'Fatto', 529 noServiceSelected: 'Nessun servizio selezionato', 530 noTimeSelected: 'Nessun orario selezionato', 531 chooseTime: 'Scegli un orario', 532 viewSetup: 'Vedi le opzioni di prenotazione', 533 paymentLoading: 'Caricamento pagamento sicuro…', 534 paymentError: 'Il pagamento non è stato confermato. Riprova.', 535 progressLabel: 'Avanzamento della prenotazione', 536 stepLabel: 'Passaggio', 537 stepOf: 'di', 538 widgetStepLabels: ['Servizio', 'Data', 'Dati', 'Confermato'], 539 chooseService: 'Scegli un servizio', 540 chooseDate: 'Scegli una data', 541 availableTimes: 'Orari disponibili', 542 summaryLabel: 'La tua selezione', 543 totalLabel: 'Prezzo', 544 detailsHeading: 'I tuoi dati', 545 previousDates: 'Date precedenti', 546 nextDates: 'Date successive', 547 successHeading: 'Prenotazione confermata!', 548 successNote: 'Riceverai a breve un’e-mail di conferma con i dettagli della prenotazione.', 549 emptySlots: 'Nessun orario disponibile in questa data.', 550 emptyServices: 'Nessun servizio disponibile al momento.', 551 bookNow: 'Prenota ora', 552 back: 'Indietro', 553 at: 'alle', 554 nameLabel: 'Nome', 555 phoneLabel: 'Telefono', 556 notesLabel: 'Note', 557 emailLabel: 'E-mail', 558 continueFromService: 'Scegli e continua', 559 continueDefault: 'Continua', 560 checkoutCta: 'Vai al pagamento', 561 confirmCta: 'Conferma prenotazione', 562 replacePaymentCta: 'Avvia un nuovo pagamento', 563 replacePaymentNote: 'Il pagamento precedente è stato annullato. Avvia un nuovo pagamento per completare lo stesso appuntamento.', 564 payDepositCta: 'Paga l’acconto', 565 depositHeading: 'Paga l’acconto per confermare', 566 depositLabel: 'acconto', 567 depositNote: 'Il tuo orario resta riservato mentre completi il pagamento.', 568 depositUnavailable: 'Questo servizio richiede un acconto, ma il pagamento online non è disponibile al momento. Riprova più tardi.', 569 processing: 'Elaborazione…', 570 redirectingToCheckout: 'Reindirizzamento al pagamento…', 571 close: 'Chiudi', 572 addToCalendar: 'Aggiungi al calendario', 573 widgetFooterPrefix: 'Offerto da', 574 namePlaceholder: 'Maria Rossi', 575 phonePlaceholder: '+39 333 123 4567', 576 emailPlaceholder: 'tu@esempio.it', 577 notesPlaceholder: 'Domande o contesto specifico…', 578 loading: 'Caricamento…', 579 genericError: 'Qualcosa è andato storto. Riprova.', 580 closedAttempt: 'Questo tentativo di prenotazione è chiuso. Scegli un nuovo orario per ricominciare.', 581 loadError: 'Impossibile caricare la configurazione di prenotazione.', 582 previewOnlyNote: 'Solo anteprima — nessuna prenotazione salvata e nessuna e-mail inviata.', 583 testModeHeading: 'Modalità test — prenotazione non salvata', 584 testModeNote: 585 'Questo sito gira su un indirizzo locale o di test, quindi non è stato inviato nulla a Bookminto e non arriverà alcuna e-mail di conferma.', 586 questionsHeading: 'Qualche domanda', 587 requiredHint: 'obbligatorio', 588 yesLabel: 'Sì', 589 noLabel: 'No', 590 chooseOption: 'Scegli…', 591 nextTimesLabel: 'Prossimi disponibili', 592 seeAllTimes: 'Tutti gli orari', 593 noNextTimes: 'Nessun orario in arrivo al momento — vedi tutte le date.', 594 recommendedBadge: 'Consigliato', 595 waitlistCta: 'Avvisami quando si libera un posto', 596 waitlistHeading: 'Entra in lista d’attesa', 597 waitlistNote: 'Ti scriveremo appena si libera un posto. Chi conferma per primo lo ottiene.', 598 waitlistFrom: 'Dal', 599 waitlistTo: 'Al', 600 waitlistSubmit: 'Tienimi aggiornato', 601 waitlistJoined: 'Sei in lista. Tieni d’occhio la casella — un posto fila via in fretta.', 602 waitlistError: 'Impossibile aggiungerti alla lista. Riprova.', 603 rebookingOptInLabel: 'Inviami un’e-mail con un invito a prenotare di nuovo', 604 phoneCountryLabel: 'Paese', 605 phoneRequiredForSms: 'Inserisci un numero di telefono valido per ricevere gli SMS.', 606 phoneRequiredForWhatsapp: 'Inserisci un numero di telefono valido per ricevere i messaggi WhatsApp.', 607 smsOptInLabel: 'Inviami conferme e promemoria del mio appuntamento via SMS.', 608 whatsappOptInLabel: 'Inviami conferme e promemoria del mio appuntamento su WhatsApp.', 609 depositLoadError: 'È richiesto un acconto, ma il pagamento sicuro non è stato caricato. Riprova o contatta l’attività.', 610 paymentLoadFailed: 'Impossibile caricare il pagamento sicuro.', 611 retryCta: 'Riprova', 612 staffPickerLabel: 'Scegli un membro del team', 613 anyStaff: 'Nessuna preferenza', 614 withStaff: 'con {name}', 615 staffUnavailable: 'Questo membro del team non è più disponibile a quell’orario. Scegli un altro orario o un altro membro.', 616 serviceNotBookable: 'Questo servizio non è prenotabile online al momento.', 617 } 618 619 const dictionaries = { 620 de: germanCopy, 621 en: englishCopy, 622 es: spanishCopy, 623 fr: frenchCopy, 624 it: italianCopy, 625 nl: dutchCopy, 626 } 627 628 export function normalizeWidgetLanguage(value) { 629 const language = String(value || '').trim().toLowerCase() 630 return Object.hasOwn(dictionaries, language) ? language : 'en' 631 } 632 633 // Copy is per widget instance: callers resolve strings from their own 634 // config.language — a global mutable dictionary would leak between two mounts. 635 export function getWidgetCopy(value) { 636 return dictionaries[normalizeWidgetLanguage(value)] 637 } 638 639 const widgetLocales = { 640 de: 'de-DE', 641 en: 'en-US', 642 es: 'es-ES', 643 fr: 'fr-BE', 644 it: 'it-IT', 645 nl: 'nl-BE', 646 } 647 648 export function widgetLocale(value) { 649 return widgetLocales[normalizeWidgetLanguage(value)] || 'en-US' 650 } -
bookminto/trunk/assets/src/floatingStyles.js
r3723811 r3730301 299 299 } 300 300 301 .service-photo { 302 display: block; 303 width: 44px; 304 height: 44px; 305 flex: 0 0 44px; 306 object-fit: cover; 307 border-radius: 6px; 308 } 309 301 310 .service-info { 311 min-width: 0; 302 312 flex: 1; 303 313 text-align: left; … … 454 464 } 455 465 466 .staff-row { 467 display: flex; 468 flex-wrap: wrap; 469 gap: 6px; 470 margin-bottom: 14px; 471 } 472 473 .staff-chip { 474 display: inline-flex; 475 align-items: center; 476 gap: 7px; 477 padding: 5px 12px; 478 border: 1.5px solid #e2e8f0; 479 border-radius: 999px; 480 background: white; 481 cursor: pointer; 482 font-family: inherit; 483 font-size: 12px; 484 font-weight: 500; 485 color: #475569; 486 transition: border-color 0.15s, background 0.15s, color 0.15s; 487 } 488 489 .staff-chip:hover { 490 border-color: var(--bm-accent-border); 491 background: var(--bm-accent-soft-hover); 492 } 493 494 .staff-chip.selected { 495 border-color: var(--bm-accent); 496 background: var(--bm-accent-soft); 497 color: var(--bm-accent-deep); 498 } 499 500 .staff-chip-dot { 501 position: relative; 502 display: inline-flex; 503 align-items: center; 504 justify-content: center; 505 width: 22px; 506 height: 22px; 507 flex: 0 0 22px; 508 border-radius: 999px; 509 background: #cbd5e1; 510 color: #fff; 511 font-size: 9px; 512 font-weight: 700; 513 letter-spacing: 0.02em; 514 overflow: hidden; 515 } 516 517 .staff-chip-avatar { 518 position: absolute; 519 inset: 0; 520 display: block; 521 width: 100%; 522 height: 100%; 523 object-fit: cover; 524 } 525 526 .staff-chip-name { 527 max-width: 140px; 528 white-space: nowrap; 529 overflow: hidden; 530 text-overflow: ellipsis; 531 } 532 456 533 .form-field { 457 534 margin-bottom: 0; … … 489 566 min-height: 74px; 490 567 resize: vertical; 568 } 569 570 .form-required { 571 color: var(--bm-accent-deep); 572 font-weight: 700; 573 } 574 575 .sr-only { 576 position: absolute; 577 width: 1px; 578 height: 1px; 579 padding: 0; 580 margin: -1px; 581 overflow: hidden; 582 clip: rect(0 0 0 0); 583 white-space: nowrap; 584 border: 0; 491 585 } 492 586 -
bookminto/trunk/assets/src/formatters.js
r3723811 r3730301 14 14 } 15 15 16 export function formatDuration(minutes) { 16 const durationHourUnits = { 17 de: 'Std.', 18 en: 'hr', 19 es: 'h', 20 fr: 'h', 21 it: 'h', 22 nl: 'u', 23 } 24 25 const freePriceLabels = { 26 de: 'Kostenlos', 27 en: 'Free', 28 es: 'Gratis', 29 fr: 'Gratuit', 30 it: 'Gratuito', 31 nl: 'Gratis', 32 } 33 34 const priceLocales = { 35 de: 'de-DE', 36 en: 'en-US', 37 es: 'es-ES', 38 fr: 'fr-BE', 39 it: 'it-IT', 40 nl: 'nl-BE', 41 } 42 43 const depositConfirmLabels = { 44 de: 'Anzahlung zur Bestätigung', 45 en: 'deposit to confirm', 46 es: 'depósito para confirmar', 47 fr: 'acompte pour confirmer', 48 it: 'acconto per confermare', 49 nl: 'voorschot om te bevestigen', 50 } 51 52 const timezoneLabels = { 53 de: ['Zeiten in', 'Zeiten in Ihrer lokalen Zeitzone'], 54 en: ['Times in', 'Times in your local timezone'], 55 es: ['Horarios en', 'Horarios en tu zona horaria'], 56 fr: ['Horaires en', 'Horaires dans votre fuseau horaire'], 57 it: ['Orari in', 'Orari nel tuo fuso orario'], 58 nl: ['Tijden in', 'Tijden in je lokale tijdzone'], 59 } 60 61 export function formatDuration(minutes, language = 'en') { 17 62 const safeMinutes = Number.isFinite(Number(minutes)) ? Number(minutes) : 30 18 63 … … 24 69 const remainder = safeMinutes % 60 25 70 26 return remainder === 0 ? `${hours} hr` : `${hours}h ${remainder}m` 71 const unit = durationHourUnits[language] || 'hr' 72 return remainder === 0 ? `${hours} ${unit}` : `${hours}${language === 'nl' ? 'u' : 'h'} ${remainder}m` 27 73 } 28 74 29 export function formatPrice(price ) {75 export function formatPrice(price, currency = 'EUR', language = 'en') { 30 76 const safePrice = Number(price) 31 77 32 78 if (!Number.isFinite(safePrice) || safePrice === 0) { 33 return 'Free'79 return freePriceLabels[language] || 'Free' 34 80 } 35 81 36 return new Intl.NumberFormat( 'en-US', {37 currency : 'EUR',82 return new Intl.NumberFormat(priceLocales[language] || 'en-US', { 83 currency, 38 84 maximumFractionDigits: 2, 39 85 minimumFractionDigits: Number.isInteger(safePrice) ? 0 : 2, … … 42 88 } 43 89 44 export function serviceDetail(service ) {90 export function serviceDetail(service, language = 'en') { 45 91 const description = text(service && service.description, '') 46 92 47 93 return description 48 ? `${formatDuration(service.durationMinutes )} · ${description}`49 : formatDuration(service.durationMinutes )94 ? `${formatDuration(service.durationMinutes, language)} · ${description}` 95 : formatDuration(service.durationMinutes, language) 50 96 } 51 97 … … 59 105 } 60 106 61 export function serviceSummary(service ) {107 export function serviceSummary(service, language = 'en') { 62 108 if (!service) { 63 109 return 'No services available yet' 64 110 } 65 111 66 return `${formatDuration(service.durationMinutes )} · ${servicePriceSummary(service)}`112 return `${formatDuration(service.durationMinutes, language)} · ${servicePriceSummary(service, language)}` 67 113 } 68 114 69 export function servicePriceSummary(service ) {70 const price = formatPrice(service?.price )115 export function servicePriceSummary(service, language = 'en') { 116 const price = formatPrice(service?.price, service?.currency || 'EUR', language) 71 117 const deposit = Number(service?.depositEuros ?? Number(service?.depositCents) / 100) 72 return Number.isFinite(deposit) && deposit > 0 ? `${price} · ${formatPrice(deposit)} deposit to confirm` : price 118 const label = depositConfirmLabels[language] || 'deposit to confirm' 119 return Number.isFinite(deposit) && deposit > 0 ? `${price} · ${formatPrice(deposit, service?.currency || 'EUR', language)} ${label}` : price 73 120 } 74 121 75 122 export function timezoneLabel(config) { 76 return config.timezone ? `Times in ${String(config.timezone).replaceAll('_', ' ')}` : 'Times in your local timezone' 123 const labels = timezoneLabels[config.language] || timezoneLabels.en 124 return config.timezone ? `${labels[0]} ${String(config.timezone).replaceAll('_', ' ')}` : labels[1] 77 125 } -
bookminto/trunk/assets/src/modalRender.js
r3723811 r3730301 1 import { createDatedAvailabilitySlots } from './availability.js' 2 import { submitBooking } from './api.js' 1 import { servicePhotoMarkup, bindServicePhotoErrors } from './servicePhotos.js' 2 import { createDatedAvailabilitySlots, getSlotDateKey, qualifiedStaffForService, serviceBlockedByStaff } from './availability.js' 3 import { submitBooking, submitWaitlistEntry } from './api.js' 3 4 import { buildIcsDataUrl } from './calendar.js' 4 import { DATE_WINDOW_SIZE, widgetCopy as copy} from './copy.js'5 import { escapeHtml, formatDuration, formatPrice, servicePriceSummary, t imezoneLabel } from './formatters.js'5 import { DATE_WINDOW_SIZE, getWidgetCopy, widgetLocale } from './copy.js' 6 import { escapeHtml, formatDuration, formatPrice, servicePriceSummary, text, timezoneLabel } from './formatters.js' 6 7 import { confirmBookingPayment, getDepositPaymentSession, mountDepositPayment, showPaymentLoadError } from './payment.js' 7 8 import { scheduleRevealContinue } from './scroll.js' … … 19 20 '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"></polyline></svg>' 20 21 21 const modalStepLabels = ['Service', 'Date & time', 'Details', 'Confirmed'] 22 23 function formatDateBadge(dateKey) { 22 23 function formatDateBadge(dateKey, locale) { 24 24 const value = new Date(`${dateKey}T12:00:00`) 25 25 return { 26 26 day: String(value.getDate()), 27 month: new Intl.DateTimeFormat( 'en-US', { month: 'short' }).format(value).replace('.', ''),28 weekday: new Intl.DateTimeFormat( 'en-US', { weekday: 'short' }).format(value).replace('.', ''),27 month: new Intl.DateTimeFormat(locale || 'en-US', { month: 'short' }).format(value).replace('.', ''), 28 weekday: new Intl.DateTimeFormat(locale || 'en-US', { weekday: 'short' }).format(value).replace('.', ''), 29 29 } 30 30 } 31 31 32 function formatSummaryDate(dateKey) { 32 function staffFirstName(name) { 33 return text(name, '').split(/\s+/).filter(Boolean)[0] || '' 34 } 35 36 function staffInitials(name) { 37 return text(name, '') 38 .split(/\s+/) 39 .filter(Boolean) 40 .slice(0, 2) 41 .map((part) => part.charAt(0)) 42 .join('') 43 .toUpperCase() 44 } 45 46 function staffChipColor(value) { 47 const color = text(value, '') 48 return /^#[0-9a-fA-F]{6}$/.test(color) ? color : '' 49 } 50 51 function staffAvatarUrl(value, previewMode) { 52 const url = text(value, '') 53 if (url.startsWith('https://')) { 54 return url 55 } 56 return previewMode && /^data:image\/(?:png|jpe?g|webp|gif);base64,/i.test(url) ? url : '' 57 } 58 59 function formatSummaryDate(dateKey, locale) { 33 60 if (!dateKey) return '' 34 return new Intl.DateTimeFormat( 'en-US', {61 return new Intl.DateTimeFormat(locale || 'en-US', { 35 62 weekday: 'short', 36 63 day: 'numeric', … … 39 66 } 40 67 41 function formatVisibleDateRange(dates ) {68 function formatVisibleDateRange(dates, locale) { 42 69 if (!dates.length) return '' 43 const formatter = new Intl.DateTimeFormat( 'en-US', { day: 'numeric', month: 'short' })70 const formatter = new Intl.DateTimeFormat(locale || 'en-US', { day: 'numeric', month: 'short' }) 44 71 return `${formatter.format(new Date(`${dates[0]}T12:00:00`))} - ${formatter.format( 45 72 new Date(`${dates[dates.length - 1]}T12:00:00`), … … 47 74 } 48 75 49 function stepperMarkup(step) { 76 function stepperMarkup(step, copy) { 77 const modalStepLabels = copy.widgetStepLabels 50 78 const currentStep = Math.min(step + 1, modalStepLabels.length) 51 79 const activeLabel = modalStepLabels[currentStep - 1] … … 55 83 class="stepper" 56 84 role="progressbar" 57 aria-label=" Booking progress"85 aria-label="${escapeHtml(copy.progressLabel)}" 58 86 aria-valuemin="1" 59 87 aria-valuemax="${modalStepLabels.length}" 60 88 aria-valuenow="${currentStep}" 61 aria-valuetext=" Step ${currentStep} of${modalStepLabels.length}: ${escapeHtml(activeLabel)}"89 aria-valuetext="${escapeHtml(copy.stepLabel)} ${currentStep} ${escapeHtml(copy.stepOf)} ${modalStepLabels.length}: ${escapeHtml(activeLabel)}" 62 90 > 63 91 <div class="stepper-meta" aria-hidden="true"> 64 <span> Step ${currentStep} of${modalStepLabels.length}</span>92 <span>${escapeHtml(copy.stepLabel)} ${currentStep} ${escapeHtml(copy.stepOf)} ${modalStepLabels.length}</span> 65 93 <strong>${escapeHtml(activeLabel)}</strong> 66 94 </div> … … 78 106 */ 79 107 export function createModalPanel(config) { 108 const copy = getWidgetCopy(config.language) 109 const locale = widgetLocale(config.language) 80 110 // <dialog> + open attribute often fails to paint inside preview shadow roots. 81 111 // Use a plain overlay div in preview so Appearance always shows the modal. … … 93 123 let selectedDate = null 94 124 let selectedSlotId = null 125 let selectedStaffId = null 95 126 let datePageStart = 0 96 let message = '' 127 let message = config.calendarVerification === 'external_unverified' 128 ? ({ de: 'Die Kalenderverfügbarkeit kann derzeit nicht geprüft werden. Bitte versuchen Sie es später erneut.', en: 'Calendar availability cannot currently be verified. Please try again later.', es: 'No se puede verificar la disponibilidad del calendario ahora mismo. Inténtalo más tarde.', fr: 'La disponibilité du calendrier ne peut pas être vérifiée. Réessayez plus tard.', it: 'La disponibilità del calendario non può essere verificata al momento. Riprova più tardi.', nl: 'De beschikbaarheid van de agenda kan nu niet worden gecontroleerd. Probeer het later opnieuw.' }[config.language] || 'Calendar availability cannot currently be verified. Please try again later.') 129 : '' 97 130 let isSubmitting = false 98 131 let confirmation = null 99 132 let paymentSession = null 100 133 let paymentController = null 134 let clientRequestId = newClientRequestId() 135 let terminalPaymentReplacementAvailable = false 101 136 let form = { 137 answers: {}, 102 138 customerEmail: '', 103 139 customerName: '', 104 140 customerPhone: '', 105 141 notes: '', 142 rebookingOptIn: false, 143 } 144 // Extension D (waitlist): shown inside the date panel when no slot fits. 145 let waitlistOpen = false 146 let waitlistBusy = false 147 let waitlistJoined = false 148 let waitlistForm = { customerEmail: '', customerName: '', dateFrom: '', dateTo: '' } 149 150 function newClientRequestId() { 151 try { 152 if (typeof crypto?.randomUUID === 'function') { 153 return crypto.randomUUID() 154 } 155 } catch { 156 // fall through to the manual id 157 } 158 159 return `bmr-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}` 106 160 } 107 161 … … 114 168 } 115 169 170 // Team members the customer may pick between for the current service. Real 171 // names only ship in 'customer_choice' mode — otherwise the picker stays 172 // hidden and availability stays a union across qualified members. 173 function staffMembers() { 174 return config.staffDisplayMode === 'customer_choice' 175 ? qualifiedStaffForService(config, selectedService()) 176 : [] 177 } 178 179 // The pinned member, or undefined for "Any" (union availability). 180 function pinnedStaffId() { 181 if (!selectedStaffId) { 182 return undefined 183 } 184 185 return staffMembers().some((member) => member.id === selectedStaffId) 186 ? selectedStaffId 187 : undefined 188 } 189 190 function selectedStaffName() { 191 const pinned = pinnedStaffId() 192 if (!pinned) { 193 return '' 194 } 195 196 const member = staffMembers().find((entry) => entry.id === pinned) 197 return member ? text(member.name, '') : '' 198 } 199 116 200 function datedSlots() { 117 return createDatedAvailabilitySlots(config, selectedService() )201 return createDatedAvailabilitySlots(config, selectedService(), pinnedStaffId()) 118 202 } 119 203 … … 130 214 if (step === 0) return Boolean(selectedService()) 131 215 if (step === 1) return Boolean(selectedSlot(slotsByDate)) 132 if (step === 2) return Boolean(form.customerName.trim() && form.customerEmail.trim()) 216 if (step === 2) { 217 if (Number(selectedService()?.depositCents) > 0 && config.features?.deposits !== true) return false 218 if (!form.customerName.trim() || !form.customerEmail.trim()) return false 219 const questions = selectedService()?.bookingQuestions || [] 220 return questions.every( 221 (question) => 222 !question.required || Boolean(String(form.answers?.[question.id] || '').trim()), 223 ) 224 } 133 225 return false 134 226 } … … 138 230 if (step === 0) return copy.continueFromService 139 231 if (paymentSession) return copy.payDepositCta 140 if (step === 2) return copy.confirmCta232 if (step === 2) return terminalPaymentReplacementAvailable ? copy.replacePaymentCta : copy.confirmCta 141 233 return copy.continueDefault 142 234 } 143 235 144 236 function formatDeposit(cents) { 145 return new Intl.NumberFormat( 'en-US', { style: 'currency', currency: 'EUR' }).format(237 return new Intl.NumberFormat(locale, { style: 'currency', currency: 'EUR' }).format( 146 238 (Number(cents) || 0) / 100, 147 239 ) … … 152 244 } 153 245 246 function questionsMarkup(service) { 247 const questions = Array.isArray(service?.bookingQuestions) ? service.bookingQuestions : [] 248 249 if (!questions.length) { 250 return '' 251 } 252 253 return ` 254 <span class="slabel slabel--with-top-gap">${escapeHtml(copy.questionsHeading)}</span> 255 ${questions 256 .map((question) => { 257 const inputId = `bm-modal-question-${escapeHtml(question.id)}` 258 const value = String(form.answers?.[question.id] || '') 259 const requiredMark = question.required 260 ? ` <span class="form-required" aria-hidden="true">*</span><span class="sr-only"> ${escapeHtml(copy.requiredHint)}</span>` 261 : '' 262 const label = `<label class="flabel" for="${inputId}">${escapeHtml(question.label)}${requiredMark}</label>` 263 264 if (question.type === 'yesno') { 265 return ` 266 <div class="form-field"> 267 ${label} 268 <select class="finput" id="${inputId}" data-question-id="${escapeHtml(question.id)}"> 269 <option value="">${escapeHtml(copy.chooseOption)}</option> 270 <option value="yes"${value === 'yes' ? ' selected' : ''}>${escapeHtml(copy.yesLabel)}</option> 271 <option value="no"${value === 'no' ? ' selected' : ''}>${escapeHtml(copy.noLabel)}</option> 272 </select> 273 </div> 274 ` 275 } 276 277 if (question.type === 'choice') { 278 return ` 279 <div class="form-field"> 280 ${label} 281 <select class="finput" id="${inputId}" data-question-id="${escapeHtml(question.id)}"> 282 <option value="">${escapeHtml(copy.chooseOption)}</option> 283 ${(question.options || []) 284 .map( 285 (option) => 286 `<option value="${escapeHtml(option)}"${value === option ? ' selected' : ''}>${escapeHtml(option)}</option>`, 287 ) 288 .join('')} 289 </select> 290 </div> 291 ` 292 } 293 294 return ` 295 <div class="form-field"> 296 ${label} 297 <input class="finput" id="${inputId}" data-question-id="${escapeHtml(question.id)}" maxlength="300" value="${escapeHtml(value)}" /> 298 </div> 299 ` 300 }) 301 .join('')} 302 ` 303 } 304 154 305 function syncFormFromDom() { 306 // `?.value || form.x` kept the stale value when a customer cleared a 307 // pre-filled field — the empty string is the real value. 308 const readField = (name) => { 309 const input = dialog.querySelector(`[name="${name}"]`) 310 return input ? input.value : undefined 311 } 155 312 form = { 156 customerEmail: dialog.querySelector('[name="customerEmail"]')?.value || form.customerEmail, 157 customerName: dialog.querySelector('[name="customerName"]')?.value || form.customerName, 158 customerPhone: dialog.querySelector('[name="customerPhone"]')?.value || form.customerPhone, 159 notes: dialog.querySelector('[name="notes"]')?.value || form.notes, 313 ...form, 314 customerEmail: readField('customerEmail') ?? form.customerEmail, 315 customerName: readField('customerName') ?? form.customerName, 316 customerPhone: readField('customerPhone') ?? form.customerPhone, 317 notes: readField('notes') ?? form.notes, 318 } 319 320 dialog.querySelectorAll('[data-question-id]').forEach((input) => { 321 const id = input.getAttribute('data-question-id') 322 if (id) { 323 form.answers[id] = input.value 324 } 325 }) 326 327 const optIn = dialog.querySelector('[data-rebooking-optin]') 328 if (optIn) { 329 form.rebookingOptIn = optIn.checked === true 160 330 } 161 331 } … … 165 335 selectedDate = null 166 336 selectedSlotId = null 337 selectedStaffId = null 167 338 datePageStart = 0 168 339 message = '' … … 174 345 paymentController = null 175 346 } 176 form = { customerEmail: '', customerName: '', customerPhone: '', notes: '' } 347 clientRequestId = newClientRequestId() 348 terminalPaymentReplacementAvailable = false 349 waitlistOpen = false 350 waitlistBusy = false 351 waitlistJoined = false 352 waitlistForm = { customerEmail: '', customerName: '', dateFrom: '', dateTo: '' } 353 form = { answers: {}, customerEmail: '', customerName: '', customerPhone: '', notes: '', rebookingOptIn: false } 177 354 render() 178 355 } … … 207 384 return ` 208 385 <button type="button" class="svc${selected}" data-service-id="${escapeHtml(id)}"> 386 ${servicePhotoMarkup(item.imageUrl, config.previewMode)} 209 387 <div class="svc-info"> 210 388 <div class="svc-name">${escapeHtml(item.name)}</div><div class="service-price">${escapeHtml(servicePriceSummary(item))}</div> … … 235 413 selectedDate && slot 236 414 ? `<div class="sumbox sumbox--compact"> 237 <div class="sumrow">${escapeHtml(formatSummaryDate(selectedDate ))}</div>415 <div class="sumrow">${escapeHtml(formatSummaryDate(selectedDate, locale))}</div> 238 416 <div class="sumrow">${escapeHtml(slot.timeLabel)} · ${escapeHtml(formatPrice(service?.price))}</div> 417 ${ 418 selectedStaffName() 419 ? `<div class="sumrow">${escapeHtml(copy.withStaff.replace('{name}', selectedStaffName()))}</div>` 420 : '' 421 } 239 422 </div>` 240 423 : '' 241 424 } 425 ` 426 } 427 428 // Compact member chips above the date grid — "Any" keeps union 429 // availability, a member chip pins slots to that calendar. 430 function staffPickerMarkup() { 431 const members = staffMembers() 432 433 if (members.length < 2) { 434 return '' 435 } 436 437 const pinned = pinnedStaffId() 438 const memberChips = members 439 .map((member) => { 440 const name = text(member.name, '') 441 const color = staffChipColor(member.color) 442 const avatar = staffAvatarUrl(member.avatarUrl, config.previewMode) 443 const selected = member.id === pinned 444 const avatarMarkup = avatar 445 ? `<img class="staff-chip-avatar" src="${escapeHtml(avatar)}" alt="" loading="lazy" decoding="async" referrerpolicy="no-referrer">` 446 : '' 447 return ` 448 <button type="button" class="staff-chip${selected ? ' selected' : ''}" data-staff-id="${escapeHtml(member.id)}" aria-pressed="${selected}"> 449 <span class="staff-chip-dot"${color ? ` style="background:${escapeHtml(color)}"` : ''}>${escapeHtml(staffInitials(name))}${avatarMarkup}</span> 450 <span class="staff-chip-name">${escapeHtml(staffFirstName(name) || name)}</span> 451 </button> 452 ` 453 }) 454 .join('') 455 456 return ` 457 <span class="slabel">${escapeHtml(copy.staffPickerLabel)}</span> 458 <div class="staff-row" role="group" aria-label="${escapeHtml(copy.staffPickerLabel)}"> 459 <button type="button" class="staff-chip${pinned ? '' : ' selected'}" data-staff-id="" aria-pressed="${!pinned}"> 460 <span class="staff-chip-name">${escapeHtml(copy.anyStaff)}</span> 461 </button> 462 ${memberChips} 463 </div> 464 ` 465 } 466 467 // Extension D: entry point when the current view has no fitting times. 468 function waitlistMarkup() { 469 const service = selectedService() 470 if (config.features?.waitlist !== true || !service) { 471 return '' 472 } 473 474 if (waitlistJoined) { 475 return `<div class="runtime-state waitlist-joined" role="status">${escapeHtml(copy.waitlistJoined)}</div>` 476 } 477 478 if (!waitlistOpen) { 479 return `<button type="button" class="waitlist-cta" data-action="waitlist-open">${escapeHtml(copy.waitlistCta)}</button>` 480 } 481 482 return ` 483 <div class="waitlist-form"> 484 <span class="slabel slabel--with-top-gap">${escapeHtml(copy.waitlistHeading)}</span> 485 <p class="waitlist-note">${escapeHtml(copy.waitlistNote)}</p> 486 <div class="form-field"> 487 <label class="flabel" for="bm-wl-name">${escapeHtml(copy.nameLabel)}</label> 488 <input class="finput" id="bm-wl-name" data-waitlist-field="customerName" autocomplete="name" value="${escapeHtml(waitlistForm.customerName)}" /> 489 </div> 490 <div class="form-field"> 491 <label class="flabel" for="bm-wl-email">${escapeHtml(copy.emailLabel)}</label> 492 <input class="finput" id="bm-wl-email" data-waitlist-field="customerEmail" autocomplete="email" type="email" value="${escapeHtml(waitlistForm.customerEmail)}" /> 493 </div> 494 <div class="form-row"> 495 <div class="form-field"> 496 <label class="flabel" for="bm-wl-from">${escapeHtml(copy.waitlistFrom)}</label> 497 <input class="finput" id="bm-wl-from" data-waitlist-field="dateFrom" type="date" value="${escapeHtml(waitlistForm.dateFrom)}" /> 498 </div> 499 <div class="form-field"> 500 <label class="flabel" for="bm-wl-to">${escapeHtml(copy.waitlistTo)}</label> 501 <input class="finput" id="bm-wl-to" data-waitlist-field="dateTo" type="date" value="${escapeHtml(waitlistForm.dateTo)}" /> 502 </div> 503 </div> 504 <button type="button" class="cta" data-action="waitlist-submit" ${waitlistBusy ? 'disabled' : ''}> 505 <span>${escapeHtml(waitlistBusy ? copy.processing : copy.waitlistSubmit)}</span> 506 </button> 507 </div> 242 508 ` 243 509 } … … 250 516 <div class="modal-intro-copy">${escapeHtml(copy.depositNote)}</div> 251 517 <div class="sumbox"> 252 <div class="sumrow"><strong>${escapeHtml(formatDeposit(paymentSession.depositCents))}</strong> deposit</div>518 <div class="sumrow"><strong>${escapeHtml(formatDeposit(paymentSession.depositCents))}</strong> ${escapeHtml(copy.depositLabel)}</div> 253 519 </div> 254 < div class="bw-payment-element" data-payment-element></div>520 <slot name="bm-payment-element" data-payment-element><div class="bw-payment-element"></div></slot> 255 521 ${message ? `<div class="runtime-state runtime-state--danger">${escapeHtml(message)}</div>` : ''} 256 522 `, … … 271 537 272 538 if (step === 1) { 539 if (serviceBlockedByStaff(config, service)) { 540 return rightFlow( 541 ` 542 <button class="back-btn" type="button" data-action="back">← ${escapeHtml(copy.back)}</button> 543 ${runtimeState(copy.serviceNotBookable, 'danger')} 544 `, 545 continueButton(slotsByDate), 546 ) 547 } 548 273 549 const dates = visibleDates(availableDates) 274 550 const daySlots = selectedDate ? slotsByDate.get(selectedDate) || [] : [] … … 278 554 ` 279 555 <button class="back-btn" type="button" data-action="back">← ${escapeHtml(copy.back)}</button> 556 ${staffPickerMarkup()} 280 557 <span class="slabel">${escapeHtml(copy.chooseDate)}</span><p class="booking-timezone">${escapeHtml(timezoneLabel(config))}</p> 281 558 <div class="date-nav"> 282 <span class="date-nav-copy">${escapeHtml(formatVisibleDateRange(dates ))}</span>559 <span class="date-nav-copy">${escapeHtml(formatVisibleDateRange(dates, locale))}</span> 283 560 <div class="date-nav-buttons"> 284 561 <button type="button" class="date-nav-btn" data-action="prev-dates" ${canPrev ? '' : 'disabled'}>${chevronLeftIcon}</button> … … 291 568 ? dates 292 569 .map((dateKey) => { 293 const badge = formatDateBadge(dateKey )570 const badge = formatDateBadge(dateKey, locale) 294 571 const selected = dateKey === selectedDate ? ' sel' : '' 295 572 return ` … … 315 592 .map((item) => { 316 593 const selected = item.id === selectedSlotId ? ' sel' : '' 317 return `<button type="button" class="tbtn${selected}" data-slot-id="${escapeHtml(item.id)}">${escapeHtml(item.timeLabel)}</button>` 594 const recommended = item.recommended ? ' rec' : '' 595 return `<button type="button" class="tbtn${selected}${recommended}" data-slot-id="${escapeHtml(item.id)}">${escapeHtml(item.timeLabel)}${item.recommended ? `<em class="rec-badge">${escapeHtml(copy.recommendedBadge)}</em>` : ''}</button>` 318 596 }) 319 597 .join('')} … … 324 602 : '' 325 603 } 604 ${!dates.length || !daySlots.length ? waitlistMarkup() : ''} 605 ${message ? `<div class="runtime-state runtime-state--danger">${escapeHtml(message)}</div>` : ''} 326 606 `, 327 607 continueButton(slotsByDate), … … 334 614 <button class="back-btn" type="button" data-action="back">← ${escapeHtml(copy.back)}</button> 335 615 <span class="slabel">${escapeHtml(copy.detailsHeading)}</span> 616 ${Number(service?.depositCents) > 0 && config.features?.deposits !== true ? `<p class="modal-intro-copy" role="note">${escapeHtml(copy.depositUnavailable)}</p>` : ''} 336 617 <div class="form-field"> 337 618 <label class="flabel" for="bm-modal-name">${escapeHtml(copy.nameLabel)}</label> … … 352 633 <textarea class="finput finput--textarea" id="bm-modal-notes" name="notes" placeholder="${escapeHtml(copy.notesPlaceholder)}">${escapeHtml(form.notes)}</textarea> 353 634 </div> 635 ${questionsMarkup(service)} 636 ${ 637 Number(service?.rebookingIntervalWeeks) > 0 638 ? `<label class="rebooking-optin"> 639 <input type="checkbox" data-rebooking-optin ${form.rebookingOptIn ? 'checked' : ''} /> 640 <span>${escapeHtml(copy.rebookingOptInLabel)}</span> 641 </label>` 642 : '' 643 } 354 644 ${message ? `<div class="runtime-state runtime-state--danger">${escapeHtml(message)}</div>` : ''} 355 645 `, … … 429 719 <div class="topbar"> 430 720 <span class="topbar-title">${escapeHtml(config.title || copy.title)}</span> 431 ${stepperMarkup(activeStep )}721 ${stepperMarkup(activeStep, copy)} 432 722 <button class="close-btn" type="button" data-action="close" aria-label="${escapeHtml(copy.close)}">${closeIcon}</button> 433 723 </div> … … 458 748 accent: config.color, 459 749 clientSecret: paymentSession.clientSecret, 750 language: config.language, 460 751 publishableKey: paymentSession.publishableKey, 461 752 }) … … 466 757 }) 467 758 .catch((error) => { 468 if (host.isConnected && paymentSession === session) showPaymentLoadError(host, error, render )759 if (host.isConnected && paymentSession === session) showPaymentLoadError(host, error, render, config.language) 469 760 }) 470 761 } … … 473 764 474 765 function bindEvents(availableDates, slotsByDate) { 766 bindServicePhotoErrors(dialog) 475 767 dialog.querySelectorAll('[data-service-id]').forEach((button) => { 476 768 button.addEventListener('click', () => { … … 478 770 selectedDate = null 479 771 selectedSlotId = null 772 selectedStaffId = null 480 773 datePageStart = 0 481 774 message = '' 482 775 render() 483 776 }) 777 }) 778 779 // Member chips: pinning re-scopes availability to that calendar; "Any" 780 // (data-staff-id="") restores the union. 781 dialog.querySelectorAll('[data-staff-id]').forEach((button) => { 782 button.addEventListener('click', () => { 783 const next = button.getAttribute('data-staff-id') || null 784 if (next === selectedStaffId) { 785 return 786 } 787 788 selectedStaffId = next 789 message = '' 790 datePageStart = 0 791 // A different member is a different booking attempt — never replay 792 // the previous request id against another member's fingerprint. 793 clientRequestId = newClientRequestId() 794 795 const fresh = datedSlots() 796 if (selectedDate && !fresh.availableDates.includes(selectedDate)) { 797 selectedDate = null 798 selectedSlotId = null 799 } else if ( 800 selectedSlotId && 801 !(fresh.slotsByDate.get(selectedDate) || []).some((slot) => slot.id === selectedSlotId) 802 ) { 803 selectedSlotId = null 804 } 805 806 render() 807 scheduleRevealContinue(dialog, { 808 containerSelector: '.panel-right-scroll', 809 margins: { top: 8, bottom: 12 }, 810 }) 811 }) 812 }) 813 814 dialog.querySelectorAll('.staff-chip-avatar').forEach((image) => { 815 const hide = () => image.remove() 816 image.addEventListener('error', hide, { once: true }) 817 if (image.complete && !image.naturalWidth) hide() 484 818 }) 485 819 … … 546 880 }) 547 881 882 dialog.querySelector('[data-action="waitlist-open"]')?.addEventListener('click', () => { 883 waitlistOpen = true 884 message = '' 885 const base = selectedDate || new Date().toISOString().slice(0, 10) 886 const end = new Date(`${base}T12:00:00`) 887 end.setDate(end.getDate() + 14) 888 waitlistForm = { 889 customerEmail: form.customerEmail, 890 customerName: form.customerName, 891 dateFrom: waitlistForm.dateFrom || base, 892 dateTo: waitlistForm.dateTo || end.toISOString().slice(0, 10), 893 } 894 render() 895 scheduleRevealContinue(dialog, { 896 containerSelector: '.panel-right-scroll', 897 margins: { top: 8, bottom: 12 }, 898 }) 899 }) 900 901 dialog.querySelectorAll('[data-waitlist-field]').forEach((input) => { 902 input.addEventListener('input', () => { 903 const field = input.getAttribute('data-waitlist-field') 904 if (field) { 905 waitlistForm[field] = input.value 906 } 907 }) 908 }) 909 910 dialog.querySelector('[data-action="waitlist-submit"]')?.addEventListener('click', () => { 911 if (waitlistBusy) return 912 const service = selectedService() 913 waitlistBusy = true 914 message = '' 915 render() 916 submitWaitlistEntry(config, { 917 customerEmail: waitlistForm.customerEmail, 918 customerName: waitlistForm.customerName, 919 dateFrom: waitlistForm.dateFrom, 920 dateTo: waitlistForm.dateTo, 921 serviceId: service?.id || service?.name || '', 922 }) 923 .then(() => { 924 waitlistBusy = false 925 waitlistJoined = true 926 waitlistOpen = false 927 render() 928 }) 929 .catch((error) => { 930 waitlistBusy = false 931 message = error instanceof Error ? error.message : copy.waitlistError 932 render() 933 }) 934 }) 935 548 936 dialog.querySelector('[data-action="continue"]')?.addEventListener('click', () => { 549 937 syncFormFromDom() … … 583 971 if (cta) { cta.disabled = true; cta.textContent = copy.processing } 584 972 try { 585 const paymentIntentId = await controller.confirm() 586 await confirmBookingPayment(config, { 973 const paymentIntentId = session.confirmedPaymentIntentId || await controller.confirm() 974 if (paymentSession !== session || paymentController !== controller) return 975 session.confirmedPaymentIntentId = paymentIntentId 976 const result = await confirmBookingPayment(config, { 587 977 bookingId: session.bookingId, 588 978 paymentIntentId, 589 979 }) 980 if (paymentSession !== session || paymentController !== controller) return 981 if (result.processing || !(result.ok || result.alreadyConfirmed)) throw new Error(copy.paymentError) 590 982 confirmation = { bookingId: session.bookingId, id: session.bookingId } 591 983 paymentSession = null 592 984 step = 3 593 985 } catch (error) { 986 if (paymentSession !== session || paymentController !== controller) return 594 987 message = error instanceof Error ? error.message : copy.genericError 988 let note = dialog.querySelector('.runtime-state--danger') 989 if (!note) { 990 note = document.createElement('p') 991 note.className = 'runtime-state runtime-state--danger' 992 note.setAttribute('role', 'alert') 993 dialog.querySelector('[data-payment-element]')?.after(note) 994 } 995 note.textContent = message 595 996 } finally { 596 isSubmitting = false 597 render() 997 if (paymentSession === session && paymentController === controller) { 998 isSubmitting = false 999 if (cta) { cta.disabled = false; cta.textContent = copy.payDepositCta } 1000 } else if (!paymentSession) { isSubmitting = false; render() } 598 1001 } 599 1002 } … … 602 1005 const service = selectedService() 603 1006 const slot = selectedSlot(slotsByDate) 604 if (!service || !slot) return 1007 if (!service || !slot || isSubmitting) return 1008 if (!canContinue(slotsByDate)) return 605 1009 606 1010 isSubmitting = true … … 610 1014 try { 611 1015 const result = await submitBooking(config, { 1016 answers: Object.keys(form.answers || {}).length ? form.answers : undefined, 1017 clientRequestId, 1018 replaceTerminalPayment: terminalPaymentReplacementAvailable || undefined, 1019 publicationRevision: config.publicationRevision, 612 1020 customerEmail: form.customerEmail.trim(), 613 1021 customerName: form.customerName.trim(), 614 1022 customerPhone: form.customerPhone.trim(), 1023 language: config.language || 'en', 615 1024 notes: form.notes.trim(), 1025 rebookingOptIn: form.rebookingOptIn || undefined, 616 1026 serviceId: service.id || service.name, 617 1027 serviceName: service.name, 1028 staffId: pinnedStaffId(), 618 1029 startsAt: slot.startsAt, 619 1030 }) … … 623 1034 } 624 1035 625 const depositSession = getDepositPaymentSession(result )1036 const depositSession = getDepositPaymentSession(result, config.language) 626 1037 if (depositSession) { 1038 terminalPaymentReplacementAvailable = false 627 1039 paymentSession = depositSession 628 1040 } else { … … 631 1043 } 632 1044 } catch (error) { 633 message = error instanceof Error ? error.message : copy.genericError 1045 terminalPaymentReplacementAvailable = error?.code === 'terminal_payment_replacement_available' 1046 if (error?.code === 'staff_unavailable') { 1047 // The pinned member was just booked (or is unqualified) — send the 1048 // customer back to a freshly computed slot list. 1049 step = 1 1050 selectedSlotId = null 1051 message = copy.staffUnavailable 1052 } else { 1053 message = terminalPaymentReplacementAvailable ? copy.replacePaymentNote : error instanceof Error ? error.message : copy.genericError 1054 } 634 1055 } finally { 635 1056 isSubmitting = false … … 644 1065 dialog.dispatchEvent(new CustomEvent('booking-widget-close', { bubbles: true })) 645 1066 }) 1067 1068 // Extension A: open the flow with service and slot preselected. Skips only 1069 // steps that are already settled — if the slot disappeared the customer is 1070 // left on the time step with the service preselected. 1071 dialog.preselectBooking = (selection) => { 1072 if (!selection || typeof selection !== 'object') { 1073 return 1074 } 1075 1076 if (selection.serviceId) { 1077 if (selection.serviceId !== selectedServiceId) { 1078 selectedStaffId = null 1079 } 1080 selectedServiceId = selection.serviceId 1081 } 1082 1083 if (selection.startsAt) { 1084 const dateKey = getSlotDateKey(selection.startsAt, config.timezone) 1085 const match = (datedSlots().slotsByDate.get(dateKey) || []).find( 1086 (slot) => slot.startsAt === selection.startsAt, 1087 ) 1088 if (match) { 1089 selectedDate = dateKey 1090 selectedSlotId = match.id 1091 step = 2 1092 } else { 1093 step = Math.max(step, 1) 1094 } 1095 } else if (selection.serviceId) { 1096 step = Math.max(step, 1) 1097 } 1098 1099 render() 1100 } 646 1101 647 1102 dialog.resetFlow = resetFlow -
bookminto/trunk/assets/src/mount.js
r3723811 r3730301 2 2 import { createBookingBar } from './bookingBar.js' 3 3 import { createBookingModal } from './bookingModal.js' 4 import { widgetCopy as copy } from './copy.js'4 import { getWidgetCopy } from './copy.js' 5 5 import { ensureFloatingPopoverChrome } from './floatingPopoverStyles.js' 6 6 import { elevateBarHost, elevateOverlayHost, ensureHostOnTop, ensureOverlayHostStyles } from './overlayHost.js' 7 7 import { escapeHtml as escapeLauncherLabel } from './formatters.js' 8 import { createNextTimes } from './nextTimes.js' 8 9 import { createWidget } from './render.js' 9 10 import { injectStyles } from './styles.js' … … 87 88 88 89 export function renderMountedWidget(element, config) { 90 const copy = getWidgetCopy(config.language) 89 91 const mountId = 90 92 element.dataset.bookingWidgetMountId || … … 92 94 element.dataset.bookingWidgetMountId = mountId 93 95 94 findWidgetOwners(mountId).forEach((node) => node.remove()) 96 findWidgetOwners(mountId).forEach((node) => { 97 node.__bookingWidgetDestroy?.() 98 node.remove() 99 }) 95 100 96 101 element 97 102 .querySelectorAll('[data-booking-widget-runtime], .bw-widget-bar, .bw-widget-shadow-host') 98 .forEach((node) => node.remove()) 103 .forEach((node) => { 104 node.__bookingWidgetDestroy?.() 105 node.remove() 106 }) 99 107 100 108 // Product-page commerce must never inherit published modal/floating chrome. 101 109 if (config.commerce === 'shopify' || config.commerce === 'woocommerce') { 102 110 config = { ...config, variant: 'inline' } 111 } 112 113 // Extension A: data-next-times strip. Only rendered when the workspace plan 114 // advertises the feature (`features.nextTimes` in the published config). 115 // Falls back to a regular modal CTA so the element is never a dead end. 116 if (config.nextTimes) { 117 const entitled = 118 config.previewMode || 119 !config.features || 120 typeof config.features !== 'object' || 121 Object.keys(config.features).length === 0 || 122 config.features.nextTimes === true 123 124 if (!entitled) { 125 config = { ...config, nextTimes: false, variant: 'modal' } 126 } else { 127 const root = prepareShadowRoot(element) 128 applyThemeProperties(element, config.color) 129 130 const modalHost = document.createElement('div') 131 const modalRoot = prepareShadowRoot(modalHost) 132 modalHost.className = 'bw-widget-shadow-host' 133 modalHost.dataset.bookingWidgetOwner = mountId 134 modalHost.dataset.variant = 'modal' 135 modalHost.dataset.open = 'false' 136 applyThemeProperties(modalHost, config.color) 137 elevateOverlayHost(modalHost) 138 document.body.append(modalHost) 139 ensureHostOnTop(modalHost) 140 141 const modal = createBookingModal(config, mountId, modalRoot, { 142 suppressCta: true, 143 }) 144 modalHost.__bookingWidgetDestroy = modal.destroy 145 146 root.appendChild( 147 createNextTimes(config, { 148 onOpen: () => modal.setOpen(true), 149 onSelectSlot: (serviceId, startsAt) => { 150 modal.panel?.preselectBooking?.({ serviceId, startsAt }) 151 modal.setOpen(true) 152 }, 153 }), 154 ) 155 return element 156 } 103 157 } 104 158 … … 116 170 let open = false 117 171 let closeTimer = 0 172 let released = false 118 173 119 174 ensureFloatingPopoverChrome() … … 144 199 145 200 function setOpen(isOpen) { 201 // Once the host is gone (remount cleanup or the merchant page removed 202 // it), nothing may resurrect the floating chrome via ensureHostOnTop. 203 if (released || host.isConnected === false) { 204 return 205 } 146 206 open = isOpen 147 207 setClassToken(launcher, 'expanded', isOpen) … … 213 273 214 274 function onKeyDown(event) { 275 // The host page removed our chrome (SPA navigation, theme re-render) — 276 // drop the document listener instead of accumulating it. 277 if (host.isConnected === false) { 278 released = true 279 if (typeof document.removeEventListener === 'function') { 280 document.removeEventListener('keydown', onKeyDown) 281 } 282 return 283 } 215 284 if (event.key === 'Escape' && open) { 216 285 setOpen(false) … … 220 289 if (typeof document.addEventListener === 'function') { 221 290 document.addEventListener('keydown', onKeyDown) 291 } 292 293 host.__bookingWidgetDestroy = () => { 294 released = true 295 if (typeof document.removeEventListener === 'function') { 296 document.removeEventListener('keydown', onKeyDown) 297 } 298 if (typeof window.clearTimeout === 'function') { 299 window.clearTimeout(closeTimer) 300 } 222 301 } 223 302 … … 287 366 } 288 367 289 createBookingModal(config, mountId, root) 368 const modal = createBookingModal(config, mountId, root) 369 host.__bookingWidgetDestroy = modal.destroy 290 370 return host 291 371 } … … 312 392 if (config.workspace) { 313 393 loadRemoteConfig(config).then((remoteConfig) => { 394 // The embed element may be gone by the time the config arrives — a 395 // floating/bar mount must not append to a page that no longer embeds. 396 // `=== false` (not falsy): minimal DOM shims may omit isConnected. 397 if (element.isConnected === false) { 398 return 399 } 400 314 401 if (shouldSkipAutoInlineWidget(element, remoteConfig)) { 315 402 return … … 366 453 .forEach((element) => { 367 454 if (element.tagName === 'SCRIPT') { 455 // Each scan used to mint a fresh placeholder after the script tag — 456 // a second scan() (loader + DOMContentLoaded) mounted duplicates. 457 if (element.dataset.bookingWidgetScanned === 'true') { 458 return 459 } 460 element.dataset.bookingWidgetScanned = 'true' 368 461 const placeholder = document.createElement('div') 369 462 placeholder.setAttribute('data-booking-widget', '') -
bookminto/trunk/assets/src/payment.js
r3723811 r3730301 1 export function getDepositPaymentSession(result) { 1 import { getWidgetCopy } from './copy.js' 2 3 export function getDepositPaymentSession(result, language) { 4 const copy = getWidgetCopy(language) 2 5 const payment = result?.payment || result 3 6 if (!result?.needsPayment && !result?.payment) return null 4 7 if (!payment?.clientSecret || !payment?.publishableKey || !(result.bookingId || result.id)) { 5 throw new Error( 'A deposit is required, but secure payment could not load. Please try again or contact the business.')8 throw new Error(copy.depositLoadError) 6 9 } 7 10 return { … … 13 16 } 14 17 15 export function showPaymentLoadError(host, error, onRetry) { 18 export function showPaymentLoadError(host, error, onRetry, language) { 19 const copy = getWidgetCopy(language) 16 20 const message = document.createElement('p') 17 21 message.setAttribute('role', 'alert') 18 message.textContent = error instanceof Error ? error.message : 'Secure payment could not load.'22 message.textContent = error instanceof Error ? error.message : copy.paymentLoadFailed 19 23 const retry = document.createElement('button') 20 24 retry.type = 'button' 21 25 retry.className = 'back-btn' 22 retry.textContent = 'Try again'26 retry.textContent = copy.retryCta 23 27 retry.addEventListener('click', onRetry) 24 28 host.replaceChildren(message, retry) … … 53 57 54 58 export async function mountDepositPayment(host, input) { 59 const copy = getWidgetCopy(input.language) 55 60 const StripeFactory = await loadStripeJs() 56 61 const stripe = StripeFactory(input.publishableKey) … … 65 70 }, 66 71 clientSecret: input.clientSecret, 72 locale: input.language || undefined, 67 73 }) 68 74 const paymentElement = elements.create('payment') 69 paymentElement.mount(host) 75 76 // Stripe Elements cannot mount inside a shadow root: the Payment Element 77 // never emits `ready` there and confirmPayment() then always fails with 78 // "the Element ... is mounted and the ready event has been emitted". The 79 // widget mounts everything inside attachShadow(), so the element is mounted 80 // into a light-DOM node that is slotted into the shadow markup — visually in 81 // place, technically in the main document where Stripe works. 82 const hostRoot = typeof host.getRootNode === 'function' ? host.getRootNode() : null 83 const inShadow = typeof ShadowRoot !== 'undefined' && hostRoot instanceof ShadowRoot && hostRoot.host 84 let slottedHost = null 85 if (inShadow) { 86 slottedHost = document.createElement('div') 87 slottedHost.setAttribute('slot', 'bm-payment-element') 88 // contain:paint + clip-path clip the Element iframe at this node's bounds — 89 // plain overflow on a slotted node does not contain Stripe's iframe for hit 90 // testing, so it would otherwise intercept clicks on the pay button below. 91 slottedHost.style.cssText = 'position: relative; margin: 8px 0 4px; min-height: 120px; contain: paint; clip-path: inset(0);' 92 93 // Slotted content renders at the slot position but is not clipped by the 94 // widget's internal scroll container — a tall Payment Element would paint 95 // over the pay button below it. Cap it to the space left in the nearest 96 // clipping ancestor and let the element scroll internally instead. 97 let ancestor = host.parentElement 98 while (ancestor && ancestor !== hostRoot) { 99 const overflowY = getComputedStyle(ancestor).overflowY 100 if (overflowY === 'auto' || overflowY === 'scroll' || overflowY === 'hidden') { 101 const available = ancestor.getBoundingClientRect().bottom - host.getBoundingClientRect().top 102 slottedHost.style.maxHeight = `${Math.max(140, Math.floor(available))}px` 103 slottedHost.style.overflowY = 'auto' 104 break 105 } 106 ancestor = ancestor.parentElement 107 } 108 109 hostRoot.host.appendChild(slottedHost) 110 } 111 const mountHost = slottedHost || host 112 const cleanupSlotted = () => { 113 if (slottedHost) { 114 slottedHost.remove() 115 slottedHost = null 116 } 117 } 118 119 try { 120 paymentElement.mount(mountHost) 121 122 // confirmPayment() throws "Element ... ready event has been emitted" while 123 // the Payment Element iframe is still initialising. mount() returns before 124 // `ready` fires, so callers keep their CTA disabled until this resolves. 125 if (typeof paymentElement.on === 'function') { 126 await new Promise((resolve, reject) => { 127 const cleanup = () => { 128 try { 129 paymentElement.off('ready', onReady) 130 paymentElement.off('loaderror', onError) 131 } catch { 132 // ignore 133 } 134 } 135 const onReady = () => { 136 cleanup() 137 resolve() 138 } 139 const onError = (event) => { 140 cleanup() 141 reject(new Error(event?.error?.message || copy.paymentLoadFailed)) 142 } 143 paymentElement.on('ready', onReady) 144 paymentElement.on('loaderror', onError) 145 }) 146 } 147 } catch (error) { 148 cleanupSlotted() 149 throw error 150 } 70 151 71 152 return { … … 80 161 81 162 if (result.error) { 82 throw new Error(result.error.message || 'Payment failed')163 throw new Error(result.error.message || copy.paymentError) 83 164 } 84 165 85 166 const intent = result.paymentIntent 86 167 if (!intent || intent.status !== 'succeeded') { 87 throw new Error( 'Payment is not completed yet')168 throw new Error(copy.paymentError) 88 169 } 89 170 … … 96 177 // ignore 97 178 } 179 cleanupSlotted() 98 180 }, 99 181 } … … 113 195 if (!response.ok) { 114 196 const result = await response.json().catch(() => ({})) 115 throw new Error(result.error || 'Payment confirmation failed')197 throw new Error(result.error || getWidgetCopy(config.language).paymentError) 116 198 } 117 199 -
bookminto/trunk/assets/src/previewMount.js
r3723811 r3730301 1 1 import { createBookingBar } from './bookingBar.js' 2 2 import { createBookingModal } from './bookingModal.js' 3 import { widgetCopy as copy } from './copy.js'3 import { getWidgetCopy } from './copy.js' 4 4 import { createWidget } from './render.js' 5 5 import { injectStyles } from './styles.js' … … 309 309 */ 310 310 export function mountPreviewWidget(container, config, options = {}) { 311 const copy = getWidgetCopy(config.language) 311 312 if (!container) { 312 313 return () => undefined -
bookminto/trunk/assets/src/render.js
r3723811 r3730301 1 import { createDatedAvailabilitySlots } from './availability.js' 2 import { isCommerceCheckout, isShopifyCommerce, isWooCommerceCommerce, submitBooking } from './api.js' 1 import { servicePhotoMarkup, bindServicePhotoErrors } from './servicePhotos.js' 2 import { createDatedAvailabilitySlots, qualifiedStaffForService, serviceBlockedByStaff } from './availability.js' 3 import { isCommerceCheckout, isShopifyCommerce, isWooCommerceCommerce, submitBooking, submitWaitlistEntry } from './api.js' 3 4 import { buildIcsDataUrl } from './calendar.js' 4 import { DATE_WINDOW_SIZE, widgetCopy as copy} from './copy.js'5 import { escapeHtml, formatDuration, formatPrice, servicePriceSummary, t imezoneLabel } from './formatters.js'5 import { DATE_WINDOW_SIZE, getWidgetCopy, widgetLocale } from './copy.js' 6 import { escapeHtml, formatDuration, formatPrice, servicePriceSummary, text, timezoneLabel } from './formatters.js' 6 7 import { confirmBookingPayment, getDepositPaymentSession, mountDepositPayment, showPaymentLoadError } from './payment.js' 7 8 import { scheduleRevealContinue } from './scroll.js' 8 9 import { applyThemeProperties } from './theme.js' 10 import { DIAL_CODES, PHONE_COUNTRIES, normalizePhoneE164 } from './phoneNumber.js' 9 11 10 12 const SHOPIFY_HOLD_NAME = 'Shopify checkout' … … 22 24 '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"></polyline></svg>' 23 25 24 function formatDateBadge(dateKey ) {26 function formatDateBadge(dateKey, locale) { 25 27 const value = new Date(`${dateKey}T12:00:00`) 26 28 return { 27 29 day: String(value.getDate()), 28 month: new Intl.DateTimeFormat( 'en-US', { month: 'short' }).format(value).replace('.', ''),29 weekday: new Intl.DateTimeFormat( 'en-US', { weekday: 'short' }).format(value).replace('.', ''),30 month: new Intl.DateTimeFormat(locale || 'en-US', { month: 'short' }).format(value).replace('.', ''), 31 weekday: new Intl.DateTimeFormat(locale || 'en-US', { weekday: 'short' }).format(value).replace('.', ''), 30 32 } 31 33 } 32 34 33 function formatSummaryDate(dateKey) { 35 function staffFirstName(name) { 36 return text(name, '').split(/\s+/).filter(Boolean)[0] || '' 37 } 38 39 function staffInitials(name) { 40 return text(name, '') 41 .split(/\s+/) 42 .filter(Boolean) 43 .slice(0, 2) 44 .map((part) => part.charAt(0)) 45 .join('') 46 .toUpperCase() 47 } 48 49 function staffChipColor(value) { 50 const color = text(value, '') 51 return /^#[0-9a-fA-F]{6}$/.test(color) ? color : '' 52 } 53 54 function staffAvatarUrl(value, previewMode) { 55 const url = text(value, '') 56 if (url.startsWith('https://')) { 57 return url 58 } 59 return previewMode && /^data:image\/(?:png|jpe?g|webp|gif);base64,/i.test(url) ? url : '' 60 } 61 62 function formatSummaryDate(dateKey, locale) { 34 63 if (!dateKey) { 35 64 return '' 36 65 } 37 66 38 return new Intl.DateTimeFormat( 'en-US', {67 return new Intl.DateTimeFormat(locale || 'en-US', { 39 68 weekday: 'short', 40 69 day: 'numeric', … … 43 72 } 44 73 45 function formatVisibleDateRange(dates ) {74 function formatVisibleDateRange(dates, locale) { 46 75 if (!dates.length) { 47 76 return '' 48 77 } 49 78 50 const formatter = new Intl.DateTimeFormat( 'en-US', {79 const formatter = new Intl.DateTimeFormat(locale || 'en-US', { 51 80 day: 'numeric', 52 81 month: 'short', … … 58 87 } 59 88 60 function stepperMarkup(step ) {89 function stepperMarkup(step, copy) { 61 90 const currentStep = Math.min(step + 1, copy.widgetStepLabels.length) 62 91 const activeLabel = copy.widgetStepLabels[currentStep - 1] … … 66 95 class="stepper" 67 96 role="progressbar" 68 aria-label=" Booking progress"97 aria-label="${escapeHtml(copy.progressLabel)}" 69 98 aria-valuemin="1" 70 99 aria-valuemax="${copy.widgetStepLabels.length}" 71 100 aria-valuenow="${currentStep}" 72 aria-valuetext=" Step ${currentStep} of${copy.widgetStepLabels.length}: ${escapeHtml(activeLabel)}"101 aria-valuetext="${escapeHtml(copy.stepLabel)} ${currentStep} ${escapeHtml(copy.stepOf)} ${copy.widgetStepLabels.length}: ${escapeHtml(activeLabel)}" 73 102 > 74 103 <div class="stepper-meta" aria-hidden="true"> 75 <span> Step ${currentStep} of${copy.widgetStepLabels.length}</span>104 <span>${escapeHtml(copy.stepLabel)} ${currentStep} ${escapeHtml(copy.stepOf)} ${copy.widgetStepLabels.length}</span> 76 105 <strong>${escapeHtml(activeLabel)}</strong> 77 106 </div> … … 101 130 102 131 export function createWidget(config) { 132 const copy = getWidgetCopy(config.language) 133 const locale = widgetLocale(config.language) 103 134 const widget = document.createElement('section') 104 135 const isFloating = config.variant === 'floating' 105 136 const isOverlay = isFloating 106 137 const isCommerce = isCommerceCheckout(config) 107 const skipServiceStep = isCommerce && config.services.length <= 1 138 // Skip the pick-a-service step when the mount is already scoped to one 139 // service (commerce, or a ?book=&service= share/rebooking link). 140 const skipServiceStep = (isCommerce || Boolean(config.service)) && config.services.length <= 1 108 141 let step = skipServiceStep ? 1 : 0 109 142 let selectedServiceId = config.services[0]?.id || config.services[0]?.name || '' 110 143 let selectedDate = null 111 144 let selectedSlotId = null 145 let selectedStaffId = null 112 146 let datePageStart = 0 113 let message = '' 147 let message = config.calendarVerification === 'external_unverified' 148 ? ({ de: 'Die Kalenderverfügbarkeit kann derzeit nicht geprüft werden. Bitte versuchen Sie es später erneut.', en: 'Calendar availability cannot currently be verified. Please try again later.', es: 'No se puede verificar la disponibilidad del calendario ahora mismo. Inténtalo más tarde.', fr: 'La disponibilité du calendrier ne peut pas être vérifiée. Réessayez plus tard.', it: 'La disponibilità del calendario non può essere verificata al momento. Riprova più tardi.', nl: 'De beschikbaarheid van de agenda kan nu niet worden gecontroleerd. Probeer het later opnieuw.' }[config.language] || 'Calendar availability cannot currently be verified. Please try again later.') 149 : '' 114 150 let messageTone = 'error' 115 151 let isSubmitting = false … … 117 153 let paymentSession = null 118 154 let paymentController = null 155 // Idempotent submit key: retries of the same attempt replay server-side 156 // instead of double-booking. Regenerated whenever the flow resets. 157 let clientRequestId = newClientRequestId() 158 let terminalPaymentReplacementAvailable = false 119 159 let form = { 160 answers: {}, 120 161 customerEmail: '', 121 162 customerName: '', 122 163 customerPhone: '', 164 customerPhoneCountry: '', 123 165 notes: '', 166 rebookingOptIn: false, 167 smsOptIn: false, 168 whatsappOptIn: false, 169 } 170 // Extension D (waitlist): entry point inside the date step when no slot fits. 171 let waitlistOpen = false 172 let waitlistBusy = false 173 let waitlistJoined = false 174 let waitlistForm = { customerEmail: '', customerName: '', dateFrom: '', dateTo: '' } 175 176 function newClientRequestId() { 177 try { 178 if (typeof crypto?.randomUUID === 'function') { 179 return crypto.randomUUID() 180 } 181 } catch { 182 // fall through to the manual id 183 } 184 185 return `bmr-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}` 124 186 } 125 187 … … 140 202 } 141 203 204 // Team members the customer may pick between for the current service. Real 205 // names only ship in 'customer_choice' mode — otherwise the picker stays 206 // hidden and availability stays a union across qualified members. 207 function staffMembers() { 208 return config.staffDisplayMode === 'customer_choice' 209 ? qualifiedStaffForService(config, selectedService()) 210 : [] 211 } 212 213 // The pinned member, or undefined for "Any" (union availability). 214 function pinnedStaffId() { 215 if (!selectedStaffId) { 216 return undefined 217 } 218 219 return staffMembers().some((member) => member.id === selectedStaffId) 220 ? selectedStaffId 221 : undefined 222 } 223 224 function selectedStaffName() { 225 const pinned = pinnedStaffId() 226 if (!pinned) { 227 return '' 228 } 229 230 const member = staffMembers().find((entry) => entry.id === pinned) 231 return member ? text(member.name, '') : '' 232 } 233 142 234 function datedSlots() { 143 return createDatedAvailabilitySlots(config, selectedService() )235 return createDatedAvailabilitySlots(config, selectedService(), pinnedStaffId()) 144 236 } 145 237 … … 166 258 167 259 if (step === 2) { 168 return Boolean(form.customerName.trim() && form.customerEmail.trim()) 260 // A stored deposit requirement is never relaxed silently: when the 261 // deposits feature or the payment link is unavailable, the booking 262 // pauses instead of confirming for free. 263 const selected = selectedService() 264 if (Number(selected?.depositCents) > 0 && config.features?.deposits !== true) { 265 return false 266 } 267 268 if (!form.customerName.trim() || !form.customerEmail.trim()) { 269 return false 270 } 271 272 const questions = selectedService()?.bookingQuestions || [] 273 return questions.every( 274 (question) => 275 !question.required || Boolean(String(form.answers?.[question.id] || '').trim()), 276 ) 169 277 } 170 278 … … 190 298 191 299 if (step === 2) { 300 if (terminalPaymentReplacementAvailable) return copy.replacePaymentCta 192 301 return copy.confirmCta 193 302 } … … 198 307 function formatDeposit(cents) { 199 308 const amount = (Number(cents) || 0) / 100 200 return new Intl.NumberFormat( 'en-US', {309 return new Intl.NumberFormat(locale, { 201 310 style: 'currency', 202 311 currency: 'EUR', … … 210 319 <div class="confirm-sub" style="text-align:left;margin-bottom:12px">${escapeHtml(copy.depositNote)}</div> 211 320 <div class="summary-box"> 212 <div class="summary-row"><span><strong>${escapeHtml(formatDeposit(paymentSession.depositCents))}</strong> deposit</span></div>321 <div class="summary-row"><span><strong>${escapeHtml(formatDeposit(paymentSession.depositCents))}</strong> ${escapeHtml(copy.depositLabel)}</span></div> 213 322 </div> 214 < div class="bw-payment-element" data-payment-element></div>323 <slot name="bm-payment-element" data-payment-element><div class="bw-payment-element"></div></slot> 215 324 </div> 216 325 ` … … 223 332 224 333 const toneClass = messageTone === 'info' ? ' widget-message--info' : '' 225 return `<div role="alert" class="widget-message${toneClass}">${escapeHtml(message)}</div>`334 return `<div role="alert" tabindex="-1" class="widget-message${toneClass}">${escapeHtml(message)}</div>` 226 335 } 227 336 … … 245 354 return ` 246 355 <button type="button" class="service-card${selected}" data-service-id="${escapeHtml(id)}"> 356 ${servicePhotoMarkup(service.imageUrl, config.previewMode)} 247 357 <div class="service-info"> 248 358 <div class="service-name">${escapeHtml(service.name)}</div><div class="service-price">${escapeHtml(servicePriceSummary(service))}</div> … … 256 366 } 257 367 368 // Extension D: shown when the selected service is on a waitlist-enabled plan 369 // and the current view has no fitting times. 370 function waitlistMarkup(service, daySlots) { 371 if (config.features?.waitlist !== true || !service) { 372 return '' 373 } 374 375 if (waitlistJoined) { 376 return `<div class="runtime-state waitlist-joined" role="status">${escapeHtml(copy.waitlistJoined)}</div>` 377 } 378 379 if (!waitlistOpen) { 380 return ` 381 <button type="button" class="waitlist-cta" data-action="waitlist-open"> 382 ${escapeHtml(copy.waitlistCta)} 383 </button> 384 ` 385 } 386 387 return ` 388 <div class="waitlist-form"> 389 <p class="section-title">${escapeHtml(copy.waitlistHeading)}</p> 390 <p class="waitlist-note">${escapeHtml(copy.waitlistNote)}</p> 391 <div class="form-field"> 392 <label class="form-label" for="bm-wl-name">${escapeHtml(copy.nameLabel)}</label> 393 <input class="form-input" id="bm-wl-name" data-waitlist-field="customerName" autocomplete="name" value="${escapeHtml(waitlistForm.customerName)}" /> 394 </div> 395 <div class="form-field"> 396 <label class="form-label" for="bm-wl-email">${escapeHtml(copy.emailLabel)}</label> 397 <input class="form-input" id="bm-wl-email" data-waitlist-field="customerEmail" autocomplete="email" type="email" value="${escapeHtml(waitlistForm.customerEmail)}" /> 398 </div> 399 <div class="form-row"> 400 <div class="form-field"> 401 <label class="form-label" for="bm-wl-from">${escapeHtml(copy.waitlistFrom)}</label> 402 <input class="form-input" id="bm-wl-from" data-waitlist-field="dateFrom" type="date" value="${escapeHtml(waitlistForm.dateFrom)}" /> 403 </div> 404 <div class="form-field"> 405 <label class="form-label" for="bm-wl-to">${escapeHtml(copy.waitlistTo)}</label> 406 <input class="form-input" id="bm-wl-to" data-waitlist-field="dateTo" type="date" value="${escapeHtml(waitlistForm.dateTo)}" /> 407 </div> 408 </div> 409 <button type="button" class="cta-btn" data-action="waitlist-submit" ${waitlistBusy ? 'disabled' : ''}> 410 <span>${escapeHtml(waitlistBusy ? copy.processing : copy.waitlistSubmit)}</span> 411 </button> 412 </div> 413 ` 414 } 415 416 // Compact member chips above the date grid — "Any" keeps union 417 // availability, a member chip pins slots to that calendar. 418 function staffPickerMarkup() { 419 const members = staffMembers() 420 421 if (members.length < 2) { 422 return '' 423 } 424 425 const pinned = pinnedStaffId() 426 const memberChips = members 427 .map((member) => { 428 const name = text(member.name, '') 429 const color = staffChipColor(member.color) 430 const avatar = staffAvatarUrl(member.avatarUrl, config.previewMode) 431 const selected = member.id === pinned 432 const avatarMarkup = avatar 433 ? `<img class="staff-chip-avatar" src="${escapeHtml(avatar)}" alt="" loading="lazy" decoding="async" referrerpolicy="no-referrer">` 434 : '' 435 return ` 436 <button type="button" class="staff-chip${selected ? ' selected' : ''}" data-staff-id="${escapeHtml(member.id)}" aria-pressed="${selected}"> 437 <span class="staff-chip-dot"${color ? ` style="background:${escapeHtml(color)}"` : ''}>${escapeHtml(staffInitials(name))}${avatarMarkup}</span> 438 <span class="staff-chip-name">${escapeHtml(staffFirstName(name) || name)}</span> 439 </button> 440 ` 441 }) 442 .join('') 443 444 return ` 445 <p class="section-title">${escapeHtml(copy.staffPickerLabel)}</p> 446 <div class="staff-row" role="group" aria-label="${escapeHtml(copy.staffPickerLabel)}"> 447 <button type="button" class="staff-chip${pinned ? '' : ' selected'}" data-staff-id="" aria-pressed="${!pinned}"> 448 <span class="staff-chip-name">${escapeHtml(copy.anyStaff)}</span> 449 </button> 450 ${memberChips} 451 </div> 452 ` 453 } 454 258 455 function dateStep(availableDates, slotsByDate) { 259 456 const dates = visibleDates(availableDates) … … 261 458 const canPrev = datePageStart > 0 262 459 const canNext = datePageStart + DATE_WINDOW_SIZE < availableDates.length 460 const service = selectedService() 263 461 const backBtn = 264 462 skipServiceStep … … 266 464 : `<button class="back-btn" type="button" data-action="back">← ${escapeHtml(copy.back)}</button>` 267 465 466 if (serviceBlockedByStaff(config, service)) { 467 return ` 468 ${backBtn} 469 ${runtimeState(copy.serviceNotBookable, 'danger')} 470 ` 471 } 472 268 473 return ` 269 474 ${backBtn} 475 ${staffPickerMarkup()} 270 476 <p class="section-title">${escapeHtml(copy.chooseDate)}</p><p class="booking-timezone">${escapeHtml(timezoneLabel(config))}</p> 271 477 <div class="date-nav"> 272 <span class="date-nav-copy">${escapeHtml(formatVisibleDateRange(dates ))}</span>478 <span class="date-nav-copy">${escapeHtml(formatVisibleDateRange(dates, locale))}</span> 273 479 <div class="date-nav-buttons"> 274 480 <button type="button" class="date-nav-btn" data-action="prev-dates" aria-label="${escapeHtml(copy.previousDates)}" ${canPrev ? '' : 'disabled'}> … … 285 491 ? dates 286 492 .map((dateKey) => { 287 const badge = formatDateBadge(dateKey )493 const badge = formatDateBadge(dateKey, locale) 288 494 const selected = dateKey === selectedDate ? ' selected' : '' 289 495 return ` … … 309 515 .map((slot) => { 310 516 const selected = slot.id === selectedSlotId ? ' selected' : '' 517 const recommended = slot.recommended ? ' recommended' : '' 311 518 return ` 312 <button type="button" class="time-btn${selected} " data-slot-id="${escapeHtml(slot.id)}">519 <button type="button" class="time-btn${selected}${recommended}" data-slot-id="${escapeHtml(slot.id)}"> 313 520 ${escapeHtml(slot.timeLabel)} 521 ${slot.recommended ? `<em class="rec-badge">${escapeHtml(copy.recommendedBadge)}</em>` : ''} 314 522 </button> 315 523 ` … … 322 530 : '' 323 531 } 532 ${!dates.length || !daySlots.length ? waitlistMarkup(service, daySlots) : ''} 324 533 ` 325 534 } … … 332 541 <div class="summary-box"> 333 542 <div class="summary-row"> 334 <span>${escapeHtml(formatSummaryDate(selectedDate ))} ${escapeHtml(copy.at)} ${escapeHtml(slot?.timeLabel || '')}</span>543 <span>${escapeHtml(formatSummaryDate(selectedDate, locale))} ${escapeHtml(copy.at)} ${escapeHtml(slot?.timeLabel || '')}</span> 335 544 </div> 336 545 <div class="summary-row"> 337 546 <span>${escapeHtml(service?.name || '')} · ${escapeHtml(formatDuration(service?.durationMinutes))}</span> 338 547 </div> 548 ${ 549 selectedStaffName() 550 ? `<div class="summary-row"><span>${escapeHtml(copy.withStaff.replace('{name}', selectedStaffName()))}</span></div>` 551 : '' 552 } 339 553 ${ 340 554 price !== 'Free' … … 349 563 <input class="form-input" id="bm-runtime-customerName" name="customerName" autocomplete="name" required placeholder="${escapeHtml(copy.namePlaceholder)}" value="${escapeHtml(form.customerName)}" /> 350 564 </div> 565 ${ 566 config.sms?.available === true 567 ? ` 568 <div class="form-field"> 569 <label class="form-label" for="bm-runtime-customerPhoneCountry">${escapeHtml(copy.phoneCountryLabel)}</label> 570 <select class="form-input" id="bm-runtime-customerPhoneCountry" name="customerPhoneCountry" autocomplete="country"> 571 <option value="">${escapeHtml(copy.chooseOption)}</option> 572 ${PHONE_COUNTRIES.map( 573 (iso) => 574 `<option value="${iso}"${form.customerPhoneCountry === iso ? ' selected' : ''}>${iso} (+${escapeHtml(DIAL_CODES[iso])})</option>`, 575 ).join('')} 576 </select> 577 </div> 578 ` 579 : '' 580 } 351 581 <div class="form-field"> 352 582 <label class="form-label" for="bm-runtime-customerPhone">${escapeHtml(copy.phoneLabel)}</label> … … 362 592 <textarea class="form-input form-input--textarea" id="bm-runtime-notes" name="notes" rows="2" maxlength="500" placeholder="${escapeHtml(copy.notesPlaceholder)}">${escapeHtml(form.notes)}</textarea> 363 593 </div> 594 ${questionsMarkup(service)} 595 ${ 596 Number(service?.depositCents) > 0 && config.features?.deposits !== true 597 ? `<p class="form-note deposit-unavailable-note" role="note">${escapeHtml(copy.depositUnavailable)}</p>` 598 : '' 599 } 600 ${ 601 Number(service?.rebookingIntervalWeeks) > 0 602 ? ` 603 <label class="rebooking-optin"> 604 <input type="checkbox" data-rebooking-optin ${form.rebookingOptIn ? 'checked' : ''} /> 605 <span>${escapeHtml(copy.rebookingOptInLabel)}</span> 606 </label> 607 ` 608 : '' 609 } 610 ${ 611 config.sms?.available === true 612 ? ` 613 <label class="rebooking-optin"> 614 <input type="checkbox" data-sms-optin ${form.smsOptIn ? 'checked' : ''} /> 615 <span>${escapeHtml(copy.smsOptInLabel)}</span> 616 </label> 617 ` 618 : '' 619 } 620 ${ 621 config.whatsapp?.available === true 622 ? ` 623 <label class="rebooking-optin"> 624 <input type="checkbox" data-whatsapp-optin ${form.whatsappOptIn ? 'checked' : ''} /> 625 <span>${escapeHtml(copy.whatsappOptInLabel)}</span> 626 </label> 627 ` 628 : '' 629 } 364 630 </div> 631 ` 632 } 633 634 function questionsMarkup(service) { 635 const questions = Array.isArray(service?.bookingQuestions) ? service.bookingQuestions : [] 636 637 if (!questions.length) { 638 return '' 639 } 640 641 return ` 642 <p class="section-title section-title--with-top-gap">${escapeHtml(copy.questionsHeading)}</p> 643 ${questions 644 .map((question) => { 645 const inputId = `bm-runtime-question-${escapeHtml(question.id)}` 646 const value = String(form.answers?.[question.id] || '') 647 const requiredMark = question.required 648 ? ` <span class="form-required" aria-hidden="true">*</span><span class="sr-only"> ${escapeHtml(copy.requiredHint)}</span>` 649 : '' 650 const label = `<label class="form-label" for="${inputId}">${escapeHtml(question.label)}${requiredMark}</label>` 651 652 if (question.type === 'yesno') { 653 return ` 654 <div class="form-field"> 655 ${label} 656 <select class="form-input" id="${inputId}" data-question-id="${escapeHtml(question.id)}"${question.required ? ' required' : ''}> 657 <option value="">${escapeHtml(copy.chooseOption)}</option> 658 <option value="yes"${value === 'yes' ? ' selected' : ''}>${escapeHtml(copy.yesLabel)}</option> 659 <option value="no"${value === 'no' ? ' selected' : ''}>${escapeHtml(copy.noLabel)}</option> 660 </select> 661 </div> 662 ` 663 } 664 665 if (question.type === 'choice') { 666 return ` 667 <div class="form-field"> 668 ${label} 669 <select class="form-input" id="${inputId}" data-question-id="${escapeHtml(question.id)}"${question.required ? ' required' : ''}> 670 <option value="">${escapeHtml(copy.chooseOption)}</option> 671 ${(question.options || []) 672 .map( 673 (option) => 674 `<option value="${escapeHtml(option)}"${value === option ? ' selected' : ''}>${escapeHtml(option)}</option>`, 675 ) 676 .join('')} 677 </select> 678 </div> 679 ` 680 } 681 682 return ` 683 <div class="form-field"> 684 ${label} 685 <input class="form-input" id="${inputId}" data-question-id="${escapeHtml(question.id)}" maxlength="300"${question.required ? ' required' : ''} value="${escapeHtml(value)}" /> 686 </div> 687 ` 688 }) 689 .join('')} 365 690 ` 366 691 } … … 401 726 <div class="summary-box"> 402 727 <div class="summary-row"><span><strong>${escapeHtml(confirmation?.customerName || form.customerName)}</strong></span></div> 403 <div class="summary-row"><span>${escapeHtml(formatSummaryDate(selectedDate ))} ${escapeHtml(copy.at)} ${escapeHtml(slot?.timeLabel || '')}</span></div>728 <div class="summary-row"><span>${escapeHtml(formatSummaryDate(selectedDate, locale))} ${escapeHtml(copy.at)} ${escapeHtml(slot?.timeLabel || '')}</span></div> 404 729 <div class="summary-row"><span>${escapeHtml(service?.name || '')} · ${escapeHtml(formatDuration(service?.durationMinutes))}</span></div> 730 ${ 731 selectedStaffName() 732 ? `<div class="summary-row"><span>${escapeHtml(copy.withStaff.replace('{name}', selectedStaffName()))}</span></div>` 733 : '' 734 } 405 735 ${ 406 736 price !== 'Free' … … 419 749 const emailInput = widget.querySelector('[name="customerEmail"]') 420 750 const phoneInput = widget.querySelector('[name="customerPhone"]') 751 const phoneCountryInput = widget.querySelector('[name="customerPhoneCountry"]') 421 752 const notesInput = widget.querySelector('[name="notes"]') 422 753 … … 424 755 if (emailInput) form.customerEmail = emailInput.value 425 756 if (phoneInput) form.customerPhone = phoneInput.value 757 if (phoneCountryInput) form.customerPhoneCountry = phoneCountryInput.value 426 758 if (notesInput) form.notes = notesInput.value 759 760 widget.querySelectorAll('[data-question-id]').forEach((input) => { 761 const id = input.getAttribute('data-question-id') 762 if (id) { 763 form.answers[id] = input.value 764 } 765 }) 766 767 const optIn = widget.querySelector('[data-rebooking-optin]') 768 if (optIn) { 769 form.rebookingOptIn = optIn.checked === true 770 } 771 const smsOptIn = widget.querySelector('[data-sms-optin]') 772 if (smsOptIn) { 773 form.smsOptIn = smsOptIn.checked === true 774 } 775 const whatsappOptIn = widget.querySelector('[data-whatsapp-optin]') 776 if (whatsappOptIn) { 777 form.whatsappOptIn = whatsappOptIn.checked === true 778 } 427 779 } 428 780 … … 437 789 selectedDate = null 438 790 selectedSlotId = null 791 selectedStaffId = null 439 792 datePageStart = 0 440 793 message = '' … … 442 795 isSubmitting = false 443 796 confirmation = null 797 clientRequestId = newClientRequestId() 798 terminalPaymentReplacementAvailable = false 799 waitlistOpen = false 800 waitlistBusy = false 801 waitlistJoined = false 802 waitlistForm = { customerEmail: '', customerName: '', dateFrom: '', dateTo: '' } 444 803 form = { 804 answers: {}, 445 805 customerEmail: '', 446 806 customerName: '', 447 807 customerPhone: '', 808 customerPhoneCountry: '', 448 809 notes: '', 810 rebookingOptIn: false, 811 smsOptIn: false, 812 whatsappOptIn: false, 449 813 } 450 814 render() … … 502 866 </div> 503 867 </div> 504 ${stepperMarkup(paymentSession ? 2 : step )}868 ${stepperMarkup(paymentSession ? 2 : step, copy)} 505 869 ` 506 870 871 const staffHint = selectedStaffName() 507 872 const serviceHint = 508 873 isCommerce && service && step >= 1 && step < 3 509 ? `<p class="commerce-service-hint">${escapeHtml(service.name)} · ${escapeHtml(formatDuration(service.durationMinutes))} </p>`874 ? `<p class="commerce-service-hint">${escapeHtml(service.name)} · ${escapeHtml(formatDuration(service.durationMinutes))}${staffHint ? ` · ${escapeHtml(copy.withStaff.replace('{name}', staffHint))}` : ''}</p>` 510 875 : '' 511 876 … … 545 910 accent: config.color, 546 911 clientSecret: paymentSession.clientSecret, 912 language: config.language, 547 913 publishableKey: paymentSession.publishableKey, 548 914 }) … … 553 919 }) 554 920 .catch((error) => { 555 if (host.isConnected && paymentSession === session) showPaymentLoadError(host, error, render )921 if (host.isConnected && paymentSession === session) showPaymentLoadError(host, error, render, config.language) 556 922 }) 557 923 } … … 560 926 561 927 function bindEvents(availableDates, slotsByDate) { 928 bindServicePhotoErrors(widget) 562 929 widget.querySelectorAll('[data-service-id]').forEach((button) => { 563 930 button.addEventListener('click', () => { … … 565 932 selectedDate = null 566 933 selectedSlotId = null 934 selectedStaffId = null 567 935 datePageStart = 0 568 936 message = '' 569 937 render() 570 938 }) 939 }) 940 941 // Member chips: pinning re-scopes availability to that calendar; "Any" 942 // (data-staff-id="") restores the union. 943 widget.querySelectorAll('[data-staff-id]').forEach((button) => { 944 button.addEventListener('click', () => { 945 const next = button.getAttribute('data-staff-id') || null 946 if (next === selectedStaffId) { 947 return 948 } 949 950 selectedStaffId = next 951 message = '' 952 messageTone = 'error' 953 datePageStart = 0 954 // A different member is a different booking attempt — never replay 955 // the previous request id against another member's fingerprint. 956 clientRequestId = newClientRequestId() 957 958 const fresh = datedSlots() 959 if (selectedDate && !fresh.availableDates.includes(selectedDate)) { 960 selectedDate = null 961 selectedSlotId = null 962 } else if ( 963 selectedSlotId && 964 !(fresh.slotsByDate.get(selectedDate) || []).some((slot) => slot.id === selectedSlotId) 965 ) { 966 selectedSlotId = null 967 } 968 969 render() 970 scheduleRevealContinue(widget, { 971 containerSelector: '.widget-body-scroll', 972 margins: { top: 8, bottom: 16 }, 973 }) 974 }) 975 }) 976 977 widget.querySelectorAll('.staff-chip-avatar').forEach((image) => { 978 const hide = () => image.remove() 979 image.addEventListener('error', hide, { once: true }) 980 if (image.complete && !image.naturalWidth) hide() 571 981 }) 572 982 … … 633 1043 }) 634 1044 1045 widget.querySelector('[data-action="waitlist-open"]')?.addEventListener('click', () => { 1046 waitlistOpen = true 1047 message = '' 1048 const base = selectedDate || new Date().toISOString().slice(0, 10) 1049 const end = new Date(`${base}T12:00:00`) 1050 end.setDate(end.getDate() + 14) 1051 waitlistForm = { 1052 customerEmail: form.customerEmail, 1053 customerName: form.customerName, 1054 dateFrom: waitlistForm.dateFrom || base, 1055 dateTo: waitlistForm.dateTo || end.toISOString().slice(0, 10), 1056 } 1057 render() 1058 scheduleRevealContinue(widget, { 1059 containerSelector: '.widget-body-scroll', 1060 margins: { top: 8, bottom: 16 }, 1061 }) 1062 }) 1063 1064 widget.querySelectorAll('[data-waitlist-field]').forEach((input) => { 1065 input.addEventListener('input', () => { 1066 const field = input.getAttribute('data-waitlist-field') 1067 if (field) { 1068 waitlistForm[field] = input.value 1069 } 1070 }) 1071 }) 1072 1073 widget.querySelector('[data-action="waitlist-submit"]')?.addEventListener('click', () => { 1074 if (waitlistBusy) return 1075 const service = selectedService() 1076 waitlistBusy = true 1077 message = '' 1078 messageTone = 'error' 1079 render() 1080 submitWaitlistEntry(config, { 1081 customerEmail: waitlistForm.customerEmail, 1082 customerName: waitlistForm.customerName, 1083 dateFrom: waitlistForm.dateFrom, 1084 dateTo: waitlistForm.dateTo, 1085 serviceId: service?.id || service?.name || '', 1086 }) 1087 .then(() => { 1088 waitlistBusy = false 1089 waitlistJoined = true 1090 waitlistOpen = false 1091 render() 1092 }) 1093 .catch((error) => { 1094 waitlistBusy = false 1095 message = error instanceof Error ? error.message : copy.waitlistError 1096 messageTone = 'error' 1097 render() 1098 }) 1099 }) 1100 635 1101 widget.querySelector('[data-action="continue"]')?.addEventListener('click', () => { 636 1102 if (isSubmitting) { … … 695 1161 } 696 1162 1163 const session = paymentSession 1164 const controller = paymentController 697 1165 try { 698 const paymentIntentId = await paymentController.confirm() 699 await confirmBookingPayment(config, { 700 bookingId: paymentSession.bookingId, 1166 const paymentIntentId = session.confirmedPaymentIntentId || await controller.confirm() 1167 if (paymentSession !== session || paymentController !== controller) return 1168 session.confirmedPaymentIntentId = paymentIntentId 1169 const result = await confirmBookingPayment(config, { 1170 bookingId: session.bookingId, 701 1171 paymentIntentId, 702 1172 }) 1173 if (paymentSession !== session || paymentController !== controller) return 1174 if (result.processing || !(result.ok || result.alreadyConfirmed)) throw new Error(copy.paymentError) 703 1175 704 1176 confirmation = { … … 716 1188 render() 717 1189 } catch (error) { 1190 if (paymentSession !== session || paymentController !== controller) return 718 1191 isSubmitting = false 719 1192 message = error instanceof Error ? error.message : copy.genericError … … 739 1212 const service = selectedService() 740 1213 const slot = selectedSlot(slotsByDate) 1214 if (Number(service?.depositCents) > 0 && config.features?.deposits !== true) return 741 1215 742 1216 if (isShopifyCommerce(config)) { … … 751 1225 752 1226 if (!service || !slot || !form.customerName.trim() || !form.customerEmail.trim()) { 1227 return 1228 } 1229 1230 // The opt-in only counts with a number the server can route — the worker 1231 // re-checks this; the early return keeps the error local and free. 1232 if ( 1233 (form.smsOptIn || form.whatsappOptIn) && 1234 !normalizePhoneE164(form.customerPhone, form.customerPhoneCountry || undefined) 1235 ) { 1236 message = 1237 form.whatsappOptIn && !form.smsOptIn 1238 ? copy.phoneRequiredForWhatsapp 1239 : copy.phoneRequiredForSms 1240 messageTone = 'error' 1241 render() 753 1242 return 754 1243 } … … 765 1254 try { 766 1255 const result = await submitBooking(config, { 1256 answers: Object.keys(form.answers || {}).length ? form.answers : undefined, 1257 clientRequestId, 1258 replaceTerminalPayment: terminalPaymentReplacementAvailable || undefined, 1259 publicationRevision: config.publicationRevision, 767 1260 customerEmail: form.customerEmail, 768 1261 customerName: form.customerName, 769 1262 customerPhone: form.customerPhone, 1263 customerPhoneCountry: form.customerPhoneCountry || undefined, 1264 language: config.language || 'en', 770 1265 notes: form.notes, 1266 rebookingOptIn: form.rebookingOptIn || undefined, 771 1267 serviceId: service.id || service.name, 772 1268 serviceName: service.name, 1269 smsOptIn: config.sms?.available === true ? form.smsOptIn || undefined : undefined, 1270 staffId: pinnedStaffId(), 773 1271 startsAt: slot.startsAt, 1272 whatsappOptIn: config.whatsapp?.available === true ? form.whatsappOptIn || undefined : undefined, 774 1273 }) 775 1274 … … 791 1290 } 792 1291 793 const depositSession = getDepositPaymentSession(result )1292 const depositSession = getDepositPaymentSession(result, config.language) 794 1293 if (depositSession) { 1294 terminalPaymentReplacementAvailable = false 795 1295 paymentSession = depositSession 796 1296 confirmation = { … … 819 1319 } catch (error) { 820 1320 isSubmitting = false 821 message = error instanceof Error ? error.message : copy.genericError 1321 terminalPaymentReplacementAvailable = error?.code === 'terminal_payment_replacement_available' 1322 const staffUnavailable = error?.code === 'staff_unavailable' 1323 if (staffUnavailable) { 1324 // The pinned member was just booked (or is unqualified) — send the 1325 // customer back to a freshly computed slot list. 1326 step = 1 1327 selectedSlotId = null 1328 } 1329 message = staffUnavailable 1330 ? copy.staffUnavailable 1331 : terminalPaymentReplacementAvailable 1332 ? copy.replacePaymentNote 1333 : error instanceof Error 1334 ? error.message 1335 : copy.genericError 822 1336 messageTone = 'error' 823 1337 render() 1338 widget.querySelector('.widget-message')?.focus() 824 1339 } 825 1340 } -
bookminto/trunk/assets/src/slotEngine.js
r3723811 r3730301 1 const MIN_SLOT_STEP_MINUTES = 15 2 3 function getTimeZoneOffsetMs(utcMs, timeZone) { 4 const formatter = new Intl.DateTimeFormat('en-US', { 5 timeZone, 6 hourCycle: 'h23', 7 year: 'numeric', 8 month: '2-digit', 9 day: '2-digit', 10 hour: '2-digit', 11 minute: '2-digit', 12 second: '2-digit', 13 }) 14 const parts = formatter.formatToParts(new Date(utcMs)) 15 const lookup = {} 16 17 for (const part of parts) { 18 if (part.type !== 'literal') { 19 lookup[part.type] = part.value 20 } 21 } 22 23 const asUtc = Date.UTC( 24 Number(lookup.year), 25 Number(lookup.month) - 1, 26 Number(lookup.day), 27 Number(lookup.hour), 28 Number(lookup.minute), 29 Number(lookup.second), 30 ) 31 32 return asUtc - utcMs 33 } 34 35 function zonedWallTimeToUtc(dateKey, minutes, timeZone) { 36 const [year, month, day] = dateKey.split('-').map(Number) 37 const hour = Math.floor(minutes / 60) 38 const minute = minutes % 60 39 const guess = Date.UTC(year, month - 1, day, hour, minute, 0) 40 const offset = getTimeZoneOffsetMs(guess, timeZone) 41 let utc = guess - offset 42 const correctedOffset = getTimeZoneOffsetMs(utc, timeZone) 43 44 if (correctedOffset !== offset) { 45 utc = guess - correctedOffset 46 } 47 48 return new Date(utc) 49 } 50 51 export function makeWallInstant(dateKey, minutes, timeZone) { 52 if (timeZone) { 53 return zonedWallTimeToUtc(dateKey, minutes, timeZone) 54 } 55 56 const hours = String(Math.floor(minutes / 60)).padStart(2, '0') 57 const mins = String(minutes % 60).padStart(2, '0') 58 59 return new Date(`${dateKey}T${hours}:${mins}:00`) 60 } 61 62 export function timeToMinutes(value) { 63 const match = String(value || '').match(/^(\d{2}):(\d{2})$/) 64 65 if (!match) { 66 return Number.NaN 67 } 68 69 return Number(match[1]) * 60 + Number(match[2]) 70 } 71 72 export function toNonNegativeInteger(value) { 73 if (!Number.isFinite(value)) { 74 return 0 75 } 76 77 return Math.max(0, Math.round(Number(value))) 78 } 79 80 export function getServiceBlockMinutes(service) { 81 return Math.max( 82 1, 83 toNonNegativeInteger(service?.durationMinutes) + 84 toNonNegativeInteger(service?.bufferBeforeMinutes) + 85 toNonNegativeInteger(service?.bufferAfterMinutes), 86 ) 87 } 88 89 export function getSlotStepMinutes(serviceBlockMinutes) { 90 return Math.max(MIN_SLOT_STEP_MINUTES, toNonNegativeInteger(serviceBlockMinutes)) 91 } 92 93 export function flattenAvailabilityRules(availability) { 94 if (!Array.isArray(availability)) { 95 return [] 96 } 97 98 return availability.flatMap((rule) => { 99 if (!rule?.enabled) { 100 return [] 101 } 102 103 const blocks = Array.isArray(rule.blocks) ? rule.blocks : [] 104 105 return blocks 106 .filter((block) => { 107 const start = timeToMinutes(block.startTime ?? block.start) 108 const end = timeToMinutes(block.endTime ?? block.end) 109 110 return Number.isFinite(start) && Number.isFinite(end) && end > start 111 }) 112 .map((block) => ({ 113 endTime: block.endTime ?? block.end, 114 startTime: block.startTime ?? block.start, 115 weekday: Number(rule.weekday), 116 })) 117 }) 118 } 119 120 export function getDateKey(date) { 121 const year = date.getFullYear() 122 const month = String(date.getMonth() + 1).padStart(2, '0') 123 const day = String(date.getDate()).padStart(2, '0') 124 125 return `${year}-${month}-${day}` 126 } 127 128 function buildWindow(dateKey, startTime, endTime, timeZone) { 129 const startMinutes = timeToMinutes(startTime) 130 const endMinutes = timeToMinutes(endTime) 131 132 if ( 133 startMinutes === null || 134 Number.isNaN(startMinutes) || 135 endMinutes === null || 136 Number.isNaN(endMinutes) || 137 endMinutes <= startMinutes 138 ) { 139 return null 140 } 141 142 const start = makeWallInstant(dateKey, startMinutes, timeZone) 143 const end = makeWallInstant(dateKey, endMinutes, timeZone) 144 145 if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime()) || start >= end) { 146 return null 147 } 148 149 return { end, endMinutes, start, startMinutes } 150 } 151 152 function getExceptionsForDate(exceptions, dateKey) { 153 return (exceptions || []).filter((exception) => exception?.date === dateKey) 154 } 155 156 function hasFullDayBlock(exceptions) { 157 return exceptions.some( 158 (exception) => 159 exception.exceptionType === 'block' && 160 (!exception.startTime || !exception.endTime), 161 ) 162 } 163 164 function getDailyWindows(dateKey, weekday, rules, exceptions, timeZone) { 165 const windows = rules 166 .filter((rule) => rule.weekday === weekday) 167 .map((rule) => buildWindow(dateKey, rule.startTime, rule.endTime, timeZone)) 168 .filter(Boolean) 169 170 for (const exception of exceptions) { 171 if (exception.exceptionType !== 'extra_open') { 172 continue 173 } 174 175 const window = buildWindow(dateKey, exception.startTime, exception.endTime, timeZone) 176 177 if (window) { 178 windows.push(window) 179 } 180 } 181 182 return windows.sort((left, right) => left.start.getTime() - right.start.getTime()) 183 } 184 185 function getPartialBlockWindows(dateKey, exceptions, timeZone) { 186 return exceptions 187 .filter( 188 (exception) => 189 exception.exceptionType === 'block' && 190 exception.startTime && 191 exception.endTime, 192 ) 193 .map((exception) => buildWindow(dateKey, exception.startTime, exception.endTime, timeZone)) 194 .filter(Boolean) 195 } 196 197 function isBlockedByWindow(start, end, blockWindows) { 198 return blockWindows.some( 199 (window) => start.getTime() < window.end.getTime() && end.getTime() > window.start.getTime(), 200 ) 201 } 202 203 function fitsInsideAvailability(start, end, availabilityWindows) { 204 return availabilityWindows.some( 205 (window) => 206 start.getTime() >= window.start.getTime() && end.getTime() <= window.end.getTime(), 207 ) 208 } 209 210 function isSlotStartAligned(slotStartMinutes, ruleStartMinutes, stepMinutes) { 211 if (slotStartMinutes < ruleStartMinutes || stepMinutes <= 0) { 212 return false 213 } 214 215 return (slotStartMinutes - ruleStartMinutes) % stepMinutes === 0 216 } 217 218 function toBookedStartSet(bookedStarts) { 219 const set = new Set() 220 221 for (const value of Array.isArray(bookedStarts) ? bookedStarts : []) { 222 const date = value instanceof Date ? value : new Date(value) 223 224 if (!Number.isNaN(date.getTime())) { 225 set.add(date.toISOString()) 226 } 227 } 228 229 return set 230 } 231 232 export function createAvailabilitySlots(input) { 233 const { 234 availability, 235 exceptions = [], 236 limit = 6, 237 now = new Date(), 238 service, 239 daysAhead = 28, 240 formatLabel, 241 bookedStarts = [], 242 timeZone, 243 } = input 244 245 const rules = flattenAvailabilityRules(availability) 246 247 if (!rules.length || !service) { 248 return [] 249 } 250 251 const bookedStartSet = toBookedStartSet(bookedStarts) 252 253 const blockMinutes = getServiceBlockMinutes(service) 254 const stepMinutes = getSlotStepMinutes(blockMinutes) 255 const durationMinutes = toNonNegativeInteger(service.durationMinutes) 256 const bufferBeforeMinutes = toNonNegativeInteger(service.bufferBeforeMinutes) 257 const bufferAfterMinutes = toNonNegativeInteger(service.bufferAfterMinutes) 258 const slots = [] 259 const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()) 260 261 for (let offset = 0; offset < daysAhead && slots.length < limit; offset += 1) { 262 const date = new Date(today) 263 date.setDate(today.getDate() + offset) 264 const dateKey = getDateKey(date) 265 const weekday = date.getDay() 266 const dayExceptions = getExceptionsForDate(exceptions, dateKey) 267 268 if (hasFullDayBlock(dayExceptions)) { 269 continue 270 } 271 272 const availabilityWindows = getDailyWindows(dateKey, weekday, rules, dayExceptions, timeZone) 273 const blockWindows = getPartialBlockWindows(dateKey, dayExceptions, timeZone) 274 275 if (!availabilityWindows.length) { 276 continue 277 } 278 279 for (const window of availabilityWindows) { 280 const windowStartMinutes = window.startMinutes 281 const windowEndMinutes = window.endMinutes 282 const latestStartMinutes = windowEndMinutes - blockMinutes 283 284 for ( 285 let slotStartMinutes = windowStartMinutes; 286 slotStartMinutes <= latestStartMinutes && slots.length < limit; 287 slotStartMinutes += stepMinutes 288 ) { 289 if (!isSlotStartAligned(slotStartMinutes, windowStartMinutes, stepMinutes)) { 290 continue 291 } 292 293 const slotStart = makeWallInstant(dateKey, slotStartMinutes, timeZone) 294 295 const appointmentStart = new Date(slotStart.getTime() + bufferBeforeMinutes * 60_000) 296 const appointmentEnd = new Date( 297 appointmentStart.getTime() + durationMinutes * 60_000, 298 ) 299 const blockEnd = new Date(appointmentEnd.getTime() + bufferAfterMinutes * 60_000) 300 301 if (appointmentStart.getTime() <= now.getTime()) { 302 continue 303 } 304 305 if (bookedStartSet.has(appointmentStart.toISOString())) { 306 continue 307 } 308 309 if ( 310 !fitsInsideAvailability(slotStart, blockEnd, availabilityWindows) || 311 isBlockedByWindow(slotStart, blockEnd, blockWindows) 312 ) { 313 continue 314 } 315 316 slots.push({ 317 capacity: `${durationMinutes} min`, 318 id: `${dateKey}-${slotStartMinutes}-${service.id || service.name || 'service'}`, 319 label: formatLabel(appointmentStart, dateKey), 320 startsAt: appointmentStart.toISOString(), 321 }) 322 } 323 } 324 } 325 326 return slots 327 } 328 329 export function isValidBookingStart(input) { 330 const { availability, exceptions = [], service, startsAt, now = new Date(), timeZone } = input 331 const start = startsAt instanceof Date ? startsAt : new Date(startsAt) 332 333 if (Number.isNaN(start.getTime())) { 334 return false 335 } 336 337 const slots = createAvailabilitySlots({ 338 availability, 339 daysAhead: 1, 340 exceptions, 341 formatLabel: () => '', 342 limit: 500, 343 now, 344 service, 345 timeZone, 346 }) 347 348 return slots.some((slot) => slot.startsAt === start.toISOString()) 349 } 1 // Canonical implementation lives in shared/availabilitySlotEngine.js — the 2 // widget previously carried a verbatim copy that could silently drift. The 3 // WordPress sync vendors the shared file and rewrites this specifier. 4 export * from './availabilitySlotEngine.js' -
bookminto/trunk/assets/src/styles.js
r3723811 r3730301 2 2 import { bookingBarModalCss } from './bookingBarModalStyles.js' 3 3 import { bookingBarCss } from './bookingBarStyles.js' 4 import { extensionsCss } from './extensionsStyles.js' 4 5 import { floatingChromeCss } from './floatingChromeStyles.js' 5 6 import { floatingWidgetCss } from './floatingStyles.js' 6 7 import { modalChromeCss } from './modalStyles.js' 8 import { nextTimesCss } from './nextTimesStyles.js' 7 9 8 10 export const widgetCss = … … 12 14 bookingBarCss + 13 15 bookingBarModalCss + 14 bookingBarFlowCss 16 bookingBarFlowCss + 17 nextTimesCss + 18 extensionsCss 15 19 16 20 export function injectStyles(target = document.head) { -
bookminto/trunk/assets/widget.js
r3723811 r3730301 1 (function(){let e=`https://api.bookminto.com/v1`,t=[],n=new Intl.DateTimeFormat(`en-US`,{day:`numeric`,month:`short`,weekday:`short`}) ;function r(e){return/^#[0-9a-fA-F]{6}$/.test(String(e||``).trim())?String(e).trim():`#E85D4C`}function i(e,t){return String(e||``).trim()||t}function a(e){return e===`24h`?`24h`:`12h`}function o(e){let t=Number.isFinite(Number(e))?Number(e):30;if(t<60)return`${t} min`;let n=Math.floor(t/60),r=t%60;return r===0?`${n} hr`:`${n}h ${r}m`}function s(e){let t=Number(e);return!Number.isFinite(t)||t===0?`Free`:new Intl.NumberFormat(`en-US`,{currency:`EUR`,maximumFractionDigits:2,minimumFractionDigits:Number.isInteger(t)?0:2,style:`currency`}).format(t)}function c(e){let t=i(e&&e.description,``);return t?`${o(e.durationMinutes)} · ${t}`:o(e.durationMinutes)}function l(e){return String(e).replace(/&/g,`&`).replace(/</g,`<`).replace(/>/g,`>`).replace(/"/g,`"`).replace(/'/g,`'`)}function u(e){return e?`${o(e.durationMinutes)} · ${d(e)}`:`No services available yet`}function d(e){let t=s(e?.price),n=Number(e?.depositEuros??Number(e?.depositCents)/100);return Number.isFinite(n)&&n>0?`${t} · ${s(n)} deposit to confirm`:t}function f(e){return e.timezone?`Times in ${String(e.timezone).replaceAll(`_`,` `)}`:`Times in your local timezone`}function p(e,t){let n=new Intl.DateTimeFormat(`en-US`,{timeZone:t,hourCycle:`h23`,year:`numeric`,month:`2-digit`,day:`2-digit`,hour:`2-digit`,minute:`2-digit`,second:`2-digit`}).formatToParts(new Date(e)),r={};for(let e of n)e.type!==`literal`&&(r[e.type]=e.value);return Date.UTC(Number(r.year),Number(r.month)-1,Number(r.day),Number(r.hour),Number(r.minute),Number(r.second))-e}function m(e,t,n){let[r,i,a]=e.split(`-`).map(Number),o=Math.floor(t/60),s=t%60,c=Date.UTC(r,i-1,a,o,s,0),l=p(c,n),u=c-l,d=p(u,n);return d!==l&&(u=c-d),new Date(u)}function h(e,t,n){if(n)return m(e,t,n);let r=String(Math.floor(t/60)).padStart(2,`0`),i=String(t%60).padStart(2,`0`);return new Date(`${e}T${r}:${i}:00`)}function g(e){let t=String(e||``).match(/^(\d{2}):(\d{2})$/);return t?Number(t[1])*60+Number(t[2]):NaN}function _(e){return Number.isFinite(e)?Math.max(0,Math.round(Number(e))):0}function v(e){return Math.max(1,_(e?.durationMinutes)+_(e?.bufferBeforeMinutes)+_(e?.bufferAfterMinutes))}function y(e){return Math.max(15,_(e))}function b(e){return Array.isArray(e)?e.flatMap(e=>e?.enabled?(Array.isArray(e.blocks)?e.blocks:[]).filter(e=>{let t=g(e.startTime??e.start),n=g(e.endTime??e.end);return Number.isFinite(t)&&Number.isFinite(n)&&n>t}).map(t=>({endTime:t.endTime??t.end,startTime:t.startTime??t.start,weekday:Number(e.weekday)})):[]):[]}function x(e){return`${e.getFullYear()}-${String(e.getMonth()+1).padStart(2,`0`)}-${String(e.getDate()).padStart(2,`0`)}`}function S(e,t,n,r){let i=g(t),a=g(n);if(i===null||Number.isNaN(i)||a===null||Number.isNaN(a)||a<=i)return null;let o=h(e,i,r),s=h(e,a,r);return Number.isNaN(o.getTime())||Number.isNaN(s.getTime())||o>=s?null:{end:s,endMinutes:a,start:o,startMinutes:i}}function C(e,t){return(e||[]).filter(e=>e?.date===t)}function w(e){return e.some(e=>e.exceptionType===`block`&&(!e.startTime||!e.endTime))}function T(e,t,n,r,i){let a=n.filter(e=>e.weekday===t).map(t=>S(e,t.startTime,t.endTime,i)).filter(Boolean);for(let t of r){if(t.exceptionType!==`extra_open`)continue;let n=S(e,t.startTime,t.endTime,i);n&&a.push(n)}return a.sort((e,t)=>e.start.getTime()-t.start.getTime())}function E(e,t,n){return t.filter(e=>e.exceptionType===`block`&&e.startTime&&e.endTime).map(t=>S(e,t.startTime,t.endTime,n)).filter(Boolean)}function D(e,t,n){return n.some(n=>e.getTime()<n.end.getTime()&&t.getTime()>n.start.getTime())}function O(e,t,n){return n.some(n=>e.getTime()>=n.start.getTime()&&t.getTime()<=n.end.getTime())}function k(e,t,n){return e<t||n<=0?!1:(e-t)%n===0}function ee(e){let t=new Set;for(let n of Array.isArray(e)?e:[]){let e=n instanceof Date?n:new Date(n);Number.isNaN(e.getTime())||t.add(e.toISOString())}return t}function A(e){let{availability:t,exceptions:n=[],limit:r=6,now:i=new Date,service:a,daysAhead:o=28,formatLabel:s,bookedStarts:c=[],timeZone:l}=e,u=b(t);if(!u.length||!a)return[];let d=ee(c),f=v(a),p=y(f),m=_(a.durationMinutes),g=_(a.bufferBeforeMinutes),S=_(a.bufferAfterMinutes),A=[],j=new Date(i.getFullYear(),i.getMonth(),i.getDate());for(let e=0;e<o&&A.length<r;e+=1){let t=new Date(j);t.setDate(j.getDate()+e);let o=x(t),c=t.getDay(),_=C(n,o);if(w(_))continue;let v=T(o,c,u,_,l),y=E(o,_,l);if(v.length)for(let e of v){let t=e.startMinutes,n=e.endMinutes-f;for(let e=t;e<=n&&A.length<r;e+=p){if(!k(e,t,p))continue;let n=h(o,e,l),r=new Date(n.getTime()+g*6e4),c=new Date(r.getTime()+m*6e4),u=new Date(c.getTime()+S*6e4);r.getTime()<=i.getTime()||d.has(r.toISOString())||!O(n,u,v)||D(n,u,y)||A.push({capacity:`${m} min`,id:`${o}-${e}-${a.id||a.name||`service`}`,label:s(r,o),startsAt:r.toISOString()})}}}return A}function j(e){let t=String(e||``).match(/^(\d{2}):(\d{2})$/);return t?Number(t[1])*60+Number(t[2]):NaN}function M(e,t){if(t===`24h`)return e;let n=j(e);if(!Number.isFinite(n))return e;let r=Math.floor(n/60),i=n%60,a=r%12||12,o=r>=12?`pm`:`am`;return i===0?`${a}${o}`:`${a}:${String(i).padStart(2,`0`)}${o}`}function N(e){if(!e)return[];try{let t=typeof e==`string`?JSON.parse(e):e;return Array.isArray(t)?t.flatMap(e=>(Array.isArray(e.blocks)?e.blocks:[e]).map((t,n)=>({blocks:[{endTime:i(t.endTime||e.endTime,``),id:i(t.id,`availability-${Number(e.weekday)||0}-${n}`),startTime:i(t.startTime||e.startTime,``)}],enabled:!!e.enabled,id:i(e.id,`availability-${Number(e.weekday)||0}`),weekday:Number(e.weekday)}))).filter(e=>e.enabled&&e.blocks.length>0):[]}catch{return[]}}function P(e){if(!e)return[];try{let t=typeof e==`string`?JSON.parse(e):e;return Array.isArray(t)?t.map(e=>({date:i(e.date,``),endTime:i(e.endTime,``)||void 0,exceptionType:e.exceptionType===`extra_open`?`extra_open`:`block`,id:i(e.id,`exception-${e.date}`),note:i(e.note,``)||void 0,startTime:i(e.startTime,``)||void 0})).filter(e=>e.date):[]}catch{return[]}}function te(e,t){return t?.useCustomAvailability&&Array.isArray(t.availability)&&t.availability.length?N(t.availability):e.availability}function F(e,t){if(!t)return`${String(e.getHours()).padStart(2,`0`)}:${String(e.getMinutes()).padStart(2,`0`)}`;let n=new Intl.DateTimeFormat(`en-US`,{timeZone:t,hourCycle:`h23`,hour:`2-digit`,minute:`2-digit`}).formatToParts(e),r={};for(let e of n)e.type!==`literal`&&(r[e.type]=e.value);return`${r.hour||`00`}:${r.minute||`00`}`}function ne(e,t){try{return new Intl.DateTimeFormat(`en-US`,{timeZone:t,timeZoneName:`short`}).formatToParts(e).find(e=>e.type===`timeZoneName`)?.value||``}catch{return``}}function I(e){if(!e)return n;try{return new Intl.DateTimeFormat(`en-US`,{timeZone:e,day:`numeric`,month:`short`,weekday:`short`})}catch{return n}}function re(e,t){if(!Array.isArray(e?.bookedSlots))return[];let n=t?.id;return e.bookedSlots.filter(e=>!e||typeof e!=`object`?!1:!e.serviceId||!n||e.serviceId===n).map(e=>e.startsAt).filter(Boolean)}function ie(e,t,n=6){if(!t)return[];let r=te(e,t);if(!b(r).length)return[];let i=e.timezone||void 0,a=I(i);return A({availability:r,bookedStarts:re(e,t),exceptions:e.exceptions||[],formatLabel:t=>{let n=i?ne(t,i):``,r=`${a.format(t)} · ${M(F(t,i),e.timeFormat)}`;return n?`${r} ${n}`:r},limit:n,timeZone:i,daysAhead:28,service:{bufferAfterMinutes:Number(t.bufferAfterMinutes)||0,bufferBeforeMinutes:Number(t.bufferBeforeMinutes)||0,durationMinutes:Number(t.durationMinutes)||30,id:t.id,name:t.name}})}function ae(e,t){let n=new Date(e);if(!Number.isFinite(n.getTime()))return``;if(!t)return`${n.getFullYear()}-${String(n.getMonth()+1).padStart(2,`0`)}-${String(n.getDate()).padStart(2,`0`)}`;try{let e=new Intl.DateTimeFormat(`en-CA`,{timeZone:t,year:`numeric`,month:`2-digit`,day:`2-digit`}).formatToParts(n),r=Object.fromEntries(e.filter(e=>e.type!==`literal`).map(e=>[e.type,e.value]));return`${r.year}-${r.month}-${r.day}`}catch{return`${n.getFullYear()}-${String(n.getMonth()+1).padStart(2,`0`)}-${String(n.getDate()).padStart(2,`0`)}`}}function oe(e,t){let n=ie(e,t,200),r=e.timezone||void 0,i=new Map;for(let e of n){let t=ae(e.startsAt,r);t&&(i.has(t)||i.set(t,[]),i.get(t).push({...e,dateKey:t,timeLabel:M(F(new Date(e.startsAt),r),`24h`)}))}return{availableDates:Array.from(i.keys()),slotsByDate:i}}function se(){let e=globalThis.location;if(!e||typeof e!=`object`)return!1;let t=e.hostname,n=e.port;return(t===`localhost`||t===`127.0.0.1`||t===`::1`)&&(n===`5173`||n===`5174`)}function ce(){return se()?`http://127.0.0.1:5174/v1`:e}function L(e){return i(e,ce())}function le(e){return/^#[0-9a-fA-F]{6}$/.test(String(e||``).trim())?String(e).trim():``}function ue(e,t){return e===!0||e===`true`?!0:e===!1||e===`false`?!1:t}function de(e){return Array.isArray(e)?e.map(e=>{if(!e||typeof e!=`object`)return null;let t=i(e.startsAt,``);return t?{serviceId:i(e.serviceId,``),startsAt:t}:null}).filter(Boolean):[]}function fe(e,t){return t===`shopify`||t===`woocommerce`?e.map(e=>({...e,depositCents:0,depositEuros:0})):e}function R(e,t){let n=i(e.commerce,``);return{...e,apiUrl:L(e.apiUrl),bookedSlots:Array.isArray(e.bookedSlots)?e.bookedSlots:[],color:r(e.color),commerce:n,services:fe(e.services,n),loadError:t||``,position:i(e.position,`bottom-right`),showBranding:ue(e.showBranding,!1),title:i(e.title,`Schedule a booking`),timeFormat:a(e.timeFormat),timezone:i(e.timezone,``),trigger:i(e.trigger||e.triggerSelector,``),buttonAlign:i(e.buttonAlign||e.buttonPosition,`left`),variant:n===`shopify`||n===`woocommerce`?`inline`:i(e.variant,`inline`)}}function pe(e){if(!e)return t;try{let n=JSON.parse(e);if(!Array.isArray(n))return t;let r=n.map(e=>({description:i(e.description,``),durationMinutes:Number(e.durationMinutes)||30,id:i(e.id,``),name:i(e.name,``),price:Number(e.price)||0,depositCents:Math.max(0,Number(e.depositCents??Number(e.depositEuros||0)*100)||0),bufferAfterMinutes:Number(e.bufferAfterMinutes)||0,bufferBeforeMinutes:Number(e.bufferBeforeMinutes)||0,useCustomAvailability:!!e.useCustomAvailability,availability:N(e.availability)})).filter(e=>e.name);return r.length>0?r:t}catch{return t}}function me(e,t){let n=i(t,``);if(!n||!Array.isArray(e)||e.length===0)return e;let r=e.filter(e=>i(e.id,``)===n||i(e.name,``)===n);return r.length>0?r:e}function he(e,t){let n=e?e.dataset:{},r=Object.assign({},n,t||{}),a=i(r.service||r.serviceId,``);return{apiUrl:L(r.apiUrl),availability:N(r.availability),color:le(r.color),commerce:i(r.commerce,``),exceptions:P(r.exceptions),position:i(r.position,``),service:a,services:me(Array.isArray(r.services)?r.services:pe(r.services),a),shopifyProduct:i(r.shopifyProduct,``),shopifyShop:i(r.shopifyShop||typeof window<`u`&&window.Shopify&&window.Shopify.shop||``,``),shopifyVariant:i(r.shopifyVariant,``),wooProductId:i(r.wooProductId||r.wooProductid,``),wooAddToCartUrl:i(r.wooAddToCartUrl||r.wooAddToCarturl,``),wooCartNonce:i(r.wooCartNonce||r.wooCartnonce,``),showBranding:ue(r.showBranding,!1),title:i(r.title,``),timeFormat:i(r.timeFormat,``),timezone:i(r.timezone,``),trigger:i(r.trigger||r.triggerSelector,``),buttonAlign:i(r.buttonAlign||r.buttonPosition,``),variant:i(r.variant,``),workspace:i(r.workspace,``)}}function ge(e){let t=e.services.length>0||e.availability.length>0;if(!e.workspace||t||typeof fetch!=`function`)return Promise.resolve(R(e));let n=L(e.apiUrl).replace(/\/$/,``);return fetch(`${n}/widget/${encodeURIComponent(e.workspace)}`,{headers:{Accept:`application/json`}}).then(e=>{if(!e.ok)throw Error(`Widget config unavailable (${e.status})`);return e.json()}).then(t=>{if(t.bookingAllowed===!1)return R(e,`Online booking is temporarily unavailable. Please contact the business directly.`);let n=Array.isArray(t.services)?t.services.map(e=>({availability:N(e.availability),bufferAfterMinutes:Number(e.bufferAfterMinutes)||0,bufferBeforeMinutes:Number(e.bufferBeforeMinutes)||0,description:i(e.description,``),durationMinutes:Number(e.durationMinutes)||30,id:i(e.id,``),name:i(e.name,``),price:Number(e.price)||0,depositCents:Math.max(0,Number(e.depositCents??Number(e.depositEuros||0)*100)||0),useCustomAvailability:!!e.useCustomAvailability})).filter(e=>e.name):[],o=N(t.availability),s=P(t.exceptions),c=me(e.services.length?e.services:n,e.service),l=i(e.commerce,``),u=l===`shopify`||l===`woocommerce`?`inline`:i(t.variant||e.variant,`inline`);return{availability:e.availability.length?e.availability:o,apiUrl:L(e.apiUrl),bookedSlots:de(t.bookedSlots),color:r(t.color||e.color),commerce:l,exceptions:s,position:i(t.position||e.position,`bottom-right`),service:i(e.service,``),services:fe(c,l),shopifyProduct:i(e.shopifyProduct,``),shopifyShop:i(e.shopifyShop,``),shopifyVariant:i(e.shopifyVariant,``),wooProductId:i(e.wooProductId,``),wooAddToCartUrl:i(e.wooAddToCartUrl,``),wooCartNonce:i(e.wooCartNonce,``),showBranding:e.showBranding,title:i(t.title||e.title,`Schedule a booking`),timeFormat:a(t.timeFormat||e.timeFormat),timezone:i(t.timezone||e.timezone,``),trigger:i(t.trigger||e.trigger,``),buttonAlign:i(t.buttonAlign||t.buttonPosition||e.buttonAlign||e.buttonPosition,`left`),variant:u,workspace:e.workspace}}).catch(t=>R(e,t instanceof Error?t.message:`Widget config unavailable`))}function z(e){return!e||typeof e!=`object`?!1:(e.commerce,!!(e.shopifyProduct&&e.shopifyShop&&e.shopifyVariant))}function B(e){return!e||typeof e!=`object`?!1:(e.commerce,!!(e.wooProductId&&e.wooAddToCartUrl&&e.wooCartNonce))}function _e(e){return z(e)||B(e)}function ve(e){try{return new Date(e).toISOString()}catch{return String(e||``)}}function ye(e,{locale:t,timeZone:n}={}){let r=new Date(e);if(Number.isNaN(r.getTime()))return String(e||``);try{return new Intl.DateTimeFormat(t||void 0,{day:`numeric`,hour:`2-digit`,minute:`2-digit`,month:`short`,timeZone:n||void 0,timeZoneName:`short`,weekday:`short`,year:`numeric`}).format(r)}catch{return ve(e)}}async function be({bookingId:e,serviceName:t,startsAt:n,timeZone:r,variantId:i}){let a=Number(String(i).replace(/\D/g,``));if(!Number.isFinite(a)||a<=0)throw Error(`This product is missing a Shopify variant for checkout.`);if(!e)throw Error(`Booking hold could not be created for checkout.`);let o;try{o=await fetch(`/cart/add.js`,{method:`POST`,headers:{Accept:`application/json`,"Content-Type":`application/json`},body:JSON.stringify({items:[{id:a,quantity:1,properties:{_bookminto_booking_id:String(e),_bookminto_starts_at:ve(n),Appointment:ye(n,{timeZone:r}),Service:String(t||`Appointment`)}}]})})}catch{throw Error(`Could not add booking to cart. Please try again.`)}if(!o.ok){let e=await o.json().catch(()=>({}));throw Error(e.description||e.message||`Could not add booking to cart.`)}return window.location.assign(`/checkout`),{redirected:!0}}async function xe({bookingId:e,serviceName:t,startsAt:n,timeZone:r,productId:i,addToCartUrl:a,cartNonce:o}){if(!e)throw Error(`Booking hold could not be created for checkout.`);let s=Number(i);if(!Number.isFinite(s)||s<=0)throw Error(`This product is missing a WooCommerce product id.`);let c=String(a||``).trim();if(!c)throw Error(`WooCommerce cart endpoint is missing.`);let l=new URLSearchParams;l.set(`action`,`bookminto_add_to_cart`),l.set(`nonce`,String(o||``)),l.set(`product_id`,String(s)),l.set(`booking_id`,String(e)),l.set(`appointment`,ye(n,{timeZone:r})),l.set(`service_name`,String(t||`Appointment`));let u;try{u=await fetch(c,{method:`POST`,headers:{Accept:`application/json`,"Content-Type":`application/x-www-form-urlencoded; charset=UTF-8`},body:l,credentials:`same-origin`})}catch{throw Error(`Could not add booking to cart. Please try again.`)}let d=await u.json().catch(()=>({}));if(!u.ok||!d?.success){let e=d?.data?.message||d?.message||`Could not add booking to cart.`;throw Error(e)}let f=d?.data?.checkoutUrl||`/checkout/`;return window.location.assign(f),{redirected:!0}}function Se(e){return String(e.apiUrl||`https://api.bookminto.com/v1`).replace(/\/$/,``)}function Ce(){let e=window.location.hostname;return e===`localhost`||e===`127.0.0.1`||e===`::1`}function we(t){return Se(t)===e.replace(/\/$/,``)}async function Te(e,t){let n=z(e),r=B(e),i=n||r;if(e.previewMode||!e.workspace||typeof fetch!=`function`||Ce()&&we(e)&&!i)return{bookingId:e.previewMode?`preview`:void 0,localOnly:!0,ok:!0};let a;try{a=await fetch(`${Se(e)}/bookings`,{body:JSON.stringify({...t,commerceCheckout:i||void 0,publicKey:e.workspace,shopifyProductGid:n&&e.shopifyProduct||void 0,shopifyShopDomain:n&&e.shopifyShop||void 0,shopifyVariantId:n&&e.shopifyVariant||void 0,wooProductId:r&&e.wooProductId||void 0,siteUrl:window.location.href,source:n?`shopify`:r?`woocommerce`:void 0}),headers:{"Content-Type":`application/json`},method:`POST`})}catch{throw Error(`Booking service unavailable. Please try again.`)}if(!a.ok){let e=await a.json().catch(()=>({}));throw Error(e.error||`Booking could not be created`)}let o=await a.json();return n&&(o.needsCheckout||o.bookingId)?(await be({bookingId:o.bookingId,serviceName:t.serviceName||t.serviceId,startsAt:t.startsAt,timeZone:e.timezone,variantId:e.shopifyVariant}),{...o,redirectedToCheckout:!0}):r&&(o.needsCheckout||o.bookingId)?(await xe({bookingId:o.bookingId,serviceName:t.serviceName||t.serviceId,startsAt:t.startsAt,timeZone:e.timezone,productId:e.wooProductId,addToCartUrl:e.wooAddToCartUrl,cartNonce:e.wooCartNonce}),{...o,redirectedToCheckout:!0}):o}function V(e){return`${e.getUTCFullYear()}${String(e.getUTCMonth()+1).padStart(2,`0`)}${String(e.getUTCDate()).padStart(2,`0`)}T${String(e.getUTCHours()).padStart(2,`0`)}${String(e.getUTCMinutes()).padStart(2,`0`)}${String(e.getUTCSeconds()).padStart(2,`0`)}Z`}function H(e){return String(e||``).replace(/\\/g,`\\\\`).replace(/;/g,`\\;`).replace(/,/g,`\\,`).replace(/\r?\n/g,`\\n`)}function Ee(e){let t=e.startsAt instanceof Date?e.startsAt:new Date(e.startsAt);if(Number.isNaN(t.getTime()))return``;let n=Math.max(1,Number(e.durationMinutes)||30),r=new Date(t.getTime()+n*6e4),i=[`BEGIN:VCALENDAR`,`VERSION:2.0`,`PRODID:-//Bookminto//Booking//EN`,`CALSCALE:GREGORIAN`,`METHOD:PUBLISH`,`BEGIN:VEVENT`,`UID:${`${e.id||Math.random().toString(36).slice(2)}@bookminto`}`,`DTSTAMP:${V(new Date)}`,`DTSTART:${V(t)}`,`DTEND:${V(r)}`,`SUMMARY:${H(e.title)}`];return e.description&&i.push(`DESCRIPTION:${H(e.description)}`),e.location&&i.push(`LOCATION:${H(e.location)}`),i.push(`END:VEVENT`,`END:VCALENDAR`),`data:text/calendar;charset=utf-8,${encodeURIComponent(i.join(`\r2 `))}`}function De(e){let t=e?.payment||e;if(!e?.needsPayment&&!e?.payment)return null;if(!t?.clientSecret||!t?.publishableKey||!(e.bookingId||e.id))throw Error(`A deposit is required, but secure payment could not load. Please try again or contact the business.`);return{bookingId:e.bookingId||e.id,clientSecret:t.clientSecret,depositCents:t.depositCents,publishableKey:t.publishableKey}}function Oe(e,t,n){let r=document.createElement(`p`);r.setAttribute(`role`,`alert`),r.textContent=t instanceof Error?t.message:`Secure payment could not load.`;let i=document.createElement(`button`);i.type=`button`,i.className=`back-btn`,i.textContent=`Try again`,i.addEventListener(`click`,n),e.replaceChildren(r,i)}function ke(){return typeof window>`u`?Promise.reject(Error(`Stripe is unavailable`)):window.Stripe?Promise.resolve(window.Stripe):new Promise((e,t)=>{let n=document.querySelector(`script[data-bookminto-stripe]`);if(n){n.addEventListener(`load`,()=>e(window.Stripe)),n.addEventListener(`error`,()=>t(Error(`Stripe.js failed to load`)));return}let r=document.createElement(`script`);r.src=`https://js.stripe.com/v3/`,r.async=!0,r.dataset.bookmintoStripe=`true`,r.onload=()=>e(window.Stripe),r.onerror=()=>t(Error(`Stripe.js failed to load`)),document.head.appendChild(r)})}async function Ae(e,t){let n=(await ke())(t.publishableKey),r=n.elements({appearance:{theme:`stripe`,variables:{colorPrimary:t.accent||`#E85D4C`,borderRadius:`8px`,fontFamily:`Sora, ui-sans-serif, system-ui, sans-serif`}},clientSecret:t.clientSecret}),i=r.create(`payment`);return i.mount(e),{async confirm(){let e=await n.confirmPayment({elements:r,redirect:`if_required`,confirmParams:{return_url:window.location.href}});if(e.error)throw Error(e.error.message||`Payment failed`);let t=e.paymentIntent;if(!t||t.status!==`succeeded`)throw Error(`Payment is not completed yet`);return t.id},destroy(){try{i.unmount()}catch{}}}}async function je(e,t){let n=String(e.apiUrl||``).replace(/\/$/,``),r=await fetch(`${n}/bookings/confirm-payment`,{body:JSON.stringify({...t,publicKey:e.workspace}),headers:{"Content-Type":`application/json`},method:`POST`});if(!r.ok){let e=await r.json().catch(()=>({}));throw Error(e.error||`Payment confirmation failed`)}return r.json()}function U(e){let t=String(e||``).replace(`#`,``);return/^[0-9a-fA-F]{6}$/.test(t)?{r:Number.parseInt(t.slice(0,2),16),g:Number.parseInt(t.slice(2,4),16),b:Number.parseInt(t.slice(4,6),16)}:null}function Me(e){return Math.max(0,Math.min(255,Math.round(e)))}function W(e,t,n){let r=U(e),i=U(t);if(!r||!i)return e;let a=(e,t)=>Me(e+(t-e)*n);return`#${[a(r.r,i.r),a(r.g,i.g),a(r.b,i.b)].map(e=>e.toString(16).padStart(2,`0`)).join(``)}`}function Ne(e,t){let n=U(e);return n?`rgba(${n.r}, ${n.g}, ${n.b}, ${t})`:`rgba(232, 93, 76, ${t})`}function Pe(e){let t=U(e);if(!t)return`#ffffff`;let n=e=>{let t=e/255;return t<=.04045?t/12.92:((t+.055)/1.055)**2.4};return .2126*n(t.r)+.7152*n(t.g)+.0722*n(t.b)>.42?`#172033`:`#ffffff`}function G(e,t){if(!e)return;if(!t){[`--bm-accent`,`--bm-accent-dark`,`--bm-accent-deep`,`--bm-accent-soft`,`--bm-accent-soft-hover`,`--bm-accent-border`,`--bm-accent-muted`,`--bm-accent-ring`,`--bm-accent-shadow`,`--bm-accent-shadow-hover`,`--bm-accent-contrast`].forEach(t=>e.style.removeProperty(t));return}let n=t,r=W(n,`#000000`,.16),i=W(n,`#000000`,.28),a=W(n,`#ffffff`,.92),o=W(n,`#ffffff`,.97),s=W(n,`#ffffff`,.62),c=W(n,`#ffffff`,.2);e.style.setProperty(`--bm-accent`,n),e.style.setProperty(`--bm-accent-dark`,r),e.style.setProperty(`--bm-accent-deep`,i),e.style.setProperty(`--bm-accent-soft`,a),e.style.setProperty(`--bm-accent-soft-hover`,o),e.style.setProperty(`--bm-accent-border`,s),e.style.setProperty(`--bm-accent-muted`,c),e.style.setProperty(`--bm-accent-ring`,Ne(n,.2)),e.style.setProperty(`--bm-accent-shadow`,Ne(n,.5)),e.style.setProperty(`--bm-accent-shadow-hover`,Ne(n,.65)),e.style.setProperty(`--bm-accent-contrast`,Pe(n))}function Fe(e,t){return e&&t?`${t.label} · ${u(e)}`:`Select a service and time to continue.`}function Ie(e){let t=[[`service`,`Service`],[`time`,`Time`],[`details`,`Details`],[`confirmed`,`Done`]],n=Math.max(0,t.findIndex(([t])=>t===e)),r=t[n][1];return`1 (function(){let e=`https://api.bookminto.com/v1`,t=[],n=new Intl.DateTimeFormat(`en-US`,{day:`numeric`,month:`short`,weekday:`short`}),r={title:`Schedule a booking`,selectServiceAndTime:`Select a service and time to continue.`,servicesLabel:`Services`,serviceSingular:`service`,servicePlural:`services`,selected:`Selected`,choose:`Choose`,timesLabel:`Times`,appointment:`Appointment`,bookingReference:`Booking reference`,done:`Done`,noServiceSelected:`No service selected`,noTimeSelected:`No time selected`,chooseTime:`Choose a time`,viewSetup:`View setup`,paymentLoading:`Loading secure payment…`,paymentError:`Payment could not be confirmed. Try again.`,progressLabel:`Booking progress`,stepLabel:`Step`,stepOf:`of`,widgetStepLabels:[`Service`,`Date`,`Details`,`Confirmed`],chooseService:`Choose an offering`,chooseDate:`Choose a date`,availableTimes:`Available times`,summaryLabel:`Your selection`,totalLabel:`Price`,detailsHeading:`Your details`,previousDates:`Previous dates`,nextDates:`Next dates`,successHeading:`Booking confirmed!`,successNote:`You will receive a confirmation email shortly with your booking details.`,emptySlots:`No times available for this date.`,emptyServices:`No offerings are available right now.`,bookNow:`Book now`,back:`Back`,at:`at`,nameLabel:`Name`,phoneLabel:`Phone`,notesLabel:`Notes`,emailLabel:`Email`,continueFromService:`Select and continue`,continueDefault:`Continue`,checkoutCta:`Continue to checkout`,confirmCta:`Confirm booking`,replacePaymentCta:`Start a new payment`,replacePaymentNote:`The previous payment was canceled. Start a new payment to continue with this same appointment.`,payDepositCta:`Pay deposit`,depositHeading:`Pay deposit to confirm`,depositLabel:`deposit`,depositNote:`Your time is held while you complete payment.`,depositUnavailable:`A deposit is required for this service, but online payment is temporarily unavailable. Please try again later.`,processing:`Processing...`,redirectingToCheckout:`Redirecting to checkout…`,close:`Close`,addToCalendar:`Add to calendar`,widgetFooterPrefix:`Powered by`,widgetFooterBrand:`bookminto`,namePlaceholder:`Jane Smith`,phonePlaceholder:`+1 555 0100`,emailPlaceholder:`jane@company.com`,notesPlaceholder:`Specific questions or context…`,loading:`Loading…`,genericError:`Something went wrong. Please try again.`,closedAttempt:`This booking attempt is closed. Choose a new time to start again.`,loadError:`Booking setup could not be loaded.`,previewOnlyNote:`Preview only — no booking was saved and no email was sent.`,testModeHeading:`Test mode — booking not saved`,testModeNote:`This site is running on a local or test address, so nothing was sent to Bookminto and no confirmation email will arrive.`,questionsHeading:`A few questions`,requiredHint:`required`,yesLabel:`Yes`,noLabel:`No`,chooseOption:`Choose…`,nextTimesLabel:`Next available`,seeAllTimes:`See all times`,noNextTimes:`No upcoming times right now — check all dates.`,recommendedBadge:`Recommended`,waitlistCta:`Notify me when a time opens up`,waitlistHeading:`Join the waitlist`,waitlistNote:`We will email you as soon as a spot opens. First to claim gets it.`,waitlistFrom:`From`,waitlistTo:`Until`,waitlistSubmit:`Keep me posted`,waitlistJoined:`You are on the list. Watch your inbox — a spot can go fast.`,waitlistError:`Could not add you to the list. Please try again.`,rebookingOptInLabel:`Email me an invitation to book again`,phoneCountryLabel:`Country`,phoneRequiredForSms:`Enter a valid phone number to receive text messages.`,phoneRequiredForWhatsapp:`Enter a valid phone number to receive WhatsApp messages.`,smsOptInLabel:`Send me appointment confirmations and reminders by text message.`,whatsappOptInLabel:`Send me appointment confirmations and reminders on WhatsApp.`,depositLoadError:`A deposit is required, but secure payment could not load. Please try again or contact the business.`,paymentLoadFailed:`Secure payment could not load.`,retryCta:`Try again`,staffPickerLabel:`Choose a team member`,anyStaff:`Any team member`,withStaff:`with {name}`,staffUnavailable:`That team member is no longer available at this time. Pick another slot or choose a different member.`,serviceNotBookable:`This service cannot be booked online right now.`},i={...r,title:`Plan een afspraak`,selectServiceAndTime:`Kies een dienst en tijdstip om verder te gaan.`,servicesLabel:`Diensten`,serviceSingular:`dienst`,servicePlural:`diensten`,selected:`Gekozen`,choose:`Kies`,timesLabel:`Tijdstippen`,appointment:`Afspraak`,bookingReference:`Boekingsreferentie`,done:`Klaar`,noServiceSelected:`Geen dienst gekozen`,noTimeSelected:`Geen tijdstip gekozen`,chooseTime:`Kies een tijdstip`,viewSetup:`Bekijk de boekingsopties`,paymentLoading:`Veilig betalen laden…`,paymentError:`De betaling kon niet worden bevestigd. Probeer opnieuw.`,progressLabel:`Voortgang van je boeking`,stepLabel:`Stap`,stepOf:`van`,widgetStepLabels:[`Dienst`,`Datum`,`Gegevens`,`Bevestigd`],chooseService:`Kies een dienst`,chooseDate:`Kies een datum`,availableTimes:`Beschikbare tijden`,summaryLabel:`Jouw keuze`,totalLabel:`Prijs`,detailsHeading:`Jouw gegevens`,previousDates:`Eerdere datums`,nextDates:`Latere datums`,successHeading:`Afspraak bevestigd!`,successNote:`Je ontvangt zo een bevestigingsmail met de details van je afspraak.`,emptySlots:`Geen tijden beschikbaar op deze datum.`,emptyServices:`Er zijn momenteel geen diensten beschikbaar.`,bookNow:`Boek nu`,back:`Terug`,at:`om`,nameLabel:`Naam`,phoneLabel:`Telefoon`,notesLabel:`Opmerkingen`,emailLabel:`E-mail`,continueFromService:`Kies en ga verder`,continueDefault:`Verder`,checkoutCta:`Verder naar afrekenen`,confirmCta:`Bevestig afspraak`,replacePaymentCta:`Start een nieuwe betaling`,replacePaymentNote:`De vorige betaling is geannuleerd. Start een nieuwe betaling om dezelfde afspraak af te ronden.`,payDepositCta:`Betaal voorschot`,depositHeading:`Betaal het voorschot om te bevestigen`,depositLabel:`voorschot`,depositNote:`Je tijdstip wordt vastgehouden terwijl je betaalt.`,depositUnavailable:`Voor deze dienst is een voorschot vereist, maar online betalen is tijdelijk niet beschikbaar. Probeer het later opnieuw.`,processing:`Bezig…`,redirectingToCheckout:`Doorsturen naar afrekenen…`,close:`Sluiten`,addToCalendar:`Zet in agenda`,widgetFooterPrefix:`Mogelijk gemaakt door`,namePlaceholder:`Jan Jansen`,emailPlaceholder:`jan@voorbeeld.be`,notesPlaceholder:`Specifieke vragen of context…`,loading:`Laden…`,genericError:`Er ging iets mis. Probeer het opnieuw.`,closedAttempt:`Deze boekingspoging is afgesloten. Kies een nieuw tijdstip om opnieuw te beginnen.`,loadError:`De boekingssetup kon niet geladen worden.`,previewOnlyNote:`Alleen voorbeeld — er is geen boeking opgeslagen en geen e-mail verstuurd.`,testModeHeading:`Testmodus — boeking niet opgeslagen`,testModeNote:`Deze site draait op een lokale of testomgeving, dus er is niets naar Bookminto verstuurd en er komt geen bevestigingsmail.`,questionsHeading:`Nog een paar vragen`,requiredHint:`verplicht`,yesLabel:`Ja`,noLabel:`Nee`,chooseOption:`Kies…`,nextTimesLabel:`Eerstvolgende`,seeAllTimes:`Alle tijden`,noNextTimes:`Momenteel geen vrije tijden — bekijk alle datums.`,recommendedBadge:`Aanbevolen`,waitlistCta:`Laat het weten als er een plekje vrijkomt`,waitlistHeading:`Zet me op de wachtlijst`,waitlistNote:`We mailen je zodra er een plekje vrijkomt. Wie het eerst bevestigt, krijgt het.`,waitlistFrom:`Vanaf`,waitlistTo:`Tot`,waitlistSubmit:`Houd me op de hoogte`,waitlistJoined:`Je staat op de lijst. Houd je inbox in de gaten — een plekje kan snel weg zijn.`,waitlistError:`Dat lukte niet. Probeer het opnieuw.`,rebookingOptInLabel:`Mail me een uitnodiging om opnieuw te boeken`,phoneCountryLabel:`Land`,phoneRequiredForSms:`Vul een geldig telefoonnummer in om sms’en te ontvangen.`,phoneRequiredForWhatsapp:`Vul een geldig telefoonnummer in om WhatsApp-berichten te ontvangen.`,smsOptInLabel:`Stuur mij bevestigingen en herinneringen van mijn afspraak per sms.`,whatsappOptInLabel:`Stuur mij bevestigingen en herinneringen van mijn afspraak via WhatsApp.`,depositLoadError:`Er is een voorschot vereist, maar de beveiligde betaling kon niet laden. Probeer het opnieuw of neem contact op met het bedrijf.`,paymentLoadFailed:`De beveiligde betaling kon niet laden.`,retryCta:`Opnieuw proberen`,staffPickerLabel:`Kies een teamlid`,anyStaff:`Geen voorkeur`,withStaff:`met {name}`,staffUnavailable:`Dit teamlid is niet meer beschikbaar op dit tijdstip. Kies een ander tijdstip of een ander teamlid.`,serviceNotBookable:`Deze dienst kan momenteel niet online geboekt worden.`},a={...r,title:`Prendre rendez-vous`,selectServiceAndTime:`Choisissez une prestation et un horaire pour continuer.`,servicesLabel:`Prestations`,serviceSingular:`prestation`,servicePlural:`prestations`,selected:`Sélectionné`,choose:`Choisir`,timesLabel:`Horaires`,appointment:`Rendez-vous`,bookingReference:`Référence de réservation`,done:`Terminé`,noServiceSelected:`Aucune prestation sélectionnée`,noTimeSelected:`Aucun horaire sélectionné`,chooseTime:`Choisir un horaire`,viewSetup:`Voir les options de réservation`,paymentLoading:`Chargement du paiement sécurisé…`,paymentError:`Le paiement n’a pas pu être confirmé. Réessayez.`,progressLabel:`Progression de la réservation`,stepLabel:`Étape`,stepOf:`sur`,widgetStepLabels:[`Prestation`,`Date`,`Coordonnées`,`Confirmé`],chooseService:`Choisissez une prestation`,chooseDate:`Choisissez une date`,availableTimes:`Horaires disponibles`,summaryLabel:`Votre sélection`,totalLabel:`Prix`,detailsHeading:`Vos coordonnées`,previousDates:`Dates précédentes`,nextDates:`Dates suivantes`,successHeading:`Rendez-vous confirmé !`,successNote:`Vous recevrez sous peu un e-mail de confirmation avec les détails.`,emptySlots:`Aucun horaire disponible à cette date.`,emptyServices:`Aucune prestation disponible pour le moment.`,bookNow:`Réserver`,back:`Retour`,at:`à`,nameLabel:`Nom`,phoneLabel:`Téléphone`,notesLabel:`Remarques`,emailLabel:`E-mail`,continueFromService:`Choisir et continuer`,continueDefault:`Continuer`,checkoutCta:`Passer au paiement`,confirmCta:`Confirmer le rendez-vous`,replacePaymentCta:`Commencer un nouveau paiement`,replacePaymentNote:`Le paiement précédent a été annulé. Commencez un nouveau paiement pour terminer ce même rendez-vous.`,payDepositCta:`Payer l'acompte`,depositHeading:`Payez l'acompte pour confirmer`,depositLabel:`acompte`,depositNote:`Votre créneau est réservé pendant le paiement.`,depositUnavailable:`Un acompte est requis pour ce service, mais le paiement en ligne est temporairement indisponible. Veuillez réessayer plus tard.`,processing:`Traitement…`,redirectingToCheckout:`Redirection vers le paiement…`,close:`Fermer`,addToCalendar:`Ajouter au calendrier`,widgetFooterPrefix:`Propulsé par`,namePlaceholder:`Marie Dupont`,emailPlaceholder:`marie@exemple.fr`,notesPlaceholder:`Questions ou contexte particulier…`,loading:`Chargement…`,genericError:`Une erreur est survenue. Veuillez réessayer.`,closedAttempt:`Cette tentative de réservation est terminée. Choisissez un nouvel horaire pour recommencer.`,loadError:`La configuration de réservation n'a pas pu être chargée.`,previewOnlyNote:`Aperçu uniquement — aucune réservation enregistrée, aucun e-mail envoyé.`,testModeHeading:`Mode test — réservation non enregistrée`,testModeNote:`Ce site fonctionne sur une adresse locale ou de test : rien n'a été envoyé à Bookminto et aucun e-mail de confirmation n'arrivera.`,questionsHeading:`Quelques questions`,requiredHint:`obligatoire`,yesLabel:`Oui`,noLabel:`Non`,chooseOption:`Choisir…`,nextTimesLabel:`Prochaines disponibilités`,seeAllTimes:`Tous les horaires`,noNextTimes:`Aucun horaire pour le moment — voir toutes les dates.`,recommendedBadge:`Recommandé`,waitlistCta:`Prévenez-moi quand un créneau se libère`,waitlistHeading:`Rejoindre la liste d’attente`,waitlistNote:`Nous vous écrivons dès qu’un créneau se libère. Le premier à confirmer l’obtient.`,waitlistFrom:`À partir du`,waitlistTo:`Jusqu’au`,waitlistSubmit:`Me tenir informé`,waitlistJoined:`Vous êtes sur la liste. Surveillez votre boîte — un créneau part vite.`,waitlistError:`Impossible de vous ajouter. Réessayez.`,rebookingOptInLabel:`M’envoyer une invitation pour réserver à nouveau`,phoneCountryLabel:`Pays`,phoneRequiredForSms:`Indiquez un numéro de téléphone valide pour recevoir des SMS.`,phoneRequiredForWhatsapp:`Indiquez un numéro de téléphone valide pour recevoir des messages WhatsApp.`,smsOptInLabel:`Recevoir les confirmations et rappels de mon rendez-vous par SMS.`,whatsappOptInLabel:`Recevoir les confirmations et rappels de mon rendez-vous par WhatsApp.`,depositLoadError:`Un acompte est requis, mais le paiement sécurisé n’a pas pu se charger. Réessayez ou contactez l’entreprise.`,paymentLoadFailed:`Le paiement sécurisé n’a pas pu se charger.`,retryCta:`Réessayer`,staffPickerLabel:`Choisissez un membre de l’équipe`,anyStaff:`Sans préférence`,withStaff:`avec {name}`,staffUnavailable:`Ce membre de l’équipe n’est plus disponible à cet horaire. Choisissez un autre horaire ou un autre membre.`,serviceNotBookable:`Cette prestation n’est pas réservable en ligne pour le moment.`},o={de:{...r,title:`Termin buchen`,selectServiceAndTime:`Wählen Sie eine Leistung und eine Uhrzeit, um fortzufahren.`,servicesLabel:`Leistungen`,serviceSingular:`Leistung`,servicePlural:`Leistungen`,selected:`Ausgewählt`,choose:`Wählen`,timesLabel:`Uhrzeiten`,appointment:`Termin`,bookingReference:`Buchungsnummer`,done:`Fertig`,noServiceSelected:`Keine Leistung ausgewählt`,noTimeSelected:`Keine Uhrzeit ausgewählt`,chooseTime:`Uhrzeit wählen`,viewSetup:`Buchungsoptionen ansehen`,paymentLoading:`Sichere Zahlung wird geladen…`,paymentError:`Die Zahlung konnte nicht bestätigt werden. Bitte erneut versuchen.`,progressLabel:`Buchungsfortschritt`,stepLabel:`Schritt`,stepOf:`von`,widgetStepLabels:[`Leistung`,`Datum`,`Angaben`,`Bestätigt`],chooseService:`Leistung wählen`,chooseDate:`Datum wählen`,availableTimes:`Verfügbare Zeiten`,summaryLabel:`Ihre Auswahl`,totalLabel:`Preis`,detailsHeading:`Ihre Angaben`,previousDates:`Vorherige Daten`,nextDates:`Nächste Daten`,successHeading:`Buchung bestätigt!`,successNote:`Sie erhalten in Kürze eine Bestätigungs-E-Mail mit Ihren Buchungsdetails.`,emptySlots:`An diesem Datum sind keine Zeiten verfügbar.`,emptyServices:`Derzeit sind keine Leistungen verfügbar.`,bookNow:`Jetzt buchen`,back:`Zurück`,at:`um`,nameLabel:`Name`,phoneLabel:`Telefon`,notesLabel:`Anmerkungen`,emailLabel:`E-Mail`,continueFromService:`Auswählen und weiter`,continueDefault:`Weiter`,checkoutCta:`Weiter zur Kasse`,confirmCta:`Buchung bestätigen`,replacePaymentCta:`Neue Zahlung starten`,replacePaymentNote:`Die vorherige Zahlung wurde abgebrochen. Starten Sie eine neue Zahlung, um denselben Termin abzuschließen.`,payDepositCta:`Anzahlung zahlen`,depositHeading:`Anzahlung zur Bestätigung zahlen`,depositLabel:`Anzahlung`,depositNote:`Ihr Termin bleibt während der Zahlung reserviert.`,depositUnavailable:`Für diese Leistung ist eine Anzahlung erforderlich, Online-Zahlung ist derzeit nicht verfügbar. Bitte versuchen Sie es später erneut.`,processing:`Wird verarbeitet…`,redirectingToCheckout:`Weiterleitung zur Kasse…`,close:`Schließen`,addToCalendar:`Zum Kalender hinzufügen`,widgetFooterPrefix:`Bereitgestellt von`,namePlaceholder:`Maria Müller`,phonePlaceholder:`+49 170 1234567`,emailPlaceholder:`sie@beispiel.de`,notesPlaceholder:`Spezifische Fragen oder Kontext…`,loading:`Wird geladen…`,genericError:`Etwas ist schiefgelaufen. Bitte versuchen Sie es erneut.`,closedAttempt:`Dieser Buchungsversuch ist abgeschlossen. Wählen Sie eine neue Uhrzeit, um erneut zu beginnen.`,loadError:`Die Buchungseinstellungen konnten nicht geladen werden.`,previewOnlyNote:`Nur Vorschau — es wurde keine Buchung gespeichert und keine E-Mail gesendet.`,testModeHeading:`Testmodus — Buchung nicht gespeichert`,testModeNote:`Diese Seite läuft auf einer lokalen oder Testadresse, daher wurde nichts an Bookminto gesendet und es kommt keine Bestätigungs-E-Mail.`,questionsHeading:`Ein paar Fragen`,requiredHint:`Pflichtfeld`,yesLabel:`Ja`,noLabel:`Nein`,chooseOption:`Wählen…`,nextTimesLabel:`Nächste freie Zeiten`,seeAllTimes:`Alle Zeiten`,noNextTimes:`Derzeit keine freien Zeiten — alle Daten ansehen.`,recommendedBadge:`Empfohlen`,waitlistCta:`Benachrichtigen Sie mich, wenn ein Termin frei wird`,waitlistHeading:`Auf die Warteliste`,waitlistNote:`Wir schreiben Ihnen, sobald ein Platz frei wird. Wer zuerst bestätigt, bekommt ihn.`,waitlistFrom:`Von`,waitlistTo:`Bis`,waitlistSubmit:`Auf dem Laufenden halten`,waitlistJoined:`Sie stehen auf der Liste. Behalten Sie Ihr Postfach im Blick — ein Platz ist schnell vergeben.`,waitlistError:`Das hat nicht geklappt. Bitte versuchen Sie es erneut.`,rebookingOptInLabel:`Mir eine Einladung zur erneuten Buchung mailen`,phoneCountryLabel:`Land`,phoneRequiredForSms:`Geben Sie eine gültige Telefonnummer ein, um SMS zu erhalten.`,phoneRequiredForWhatsapp:`Geben Sie eine gültige Telefonnummer ein, um WhatsApp-Nachrichten zu erhalten.`,smsOptInLabel:`Senden Sie mir Terminbestätigungen und -erinnerungen per SMS.`,whatsappOptInLabel:`Senden Sie mir Terminbestätigungen und -erinnerungen per WhatsApp.`,depositLoadError:`Eine Anzahlung ist erforderlich, aber die sichere Zahlung konnte nicht geladen werden. Bitte versuchen Sie es erneut oder kontaktieren Sie den Betrieb.`,paymentLoadFailed:`Die sichere Zahlung konnte nicht geladen werden.`,retryCta:`Erneut versuchen`,staffPickerLabel:`Teammitglied wählen`,anyStaff:`Keine Präferenz`,withStaff:`mit {name}`,staffUnavailable:`Dieses Teammitglied ist zu dieser Zeit nicht mehr verfügbar. Wählen Sie eine andere Uhrzeit oder ein anderes Mitglied.`,serviceNotBookable:`Diese Leistung ist derzeit nicht online buchbar.`},en:r,es:{...r,title:`Reserva una cita`,selectServiceAndTime:`Elige un servicio y una hora para continuar.`,servicesLabel:`Servicios`,serviceSingular:`servicio`,servicePlural:`servicios`,selected:`Seleccionado`,choose:`Elegir`,timesLabel:`Horarios`,appointment:`Cita`,bookingReference:`Referencia de reserva`,done:`Listo`,noServiceSelected:`Ningún servicio seleccionado`,noTimeSelected:`Ninguna hora seleccionada`,chooseTime:`Elige una hora`,viewSetup:`Ver opciones de reserva`,paymentLoading:`Cargando pago seguro…`,paymentError:`No se pudo confirmar el pago. Inténtalo de nuevo.`,progressLabel:`Progreso de la reserva`,stepLabel:`Paso`,stepOf:`de`,widgetStepLabels:[`Servicio`,`Fecha`,`Datos`,`Confirmado`],chooseService:`Elige un servicio`,chooseDate:`Elige una fecha`,availableTimes:`Horarios disponibles`,summaryLabel:`Tu selección`,totalLabel:`Precio`,detailsHeading:`Tus datos`,previousDates:`Fechas anteriores`,nextDates:`Fechas siguientes`,successHeading:`¡Reserva confirmada!`,successNote:`Recibirás en breve un correo de confirmación con los detalles de tu reserva.`,emptySlots:`No hay horarios disponibles en esta fecha.`,emptyServices:`No hay servicios disponibles ahora mismo.`,bookNow:`Reservar ahora`,back:`Volver`,at:`a las`,nameLabel:`Nombre`,phoneLabel:`Teléfono`,notesLabel:`Notas`,emailLabel:`Correo electrónico`,continueFromService:`Elegir y continuar`,continueDefault:`Continuar`,checkoutCta:`Ir al pago`,confirmCta:`Confirmar reserva`,replacePaymentCta:`Iniciar un nuevo pago`,replacePaymentNote:`El pago anterior se canceló. Inicia un nuevo pago para terminar esta misma cita.`,payDepositCta:`Pagar depósito`,depositHeading:`Paga el depósito para confirmar`,depositLabel:`depósito`,depositNote:`Tu horario queda reservado mientras completas el pago.`,depositUnavailable:`Este servicio requiere un depósito, pero el pago online no está disponible ahora. Inténtalo más tarde.`,processing:`Procesando…`,redirectingToCheckout:`Redirigiendo al pago…`,close:`Cerrar`,addToCalendar:`Añadir al calendario`,widgetFooterPrefix:`Con tecnología de`,namePlaceholder:`María García`,phonePlaceholder:`+34 600 123 456`,emailPlaceholder:`tu@ejemplo.es`,notesPlaceholder:`Preguntas o contexto específico…`,loading:`Cargando…`,genericError:`Algo salió mal. Inténtalo de nuevo.`,closedAttempt:`Este intento de reserva está cerrado. Elige una nueva hora para empezar de nuevo.`,loadError:`No se pudo cargar la configuración de reserva.`,previewOnlyNote:`Solo vista previa — no se guardó ninguna reserva ni se envió correo.`,testModeHeading:`Modo prueba — reserva no guardada`,testModeNote:`Este sitio funciona en una dirección local o de prueba, así que no se envió nada a Bookminto y no llegará correo de confirmación.`,questionsHeading:`Unas preguntas`,requiredHint:`obligatorio`,yesLabel:`Sí`,noLabel:`No`,chooseOption:`Elegir…`,nextTimesLabel:`Próximos horarios`,seeAllTimes:`Todos los horarios`,noNextTimes:`No hay horarios próximos ahora — revisa todas las fechas.`,recommendedBadge:`Recomendado`,waitlistCta:`Avísame cuando se libere un hueco`,waitlistHeading:`Únete a la lista de espera`,waitlistNote:`Te escribiremos en cuanto se libere un hueco. El primero en confirmar se lo queda.`,waitlistFrom:`Desde`,waitlistTo:`Hasta`,waitlistSubmit:`Mantenme al tanto`,waitlistJoined:`Ya estás en la lista. Revisa tu correo — un hueco vuela.`,waitlistError:`No se pudo añadirte a la lista. Inténtalo de nuevo.`,rebookingOptInLabel:`Enviarme una invitación por correo para volver a reservar`,phoneCountryLabel:`País`,phoneRequiredForSms:`Introduce un número de teléfono válido para recibir SMS.`,phoneRequiredForWhatsapp:`Introduce un número de teléfono válido para recibir mensajes de WhatsApp.`,smsOptInLabel:`Enviarme confirmaciones y recordatorios de mi cita por SMS.`,whatsappOptInLabel:`Enviarme confirmaciones y recordatorios de mi cita por WhatsApp.`,depositLoadError:`Se requiere un depósito, pero el pago seguro no se pudo cargar. Inténtalo de nuevo o contacta con el negocio.`,paymentLoadFailed:`No se pudo cargar el pago seguro.`,retryCta:`Reintentar`,staffPickerLabel:`Elige un miembro del equipo`,anyStaff:`Sin preferencia`,withStaff:`con {name}`,staffUnavailable:`Ese miembro del equipo ya no está disponible a esa hora. Elige otro horario u otro miembro.`,serviceNotBookable:`Este servicio no se puede reservar online ahora mismo.`},fr:a,it:{...r,title:`Prenota un appuntamento`,selectServiceAndTime:`Scegli un servizio e un orario per continuare.`,servicesLabel:`Servizi`,serviceSingular:`servizio`,servicePlural:`servizi`,selected:`Selezionato`,choose:`Scegli`,timesLabel:`Orari`,appointment:`Appuntamento`,bookingReference:`Riferimento prenotazione`,done:`Fatto`,noServiceSelected:`Nessun servizio selezionato`,noTimeSelected:`Nessun orario selezionato`,chooseTime:`Scegli un orario`,viewSetup:`Vedi le opzioni di prenotazione`,paymentLoading:`Caricamento pagamento sicuro…`,paymentError:`Il pagamento non è stato confermato. Riprova.`,progressLabel:`Avanzamento della prenotazione`,stepLabel:`Passaggio`,stepOf:`di`,widgetStepLabels:[`Servizio`,`Data`,`Dati`,`Confermato`],chooseService:`Scegli un servizio`,chooseDate:`Scegli una data`,availableTimes:`Orari disponibili`,summaryLabel:`La tua selezione`,totalLabel:`Prezzo`,detailsHeading:`I tuoi dati`,previousDates:`Date precedenti`,nextDates:`Date successive`,successHeading:`Prenotazione confermata!`,successNote:`Riceverai a breve un’e-mail di conferma con i dettagli della prenotazione.`,emptySlots:`Nessun orario disponibile in questa data.`,emptyServices:`Nessun servizio disponibile al momento.`,bookNow:`Prenota ora`,back:`Indietro`,at:`alle`,nameLabel:`Nome`,phoneLabel:`Telefono`,notesLabel:`Note`,emailLabel:`E-mail`,continueFromService:`Scegli e continua`,continueDefault:`Continua`,checkoutCta:`Vai al pagamento`,confirmCta:`Conferma prenotazione`,replacePaymentCta:`Avvia un nuovo pagamento`,replacePaymentNote:`Il pagamento precedente è stato annullato. Avvia un nuovo pagamento per completare lo stesso appuntamento.`,payDepositCta:`Paga l’acconto`,depositHeading:`Paga l’acconto per confermare`,depositLabel:`acconto`,depositNote:`Il tuo orario resta riservato mentre completi il pagamento.`,depositUnavailable:`Questo servizio richiede un acconto, ma il pagamento online non è disponibile al momento. Riprova più tardi.`,processing:`Elaborazione…`,redirectingToCheckout:`Reindirizzamento al pagamento…`,close:`Chiudi`,addToCalendar:`Aggiungi al calendario`,widgetFooterPrefix:`Offerto da`,namePlaceholder:`Maria Rossi`,phonePlaceholder:`+39 333 123 4567`,emailPlaceholder:`tu@esempio.it`,notesPlaceholder:`Domande o contesto specifico…`,loading:`Caricamento…`,genericError:`Qualcosa è andato storto. Riprova.`,closedAttempt:`Questo tentativo di prenotazione è chiuso. Scegli un nuovo orario per ricominciare.`,loadError:`Impossibile caricare la configurazione di prenotazione.`,previewOnlyNote:`Solo anteprima — nessuna prenotazione salvata e nessuna e-mail inviata.`,testModeHeading:`Modalità test — prenotazione non salvata`,testModeNote:`Questo sito gira su un indirizzo locale o di test, quindi non è stato inviato nulla a Bookminto e non arriverà alcuna e-mail di conferma.`,questionsHeading:`Qualche domanda`,requiredHint:`obbligatorio`,yesLabel:`Sì`,noLabel:`No`,chooseOption:`Scegli…`,nextTimesLabel:`Prossimi disponibili`,seeAllTimes:`Tutti gli orari`,noNextTimes:`Nessun orario in arrivo al momento — vedi tutte le date.`,recommendedBadge:`Consigliato`,waitlistCta:`Avvisami quando si libera un posto`,waitlistHeading:`Entra in lista d’attesa`,waitlistNote:`Ti scriveremo appena si libera un posto. Chi conferma per primo lo ottiene.`,waitlistFrom:`Dal`,waitlistTo:`Al`,waitlistSubmit:`Tienimi aggiornato`,waitlistJoined:`Sei in lista. Tieni d’occhio la casella — un posto fila via in fretta.`,waitlistError:`Impossibile aggiungerti alla lista. Riprova.`,rebookingOptInLabel:`Inviami un’e-mail con un invito a prenotare di nuovo`,phoneCountryLabel:`Paese`,phoneRequiredForSms:`Inserisci un numero di telefono valido per ricevere gli SMS.`,phoneRequiredForWhatsapp:`Inserisci un numero di telefono valido per ricevere i messaggi WhatsApp.`,smsOptInLabel:`Inviami conferme e promemoria del mio appuntamento via SMS.`,whatsappOptInLabel:`Inviami conferme e promemoria del mio appuntamento su WhatsApp.`,depositLoadError:`È richiesto un acconto, ma il pagamento sicuro non è stato caricato. Riprova o contatta l’attività.`,paymentLoadFailed:`Impossibile caricare il pagamento sicuro.`,retryCta:`Riprova`,staffPickerLabel:`Scegli un membro del team`,anyStaff:`Nessuna preferenza`,withStaff:`con {name}`,staffUnavailable:`Questo membro del team non è più disponibile a quell’orario. Scegli un altro orario o un altro membro.`,serviceNotBookable:`Questo servizio non è prenotabile online al momento.`},nl:i};function s(e){let t=String(e||``).trim().toLowerCase();return Object.hasOwn(o,t)?t:`en`}function c(e){return o[s(e)]}let l={de:`de-DE`,en:`en-US`,es:`es-ES`,fr:`fr-BE`,it:`it-IT`,nl:`nl-BE`};function u(e){return l[s(e)]||`en-US`}function d(e){return/^#[0-9a-fA-F]{6}$/.test(String(e||``).trim())?String(e).trim():`#E85D4C`}function f(e,t){return String(e||``).trim()||t}function p(e){return e===`24h`?`24h`:`12h`}let m={de:`Std.`,en:`hr`,es:`h`,fr:`h`,it:`h`,nl:`u`},h={de:`Kostenlos`,en:`Free`,es:`Gratis`,fr:`Gratuit`,it:`Gratuito`,nl:`Gratis`},g={de:`de-DE`,en:`en-US`,es:`es-ES`,fr:`fr-BE`,it:`it-IT`,nl:`nl-BE`},_={de:`Anzahlung zur Bestätigung`,en:`deposit to confirm`,es:`depósito para confirmar`,fr:`acompte pour confirmer`,it:`acconto per confermare`,nl:`voorschot om te bevestigen`},v={de:[`Zeiten in`,`Zeiten in Ihrer lokalen Zeitzone`],en:[`Times in`,`Times in your local timezone`],es:[`Horarios en`,`Horarios en tu zona horaria`],fr:[`Horaires en`,`Horaires dans votre fuseau horaire`],it:[`Orari in`,`Orari nel tuo fuso orario`],nl:[`Tijden in`,`Tijden in je lokale tijdzone`]};function y(e,t=`en`){let n=Number.isFinite(Number(e))?Number(e):30;if(n<60)return`${n} min`;let r=Math.floor(n/60),i=n%60,a=m[t]||`hr`;return i===0?`${r} ${a}`:`${r}${t===`nl`?`u`:`h`} ${i}m`}function b(e,t=`EUR`,n=`en`){let r=Number(e);return!Number.isFinite(r)||r===0?h[n]||`Free`:new Intl.NumberFormat(g[n]||`en-US`,{currency:t,maximumFractionDigits:2,minimumFractionDigits:Number.isInteger(r)?0:2,style:`currency`}).format(r)}function x(e,t=`en`){let n=f(e&&e.description,``);return n?`${y(e.durationMinutes,t)} · ${n}`:y(e.durationMinutes,t)}function S(e){return String(e).replace(/&/g,`&`).replace(/</g,`<`).replace(/>/g,`>`).replace(/"/g,`"`).replace(/'/g,`'`)}function C(e,t=`en`){return e?`${y(e.durationMinutes,t)} · ${w(e,t)}`:`No services available yet`}function w(e,t=`en`){let n=b(e?.price,e?.currency||`EUR`,t),r=Number(e?.depositEuros??Number(e?.depositCents)/100),i=_[t]||`deposit to confirm`;return Number.isFinite(r)&&r>0?`${n} · ${b(r,e?.currency||`EUR`,t)} ${i}`:n}function ee(e){let t=v[e.language]||v.en;return e.timezone?`${t[0]} ${String(e.timezone).replaceAll(`_`,` `)}`:t[1]}function T(e,t){let n=new Intl.DateTimeFormat(`en-US`,{timeZone:t,hourCycle:`h23`,year:`numeric`,month:`2-digit`,day:`2-digit`,hour:`2-digit`,minute:`2-digit`,second:`2-digit`}).formatToParts(new Date(e)),r={};for(let e of n)e.type!==`literal`&&(r[e.type]=e.value);return Date.UTC(Number(r.year),Number(r.month)-1,Number(r.day),Number(r.hour),Number(r.minute),Number(r.second))-e}function E(e,t){try{let n=new Intl.DateTimeFormat(`en-US`,{timeZone:t,hourCycle:`h23`,year:`numeric`,month:`2-digit`,day:`2-digit`,hour:`2-digit`,minute:`2-digit`}).formatToParts(e),r={};for(let e of n)e.type!==`literal`&&(r[e.type]=e.value);let i=Number(r.hour)*60+Number(r.minute);return!r.year||!r.month||!r.day||!Number.isFinite(i)?null:{dateKey:`${r.year}-${r.month}-${r.day}`,minutes:i}}catch{return null}}function D(e,t,n){let[r,i,a]=e.split(`-`).map(Number),o=Math.floor(t/60),s=t%60,c=Date.UTC(r,i-1,a,o,s,0),l=T(c,n),u=c-l,d=T(u,n);d!==l&&(u=c-d);let f=E(new Date(u),n),p=L(e,Math.floor(t/1440)),m=t%1440;return!f||f.dateKey!==p||f.minutes!==m?new Date(NaN):new Date(u)}function O(e,t,n){if(n)return D(e,t,n);let r=String(Math.floor(t/60)).padStart(2,`0`),i=String(t%60).padStart(2,`0`);return new Date(`${e}T${r}:${i}:00`)}function k(e){let t=String(e||``).match(/^(\d{2}):(\d{2})$/);return t?Number(t[1])*60+Number(t[2]):NaN}function A(e){return Number.isFinite(e)?Math.max(0,Math.round(Number(e))):0}function j(e){return Math.max(1,A(e?.durationMinutes)+A(e?.bufferBeforeMinutes)+A(e?.bufferAfterMinutes))}function M(e){return Math.max(15,A(e))}function N(e){return Array.isArray(e)?e.flatMap(e=>e?.enabled?(Array.isArray(e.blocks)?e.blocks:[]).filter(e=>{let t=k(e.startTime??e.start),n=k(e.endTime??e.end);return Number.isFinite(t)&&Number.isFinite(n)&&n>t}).map(t=>({endTime:t.endTime??t.end,startTime:t.startTime??t.start,weekday:Number(e.weekday)})):[]):[]}function P(e){return`${e.getFullYear()}-${String(e.getMonth()+1).padStart(2,`0`)}-${String(e.getDate()).padStart(2,`0`)}`}function F(e,t){if(!t)return P(e);try{let n=new Intl.DateTimeFormat(`en-CA`,{timeZone:t,year:`numeric`,month:`2-digit`,day:`2-digit`}).formatToParts(e),r={};for(let e of n)e.type!==`literal`&&(r[e.type]=e.value);return!r.year||!r.month||!r.day?P(e):`${r.year}-${r.month}-${r.day}`}catch{return P(e)}}function I(e){let[t,n,r]=e.split(`-`).map(Number);return new Date(Date.UTC(t,n-1,r,12)).getUTCDay()}function L(e,t){let[n,r,i]=e.split(`-`).map(Number),a=new Date(Date.UTC(n,r-1,i+t,12));return`${a.getUTCFullYear()}-${String(a.getUTCMonth()+1).padStart(2,`0`)}-${String(a.getUTCDate()).padStart(2,`0`)}`}function R(e,t,n,r){let i=k(t),a=k(n);if(i===null||Number.isNaN(i)||a===null||Number.isNaN(a)||a<=i)return null;let o=O(e,i,r),s=O(e,a,r);return Number.isNaN(o.getTime())||Number.isNaN(s.getTime())||o>=s?null:{end:s,endMinutes:a,start:o,startMinutes:i}}function te(e,t){return(e||[]).filter(e=>e?.date===t)}function z(e){return e.some(e=>e.exceptionType===`block`&&(!e.startTime||!e.endTime))}function B(e,t,n,r,i){let a=n.filter(e=>e.weekday===t).map(t=>R(e,t.startTime,t.endTime,i)).filter(Boolean);for(let t of r){if(t.exceptionType!==`extra_open`)continue;let n=R(e,t.startTime,t.endTime,i);n&&a.push(n)}return a.sort((e,t)=>e.start.getTime()-t.start.getTime())}function V(e,t,n){return t.filter(e=>e.exceptionType===`block`&&e.startTime&&e.endTime).map(t=>R(e,t.startTime,t.endTime,n)).filter(Boolean)}function ne(e,t,n){return n.some(n=>e.getTime()<n.end.getTime()&&t.getTime()>n.start.getTime())}function H(e,t,n){return n.some(n=>e.getTime()>=n.start.getTime()&&t.getTime()<=n.end.getTime())}function U(e,t,n){return e<t||n<=0?!1:(e-t)%n===0}function re(e){let t=new Set;for(let n of Array.isArray(e)?e:[]){let e=n instanceof Date?n:new Date(n);Number.isNaN(e.getTime())||t.add(e.toISOString())}return t}function W(e){let t=[];for(let n of Array.isArray(e)?e:[]){let e=new Date(n?.start??n?.startsAt),r=new Date(n?.end??n?.endsAt??n?.start??n?.startsAt);!Number.isNaN(e.getTime())&&!Number.isNaN(r.getTime())&&t.push({end:r.getTime(),staffId:typeof n?.staffId==`string`&&n.staffId?n.staffId:null,start:e.getTime()})}return t}function ie({exceptions:e,occupiedRanges:t,rules:n,service:r,staff:i,staffAvailability:a,staffId:o}){let s=(Array.isArray(i)?i:[]).map(e=>typeof e==`string`?e:e?.id).filter(e=>typeof e==`string`&&e),c=Array.isArray(r?.staffIds)?new Set(r.staffIds):null,l=c?s.filter(e=>c.has(e)):s,u=typeof o==`string`&&o?o:null;return u?l.includes(u)?[G(u,e,t,n,a)]:[]:s.length?l.length?l.map(r=>G(r,e,t,n,a)):[]:[{exceptions:e,id:null,occupied:t,rules:n,staffExceptions:[],staffRules:null}]}function G(e,t,n,r,i){let a=i&&typeof i==`object`?i[e]:null;return{exceptions:t,id:e,occupied:n.filter(t=>!t.staffId||t.staffId===e),rules:r,staffExceptions:a&&Array.isArray(a.exceptions)?a.exceptions:[],staffRules:a&&Array.isArray(a.availability)?N(a.availability):null}}function K(e,t){let n=[];for(let r of e)for(let e of t){let t=Math.max(r.startMinutes,e.startMinutes),i=Math.min(r.endMinutes,e.endMinutes);t<i&&n.push({end:r.endMinutes<=e.endMinutes?r.end:e.end,endMinutes:i,start:r.startMinutes>=e.startMinutes?r.start:e.start,startMinutes:t})}return n.sort((e,t)=>e.start.getTime()-t.start.getTime())}function ae(e,t,n,r=3,i=null){let a=new Map;e.forEach(e=>{let t=a.get(e.windowStartMs)||[];t.push(e),a.set(e.windowStartMs,t)});let o=[];for(let e of a.values()){let r=e[0].windowStartMs,i=e[0].windowEndMs,a=t.filter(e=>e.end>r&&e.start<i).sort((e,t)=>e.start-t.start);if(!a.length)continue;let s=a[0].start,c=a[a.length-1].end;for(let t of e){let e=a.reduce((e,n)=>n.end<=t.footprintStartMs&&n.end>e?n.end:e,0),r=a.reduce((e,n)=>n.start>=t.footprintEndMs&&n.start<e?n.start:e,1/0),i=Math.max(c,t.footprintEndMs)-Math.min(s,t.footprintStartMs),l=0;if(e>0){let r=t.footprintStartMs-e;r>0&&r<n&&(l+=1)}if(Number.isFinite(r)){let e=r-t.footprintEndMs;e>0&&e<n&&(l+=1)}o.push({gapPenalty:l,meta:t,spread:i})}}o.sort((e,t)=>e.spread-t.spread||e.gapPenalty-t.gapPenalty||e.meta.footprintStartMs-t.meta.footprintStartMs).slice(0,r).forEach(({meta:e})=>{e.slot.recommended=!0,typeof i==`function`&&i(e)})}function oe(e){return Array.isArray(e?.bookingDates)?e.bookingDates.map(e=>{if(!e||typeof e!=`object`||typeof e.date!=`string`||!/^\d{4}-\d{2}-\d{2}$/.test(e.date))return null;let[t,n,r]=e.date.split(`-`).map(Number),i=new Date(Date.UTC(t,n-1,r,12));if(i.getUTCFullYear()!==t||i.getUTCMonth()!==n-1||i.getUTCDate()!==r)return null;let a=e=>{let t=k(e);return Number.isFinite(t)&&t>=0&&t<=1440?String(e).padStart(5,`0`):null},o=a(e.startTime),s=a(e.endTime);return o===null==(s===null)?{date:e.date,endTime:s,hasExplicitTimes:!!(o&&s),startTime:o}:null}).filter(Boolean):[]}function se(e){let{availability:t,exceptions:n=[],limit:r=6,now:i=new Date,service:a,daysAhead:o,formatLabel:s,bookedStarts:c=[],bookedRanges:l=[],minNoticeMinutes:u=0,preferCompactSchedule:d=!1,staff:f=[],staffAvailability:p,staffId:m,timeZone:h}=e,g=N(t),_=oe(a),v=_.length>0;if(!a||!v&&!g.length)return[];let y=Math.max(1,A(o)||28),b=re(c),x=ie({exceptions:n,occupiedRanges:W(l),rules:g,service:a,staff:f,staffAvailability:p,staffId:m});if(!x.length)return[];let S=j(a),C=M(S),w=A(a.durationMinutes),ee=A(a.bufferBeforeMinutes),T=A(a.bufferAfterMinutes),E=i.getTime()+A(u)*6e4,D=[],k=new Map,P=[],G=F(i,h);for(let e=0;e<y&&D.length<r;e+=1){let e=I(G),t=_.find(e=>e.date===G);for(let n of x){let i=te(n.exceptions,G),o=te(n.staffExceptions,G);if(z(i)||z(o))continue;let c=v?t?t.hasExplicitTimes?[R(G,t.startTime,t.endTime,h)].filter(Boolean):B(G,e,n.rules,i,h):[]:B(G,e,n.rules,i,h);n.staffRules&&(c=K(c,B(G,e,n.staffRules,o,h)));let l=[...V(G,i,h),...V(G,o,h)];if(c.length)for(let e of c){let t=e.startMinutes,i=e.endMinutes,o=O(G,t,h).getTime(),u=O(G,i,h).getTime(),d=i-S;for(let e=t;e<=d&&D.length<r;e+=C){if(!U(e,t,C))continue;let r=O(G,e,h);if(Number.isNaN(r.getTime()))continue;let i=new Date(r.getTime()+ee*6e4),d=new Date(i.getTime()+w*6e4),f=new Date(d.getTime()+T*6e4);if(i.getTime()<=E||n.id===null&&b.has(i.toISOString())||n.occupied.some(e=>r.getTime()<e.end&&f.getTime()>e.start)||!H(r,f,c)||ne(r,f,l))continue;let p=i.toISOString(),m=k.get(p);if(m){n.id&&!m.staffCandidates?.includes(n.id)&&(m.staffCandidates=[...m.staffCandidates||[],n.id]),P.push({footprintEndMs:f.getTime(),footprintStartMs:r.getTime(),memberId:n.id,slot:m,windowEndMs:u,windowStartMs:o});continue}let g={capacity:`${w} min`,id:`${G}-${e}-${a.id||a.name||`service`}`,label:s(i,G),startsAt:p,...n.id?{staffCandidates:[n.id]}:{}};D.push(g),k.set(p,g),P.push({footprintEndMs:f.getTime(),footprintStartMs:r.getTime(),memberId:n.id,slot:g,windowEndMs:u,windowStartMs:o})}}}G=L(G,1)}if(d)for(let e of x)ae(P.filter(t=>t.memberId===e.id),e.occupied,S*6e4,3,t=>{e.id&&(t.slot.compactStaffIds=[...t.slot.compactStaffIds||[],e.id])});return D}function ce(e){let t=String(e||``).match(/^(\d{2}):(\d{2})$/);return t?Number(t[1])*60+Number(t[2]):NaN}function le(e,t){if(t===`24h`)return e;let n=ce(e);if(!Number.isFinite(n))return e;let r=Math.floor(n/60),i=n%60,a=r%12||12,o=r>=12?`pm`:`am`;return i===0?`${a}${o}`:`${a}:${String(i).padStart(2,`0`)}${o}`}function q(e){if(!e)return[];try{let t=typeof e==`string`?JSON.parse(e):e;return Array.isArray(t)?t.flatMap(e=>(Array.isArray(e.blocks)?e.blocks:[e]).map((t,n)=>({blocks:[{endTime:f(t.endTime||e.endTime,``),id:f(t.id,`availability-${Number(e.weekday)||0}-${n}`),startTime:f(t.startTime||e.startTime,``)}],enabled:!!e.enabled,id:f(e.id,`availability-${Number(e.weekday)||0}`),weekday:Number(e.weekday)}))).filter(e=>e.enabled&&e.blocks.length>0):[]}catch{return[]}}function J(e){if(!e)return[];try{let t=typeof e==`string`?JSON.parse(e):e;return Array.isArray(t)?t.map(e=>({date:f(e.date,``),endTime:f(e.endTime,``)||void 0,exceptionType:e.exceptionType===`extra_open`?`extra_open`:`block`,id:f(e.id,`exception-${e.date}`),note:f(e.note,``)||void 0,startTime:f(e.startTime,``)||void 0})).filter(e=>e.date):[]}catch{return[]}}function ue(e,t){return t?.useCustomAvailability&&Array.isArray(t.availability)&&t.availability.length?q(t.availability):e.availability}function de(e,t){if(!t)return`${String(e.getHours()).padStart(2,`0`)}:${String(e.getMinutes()).padStart(2,`0`)}`;let n=new Intl.DateTimeFormat(`en-US`,{timeZone:t,hourCycle:`h23`,hour:`2-digit`,minute:`2-digit`}).formatToParts(e),r={};for(let e of n)e.type!==`literal`&&(r[e.type]=e.value);return`${r.hour||`00`}:${r.minute||`00`}`}function fe(e,t){try{return new Intl.DateTimeFormat(`en-US`,{timeZone:t,timeZoneName:`short`}).formatToParts(e).find(e=>e.type===`timeZoneName`)?.value||``}catch{return``}}function pe(e,t){let r=u(t);if(!e&&r===`en-US`)return n;try{return new Intl.DateTimeFormat(r,{timeZone:e,day:`numeric`,month:`short`,weekday:`short`})}catch{return n}}function me(e){return Array.isArray(e?.bookedSlots)?e.bookedSlots.filter(e=>e&&typeof e==`object`&&e.startsAt).map(e=>({end:e.blockedEnd||e.endsAt||e.startsAt,staffId:e.staffId||null,start:e.blockedStart||e.startsAt})):[]}function he(e,t){let n=(Array.isArray(e?.staff)?e.staff:[]).map(e=>typeof e==`string`?{id:e}:e).filter(e=>e&&typeof e.id==`string`&&e.id);if(!Array.isArray(t?.staffIds))return n;let r=new Set(t.staffIds);return n.filter(e=>r.has(e.id))}function ge(e,t){return Array.isArray(e?.staff)&&e.staff.length>0&&Array.isArray(t?.staffIds)&&he(e,t).length===0}function _e(e,t,n=6,r){if(!t)return[];let i=ue(e,t);if(!N(i).length&&!t.bookingDates?.some(e=>e.startTime&&e.endTime))return[];let a=e.timezone||void 0,o=pe(a,e.language),s=Math.min(Math.max(Number(e.bookingHorizonDays)||28,1),366);return se({availability:i,bookedRanges:me(e),exceptions:e.exceptions||[],formatLabel:t=>{let n=a?fe(t,a):``,r=`${o.format(t)} · ${le(de(t,a),e.timeFormat)}`;return n?`${r} ${n}`:r},limit:n,minNoticeMinutes:Math.max(Number(e.minNoticeMinutes)||0,0),preferCompactSchedule:e.preferCompactSchedule===!0,staff:Array.isArray(e.staff)?e.staff:[],staffAvailability:e.staffAvailability||void 0,staffId:r||void 0,timeZone:a,daysAhead:s,service:{bookingDates:Array.isArray(t.bookingDates)?t.bookingDates:[],bufferAfterMinutes:Number(t.bufferAfterMinutes)||0,bufferBeforeMinutes:Number(t.bufferBeforeMinutes)||0,durationMinutes:Number(t.durationMinutes)||30,id:t.id,name:t.name,staffIds:Array.isArray(t.staffIds)?t.staffIds:t.staffIds===null?null:void 0}})}function ve(e,t){let n=new Date(e);if(!Number.isFinite(n.getTime()))return``;if(!t)return`${n.getFullYear()}-${String(n.getMonth()+1).padStart(2,`0`)}-${String(n.getDate()).padStart(2,`0`)}`;try{let e=new Intl.DateTimeFormat(`en-CA`,{timeZone:t,year:`numeric`,month:`2-digit`,day:`2-digit`}).formatToParts(n),r=Object.fromEntries(e.filter(e=>e.type!==`literal`).map(e=>[e.type,e.value]));return`${r.year}-${r.month}-${r.day}`}catch{return`${n.getFullYear()}-${String(n.getMonth()+1).padStart(2,`0`)}-${String(n.getDate()).padStart(2,`0`)}`}}function ye(e,t,n){let r=_e(e,t,200,n),i=e.timezone||void 0,a=new Map;for(let e of r){let t=ve(e.startsAt,i);t&&(a.has(t)||a.set(t,[]),a.get(t).push({...e,dateKey:t,timeLabel:le(de(new Date(e.startsAt),i),`24h`)}))}return{availableDates:Array.from(a.keys()),slotsByDate:a}}function be(){let e=globalThis.location;if(!e||typeof e!=`object`)return!1;let t=e.hostname,n=e.port;return(t===`localhost`||t===`127.0.0.1`||t===`::1`)&&(n===`5173`||n===`5174`)}function xe(){return be()?`http://127.0.0.1:5174/v1`:e}function Se(e){return f(e,xe())}function Ce(e){return/^#[0-9a-fA-F]{6}$/.test(String(e||``).trim())?String(e).trim():``}function Y(e,t){return e===!0||e===`true`?!0:e===!1||e===`false`?!1:t}function we(e){return Array.isArray(e)?e.map(e=>{if(!e||typeof e!=`object`)return null;let t=f(e.startsAt,``);return t?{blockedEnd:f(e.blockedEnd,``),blockedStart:f(e.blockedStart,``),serviceId:f(e.serviceId,``),staffId:f(e.staffId,``)||null,startsAt:t}:null}).filter(Boolean):[]}function Te(e,t){return t===`shopify`||t===`woocommerce`?e.map(e=>({...e,depositCents:0,depositEuros:0})):e}function Ee(e){let t=f(e,``).toLowerCase().split(/[-_]/)[0];return[`de`,`en`,`es`,`fr`,`it`,`nl`].includes(t)?t:`en`}function De(e){if(e===null)return null;if(Array.isArray(e))return e.map(e=>f(e,``)).filter(Boolean).slice(0,100)}function Oe(e){return Array.isArray(e)?e.map(e=>{if(!e||typeof e!=`object`)return null;let t=f(e.id,``);if(!t)return null;let n=f(e.color,``);return{avatarUrl:Re(e.avatarUrl),color:/^#[0-9a-fA-F]{6}$/.test(n)?n:``,id:t,name:f(e.name,``)}}).filter(Boolean).slice(0,50):[]}function ke(e){if(!e||typeof e!=`object`||Array.isArray(e))return;let t={};for(let[n,r]of Object.entries(e)){let e=f(n,``);!e||!r||typeof r!=`object`||(t[e]={availability:q(r.availability),exceptions:J(r.exceptions)})}return t}function Ae(e){return Array.isArray(e)?e.map((e,t)=>{if(!e||typeof e!=`object`)return null;let n=f(e.label,``),r=[`text`,`choice`,`yesno`].includes(e.type)?e.type:`text`;return n?{id:f(e.id,`q${t+1}`),label:n,options:r===`choice`&&Array.isArray(e.options)?e.options.map(e=>f(e,``)).filter(Boolean).slice(0,8):[],required:e.required===!0,type:r}:null}).filter(Boolean).slice(0,5):[]}function je(e,t){let n=f(e.commerce,``);return{...e,apiUrl:Se(e.apiUrl),bookedSlots:Array.isArray(e.bookedSlots)?e.bookedSlots:[],bookingHorizonDays:Number(e.bookingHorizonDays)||0,color:d(e.color),features:e.features&&typeof e.features==`object`?e.features:{},publicationRevision:String(e.publicationRevision||``),language:Ee(e.language),minNoticeMinutes:Number(e.minNoticeMinutes)||0,nextTimes:Y(e.nextTimes,!1),preferCompactSchedule:Y(e.preferCompactSchedule,!1),selfServiceCutoffHours:Number(e.selfServiceCutoffHours)||0,commerce:n,services:Te(e.services,n),loadError:t||``,position:f(e.position,`bottom-right`),showBranding:Y(e.showBranding,!1),title:f(e.title,c(e.language).title),timeFormat:p(e.timeFormat),timezone:f(e.timezone,``),trigger:f(e.trigger||e.triggerSelector,``),buttonAlign:f(e.buttonAlign||e.buttonPosition,`left`),variant:n===`shopify`||n===`woocommerce`?`inline`:f(e.variant,`inline`)}}function Me(e){if(Object.hasOwn(e,`priceCents`)){let t=e.priceCents;if(typeof t!=`number`||!Number.isInteger(t)||t<0||t>2147483647)throw Error(`Service price unavailable`);return t/100}if(typeof e.price!=`number`||!Number.isFinite(e.price)||e.price<0)throw Error(`Service price unavailable`);return Math.round((e.price+2**-52)*100)/100}function Ne(e){if(!e)return t;try{let n=JSON.parse(e);if(!Array.isArray(n))return t;let r=n.map(e=>({description:f(e.description,``),imageUrl:Re(e.imageUrl),durationMinutes:Number(e.durationMinutes)||30,id:f(e.id,``),name:f(e.name,``),price:Me(e),depositCents:Math.max(0,Number(e.depositCents??Number(e.depositEuros||0)*100)||0),bufferAfterMinutes:Number(e.bufferAfterMinutes)||0,bufferBeforeMinutes:Number(e.bufferBeforeMinutes)||0,useCustomAvailability:!!e.useCustomAvailability,availability:q(e.availability),bookingDates:Array.isArray(e.bookingDates)?e.bookingDates.map(e=>({date:f(e&&e.date,``),endTime:f(e&&e.endTime,``),startTime:f(e&&e.startTime,``)})).filter(e=>/^\d{4}-\d{2}-\d{2}$/.test(e.date)).slice(0,60):[],bookingQuestions:Ae(e.bookingQuestions),rebookingIntervalWeeks:Math.min(52,Math.max(0,Number(e.rebookingIntervalWeeks)||0)),staffIds:De(e.staffIds)})).filter(e=>e.name);return r.length>0?r:t}catch{return t}}function Pe(e,t){let n=f(t,``);if(!n||!Array.isArray(e)||e.length===0)return e;let r=e.filter(e=>f(e.id,``)===n||f(e.name,``)===n);return r.length>0?r:e}function Fe(e,t){let n=e?e.dataset:{},r=Object.assign({},n,t||{}),i=f(r.service||r.serviceId,``);return{apiUrl:Se(r.apiUrl),availability:q(r.availability),color:Ce(r.color),commerce:f(r.commerce,``),exceptions:J(r.exceptions),nextTimes:Y(r.nextTimes,!1),position:f(r.position,``),proposalSlug:f(r.proposalSlug,``),service:i,services:Pe(Array.isArray(r.services)?r.services:Ne(r.services),i),staff:Oe(r.staff),staffAvailability:ke(r.staffAvailability),staffDisplayMode:f(r.staffDisplayMode,``)===`customer_choice`?`customer_choice`:`hidden`,shopifyProduct:f(r.shopifyProduct,``),shopifyShop:f(r.shopifyShop||typeof window<`u`&&window.Shopify&&window.Shopify.shop||``,``),shopifyVariant:f(r.shopifyVariant,``),wooProductId:f(r.wooProductId||r.wooProductid,``),wooAddToCartUrl:f(r.wooAddToCartUrl||r.wooAddToCarturl,``),wooCartNonce:f(r.wooCartNonce||r.wooCartnonce,``),showBranding:Y(r.showBranding,!1),hostedPage:Y(r.hostedPage,!1),title:f(r.title,``),timeFormat:f(r.timeFormat,``),timezone:f(r.timezone,``),trigger:f(r.trigger||r.triggerSelector,``),buttonAlign:f(r.buttonAlign||r.buttonPosition,``),variant:f(r.variant,``),workspace:f(r.workspace,``)}}function Ie(e){let t=e.services.length>0||e.availability.length>0;if(!e.workspace||t||typeof fetch!=`function`)return Promise.resolve(je(e));let n=Se(e.apiUrl).replace(/\/$/,``);return fetch(`${n}/widget/${encodeURIComponent(e.workspace)}`,{headers:{Accept:`application/json`}}).then(e=>{if(!e.ok)throw Error(`Widget config unavailable (${e.status})`);return e.json()}).then(t=>{if(t.bookingAllowed===!1)return je(e,`Online booking is temporarily unavailable. Please contact the business directly.`);let n=Array.isArray(t.services)?t.services.map(e=>({availability:q(e.availability),bufferAfterMinutes:Number(e.bufferAfterMinutes)||0,bufferBeforeMinutes:Number(e.bufferBeforeMinutes)||0,description:f(e.description,``),imageUrl:Re(e.imageUrl),durationMinutes:Number(e.durationMinutes)||30,id:f(e.id,``),name:f(e.name,``),price:Me(e),depositCents:Math.max(0,Number(e.depositCents??Number(e.depositEuros||0)*100)||0),useCustomAvailability:!!e.useCustomAvailability,bookingDates:Array.isArray(e.bookingDates)?e.bookingDates.map(e=>({date:f(e&&e.date,``),endTime:f(e&&e.endTime,``),startTime:f(e&&e.startTime,``)})).filter(e=>/^\d{4}-\d{2}-\d{2}$/.test(e.date)).slice(0,60):[],bookingQuestions:Ae(e.bookingQuestions),rebookingIntervalWeeks:Math.min(52,Math.max(0,Number(e.rebookingIntervalWeeks)||0)),staffIds:De(e.staffIds)})).filter(e=>e.name):[],r=q(t.availability),i=J(t.exceptions),a=Pe(e.services.length?e.services:n,e.service),o=f(e.commerce,``),s=o===`shopify`||o===`woocommerce`||e.hostedPage===!0?`inline`:f(t.variant||e.variant,`inline`),l=Ee(t.language||e.language);return{availability:e.availability.length?e.availability:r,apiUrl:Se(e.apiUrl),bookedSlots:we(t.bookedSlots),calendarVerification:f(t.calendarVerification,`none`),bookingHorizonDays:Number(t.bookingHorizonDays)||0,color:d(t.color||e.color),features:t.features&&typeof t.features==`object`?t.features:{},language:l,publicationRevision:f(t.publicationRevision,``),minNoticeMinutes:Number(t.minNoticeMinutes)||0,nextTimes:Y(e.nextTimes,!1),preferCompactSchedule:Y(t.preferCompactSchedule,!1),selfServiceCutoffHours:Number(t.selfServiceCutoffHours)||0,commerce:o,exceptions:i,position:f(t.position||e.position,`bottom-right`),proposalSlug:f(e.proposalSlug,``),service:f(e.service,``),services:Te(a,o),shopifyProduct:f(e.shopifyProduct,``),shopifyShop:f(e.shopifyShop,``),shopifyVariant:f(e.shopifyVariant,``),wooProductId:f(e.wooProductId,``),wooAddToCartUrl:f(e.wooAddToCartUrl,``),wooCartNonce:f(e.wooCartNonce,``),showBranding:e.showBranding,sms:Le(t.sms),staff:Oe(t.staff),staffAvailability:ke(t.staffAvailability),staffDisplayMode:f(t.staffDisplayMode,``)===`customer_choice`?`customer_choice`:`hidden`,title:f(t.title||e.title,c(t.language||e.language).title),timeFormat:p(t.timeFormat||e.timeFormat),timezone:f(t.timezone||e.timezone,``),trigger:f(t.trigger||e.trigger,``),buttonAlign:f(t.buttonAlign||t.buttonPosition||e.buttonAlign||e.buttonPosition,`left`),variant:s,whatsapp:Le(t.whatsapp),workspace:e.workspace}}).catch(t=>je(e,t instanceof Error?t.message:`Widget config unavailable`))}function Le(e){let t=e&&typeof e==`object`?e:{},n=t.events&&typeof t.events==`object`?t.events:{};return{available:t.available===!0,events:{cancellation:n.cancellation===!0,confirmation:n.confirmation===!0,reminder24h:n.reminder24h===!0,reminder2h:n.reminder2h===!0,reschedule:n.reschedule===!0},textVersion:f(t.textVersion,``)||void 0}}function Re(e){if(typeof e!=`string`||e.length>2048)return``;try{let t=new URL(e);return t.protocol===`https:`&&!t.username&&!t.password?t.href:``}catch{return``}}function ze(e){if(typeof e!=`string`||!e.startsWith(`data:image/webp;base64,`)||e.length>65559)return``;let t=e.slice(23);if(!/^[A-Za-z0-9+/]+={0,2}$/.test(t)||t.length%4!=0)return``;try{let n=atob(t);return n.length<30||n.slice(0,4)!==`RIFF`||n.slice(8,12)!==`WEBP`?``:((n.charCodeAt(4)|n.charCodeAt(5)<<8|n.charCodeAt(6)<<16|n.charCodeAt(7)<<24)>>>0)+8===n.length?e:``}catch{return``}}function Be(e,t=!1){let n=Re(e)||(t===!0?ze(e):``);return n?`<img class="service-photo" src="${S(n)}" width="44" height="44" alt="" loading="lazy" decoding="async" referrerpolicy="no-referrer">`:``}function Ve(e){e.querySelectorAll(`.service-photo`).forEach(e=>{let t=()=>e.remove();e.addEventListener(`error`,t,{once:!0}),e.complete&&!e.naturalWidth&&t()})}function X(e){return!e||typeof e!=`object`?!1:(e.commerce,!!(e.shopifyProduct&&e.shopifyShop&&e.shopifyVariant))}function Z(e){return!e||typeof e!=`object`?!1:(e.commerce,!!(e.wooProductId&&e.wooAddToCartUrl&&e.wooCartNonce))}function He(e){return X(e)||Z(e)}function Ue(e){try{return new Date(e).toISOString()}catch{return String(e||``)}}function We(e,{locale:t,timeZone:n}={}){let r=new Date(e);if(Number.isNaN(r.getTime()))return String(e||``);try{return new Intl.DateTimeFormat(t||void 0,{day:`numeric`,hour:`2-digit`,minute:`2-digit`,month:`short`,timeZone:n||void 0,timeZoneName:`short`,weekday:`short`,year:`numeric`}).format(r)}catch{return Ue(e)}}async function Ge({bookingId:e,serviceName:t,startsAt:n,timeZone:r,variantId:i}){let a=Number(String(i).replace(/\D/g,``));if(!Number.isFinite(a)||a<=0)throw Error(`This product is missing a Shopify variant for checkout.`);if(!e)throw Error(`Booking hold could not be created for checkout.`);let o;try{o=await fetch(`/cart/add.js`,{method:`POST`,headers:{Accept:`application/json`,"Content-Type":`application/json`},body:JSON.stringify({items:[{id:a,quantity:1,properties:{_bookminto_booking_id:String(e),_bookminto_starts_at:Ue(n),Appointment:We(n,{timeZone:r}),Service:String(t||`Appointment`)}}]})})}catch{throw Error(`Could not add booking to cart. Please try again.`)}if(!o.ok){let e=await o.json().catch(()=>({}));throw Error(e.description||e.message||`Could not add booking to cart.`)}return window.location.assign(`/checkout`),{redirected:!0}}async function Ke({bookingId:e,serviceName:t,startsAt:n,timeZone:r,productId:i,addToCartUrl:a,cartNonce:o}){if(!e)throw Error(`Booking hold could not be created for checkout.`);let s=Number(i);if(!Number.isFinite(s)||s<=0)throw Error(`This product is missing a WooCommerce product id.`);let c=String(a||``).trim();if(!c)throw Error(`WooCommerce cart endpoint is missing.`);let l=new URLSearchParams;l.set(`action`,`bookminto_add_to_cart`),l.set(`nonce`,String(o||``)),l.set(`product_id`,String(s)),l.set(`booking_id`,String(e)),l.set(`appointment`,We(n,{timeZone:r})),l.set(`service_name`,String(t||`Appointment`));let u;try{u=await fetch(c,{method:`POST`,headers:{Accept:`application/json`,"Content-Type":`application/x-www-form-urlencoded; charset=UTF-8`},body:l,credentials:`same-origin`})}catch{throw Error(`Could not add booking to cart. Please try again.`)}let d=await u.json().catch(()=>({}));if(!u.ok||!d?.success){let e=d?.data?.message||d?.message||`Could not add booking to cart.`;throw Error(e)}let f=qe(d?.data?.checkoutUrl)||`/checkout/`;return window.location.assign(f),{redirected:!0}}function qe(e){if(typeof e!=`string`||!e)return``;try{let t=new URL(e,window.location.href);return t.protocol===`https:`||t.protocol===`http:`?t.href:``}catch{return``}}function Je(e){return String(e.apiUrl||`https://api.bookminto.com/v1`).replace(/\/$/,``)}function Ye(){let e=window.location.hostname;return e===`localhost`||e===`127.0.0.1`||e===`::1`}function Xe(t){return Je(t)===e.replace(/\/$/,``)}async function Ze(e,t){let n=X(e),r=Z(e),i=n||r;if(e.previewMode||!e.workspace||typeof fetch!=`function`||Ye()&&Xe(e)&&!i)return{bookingId:e.previewMode?`preview`:void 0,localOnly:!0,ok:!0};let a;try{a=await fetch(`${Je(e)}/bookings`,{body:JSON.stringify({...t,commerceCheckout:i||void 0,publicKey:e.workspace,proposalSlug:e.proposalSlug||void 0,shopifyProductGid:n&&e.shopifyProduct||void 0,shopifyShopDomain:n&&e.shopifyShop||void 0,shopifyVariantId:n&&e.shopifyVariant||void 0,wooProductId:r&&e.wooProductId||void 0,siteUrl:window.location.href,source:n?`shopify`:r?`woocommerce`:void 0}),headers:{"Content-Type":`application/json`},method:`POST`})}catch{throw Error(`Booking service unavailable. Please try again.`)}if(!a.ok){let e=await a.json().catch(()=>({})),t=Error(e.error||`Booking could not be created`);throw t.code=e.code,t}let o=await a.json();if(o.ok!==!0||o.holdExpired||[`cancelled`,`expired`].includes(o.status))throw Error(o.error||c(e.language).closedAttempt);return n&&o.needsCheckout===!0?(await Ge({bookingId:o.bookingId,serviceName:t.serviceName||t.serviceId,startsAt:t.startsAt,timeZone:e.timezone,variantId:e.shopifyVariant}),{...o,redirectedToCheckout:!0}):r&&o.needsCheckout===!0?(await Ke({bookingId:o.bookingId,serviceName:t.serviceName||t.serviceId,startsAt:t.startsAt,timeZone:e.timezone,productId:e.wooProductId,addToCartUrl:e.wooAddToCartUrl,cartNonce:e.wooCartNonce}),{...o,redirectedToCheckout:!0}):o}async function Qe(e,t){if(e.previewMode||!e.workspace||typeof fetch!=`function`||Ye()&&Xe(e))return{ok:!0,localOnly:!0};let n=await fetch(`${Je(e)}/waitlist`,{body:JSON.stringify({bookingId:t.bookingId||void 0,customerEmail:t.customerEmail,customerName:t.customerName,dateFrom:t.dateFrom,dateTo:t.dateTo,language:e.language,publicKey:e.workspace,serviceId:t.serviceId,source:X(e)?`shopify`:Z(e)?`woocommerce`:void 0,shopifyShopDomain:X(e)?e.shopifyShop:void 0,shopifyProductGid:X(e)?e.shopifyProduct:void 0,shopifyVariantId:X(e)?String(e.shopifyVariant):void 0,wooProductId:Z(e)?String(e.wooProductId):void 0,siteUrl:window.location.href}),headers:{"Content-Type":`application/json`},method:`POST`}).catch(()=>null);if(!n)throw Error(`Booking service unavailable. Please try again.`);let r=await n.json().catch(()=>({}));if(!n.ok)throw Error(r.error||`Could not join the list`);return r}function $e(e){return`${e.getUTCFullYear()}${String(e.getUTCMonth()+1).padStart(2,`0`)}${String(e.getUTCDate()).padStart(2,`0`)}T${String(e.getUTCHours()).padStart(2,`0`)}${String(e.getUTCMinutes()).padStart(2,`0`)}${String(e.getUTCSeconds()).padStart(2,`0`)}Z`}function et(e){return String(e||``).replace(/\\/g,`\\\\`).replace(/;/g,`\\;`).replace(/,/g,`\\,`).replace(/\r?\n/g,`\\n`)}function tt(e){let t=e.startsAt instanceof Date?e.startsAt:new Date(e.startsAt);if(Number.isNaN(t.getTime()))return``;let n=Math.max(1,Number(e.durationMinutes)||30),r=new Date(t.getTime()+n*6e4),i=[`BEGIN:VCALENDAR`,`VERSION:2.0`,`PRODID:-//Bookminto//Booking//EN`,`CALSCALE:GREGORIAN`,`METHOD:PUBLISH`,`BEGIN:VEVENT`,`UID:${`${e.id||Math.random().toString(36).slice(2)}@bookminto`}`,`DTSTAMP:${$e(new Date)}`,`DTSTART:${$e(t)}`,`DTEND:${$e(r)}`,`SUMMARY:${et(e.title)}`];return e.description&&i.push(`DESCRIPTION:${et(e.description)}`),e.location&&i.push(`LOCATION:${et(e.location)}`),i.push(`END:VEVENT`,`END:VCALENDAR`),`data:text/calendar;charset=utf-8,${encodeURIComponent(i.join(`\r 2 `))}`}function nt(e,t){let n=c(t),r=e?.payment||e;if(!e?.needsPayment&&!e?.payment)return null;if(!r?.clientSecret||!r?.publishableKey||!(e.bookingId||e.id))throw Error(n.depositLoadError);return{bookingId:e.bookingId||e.id,clientSecret:r.clientSecret,depositCents:r.depositCents,publishableKey:r.publishableKey}}function rt(e,t,n,r){let i=c(r),a=document.createElement(`p`);a.setAttribute(`role`,`alert`),a.textContent=t instanceof Error?t.message:i.paymentLoadFailed;let o=document.createElement(`button`);o.type=`button`,o.className=`back-btn`,o.textContent=i.retryCta,o.addEventListener(`click`,n),e.replaceChildren(a,o)}function it(){return typeof window>`u`?Promise.reject(Error(`Stripe is unavailable`)):window.Stripe?Promise.resolve(window.Stripe):new Promise((e,t)=>{let n=document.querySelector(`script[data-bookminto-stripe]`);if(n){n.addEventListener(`load`,()=>e(window.Stripe)),n.addEventListener(`error`,()=>t(Error(`Stripe.js failed to load`)));return}let r=document.createElement(`script`);r.src=`https://js.stripe.com/v3/`,r.async=!0,r.dataset.bookmintoStripe=`true`,r.onload=()=>e(window.Stripe),r.onerror=()=>t(Error(`Stripe.js failed to load`)),document.head.appendChild(r)})}async function at(e,t){let n=c(t.language),r=(await it())(t.publishableKey),i=r.elements({appearance:{theme:`stripe`,variables:{colorPrimary:t.accent||`#E85D4C`,borderRadius:`8px`,fontFamily:`Sora, ui-sans-serif, system-ui, sans-serif`}},clientSecret:t.clientSecret,locale:t.language||void 0}),a=i.create(`payment`),o=typeof e.getRootNode==`function`?e.getRootNode():null,s=typeof ShadowRoot<`u`&&o instanceof ShadowRoot&&o.host,l=null;if(s){l=document.createElement(`div`),l.setAttribute(`slot`,`bm-payment-element`),l.style.cssText=`position: relative; margin: 8px 0 4px; min-height: 120px; contain: paint; clip-path: inset(0);`;let t=e.parentElement;for(;t&&t!==o;){let n=getComputedStyle(t).overflowY;if(n===`auto`||n===`scroll`||n===`hidden`){let n=t.getBoundingClientRect().bottom-e.getBoundingClientRect().top;l.style.maxHeight=`${Math.max(140,Math.floor(n))}px`,l.style.overflowY=`auto`;break}t=t.parentElement}o.host.appendChild(l)}let u=l||e,d=()=>{l&&=(l.remove(),null)};try{a.mount(u),typeof a.on==`function`&&await new Promise((e,t)=>{let r=()=>{try{a.off(`ready`,i),a.off(`loaderror`,o)}catch{}},i=()=>{r(),e()},o=e=>{r(),t(Error(e?.error?.message||n.paymentLoadFailed))};a.on(`ready`,i),a.on(`loaderror`,o)})}catch(e){throw d(),e}return{async confirm(){let e=await r.confirmPayment({elements:i,redirect:`if_required`,confirmParams:{return_url:window.location.href}});if(e.error)throw Error(e.error.message||n.paymentError);let t=e.paymentIntent;if(!t||t.status!==`succeeded`)throw Error(n.paymentError);return t.id},destroy(){try{a.unmount()}catch{}d()}}}async function ot(e,t){let n=String(e.apiUrl||``).replace(/\/$/,``),r=await fetch(`${n}/bookings/confirm-payment`,{body:JSON.stringify({...t,publicKey:e.workspace}),headers:{"Content-Type":`application/json`},method:`POST`});if(!r.ok){let t=await r.json().catch(()=>({}));throw Error(t.error||c(e.language).paymentError)}return r.json()}function st(e){let t=String(e||``).replace(`#`,``);return/^[0-9a-fA-F]{6}$/.test(t)?{r:Number.parseInt(t.slice(0,2),16),g:Number.parseInt(t.slice(2,4),16),b:Number.parseInt(t.slice(4,6),16)}:null}function ct(e){return Math.max(0,Math.min(255,Math.round(e)))}function lt(e,t,n){let r=st(e),i=st(t);if(!r||!i)return e;let a=(e,t)=>ct(e+(t-e)*n);return`#${[a(r.r,i.r),a(r.g,i.g),a(r.b,i.b)].map(e=>e.toString(16).padStart(2,`0`)).join(``)}`}function ut(e,t){let n=st(e);return n?`rgba(${n.r}, ${n.g}, ${n.b}, ${t})`:`rgba(232, 93, 76, ${t})`}function dt(e){let t=st(e);if(!t)return`#ffffff`;let n=e=>{let t=e/255;return t<=.04045?t/12.92:((t+.055)/1.055)**2.4};return .2126*n(t.r)+.7152*n(t.g)+.0722*n(t.b)>.42?`#172033`:`#ffffff`}function Q(e,t){if(!e)return;if(!t){[`--bm-accent`,`--bm-accent-dark`,`--bm-accent-deep`,`--bm-accent-soft`,`--bm-accent-soft-hover`,`--bm-accent-border`,`--bm-accent-muted`,`--bm-accent-ring`,`--bm-accent-shadow`,`--bm-accent-shadow-hover`,`--bm-accent-contrast`].forEach(t=>e.style.removeProperty(t));return}let n=t,r=lt(n,`#000000`,.16),i=lt(n,`#000000`,.28),a=lt(n,`#ffffff`,.92),o=lt(n,`#ffffff`,.97),s=lt(n,`#ffffff`,.62),c=lt(n,`#ffffff`,.2);e.style.setProperty(`--bm-accent`,n),e.style.setProperty(`--bm-accent-dark`,r),e.style.setProperty(`--bm-accent-deep`,i),e.style.setProperty(`--bm-accent-soft`,a),e.style.setProperty(`--bm-accent-soft-hover`,o),e.style.setProperty(`--bm-accent-border`,s),e.style.setProperty(`--bm-accent-muted`,c),e.style.setProperty(`--bm-accent-ring`,ut(n,.2)),e.style.setProperty(`--bm-accent-shadow`,ut(n,.5)),e.style.setProperty(`--bm-accent-shadow-hover`,ut(n,.65)),e.style.setProperty(`--bm-accent-contrast`,dt(n))}let ft={AT:`43`,BE:`32`,CH:`41`,DE:`49`,ES:`34`,FR:`33`,GB:`44`,IE:`353`,IT:`39`,LU:`352`,NL:`31`,PT:`351`},pt=Object.keys(ft).sort(),mt=/^\+[1-9]\d{6,14}$/;function ht(e){return String(e||``).replace(/[^\d]/g,``)}function gt(e,t){let n=String(e||``).trim();if(!n)return null;let r;if(n.startsWith(`+`))r=ht(n);else if(n.startsWith(`00`))r=ht(n.slice(2));else{let e=ft[String(t||``).toUpperCase()];if(!e)return null;let i=ht(n);if(i.startsWith(`0`)&&(i=i.slice(1)),!i)return null;r=e+i}let i=`+${r}`;return mt.test(i)?i:null}function _t(e,t,n,r){return e&&t?`${t.label} · ${C(e,r)}`:n.selectServiceAndTime}function vt(e,t){let n=[[`service`,t.widgetStepLabels[0]],[`time`,t.widgetStepLabels[1]],[`details`,t.widgetStepLabels[2]],[`confirmed`,t.widgetStepLabels[3]]],r=Math.max(0,n.findIndex(([t])=>t===e)),i=n[r][1];return` 3 3 <div 4 4 class="bw-booking-modal__stepper" 5 5 role="progressbar" 6 aria-label=" Booking progress"6 aria-label="${S(t.progressLabel)}" 7 7 aria-valuemin="1" 8 aria-valuemax="${ t.length}"9 aria-valuenow="${ n+1}"10 aria-valuetext=" Step ${n+1} of ${t.length}: ${r}"8 aria-valuemax="${n.length}" 9 aria-valuenow="${r+1}" 10 aria-valuetext="${S(t.stepLabel)} ${r+1} ${S(t.stepOf)} ${n.length}: ${S(i)}" 11 11 > 12 12 <div class="bw-booking-modal__step-meta" aria-hidden="true"> 13 <span> Step ${n+1} of ${t.length}</span>14 <strong>${ r}</strong>13 <span>${S(t.stepLabel)} ${r+1} ${S(t.stepOf)} ${n.length}</span> 14 <strong>${i}</strong> 15 15 </div> 16 16 <div class="bw-booking-modal__step-track" aria-hidden="true"> 17 ${ t.map((e,t)=>`<span class="bw-booking-modal__step-segment${t<=n?` is-complete`:``}"></span>`).join(``)}17 ${n.map((e,t)=>`<span class="bw-booking-modal__step-segment${t<=r?` is-complete`:``}"></span>`).join(``)} 18 18 </div> 19 19 </div> 20 `}function Le(e,t){return e.length?e.map((e,n)=>`21 <button type="button" class="bw-booking-modal__service" data-panel-service-index="${ n}" data-active="${n===t}" aria-pressed="${n===t}">20 `}function yt(e,t,n,r,i){return e.length?e.map((e,r)=>` 21 <button type="button" class="bw-booking-modal__service" data-panel-service-index="${r}" data-active="${r===t}" aria-pressed="${r===t}"> 22 22 <span class="bw-booking-modal__service-body"> 23 <strong>${l(e.name)}</strong> 24 <em>${l(c(e))}</em> 23 ${Be(e.imageUrl,n)} 24 <span class="bw-booking-modal__service-copy"> 25 <strong>${S(e.name)}</strong> 26 <em>${S(x(e,i))}</em> 27 </span> 25 28 </span> 26 <span class="bw-booking-modal__service-price">${ l(d(e))}</span>29 <span class="bw-booking-modal__service-price">${S(w(e,i))}</span> 27 30 </button> 28 `).join(``):`<div class="bw-booking-bar-panel__empty"> No services are published for this widget yet.</div>`}function Re(e,t,n=document.body){let r=document.createElement(`div`),i=document.createElement(`div`),a=document.createElement(`div`),o=!1,s=0,c=0,p=`time`,m=``,h=null,g=null,_=null,v=!1,y={customerName:``,customerEmail:``,customerPhone:``,notes:``};r.className=`bw-booking-bar-shell`,r.dataset.bookingWidgetOwner=t,r.style.setProperty(`--bw-color`,e.color),r.style.setProperty(`--bw-contrast`,Pe(e.color)),i.className=`bw-booking-bar-spacer`,i.dataset.bookingWidgetOwner=t,a.className=`bw-booking-bar-panel`,a.dataset.bookingWidgetOwner=t,a.style.setProperty(`--bw-color`,e.color),a.style.setProperty(`--bw-contrast`,Pe(e.color));function b(){if(n.host?.style?.setProperty){if(n.host.dataset.open=String(o),o){n.host.style.setProperty(`inset`,`0`,`important`),n.host.style.setProperty(`top`,`0`,`important`),n.host.style.setProperty(`left`,`0`,`important`),n.host.style.setProperty(`right`,`0`,`important`),n.host.style.setProperty(`bottom`,`0`,`important`),n.host.style.setProperty(`width`,`100%`,`important`),n.host.style.setProperty(`height`,`100%`,`important`),n.host.style.setProperty(`pointer-events`,`auto`,`important`);return}n.host.style.setProperty(`inset`,`auto 0 0 0`,`important`),n.host.style.setProperty(`top`,`auto`,`important`),n.host.style.setProperty(`left`,`0`,`important`),n.host.style.setProperty(`right`,`0`,`important`),n.host.style.setProperty(`bottom`,`0`,`important`),n.host.style.setProperty(`width`,`100%`,`important`),n.host.style.setProperty(`height`,`auto`,`important`),n.host.style.setProperty(`pointer-events`,`auto`,`important`)}}if(n.host){let e=document.getElementById(`wpadminbar`),t=e?window.getComputedStyle(e):null,i=e&&t?.position===`fixed`?Math.ceil(e.getBoundingClientRect().height):0;n.host.style.setProperty(`--bw-top-offset`,`${i}px`),i>0&&(n.host.style.setProperty(`--bw-bar-z-index`,`90000`),n.host.style.setProperty(`--bw-panel-z-index`,`89999`));let o=e=>{e.stopPropagation()};r.addEventListener(`pointerdown`,o),r.addEventListener(`click`,o),a.addEventListener(`pointerdown`,o),a.addEventListener(`click`,o)}function x(t,n,r){if(e.loadError)return`<div class="bw-booking-bar-panel__empty">Booking setup could not be loaded.</div>`;if(!n)return`<div class="bw-booking-bar-panel__empty">No services are published for this widget yet.</div>`;let i=`${t.length} ${t.length===1?`service`:`services`}`,a=r.length?r.map((e,t)=>`29 <button type="button" class="bw-booking-modal__slot" data-slot-index="${t}" data-active="${t=== c}">31 `).join(``):`<div class="bw-booking-bar-panel__empty">${S(r.emptyServices)}</div>`}function bt(){try{if(typeof crypto?.randomUUID==`function`)return crypto.randomUUID()}catch{}return`bmr-${Date.now().toString(36)}-${Math.random().toString(36).slice(2,12)}`}function xt(e,t,n=document.body){let r=c(e.language),i=bt(),a=!1,o={},s=document.createElement(`div`),l=document.createElement(`div`),u=document.createElement(`div`),d=!1,f=0,p=0,m=`time`,h=``,g=null,_=null,v=null,y=!1,b={customerName:``,customerEmail:``,customerPhone:``,customerPhoneCountry:``,notes:``};s.className=`bw-booking-bar-shell`,s.dataset.bookingWidgetOwner=t,s.style.setProperty(`--bw-color`,e.color),s.style.setProperty(`--bw-contrast`,dt(e.color)),l.className=`bw-booking-bar-spacer`,l.dataset.bookingWidgetOwner=t,u.className=`bw-booking-bar-panel`,u.dataset.bookingWidgetOwner=t,u.style.setProperty(`--bw-color`,e.color),u.style.setProperty(`--bw-contrast`,dt(e.color));function x(){if(n.host?.style?.setProperty){if(n.host.dataset.open=String(d),d){n.host.style.setProperty(`inset`,`0`,`important`),n.host.style.setProperty(`top`,`0`,`important`),n.host.style.setProperty(`left`,`0`,`important`),n.host.style.setProperty(`right`,`0`,`important`),n.host.style.setProperty(`bottom`,`0`,`important`),n.host.style.setProperty(`width`,`100%`,`important`),n.host.style.setProperty(`height`,`100%`,`important`),n.host.style.setProperty(`pointer-events`,`auto`,`important`);return}n.host.style.setProperty(`inset`,`auto 0 0 0`,`important`),n.host.style.setProperty(`top`,`auto`,`important`),n.host.style.setProperty(`left`,`0`,`important`),n.host.style.setProperty(`right`,`0`,`important`),n.host.style.setProperty(`bottom`,`0`,`important`),n.host.style.setProperty(`width`,`100%`,`important`),n.host.style.setProperty(`height`,`auto`,`important`),n.host.style.setProperty(`pointer-events`,`auto`,`important`)}}if(n.host){let e=document.getElementById(`wpadminbar`),t=e?window.getComputedStyle(e):null,r=e&&t?.position===`fixed`?Math.ceil(e.getBoundingClientRect().height):0;n.host.style.setProperty(`--bw-top-offset`,`${r}px`),r>0&&(n.host.style.setProperty(`--bw-bar-z-index`,`90000`),n.host.style.setProperty(`--bw-panel-z-index`,`89999`));let i=e=>{e.stopPropagation()};s.addEventListener(`pointerdown`,i),s.addEventListener(`click`,i),u.addEventListener(`pointerdown`,i),u.addEventListener(`click`,i)}function T(t,n,i){if(e.loadError)return`<div class="bw-booking-bar-panel__empty">${S(r.loadError)}</div>`;if(!n)return`<div class="bw-booking-bar-panel__empty">${S(r.emptyServices)}</div>`;let a=`${t.length} ${t.length===1?r.serviceSingular:r.servicePlural}`,o=i.length?i.map((e,t)=>` 32 <button type="button" class="bw-booking-modal__slot" data-slot-index="${t}" data-active="${t===p}"> 30 33 <span> 31 <strong>${ l(e.label)}</strong>32 <em>${ l(e.capacity)}</em>34 <strong>${S(e.label)}</strong> 35 <em>${S(e.capacity)}</em> 33 36 </span> 34 <span>${ t===c?`Selected`:`Choose`}</span>37 <span>${S(t===p?r.selected:r.choose)}</span> 35 38 </button> 36 `).join(``):`<div class="bw-booking-bar-panel__empty"> No times are available yet.</div>`;return`39 `).join(``):`<div class="bw-booking-bar-panel__empty">${S(r.emptySlots)}</div>`;return` 37 40 <div class="bw-booking-modal__time"> 38 <aside class="bw-booking-modal__services" aria-label=" Services">41 <aside class="bw-booking-modal__services" aria-label="${S(r.servicesLabel)}"> 39 42 <div class="bw-booking-modal__section-title"> 40 <strong> Services</strong>41 <em>${ l(i)}</em>43 <strong>${S(r.servicesLabel)}</strong> 44 <em>${S(a)}</em> 42 45 </div> 43 46 <div class="bw-booking-modal__service-list"> 44 ${ Le(t,s)}47 ${yt(t,f,e.previewMode,r,e.language)} 45 48 </div> 46 49 </aside> 47 <section class="bw-booking-modal__times" aria-label=" Available times">50 <section class="bw-booking-modal__times" aria-label="${S(r.availableTimes)}"> 48 51 <div class="bw-booking-modal__section-title"> 49 <strong> Times</strong><span>${l(f(e))}</span>50 <em>${ l(n.name)}</em>52 <strong>${S(r.timesLabel)}</strong><span>${S(ee(e))}</span> 53 <em>${S(n.name)}</em> 51 54 </div> 52 55 <div class="bw-booking-modal__slots"> 53 ${ a}56 ${o} 54 57 </div> 55 58 </section> 56 59 </div> 57 `}function S(e,t){return!e||!t?`<div class="bw-booking-modal__pane"><div class="bw-booking-bar-panel__empty">Select a service and time first.</div></div>`:`60 `}function E(t,n){if(!t||!n)return`<div class="bw-booking-modal__pane"><div class="bw-booking-bar-panel__empty">${S(r.selectServiceAndTime)}</div></div>`;let i=Number(t.depositCents)>0&&e.features?.deposits!==!0;return` 58 61 <div class="bw-booking-modal__pane"> 59 62 <form class="bw-booking-bar-panel__form"> 60 63 <div class="bw-booking-bar-panel__summary-card"> 61 <strong>${ l(e.name)}</strong>62 <span>${ l(Fe(e,t))}</span>64 <strong>${S(t.name)}</strong> 65 <span>${S(_t(t,n,r,e.language))}</span> 63 66 </div> 64 <p class="bw-booking-bar-panel__section-title">Your details</p> 67 <p class="bw-booking-bar-panel__section-title">${S(r.detailsHeading)}</p> 68 ${i?`<p role="note">${S(r.depositUnavailable)}</p>`:``} 65 69 <div class="bw-booking-bar-panel__form-row"> 66 70 <label> 67 <span> Name</span>68 <input name="customerName" autocomplete="name" required placeholder=" Full name" value="${l(y.customerName)}" />71 <span>${S(r.nameLabel)}</span> 72 <input name="customerName" autocomplete="name" required placeholder="${S(r.namePlaceholder)}" value="${S(b.customerName)}" /> 69 73 </label> 74 ${e.sms?.available===!0?` 75 <label> 76 <span>${S(r.phoneCountryLabel)}</span> 77 <select name="customerPhoneCountry" autocomplete="country"> 78 <option value="">${S(r.chooseOption)}</option> 79 ${pt.map(e=>`<option value="${e}"${b.customerPhoneCountry===e?` selected`:``}>${e} (+${S(ft[e])})</option>`).join(``)} 80 </select> 81 </label> 82 `:``} 70 83 <label> 71 <span> Phone <small>(optional)</small></span>72 <input name="customerPhone" autocomplete="tel" type="tel" placeholder=" +1 555 0100" value="${l(y.customerPhone)}" />84 <span>${S(r.phoneLabel)}</span> 85 <input name="customerPhone" autocomplete="tel" type="tel" placeholder="${S(r.phonePlaceholder)}" value="${S(b.customerPhone)}" /> 73 86 74 87 </label> 75 88 </div> 76 89 <label> 77 <span> Email</span>78 <input name="customerEmail" autocomplete="email" type="email" required placeholder=" you@example.com" value="${l(y.customerEmail)}" />90 <span>${S(r.emailLabel)}</span> 91 <input name="customerEmail" autocomplete="email" type="email" required placeholder="${S(r.emailPlaceholder)}" value="${S(b.customerEmail)}" /> 79 92 </label> 80 93 <label> 81 <span> Notes <small>(optional)</small></span>82 <textarea name="notes" rows="3" maxlength="500" placeholder=" Anything we should know?">${l(y.notes)}</textarea>94 <span>${S(r.notesLabel)}</span> 95 <textarea name="notes" rows="3" maxlength="500" placeholder="${S(r.notesPlaceholder)}">${S(b.notes)}</textarea> 83 96 </label> 84 ${m?`<div class="bw-booking-bar-panel__message" role="status">${l(m)}</div>`:``} 97 ${(t.bookingQuestions||[]).map(e=>{let t=String(o[e.id]||``),n=`answer-${S(e.id)}`,i=e.required?` required`:``,a=e.type===`yesno`?[`yes`,`no`]:e.options||[],s=e.type===`text`?`<input name="${n}" maxlength="500"${i} value="${S(t)}">`:`<select name="${n}"${i}><option value="">${S(r.chooseOption)}</option>${a.map(n=>`<option value="${S(n)}"${t===n?` selected`:``}>${S(e.type===`yesno`?n===`yes`?r.yesLabel:r.noLabel:n)}</option>`).join(``)}</select>`;return`<label><span>${S(e.label)}${e.required?` *`:``}</span>${s}</label>`}).join(``)} 98 ${e.sms?.available===!0?` 99 <label class="bw-booking-bar-panel__optin"> 100 <input type="checkbox" name="smsOptIn" /> 101 <span>${S(r.smsOptInLabel)}</span> 102 </label> 103 `:``} 104 ${e.whatsapp?.available===!0?` 105 <label class="bw-booking-bar-panel__optin"> 106 <input type="checkbox" name="whatsappOptIn" /> 107 <span>${S(r.whatsappOptInLabel)}</span> 108 </label> 109 `:``} 110 ${h?`<div class="bw-booking-bar-panel__message" role="status">${S(h)}</div>`:``} 85 111 <div class="bw-booking-bar-panel__actions"> 86 <button type="button" class="bw-booking-bar-panel__back"> Back</button>87 <button type="submit" class="bw-booking-bar-panel__continue" >Confirm booking</button>112 <button type="button" class="bw-booking-bar-panel__back">${S(r.back)}</button> 113 <button type="submit" class="bw-booking-bar-panel__continue" ${i?`disabled`:``}>${S(a?r.replacePaymentCta:r.confirmCta)}</button> 88 114 </div> 89 115 </form> 90 116 </div> 91 `}function C(e,t){return`<div class="bw-booking-modal__pane">92 <div class="bw-booking-bar-panel__summary-card"><strong>${ l(e?.name||`Appointment`)}</strong><span>${l(Fe(e,t))}</span></div>93 <p class="bw-booking-bar-panel__section-title"> Pay ${l(d({price:g.depositCents/100}))} deposit</p>94 <p> Your booking is confirmed after payment.</p>95 < div data-payment-element></div>96 <p data-payment-status role="status"> Loading secure payment…</p>97 ${ m?`<p role="alert">${l(m)}</p>`:``}98 <button type="button" class="bw-booking-bar-panel__continue" data-pay-deposit disabled> Pay deposit and confirm</button>99 </div>`}function w(){let e=h?.localOnly===!0,t=h&&!e?Ee({description:h.serviceName,durationMinutes:h.durationMinutes,id:h.bookingId,startsAt:h.startsAt,title:h.serviceName}):``,n=h?.bookingId&&!e?`<span class="bw-booking-bar-panel__ref">Booking reference: <code>${l(String(h.bookingId).slice(0,8))}</code></span>`:``,r=t?`<a class="bw-booking-bar-panel__calendar" href="${t}" download="booking.ics">Add to calendar</a>`:``;return`117 `}function D(t,n){return`<div class="bw-booking-modal__pane"> 118 <div class="bw-booking-bar-panel__summary-card"><strong>${S(t?.name||r.appointment)}</strong><span>${S(_t(t,n,r,e.language))}</span></div> 119 <p class="bw-booking-bar-panel__section-title">${S(r.depositHeading)} · ${S(w({price:_.depositCents/100,currency:t?.currency},e.language))}</p> 120 <p>${S(r.depositNote)}</p> 121 <slot name="bm-payment-element" data-payment-element></slot> 122 <p data-payment-status role="status">${S(r.paymentLoading)}</p> 123 ${h?`<p role="alert">${S(h)}</p>`:``} 124 <button type="button" class="bw-booking-bar-panel__continue" data-pay-deposit disabled>${S(r.payDepositCta)}</button> 125 </div>`}function O(){let e=g?.localOnly===!0,t=g&&!e?tt({description:g.serviceName,durationMinutes:g.durationMinutes,id:g.bookingId,startsAt:g.startsAt,title:g.serviceName}):``,n=g?.bookingId&&!e?`<span class="bw-booking-bar-panel__ref">${S(r.bookingReference)}: <code>${S(String(g.bookingId).slice(0,8))}</code></span>`:``,i=t?`<a class="bw-booking-bar-panel__calendar" href="${t}" download="booking.ics">${S(r.addToCalendar)}</a>`:``;return` 100 126 <div class="bw-booking-modal__pane"> 101 127 <div class="bw-booking-bar-panel__confirmed"> 102 128 <div class="bw-booking-bar-panel__confirm-icon" aria-hidden="true">✓</div> 103 <strong>${ e?`Test mode — booking not saved`:`Request received`}</strong>104 <span>${ e?`Nothing was sent — this site runs on a local or test address.`:`A confirmation follows by email.`}</span>129 <strong>${S(e?r.testModeHeading:r.successHeading)}</strong> 130 <span>${S(e?r.testModeNote:r.successNote)}</span> 105 131 ${n} 106 ${ r}107 <button type="button" class="bw-booking-bar-panel__continue" data-bar-done> Done</button>132 ${i} 133 <button type="button" class="bw-booking-bar-panel__continue" data-bar-done>${S(r.done)}</button> 108 134 </div> 109 135 </div> 110 `}function T(t,n,r,i){let a=g?C(t,n):p===`details`?S(t,n):p===`confirmed`?w():x(e.services,t,r);return`136 `}function k(t,n,i,a){let o=_?D(t,n):m===`details`?E(t,n):m===`confirmed`?O():T(e.services,t,i);return` 111 137 <div class="bw-booking-modal"> 112 138 <div class="bw-booking-modal__topbar"> … … 114 140 <span class="bw-booking-modal__brand-mark">B</span> 115 141 <span> 116 <strong>${ l(e.title)}</strong>117 <em>${ l(p===`details`?`Enter your details`:p===`confirmed`?`Request received`:`Choose a time`)}</em>142 <strong>${S(e.title)}</strong> 143 <em>${S(m===`details`?r.detailsHeading:m===`confirmed`?r.successHeading:r.chooseTime)}</em> 118 144 </span> 119 145 </span> 120 <button type="button" class="bw-booking-bar-panel__close" aria-label=" Close booking bar">×</button>146 <button type="button" class="bw-booking-bar-panel__close" aria-label="${S(r.close)}">×</button> 121 147 </div> 122 ${ Ie(p)}123 <div class="bw-booking-modal__body">${ a}</div>124 ${e.showBranding?`<div class="bw-booking-modal__powered"> Powered by<strong>Bookminto</strong></div>`:``}125 ${ p===`time`?`<div class="bw-booking-modal__footer">148 ${vt(m,r)} 149 <div class="bw-booking-modal__body">${o}</div> 150 ${e.showBranding?`<div class="bw-booking-modal__powered">${S(r.widgetFooterPrefix)} <strong>Bookminto</strong></div>`:``} 151 ${m===`time`?`<div class="bw-booking-modal__footer"> 126 152 <span> 127 <strong>${ l(t?.name||`No service selected`)}</strong>128 <span>${ l(Fe(t,n))}</span>153 <strong>${S(t?.name||r.noServiceSelected)}</strong> 154 <span>${S(_t(t,n,r,e.language))}</span> 129 155 </span> 130 <button type="button" class="bw-booking-bar-panel__continue" ${ i?``:`disabled`}>${i?`Continue booking`:`No time selected`}</button>156 <button type="button" class="bw-booking-bar-panel__continue" ${a?``:`disabled`}>${S(a?r.continueDefault:r.noTimeSelected)}</button> 131 157 </div>`:``} 132 158 </div> 133 `}function E(){_?.destroy(),_=null;let t=e.services,n=t[s],i=ie(e,n,6),d=i[c],f=!!(n&&d),x=e.loadError?`Booking setup unavailable`:t.length>1?`Choose a service and time`:n?u(n):`No services available yet`;if(r.dataset.open=String(o),b(),r.innerHTML=`159 `}function A(){v?.destroy(),v=null;let t=e.services,n=t[f],c=ge(e,n)?[]:_e(e,n,6),l=c[p],w=!!(n&&l),ee=e.loadError?r.loadError:t.length>1?r.selectServiceAndTime:n?C(n,e.language):r.emptyServices;if(s.dataset.open=String(d),x(),s.innerHTML=` 134 160 <div class="bw-booking-bar"> 135 <button type="button" class="bw-booking-bar__main" aria-expanded="${ o}">161 <button type="button" class="bw-booking-bar__main" aria-expanded="${d}"> 136 162 <span class="bw-booking-bar__mark">B</span> 137 163 <span> 138 <strong>${ l(e.title)}</strong>139 <em>${ l(x)}</em>164 <strong>${S(e.title)}</strong> 165 <em>${S(ee)}</em> 140 166 </span> 141 167 </button> 142 <button type="button" class="bw-booking-bar__cta">${ o?`Close`:n?`Choose time`:`View setup`}</button>168 <button type="button" class="bw-booking-bar__cta">${S(d?r.close:n?r.chooseTime:r.viewSetup)}</button> 143 169 </div> 144 `, a.hidden=!o,a.className=`bw-booking-bar-panel bw-booking-bar-panel--booking`,a.innerHTML=T(n,d,i,f),r.querySelector(`.bw-booking-bar__main`)?.addEventListener(`click`,()=>{o=!o,p=`time`,E()}),r.querySelector(`.bw-booking-bar__cta`)?.addEventListener(`click`,()=>{o=!o,p=`time`,E()}),a.querySelector(`.bw-booking-bar-panel__close`)?.addEventListener(`click`,()=>{o=!1,p=`time`,E()}),a.querySelectorAll(`[data-panel-service-index]`).forEach(e=>{e.setAttribute(`data-active`,String(Number(e.getAttribute(`data-panel-service-index`))===s)),e.addEventListener(`click`,()=>{s=Number(e.getAttribute(`data-panel-service-index`))||0,c=0,m=``,o=!0,p=`time`,E()})}),a.querySelectorAll(`[data-slot-index]`).forEach(e=>{e.addEventListener(`click`,()=>{c=Number(e.getAttribute(`data-slot-index`))||0,m=``,E()})}),a.querySelector(`.bw-booking-bar-panel__continue`)?.addEventListener(`click`,()=>{p===`time`&&f&&(p=`details`,E())}),a.querySelector(`.bw-booking-bar-panel__back`)?.addEventListener(`click`,()=>{p=`time`,m=``,E()}),a.querySelector(`[data-bar-done]`)?.addEventListener(`click`,()=>{o=!1,p=`time`,E()}),g){let t=a.querySelector(`[data-payment-element]`),r=a.querySelector(`[data-pay-deposit]`),i=g;Ae(t,{...i,accent:e.color}).then(e=>{if(!t.isConnected||g!==i){e.destroy();return}_=e,r.disabled=!1,a.querySelector(`[data-payment-status]`).textContent=``}).catch(e=>{t.isConnected&&g===i&&(a.querySelector(`[data-payment-status]`).textContent=``,Oe(t,e,E))}),r.addEventListener(`click`,async()=>{if(!_||v)return;v=!0,r.disabled=!0;let t=_;try{let r=await t.confirm();await je(e,{bookingId:i.bookingId,paymentIntentId:r}),h={bookingId:i.bookingId,serviceName:n?.name,durationMinutes:n?.durationMinutes,startsAt:d?.startsAt},g=null,p=`confirmed`,m=``}catch(e){m=e instanceof Error?e.message:`Payment could not be confirmed. Try again.`}finally{v=!1,E()}})}a.querySelector(`.bw-booking-bar-panel__form`)?.addEventListener(`submit`,t=>{if(t.preventDefault(),v)return;v=!0;let r=new FormData(t.currentTarget);y=Object.fromEntries(Object.keys(y).map(e=>[e,String(r.get(e)||``)])),m=`Sending request...`,E();let i=n,a=d;Te(e,{customerEmail:r.get(`customerEmail`),customerName:r.get(`customerName`),customerPhone:r.get(`customerPhone`),notes:r.get(`notes`),serviceId:n?.id||n?.name,serviceName:n?.name,startsAt:d?.startsAt}).then(e=>{if(e?.redirectedToCheckout)return;let t=De(e);if(t){g=t,m=``,E();return}h={bookingId:e&&typeof e==`object`?e.bookingId:void 0,durationMinutes:i?.durationMinutes,localOnly:e?.localOnly===!0,serviceName:i?.name||`Appointment`,startsAt:a?.startsAt},p=`confirmed`,m=``,E()}).catch(e=>{m=e instanceof Error?e.message:`Booking could not be created`,p=`details`,E()}).finally(()=>{v=!1})})}return E(),n.prepend(i,r,a),r}let K={title:`Schedule a booking`,widgetStepLabels:[`Service`,`Date`,`Details`,`Confirmed`],chooseService:`Choose an offering`,chooseDate:`Choose a date`,availableTimes:`Available times`,summaryLabel:`Your selection`,totalLabel:`Price`,detailsHeading:`Your details`,previousDates:`Previous dates`,nextDates:`Next dates`,successHeading:`Booking confirmed!`,successNote:`You will receive a confirmation email shortly with your booking details.`,emptySlots:`No times available for this date.`,emptyServices:`No offerings are available right now.`,bookNow:`Book now`,back:`Back`,at:`at`,nameLabel:`Name`,phoneLabel:`Phone`,notesLabel:`Notes`,emailLabel:`Email`,continueFromService:`Select and continue`,continueDefault:`Continue`,checkoutCta:`Continue to checkout`,confirmCta:`Confirm booking`,payDepositCta:`Pay deposit`,depositHeading:`Pay deposit to confirm`,depositNote:`Your time is held while you complete payment.`,processing:`Processing...`,redirectingToCheckout:`Redirecting to checkout…`,close:`Close`,addToCalendar:`Add to calendar`,widgetFooterPrefix:`Powered by`,widgetFooterBrand:`bookminto`,namePlaceholder:`Jane Smith`,phonePlaceholder:`+1 555 0100`,emailPlaceholder:`jane@company.com`,notesPlaceholder:`Specific questions or context…`,loading:`Loading…`,genericError:`Something went wrong. Please try again.`,loadError:`Booking setup could not be loaded.`,previewOnlyNote:`Preview only — no booking was saved and no email was sent.`,testModeHeading:`Test mode — booking not saved`,testModeNote:`This site is running on a local or test address, so nothing was sent to Bookminto and no confirmation email will arrive.`};function ze(e,t,n={}){if(!e||!t||typeof e.scrollTo!=`function`)return 0;let r=Number(n.top)||0,i=Number(n.bottom)||16,a=e.getBoundingClientRect(),o=t.getBoundingClientRect(),s=a.top+r,c=a.bottom-i;if(o.top<s){let t=o.top-s;return e.scrollTo({top:Math.max(e.scrollTop+t,0),behavior:`smooth`}),Math.abs(t)}if(o.bottom>c){let t=o.bottom-c;return e.scrollTo({top:Math.max(e.scrollTop+t,0),behavior:`smooth`}),Math.abs(t)}return 0}function q(e,t={}){let n=t.ctaSelector||`[data-action="continue"]`,r=t.containerSelector||null,i=t.margins||{top:8,bottom:20},a=()=>{let t=e?.querySelector?.(n);if(!t)return;let a=r?e.querySelector(r):t.closest(`.widget-body-scroll, .widget-body, .panel-right-scroll, .panel-right, .bw-booking-modal__body`);if(a){if(!a.contains(t)){a.scrollTo({top:Math.max(a.scrollHeight-a.clientHeight,0),behavior:`smooth`});return}ze(a,t,i)}};if(typeof window.requestAnimationFrame==`function`){window.requestAnimationFrame(()=>{window.requestAnimationFrame(a)});return}if(typeof window.setTimeout==`function`){window.setTimeout(a,0);return}a()}let J=[`Service`,`Date & time`,`Details`,`Confirmed`];function Be(e){let t=new Date(`${e}T12:00:00`);return{day:String(t.getDate()),month:new Intl.DateTimeFormat(`en-US`,{month:`short`}).format(t).replace(`.`,``),weekday:new Intl.DateTimeFormat(`en-US`,{weekday:`short`}).format(t).replace(`.`,``)}}function Ve(e){return e?new Intl.DateTimeFormat(`en-US`,{weekday:`short`,day:`numeric`,month:`short`}).format(new Date(`${e}T12:00:00`)):``}function He(e){if(!e.length)return``;let t=new Intl.DateTimeFormat(`en-US`,{day:`numeric`,month:`short`});return`${t.format(new Date(`${e[0]}T12:00:00`))} - ${t.format(new Date(`${e[e.length-1]}T12:00:00`))}`}function Ue(e){let t=Math.min(e+1,J.length),n=J[t-1];return`170 `,u.hidden=!d,u.className=`bw-booking-bar-panel bw-booking-bar-panel--booking`,u.innerHTML=k(n,l,c,w),Ve(u),s.querySelector(`.bw-booking-bar__main`)?.addEventListener(`click`,()=>{d=!d,m=`time`,A(),d?u.querySelector(`.bw-booking-bar-panel__close`)?.focus():s.querySelector(`.bw-booking-bar__cta`)?.focus()}),s.querySelector(`.bw-booking-bar__cta`)?.addEventListener(`click`,()=>{d=!d,m=`time`,A(),d?u.querySelector(`.bw-booking-bar-panel__close`)?.focus():s.querySelector(`.bw-booking-bar__cta`)?.focus()}),u.querySelector(`.bw-booking-bar-panel__close`)?.addEventListener(`click`,()=>{d=!1,m=`time`,A(),s.querySelector(`.bw-booking-bar__cta`)?.focus()}),u.querySelectorAll(`[data-panel-service-index]`).forEach(e=>{e.setAttribute(`data-active`,String(Number(e.getAttribute(`data-panel-service-index`))===f)),e.addEventListener(`click`,()=>{f=Number(e.getAttribute(`data-panel-service-index`))||0,i=bt(),a=!1,o={},p=0,h=``,d=!0,m=`time`,A()})}),u.querySelectorAll(`[data-slot-index]`).forEach(e=>{e.addEventListener(`click`,()=>{p=Number(e.getAttribute(`data-slot-index`))||0,i=bt(),a=!1,h=``,A()})}),u.querySelector(`.bw-booking-bar-panel__continue`)?.addEventListener(`click`,()=>{m===`time`&&w&&(m=`details`,A())}),u.querySelector(`.bw-booking-bar-panel__back`)?.addEventListener(`click`,()=>{m=`time`,h=``,A()}),u.querySelector(`[data-bar-done]`)?.addEventListener(`click`,()=>{i=bt(),a=!1,o={},d=!1,m=`time`,A()}),_){let t=u.querySelector(`[data-payment-element]`),i=u.querySelector(`[data-pay-deposit]`),a=_;at(t,{...a,accent:e.color,language:e.language}).then(e=>{if(!t.isConnected||_!==a){e.destroy();return}v=e,i.disabled=!1,u.querySelector(`[data-payment-status]`).textContent=``}).catch(n=>{t.isConnected&&_===a&&(u.querySelector(`[data-payment-status]`).textContent=``,rt(t,n,A,e.language))}),i.addEventListener(`click`,async()=>{if(!v||y)return;y=!0,i.disabled=!0;let o=v;try{let t=a.confirmedPaymentIntentId||await o.confirm();if(_!==a||v!==o)return;a.confirmedPaymentIntentId=t;let i=await ot(e,{bookingId:a.bookingId,paymentIntentId:t});if(_!==a||v!==o)return;if(i.processing||!(i.ok||i.alreadyConfirmed))throw Error(r.paymentError);g={bookingId:a.bookingId,serviceName:n?.name,durationMinutes:n?.durationMinutes,startsAt:l?.startsAt},_=null,m=`confirmed`,h=``}catch(e){h=e instanceof Error?e.message:r.paymentError,t.isConnected&&(u.querySelector(`[data-payment-status]`).textContent=h)}finally{y=!1,_===a?i.disabled=!1:A()}})}u.querySelector(`.bw-booking-bar-panel__form`)?.addEventListener(`submit`,t=>{if(t.preventDefault(),y||Number(n?.depositCents)>0&&e.features?.deposits!==!0)return;y=!0;let s=new FormData(t.currentTarget);b=Object.fromEntries(Object.keys(b).map(e=>[e,String(s.get(e)||``)]));let c=s.get(`smsOptIn`)===`on`,u=s.get(`whatsappOptIn`)===`on`;if(o=Object.fromEntries((n.bookingQuestions||[]).map(e=>[e.id,String(s.get(`answer-${e.id}`)||``)])),(c||u)&&!gt(b.customerPhone,b.customerPhoneCountry||void 0)){y=!1,h=u&&!c?r.phoneRequiredForWhatsapp:r.phoneRequiredForSms,A();return}h=r.processing,A();let d=n,f=l;Ze(e,{answers:o,clientRequestId:i,replaceTerminalPayment:a||void 0,publicationRevision:e.publicationRevision,language:e.language||`en`,customerEmail:s.get(`customerEmail`),customerName:s.get(`customerName`),customerPhone:s.get(`customerPhone`),customerPhoneCountry:b.customerPhoneCountry||void 0,notes:s.get(`notes`),serviceId:n?.id||n?.name,serviceName:n?.name,smsOptIn:e.sms?.available===!0&&c||void 0,startsAt:l?.startsAt,whatsappOptIn:e.whatsapp?.available===!0&&u||void 0}).then(t=>{if(t?.redirectedToCheckout)return;let n=nt(t,e.language);if(n){a=!1,_=n,h=``,A();return}g={bookingId:t&&typeof t==`object`?t.bookingId:void 0,durationMinutes:d?.durationMinutes,localOnly:t?.localOnly===!0,serviceName:d?.name||r.appointment,startsAt:f?.startsAt},m=`confirmed`,h=``,A()}).catch(e=>{a=e?.code===`terminal_payment_replacement_available`,h=a?r.replacePaymentNote:e instanceof Error?e.message:r.genericError,m=`details`,A()}).finally(()=>{y=!1})})}return A(),n.prepend(l,s,u),s}function St(e,t,n={}){if(!e||!t||typeof e.scrollTo!=`function`)return 0;let r=Number(n.top)||0,i=Number(n.bottom)||16,a=e.getBoundingClientRect(),o=t.getBoundingClientRect(),s=a.top+r,c=a.bottom-i;if(o.top<s){let t=o.top-s;return e.scrollTo({top:Math.max(e.scrollTop+t,0),behavior:`smooth`}),Math.abs(t)}if(o.bottom>c){let t=o.bottom-c;return e.scrollTo({top:Math.max(e.scrollTop+t,0),behavior:`smooth`}),Math.abs(t)}return 0}function $(e,t={}){let n=t.ctaSelector||`[data-action="continue"]`,r=t.containerSelector||null,i=t.margins||{top:8,bottom:20},a=()=>{let t=e?.querySelector?.(n);if(!t)return;let a=r?e.querySelector(r):t.closest(`.widget-body-scroll, .widget-body, .panel-right-scroll, .panel-right, .bw-booking-modal__body`);if(a){if(!a.contains(t)){a.scrollTo({top:Math.max(a.scrollHeight-a.clientHeight,0),behavior:`smooth`});return}St(a,t,i)}};if(typeof window.requestAnimationFrame==`function`){window.requestAnimationFrame(()=>{window.requestAnimationFrame(a)});return}if(typeof window.setTimeout==`function`){window.setTimeout(a,0);return}a()}function Ct(e,t){let n=new Date(`${e}T12:00:00`);return{day:String(n.getDate()),month:new Intl.DateTimeFormat(t||`en-US`,{month:`short`}).format(n).replace(`.`,``),weekday:new Intl.DateTimeFormat(t||`en-US`,{weekday:`short`}).format(n).replace(`.`,``)}}function wt(e){return f(e,``).split(/\s+/).filter(Boolean)[0]||``}function Tt(e){return f(e,``).split(/\s+/).filter(Boolean).slice(0,2).map(e=>e.charAt(0)).join(``).toUpperCase()}function Et(e){let t=f(e,``);return/^#[0-9a-fA-F]{6}$/.test(t)?t:``}function Dt(e,t){let n=f(e,``);return n.startsWith(`https://`)||t&&/^data:image\/(?:png|jpe?g|webp|gif);base64,/i.test(n)?n:``}function Ot(e,t){return e?new Intl.DateTimeFormat(t||`en-US`,{weekday:`short`,day:`numeric`,month:`short`}).format(new Date(`${e}T12:00:00`)):``}function kt(e,t){if(!e.length)return``;let n=new Intl.DateTimeFormat(t||`en-US`,{day:`numeric`,month:`short`});return`${n.format(new Date(`${e[0]}T12:00:00`))} - ${n.format(new Date(`${e[e.length-1]}T12:00:00`))}`}function At(e,t){let n=t.widgetStepLabels,r=Math.min(e+1,n.length),i=n[r-1];return` 145 171 <div 146 172 class="stepper" 147 173 role="progressbar" 148 aria-label=" Booking progress"174 aria-label="${S(t.progressLabel)}" 149 175 aria-valuemin="1" 150 aria-valuemax="${ J.length}"151 aria-valuenow="${ t}"152 aria-valuetext=" Step ${t} of ${J.length}: ${l(n)}"176 aria-valuemax="${n.length}" 177 aria-valuenow="${r}" 178 aria-valuetext="${S(t.stepLabel)} ${r} ${S(t.stepOf)} ${n.length}: ${S(i)}" 153 179 > 154 180 <div class="stepper-meta" aria-hidden="true"> 155 <span> Step ${t} of ${J.length}</span>156 <strong>${ l(n)}</strong>181 <span>${S(t.stepLabel)} ${r} ${S(t.stepOf)} ${n.length}</span> 182 <strong>${S(i)}</strong> 157 183 </div> 158 184 <div class="stepper-track" aria-hidden="true"> 159 ${ J.map((t,n)=>`<span class="stepper-segment${n<=e?` is-complete`:``}"></span>`).join(``)}185 ${n.map((t,n)=>`<span class="stepper-segment${n<=e?` is-complete`:``}"></span>`).join(``)} 160 186 </div> 161 187 </div> 162 `}function We(e){let t=e.previewMode?document.createElement(`div`):document.createElement(`dialog`);t.className=`modal-layer`,t.setAttribute(`role`,`dialog`),t.setAttribute(`aria-modal`,`true`),t.setAttribute(`aria-label`,e.title||K.title),G(t,e.color);let n=0,r=e.services[0]?.id||e.services[0]?.name||``,i=null,a=null,c=0,u=``,p=!1,m=null,h=null,g=null,_={customerEmail:``,customerName:``,customerPhone:``,notes:``};function v(){return e.services.find(e=>(e.id||e.name)===r)||e.services[0]||null}function y(){return oe(e,v())}function b(e){return e.slice(c,c+5)}function x(e){return!i||!a?null:(e.get(i)||[]).find(e=>e.id===a)||null}function S(e){return n===0?!!v():n===1?!!x(e):n===2?!!(_.customerName.trim()&&_.customerEmail.trim()):!1}function C(){return p?K.processing:n===0?K.continueFromService:h?K.payDepositCta:n===2?K.confirmCta:K.continueDefault}function w(e){return new Intl.NumberFormat(`en-US`,{style:`currency`,currency:`EUR`}).format((Number(e)||0)/100)}function T(e,t){return`<div class="runtime-state${t===`danger`?` runtime-state--danger`:``}">${l(e)}</div>`}function E(){_={customerEmail:t.querySelector(`[name="customerEmail"]`)?.value||_.customerEmail,customerName:t.querySelector(`[name="customerName"]`)?.value||_.customerName,customerPhone:t.querySelector(`[name="customerPhone"]`)?.value||_.customerPhone,notes:t.querySelector(`[name="notes"]`)?.value||_.notes}}function D(){n=0,i=null,a=null,c=0,u=``,p=!1,m=null,h=null,g&&=(g.destroy(),null),_={customerEmail:``,customerName:``,customerPhone:``,notes:``},j()}function O(e,t=S(e)){return` 163 <button class="cta" type="button" data-action="continue" ${t&&!p?``:`disabled`}> 164 <span>${l(C())}</span><svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1 7h12M7 1l6 6-6 6"></path></svg> 188 `}function jt(e){let t=c(e.language),n=u(e.language),r=e.previewMode?document.createElement(`div`):document.createElement(`dialog`);r.className=`modal-layer`,r.setAttribute(`role`,`dialog`),r.setAttribute(`aria-modal`,`true`),r.setAttribute(`aria-label`,e.title||t.title),Q(r,e.color);let i=0,a=e.services[0]?.id||e.services[0]?.name||``,o=null,s=null,l=null,d=0,p=e.calendarVerification===`external_unverified`?{de:`Die Kalenderverfügbarkeit kann derzeit nicht geprüft werden. Bitte versuchen Sie es später erneut.`,en:`Calendar availability cannot currently be verified. Please try again later.`,es:`No se puede verificar la disponibilidad del calendario ahora mismo. Inténtalo más tarde.`,fr:`La disponibilité du calendrier ne peut pas être vérifiée. Réessayez plus tard.`,it:`La disponibilità del calendario non può essere verificata al momento. Riprova più tardi.`,nl:`De beschikbaarheid van de agenda kan nu niet worden gecontroleerd. Probeer het later opnieuw.`}[e.language]||`Calendar availability cannot currently be verified. Please try again later.`:``,m=!1,h=null,g=null,_=null,v=k(),x=!1,C={answers:{},customerEmail:``,customerName:``,customerPhone:``,notes:``,rebookingOptIn:!1},T=!1,E=!1,D=!1,O={customerEmail:``,customerName:``,dateFrom:``,dateTo:``};function k(){try{if(typeof crypto?.randomUUID==`function`)return crypto.randomUUID()}catch{}return`bmr-${Date.now().toString(36)}-${Math.random().toString(36).slice(2,12)}`}function A(){return e.services.find(e=>(e.id||e.name)===a)||e.services[0]||null}function j(){return e.staffDisplayMode===`customer_choice`?he(e,A()):[]}function M(){if(l)return j().some(e=>e.id===l)?l:void 0}function N(){let e=M();if(!e)return``;let t=j().find(t=>t.id===e);return t?f(t.name,``):``}function P(){return ye(e,A(),M())}function F(e){return e.slice(d,d+5)}function I(e){return!o||!s?null:(e.get(o)||[]).find(e=>e.id===s)||null}function L(t){return i===0?!!A():i===1?!!I(t):i===2?Number(A()?.depositCents)>0&&e.features?.deposits!==!0||!C.customerName.trim()||!C.customerEmail.trim()?!1:(A()?.bookingQuestions||[]).every(e=>!e.required||!!String(C.answers?.[e.id]||``).trim()):!1}function R(){return m?t.processing:i===0?t.continueFromService:g?t.payDepositCta:i===2?x?t.replacePaymentCta:t.confirmCta:t.continueDefault}function te(e){return new Intl.NumberFormat(n,{style:`currency`,currency:`EUR`}).format((Number(e)||0)/100)}function z(e,t){return`<div class="runtime-state${t===`danger`?` runtime-state--danger`:``}">${S(e)}</div>`}function B(e){let n=Array.isArray(e?.bookingQuestions)?e.bookingQuestions:[];return n.length?` 189 <span class="slabel slabel--with-top-gap">${S(t.questionsHeading)}</span> 190 ${n.map(e=>{let n=`bm-modal-question-${S(e.id)}`,r=String(C.answers?.[e.id]||``),i=e.required?` <span class="form-required" aria-hidden="true">*</span><span class="sr-only"> ${S(t.requiredHint)}</span>`:``,a=`<label class="flabel" for="${n}">${S(e.label)}${i}</label>`;return e.type===`yesno`?` 191 <div class="form-field"> 192 ${a} 193 <select class="finput" id="${n}" data-question-id="${S(e.id)}"> 194 <option value="">${S(t.chooseOption)}</option> 195 <option value="yes"${r===`yes`?` selected`:``}>${S(t.yesLabel)}</option> 196 <option value="no"${r===`no`?` selected`:``}>${S(t.noLabel)}</option> 197 </select> 198 </div> 199 `:e.type===`choice`?` 200 <div class="form-field"> 201 ${a} 202 <select class="finput" id="${n}" data-question-id="${S(e.id)}"> 203 <option value="">${S(t.chooseOption)}</option> 204 ${(e.options||[]).map(e=>`<option value="${S(e)}"${r===e?` selected`:``}>${S(e)}</option>`).join(``)} 205 </select> 206 </div> 207 `:` 208 <div class="form-field"> 209 ${a} 210 <input class="finput" id="${n}" data-question-id="${S(e.id)}" maxlength="300" value="${S(r)}" /> 211 </div> 212 `}).join(``)} 213 `:``}function V(){let e=e=>{let t=r.querySelector(`[name="${e}"]`);return t?t.value:void 0};C={...C,customerEmail:e(`customerEmail`)??C.customerEmail,customerName:e(`customerName`)??C.customerName,customerPhone:e(`customerPhone`)??C.customerPhone,notes:e(`notes`)??C.notes},r.querySelectorAll(`[data-question-id]`).forEach(e=>{let t=e.getAttribute(`data-question-id`);t&&(C.answers[t]=e.value)});let t=r.querySelector(`[data-rebooking-optin]`);t&&(C.rebookingOptIn=t.checked===!0)}function ne(){i=0,o=null,s=null,l=null,d=0,p=``,m=!1,h=null,g=null,_&&=(_.destroy(),null),v=k(),x=!1,T=!1,E=!1,D=!1,O={customerEmail:``,customerName:``,dateFrom:``,dateTo:``},C={answers:{},customerEmail:``,customerName:``,customerPhone:``,notes:``,rebookingOptIn:!1},K()}function H(e,t=L(e)){return` 214 <button class="cta" type="button" data-action="continue" ${t&&!m?``:`disabled`}> 215 <span>${S(R())}</span><svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1 7h12M7 1l6 6-6 6"></path></svg> 165 216 </button> 166 `}function k(e,t){return`217 `}function U(e,t){return` 167 218 <div class="panel-right-flow"> 168 219 <div class="panel-right-scroll">${e}</div> 169 220 ${t} 170 221 </div> 171 `}function ee(t,a){return n===0?e.loadError?T(K.loadError,`danger`):e.services.length?` 172 <span class="slabel">${l(K.chooseService)}</span> 173 ${e.services.map(e=>{let t=e.id||e.name;return` 174 <button type="button" class="svc${t===r?` sel`:``}" data-service-id="${l(t)}"> 222 `}function re(r,s){return i===0?e.loadError?z(t.loadError,`danger`):e.services.length?` 223 <span class="slabel">${S(t.chooseService)}</span> 224 ${e.services.map(t=>{let n=t.id||t.name;return` 225 <button type="button" class="svc${n===a?` sel`:``}" data-service-id="${S(n)}"> 226 ${Be(t.imageUrl,e.previewMode)} 175 227 <div class="svc-info"> 176 <div class="svc-name">${ l(e.name)}</div><div class="service-price">${l(d(e))}</div>177 ${ e.description?`<div class="svc-desc">${l(e.description)}</div>`:``}228 <div class="svc-name">${S(t.name)}</div><div class="service-price">${S(w(t))}</div> 229 ${t.description?`<div class="svc-desc">${S(t.description)}</div>`:``} 178 230 </div> 179 <div class="svc-badge">${ l(o(e.durationMinutes))}</div>231 <div class="svc-badge">${S(y(t.durationMinutes))}</div> 180 232 </button> 181 233 `}).join(``)} 182 `: T(K.emptyServices):`183 <span class="slabel">${ l(K.summaryLabel)}</span>184 ${ t?`<div class="svc sel svc--static">234 `:z(t.emptyServices):` 235 <span class="slabel">${S(t.summaryLabel)}</span> 236 ${r?`<div class="svc sel svc--static"> 185 237 <div class="svc-info"> 186 <div class="svc-name">${ l(t.name)}</div>187 ${ t.description?`<div class="svc-desc">${l(t.description)}</div>`:``}238 <div class="svc-name">${S(r.name)}</div> 239 ${r.description?`<div class="svc-desc">${S(r.description)}</div>`:``} 188 240 </div> 189 <div class="svc-badge">${ l(o(t.durationMinutes))}</div>241 <div class="svc-badge">${S(y(r.durationMinutes))}</div> 190 242 </div>`:``} 191 ${i&&a?`<div class="sumbox sumbox--compact"> 192 <div class="sumrow">${l(Ve(i))}</div> 193 <div class="sumrow">${l(a.timeLabel)} · ${l(s(t?.price))}</div> 243 ${o&&s?`<div class="sumbox sumbox--compact"> 244 <div class="sumrow">${S(Ot(o,n))}</div> 245 <div class="sumrow">${S(s.timeLabel)} · ${S(b(r?.price))}</div> 246 ${N()?`<div class="sumrow">${S(t.withStaff.replace(`{name}`,N()))}</div>`:``} 194 247 </div>`:``} 195 `}function A(t,r,o,s){if(h)return k(` 196 <span class="slabel">${l(K.depositHeading)}</span> 197 <div class="modal-intro-copy">${l(K.depositNote)}</div> 248 `}function W(){let n=j();if(n.length<2)return``;let r=M(),i=n.map(t=>{let n=f(t.name,``),i=Et(t.color),a=Dt(t.avatarUrl,e.previewMode),o=t.id===r,s=a?`<img class="staff-chip-avatar" src="${S(a)}" alt="" loading="lazy" decoding="async" referrerpolicy="no-referrer">`:``;return` 249 <button type="button" class="staff-chip${o?` selected`:``}" data-staff-id="${S(t.id)}" aria-pressed="${o}"> 250 <span class="staff-chip-dot"${i?` style="background:${S(i)}"`:``}>${S(Tt(n))}${s}</span> 251 <span class="staff-chip-name">${S(wt(n)||n)}</span> 252 </button> 253 `}).join(``);return` 254 <span class="slabel">${S(t.staffPickerLabel)}</span> 255 <div class="staff-row" role="group" aria-label="${S(t.staffPickerLabel)}"> 256 <button type="button" class="staff-chip${r?``:` selected`}" data-staff-id="" aria-pressed="${!r}"> 257 <span class="staff-chip-name">${S(t.anyStaff)}</span> 258 </button> 259 ${i} 260 </div> 261 `}function ie(){let n=A();return e.features?.waitlist!==!0||!n?``:D?`<div class="runtime-state waitlist-joined" role="status">${S(t.waitlistJoined)}</div>`:T?` 262 <div class="waitlist-form"> 263 <span class="slabel slabel--with-top-gap">${S(t.waitlistHeading)}</span> 264 <p class="waitlist-note">${S(t.waitlistNote)}</p> 265 <div class="form-field"> 266 <label class="flabel" for="bm-wl-name">${S(t.nameLabel)}</label> 267 <input class="finput" id="bm-wl-name" data-waitlist-field="customerName" autocomplete="name" value="${S(O.customerName)}" /> 268 </div> 269 <div class="form-field"> 270 <label class="flabel" for="bm-wl-email">${S(t.emailLabel)}</label> 271 <input class="finput" id="bm-wl-email" data-waitlist-field="customerEmail" autocomplete="email" type="email" value="${S(O.customerEmail)}" /> 272 </div> 273 <div class="form-row"> 274 <div class="form-field"> 275 <label class="flabel" for="bm-wl-from">${S(t.waitlistFrom)}</label> 276 <input class="finput" id="bm-wl-from" data-waitlist-field="dateFrom" type="date" value="${S(O.dateFrom)}" /> 277 </div> 278 <div class="form-field"> 279 <label class="flabel" for="bm-wl-to">${S(t.waitlistTo)}</label> 280 <input class="finput" id="bm-wl-to" data-waitlist-field="dateTo" type="date" value="${S(O.dateTo)}" /> 281 </div> 282 </div> 283 <button type="button" class="cta" data-action="waitlist-submit" ${E?`disabled`:``}> 284 <span>${S(E?t.processing:t.waitlistSubmit)}</span> 285 </button> 286 </div> 287 `:`<button type="button" class="waitlist-cta" data-action="waitlist-open">${S(t.waitlistCta)}</button>`}function G(r,a,c,l){if(g)return U(` 288 <span class="slabel">${S(t.depositHeading)}</span> 289 <div class="modal-intro-copy">${S(t.depositNote)}</div> 198 290 <div class="sumbox"> 199 <div class="sumrow"><strong>${ l(w(h.depositCents))}</strong> deposit</div>291 <div class="sumrow"><strong>${S(te(g.depositCents))}</strong> ${S(t.depositLabel)}</div> 200 292 </div> 201 < div class="bw-payment-element" data-payment-element></div>202 ${ u?`<div class="runtime-state runtime-state--danger">${l(u)}</div>`:``}203 `, O(o,!p));if(n===0)return k(`293 <slot name="bm-payment-element" data-payment-element><div class="bw-payment-element"></div></slot> 294 ${p?`<div class="runtime-state runtime-state--danger">${S(p)}</div>`:``} 295 `,H(c,!m));if(i===0)return U(` 204 296 <div class="modal-intro-copy"> 205 297 Choose a service on the left, then continue to pick a date and time. 206 298 </div> 207 `,O(o));if(n===1){let t=b(r),n=i&&o.get(i)||[],s=c>0,u=c+5<r.length;return k(` 208 <button class="back-btn" type="button" data-action="back">← ${l(K.back)}</button> 209 <span class="slabel">${l(K.chooseDate)}</span><p class="booking-timezone">${l(f(e))}</p> 299 `,H(c));if(i===1){if(ge(e,r))return U(` 300 <button class="back-btn" type="button" data-action="back">← ${S(t.back)}</button> 301 ${z(t.serviceNotBookable,`danger`)} 302 `,H(c));let i=F(a),l=o&&c.get(o)||[],u=d>0,f=d+5<a.length;return U(` 303 <button class="back-btn" type="button" data-action="back">← ${S(t.back)}</button> 304 ${W()} 305 <span class="slabel">${S(t.chooseDate)}</span><p class="booking-timezone">${S(ee(e))}</p> 210 306 <div class="date-nav"> 211 <span class="date-nav-copy">${ l(He(t))}</span>307 <span class="date-nav-copy">${S(kt(i,n))}</span> 212 308 <div class="date-nav-buttons"> 213 <button type="button" class="date-nav-btn" data-action="prev-dates" ${ s?``:`disabled`}><svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9.5 2.5 5 7l4.5 4.5"></path></svg></button>214 <button type="button" class="date-nav-btn" data-action="next-dates" ${ u?``:`disabled`}><svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4.5 2.5 9 7l-4.5 4.5"></path></svg></button>309 <button type="button" class="date-nav-btn" data-action="prev-dates" ${u?``:`disabled`}><svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9.5 2.5 5 7l4.5 4.5"></path></svg></button> 310 <button type="button" class="date-nav-btn" data-action="next-dates" ${f?``:`disabled`}><svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4.5 2.5 9 7l-4.5 4.5"></path></svg></button> 215 311 </div> 216 312 </div> 217 313 <div class="date-grid"> 218 ${ t.length?t.map(e=>{let t=Be(e);return`219 <button type="button" class="dbtn${e=== i?` sel`:``}" data-date="${l(e)}">220 <div class="dbtn-dow">${ l(t.weekday)}</div>221 <div class="dbtn-day">${ l(t.day)}</div>222 <div class="dbtn-mon">${ l(t.month)}</div>314 ${i.length?i.map(e=>{let t=Ct(e,n);return` 315 <button type="button" class="dbtn${e===o?` sel`:``}" data-date="${S(e)}"> 316 <div class="dbtn-dow">${S(t.weekday)}</div> 317 <div class="dbtn-day">${S(t.day)}</div> 318 <div class="dbtn-mon">${S(t.month)}</div> 223 319 </button> 224 `}).join(``): T(K.emptySlots)}320 `}).join(``):z(t.emptySlots)} 225 321 </div> 226 ${ i?`227 <span class="slabel slabel--with-top-gap">${ l(K.availableTimes)}</span>228 ${ n.length?`<div class="time-grid">229 ${ n.map(e=>`<button type="button" class="tbtn${e.id===a?` sel`:``}" data-slot-id="${l(e.id)}">${l(e.timeLabel)}</button>`).join(``)}230 </div>`: T(K.emptySlots)}322 ${o?` 323 <span class="slabel slabel--with-top-gap">${S(t.availableTimes)}</span> 324 ${l.length?`<div class="time-grid"> 325 ${l.map(e=>`<button type="button" class="tbtn${e.id===s?` sel`:``}${e.recommended?` rec`:``}" data-slot-id="${S(e.id)}">${S(e.timeLabel)}${e.recommended?`<em class="rec-badge">${S(t.recommendedBadge)}</em>`:``}</button>`).join(``)} 326 </div>`:z(t.emptySlots)} 231 327 `:``} 232 `,O(o))}if(n===2)return k(` 233 <button class="back-btn" type="button" data-action="back">← ${l(K.back)}</button> 234 <span class="slabel">${l(K.detailsHeading)}</span> 328 ${!i.length||!l.length?ie():``} 329 ${p?`<div class="runtime-state runtime-state--danger">${S(p)}</div>`:``} 330 `,H(c))}if(i===2)return U(` 331 <button class="back-btn" type="button" data-action="back">← ${S(t.back)}</button> 332 <span class="slabel">${S(t.detailsHeading)}</span> 333 ${Number(r?.depositCents)>0&&e.features?.deposits!==!0?`<p class="modal-intro-copy" role="note">${S(t.depositUnavailable)}</p>`:``} 235 334 <div class="form-field"> 236 <label class="flabel" for="bm-modal-name">${ l(K.nameLabel)}</label>237 <input class="finput" id="bm-modal-name" name="customerName" value="${ l(_.customerName)}" placeholder="${l(K.namePlaceholder)}" autocomplete="name" />335 <label class="flabel" for="bm-modal-name">${S(t.nameLabel)}</label> 336 <input class="finput" id="bm-modal-name" name="customerName" value="${S(C.customerName)}" placeholder="${S(t.namePlaceholder)}" autocomplete="name" /> 238 337 </div> 239 338 <div class="form-row"> 240 339 <div class="form-field"> 241 <label class="flabel" for="bm-modal-email">${ l(K.emailLabel)}</label>242 <input class="finput" id="bm-modal-email" name="customerEmail" type="email" value="${ l(_.customerEmail)}" placeholder="${l(K.emailPlaceholder)}" autocomplete="email" />340 <label class="flabel" for="bm-modal-email">${S(t.emailLabel)}</label> 341 <input class="finput" id="bm-modal-email" name="customerEmail" type="email" value="${S(C.customerEmail)}" placeholder="${S(t.emailPlaceholder)}" autocomplete="email" /> 243 342 </div> 244 343 <div class="form-field"> 245 <label class="flabel" for="bm-modal-phone">${ l(K.phoneLabel)}</label>246 <input class="finput" id="bm-modal-phone" name="customerPhone" value="${ l(_.customerPhone)}" placeholder="${l(K.phonePlaceholder)}" autocomplete="tel" />344 <label class="flabel" for="bm-modal-phone">${S(t.phoneLabel)}</label> 345 <input class="finput" id="bm-modal-phone" name="customerPhone" value="${S(C.customerPhone)}" placeholder="${S(t.phonePlaceholder)}" autocomplete="tel" /> 247 346 </div> 248 347 </div> 249 348 <div class="form-field"> 250 <label class="flabel" for="bm-modal-notes">${ l(K.notesLabel)}</label>251 <textarea class="finput finput--textarea" id="bm-modal-notes" name="notes" placeholder="${ l(K.notesPlaceholder)}">${l(_.notes)}</textarea>349 <label class="flabel" for="bm-modal-notes">${S(t.notesLabel)}</label> 350 <textarea class="finput finput--textarea" id="bm-modal-notes" name="notes" placeholder="${S(t.notesPlaceholder)}">${S(C.notes)}</textarea> 252 351 </div> 253 ${u?`<div class="runtime-state runtime-state--danger">${l(u)}</div>`:``} 254 `,O(o));let d=e.previewMode||m?.localOnly===!0,g=m&&!d?Ee({description:m.notes||``,durationMinutes:t?.durationMinutes||30,id:m.bookingId||m.id,startsAt:s?.startsAt||i,title:`${t?.name||`Booking`} — ${e.title||K.title}`}):``;return` 352 ${B(r)} 353 ${Number(r?.rebookingIntervalWeeks)>0?`<label class="rebooking-optin"> 354 <input type="checkbox" data-rebooking-optin ${C.rebookingOptIn?`checked`:``} /> 355 <span>${S(t.rebookingOptInLabel)}</span> 356 </label>`:``} 357 ${p?`<div class="runtime-state runtime-state--danger">${S(p)}</div>`:``} 358 `,H(c));let u=e.previewMode||h?.localOnly===!0,f=h&&!u?tt({description:h.notes||``,durationMinutes:r?.durationMinutes||30,id:h.bookingId||h.id,startsAt:l?.startsAt||o,title:`${r?.name||`Booking`} — ${e.title||t.title}`}):``;return` 255 359 <div class="confirm-wrap"> 256 360 <div class="confirm-check"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"></polyline></svg></div> 257 <div class="confirm-title">${ l(m?.localOnly===!0&&!e.previewMode?K.testModeHeading:K.successHeading)}</div>258 <div class="confirm-sub">${ l(e.previewMode?K.previewOnlyNote:m?.localOnly===!0?K.testModeNote:K.successNote)}</div>259 ${ g?`<a class="cta cta--success" href="${g}" download="booking.ics">${l(K.addToCalendar)}</a>`:``}361 <div class="confirm-title">${S(h?.localOnly===!0&&!e.previewMode?t.testModeHeading:t.successHeading)}</div> 362 <div class="confirm-sub">${S(e.previewMode?t.previewOnlyNote:h?.localOnly===!0?t.testModeNote:t.successNote)}</div> 363 ${f?`<a class="cta cta--success" href="${f}" download="booking.ics">${S(t.addToCalendar)}</a>`:``} 260 364 <button class="cta cta--close-success" type="button" data-action="close-success"> 261 ${ l(K.close)}365 ${S(t.close)} 262 366 </button> 263 367 </div> 264 `}function j(){let r=v(),{availableDates:o,slotsByDate:s}=y();if(r&&o.length&&(!i||!o.includes(i))&&(i=o[0],a=null,c=0),i&&c>0){let e=o.indexOf(i);e>=0&&(e<c||e>=c+5)&&(c=Math.floor(e/5)*5)}let u=x(s),d=h?2:n;if(g&&=(g.destroy(),null),t.innerHTML=`368 `}function K(){let n=A(),{availableDates:a,slotsByDate:c}=P();if(n&&a.length&&(!o||!a.includes(o))&&(o=a[0],s=null,d=0),o&&d>0){let e=a.indexOf(o);e>=0&&(e<d||e>=d+5)&&(d=Math.floor(e/5)*5)}let l=I(c),u=g?2:i;if(_&&=(_.destroy(),null),r.innerHTML=` 265 369 <div class="modal-card"> 266 370 <div class="topbar"> 267 <span class="topbar-title">${ l(e.title||K.title)}</span>268 ${ Ue(d)}269 <button class="close-btn" type="button" data-action="close" aria-label="${ l(K.close)}"><svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M2 2l10 10M12 2 2 12"></path></svg></button>371 <span class="topbar-title">${S(e.title||t.title)}</span> 372 ${At(u,t)} 373 <button class="close-btn" type="button" data-action="close" aria-label="${S(t.close)}"><svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M2 2l10 10M12 2 2 12"></path></svg></button> 270 374 </div> 271 375 <div class="modal-body"> 272 <div class="panel-left">${ ee(r,u)}</div>273 <div class="panel-right">${ A(r,o,s,u)}</div>376 <div class="panel-left">${re(n,l)}</div> 377 <div class="panel-right">${G(n,a,c,l)}</div> 274 378 </div> 275 379 ${e.showBranding===!1?``:`<div class="modal-footer"> 276 <span class="p-text">${ l(K.widgetFooterPrefix)}</span>277 <span class="p-brand">${ l(K.widgetFooterBrand)}</span>380 <span class="p-text">${S(t.widgetFooterPrefix)}</span> 381 <span class="p-brand">${S(t.widgetFooterBrand)}</span> 278 382 </div>`} 279 383 </div> 280 `, M(o,s),h){let n=t.querySelector(`[data-payment-element]`),r=h,i=t.querySelector(`[data-action="continue"]`);i&&(i.disabled=!0),n&&r.publishableKey&&r.clientSecret&&Ae(n,{accent:e.color,clientSecret:h.clientSecret,publishableKey:h.publishableKey}).then(e=>{if(!n.isConnected||h!==r){e.destroy();return}g=e,i&&(i.disabled=p)}).catch(e=>{n.isConnected&&h===r&&Oe(n,e,j)})}}function M(e,o){t.querySelectorAll(`[data-service-id]`).forEach(e=>{e.addEventListener(`click`,()=>{r=e.getAttribute(`data-service-id`)||``,i=null,a=null,c=0,u=``,j()})}),t.querySelectorAll(`[data-date]`).forEach(e=>{e.addEventListener(`click`,()=>{i=e.getAttribute(`data-date`),a=null,u=``,j(),q(t,{containerSelector:`.panel-right-scroll`,margins:{top:8,bottom:12}})})}),t.querySelectorAll(`[data-slot-id]`).forEach(e=>{e.addEventListener(`click`,()=>{a=e.getAttribute(`data-slot-id`),u=``,j(),q(t,{containerSelector:`.panel-right-scroll`,margins:{top:8,bottom:12}})})}),t.querySelector(`[data-action="prev-dates"]`)?.addEventListener(`click`,()=>{c=Math.max(0,c-5),j()}),t.querySelector(`[data-action="next-dates"]`)?.addEventListener(`click`,()=>{c=Math.min(Math.max(0,e.length-5),c+5),j()}),t.querySelector(`[data-action="back"]`)?.addEventListener(`click`,()=>{E(),n=Math.max(0,n-1),u=``,j()}),t.querySelector(`[data-action="close"]`)?.addEventListener(`click`,()=>{t.dispatchEvent(new CustomEvent(`booking-widget-close`,{bubbles:!0}))}),t.querySelector(`[data-action="close-success"]`)?.addEventListener(`click`,()=>{t.dispatchEvent(new CustomEvent(`booking-widget-close`,{bubbles:!0})),window.setTimeout(()=>D(),300)}),t.querySelectorAll(`.finput`).forEach(e=>{e.addEventListener(`input`,()=>{E();let e=t.querySelector(`[data-action="continue"]`);e&&(e.disabled=!S(o)||p)})}),t.querySelector(`[data-action="continue"]`)?.addEventListener(`click`,()=>{if(E(),n===0&&v()){n=1,u=``,j();return}if(n===1&&x(o)){n=2,u=``,j();return}if(h){N();return}n===2&&P(o)})}async function N(){if(!h||!g){u=`Payment form is still loading. Please wait a moment.`,j();return}p=!0,u=``;let r=g,i=h,a=t.querySelector(`[data-action="continue"]`);a&&(a.disabled=!0,a.textContent=K.processing);try{let t=await r.confirm();await je(e,{bookingId:i.bookingId,paymentIntentId:t}),m={bookingId:i.bookingId,id:i.bookingId},h=null,n=3}catch(e){u=e instanceof Error?e.message:K.genericError}finally{p=!1,j()}}async function P(t){let r=v(),i=x(t);if(!(!r||!i)){p=!0,u=``,j();try{let t=await Te(e,{customerEmail:_.customerEmail.trim(),customerName:_.customerName.trim(),customerPhone:_.customerPhone.trim(),notes:_.notes.trim(),serviceId:r.id||r.name,serviceName:r.name,startsAt:i.startsAt});if(t?.redirectedToCheckout)return;let a=De(t);a?h=a:(m=t||{bookingId:`local`},n=3)}catch(e){u=e instanceof Error?e.message:K.genericError}finally{p=!1,j()}}}return j(),t.addEventListener(`cancel`,e=>{e.preventDefault(),t.dispatchEvent(new CustomEvent(`booking-widget-close`,{bubbles:!0}))}),t.resetFlow=D,t.openModal=()=>{if(e.previewMode){t.classList.add(`is-open`),t.setAttribute(`data-open`,`true`),t.setAttribute(`open`,``);return}typeof t.showModal==`function`&&!t.open?t.showModal():t.setAttribute(`open`,``)},t.closeModal=()=>{if(e.previewMode){t.classList.remove(`is-open`),t.removeAttribute(`data-open`),t.removeAttribute(`open`);return}typeof t.close==`function`&&t.open?t.close():t.removeAttribute(`open`)},t}function Ge(e){return e===`center`||e===`right`||e===`left`?e:`left`}function Ke(e,t,n,r={}){let i=typeof e.trigger==`string`?e.trigger.trim():typeof e.triggerSelector==`string`?e.triggerSelector.trim():``,a=Ge(e.buttonAlign||e.buttonPosition),o=!!r.openByDefault,s=r.ctaTarget||null,c=n.host||null,l=document.createElement(`div`),u=We(e),d=!1,f=0,p=null,m=null;function h(e){d=e,l.classList.toggle(`is-open`,e),c&&(c.dataset.open=e?`true`:`false`),e?(typeof window.clearTimeout==`function`&&window.clearTimeout(f),u.openModal?.()):(u.closeModal?.(),typeof window.clearTimeout==`function`&&window.clearTimeout(f),typeof window.setTimeout==`function`?f=window.setTimeout(()=>{typeof u.resetFlow==`function`&&u.resetFlow()},280):typeof u.resetFlow==`function`&&u.resetFlow())}l.className=`runtime-shell runtime-shell--modal`,l.dataset.bookingWidgetOwner=t,l.dataset.align=a,c&&(c.dataset.buttonPosition=a),i||(p=document.createElement(`div`),m=document.createElement(`button`),p.className=`bw-modal-cta-wrap`,p.dataset.align=a,m.type=`button`,m.className=`btn-book`,m.dataset.bookingWidgetOwner=t,m.innerHTML=`<span>${K.bookNow}</span>`,m.addEventListener(`click`,()=>h(!0)),p.append(m),s&&!n?s.replaceChildren(p):(l.append(p),s&&s.remove())),u.addEventListener(`booking-widget-close`,()=>h(!1));function g(e){e.key===`Escape`&&d&&h(!1)}function _(e){let t=e.target;!i||!t||typeof t.closest!=`function`||t.closest(i)&&(e.preventDefault(),h(!0))}return typeof document.addEventListener==`function`&&(document.addEventListener(`keydown`,g),i&&document.addEventListener(`click`,_,!0)),l.append(u),n.append(l),h(o),{destroy(){typeof document.removeEventListener==`function`&&(document.removeEventListener(`keydown`,g),i&&document.removeEventListener(`click`,_,!0)),typeof window.clearTimeout==`function`&&window.clearTimeout(f);try{u.closeModal?.()}catch{}s&&(s.replaceChildren(),s.isConnected&&s.childNodes.length===0&&s.remove()),l.remove(),c?.isConnected&&c.dataset.bookingWidgetOwner===t&&c.remove()},setOpen:h}}function qe(){if(typeof document>`u`||document.querySelector(`#bookminto-floating-popover-style`))return;let e=document.createElement(`style`);e.id=`bookminto-floating-popover-style`,e.textContent=`384 `,ae(a,c),g){let t=r.querySelector(`[data-payment-element]`),n=g,i=r.querySelector(`[data-action="continue"]`);i&&(i.disabled=!0),t&&n.publishableKey&&n.clientSecret&&at(t,{accent:e.color,clientSecret:g.clientSecret,language:e.language,publishableKey:g.publishableKey}).then(e=>{if(!t.isConnected||g!==n){e.destroy();return}_=e,i&&(i.disabled=m)}).catch(r=>{t.isConnected&&g===n&&rt(t,r,K,e.language)})}}function ae(n,c){Ve(r),r.querySelectorAll(`[data-service-id]`).forEach(e=>{e.addEventListener(`click`,()=>{a=e.getAttribute(`data-service-id`)||``,o=null,s=null,l=null,d=0,p=``,K()})}),r.querySelectorAll(`[data-staff-id]`).forEach(e=>{e.addEventListener(`click`,()=>{let t=e.getAttribute(`data-staff-id`)||null;if(t===l)return;l=t,p=``,d=0,v=k();let n=P();o&&!n.availableDates.includes(o)?(o=null,s=null):s&&!(n.slotsByDate.get(o)||[]).some(e=>e.id===s)&&(s=null),K(),$(r,{containerSelector:`.panel-right-scroll`,margins:{top:8,bottom:12}})})}),r.querySelectorAll(`.staff-chip-avatar`).forEach(e=>{let t=()=>e.remove();e.addEventListener(`error`,t,{once:!0}),e.complete&&!e.naturalWidth&&t()}),r.querySelectorAll(`[data-date]`).forEach(e=>{e.addEventListener(`click`,()=>{o=e.getAttribute(`data-date`),s=null,p=``,K(),$(r,{containerSelector:`.panel-right-scroll`,margins:{top:8,bottom:12}})})}),r.querySelectorAll(`[data-slot-id]`).forEach(e=>{e.addEventListener(`click`,()=>{s=e.getAttribute(`data-slot-id`),p=``,K(),$(r,{containerSelector:`.panel-right-scroll`,margins:{top:8,bottom:12}})})}),r.querySelector(`[data-action="prev-dates"]`)?.addEventListener(`click`,()=>{d=Math.max(0,d-5),K()}),r.querySelector(`[data-action="next-dates"]`)?.addEventListener(`click`,()=>{d=Math.min(Math.max(0,n.length-5),d+5),K()}),r.querySelector(`[data-action="back"]`)?.addEventListener(`click`,()=>{V(),i=Math.max(0,i-1),p=``,K()}),r.querySelector(`[data-action="close"]`)?.addEventListener(`click`,()=>{r.dispatchEvent(new CustomEvent(`booking-widget-close`,{bubbles:!0}))}),r.querySelector(`[data-action="close-success"]`)?.addEventListener(`click`,()=>{r.dispatchEvent(new CustomEvent(`booking-widget-close`,{bubbles:!0})),window.setTimeout(()=>ne(),300)}),r.querySelectorAll(`.finput`).forEach(e=>{e.addEventListener(`input`,()=>{V();let e=r.querySelector(`[data-action="continue"]`);e&&(e.disabled=!L(c)||m)})}),r.querySelector(`[data-action="waitlist-open"]`)?.addEventListener(`click`,()=>{T=!0,p=``;let e=o||new Date().toISOString().slice(0,10),t=new Date(`${e}T12:00:00`);t.setDate(t.getDate()+14),O={customerEmail:C.customerEmail,customerName:C.customerName,dateFrom:O.dateFrom||e,dateTo:O.dateTo||t.toISOString().slice(0,10)},K(),$(r,{containerSelector:`.panel-right-scroll`,margins:{top:8,bottom:12}})}),r.querySelectorAll(`[data-waitlist-field]`).forEach(e=>{e.addEventListener(`input`,()=>{let t=e.getAttribute(`data-waitlist-field`);t&&(O[t]=e.value)})}),r.querySelector(`[data-action="waitlist-submit"]`)?.addEventListener(`click`,()=>{if(E)return;let n=A();E=!0,p=``,K(),Qe(e,{customerEmail:O.customerEmail,customerName:O.customerName,dateFrom:O.dateFrom,dateTo:O.dateTo,serviceId:n?.id||n?.name||``}).then(()=>{E=!1,D=!0,T=!1,K()}).catch(e=>{E=!1,p=e instanceof Error?e.message:t.waitlistError,K()})}),r.querySelector(`[data-action="continue"]`)?.addEventListener(`click`,()=>{if(V(),i===0&&A()){i=1,p=``,K();return}if(i===1&&I(c)){i=2,p=``,K();return}if(g){oe();return}i===2&&se(c)})}async function oe(){if(!g||!_){p=`Payment form is still loading. Please wait a moment.`,K();return}m=!0,p=``;let n=_,a=g,o=r.querySelector(`[data-action="continue"]`);o&&(o.disabled=!0,o.textContent=t.processing);try{let r=a.confirmedPaymentIntentId||await n.confirm();if(g!==a||_!==n)return;a.confirmedPaymentIntentId=r;let o=await ot(e,{bookingId:a.bookingId,paymentIntentId:r});if(g!==a||_!==n)return;if(o.processing||!(o.ok||o.alreadyConfirmed))throw Error(t.paymentError);h={bookingId:a.bookingId,id:a.bookingId},g=null,i=3}catch(e){if(g!==a||_!==n)return;p=e instanceof Error?e.message:t.genericError;let i=r.querySelector(`.runtime-state--danger`);i||(i=document.createElement(`p`),i.className=`runtime-state runtime-state--danger`,i.setAttribute(`role`,`alert`),r.querySelector(`[data-payment-element]`)?.after(i)),i.textContent=p}finally{g===a&&_===n?(m=!1,o&&(o.disabled=!1,o.textContent=t.payDepositCta)):g||(m=!1,K())}}async function se(n){let r=A(),a=I(n);if(!(!r||!a||m)&&L(n)){m=!0,p=``,K();try{let t=await Ze(e,{answers:Object.keys(C.answers||{}).length?C.answers:void 0,clientRequestId:v,replaceTerminalPayment:x||void 0,publicationRevision:e.publicationRevision,customerEmail:C.customerEmail.trim(),customerName:C.customerName.trim(),customerPhone:C.customerPhone.trim(),language:e.language||`en`,notes:C.notes.trim(),rebookingOptIn:C.rebookingOptIn||void 0,serviceId:r.id||r.name,serviceName:r.name,staffId:M(),startsAt:a.startsAt});if(t?.redirectedToCheckout)return;let n=nt(t,e.language);n?(x=!1,g=n):(h=t||{bookingId:`local`},i=3)}catch(e){x=e?.code===`terminal_payment_replacement_available`,e?.code===`staff_unavailable`?(i=1,s=null,p=t.staffUnavailable):p=x?t.replacePaymentNote:e instanceof Error?e.message:t.genericError}finally{m=!1,K()}}}return K(),r.addEventListener(`cancel`,e=>{e.preventDefault(),r.dispatchEvent(new CustomEvent(`booking-widget-close`,{bubbles:!0}))}),r.preselectBooking=t=>{if(!(!t||typeof t!=`object`)){if(t.serviceId&&(t.serviceId!==a&&(l=null),a=t.serviceId),t.startsAt){let n=ve(t.startsAt,e.timezone),r=(P().slotsByDate.get(n)||[]).find(e=>e.startsAt===t.startsAt);r?(o=n,s=r.id,i=2):i=Math.max(i,1)}else t.serviceId&&(i=Math.max(i,1));K()}},r.resetFlow=ne,r.openModal=()=>{if(e.previewMode){r.classList.add(`is-open`),r.setAttribute(`data-open`,`true`),r.setAttribute(`open`,``);return}typeof r.showModal==`function`&&!r.open?r.showModal():r.setAttribute(`open`,``)},r.closeModal=()=>{if(e.previewMode){r.classList.remove(`is-open`),r.removeAttribute(`data-open`),r.removeAttribute(`open`);return}typeof r.close==`function`&&r.open?r.close():r.removeAttribute(`open`)},r}function Mt(e){return e===`center`||e===`right`||e===`left`?e:`left`}function Nt(e,t,n,r={}){let i=c(e.language),a=typeof e.trigger==`string`?e.trigger.trim():typeof e.triggerSelector==`string`?e.triggerSelector.trim():``,o=``;if(a&&typeof document.querySelector==`function`)try{document.querySelector(a),o=a}catch{}let s=Mt(e.buttonAlign||e.buttonPosition),l=!!r.openByDefault,u=r.ctaTarget||null,d=n.host||null,f=document.createElement(`div`),p=jt(e),m=!1,h=0,g=null,_=null;function v(e){m=e,f.classList.toggle(`is-open`,e),d&&(d.dataset.open=e?`true`:`false`),e?(typeof window.clearTimeout==`function`&&window.clearTimeout(h),p.openModal?.()):(p.closeModal?.(),typeof window.clearTimeout==`function`&&window.clearTimeout(h),typeof window.setTimeout==`function`?h=window.setTimeout(()=>{typeof p.resetFlow==`function`&&p.resetFlow()},280):typeof p.resetFlow==`function`&&p.resetFlow())}f.className=`runtime-shell runtime-shell--modal`,f.dataset.bookingWidgetOwner=t,f.dataset.align=s,d&&(d.dataset.buttonPosition=s),!o&&!r.suppressCta&&(g=document.createElement(`div`),_=document.createElement(`button`),g.className=`bw-modal-cta-wrap`,g.dataset.align=s,_.type=`button`,_.className=`btn-book`,_.dataset.bookingWidgetOwner=t,_.innerHTML=`<span>${i.bookNow}</span>`,_.addEventListener(`click`,()=>v(!0)),g.append(_),u&&!n?u.replaceChildren(g):(f.append(g),u&&u.remove())),p.addEventListener(`booking-widget-close`,()=>v(!1));function y(e){e.key===`Escape`&&m&&v(!1)}function b(e){let t=e.target;!o||!t||typeof t.closest!=`function`||t.closest(o)&&(e.preventDefault(),v(!0))}return typeof document.addEventListener==`function`&&(document.addEventListener(`keydown`,y),o&&document.addEventListener(`click`,b,!0)),f.append(p),n.append(f),v(l),{destroy(){typeof document.removeEventListener==`function`&&(document.removeEventListener(`keydown`,y),o&&document.removeEventListener(`click`,b,!0)),typeof window.clearTimeout==`function`&&window.clearTimeout(h);try{p.closeModal?.()}catch{}u&&(u.replaceChildren(),u.isConnected&&u.childNodes.length===0&&u.remove()),f.remove(),d?.isConnected&&d.dataset.bookingWidgetOwner===t&&d.remove()},panel:p,setOpen:v}}function Pt(){if(typeof document>`u`||document.querySelector(`#bookminto-floating-popover-style`))return;let e=document.createElement(`style`);e.id=`bookminto-floating-popover-style`,e.textContent=` 281 385 .bw-floating-popover { 282 386 position: fixed !important; … … 359 463 } 360 464 } 361 `,document.head.appendChild(e)}let Y=`2147483647`;function Je(){if(typeof document>`u`||document.querySelector(`#bookminto-overlay-host-style`))return;let e=document.createElement(`style`);e.id=`bookminto-overlay-host-style`,e.textContent=`465 `,document.head.appendChild(e)}let Ft=`2147483647`;function It(){if(typeof document>`u`||document.querySelector(`#bookminto-overlay-host-style`))return;let e=document.createElement(`style`);e.id=`bookminto-overlay-host-style`,e.textContent=` 362 466 .bw-widget-shadow-host[data-variant="floating"], 363 467 .bw-widget-shadow-host[data-variant="modal"][data-trigger-mode="external"] { … … 369 473 padding: 0 !important; 370 474 border: 0 !important; 371 z-index: ${ Y} !important;475 z-index: ${Ft} !important; 372 476 pointer-events: none !important; 373 477 transform: none !important; … … 406 510 border: 0 !important; 407 511 overflow: visible !important; 408 z-index: ${ Y} !important;512 z-index: ${Ft} !important; 409 513 pointer-events: auto !important; 410 514 transform: none !important; … … 437 541 box-sizing: border-box !important; 438 542 } 439 `,document.head.appendChild(e)}function Ye(){Je()}function Xe(e){return Je(),e?.style?.setProperty?(e.style.setProperty(`position`,`fixed`,`important`),e.style.setProperty(`inset`,`0`,`important`),e.style.setProperty(`width`,`auto`,`important`),e.style.setProperty(`height`,`auto`,`important`),e.style.setProperty(`margin`,`0`,`important`),e.style.setProperty(`padding`,`0`,`important`),e.style.setProperty(`border`,`0`,`important`),e.style.setProperty(`z-index`,Y,`important`),e.style.setProperty(`pointer-events`,`none`,`important`),e.style.setProperty(`transform`,`none`,`important`),e.style.setProperty(`filter`,`none`,`important`),e.style.setProperty(`isolation`,`isolate`,`important`),e.style.setProperty(`contain`,`none`,`important`),e.style.setProperty(`overflow`,`visible`,`important`),e):e}function Ze(e){return Je(),e?.style?.setProperty?(e.style.setProperty(`position`,`fixed`,`important`),e.style.setProperty(`left`,`0`,`important`),e.style.setProperty(`right`,`0`,`important`),e.style.setProperty(`bottom`,`0`,`important`),e.style.setProperty(`top`,`auto`,`important`),e.style.setProperty(`inset`,`auto 0 0 0`,`important`),e.style.setProperty(`width`,`100%`,`important`),e.style.setProperty(`height`,`auto`,`important`),e.style.setProperty(`max-height`,`100vh`,`important`),e.style.setProperty(`margin`,`0`,`important`),e.style.setProperty(`padding`,`0`,`important`),e.style.setProperty(`border`,`0`,`important`),e.style.setProperty(`overflow`,`visible`,`important`),e.style.setProperty(`z-index`,Y,`important`),e.style.setProperty(`pointer-events`,`auto`,`important`),e.style.setProperty(`transform`,`none`,`important`),e.style.setProperty(`filter`,`none`,`important`),e.style.setProperty(`isolation`,`isolate`,`important`),e.style.setProperty(`contain`,`none`,`important`),e):e}function X(e){if(!(!e||!document.body)){if(e.parentNode!==document.body){document.body.append(e);return}document.body.lastElementChild!==e&&document.body.append(e)}}function Qe(e){let t=new Date(`${e}T12:00:00`);return{day:String(t.getDate()),month:new Intl.DateTimeFormat(`en-US`,{month:`short`}).format(t).replace(`.`,``),weekday:new Intl.DateTimeFormat(`en-US`,{weekday:`short`}).format(t).replace(`.`,``)}}function $e(e){return e?new Intl.DateTimeFormat(`en-US`,{weekday:`short`,day:`numeric`,month:`short`}).format(new Date(`${e}T12:00:00`)):``}function et(e){if(!e.length)return``;let t=new Intl.DateTimeFormat(`en-US`,{day:`numeric`,month:`short`});return`${t.format(new Date(`${e[0]}T12:00:00`))} - ${t.format(new Date(`${e[e.length-1]}T12:00:00`))}`}function tt(e){let t=Math.min(e+1,K.widgetStepLabels.length),n=K.widgetStepLabels[t-1];return` 543 `,document.head.appendChild(e)}function Lt(){It()}function Rt(e){return It(),e?.style?.setProperty?(e.style.setProperty(`position`,`fixed`,`important`),e.style.setProperty(`inset`,`0`,`important`),e.style.setProperty(`width`,`auto`,`important`),e.style.setProperty(`height`,`auto`,`important`),e.style.setProperty(`margin`,`0`,`important`),e.style.setProperty(`padding`,`0`,`important`),e.style.setProperty(`border`,`0`,`important`),e.style.setProperty(`z-index`,Ft,`important`),e.style.setProperty(`pointer-events`,`none`,`important`),e.style.setProperty(`transform`,`none`,`important`),e.style.setProperty(`filter`,`none`,`important`),e.style.setProperty(`isolation`,`isolate`,`important`),e.style.setProperty(`contain`,`none`,`important`),e.style.setProperty(`overflow`,`visible`,`important`),e):e}function zt(e){return It(),e?.style?.setProperty?(e.style.setProperty(`position`,`fixed`,`important`),e.style.setProperty(`left`,`0`,`important`),e.style.setProperty(`right`,`0`,`important`),e.style.setProperty(`bottom`,`0`,`important`),e.style.setProperty(`top`,`auto`,`important`),e.style.setProperty(`inset`,`auto 0 0 0`,`important`),e.style.setProperty(`width`,`100%`,`important`),e.style.setProperty(`height`,`auto`,`important`),e.style.setProperty(`max-height`,`100vh`,`important`),e.style.setProperty(`margin`,`0`,`important`),e.style.setProperty(`padding`,`0`,`important`),e.style.setProperty(`border`,`0`,`important`),e.style.setProperty(`overflow`,`visible`,`important`),e.style.setProperty(`z-index`,Ft,`important`),e.style.setProperty(`pointer-events`,`auto`,`important`),e.style.setProperty(`transform`,`none`,`important`),e.style.setProperty(`filter`,`none`,`important`),e.style.setProperty(`isolation`,`isolate`,`important`),e.style.setProperty(`contain`,`none`,`important`),e):e}function Bt(e){if(!(!e||!document.body)){if(e.parentNode!==document.body){document.body.append(e);return}document.body.lastElementChild!==e&&document.body.append(e)}}function Vt(e,t,n){try{return new Intl.DateTimeFormat(n||`en-US`,{timeZone:t||void 0,weekday:`short`,day:`numeric`,month:`short`}).format(new Date(e))}catch{return``}}function Ht(e,t,n,r){try{return new Intl.DateTimeFormat(r||`en-US`,{hour:`numeric`,minute:`2-digit`,hour12:n!==`24h`,timeZone:t||void 0}).format(new Date(e))}catch{return``}}function Ut(e,t={}){let n=c(e.language),r=u(e.language),i=document.createElement(`div`);i.className=`bw-next-times`;function a(){let t=String(e.service||``).trim();return t?e.services.find(e=>e.id===t||e.name===t)||null:e.services[0]||null}function o(){let o=a();if(!o){i.innerHTML=``,i.hidden=!0;return}i.hidden=!1;let s=_e(e,o,3);if(!s.length){i.innerHTML=` 544 <span class="bw-next-times__label">${S(n.nextTimesLabel)}</span> 545 <span class="bw-next-times__empty">${S(n.noNextTimes)}</span> 546 <button type="button" class="bw-next-times__all">${S(n.seeAllTimes)}</button> 547 `,i.querySelector(`.bw-next-times__all`)?.addEventListener(`click`,()=>t.onOpen?.());return}i.innerHTML=` 548 <span class="bw-next-times__label">${S(n.nextTimesLabel)}</span> 549 <span class="bw-next-times__slots"> 550 ${s.map(t=>` 551 <button type="button" class="bw-next-times__slot" data-starts-at="${S(t.startsAt)}"> 552 <b>${S(Vt(t.startsAt,e.timezone,r))}</b> 553 <span>${S(Ht(t.startsAt,e.timezone,e.timeFormat,r))}</span> 554 </button>`).join(``)} 555 </span> 556 <button type="button" class="bw-next-times__all">${S(n.seeAllTimes)}</button> 557 `,i.querySelectorAll(`.bw-next-times__slot`).forEach(e=>{e.addEventListener(`click`,()=>{let n=e.getAttribute(`data-starts-at`);n&&t.onSelectSlot?.(o.id||o.name,n)})}),i.querySelector(`.bw-next-times__all`)?.addEventListener(`click`,()=>t.onOpen?.())}return o(),i}function Wt(e,t){let n=new Date(`${e}T12:00:00`);return{day:String(n.getDate()),month:new Intl.DateTimeFormat(t||`en-US`,{month:`short`}).format(n).replace(`.`,``),weekday:new Intl.DateTimeFormat(t||`en-US`,{weekday:`short`}).format(n).replace(`.`,``)}}function Gt(e){return f(e,``).split(/\s+/).filter(Boolean)[0]||``}function Kt(e){return f(e,``).split(/\s+/).filter(Boolean).slice(0,2).map(e=>e.charAt(0)).join(``).toUpperCase()}function qt(e){let t=f(e,``);return/^#[0-9a-fA-F]{6}$/.test(t)?t:``}function Jt(e,t){let n=f(e,``);return n.startsWith(`https://`)||t&&/^data:image\/(?:png|jpe?g|webp|gif);base64,/i.test(n)?n:``}function Yt(e,t){return e?new Intl.DateTimeFormat(t||`en-US`,{weekday:`short`,day:`numeric`,month:`short`}).format(new Date(`${e}T12:00:00`)):``}function Xt(e,t){if(!e.length)return``;let n=new Intl.DateTimeFormat(t||`en-US`,{day:`numeric`,month:`short`});return`${n.format(new Date(`${e[0]}T12:00:00`))} - ${n.format(new Date(`${e[e.length-1]}T12:00:00`))}`}function Zt(e,t){let n=Math.min(e+1,t.widgetStepLabels.length),r=t.widgetStepLabels[n-1];return` 440 558 <div 441 559 class="stepper" 442 560 role="progressbar" 443 aria-label=" Booking progress"561 aria-label="${S(t.progressLabel)}" 444 562 aria-valuemin="1" 445 aria-valuemax="${ K.widgetStepLabels.length}"446 aria-valuenow="${ t}"447 aria-valuetext=" Step ${t} of ${K.widgetStepLabels.length}: ${l(n)}"563 aria-valuemax="${t.widgetStepLabels.length}" 564 aria-valuenow="${n}" 565 aria-valuetext="${S(t.stepLabel)} ${n} ${S(t.stepOf)} ${t.widgetStepLabels.length}: ${S(r)}" 448 566 > 449 567 <div class="stepper-meta" aria-hidden="true"> 450 <span> Step ${t} of ${K.widgetStepLabels.length}</span>451 <strong>${ l(n)}</strong>568 <span>${S(t.stepLabel)} ${n} ${S(t.stepOf)} ${t.widgetStepLabels.length}</span> 569 <strong>${S(r)}</strong> 452 570 </div> 453 571 <div class="stepper-track" aria-hidden="true"> 454 ${ K.widgetStepLabels.map((t,n)=>`<span class="stepper-segment${n<=e?` is-complete`:``}"></span>`).join(``)}572 ${t.widgetStepLabels.map((t,n)=>`<span class="stepper-segment${n<=e?` is-complete`:``}"></span>`).join(``)} 455 573 </div> 456 574 </div> 457 `}function nt(e,t,n){if(e.classList&&typeof e.classList.toggle==`function`){e.classList.toggle(t,n);return}let r=new Set(String(e.className||``).split(/\s+/).filter(Boolean));n?r.add(t):r.delete(t),e.className=Array.from(r).join(` `)}function rt(e){let t=document.createElement(`section`),n=e.variant===`floating`,r=n,i=_e(e),a=i&&e.services.length<=1,c=+!!a,u=e.services[0]?.id||e.services[0]?.name||``,p=null,m=null,h=0,g=``,_=`error`,v=!1,y=null,b=null,x=null,S={customerEmail:``,customerName:``,customerPhone:``,notes:``};t.className=n?`widget closed`:i?`widget widget--inline widget--commerce open`:`widget widget--inline open`,t.setAttribute(`data-booking-widget-runtime`,``),G(t,e.color);function C(){return e.services.find(e=>(e.id||e.name)===u)||e.services[0]||null}function w(){return oe(e,C())}function T(e){return e.slice(h,h+5)}function E(e){return!p||!m?null:(e.get(p)||[]).find(e=>e.id===m)||null}function D(e){return c===0?!!C():c===1?!!E(e):c===2?!!(S.customerName.trim()&&S.customerEmail.trim()):!1}function O(){return v?K.processing:c===0?K.continueFromService:b?K.payDepositCta:(B(e)||z(e))&&c===1?K.checkoutCta:c===2?K.confirmCta:K.continueDefault}function k(e){let t=(Number(e)||0)/100;return new Intl.NumberFormat(`en-US`,{style:`currency`,currency:`EUR`}).format(t)}function ee(){return`575 `}function Qt(e,t,n){if(e.classList&&typeof e.classList.toggle==`function`){e.classList.toggle(t,n);return}let r=new Set(String(e.className||``).split(/\s+/).filter(Boolean));n?r.add(t):r.delete(t),e.className=Array.from(r).join(` `)}function $t(e){let t=c(e.language),n=u(e.language),r=document.createElement(`section`),i=e.variant===`floating`,a=i,o=He(e),s=(o||!!e.service)&&e.services.length<=1,l=+!!s,d=e.services[0]?.id||e.services[0]?.name||``,p=null,m=null,h=null,g=0,_=e.calendarVerification===`external_unverified`?{de:`Die Kalenderverfügbarkeit kann derzeit nicht geprüft werden. Bitte versuchen Sie es später erneut.`,en:`Calendar availability cannot currently be verified. Please try again later.`,es:`No se puede verificar la disponibilidad del calendario ahora mismo. Inténtalo más tarde.`,fr:`La disponibilité du calendrier ne peut pas être vérifiée. Réessayez plus tard.`,it:`La disponibilità del calendario non può essere verificata al momento. Riprova più tardi.`,nl:`De beschikbaarheid van de agenda kan nu niet worden gecontroleerd. Probeer het later opnieuw.`}[e.language]||`Calendar availability cannot currently be verified. Please try again later.`:``,v=`error`,x=!1,C=null,T=null,E=null,D=P(),O=!1,k={answers:{},customerEmail:``,customerName:``,customerPhone:``,customerPhoneCountry:``,notes:``,rebookingOptIn:!1,smsOptIn:!1,whatsappOptIn:!1},A=!1,j=!1,M=!1,N={customerEmail:``,customerName:``,dateFrom:``,dateTo:``};function P(){try{if(typeof crypto?.randomUUID==`function`)return crypto.randomUUID()}catch{}return`bmr-${Date.now().toString(36)}-${Math.random().toString(36).slice(2,12)}`}r.className=i?`widget closed`:o?`widget widget--inline widget--commerce open`:`widget widget--inline open`,r.setAttribute(`data-booking-widget-runtime`,``),Q(r,e.color);function F(){return e.services.find(e=>(e.id||e.name)===d)||e.services[0]||null}function I(){return e.staffDisplayMode===`customer_choice`?he(e,F()):[]}function L(){if(h)return I().some(e=>e.id===h)?h:void 0}function R(){let e=L();if(!e)return``;let t=I().find(t=>t.id===e);return t?f(t.name,``):``}function te(){return ye(e,F(),L())}function z(e){return e.slice(g,g+5)}function B(e){return!p||!m?null:(e.get(p)||[]).find(e=>e.id===m)||null}function V(t){if(l===0)return!!F();if(l===1)return!!B(t);if(l===2){let t=F();return Number(t?.depositCents)>0&&e.features?.deposits!==!0||!k.customerName.trim()||!k.customerEmail.trim()?!1:(F()?.bookingQuestions||[]).every(e=>!e.required||!!String(k.answers?.[e.id]||``).trim())}return!1}function ne(){return x?t.processing:l===0?t.continueFromService:T?t.payDepositCta:(Z(e)||X(e))&&l===1?t.checkoutCta:l===2?O?t.replacePaymentCta:t.confirmCta:t.continueDefault}function H(e){let t=(Number(e)||0)/100;return new Intl.NumberFormat(n,{style:`currency`,currency:`EUR`}).format(t)}function U(){return` 458 576 <div class="details-step"> 459 <p class="section-title">${ l(K.depositHeading)}</p>460 <div class="confirm-sub" style="text-align:left;margin-bottom:12px">${ l(K.depositNote)}</div>577 <p class="section-title">${S(t.depositHeading)}</p> 578 <div class="confirm-sub" style="text-align:left;margin-bottom:12px">${S(t.depositNote)}</div> 461 579 <div class="summary-box"> 462 <div class="summary-row"><span><strong>${ l(k(b.depositCents))}</strong> deposit</span></div>580 <div class="summary-row"><span><strong>${S(H(T.depositCents))}</strong> ${S(t.depositLabel)}</span></div> 463 581 </div> 464 < div class="bw-payment-element" data-payment-element></div>582 <slot name="bm-payment-element" data-payment-element><div class="bw-payment-element"></div></slot> 465 583 </div> 466 `}function A(){return g?`<div role="alert" class="widget-message${_===`info`?` widget-message--info`:``}">${l(g)}</div>`:``}function j(e,t){return`<div class="runtime-state${t===`danger`?` runtime-state--danger`:``}">${l(e)}</div>`}function M(){return e.loadError?j(K.loadError,`danger`):e.services.length?e.services.map(e=>{let t=e.id||e.name;return` 467 <button type="button" class="service-card${t===u?` selected`:``}" data-service-id="${l(t)}"> 584 `}function re(){return _?`<div role="alert" tabindex="-1" class="widget-message${v===`info`?` widget-message--info`:``}">${S(_)}</div>`:``}function W(e,t){return`<div class="runtime-state${t===`danger`?` runtime-state--danger`:``}">${S(e)}</div>`}function ie(){return e.loadError?W(t.loadError,`danger`):e.services.length?e.services.map(t=>{let n=t.id||t.name;return` 585 <button type="button" class="service-card${n===d?` selected`:``}" data-service-id="${S(n)}"> 586 ${Be(t.imageUrl,e.previewMode)} 468 587 <div class="service-info"> 469 <div class="service-name">${ l(e.name)}</div><div class="service-price">${l(d(e))}</div>470 ${ e.description?`<div class="service-desc">${l(e.description)}</div>`:``}588 <div class="service-name">${S(t.name)}</div><div class="service-price">${S(w(t))}</div> 589 ${t.description?`<div class="service-desc">${S(t.description)}</div>`:``} 471 590 </div> 472 <div class="service-duration">${ l(o(e.durationMinutes))}</div>591 <div class="service-duration">${S(y(t.durationMinutes))}</div> 473 592 </button> 474 `}).join(``):j(K.emptyServices)}function N(t,n){let r=T(t),i=p&&n.get(p)||[],o=h>0,s=h+5<t.length;return` 475 ${a?``:`<button class="back-btn" type="button" data-action="back">← ${l(K.back)}</button>`} 476 <p class="section-title">${l(K.chooseDate)}</p><p class="booking-timezone">${l(f(e))}</p> 593 `}).join(``):W(t.emptyServices)}function G(n,r){return e.features?.waitlist!==!0||!n?``:M?`<div class="runtime-state waitlist-joined" role="status">${S(t.waitlistJoined)}</div>`:A?` 594 <div class="waitlist-form"> 595 <p class="section-title">${S(t.waitlistHeading)}</p> 596 <p class="waitlist-note">${S(t.waitlistNote)}</p> 597 <div class="form-field"> 598 <label class="form-label" for="bm-wl-name">${S(t.nameLabel)}</label> 599 <input class="form-input" id="bm-wl-name" data-waitlist-field="customerName" autocomplete="name" value="${S(N.customerName)}" /> 600 </div> 601 <div class="form-field"> 602 <label class="form-label" for="bm-wl-email">${S(t.emailLabel)}</label> 603 <input class="form-input" id="bm-wl-email" data-waitlist-field="customerEmail" autocomplete="email" type="email" value="${S(N.customerEmail)}" /> 604 </div> 605 <div class="form-row"> 606 <div class="form-field"> 607 <label class="form-label" for="bm-wl-from">${S(t.waitlistFrom)}</label> 608 <input class="form-input" id="bm-wl-from" data-waitlist-field="dateFrom" type="date" value="${S(N.dateFrom)}" /> 609 </div> 610 <div class="form-field"> 611 <label class="form-label" for="bm-wl-to">${S(t.waitlistTo)}</label> 612 <input class="form-input" id="bm-wl-to" data-waitlist-field="dateTo" type="date" value="${S(N.dateTo)}" /> 613 </div> 614 </div> 615 <button type="button" class="cta-btn" data-action="waitlist-submit" ${j?`disabled`:``}> 616 <span>${S(j?t.processing:t.waitlistSubmit)}</span> 617 </button> 618 </div> 619 `:` 620 <button type="button" class="waitlist-cta" data-action="waitlist-open"> 621 ${S(t.waitlistCta)} 622 </button> 623 `}function K(){let n=I();if(n.length<2)return``;let r=L(),i=n.map(t=>{let n=f(t.name,``),i=qt(t.color),a=Jt(t.avatarUrl,e.previewMode),o=t.id===r,s=a?`<img class="staff-chip-avatar" src="${S(a)}" alt="" loading="lazy" decoding="async" referrerpolicy="no-referrer">`:``;return` 624 <button type="button" class="staff-chip${o?` selected`:``}" data-staff-id="${S(t.id)}" aria-pressed="${o}"> 625 <span class="staff-chip-dot"${i?` style="background:${S(i)}"`:``}>${S(Kt(n))}${s}</span> 626 <span class="staff-chip-name">${S(Gt(n)||n)}</span> 627 </button> 628 `}).join(``);return` 629 <p class="section-title">${S(t.staffPickerLabel)}</p> 630 <div class="staff-row" role="group" aria-label="${S(t.staffPickerLabel)}"> 631 <button type="button" class="staff-chip${r?``:` selected`}" data-staff-id="" aria-pressed="${!r}"> 632 <span class="staff-chip-name">${S(t.anyStaff)}</span> 633 </button> 634 ${i} 635 </div> 636 `}function ae(r,i){let a=z(r),o=p&&i.get(p)||[],c=g>0,l=g+5<r.length,u=F(),d=s?``:`<button class="back-btn" type="button" data-action="back">← ${S(t.back)}</button>`;return ge(e,u)?` 637 ${d} 638 ${W(t.serviceNotBookable,`danger`)} 639 `:` 640 ${d} 641 ${K()} 642 <p class="section-title">${S(t.chooseDate)}</p><p class="booking-timezone">${S(ee(e))}</p> 477 643 <div class="date-nav"> 478 <span class="date-nav-copy">${ l(et(r))}</span>644 <span class="date-nav-copy">${S(Xt(a,n))}</span> 479 645 <div class="date-nav-buttons"> 480 <button type="button" class="date-nav-btn" data-action="prev-dates" aria-label="${ l(K.previousDates)}" ${o?``:`disabled`}>646 <button type="button" class="date-nav-btn" data-action="prev-dates" aria-label="${S(t.previousDates)}" ${c?``:`disabled`}> 481 647 <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9.5 2.5 5 7l4.5 4.5"></path></svg> 482 648 </button> 483 <button type="button" class="date-nav-btn" data-action="next-dates" aria-label="${ l(K.nextDates)}" ${s?``:`disabled`}>649 <button type="button" class="date-nav-btn" data-action="next-dates" aria-label="${S(t.nextDates)}" ${l?``:`disabled`}> 484 650 <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4.5 2.5 9 7l-4.5 4.5"></path></svg> 485 651 </button> … … 487 653 </div> 488 654 <div class="date-grid"> 489 ${ r.length?r.map(e=>{let t=Qe(e);return`490 <button type="button" class="date-btn${e===p?` selected`:``}" data-date="${ l(e)}">491 <div class="date-dow">${ l(t.weekday)}</div>492 <div class="date-day">${ l(t.day)}</div>493 <div class="date-mon">${ l(t.month)}</div>655 ${a.length?a.map(e=>{let t=Wt(e,n);return` 656 <button type="button" class="date-btn${e===p?` selected`:``}" data-date="${S(e)}"> 657 <div class="date-dow">${S(t.weekday)}</div> 658 <div class="date-day">${S(t.day)}</div> 659 <div class="date-mon">${S(t.month)}</div> 494 660 </button> 495 `}).join(``): j(K.emptySlots)}661 `}).join(``):W(t.emptySlots)} 496 662 </div> 497 663 ${p?` 498 <p class="section-title section-title--with-top-gap">${l(K.availableTimes)}</p> 499 ${i.length?`<div class="time-grid"> 500 ${i.map(e=>` 501 <button type="button" class="time-btn${e.id===m?` selected`:``}" data-slot-id="${l(e.id)}"> 502 ${l(e.timeLabel)} 664 <p class="section-title section-title--with-top-gap">${S(t.availableTimes)}</p> 665 ${o.length?`<div class="time-grid"> 666 ${o.map(e=>` 667 <button type="button" class="time-btn${e.id===m?` selected`:``}${e.recommended?` recommended`:``}" data-slot-id="${S(e.id)}"> 668 ${S(e.timeLabel)} 669 ${e.recommended?`<em class="rec-badge">${S(t.recommendedBadge)}</em>`:``} 503 670 </button> 504 671 `).join(``)} 505 </div>`: j(K.emptySlots)}672 </div>`:W(t.emptySlots)} 506 673 `:``} 507 `}function P(e,t){let n=s(e?.price);return` 674 ${!a.length||!o.length?G(u,o):``} 675 `}function oe(r,i){let a=b(r?.price);return` 508 676 <div class="details-step"> 509 <button class="back-btn" type="button" data-action="back">← ${ l(K.back)}</button>677 <button class="back-btn" type="button" data-action="back">← ${S(t.back)}</button> 510 678 <div class="summary-box"> 511 679 <div class="summary-row"> 512 <span>${ l($e(p))} ${l(K.at)} ${l(t?.timeLabel||``)}</span>680 <span>${S(Yt(p,n))} ${S(t.at)} ${S(i?.timeLabel||``)}</span> 513 681 </div> 514 682 <div class="summary-row"> 515 <span>${ l(e?.name||``)} · ${l(o(e?.durationMinutes))}</span>683 <span>${S(r?.name||``)} · ${S(y(r?.durationMinutes))}</span> 516 684 </div> 517 ${n===`Free`?``:`<div class="summary-row"><span>${l(K.totalLabel)}: ${l(n)}</span></div>`} 685 ${R()?`<div class="summary-row"><span>${S(t.withStaff.replace(`{name}`,R()))}</span></div>`:``} 686 ${a===`Free`?``:`<div class="summary-row"><span>${S(t.totalLabel)}: ${S(a)}</span></div>`} 518 687 </div> 519 <p class="section-title">${ l(K.detailsHeading)}</p>688 <p class="section-title">${S(t.detailsHeading)}</p> 520 689 <div class="form-row"> 521 690 <div class="form-field"> 522 <label class="form-label" for="bm-runtime-customerName">${ l(K.nameLabel)}</label>523 <input class="form-input" id="bm-runtime-customerName" name="customerName" autocomplete="name" required placeholder="${ l(K.namePlaceholder)}" value="${l(S.customerName)}" />691 <label class="form-label" for="bm-runtime-customerName">${S(t.nameLabel)}</label> 692 <input class="form-input" id="bm-runtime-customerName" name="customerName" autocomplete="name" required placeholder="${S(t.namePlaceholder)}" value="${S(k.customerName)}" /> 524 693 </div> 694 ${e.sms?.available===!0?` 695 <div class="form-field"> 696 <label class="form-label" for="bm-runtime-customerPhoneCountry">${S(t.phoneCountryLabel)}</label> 697 <select class="form-input" id="bm-runtime-customerPhoneCountry" name="customerPhoneCountry" autocomplete="country"> 698 <option value="">${S(t.chooseOption)}</option> 699 ${pt.map(e=>`<option value="${e}"${k.customerPhoneCountry===e?` selected`:``}>${e} (+${S(ft[e])})</option>`).join(``)} 700 </select> 701 </div> 702 `:``} 525 703 <div class="form-field"> 526 <label class="form-label" for="bm-runtime-customerPhone">${ l(K.phoneLabel)}</label>527 <input class="form-input" id="bm-runtime-customerPhone" name="customerPhone" autocomplete="tel" type="tel" placeholder="${ l(K.phonePlaceholder)}" value="${l(S.customerPhone)}" />704 <label class="form-label" for="bm-runtime-customerPhone">${S(t.phoneLabel)}</label> 705 <input class="form-input" id="bm-runtime-customerPhone" name="customerPhone" autocomplete="tel" type="tel" placeholder="${S(t.phonePlaceholder)}" value="${S(k.customerPhone)}" /> 528 706 </div> 529 707 </div> 530 708 <div class="form-field"> 531 <label class="form-label" for="bm-runtime-customerEmail">${ l(K.emailLabel)}</label>532 <input class="form-input" id="bm-runtime-customerEmail" name="customerEmail" autocomplete="email" type="email" required placeholder="${ l(K.emailPlaceholder)}" value="${l(S.customerEmail)}" />709 <label class="form-label" for="bm-runtime-customerEmail">${S(t.emailLabel)}</label> 710 <input class="form-input" id="bm-runtime-customerEmail" name="customerEmail" autocomplete="email" type="email" required placeholder="${S(t.emailPlaceholder)}" value="${S(k.customerEmail)}" /> 533 711 </div> 534 712 <div class="form-field"> 535 <label class="form-label" for="bm-runtime-notes">${l(K.notesLabel)}</label> 536 <textarea class="form-input form-input--textarea" id="bm-runtime-notes" name="notes" rows="2" maxlength="500" placeholder="${l(K.notesPlaceholder)}">${l(S.notes)}</textarea> 713 <label class="form-label" for="bm-runtime-notes">${S(t.notesLabel)}</label> 714 <textarea class="form-input form-input--textarea" id="bm-runtime-notes" name="notes" rows="2" maxlength="500" placeholder="${S(t.notesPlaceholder)}">${S(k.notes)}</textarea> 715 </div> 716 ${se(r)} 717 ${Number(r?.depositCents)>0&&e.features?.deposits!==!0?`<p class="form-note deposit-unavailable-note" role="note">${S(t.depositUnavailable)}</p>`:``} 718 ${Number(r?.rebookingIntervalWeeks)>0?` 719 <label class="rebooking-optin"> 720 <input type="checkbox" data-rebooking-optin ${k.rebookingOptIn?`checked`:``} /> 721 <span>${S(t.rebookingOptInLabel)}</span> 722 </label> 723 `:``} 724 ${e.sms?.available===!0?` 725 <label class="rebooking-optin"> 726 <input type="checkbox" data-sms-optin ${k.smsOptIn?`checked`:``} /> 727 <span>${S(t.smsOptInLabel)}</span> 728 </label> 729 `:``} 730 ${e.whatsapp?.available===!0?` 731 <label class="rebooking-optin"> 732 <input type="checkbox" data-whatsapp-optin ${k.whatsappOptIn?`checked`:``} /> 733 <span>${S(t.whatsappOptInLabel)}</span> 734 </label> 735 `:``} 736 </div> 737 `}function se(e){let n=Array.isArray(e?.bookingQuestions)?e.bookingQuestions:[];return n.length?` 738 <p class="section-title section-title--with-top-gap">${S(t.questionsHeading)}</p> 739 ${n.map(e=>{let n=`bm-runtime-question-${S(e.id)}`,r=String(k.answers?.[e.id]||``),i=e.required?` <span class="form-required" aria-hidden="true">*</span><span class="sr-only"> ${S(t.requiredHint)}</span>`:``,a=`<label class="form-label" for="${n}">${S(e.label)}${i}</label>`;return e.type===`yesno`?` 740 <div class="form-field"> 741 ${a} 742 <select class="form-input" id="${n}" data-question-id="${S(e.id)}"${e.required?` required`:``}> 743 <option value="">${S(t.chooseOption)}</option> 744 <option value="yes"${r===`yes`?` selected`:``}>${S(t.yesLabel)}</option> 745 <option value="no"${r===`no`?` selected`:``}>${S(t.noLabel)}</option> 746 </select> 747 </div> 748 `:e.type===`choice`?` 749 <div class="form-field"> 750 ${a} 751 <select class="form-input" id="${n}" data-question-id="${S(e.id)}"${e.required?` required`:``}> 752 <option value="">${S(t.chooseOption)}</option> 753 ${(e.options||[]).map(e=>`<option value="${S(e)}"${r===e?` selected`:``}>${S(e)}</option>`).join(``)} 754 </select> 755 </div> 756 `:` 757 <div class="form-field"> 758 ${a} 759 <input class="form-input" id="${n}" data-question-id="${S(e.id)}" maxlength="300"${e.required?` required`:``} value="${S(r)}" /> 760 </div> 761 `}).join(``)} 762 `:``}function ce(r,i){let a=b(r?.price),o=C?.startsAt||i?.startsAt,s=!(e.previewMode||C?.localOnly===!0)&&o?tt({description:r?.name||``,durationMinutes:r?.durationMinutes||C?.durationMinutes,id:C?.bookingId||C?.id,startsAt:o,title:r?.name||t.successHeading}):``,c=s?`<a class="confirm-calendar-link" href="${s}" download="booking.ics">${S(t.addToCalendar)}</a>`:``;return` 763 <div class="widget-success-shell"> 764 <div class="confirm-icon"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"></polyline></svg></div> 765 <div class="confirm-title">${S(C?.localOnly===!0&&!e.previewMode?t.testModeHeading:t.successHeading)}</div> 766 <div class="confirm-sub">${S(e.previewMode?t.previewOnlyNote:C?.localOnly===!0?t.testModeNote:t.successNote)}</div> 767 <div class="summary-box"> 768 <div class="summary-row"><span><strong>${S(C?.customerName||k.customerName)}</strong></span></div> 769 <div class="summary-row"><span>${S(Yt(p,n))} ${S(t.at)} ${S(i?.timeLabel||``)}</span></div> 770 <div class="summary-row"><span>${S(r?.name||``)} · ${S(y(r?.durationMinutes))}</span></div> 771 ${R()?`<div class="summary-row"><span>${S(t.withStaff.replace(`{name}`,R()))}</span></div>`:``} 772 ${a===`Free`?``:`<div class="summary-row"><span>${S(t.totalLabel)}: ${S(a)}</span></div>`} 773 </div> 774 ${c} 775 <button class="cta-btn cta-btn--success" type="button" data-action="close-success">${S(t.close)}</button> 776 </div> 777 `}function le(){let e=r.querySelector(`[name="customerName"]`),t=r.querySelector(`[name="customerEmail"]`),n=r.querySelector(`[name="customerPhone"]`),i=r.querySelector(`[name="customerPhoneCountry"]`),a=r.querySelector(`[name="notes"]`);e&&(k.customerName=e.value),t&&(k.customerEmail=t.value),n&&(k.customerPhone=n.value),i&&(k.customerPhoneCountry=i.value),a&&(k.notes=a.value),r.querySelectorAll(`[data-question-id]`).forEach(e=>{let t=e.getAttribute(`data-question-id`);t&&(k.answers[t]=e.value)});let o=r.querySelector(`[data-rebooking-optin]`);o&&(k.rebookingOptIn=o.checked===!0);let s=r.querySelector(`[data-sms-optin]`);s&&(k.smsOptIn=s.checked===!0);let c=r.querySelector(`[data-whatsapp-optin]`);c&&(k.whatsappOptIn=c.checked===!0)}function q(){E&&=(E.destroy(),null),T=null,l=+!!s,d=e.services[0]?.id||e.services[0]?.name||``,p=null,m=null,h=null,g=0,_=``,v=`error`,x=!1,C=null,D=P(),O=!1,A=!1,j=!1,M=!1,N={customerEmail:``,customerName:``,dateFrom:``,dateTo:``},k={answers:{},customerEmail:``,customerName:``,customerPhone:``,customerPhoneCountry:``,notes:``,rebookingOptIn:!1,smsOptIn:!1,whatsappOptIn:!1},J()}function J(){let n=F(),{availableDates:i,slotsByDate:a}=te();if(n&&i.length&&(!p||!i.includes(p))&&(p=i[0],m=null,g=0),p&&g>0){let e=i.indexOf(p);e>=0&&(e<g||e>=g+5)&&(g=Math.floor(e/5)*5)}let s=B(a),c=l<3||!!T,u=(T||V(a))&&!x,d=``;d=T?U():l===0?ie():l===1?ae(i,a):l===2?oe(n,s):ce(n,s),E&&=(E.destroy(),null),Qt(r,`widget--confirmed`,!T&&l===3);let f=o?``:` 778 <div class="widget-header"> 779 <div class="widget-header-top"> 780 <span class="widget-header-title">${S(e.title||t.title)}</span> 537 781 </div> 538 782 </div> 539 `}function te(t,n){let r=s(t?.price),i=y?.startsAt||n?.startsAt,a=!(e.previewMode||y?.localOnly===!0)&&i?Ee({description:t?.name||``,durationMinutes:t?.durationMinutes||y?.durationMinutes,id:y?.bookingId||y?.id,startsAt:i,title:t?.name||K.successHeading}):``,c=a?`<a class="confirm-calendar-link" href="${a}" download="booking.ics">${l(K.addToCalendar)}</a>`:``;return` 540 <div class="widget-success-shell"> 541 <div class="confirm-icon"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"></polyline></svg></div> 542 <div class="confirm-title">${l(y?.localOnly===!0&&!e.previewMode?K.testModeHeading:K.successHeading)}</div> 543 <div class="confirm-sub">${l(e.previewMode?K.previewOnlyNote:y?.localOnly===!0?K.testModeNote:K.successNote)}</div> 544 <div class="summary-box"> 545 <div class="summary-row"><span><strong>${l(y?.customerName||S.customerName)}</strong></span></div> 546 <div class="summary-row"><span>${l($e(p))} ${l(K.at)} ${l(n?.timeLabel||``)}</span></div> 547 <div class="summary-row"><span>${l(t?.name||``)} · ${l(o(t?.durationMinutes))}</span></div> 548 ${r===`Free`?``:`<div class="summary-row"><span>${l(K.totalLabel)}: ${l(r)}</span></div>`} 549 </div> 550 ${c} 551 <button class="cta-btn cta-btn--success" type="button" data-action="close-success">${l(K.close)}</button> 552 </div> 553 `}function F(){let e=t.querySelector(`[name="customerName"]`),n=t.querySelector(`[name="customerEmail"]`),r=t.querySelector(`[name="customerPhone"]`),i=t.querySelector(`[name="notes"]`);e&&(S.customerName=e.value),n&&(S.customerEmail=n.value),r&&(S.customerPhone=r.value),i&&(S.notes=i.value)}function ne(){x&&=(x.destroy(),null),b=null,c=+!!a,u=e.services[0]?.id||e.services[0]?.name||``,p=null,m=null,h=0,g=``,_=`error`,v=!1,y=null,S={customerEmail:``,customerName:``,customerPhone:``,notes:``},I()}function I(){let n=C(),{availableDates:r,slotsByDate:a}=w();if(n&&r.length&&(!p||!r.includes(p))&&(p=r[0],m=null,h=0),p&&h>0){let e=r.indexOf(p);e>=0&&(e<h||e>=h+5)&&(h=Math.floor(e/5)*5)}let s=E(a),u=c<3||!!b,d=(b||D(a))&&!v,f=``;if(f=b?ee():c===0?M():c===1?N(r,a):c===2?P(n,s):te(n,s),x&&=(x.destroy(),null),nt(t,`widget--confirmed`,!b&&c===3),t.innerHTML=` 554 ${i?``:` 555 <div class="widget-header"> 556 <div class="widget-header-top"> 557 <span class="widget-header-title">${l(e.title||K.title)}</span> 558 </div> 559 </div> 560 ${tt(b?2:c)} 561 `} 783 ${Zt(T?2:l,t)} 784 `,h=R();if(r.innerHTML=` 785 ${f} 562 786 <div class="widget-body"> 563 787 <div class="widget-body-scroll"> 564 ${ i&&n&&c>=1&&c<3?`<p class="commerce-service-hint">${l(n.name)} · ${l(o(n.durationMinutes))}</p>`:``}565 ${ f}566 ${ A()}788 ${o&&n&&l>=1&&l<3?`<p class="commerce-service-hint">${S(n.name)} · ${S(y(n.durationMinutes))}${h?` · ${S(t.withStaff.replace(`{name}`,h))}`:``}</p>`:``} 789 ${d} 790 ${re()} 567 791 </div> 568 ${ u&&c<3?`<button class="cta-btn" type="button" data-action="continue" ${d?``:`disabled`}>569 <span>${ l(O())}</span>792 ${c&&l<3?`<button class="cta-btn" type="button" data-action="continue" ${u?``:`disabled`}> 793 <span>${S(ne())}</span> 570 794 <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1 7h12M7 1l6 6-6 6"></path></svg> 571 795 </button>`:``} 572 796 </div> 573 ${e.showBranding&&! i?`<div class="powered"><span>${l(K.widgetFooterPrefix)}</span> <strong>${l(K.widgetFooterBrand)}</strong></div>`:``}574 `, re(r,a),b){let n=t.querySelector(`[data-payment-element]`),r=b,i=t.querySelector(`[data-action="continue"]`);i&&(i.disabled=!0),n&&r.publishableKey&&r.clientSecret&&Ae(n,{accent:e.color,clientSecret:b.clientSecret,publishableKey:b.publishableKey}).then(e=>{if(!n.isConnected||b!==r){e.destroy();return}x=e,i&&(i.disabled=v)}).catch(e=>{n.isConnected&&b===r&&Oe(n,e,I)})}}function re(n,i){t.querySelectorAll(`[data-service-id]`).forEach(e=>{e.addEventListener(`click`,()=>{u=e.getAttribute(`data-service-id`)||``,p=null,m=null,h=0,g=``,I()})}),t.querySelectorAll(`[data-date]`).forEach(e=>{e.addEventListener(`click`,()=>{p=e.getAttribute(`data-date`),m=null,g=``,I(),q(t,{containerSelector:`.widget-body-scroll`,margins:{top:8,bottom:16}})})}),t.querySelectorAll(`[data-slot-id]`).forEach(e=>{e.addEventListener(`click`,()=>{m=e.getAttribute(`data-slot-id`),g=``,I(),q(t,{containerSelector:`.widget-body-scroll`,margins:{top:8,bottom:16}})})}),t.querySelector(`[data-action="prev-dates"]`)?.addEventListener(`click`,()=>{h=Math.max(0,h-5),I()}),t.querySelector(`[data-action="next-dates"]`)?.addEventListener(`click`,()=>{h=Math.min(Math.max(0,n.length-5),h+5),I()}),t.querySelector(`[data-action="back"]`)?.addEventListener(`click`,()=>{F(),c=Math.max(+!!a,c-1),g=``,_=`error`,I()}),t.querySelector(`[data-action="close-success"]`)?.addEventListener(`click`,()=>{t.dispatchEvent(new CustomEvent(`booking-widget-close`,{bubbles:!0})),window.setTimeout(()=>ne(),r?300:0)}),t.querySelectorAll(`.form-input`).forEach(e=>{e.addEventListener(`input`,()=>{F();let e=t.querySelector(`[data-action="continue"]`);e&&(e.disabled=!D(i)||v)})}),t.querySelector(`[data-action="continue"]`)?.addEventListener(`click`,()=>{if(!v){if(F(),c===0&&C()){c=1,g=``,_=`error`,I();return}if(c===1&&E(i)){if(z(e)||B(e)){ae(i);return}c=2,g=``,_=`error`,I();return}if(b){ie();return}c===2&&ae(i)}})}async function ie(){if(!b||!x){g=`Payment form is still loading. Please wait a moment.`;let e=t.querySelector(`.widget-message`);e?e.textContent=g:I();return}v=!0,g=``;let n=t.querySelector(`[data-action="continue"]`);if(n){n.disabled=!0;let e=n.querySelector(`span`);e&&(e.textContent=K.processing)}try{let t=await x.confirm();await je(e,{bookingId:b.bookingId,paymentIntentId:t}),y={bookingId:b.bookingId,customerName:S.customerName,durationMinutes:C()?.durationMinutes,startsAt:E(w().slotsByDate)?.startsAt},x.destroy(),x=null,b=null,c=3,v=!1,g=``,I()}catch(e){if(v=!1,g=e instanceof Error?e.message:K.genericError,n){n.disabled=!1;let e=n.querySelector(`span`);e&&(e.textContent=K.payDepositCta)}let r=t.querySelector(`.widget-message`);if(!r){let e=t.querySelector(`[data-payment-element]`);r=document.createElement(`div`),r.className=`widget-message`,e?.after(r)}r.textContent=g}}async function ae(t){let n=C(),r=E(t);if(z(e)&&(S.customerName=S.customerName.trim()||`Shopify checkout`,S.customerEmail=S.customerEmail.trim()||`shopify-hold@bookminto.hold`),B(e)&&(S.customerName=S.customerName.trim()||`WooCommerce checkout`,S.customerEmail=S.customerEmail.trim()||`woocommerce-hold@bookminto.hold`),!(!n||!r||!S.customerName.trim()||!S.customerEmail.trim())&&!v){v=!0,g=``,_=`error`,I();try{let t=await Te(e,{customerEmail:S.customerEmail,customerName:S.customerName,customerPhone:S.customerPhone,notes:S.notes,serviceId:n.id||n.name,serviceName:n.name,startsAt:r.startsAt});if(t?.redirectedToCheckout){g=K.redirectingToCheckout,_=`info`,I();return}if(t?.needsCheckout){v=!1,g=`Could not open checkout. Please try again.`,_=`error`,I();return}let i=De(t);if(i){b=i,y={bookingId:t.bookingId,customerName:S.customerName,durationMinutes:n.durationMinutes,startsAt:r.startsAt},v=!1,g=``,I();return}y={bookingId:t&&typeof t==`object`?t.bookingId:void 0,customerName:S.customerName,durationMinutes:n.durationMinutes,localOnly:t?.localOnly===!0,startsAt:r.startsAt},c=3,v=!1,g=``,I()}catch(e){v=!1,g=e instanceof Error?e.message:K.genericError,_=`error`,I()}}}return t.resetFlow=ne,I(),t}function it(e=document.head){if(e.querySelector(`#booking-widget-runtime-style`))return;let t=document.createElement(`style`);t.id=`booking-widget-runtime-style`,t.textContent=`797 ${e.showBranding&&!o?`<div class="powered"><span>${S(t.widgetFooterPrefix)}</span> <strong>${S(t.widgetFooterBrand)}</strong></div>`:``} 798 `,ue(i,a),T){let t=r.querySelector(`[data-payment-element]`),n=T,i=r.querySelector(`[data-action="continue"]`);i&&(i.disabled=!0),t&&n.publishableKey&&n.clientSecret&&at(t,{accent:e.color,clientSecret:T.clientSecret,language:e.language,publishableKey:T.publishableKey}).then(e=>{if(!t.isConnected||T!==n){e.destroy();return}E=e,i&&(i.disabled=x)}).catch(r=>{t.isConnected&&T===n&&rt(t,r,J,e.language)})}}function ue(n,i){Ve(r),r.querySelectorAll(`[data-service-id]`).forEach(e=>{e.addEventListener(`click`,()=>{d=e.getAttribute(`data-service-id`)||``,p=null,m=null,h=null,g=0,_=``,J()})}),r.querySelectorAll(`[data-staff-id]`).forEach(e=>{e.addEventListener(`click`,()=>{let t=e.getAttribute(`data-staff-id`)||null;if(t===h)return;h=t,_=``,v=`error`,g=0,D=P();let n=te();p&&!n.availableDates.includes(p)?(p=null,m=null):m&&!(n.slotsByDate.get(p)||[]).some(e=>e.id===m)&&(m=null),J(),$(r,{containerSelector:`.widget-body-scroll`,margins:{top:8,bottom:16}})})}),r.querySelectorAll(`.staff-chip-avatar`).forEach(e=>{let t=()=>e.remove();e.addEventListener(`error`,t,{once:!0}),e.complete&&!e.naturalWidth&&t()}),r.querySelectorAll(`[data-date]`).forEach(e=>{e.addEventListener(`click`,()=>{p=e.getAttribute(`data-date`),m=null,_=``,J(),$(r,{containerSelector:`.widget-body-scroll`,margins:{top:8,bottom:16}})})}),r.querySelectorAll(`[data-slot-id]`).forEach(e=>{e.addEventListener(`click`,()=>{m=e.getAttribute(`data-slot-id`),_=``,J(),$(r,{containerSelector:`.widget-body-scroll`,margins:{top:8,bottom:16}})})}),r.querySelector(`[data-action="prev-dates"]`)?.addEventListener(`click`,()=>{g=Math.max(0,g-5),J()}),r.querySelector(`[data-action="next-dates"]`)?.addEventListener(`click`,()=>{g=Math.min(Math.max(0,n.length-5),g+5),J()}),r.querySelector(`[data-action="back"]`)?.addEventListener(`click`,()=>{le(),l=Math.max(+!!s,l-1),_=``,v=`error`,J()}),r.querySelector(`[data-action="close-success"]`)?.addEventListener(`click`,()=>{r.dispatchEvent(new CustomEvent(`booking-widget-close`,{bubbles:!0})),window.setTimeout(()=>q(),a?300:0)}),r.querySelectorAll(`.form-input`).forEach(e=>{e.addEventListener(`input`,()=>{le();let e=r.querySelector(`[data-action="continue"]`);e&&(e.disabled=!V(i)||x)})}),r.querySelector(`[data-action="waitlist-open"]`)?.addEventListener(`click`,()=>{A=!0,_=``;let e=p||new Date().toISOString().slice(0,10),t=new Date(`${e}T12:00:00`);t.setDate(t.getDate()+14),N={customerEmail:k.customerEmail,customerName:k.customerName,dateFrom:N.dateFrom||e,dateTo:N.dateTo||t.toISOString().slice(0,10)},J(),$(r,{containerSelector:`.widget-body-scroll`,margins:{top:8,bottom:16}})}),r.querySelectorAll(`[data-waitlist-field]`).forEach(e=>{e.addEventListener(`input`,()=>{let t=e.getAttribute(`data-waitlist-field`);t&&(N[t]=e.value)})}),r.querySelector(`[data-action="waitlist-submit"]`)?.addEventListener(`click`,()=>{if(j)return;let n=F();j=!0,_=``,v=`error`,J(),Qe(e,{customerEmail:N.customerEmail,customerName:N.customerName,dateFrom:N.dateFrom,dateTo:N.dateTo,serviceId:n?.id||n?.name||``}).then(()=>{j=!1,M=!0,A=!1,J()}).catch(e=>{j=!1,_=e instanceof Error?e.message:t.waitlistError,v=`error`,J()})}),r.querySelector(`[data-action="continue"]`)?.addEventListener(`click`,()=>{if(!x){if(le(),l===0&&F()){l=1,_=``,v=`error`,J();return}if(l===1&&B(i)){if(X(e)||Z(e)){fe(i);return}l=2,_=``,v=`error`,J();return}if(T){de();return}l===2&&fe(i)}})}async function de(){if(!T||!E){_=`Payment form is still loading. Please wait a moment.`;let e=r.querySelector(`.widget-message`);e?e.textContent=_:J();return}x=!0,_=``;let n=r.querySelector(`[data-action="continue"]`);if(n){n.disabled=!0;let e=n.querySelector(`span`);e&&(e.textContent=t.processing)}let i=T,a=E;try{let n=i.confirmedPaymentIntentId||await a.confirm();if(T!==i||E!==a)return;i.confirmedPaymentIntentId=n;let r=await ot(e,{bookingId:i.bookingId,paymentIntentId:n});if(T!==i||E!==a)return;if(r.processing||!(r.ok||r.alreadyConfirmed))throw Error(t.paymentError);C={bookingId:T.bookingId,customerName:k.customerName,durationMinutes:F()?.durationMinutes,startsAt:B(te().slotsByDate)?.startsAt},E.destroy(),E=null,T=null,l=3,x=!1,_=``,J()}catch(e){if(T!==i||E!==a)return;if(x=!1,_=e instanceof Error?e.message:t.genericError,n){n.disabled=!1;let e=n.querySelector(`span`);e&&(e.textContent=t.payDepositCta)}let o=r.querySelector(`.widget-message`);if(!o){let e=r.querySelector(`[data-payment-element]`);o=document.createElement(`div`),o.className=`widget-message`,e?.after(o)}o.textContent=_}}async function fe(n){let i=F(),a=B(n);if(!(Number(i?.depositCents)>0&&e.features?.deposits!==!0)&&(X(e)&&(k.customerName=k.customerName.trim()||`Shopify checkout`,k.customerEmail=k.customerEmail.trim()||`shopify-hold@bookminto.hold`),Z(e)&&(k.customerName=k.customerName.trim()||`WooCommerce checkout`,k.customerEmail=k.customerEmail.trim()||`woocommerce-hold@bookminto.hold`),!(!i||!a||!k.customerName.trim()||!k.customerEmail.trim()))){if((k.smsOptIn||k.whatsappOptIn)&&!gt(k.customerPhone,k.customerPhoneCountry||void 0)){_=k.whatsappOptIn&&!k.smsOptIn?t.phoneRequiredForWhatsapp:t.phoneRequiredForSms,v=`error`,J();return}if(!x){x=!0,_=``,v=`error`,J();try{let n=await Ze(e,{answers:Object.keys(k.answers||{}).length?k.answers:void 0,clientRequestId:D,replaceTerminalPayment:O||void 0,publicationRevision:e.publicationRevision,customerEmail:k.customerEmail,customerName:k.customerName,customerPhone:k.customerPhone,customerPhoneCountry:k.customerPhoneCountry||void 0,language:e.language||`en`,notes:k.notes,rebookingOptIn:k.rebookingOptIn||void 0,serviceId:i.id||i.name,serviceName:i.name,smsOptIn:e.sms?.available===!0&&k.smsOptIn||void 0,staffId:L(),startsAt:a.startsAt,whatsappOptIn:e.whatsapp?.available===!0&&k.whatsappOptIn||void 0});if(n?.redirectedToCheckout){_=t.redirectingToCheckout,v=`info`,J();return}if(n?.needsCheckout){x=!1,_=`Could not open checkout. Please try again.`,v=`error`,J();return}let r=nt(n,e.language);if(r){O=!1,T=r,C={bookingId:n.bookingId,customerName:k.customerName,durationMinutes:i.durationMinutes,startsAt:a.startsAt},x=!1,_=``,J();return}C={bookingId:n&&typeof n==`object`?n.bookingId:void 0,customerName:k.customerName,durationMinutes:i.durationMinutes,localOnly:n?.localOnly===!0,startsAt:a.startsAt},l=3,x=!1,_=``,J()}catch(e){x=!1,O=e?.code===`terminal_payment_replacement_available`;let n=e?.code===`staff_unavailable`;n&&(l=1,m=null),_=n?t.staffUnavailable:O?t.replacePaymentNote:e instanceof Error?e.message:t.genericError,v=`error`,J(),r.querySelector(`.widget-message`)?.focus()}}}}return r.resetFlow=q,J(),r}function en(e=document.head){if(e.querySelector(`#booking-widget-runtime-style`))return;let t=document.createElement(`style`);t.id=`booking-widget-runtime-style`,t.textContent=` 575 799 :host { 576 800 color-scheme: light; … … 872 1096 } 873 1097 1098 .service-photo { 1099 display: block; 1100 width: 44px; 1101 height: 44px; 1102 flex: 0 0 44px; 1103 object-fit: cover; 1104 border-radius: 6px; 1105 } 1106 874 1107 .service-info { 1108 min-width: 0; 875 1109 flex: 1; 876 1110 text-align: left; … … 1027 1261 } 1028 1262 1263 .staff-row { 1264 display: flex; 1265 flex-wrap: wrap; 1266 gap: 6px; 1267 margin-bottom: 14px; 1268 } 1269 1270 .staff-chip { 1271 display: inline-flex; 1272 align-items: center; 1273 gap: 7px; 1274 padding: 5px 12px; 1275 border: 1.5px solid #e2e8f0; 1276 border-radius: 999px; 1277 background: white; 1278 cursor: pointer; 1279 font-family: inherit; 1280 font-size: 12px; 1281 font-weight: 500; 1282 color: #475569; 1283 transition: border-color 0.15s, background 0.15s, color 0.15s; 1284 } 1285 1286 .staff-chip:hover { 1287 border-color: var(--bm-accent-border); 1288 background: var(--bm-accent-soft-hover); 1289 } 1290 1291 .staff-chip.selected { 1292 border-color: var(--bm-accent); 1293 background: var(--bm-accent-soft); 1294 color: var(--bm-accent-deep); 1295 } 1296 1297 .staff-chip-dot { 1298 position: relative; 1299 display: inline-flex; 1300 align-items: center; 1301 justify-content: center; 1302 width: 22px; 1303 height: 22px; 1304 flex: 0 0 22px; 1305 border-radius: 999px; 1306 background: #cbd5e1; 1307 color: #fff; 1308 font-size: 9px; 1309 font-weight: 700; 1310 letter-spacing: 0.02em; 1311 overflow: hidden; 1312 } 1313 1314 .staff-chip-avatar { 1315 position: absolute; 1316 inset: 0; 1317 display: block; 1318 width: 100%; 1319 height: 100%; 1320 object-fit: cover; 1321 } 1322 1323 .staff-chip-name { 1324 max-width: 140px; 1325 white-space: nowrap; 1326 overflow: hidden; 1327 text-overflow: ellipsis; 1328 } 1329 1029 1330 .form-field { 1030 1331 margin-bottom: 0; … … 1062 1363 min-height: 74px; 1063 1364 resize: vertical; 1365 } 1366 1367 .form-required { 1368 color: var(--bm-accent-deep); 1369 font-weight: 700; 1370 } 1371 1372 .sr-only { 1373 position: absolute; 1374 width: 1px; 1375 height: 1px; 1376 padding: 0; 1377 margin: -1px; 1378 overflow: hidden; 1379 clip: rect(0 0 0 0); 1380 white-space: nowrap; 1381 border: 0; 1064 1382 } 1065 1383 … … 2394 2712 2395 2713 @media (max-width: 760px) { 2714 /* The mobile panel fills the viewport. Its own close button stays visible; 2715 * the bottom launcher would otherwise cover the booking action. */ 2716 .bw-booking-bar-shell[data-open="true"] { 2717 display: none; 2718 } 2719 2396 2720 .bw-booking-bar-spacer { 2397 2721 height: 0; … … 2605 2929 border-color: rgb(31 95 58 / 0.28); 2606 2930 background: var(--bm-widget-surface); 2931 } 2932 2933 .bw-booking-modal__service .bw-booking-modal__service-body { 2934 display: flex; 2935 align-items: center; 2936 gap: 10px; 2937 min-width: 0; 2938 } 2939 2940 .bw-booking-modal__service .bw-booking-modal__service-copy { 2941 display: flex; 2942 flex-direction: column; 2943 min-width: 0; 2607 2944 } 2608 2945 … … 2945 3282 } 2946 3283 } 2947 `,e.appendChild(t)}let at=`<svg class="launcher-icon visible" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2"></rect><path d="M16 2v4M8 2v4M3 10h18"></path></svg>`;function Z(e,t,n){if(!e)return;if(e.classList&&typeof e.classList.toggle==`function`){e.classList.toggle(t,n);return}let r=new Set(String(e.className||``).split(/\s+/).filter(Boolean));n?r.add(t):r.delete(t),e.className=Array.from(r).join(` `)}function Q(e){let t=e.shadowRoot||e.attachShadow({mode:`open`});return t.replaceChildren(),it(t),t}function ot(e){return e===`bottom-left`||e===`top-right`||e===`top-left`||e===`bottom-right`?e:`bottom-right`}function st(e){return typeof e?.integrationId==`string`?e.integrationId.trim():``}function ct(e){return e&&Array.from(document.querySelectorAll(`[data-booking-widget-integration-id]`)).find(t=>t.dataset?.bookingWidgetIntegrationId===e)||null}function lt(e){return Array.from(document.querySelectorAll(`[data-booking-widget-owner]`)).filter(t=>t.dataset?.bookingWidgetOwner===e)}function ut(e,t){return e?.dataset?.bookingWidgetPlacement===`auto`&&t.variant===`inline`}function dt(e,t){let n=e.dataset.bookingWidgetMountId||`bw-${Date.now()}-${Math.random().toString(16).slice(2)}`;if(e.dataset.bookingWidgetMountId=n,lt(n).forEach(e=>e.remove()),e.querySelectorAll(`[data-booking-widget-runtime], .bw-widget-bar, .bw-widget-shadow-host`).forEach(e=>e.remove()),(t.commerce===`shopify`||t.commerce===`woocommerce`)&&(t={...t,variant:`inline`}),t.variant===`floating`){let e=document.createElement(`div`),a=Q(e),o=document.createElement(`div`),s=document.createElement(`button`),c=ot(t.position),u=rt({...t,variant:`floating`}),d=document.createElement(`div`),f=document.createElement(`div`),p=f.attachShadow({mode:`open`}),m=typeof d.showPopover==`function`,h=!1,g=0;qe(),it(p),Z(u,`widget--floating-panel`,!0),Z(u,`open`,!0),Z(u,`closed`,!1),p.append(u),f.className=`bw-floating-popover__host`,f.style.cssText=`display:block;width:100%;height:100%;`,d.className=`bw-floating-popover`,d.id=`${n}-floating-panel`,d.dataset.bookingWidgetOwner=n,d.dataset.position=c,d.setAttribute(`data-position`,c),d.setAttribute(`role`,`dialog`),d.setAttribute(`aria-modal`,`false`),d.setAttribute(`aria-label`,t.title||K.bookNow),m?d.setAttribute(`popover`,`manual`):d.hidden=!0,G(d,t.color),G(f,t.color),d.append(f);function r(n){h=n,Z(s,`expanded`,n),s.setAttribute(`aria-expanded`,n?`true`:`false`),s.setAttribute(`aria-label`,n?K.close:K.bookNow),s.innerHTML=n?`<svg class="launcher-icon visible" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12"></path></svg>`:`${at}<span class="launcher-label">${l(t.title||K.bookNow)}</span>`,Z(o,`is-open`,n),X(e),X(d);try{m?n&&!d.matches(`:popover-open`)?d.showPopover():!n&&d.matches(`:popover-open`)&&d.hidePopover():d.hidden=!n}catch{d.hidden=!n,Z(d,`is-fallback-open`,n)}n?typeof window.clearTimeout==`function`&&window.clearTimeout(g):(typeof window.clearTimeout==`function`&&window.clearTimeout(g),typeof window.setTimeout==`function`?g=window.setTimeout(()=>{typeof u.resetFlow==`function`&&u.resetFlow()},300):typeof u.resetFlow==`function`&&u.resetFlow())}e.className=`bw-widget-shadow-host`,e.dataset.bookingWidgetOwner=n,e.dataset.variant=`floating`,e.dataset.position=c,e.setAttribute(`data-position`,c),Xe(e),G(e,t.color),o.className=`runtime-shell runtime-shell--widget`,o.dataset.bookingWidgetOwner=n,s.type=`button`,s.className=`launcher`,s.dataset.bookingWidgetOwner=n,s.setAttribute(`aria-label`,K.bookNow),s.setAttribute(`aria-expanded`,`false`),s.setAttribute(`aria-controls`,d.id),s.setAttribute(`aria-haspopup`,`dialog`),s.innerHTML=`${at}<span class="launcher-label">${l(t.title||K.bookNow)}</span>`,s.addEventListener(`click`,()=>r(!h)),u.addEventListener(`booking-widget-close`,()=>r(!1));function i(e){e.key===`Escape`&&h&&r(!1)}return typeof document.addEventListener==`function`&&document.addEventListener(`keydown`,i),r(!1),o.append(s),a.append(o),document.body.append(e,d),X(e),e}if(t.variant===`bar`){let e=document.createElement(`div`),r=Q(e);return e.className=`bw-widget-shadow-host`,e.dataset.bookingWidgetOwner=n,e.dataset.variant=`bar`,Ze(e),document.body.append(e),X(e),Re(t,n,r)}if(t.variant===`modal`){let r=typeof t.trigger==`string`?t.trigger.trim():typeof t.triggerSelector==`string`?t.triggerSelector.trim():``,i=t.buttonAlign===`center`||t.buttonAlign===`right`||t.buttonAlign===`left`?t.buttonAlign:t.buttonPosition===`center`||t.buttonPosition===`right`||t.buttonPosition===`left`?t.buttonPosition:`left`,a=document.createElement(`div`),o=Q(a);return a.className=`bw-widget-shadow-host`,a.dataset.bookingWidgetOwner=n,a.dataset.variant=`modal`,a.dataset.triggerMode=r?`external`:`inline`,a.dataset.buttonPosition=i,a.dataset.open=`false`,G(a,t.color),r?(Xe(a),document.body.append(a),X(a)):(Ye(),a.style.setProperty(`position`,`relative`,`important`),a.style.setProperty(`inset`,`auto`,`important`),a.style.setProperty(`display`,`block`,`important`),a.style.setProperty(`width`,`100%`,`important`),a.style.setProperty(`height`,`auto`,`important`),a.style.setProperty(`pointer-events`,`auto`,`important`),a.style.setProperty(`z-index`,`1`,`important`),e.append(a)),Ke(t,n,o),a}let a=Q(e);return G(e,t.color),a.appendChild(rt({...t,variant:`inline`})),e}function $(e,t){if(!e)return null;let n=he(e,t);return e.dataset.bookingWidgetMounted===`true`?e:(e.dataset.bookingWidgetMounted=`true`,n.workspace?(ge(n).then(t=>{ut(e,t)||dt(e,t)}),e):ut(e,n)?e:dt(e,R(n)))}function ft(e){let t=st(e),n=ct(t)||document.createElement(`div`);return n.setAttribute(`data-booking-widget`,``),t&&(n.dataset.bookingWidgetIntegrationId=t),n.parentNode||document.body.appendChild(n),$(n,e)}function pt(){if(Array.isArray(window.bookingWidgetQueue))for(;window.bookingWidgetQueue.length>0;)ft(window.bookingWidgetQueue.shift())}function mt(e){pt(),(e||document).querySelectorAll(`[data-booking-widget], script[data-workspace][src*="bookminto"], script[data-workspace][src*="bookonpage"], script[data-workspace][src*="bookingwidget"]`).forEach(e=>{if(e.tagName===`SCRIPT`){let t=document.createElement(`div`);t.setAttribute(`data-booking-widget`,``),e.after(t),$(t,e.dataset);return}$(e)})}window.BookingWidget={create:ft,mount:$,scan:mt},document.readyState===`loading`?document.addEventListener(`DOMContentLoaded`,()=>mt()):mt()})(); 3284 3285 .bw-next-times { 3286 display: flex; 3287 flex-wrap: wrap; 3288 align-items: center; 3289 gap: 8px 10px; 3290 padding: 10px 12px; 3291 border: 1px solid color-mix(in srgb, var(--bw-color, #14685f) 18%, #d8d7d0); 3292 border-radius: 12px; 3293 background: var(--bw-surface, #fff); 3294 font-family: var(--bw-font, 'Sora', system-ui, sans-serif); 3295 font-size: 13px; 3296 color: #3f3d3a; 3297 max-width: 100%; 3298 box-sizing: border-box; 3299 } 3300 3301 .bw-next-times__label { 3302 font-weight: 600; 3303 color: #26251f; 3304 white-space: nowrap; 3305 } 3306 3307 .bw-next-times__slots { 3308 display: flex; 3309 flex-wrap: wrap; 3310 gap: 6px; 3311 } 3312 3313 .bw-next-times__slot { 3314 display: inline-flex; 3315 align-items: baseline; 3316 gap: 6px; 3317 padding: 6px 10px; 3318 border: 1px solid color-mix(in srgb, var(--bw-color, #14685f) 40%, #d8d7d0); 3319 border-radius: 999px; 3320 background: color-mix(in srgb, var(--bw-color, #14685f) 7%, #fff); 3321 color: #26251f; 3322 font: inherit; 3323 font-size: 12.5px; 3324 cursor: pointer; 3325 transition: border-color 0.15s ease, background 0.15s ease; 3326 } 3327 3328 .bw-next-times__slot b { 3329 font-weight: 600; 3330 text-transform: capitalize; 3331 } 3332 3333 .bw-next-times__slot span { 3334 color: #5f5b52; 3335 } 3336 3337 .bw-next-times__slot:hover, 3338 .bw-next-times__slot:focus-visible { 3339 border-color: var(--bw-color, #14685f); 3340 background: color-mix(in srgb, var(--bw-color, #14685f) 14%, #fff); 3341 outline: none; 3342 } 3343 3344 .bw-next-times__all { 3345 padding: 6px 4px; 3346 border: 0; 3347 background: none; 3348 color: var(--bw-color, #14685f); 3349 font: inherit; 3350 font-size: 12.5px; 3351 font-weight: 600; 3352 cursor: pointer; 3353 text-decoration: underline; 3354 text-underline-offset: 2px; 3355 } 3356 3357 .bw-next-times__empty { 3358 color: #6b675d; 3359 font-size: 12.5px; 3360 } 3361 3362 @media (max-width: 480px) { 3363 .bw-next-times { 3364 flex-direction: column; 3365 align-items: flex-start; 3366 } 3367 3368 .bw-next-times__slots { 3369 width: 100%; 3370 } 3371 3372 .bw-next-times__slot { 3373 flex: 1 1 auto; 3374 justify-content: center; 3375 } 3376 } 3377 3378 /* Extension F — recommended compact-schedule slots */ 3379 .time-btn.recommended, 3380 .tbtn.rec { 3381 border-color: var(--bw-color, #14685f); 3382 position: relative; 3383 } 3384 3385 .time-btn.recommended .rec-badge, 3386 .tbtn.rec .rec-badge { 3387 display: block; 3388 font-style: normal; 3389 font-size: 9.5px; 3390 font-weight: 600; 3391 letter-spacing: 0.03em; 3392 color: var(--bw-color, #14685f); 3393 text-transform: uppercase; 3394 } 3395 3396 /* Extension D — waitlist entry point inside the date step */ 3397 .waitlist-cta { 3398 display: block; 3399 width: 100%; 3400 margin-top: 10px; 3401 padding: 10px 12px; 3402 border: 1px dashed color-mix(in srgb, var(--bw-color, #14685f) 45%, #d8d7d0); 3403 border-radius: 10px; 3404 background: color-mix(in srgb, var(--bw-color, #14685f) 6%, transparent); 3405 color: var(--bw-color, #14685f); 3406 font-family: inherit; 3407 font-size: 13px; 3408 font-weight: 600; 3409 cursor: pointer; 3410 transition: background 0.15s ease; 3411 } 3412 3413 .waitlist-cta:hover, 3414 .waitlist-cta:focus-visible { 3415 background: color-mix(in srgb, var(--bw-color, #14685f) 12%, transparent); 3416 outline: none; 3417 } 3418 3419 .waitlist-form { 3420 margin-top: 12px; 3421 padding: 12px; 3422 border: 1px solid color-mix(in srgb, var(--bw-color, #14685f) 18%, #d8d7d0); 3423 border-radius: 12px; 3424 display: flex; 3425 flex-direction: column; 3426 gap: 10px; 3427 } 3428 3429 .waitlist-note { 3430 margin: 0; 3431 font-size: 12.5px; 3432 line-height: 1.45; 3433 color: #6b6963; 3434 } 3435 3436 .waitlist-form .cta-btn, 3437 .waitlist-form .cta { 3438 margin-top: 2px; 3439 } 3440 3441 .waitlist-joined { 3442 margin-top: 10px; 3443 color: var(--bw-color, #14685f); 3444 } 3445 3446 /* Extension B — rebooking invitation opt-in */ 3447 .rebooking-optin { 3448 display: flex; 3449 align-items: flex-start; 3450 gap: 8px; 3451 margin-top: 14px; 3452 padding: 10px 12px; 3453 border-radius: 10px; 3454 background: color-mix(in srgb, var(--bw-color, #14685f) 6%, transparent); 3455 font-size: 13px; 3456 line-height: 1.4; 3457 color: #3f3d3a; 3458 cursor: pointer; 3459 } 3460 3461 .rebooking-optin input { 3462 margin-top: 1px; 3463 accent-color: var(--bw-color, #14685f); 3464 flex: none; 3465 } 3466 `,e.appendChild(t)}let tn=`<svg class="launcher-icon visible" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2"></rect><path d="M16 2v4M8 2v4M3 10h18"></path></svg>`;function nn(e,t,n){if(!e)return;if(e.classList&&typeof e.classList.toggle==`function`){e.classList.toggle(t,n);return}let r=new Set(String(e.className||``).split(/\s+/).filter(Boolean));n?r.add(t):r.delete(t),e.className=Array.from(r).join(` `)}function rn(e){let t=e.shadowRoot||e.attachShadow({mode:`open`});return t.replaceChildren(),en(t),t}function an(e){return e===`bottom-left`||e===`top-right`||e===`top-left`||e===`bottom-right`?e:`bottom-right`}function on(e){return typeof e?.integrationId==`string`?e.integrationId.trim():``}function sn(e){return e&&Array.from(document.querySelectorAll(`[data-booking-widget-integration-id]`)).find(t=>t.dataset?.bookingWidgetIntegrationId===e)||null}function cn(e){return Array.from(document.querySelectorAll(`[data-booking-widget-owner]`)).filter(t=>t.dataset?.bookingWidgetOwner===e)}function ln(e,t){return e?.dataset?.bookingWidgetPlacement===`auto`&&t.variant===`inline`}function un(e,t){let n=c(t.language),r=e.dataset.bookingWidgetMountId||`bw-${Date.now()}-${Math.random().toString(16).slice(2)}`;if(e.dataset.bookingWidgetMountId=r,cn(r).forEach(e=>{e.__bookingWidgetDestroy?.(),e.remove()}),e.querySelectorAll(`[data-booking-widget-runtime], .bw-widget-bar, .bw-widget-shadow-host`).forEach(e=>{e.__bookingWidgetDestroy?.(),e.remove()}),(t.commerce===`shopify`||t.commerce===`woocommerce`)&&(t={...t,variant:`inline`}),t.nextTimes)if(!(t.previewMode||!t.features||typeof t.features!=`object`||Object.keys(t.features).length===0||t.features.nextTimes===!0))t={...t,nextTimes:!1,variant:`modal`};else{let n=rn(e);Q(e,t.color);let i=document.createElement(`div`),a=rn(i);i.className=`bw-widget-shadow-host`,i.dataset.bookingWidgetOwner=r,i.dataset.variant=`modal`,i.dataset.open=`false`,Q(i,t.color),Rt(i),document.body.append(i),Bt(i);let o=Nt(t,r,a,{suppressCta:!0});return i.__bookingWidgetDestroy=o.destroy,n.appendChild(Ut(t,{onOpen:()=>o.setOpen(!0),onSelectSlot:(e,t)=>{o.panel?.preselectBooking?.({serviceId:e,startsAt:t}),o.setOpen(!0)}})),e}if(t.variant===`floating`){let e=document.createElement(`div`),o=rn(e),s=document.createElement(`div`),c=document.createElement(`button`),l=an(t.position),u=$t({...t,variant:`floating`}),d=document.createElement(`div`),f=document.createElement(`div`),p=f.attachShadow({mode:`open`}),m=typeof d.showPopover==`function`,h=!1,g=0,_=!1;Pt(),en(p),nn(u,`widget--floating-panel`,!0),nn(u,`open`,!0),nn(u,`closed`,!1),p.append(u),f.className=`bw-floating-popover__host`,f.style.cssText=`display:block;width:100%;height:100%;`,d.className=`bw-floating-popover`,d.id=`${r}-floating-panel`,d.dataset.bookingWidgetOwner=r,d.dataset.position=l,d.setAttribute(`data-position`,l),d.setAttribute(`role`,`dialog`),d.setAttribute(`aria-modal`,`false`),d.setAttribute(`aria-label`,t.title||n.bookNow),m?d.setAttribute(`popover`,`manual`):d.hidden=!0,Q(d,t.color),Q(f,t.color),d.append(f);function i(r){if(!(_||e.isConnected===!1)){h=r,nn(c,`expanded`,r),c.setAttribute(`aria-expanded`,r?`true`:`false`),c.setAttribute(`aria-label`,r?n.close:n.bookNow),c.innerHTML=r?`<svg class="launcher-icon visible" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12"></path></svg>`:`${tn}<span class="launcher-label">${S(t.title||n.bookNow)}</span>`,nn(s,`is-open`,r),Bt(e),Bt(d);try{m?r&&!d.matches(`:popover-open`)?d.showPopover():!r&&d.matches(`:popover-open`)&&d.hidePopover():d.hidden=!r}catch{d.hidden=!r,nn(d,`is-fallback-open`,r)}r?typeof window.clearTimeout==`function`&&window.clearTimeout(g):(typeof window.clearTimeout==`function`&&window.clearTimeout(g),typeof window.setTimeout==`function`?g=window.setTimeout(()=>{typeof u.resetFlow==`function`&&u.resetFlow()},300):typeof u.resetFlow==`function`&&u.resetFlow())}}e.className=`bw-widget-shadow-host`,e.dataset.bookingWidgetOwner=r,e.dataset.variant=`floating`,e.dataset.position=l,e.setAttribute(`data-position`,l),Rt(e),Q(e,t.color),s.className=`runtime-shell runtime-shell--widget`,s.dataset.bookingWidgetOwner=r,c.type=`button`,c.className=`launcher`,c.dataset.bookingWidgetOwner=r,c.setAttribute(`aria-label`,n.bookNow),c.setAttribute(`aria-expanded`,`false`),c.setAttribute(`aria-controls`,d.id),c.setAttribute(`aria-haspopup`,`dialog`),c.innerHTML=`${tn}<span class="launcher-label">${S(t.title||n.bookNow)}</span>`,c.addEventListener(`click`,()=>i(!h)),u.addEventListener(`booking-widget-close`,()=>i(!1));function a(t){if(e.isConnected===!1){_=!0,typeof document.removeEventListener==`function`&&document.removeEventListener(`keydown`,a);return}t.key===`Escape`&&h&&i(!1)}return typeof document.addEventListener==`function`&&document.addEventListener(`keydown`,a),e.__bookingWidgetDestroy=()=>{_=!0,typeof document.removeEventListener==`function`&&document.removeEventListener(`keydown`,a),typeof window.clearTimeout==`function`&&window.clearTimeout(g)},i(!1),s.append(c),o.append(s),document.body.append(e,d),Bt(e),e}if(t.variant===`bar`){let e=document.createElement(`div`),n=rn(e);return e.className=`bw-widget-shadow-host`,e.dataset.bookingWidgetOwner=r,e.dataset.variant=`bar`,zt(e),document.body.append(e),Bt(e),xt(t,r,n)}if(t.variant===`modal`){let n=typeof t.trigger==`string`?t.trigger.trim():typeof t.triggerSelector==`string`?t.triggerSelector.trim():``,i=t.buttonAlign===`center`||t.buttonAlign===`right`||t.buttonAlign===`left`?t.buttonAlign:t.buttonPosition===`center`||t.buttonPosition===`right`||t.buttonPosition===`left`?t.buttonPosition:`left`,a=document.createElement(`div`),o=rn(a);return a.className=`bw-widget-shadow-host`,a.dataset.bookingWidgetOwner=r,a.dataset.variant=`modal`,a.dataset.triggerMode=n?`external`:`inline`,a.dataset.buttonPosition=i,a.dataset.open=`false`,Q(a,t.color),n?(Rt(a),document.body.append(a),Bt(a)):(Lt(),a.style.setProperty(`position`,`relative`,`important`),a.style.setProperty(`inset`,`auto`,`important`),a.style.setProperty(`display`,`block`,`important`),a.style.setProperty(`width`,`100%`,`important`),a.style.setProperty(`height`,`auto`,`important`),a.style.setProperty(`pointer-events`,`auto`,`important`),a.style.setProperty(`z-index`,`1`,`important`),e.append(a)),a.__bookingWidgetDestroy=Nt(t,r,o).destroy,a}let o=rn(e);return Q(e,t.color),o.appendChild($t({...t,variant:`inline`})),e}function dn(e,t){if(!e)return null;let n=Fe(e,t);return e.dataset.bookingWidgetMounted===`true`?e:(e.dataset.bookingWidgetMounted=`true`,n.workspace?(Ie(n).then(t=>{e.isConnected!==!1&&(ln(e,t)||un(e,t))}),e):ln(e,n)?e:un(e,je(n)))}function fn(e){let t=on(e),n=sn(t)||document.createElement(`div`);return n.setAttribute(`data-booking-widget`,``),t&&(n.dataset.bookingWidgetIntegrationId=t),n.parentNode||document.body.appendChild(n),dn(n,e)}function pn(){if(Array.isArray(window.bookingWidgetQueue))for(;window.bookingWidgetQueue.length>0;)fn(window.bookingWidgetQueue.shift())}function mn(e){pn(),(e||document).querySelectorAll(`[data-booking-widget], script[data-workspace][src*="bookminto"], script[data-workspace][src*="bookonpage"], script[data-workspace][src*="bookingwidget"]`).forEach(e=>{if(e.tagName===`SCRIPT`){if(e.dataset.bookingWidgetScanned===`true`)return;e.dataset.bookingWidgetScanned=`true`;let t=document.createElement(`div`);t.setAttribute(`data-booking-widget`,``),e.after(t),dn(t,e.dataset);return}dn(e)})}window.BookingWidget={create:fn,mount:dn,scan:mn},document.readyState===`loading`?document.addEventListener(`DOMContentLoaded`,()=>mn()):mn()})(); -
bookminto/trunk/bookminto.php
r3723811 r3730301 3 3 * Plugin Name: Bookminto 4 4 * Description: Add Bookminto to WordPress pages with automatic display, a block, a shortcode, or WooCommerce product checkout booking. 5 * Version: 1.0. 05 * Version: 1.0.5 6 6 * Author: Bookminto 7 7 * Author URI: https://bookminto.com/ … … 23 23 const BOOKMINTO_OPTION = 'bookminto_options'; 24 24 const BOOKMINTO_OPTION_LEGACY = 'booking_widget_options'; 25 const BOOKMINTO_VERSION = '1.0. 0';25 const BOOKMINTO_VERSION = '1.0.5'; 26 26 const BOOKMINTO_API_URL = 'https://api.bookminto.com/v1'; 27 27 const BOOKMINTO_PLUGIN_DIR = __DIR__; -
bookminto/trunk/includes/woocommerce.php
r3723811 r3730301 50 50 add_action('wp_ajax_bookminto_add_to_cart', 'bookminto_woo_ajax_add_to_cart'); 51 51 add_action('wp_ajax_nopriv_bookminto_add_to_cart', 'bookminto_woo_ajax_add_to_cart'); 52 add_action('template_redirect', 'bookminto_woo_waitlist_checkout'); 52 53 53 54 add_filter('woocommerce_add_cart_item_data', 'bookminto_woo_add_cart_item_data', 10, 3); … … 933 934 } 934 935 935 // One booking line per request; clear prior bookable lines for this product. 936 $cart_key = bookminto_woo_put_booking_in_cart($product_id, $booking_id, $appointment, $service_name); 937 if (!$cart_key) { 938 if (function_exists('wc_clear_notices')) { 939 wc_clear_notices(); 940 } 941 wp_send_json_error(['message' => __('Could not add booking to cart.', 'bookminto')], 400); 942 } 943 944 wp_send_json_success(['checkoutUrl' => wc_get_checkout_url()]); 945 } 946 947 /** The normal planner and waitlist handoff use the same cart metadata path. */ 948 function bookminto_woo_put_booking_in_cart(int $product_id, string $booking_id, string $appointment, string $service_name): string 949 { 950 if (!function_exists('WC') || !WC()->cart) { 951 return ''; 952 } 953 // One booking line per product; reopening the same checkout keeps one id. 936 954 foreach (WC()->cart->get_cart() as $key => $item) { 937 955 if ((int) ($item['product_id'] ?? 0) === $product_id && !empty($item[BOOKMINTO_WOO_BOOKING_META])) { … … 944 962 $_REQUEST['bookminto_service_name'] = $service_name; 945 963 $_REQUEST['bookminto_ajax'] = '1'; 964 $_REQUEST['nonce'] = wp_create_nonce('bookminto_woo_cart'); 946 965 947 966 $cart_key = WC()->cart->add_to_cart($product_id, 1); … … 950 969 wc_clear_notices(); 951 970 } 952 wp_send_json_error(['message' => __('Could not add booking to cart.', 'bookminto')], 400);971 return ''; 953 972 } 954 973 … … 957 976 } 958 977 959 wp_send_json_success( 960 [ 961 'checkoutUrl' => wc_get_checkout_url(), 962 ] 963 ); 978 return (string) $cart_key; 979 } 980 981 /** GET only previews. The explicit nonce-protected POST attests this store's 982 * product/service mapping with its existing installation signature. */ 983 function bookminto_woo_waitlist_checkout(): void 984 { 985 $token = sanitize_text_field(wp_unslash($_REQUEST['bookminto_waitlist'] ?? '')); 986 if ($token === '' || !preg_match('/^bmwl_[A-Za-z0-9_-]{20,64}$/', $token)) { 987 return; 988 } 989 nocache_headers(); 990 header('Referrer-Policy: no-referrer'); 991 header('X-Robots-Tag: noindex, nofollow'); 992 if (!bookminto_woo_credentials()) { 993 wp_die(esc_html__('This store is not connected to Bookminto.', 'bookminto'), '', ['response' => 503]); 994 } 995 $response = wp_remote_get(rtrim(BOOKMINTO_API_URL, '/') . '/waitlist-claim/' . rawurlencode($token), ['timeout' => 15]); 996 $preview = !is_wp_error($response) ? json_decode((string) wp_remote_retrieve_body($response), true) : null; 997 if (!is_array($preview) || !in_array($preview['state'] ?? '', ['open', 'payment_hold', 'claimed'], true) || empty($preview['serviceId'])) { 998 wp_die(esc_html__('This offer is no longer available. Please contact the store.', 'bookminto'), '', ['response' => 410]); 999 } 1000 if (($preview['state'] ?? '') === 'claimed') { 1001 wp_die(esc_html__('This appointment is already confirmed.', 'bookminto')); 1002 } 1003 $products = get_posts(['post_type' => 'product', 'post_status' => 'publish', 'fields' => 'ids', 'posts_per_page' => 2, 1004 'meta_query' => [['key' => BOOKMINTO_WOO_SERVICE_META, 'value' => (string) $preview['serviceId']]]]); 1005 if (count($products) !== 1) { 1006 wp_die(esc_html__('The booking product cannot be determined. Please contact the store.', 'bookminto'), '', ['response' => 409]); 1007 } 1008 $product_id = (int) $products[0]; 1009 $product = wc_get_product($product_id); 1010 if (!$product || !$product->is_purchasable() || bookminto_woo_get_service_id($product) !== (string) $preview['serviceId']) { 1011 wp_die(esc_html__('This booking product is unavailable.', 'bookminto'), '', ['response' => 409]); 1012 } 1013 if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') { 1014 if (!wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['nonce'] ?? '')), 'bookminto_woo_waitlist')) { 1015 wp_die(esc_html__('Reload this offer before continuing.', 'bookminto'), '', ['response' => 403]); 1016 } 1017 $revision = sanitize_text_field(wp_unslash($_POST['publication_revision'] ?? '')); 1018 if ($revision !== ($preview['publicationRevision'] ?? '')) { 1019 wp_die(esc_html__('The booking conditions changed. Reload this offer before continuing.', 'bookminto'), '', ['response' => 409]); 1020 } 1021 $answers = []; 1022 foreach ((array) ($preview['questions'] ?? []) as $question) { 1023 $id = (string) ($question['id'] ?? ''); 1024 $value = $_POST['answers'][$id] ?? ''; 1025 if (is_string($value)) { 1026 $answers[$id] = sanitize_text_field(wp_unslash($value)); 1027 } 1028 } 1029 $result = bookminto_woo_signed_api_post('/waitlist/woocommerce-checkout', [ 1030 'token' => $token, 'productId' => (string) $product_id, 'serviceId' => (string) $preview['serviceId'], 1031 'publicationRevision' => $revision, 'answers' => (object) $answers, 1032 ]); 1033 $body = $result['body'] ?? []; 1034 if (empty($result['ok']) || empty($body['needsCheckout']) || empty($body['bookingId']) 1035 || ($body['commerceContext']['wooProductId'] ?? '') !== (string) $product_id) { 1036 wp_die(esc_html__('This offer could not be reserved. Reload it before trying again.', 'bookminto'), '', ['response' => 409]); 1037 } 1038 if (function_exists('wc_load_cart') && !WC()->cart) { 1039 wc_load_cart(); 1040 } 1041 $cart_key = bookminto_woo_put_booking_in_cart($product_id, (string) $body['bookingId'], 1042 (string) ($body['startsAt'] ?? ''), (string) ($preview['serviceName'] ?? '')); 1043 if (!$cart_key) { 1044 wp_die(esc_html__('The cart is unavailable. Reopen this same offer to continue.', 'bookminto'), '', ['response' => 503]); 1045 } 1046 wp_safe_redirect(wc_get_checkout_url()); 1047 exit; 1048 } 1049 get_header(); 1050 echo '<main class="bookminto-woo-waitlist"><h1>' . esc_html__('Complete your appointment', 'bookminto') . '</h1>'; 1051 $when = (string) ($preview['startsAt'] ?? ''); 1052 $timestamp = strtotime($when); 1053 if ($timestamp !== false) { 1054 try { 1055 $timezone = new DateTimeZone((string) ($preview['timezone'] ?? 'UTC')); 1056 $when = wp_date('j F Y H:i T', $timestamp, $timezone); 1057 } catch (Exception $error) { 1058 // Keep the explicit provider instant when its timezone is invalid. 1059 } 1060 } 1061 echo '<p>' . esc_html((string) ($preview['serviceName'] ?? '')) . '</p><p>' . esc_html($when) . '</p>'; 1062 echo '<p>' . wp_kses_post($product->get_price_html()) . '</p>'; 1063 echo '<p>' . esc_html__('Your appointment is confirmed after the shop payment has been verified.', 'bookminto') . '</p>'; 1064 echo '<form method="post"><input type="hidden" name="bookminto_waitlist" value="' . esc_attr($token) . '">'; 1065 echo '<input type="hidden" name="publication_revision" value="' . esc_attr((string) ($preview['publicationRevision'] ?? '')) . '">'; 1066 wp_nonce_field('bookminto_woo_waitlist', 'nonce'); 1067 foreach ((array) ($preview['questions'] ?? []) as $q) { 1068 $name = 'answers[' . (string) ($q['id'] ?? '') . ']'; 1069 $required = !empty($q['required']) ? ' required' : ''; 1070 echo '<p><label>' . esc_html((string) ($q['label'] ?? '')); 1071 $type = (string) ($q['type'] ?? ''); 1072 if (in_array($type, ['choice', 'select', 'yesno', 'yes_no'], true)) { 1073 $choices = in_array($type, ['yesno', 'yes_no'], true) ? ['yes' => __('Yes', 'bookminto'), 'no' => __('No', 'bookminto')] : array_combine((array) ($q['options'] ?? []), (array) ($q['options'] ?? [])); 1074 echo '<select name="' . esc_attr($name) . '"' . $required . '><option value="">—</option>'; 1075 foreach ((array) $choices as $value => $label) { 1076 echo '<option value="' . esc_attr((string) $value) . '">' . esc_html((string) $label) . '</option>'; 1077 } 1078 echo '</select>'; 1079 } else { 1080 echo '<input name="' . esc_attr($name) . '" maxlength="500"' . $required . '>'; 1081 } 1082 echo '</label></p>'; 1083 } 1084 echo '<button type="submit">' . esc_html__('Continue to checkout', 'bookminto') . '</button></form></main>'; 1085 get_footer(); 1086 exit; 964 1087 } 965 1088 -
bookminto/trunk/languages/bookminto.pot
r3723811 r3730301 1 1 msgid "" 2 2 msgstr "" 3 "Project-Id-Version: Bookminto 1.0. 0\n"3 "Project-Id-Version: Bookminto 1.0.5\n" 4 4 "Report-Msgid-Bugs-To: https://bookminto.com/\n" 5 "POT-Creation-Date: 2026-10-0 1\n"5 "POT-Creation-Date: 2026-10-06\n" 6 6 "MIME-Version: 1.0\n" 7 7 "Content-Type: text/plain; charset=UTF-8\n" … … 57 57 msgstr "" 58 58 59 #: includes/admin-ui.php60 msgid "Booking bar, buffers, and schedule exceptions"61 msgstr ""62 63 59 #: blocks/widget/editor.js bookminto.php includes/admin-menu.php includes/admin-menu.php includes/admin-menu.php 64 60 msgid "Bookminto" … … 101 97 msgstr "" 102 98 99 #: includes/admin-ui.php 100 msgid "Bookminto service access" 101 msgstr "" 102 103 103 #: includes/admin-menu.php 104 104 msgid "Bookminto services" … … 153 153 msgstr "" 154 154 155 #: includes/ admin-ui.php156 msgid "Comp are plans"155 #: includes/woocommerce.php 156 msgid "Complete your appointment" 157 157 msgstr "" 158 158 … … 177 177 msgstr "" 178 178 179 #: includes/woocommerce.php 180 msgid "Continue to checkout" 181 msgstr "" 182 179 183 #: includes/settings.php 180 184 msgid "Copy the key from Bookminto → Install → WordPress, then save it here." … … 206 210 207 211 #: includes/admin-ui.php 208 msgid "Create a Bookminto account , open Install, and paste the key under Settings. Bookminto is an external service with Free, Solo, and Pro plans."212 msgid "Create a Bookminto account" 209 213 msgstr "" 210 214 … … 253 257 msgstr "" 254 258 255 #: includes/admin-ui.php includes/admin-ui.php256 msgid "Deposits via Stripe Connect"257 msgstr ""258 259 259 #: includes/settings.php 260 260 msgid "Disconnect could not be confirmed. Your local credentials were kept. Retry, or revoke this connection under Bookminto → Install." … … 275 275 #: includes/admin-menu.php 276 276 msgid "Edit product" 277 msgstr ""278 279 #: includes/admin-ui.php includes/admin-ui.php280 msgid "For growing businesses that need deposits and automations."281 msgstr ""282 283 #: includes/admin-ui.php284 msgid "For independent professionals running their bookable business."285 277 msgstr "" 286 278 … … 298 290 299 291 #: includes/admin-ui.php 300 msgid "Get an installation key"301 msgstr ""302 303 #: includes/admin-ui.php includes/admin-ui.php304 msgid "Get Pro"305 msgstr ""306 307 #: includes/admin-ui.php308 msgid "Get Solo"309 msgstr ""310 311 #: includes/admin-ui.php312 292 msgid "Help & links" 313 293 msgstr "" … … 381 361 msgstr "" 382 362 363 #: includes/woocommerce.php 364 msgid "No" 365 msgstr "" 366 383 367 #: includes/admin-menu.php 384 368 msgid "No appointments yet." … … 473 457 msgstr "" 474 458 475 #: includes/admin-ui.php includes/admin-ui.php 476 msgid "Priority support" 477 msgstr "" 478 479 #: includes/admin-ui.php includes/admin-ui.php 459 #: includes/admin-ui.php 480 460 msgid "Pro" 481 461 msgstr "" … … 491 471 #: includes/admin-ui.php 492 472 msgid "Product overview" 493 msgstr ""494 495 #: includes/admin-ui.php496 msgid "Product-page checkout booking needs Solo or Pro. The block, shortcode, and site-wide widget work on Free."497 473 msgstr "" 498 474 … … 510 486 511 487 #: includes/woocommerce.php 488 msgid "Reload this offer before continuing." 489 msgstr "" 490 491 #: includes/woocommerce.php 512 492 msgid "Retry Bookminto order synchronization" 513 493 msgstr "" … … 535 515 #: includes/settings.php 536 516 msgid "Secure connection active" 537 msgstr ""538 539 #: includes/admin-ui.php540 msgid "See pricing on bookminto.com"541 517 msgstr "" 542 518 … … 569 545 msgstr "" 570 546 571 #: includes/admin-ui.php includes/admin-ui.php547 #: includes/admin-ui.php 572 548 msgid "Solo" 573 549 msgstr "" … … 575 551 #: includes/admin-menu.php 576 552 msgid "Source" 577 msgstr ""578 579 #: includes/admin-ui.php580 msgid "Start for free"581 553 msgstr "" 582 554 … … 609 581 msgstr "" 610 582 583 #: includes/woocommerce.php 584 msgid "The booking conditions changed. Reload this offer before continuing." 585 msgstr "" 586 587 #: includes/woocommerce.php 588 msgid "The booking product cannot be determined. Please contact the store." 589 msgstr "" 590 611 591 #: blocks/widget/editor.js 612 592 msgid "The booking widget appears here on the published page." … … 621 601 msgstr "" 622 602 603 #: includes/woocommerce.php 604 msgid "The cart is unavailable. Reopen this same offer to continue." 605 msgstr "" 606 623 607 #: includes/admin-menu.php 624 608 msgid "The product could not be created." 609 msgstr "" 610 611 #: includes/woocommerce.php 612 msgid "This appointment is already confirmed." 613 msgstr "" 614 615 #: includes/woocommerce.php 616 msgid "This booking product is unavailable." 617 msgstr "" 618 619 #: includes/admin-ui.php 620 msgid "This free WordPress plugin connects to the external Bookminto booking service. Account access, current prices, and beta terms are published on bookminto.com." 621 msgstr "" 622 623 #: includes/woocommerce.php 624 msgid "This offer could not be reserved. Reload it before trying again." 625 msgstr "" 626 627 #: includes/woocommerce.php 628 msgid "This offer is no longer available. Please contact the store." 625 629 msgstr "" 626 630 … … 641 645 msgstr "" 642 646 647 #: includes/woocommerce.php 648 msgid "This store is not connected to Bookminto." 649 msgstr "" 650 643 651 #: includes/settings.php 644 652 msgid "To connect or rotate credentials, create a one-time WordPress connection code under Bookminto → Install. The code expires after 10 minutes." … … 653 661 msgstr "" 654 662 655 #: includes/admin-ui.php656 msgid "Unlimited bookings"657 msgstr ""658 659 663 #: includes/admin-menu.php 660 664 msgid "Updated “%s” to match the Bookminto service." … … 665 669 msgstr "" 666 670 667 #: includes/admin-ui.php668 msgid "Upgrade to Pro"669 msgstr ""670 671 671 #: includes/settings.php 672 672 msgid "Use the checkbox above for a floating button, booking bar, or modal on every page." … … 675 675 #: includes/woocommerce.php 676 676 msgid "Used when this product creates or updates a Bookminto service." 677 msgstr "" 678 679 #: includes/admin-ui.php 680 msgid "View current plans and beta terms" 677 681 msgstr "" 678 682 … … 693 697 msgstr "" 694 698 695 #: includes/admin-ui.php696 msgid "WooCommerce checkout booking"697 msgstr ""698 699 699 #: includes/settings.php 700 700 msgid "WooCommerce is securely connected to Bookminto." … … 713 713 msgstr "" 714 714 715 #: includes/ admin-ui.php includes/settings.php715 #: includes/settings.php 716 716 msgid "WordPress install guide" 717 717 msgstr "" … … 719 719 #: includes/admin-menu.php 720 720 msgid "Workspace" 721 msgstr "" 722 723 #: includes/woocommerce.php 724 msgid "Yes" 721 725 msgstr "" 722 726 … … 729 733 msgstr "" 730 734 731 #: includes/admin-ui.php includes/admin-ui.php 732 msgid "Zapier, Make, and API webhooks" 733 msgstr "" 734 735 #: includes/admin-ui.php 736 msgid "Bookminto service access" 737 msgstr "" 738 739 #: includes/admin-ui.php 740 msgid "This free WordPress plugin connects to the external Bookminto booking service. Account access, current prices, and beta terms are published on bookminto.com." 741 msgstr "" 742 743 #: includes/admin-ui.php 744 msgid "Create a Bookminto account" 745 msgstr "" 746 747 #: includes/admin-ui.php 748 msgid "View current plans and beta terms" 749 msgstr "" 735 #: includes/woocommerce.php 736 msgid "Your appointment is confirmed after the shop payment has been verified." 737 msgstr "" 738 -
bookminto/trunk/readme.txt
r3724276 r3730301 5 5 Tested up to: 7.1 6 6 Requires PHP: 7.4 7 Stable tag: 1.0. 07 Stable tag: 1.0.5 8 8 License: GPLv2 or later 9 9 License URI: https://www.gnu.org/licenses/gpl-2.0.html … … 32 32 * **Popup booking** — open the booking flow when visitors are ready to book. 33 33 34 Customize the appearance to better match your website and brand. 34 Customize the appearance to better match your website and brand. You can optionally add a small photo to each service in Bookminto. Services without a photo keep the compact text layout. 35 35 36 36 Use the Bookminto block in the WordPress editor, or place the booking experience manually with a shortcode. … … 96 96 97 97 `https://api.bookminto.com/v1` 98 99 Optional service photos load from Bookminto-managed Supabase Storage: 100 101 `https://zmzskjwuiryxrwlitkqn.supabase.co/storage/v1/object/public/service-images/` 102 103 Image requests share the visitor’s IP and browser information with storage, without booking details. See Bookminto’s Privacy Policy below. 98 104 99 105 The Bookminto service is used to: … … 168 174 = Where can developers find the source and build instructions? = 169 175 170 The compiled widget runtime is included locally as `assets/widget.js`. Readable source is included in `assets/src`. From the repository root, run `npm ci`, then `npm run sync:wordpress-plugin` and `npm run package:integrations` to reproduce the runtime and plugin package with Node.js 22 and the pinned Rolldown dependency. The runtime is updated only when the plugin is updated and cannot be replaced by an external Bookminto script.176 The plugin bundles its runtime in `assets/widget.js` and readable source in `assets/src`. To reproduce it, use Node.js 22 and the repository lockfile: run `npm ci`, `npm run sync:wordpress-plugin`, then `npm run package:integrations`. Updates arrive through plugin releases. 171 177 172 178 = Can I place the widget manually? = 173 179 174 Yes. Use the Bookminto block in the editor, or `[bookminto]` / `[booking_widget]` in a page or post. When a block or shortcode is present on a singular page, automatic rendering is skipped for that page.180 Use the Bookminto block or `[bookminto]` / `[booking_widget]`. Automatic rendering is skipped on singular pages containing a block or shortcode. 175 181 176 182 = Does the plugin charge visitors? = … … 210 216 == Changelog == 211 217 218 = 1.0.5 = 219 * Fix deposit payments in the embedded widget: the Stripe Payment Element now mounts correctly and the pay button waits for Stripe to be ready. 220 * Improve widget lifecycle cleanup, trigger selector validation, and compatibility on sites served over plain HTTP. 221 * Pass SMS and WhatsApp opt-in availability through to the embedded widget. 222 * Safer WooCommerce checkout URL handling and a multisite-safe uninstall. 223 224 = 1.0.4 = 225 * Preserve the active proposal identity through the shared deposit booking flow. 226 227 = 1.0.3 = 228 * Read published service prices in integer cents, with compatibility for older EUR prices. 229 * Reject unavailable prices instead of displaying a free appointment. 230 231 = 1.0.2 = 232 * Preserve booking retry identity, published revision and calendar verification. 233 * Keep isolated widget languages and support explicit booking-day hours. 234 235 = 1.0.1 = 236 * Optional service photos in the booking widget. Compact thumbnails appear only when a service has a photo. 237 * Failed images fall back to the existing text-only service selection. 238 212 239 = 1.0.0 = 213 240 -
bookminto/trunk/uninstall.php
r3723811 r3730301 10 10 } 11 11 12 delete_option('bookminto_options'); 13 delete_option('booking_widget_options'); 14 delete_option('bookminto_woocommerce_credentials'); 12 function bookminto_uninstall_site(): void { 13 delete_option('bookminto_options'); 14 delete_option('booking_widget_options'); 15 delete_option('bookminto_woocommerce_credentials'); 15 16 16 if (function_exists('as_unschedule_all_actions')) { 17 as_unschedule_all_actions('bookminto_woo_retry_order', null, 'bookminto'); 17 if (function_exists('as_unschedule_all_actions')) { 18 as_unschedule_all_actions('bookminto_woo_retry_order', null, 'bookminto'); 19 } 20 wp_unschedule_hook('bookminto_woo_retry_order'); 21 22 foreach (['_bookminto_service_id', '_bookminto_forced_sold_individually', '_bookminto_catalog_sync', '_bookminto_duration_minutes'] as $bookminto_meta_key) { 23 delete_post_meta_by_key($bookminto_meta_key); 24 } 18 25 } 19 wp_unschedule_hook('bookminto_woo_retry_order');20 26 21 foreach (['_bookminto_service_id', '_bookminto_forced_sold_individually', '_bookminto_catalog_sync', '_bookminto_duration_minutes'] as $bookminto_meta_key) { 22 delete_post_meta_by_key($bookminto_meta_key); 27 // A network-activated install stores options per blog — clean every site or 28 // the data is orphaned on all but the current one. 29 if (is_multisite()) { 30 $bookminto_site_ids = get_sites(array('fields' => 'ids', 'number' => 0)); 31 foreach ($bookminto_site_ids as $bookminto_site_id) { 32 switch_to_blog((int) $bookminto_site_id); 33 bookminto_uninstall_site(); 34 restore_current_blog(); 35 } 36 } else { 37 bookminto_uninstall_site(); 23 38 }
Note:
See TracChangeset
for help on using the changeset viewer.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)