Plugin Directory

Changeset 3730301


Ignore:
Timestamp:
10/06/2026 08:35:08 AM (3 weeks ago)
Author:
bookminto
Message:

Update to version wp-1.0.5 from GitHub

Location:
bookminto
Files:
12 added
48 edited
1 copied

Legend:

Unmodified
Added
Removed
  • bookminto/tags/wp-1.0.5/assets/src/api.js

    r3723811 r3730301  
    11import { apiUrl as defaultApiUrl } from './constants.js'
     2import { getWidgetCopy } from './copy.js'
    23import {
    34  addBookingToShopifyCart,
    … …  
    4950        commerceCheckout: commerce || undefined,
    5051        publicKey: config.workspace,
     52        proposalSlug: config.proposalSlug || undefined,
    5153        shopifyProductGid: shopify ? config.shopifyProduct || undefined : undefined,
    5254        shopifyShopDomain: shopify ? config.shopifyShop || undefined : undefined,
    … …  
    6567  if (!response.ok) {
    6668    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
    6872  }
    6973
    7074  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  }
    7178
    72   if (shopify && (result.needsCheckout || result.bookingId)) {
     79  if (shopify && result.needsCheckout === true) {
    7380    await addBookingToShopifyCart({
    7481      bookingId: result.bookingId,
    … …  
    8188  }
    8289
    83   if (woo && (result.needsCheckout || result.bookingId)) {
     90  if (woo && result.needsCheckout === true) {
    8491    await addBookingToWooCommerceCart({
    8592      bookingId: result.bookingId,
    … …  
    97104}
    98105
     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 */
     110export 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
    99154export {
    100155  addBookingToShopifyCart,
  • bookminto/tags/wp-1.0.5/assets/src/availability.js

    r3723811 r3730301  
    11import { slotDateFormatter } from './constants.js'
     2import { widgetLocale } from './copy.js'
    23import { text } from './formatters.js'
    34import {
    … …  
    154155}
    155156
    156 function getZonedDateFormatter(timeZone) {
    157   if (!timeZone) {
     157function getZonedDateFormatter(timeZone, language) {
     158  const locale = widgetLocale(language)
     159
     160  if (!timeZone && locale === 'en-US') {
    158161    return slotDateFormatter
    159162  }
    160163
    161164  try {
    162     return new Intl.DateTimeFormat('en-US', {
     165    return new Intl.DateTimeFormat(locale, {
    163166      timeZone,
    164167      day: 'numeric',
    … …  
    171174}
    172175
    173 function getBookedStartsForService(config, service) {
     176function getBookedRanges(config) {
    174177  if (!Array.isArray(config?.bookedSlots)) {
    175178    return []
    176179  }
    177180
    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.
    180185  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).
     198export 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.
     214export 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
     223export function createAvailabilitySlots(config, service, limit = 6, staffId) {
    195224  if (!service) {
    196225    return []
    … …  
    200229  const rules = flattenAvailabilityRules(availability)
    201230
    202   if (!rules.length) {
     231  if (!rules.length && !service.bookingDates?.some(day => day.startTime && day.endTime)) {
    203232    return []
    204233  }
    205234
    206235  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  )
    208241
    209242  return buildSlots({
    210243    availability,
    211     bookedStarts: getBookedStartsForService(config, service),
     244    bookedRanges: getBookedRanges(config),
    212245    exceptions: config.exceptions || [],
    213246    formatLabel: (startsAt) => {
    … …  
    221254    },
    222255    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,
    223261    timeZone,
    224     daysAhead: 28,
     262    daysAhead,
    225263    service: {
     264      bookingDates: Array.isArray(service.bookingDates) ? service.bookingDates : [],
    226265      bufferAfterMinutes: Number(service.bufferAfterMinutes) || 0,
    227266      bufferBeforeMinutes: Number(service.bufferBeforeMinutes) || 0,
    … …  
    229268      id: service.id,
    230269      name: service.name,
     270      staffIds: Array.isArray(service.staffIds) ? service.staffIds : service.staffIds === null ? null : undefined,
    231271    },
    232272  })
    233273}
    234274
    235 function getSlotDateKey(startsAt, timeZone) {
     275export function getSlotDateKey(startsAt, timeZone) {
    236276  const date = new Date(startsAt)
    237277
    … …  
    258298}
    259299
    260 export function createDatedAvailabilitySlots(config, service) {
    261   const slots = createAvailabilitySlots(config, service, 200)
     300export function createDatedAvailabilitySlots(config, service, staffId) {
     301  const slots = createAvailabilitySlots(config, service, 200, staffId)
    262302  const timeZone = config.timezone || undefined
    263303  const byDate = new Map()
  • bookminto/tags/wp-1.0.5/assets/src/bookingBar.js

    r3723811 r3730301  
    1 import { createAvailabilitySlots } from './availability.js'
     1import { servicePhotoMarkup, bindServicePhotoErrors } from './servicePhotos.js'
     2import { createAvailabilitySlots, serviceBlockedByStaff } from './availability.js'
    23import { submitBooking } from './api.js'
    34import { buildIcsDataUrl } from './calendar.js'
    … …  
    56import { confirmBookingPayment, getDepositPaymentSession, mountDepositPayment, showPaymentLoadError } from './payment.js'
    67import { getContrastText } from './theme.js'
    7 
    8 function panelSummary(service, slot) {
     8import { getWidgetCopy } from './copy.js'
     9import { DIAL_CODES, PHONE_COUNTRIES, normalizePhoneE164 } from './phoneNumber.js'
     10
     11function panelSummary(service, slot, copy, language) {
    912  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
    1215}
    1316
    14 function stepperMarkup(currentStep) {
     17function stepperMarkup(currentStep, copy) {
    1518  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]],
    2023  ]
    2124
    … …  
    2730      class="bw-booking-modal__stepper"
    2831      role="progressbar"
    29       aria-label="Booking progress"
     32      aria-label="${escapeHtml(copy.progressLabel)}"
    3033      aria-valuemin="1"
    3134      aria-valuemax="${steps.length}"
    3235      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)}"
    3437    >
    3538      <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>
    3740        <strong>${activeLabel}</strong>
    3841      </div>
    … …  
    4649}
    4750
    48 function serviceRowsMarkup(services, activeIndex) {
     51function serviceRowsMarkup(services, activeIndex, previewMode, copy, language) {
    4952  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>`
    5154  }
    5255
    … …  
    5659        <button type="button" class="bw-booking-modal__service" data-panel-service-index="${index}" data-active="${index === activeIndex}" aria-pressed="${index === activeIndex}">
    5760          <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>
    6066          </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>
    6268        </button>
    6369      `,
    … …  
    6672}
    6773
     74// crypto.randomUUID is secure-context only — plain-HTTP embeds (common for
     75// WordPress hosts) must fall back or the bar never mounts.
     76function 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
    6887export function createBookingBar(config, mountId, target = document.body) {
     88  const copy = getWidgetCopy(config.language)
     89  let clientRequestId = newClientRequestId()
     90  let terminalPaymentReplacementAvailable = false
     91  let answers = {}
    6992  const shell = document.createElement('div')
    7093  const spacer = document.createElement('div')
    … …  
    79102  let paymentController = null
    80103  let isSubmitting = false
    81   let form = { customerName: '', customerEmail: '', customerPhone: '', notes: '' }
     104  let form = { customerName: '', customerEmail: '', customerPhone: '', customerPhoneCountry: '', notes: '' }
    82105
    83106  shell.className = 'bw-booking-bar-shell'
    … …  
    149172  function timeMarkup(services, selectedService, slots) {
    150173    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>`
    152175    }
    153176
    154177    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}`
    159182    const slotMarkup = slots.length
    160183      ? slots
    … …  
    166189                  <em>${escapeHtml(slot.capacity)}</em>
    167190                </span>
    168                 <span>${index === selectedSlotIndex ? 'Selected' : 'Choose'}</span>
     191                <span>${escapeHtml(index === selectedSlotIndex ? copy.selected : copy.choose)}</span>
    169192              </button>
    170193            `,
    171194          )
    172195          .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>`
    174197
    175198    return `
    176199      <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)}">
    178201          <div class="bw-booking-modal__section-title">
    179             <strong>Services</strong>
     202            <strong>${escapeHtml(copy.servicesLabel)}</strong>
    180203            <em>${escapeHtml(serviceCountLabel)}</em>
    181204          </div>
    182205          <div class="bw-booking-modal__service-list">
    183             ${serviceRowsMarkup(services, selectedServiceIndex)}
     206            ${serviceRowsMarkup(services, selectedServiceIndex, config.previewMode, copy, config.language)}
    184207          </div>
    185208        </aside>
    186         <section class="bw-booking-modal__times" aria-label="Available times">
     209        <section class="bw-booking-modal__times" aria-label="${escapeHtml(copy.availableTimes)}">
    187210          <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>
    189212            <em>${escapeHtml(selectedService.name)}</em>
    190213          </div>
    … …  
    199222  function detailsMarkup(selectedService, selectedSlot) {
    200223    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
    203227
    204228    return `
    … …  
    207231          <div class="bw-booking-bar-panel__summary-card">
    208232            <strong>${escapeHtml(selectedService.name)}</strong>
    209             <span>${escapeHtml(panelSummary(selectedService, selectedSlot))}</span>
     233            <span>${escapeHtml(panelSummary(selectedService, selectedSlot, copy, config.language))}</span>
    210234          </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>` : ''}
    212237          <div class="bw-booking-bar-panel__form-row">
    213238            <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)}" />
    216241            </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            }
    217258            <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)}" />
    220261
    221262            </label>
    222263          </div>
    223264          <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)}" />
    226267          </label>
    227268          <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>
    230271          </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          }
    231302          ${message ? `<div class="bw-booking-bar-panel__message" role="status">${escapeHtml(message)}</div>` : ''}
    232303          <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>
    235306          </div>
    236307        </form>
    … …  
    241312  function paymentMarkup(selectedService, selectedSlot) {
    242313    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>
    248319      ${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>
    250321    </div>`
    251322  }
    … …  
    265336    const reference =
    266337      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(
    268339            String(confirmation.bookingId).slice(0, 8),
    269340          )}</code></span>`
    270341        : ''
    271342    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>`
    273344      : ''
    274345
    … …  
    277348        <div class="bw-booking-bar-panel__confirmed">
    278349          <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>
    280351          <span>${
    281352            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)
    284355          }</span>
    285356          ${reference}
    286357          ${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>
    288359        </div>
    289360      </div>
    … …  
    310381              <em>${escapeHtml(
    311382                bookingStep === 'details'
    312                   ? 'Enter your details'
     383                  ? copy.detailsHeading
    313384                  : bookingStep === 'confirmed'
    314                     ? 'Request received'
    315                     : 'Choose a time',
     385                    ? copy.successHeading
     386                    : copy.chooseTime,
    316387              )}</em>
    317388            </span>
    318389          </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>
    320391        </div>
    321         ${stepperMarkup(bookingStep)}
     392        ${stepperMarkup(bookingStep, copy)}
    322393        <div class="bw-booking-modal__body">${content}</div>
    323394        ${
    324395          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>`
    326397            : ''
    327398        }
    … …  
    330401            ? `<div class="bw-booking-modal__footer">
    331402                <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>
    334405                </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>
    336407              </div>`
    337408            : ''
    … …  
    346417    const services = config.services
    347418    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)
    349424    const selectedSlot = slots[selectedSlotIndex]
    350425    const canContinue = Boolean(selectedService && selectedSlot)
    351426    const invitation = config.loadError
    352       ? 'Booking setup unavailable'
     427      ? copy.loadError
    353428      : services.length > 1
    354       ? 'Choose a service and time'
     429      ? copy.selectServiceAndTime
    355430      : selectedService
    356         ? serviceSummary(selectedService)
    357         : 'No services available yet'
     431        ? serviceSummary(selectedService, config.language)
     432        : copy.emptyServices
    358433
    359434    shell.dataset.open = String(isOpen)
    … …  
    369444        </button>
    370445        <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)
    372447        }</button>
    373448      </div>
    … …  
    377452    panel.className = 'bw-booking-bar-panel bw-booking-bar-panel--booking'
    378453    panel.innerHTML = bookingMarkup(selectedService, selectedSlot, slots, canContinue)
     454    bindServicePhotoErrors(panel)
    379455
    380456    shell.querySelector('.bw-booking-bar__main')?.addEventListener('click', () => {
    … …  
    382458      bookingStep = 'time'
    383459      render()
     460      if (isOpen) panel.querySelector('.bw-booking-bar-panel__close')?.focus()
     461      else shell.querySelector('.bw-booking-bar__cta')?.focus()
    384462    })
    385463    shell.querySelector('.bw-booking-bar__cta')?.addEventListener('click', () => {
    … …  
    387465      bookingStep = 'time'
    388466      render()
     467      if (isOpen) panel.querySelector('.bw-booking-bar-panel__close')?.focus()
     468      else shell.querySelector('.bw-booking-bar__cta')?.focus()
    389469    })
    390470    panel.querySelector('.bw-booking-bar-panel__close')?.addEventListener('click', () => {
    … …  
    392472      bookingStep = 'time'
    393473      render()
     474      shell.querySelector('.bw-booking-bar__cta')?.focus()
    394475    })
    395476    panel.querySelectorAll('[data-panel-service-index]').forEach((button) => {
    … …  
    397478      button.addEventListener('click', () => {
    398479        selectedServiceIndex = Number(button.getAttribute('data-panel-service-index')) || 0
     480        clientRequestId = newClientRequestId()
     481        terminalPaymentReplacementAvailable = false
     482        answers = {}
    399483        selectedSlotIndex = 0
    400484        message = ''
    … …  
    407491      button.addEventListener('click', () => {
    408492        selectedSlotIndex = Number(button.getAttribute('data-slot-index')) || 0
     493        clientRequestId = newClientRequestId()
     494        terminalPaymentReplacementAvailable = false
    409495        message = ''
    410496        render()
    … …  
    423509    })
    424510    panel.querySelector('[data-bar-done]')?.addEventListener('click', () => {
     511      clientRequestId = newClientRequestId()
     512      terminalPaymentReplacementAvailable = false
     513      answers = {}
    425514      isOpen = false
    426515      bookingStep = 'time'
    … …  
    431520      const button = panel.querySelector('[data-pay-deposit]')
    432521      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) => {
    434523        if (!host.isConnected || paymentSession !== session) { controller.destroy(); return }
    435524        paymentController = controller
    … …  
    437526        panel.querySelector('[data-payment-status]').textContent = ''
    438527      }).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) }
    440529      })
    441530      button.addEventListener('click', async () => {
    … …  
    445534        const controller = paymentController
    446535        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)
    449542          confirmation = { bookingId: session.bookingId, serviceName: selectedService?.name, durationMinutes: selectedService?.durationMinutes, startsAt: selectedSlot?.startsAt }
    450543          paymentSession = null
    … …  
    452545          message = ''
    453546        } 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
    455549        } finally {
    456550          isSubmitting = false
    457           render()
     551          if (paymentSession === session) button.disabled = false
     552          else render()
    458553        }
    459554      })
    … …  
    462557      event.preventDefault()
    463558      if (isSubmitting) return
     559      if (Number(selectedService?.depositCents) > 0 && config.features?.deposits !== true) return
    464560      isSubmitting = true
    465561      const formData = new FormData(event.currentTarget)
    466562      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
    469576      render()
    470577      const bookedService = selectedService
    … …  
    472579
    473580      submitBooking(config, {
     581        answers,
     582        clientRequestId,
     583        replaceTerminalPayment: terminalPaymentReplacementAvailable || undefined,
     584        publicationRevision: config.publicationRevision,
     585        language: config.language || 'en',
    474586        customerEmail: formData.get('customerEmail'),
    475587        customerName: formData.get('customerName'),
    476588        customerPhone: formData.get('customerPhone'),
     589        customerPhoneCountry: form.customerPhoneCountry || undefined,
    477590        notes: formData.get('notes'),
    478591        serviceId: selectedService?.id || selectedService?.name,
    479592        serviceName: selectedService?.name,
     593        smsOptIn: config.sms?.available === true ? smsOptIn || undefined : undefined,
    480594        startsAt: selectedSlot?.startsAt,
     595        whatsappOptIn: config.whatsapp?.available === true ? whatsappOptIn || undefined : undefined,
    481596      })
    482597        .then((result) => {
    … …  
    484599            return
    485600          }
    486           const depositSession = getDepositPaymentSession(result)
     601          const depositSession = getDepositPaymentSession(result, config.language)
    487602          if (depositSession) {
     603            terminalPaymentReplacementAvailable = false
    488604            paymentSession = depositSession
    489605            message = ''
    … …  
    495611            durationMinutes: bookedService?.durationMinutes,
    496612            localOnly: result?.localOnly === true,
    497             serviceName: bookedService?.name || 'Appointment',
     613            serviceName: bookedService?.name || copy.appointment,
    498614            startsAt: bookedSlot?.startsAt,
    499615          }
    … …  
    503619        })
    504620        .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
    506623          bookingStep = 'details'
    507624          render()
  • bookminto/tags/wp-1.0.5/assets/src/bookingBarModalStyles.js

    r3723811 r3730301  
    194194}
    195195
     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
    196209.bw-booking-modal__service-body strong {
    197210  overflow: hidden;
  • bookminto/tags/wp-1.0.5/assets/src/bookingBarStyles.js

    r3723811 r3730301  
    224224
    225225@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
    226232  .bw-booking-bar-spacer {
    227233    height: 0;
  • bookminto/tags/wp-1.0.5/assets/src/bookingModal.js

    r3723811 r3730301  
    1 import { widgetCopy as copy } from './copy.js'
     1import { getWidgetCopy } from './copy.js'
    22import { createModalPanel } from './modalRender.js'
    33
    … …  
    1414 */
    1515export function createBookingModal(config, mountId, root, options = {}) {
     16  const copy = getWidgetCopy(config.language)
    1617  const triggerSelector =
    1718    typeof config.trigger === 'string'
    … …  
    2021        ? config.triggerSelector.trim()
    2122        : ''
     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  }
    2234  const buttonAlign = normalizeButtonAlign(config.buttonAlign || config.buttonPosition)
    2335  const openByDefault = Boolean(options.openByDefault)
    … …  
    6880  }
    6981
    70   if (!triggerSelector) {
     82  if (!safeTriggerSelector && !options.suppressCta) {
    7183    ctaWrap = document.createElement('div')
    7284    cta = document.createElement('button')
    … …  
    102114  function onExternalClick(event) {
    103115    const target = event.target
    104     if (!triggerSelector || !target || typeof target.closest !== 'function') {
     116    if (!safeTriggerSelector || !target || typeof target.closest !== 'function') {
    105117      return
    106118    }
    107119
    108     if (target.closest(triggerSelector)) {
     120    if (target.closest(safeTriggerSelector)) {
    109121      event.preventDefault()
    110122      setOpen(true)
    … …  
    114126  if (typeof document.addEventListener === 'function') {
    115127    document.addEventListener('keydown', onKeyDown)
    116     if (triggerSelector) {
     128    if (safeTriggerSelector) {
    117129      document.addEventListener('click', onExternalClick, true)
    118130    }
    … …  
    127139      if (typeof document.removeEventListener === 'function') {
    128140        document.removeEventListener('keydown', onKeyDown)
    129         if (triggerSelector) {
     141        if (safeTriggerSelector) {
    130142          document.removeEventListener('click', onExternalClick, true)
    131143        }
    … …  
    150162      }
    151163    },
     164    panel,
    152165    setOpen,
    153166  }
  • bookminto/tags/wp-1.0.5/assets/src/commerce.js

    r3723811 r3730301  
    174174  }
    175175
    176   const checkoutUrl = result?.data?.checkoutUrl || '/checkout/'
     176  const checkoutUrl = safeNavigationUrl(result?.data?.checkoutUrl) || '/checkout/'
    177177  window.location.assign(checkoutUrl)
    178178  return { redirected: true }
    179179}
     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.
     184function 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  
    22import { readAvailability, readExceptions } from './availability.js'
    33import { normalizeColor, normalizeTimeFormat, text } from './formatters.js'
     4import { getWidgetCopy } from './copy.js'
    45
    56const localApiUrl = 'http://127.0.0.1:5174/v1'
    … …  
    5758      }
    5859
    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      }
    6067    })
    6168    .filter(Boolean)
    … …  
    6471function checkoutServices(services, commerce) {
    6572  return commerce === 'shopify' || commerce === 'woocommerce' ? services.map((service) => ({ ...service, depositCents: 0, depositEuros: 0 })) : services
     73}
     74
     75export 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.
     83function 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.
     97function 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
     127function 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).
     151function 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)
    66184}
    67185
    … …  
    72190    apiUrl: runtimeApiUrl(config.apiUrl),
    73191    bookedSlots: Array.isArray(config.bookedSlots) ? config.bookedSlots : [],
     192    bookingHorizonDays: Number(config.bookingHorizonDays) || 0,
    74193    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,
    75202    commerce,
    76203    services: checkoutServices(config.services, commerce),
    … …  
    78205    position: text(config.position, 'bottom-right'),
    79206    showBranding: optionalBoolean(config.showBranding, false),
    80     title: text(config.title, 'Schedule a booking'),
     207    title: text(config.title, getWidgetCopy(config.language).title),
    81208    timeFormat: normalizeTimeFormat(config.timeFormat),
    82209    timezone: text(config.timezone, ''),
    … …  
    90217}
    91218
     219export 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
    92234export function readServices(value) {
    93235  if (!value) {
    … …  
    105247      .map((service) => ({
    106248        description: text(service.description, ''),
     249        imageUrl: safeServiceImageUrl(service.imageUrl),
    107250        durationMinutes: Number(service.durationMinutes) || 30,
    108251        id: text(service.id, ''),
    109252        name: text(service.name, ''),
    110         price: Number(service.price) || 0,
     253        price: readServicePrice(service),
    111254        depositCents: Math.max(0, Number(service.depositCents ?? Number(service.depositEuros || 0) * 100) || 0),
    112255        bufferAfterMinutes: Number(service.bufferAfterMinutes) || 0,
    … …  
    114257        useCustomAvailability: Boolean(service.useCustomAvailability),
    115258        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),
    116272      }))
    117273      .filter((service) => service.name)
    … …  
    146302    commerce: text(config.commerce, ''),
    147303    exceptions: readExceptions(config.exceptions),
     304    // Extension A: `data-next-times` renders the compact upcoming-times strip.
     305    nextTimes: optionalBoolean(config.nextTimes, false),
    148306    position: text(config.position, ''),
     307    proposalSlug: text(config.proposalSlug, ''),
    149308    service: serviceFilter,
    150309    services: filterServicesById(
    … …  
    152311      serviceFilter,
    153312    ),
     313    staff: readStaffList(config.staff),
     314    staffAvailability: readStaffAvailability(config.staffAvailability),
     315    staffDisplayMode:
     316      text(config.staffDisplayMode, '') === 'customer_choice' ? 'customer_choice' : 'hidden',
    154317    shopifyProduct: text(config.shopifyProduct, ''),
    155318    shopifyShop: text(
    … …  
    164327    wooCartNonce: text(config.wooCartNonce || config.wooCartnonce, ''),
    165328    showBranding: optionalBoolean(config.showBranding, false),
     329    hostedPage: optionalBoolean(config.hostedPage, false),
    166330    title: text(config.title, ''),
    167331    timeFormat: text(config.timeFormat, ''),
    … …  
    204368              bufferBeforeMinutes: Number(service.bufferBeforeMinutes) || 0,
    205369              description: text(service.description, ''),
     370              imageUrl: safeServiceImageUrl(service.imageUrl),
    206371              durationMinutes: Number(service.durationMinutes) || 30,
    207372              id: text(service.id, ''),
    208373              name: text(service.name, ''),
    209               price: Number(service.price) || 0,
     374              price: readServicePrice(service),
    210375              depositCents: Math.max(0, Number(service.depositCents ?? Number(service.depositEuros || 0) * 100) || 0),
    211376              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),
    212393            }))
    213394            .filter((service) => service.name)
    … …  
    222403
    223404      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.
    226408      const variant =
    227         commerce === 'shopify' || commerce === 'woocommerce'
     409        commerce === 'shopify' || commerce === 'woocommerce' || config.hostedPage === true
    228410          ? 'inline'
    229411          : text(remoteConfig.variant || config.variant, 'inline')
     412      const language = normalizeLanguage(remoteConfig.language || config.language)
    230413
    231414      return {
    … …  
    233416        apiUrl: runtimeApiUrl(config.apiUrl),
    234417        bookedSlots: readBookedSlots(remoteConfig.bookedSlots),
     418        calendarVerification: text(remoteConfig.calendarVerification, 'none'),
     419        bookingHorizonDays: Number(remoteConfig.bookingHorizonDays) || 0,
    235420        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,
    236431        commerce,
    237432        exceptions: remoteExceptions,
    238433        position: text(remoteConfig.position || config.position, 'bottom-right'),
     434        proposalSlug: text(config.proposalSlug, ''),
    239435        service: text(config.service, ''),
    240436        services: checkoutServices(services, commerce),
    … …  
    247443        // Public credits are controlled locally. Remote SaaS configuration cannot enable them.
    248444        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),
    250453        timeFormat: normalizeTimeFormat(remoteConfig.timeFormat || config.timeFormat),
    251454        timezone: text(remoteConfig.timezone || config.timezone, ''),
    … …  
    259462        ),
    260463        variant,
     464        whatsapp: readChannelOptin(remoteConfig.whatsapp),
    261465        workspace: config.workspace,
    262466      }
    … …  
    271475
    272476export { 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.
     481function 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
     497export 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  
    11export const DATE_WINDOW_SIZE = 5
    22
    3 export const widgetCopy = {
     3const englishCopy = {
    44  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',
    524  widgetStepLabels: ['Service', 'Date', 'Details', 'Confirmed'],
    625  chooseService: 'Choose an offering',
    … …  
    2746  checkoutCta: 'Continue to checkout',
    2847  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.',
    2950  payDepositCta: 'Pay deposit',
    3051  depositHeading: 'Pay deposit to confirm',
     52  depositLabel: 'deposit',
    3153  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.',
    3255  processing: 'Processing...',
    3356  redirectingToCheckout: 'Redirecting to checkout…',
    … …  
    4265  loading: 'Loading…',
    4366  genericError: 'Something went wrong. Please try again.',
     67  closedAttempt: 'This booking attempt is closed. Choose a new time to start again.',
    4468  loadError: 'Booking setup could not be loaded.',
    4569  previewOnlyNote: 'Preview only — no booking was saved and no email was sent.',
    … …  
    4771  testModeNote:
    4872    '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
     106const 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
     208const 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
     310const 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
     413const 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
     516const 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
     619const dictionaries = {
     620  de: germanCopy,
     621  en: englishCopy,
     622  es: spanishCopy,
     623  fr: frenchCopy,
     624  it: italianCopy,
     625  nl: dutchCopy,
     626}
     627
     628export 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.
     635export function getWidgetCopy(value) {
     636  return dictionaries[normalizeWidgetLanguage(value)]
     637}
     638
     639const 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
     648export function widgetLocale(value) {
     649  return widgetLocales[normalizeWidgetLanguage(value)] || 'en-US'
     650}
  • bookminto/tags/wp-1.0.5/assets/src/floatingStyles.js

    r3723811 r3730301  
    299299}
    300300
     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
    301310.service-info {
     311  min-width: 0;
    302312  flex: 1;
    303313  text-align: left;
    … …  
    454464}
    455465
     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
    456533.form-field {
    457534  margin-bottom: 0;
    … …  
    489566  min-height: 74px;
    490567  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;
    491585}
    492586
  • bookminto/tags/wp-1.0.5/assets/src/formatters.js

    r3723811 r3730301  
    1414}
    1515
    16 export function formatDuration(minutes) {
     16const durationHourUnits = {
     17  de: 'Std.',
     18  en: 'hr',
     19  es: 'h',
     20  fr: 'h',
     21  it: 'h',
     22  nl: 'u',
     23}
     24
     25const freePriceLabels = {
     26  de: 'Kostenlos',
     27  en: 'Free',
     28  es: 'Gratis',
     29  fr: 'Gratuit',
     30  it: 'Gratuito',
     31  nl: 'Gratis',
     32}
     33
     34const 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
     43const 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
     52const 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
     61export function formatDuration(minutes, language = 'en') {
    1762  const safeMinutes = Number.isFinite(Number(minutes)) ? Number(minutes) : 30
    1863
    … …  
    2469  const remainder = safeMinutes % 60
    2570
    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`
    2773}
    2874
    29 export function formatPrice(price) {
     75export function formatPrice(price, currency = 'EUR', language = 'en') {
    3076  const safePrice = Number(price)
    3177
    3278  if (!Number.isFinite(safePrice) || safePrice === 0) {
    33     return 'Free'
     79    return freePriceLabels[language] || 'Free'
    3480  }
    3581
    36   return new Intl.NumberFormat('en-US', {
    37     currency: 'EUR',
     82  return new Intl.NumberFormat(priceLocales[language] || 'en-US', {
     83    currency,
    3884    maximumFractionDigits: 2,
    3985    minimumFractionDigits: Number.isInteger(safePrice) ? 0 : 2,
    … …  
    4288}
    4389
    44 export function serviceDetail(service) {
     90export function serviceDetail(service, language = 'en') {
    4591  const description = text(service && service.description, '')
    4692
    4793  return description
    48     ? `${formatDuration(service.durationMinutes)} · ${description}`
    49     : formatDuration(service.durationMinutes)
     94    ? `${formatDuration(service.durationMinutes, language)} · ${description}`
     95    : formatDuration(service.durationMinutes, language)
    5096}
    5197
    … …  
    59105}
    60106
    61 export function serviceSummary(service) {
     107export function serviceSummary(service, language = 'en') {
    62108  if (!service) {
    63109    return 'No services available yet'
    64110  }
    65111
    66   return `${formatDuration(service.durationMinutes)} · ${servicePriceSummary(service)}`
     112  return `${formatDuration(service.durationMinutes, language)} · ${servicePriceSummary(service, language)}`
    67113}
    68114
    69 export function servicePriceSummary(service) {
    70   const price = formatPrice(service?.price)
     115export function servicePriceSummary(service, language = 'en') {
     116  const price = formatPrice(service?.price, service?.currency || 'EUR', language)
    71117  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
    73120}
    74121
    75122export 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]
    77125}
  • bookminto/tags/wp-1.0.5/assets/src/modalRender.js

    r3723811 r3730301  
    1 import { createDatedAvailabilitySlots } from './availability.js'
    2 import { submitBooking } from './api.js'
     1import { servicePhotoMarkup, bindServicePhotoErrors } from './servicePhotos.js'
     2import { createDatedAvailabilitySlots, getSlotDateKey, qualifiedStaffForService, serviceBlockedByStaff } from './availability.js'
     3import { submitBooking, submitWaitlistEntry } from './api.js'
    34import { buildIcsDataUrl } from './calendar.js'
    4 import { DATE_WINDOW_SIZE, widgetCopy as copy } from './copy.js'
    5 import { escapeHtml, formatDuration, formatPrice, servicePriceSummary, timezoneLabel } from './formatters.js'
     5import { DATE_WINDOW_SIZE, getWidgetCopy, widgetLocale } from './copy.js'
     6import { escapeHtml, formatDuration, formatPrice, servicePriceSummary, text, timezoneLabel } from './formatters.js'
    67import { confirmBookingPayment, getDepositPaymentSession, mountDepositPayment, showPaymentLoadError } from './payment.js'
    78import { scheduleRevealContinue } from './scroll.js'
    … …  
    1920  '<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>'
    2021
    21 const modalStepLabels = ['Service', 'Date & time', 'Details', 'Confirmed']
    22 
    23 function formatDateBadge(dateKey) {
     22
     23function formatDateBadge(dateKey, locale) {
    2424  const value = new Date(`${dateKey}T12:00:00`)
    2525  return {
    2626    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('.', ''),
    2929  }
    3030}
    3131
    32 function formatSummaryDate(dateKey) {
     32function staffFirstName(name) {
     33  return text(name, '').split(/\s+/).filter(Boolean)[0] || ''
     34}
     35
     36function 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
     46function staffChipColor(value) {
     47  const color = text(value, '')
     48  return /^#[0-9a-fA-F]{6}$/.test(color) ? color : ''
     49}
     50
     51function 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
     59function formatSummaryDate(dateKey, locale) {
    3360  if (!dateKey) return ''
    34   return new Intl.DateTimeFormat('en-US', {
     61  return new Intl.DateTimeFormat(locale || 'en-US', {
    3562    weekday: 'short',
    3663    day: 'numeric',
    … …  
    3966}
    4067
    41 function formatVisibleDateRange(dates) {
     68function formatVisibleDateRange(dates, locale) {
    4269  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' })
    4471  return `${formatter.format(new Date(`${dates[0]}T12:00:00`))} - ${formatter.format(
    4572    new Date(`${dates[dates.length - 1]}T12:00:00`),
    … …  
    4774}
    4875
    49 function stepperMarkup(step) {
     76function stepperMarkup(step, copy) {
     77  const modalStepLabels = copy.widgetStepLabels
    5078  const currentStep = Math.min(step + 1, modalStepLabels.length)
    5179  const activeLabel = modalStepLabels[currentStep - 1]
    … …  
    5583      class="stepper"
    5684      role="progressbar"
    57       aria-label="Booking progress"
     85      aria-label="${escapeHtml(copy.progressLabel)}"
    5886      aria-valuemin="1"
    5987      aria-valuemax="${modalStepLabels.length}"
    6088      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)}"
    6290    >
    6391      <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>
    6593        <strong>${escapeHtml(activeLabel)}</strong>
    6694      </div>
    … …  
    78106 */
    79107export function createModalPanel(config) {
     108  const copy = getWidgetCopy(config.language)
     109  const locale = widgetLocale(config.language)
    80110  // <dialog> + open attribute often fails to paint inside preview shadow roots.
    81111  // Use a plain overlay div in preview so Appearance always shows the modal.
    … …  
    93123  let selectedDate = null
    94124  let selectedSlotId = null
     125  let selectedStaffId = null
    95126  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    : ''
    97130  let isSubmitting = false
    98131  let confirmation = null
    99132  let paymentSession = null
    100133  let paymentController = null
     134  let clientRequestId = newClientRequestId()
     135  let terminalPaymentReplacementAvailable = false
    101136  let form = {
     137    answers: {},
    102138    customerEmail: '',
    103139    customerName: '',
    104140    customerPhone: '',
    105141    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)}`
    106160  }
    107161
    … …  
    114168  }
    115169
     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
    116200  function datedSlots() {
    117     return createDatedAvailabilitySlots(config, selectedService())
     201    return createDatedAvailabilitySlots(config, selectedService(), pinnedStaffId())
    118202  }
    119203
    … …  
    130214    if (step === 0) return Boolean(selectedService())
    131215    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    }
    133225    return false
    134226  }
    … …  
    138230    if (step === 0) return copy.continueFromService
    139231    if (paymentSession) return copy.payDepositCta
    140     if (step === 2) return copy.confirmCta
     232    if (step === 2) return terminalPaymentReplacementAvailable ? copy.replacePaymentCta : copy.confirmCta
    141233    return copy.continueDefault
    142234  }
    143235
    144236  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(
    146238      (Number(cents) || 0) / 100,
    147239    )
    … …  
    152244  }
    153245
     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
    154305  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    }
    155312    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
    160330    }
    161331  }
    … …  
    165335    selectedDate = null
    166336    selectedSlotId = null
     337    selectedStaffId = null
    167338    datePageStart = 0
    168339    message = ''
    … …  
    174345      paymentController = null
    175346    }
    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 }
    177354    render()
    178355  }
    … …  
    207384            return `
    208385              <button type="button" class="svc${selected}" data-service-id="${escapeHtml(id)}">
     386                ${servicePhotoMarkup(item.imageUrl, config.previewMode)}
    209387                <div class="svc-info">
    210388                  <div class="svc-name">${escapeHtml(item.name)}</div><div class="service-price">${escapeHtml(servicePriceSummary(item))}</div>
    … …  
    235413        selectedDate && slot
    236414          ? `<div class="sumbox sumbox--compact">
    237               <div class="sumrow">${escapeHtml(formatSummaryDate(selectedDate))}</div>
     415              <div class="sumrow">${escapeHtml(formatSummaryDate(selectedDate, locale))}</div>
    238416              <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              }
    239422            </div>`
    240423          : ''
    241424      }
     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>
    242508    `
    243509  }
    … …  
    250516          <div class="modal-intro-copy">${escapeHtml(copy.depositNote)}</div>
    251517          <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>
    253519          </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>
    255521          ${message ? `<div class="runtime-state runtime-state--danger">${escapeHtml(message)}</div>` : ''}
    256522        `,
    … …  
    271537
    272538    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
    273549      const dates = visibleDates(availableDates)
    274550      const daySlots = selectedDate ? slotsByDate.get(selectedDate) || [] : []
    … …  
    278554        `
    279555          <button class="back-btn" type="button" data-action="back">← ${escapeHtml(copy.back)}</button>
     556          ${staffPickerMarkup()}
    280557          <span class="slabel">${escapeHtml(copy.chooseDate)}</span><p class="booking-timezone">${escapeHtml(timezoneLabel(config))}</p>
    281558          <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>
    283560            <div class="date-nav-buttons">
    284561              <button type="button" class="date-nav-btn" data-action="prev-dates" ${canPrev ? '' : 'disabled'}>${chevronLeftIcon}</button>
    … …  
    291568                ? dates
    292569                    .map((dateKey) => {
    293                       const badge = formatDateBadge(dateKey)
     570                      const badge = formatDateBadge(dateKey, locale)
    294571                      const selected = dateKey === selectedDate ? ' sel' : ''
    295572                      return `
    … …  
    315592                          .map((item) => {
    316593                            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>`
    318596                          })
    319597                          .join('')}
    … …  
    324602              : ''
    325603          }
     604          ${!dates.length || !daySlots.length ? waitlistMarkup() : ''}
     605          ${message ? `<div class="runtime-state runtime-state--danger">${escapeHtml(message)}</div>` : ''}
    326606        `,
    327607        continueButton(slotsByDate),
    … …  
    334614          <button class="back-btn" type="button" data-action="back">← ${escapeHtml(copy.back)}</button>
    335615          <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>` : ''}
    336617          <div class="form-field">
    337618            <label class="flabel" for="bm-modal-name">${escapeHtml(copy.nameLabel)}</label>
    … …  
    352633            <textarea class="finput finput--textarea" id="bm-modal-notes" name="notes" placeholder="${escapeHtml(copy.notesPlaceholder)}">${escapeHtml(form.notes)}</textarea>
    353634          </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          }
    354644          ${message ? `<div class="runtime-state runtime-state--danger">${escapeHtml(message)}</div>` : ''}
    355645        `,
    … …  
    429719        <div class="topbar">
    430720          <span class="topbar-title">${escapeHtml(config.title || copy.title)}</span>
    431           ${stepperMarkup(activeStep)}
     721          ${stepperMarkup(activeStep, copy)}
    432722          <button class="close-btn" type="button" data-action="close" aria-label="${escapeHtml(copy.close)}">${closeIcon}</button>
    433723        </div>
    … …  
    458748          accent: config.color,
    459749          clientSecret: paymentSession.clientSecret,
     750          language: config.language,
    460751          publishableKey: paymentSession.publishableKey,
    461752        })
    … …  
    466757          })
    467758          .catch((error) => {
    468             if (host.isConnected && paymentSession === session) showPaymentLoadError(host, error, render)
     759            if (host.isConnected && paymentSession === session) showPaymentLoadError(host, error, render, config.language)
    469760          })
    470761      }
    … …  
    473764
    474765  function bindEvents(availableDates, slotsByDate) {
     766    bindServicePhotoErrors(dialog)
    475767    dialog.querySelectorAll('[data-service-id]').forEach((button) => {
    476768      button.addEventListener('click', () => {
    … …  
    478770        selectedDate = null
    479771        selectedSlotId = null
     772        selectedStaffId = null
    480773        datePageStart = 0
    481774        message = ''
    482775        render()
    483776      })
     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()
    484818    })
    485819
    … …  
    546880    })
    547881
     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
    548936    dialog.querySelector('[data-action="continue"]')?.addEventListener('click', () => {
    549937      syncFormFromDom()
    … …  
    583971    if (cta) { cta.disabled = true; cta.textContent = copy.processing }
    584972    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, {
    587977        bookingId: session.bookingId,
    588978        paymentIntentId,
    589979      })
     980      if (paymentSession !== session || paymentController !== controller) return
     981      if (result.processing || !(result.ok || result.alreadyConfirmed)) throw new Error(copy.paymentError)
    590982      confirmation = { bookingId: session.bookingId, id: session.bookingId }
    591983      paymentSession = null
    592984      step = 3
    593985    } catch (error) {
     986      if (paymentSession !== session || paymentController !== controller) return
    594987      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
    595996    } 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() }
    5981001    }
    5991002  }
    … …  
    6021005    const service = selectedService()
    6031006    const slot = selectedSlot(slotsByDate)
    604     if (!service || !slot) return
     1007    if (!service || !slot || isSubmitting) return
     1008    if (!canContinue(slotsByDate)) return
    6051009
    6061010    isSubmitting = true
    … …  
    6101014    try {
    6111015      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,
    6121020        customerEmail: form.customerEmail.trim(),
    6131021        customerName: form.customerName.trim(),
    6141022        customerPhone: form.customerPhone.trim(),
     1023        language: config.language || 'en',
    6151024        notes: form.notes.trim(),
     1025        rebookingOptIn: form.rebookingOptIn || undefined,
    6161026        serviceId: service.id || service.name,
    6171027        serviceName: service.name,
     1028        staffId: pinnedStaffId(),
    6181029        startsAt: slot.startsAt,
    6191030      })
    … …  
    6231034      }
    6241035
    625       const depositSession = getDepositPaymentSession(result)
     1036      const depositSession = getDepositPaymentSession(result, config.language)
    6261037      if (depositSession) {
     1038        terminalPaymentReplacementAvailable = false
    6271039        paymentSession = depositSession
    6281040      } else {
    … …  
    6311043      }
    6321044    } 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      }
    6341055    } finally {
    6351056      isSubmitting = false
    … …  
    6441065    dialog.dispatchEvent(new CustomEvent('booking-widget-close', { bubbles: true }))
    6451066  })
     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  }
    6461101
    6471102  dialog.resetFlow = resetFlow
  • bookminto/tags/wp-1.0.5/assets/src/mount.js

    r3723811 r3730301  
    22import { createBookingBar } from './bookingBar.js'
    33import { createBookingModal } from './bookingModal.js'
    4 import { widgetCopy as copy } from './copy.js'
     4import { getWidgetCopy } from './copy.js'
    55import { ensureFloatingPopoverChrome } from './floatingPopoverStyles.js'
    66import { elevateBarHost, elevateOverlayHost, ensureHostOnTop, ensureOverlayHostStyles } from './overlayHost.js'
    77import { escapeHtml as escapeLauncherLabel } from './formatters.js'
     8import { createNextTimes } from './nextTimes.js'
    89import { createWidget } from './render.js'
    910import { injectStyles } from './styles.js'
    … …  
    8788
    8889export function renderMountedWidget(element, config) {
     90  const copy = getWidgetCopy(config.language)
    8991  const mountId =
    9092    element.dataset.bookingWidgetMountId ||
    … …  
    9294  element.dataset.bookingWidgetMountId = mountId
    9395
    94   findWidgetOwners(mountId).forEach((node) => node.remove())
     96  findWidgetOwners(mountId).forEach((node) => {
     97    node.__bookingWidgetDestroy?.()
     98    node.remove()
     99  })
    95100
    96101  element
    97102    .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    })
    99107
    100108  // Product-page commerce must never inherit published modal/floating chrome.
    101109  if (config.commerce === 'shopify' || config.commerce === 'woocommerce') {
    102110    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    }
    103157  }
    104158
    … …  
    116170    let open = false
    117171    let closeTimer = 0
     172    let released = false
    118173
    119174    ensureFloatingPopoverChrome()
    … …  
    144199
    145200    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      }
    146206      open = isOpen
    147207      setClassToken(launcher, 'expanded', isOpen)
    … …  
    213273
    214274    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      }
    215284      if (event.key === 'Escape' && open) {
    216285        setOpen(false)
    … …  
    220289    if (typeof document.addEventListener === 'function') {
    221290      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      }
    222301    }
    223302
    … …  
    287366    }
    288367
    289     createBookingModal(config, mountId, root)
     368    const modal = createBookingModal(config, mountId, root)
     369    host.__bookingWidgetDestroy = modal.destroy
    290370    return host
    291371  }
    … …  
    312392  if (config.workspace) {
    313393    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
    314401      if (shouldSkipAutoInlineWidget(element, remoteConfig)) {
    315402        return
    … …  
    366453    .forEach((element) => {
    367454      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'
    368461        const placeholder = document.createElement('div')
    369462        placeholder.setAttribute('data-booking-widget', '')
  • bookminto/tags/wp-1.0.5/assets/src/payment.js

    r3723811 r3730301  
    1 export function getDepositPaymentSession(result) {
     1import { getWidgetCopy } from './copy.js'
     2
     3export function getDepositPaymentSession(result, language) {
     4  const copy = getWidgetCopy(language)
    25  const payment = result?.payment || result
    36  if (!result?.needsPayment && !result?.payment) return null
    47  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)
    69  }
    710  return {
    … …  
    1316}
    1417
    15 export function showPaymentLoadError(host, error, onRetry) {
     18export function showPaymentLoadError(host, error, onRetry, language) {
     19  const copy = getWidgetCopy(language)
    1620  const message = document.createElement('p')
    1721  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
    1923  const retry = document.createElement('button')
    2024  retry.type = 'button'
    2125  retry.className = 'back-btn'
    22   retry.textContent = 'Try again'
     26  retry.textContent = copy.retryCta
    2327  retry.addEventListener('click', onRetry)
    2428  host.replaceChildren(message, retry)
    … …  
    5357
    5458export async function mountDepositPayment(host, input) {
     59  const copy = getWidgetCopy(input.language)
    5560  const StripeFactory = await loadStripeJs()
    5661  const stripe = StripeFactory(input.publishableKey)
    … …  
    6570    },
    6671    clientSecret: input.clientSecret,
     72    locale: input.language || undefined,
    6773  })
    6874  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  }
    70151
    71152  return {
    … …  
    80161
    81162      if (result.error) {
    82         throw new Error(result.error.message || 'Payment failed')
     163        throw new Error(result.error.message || copy.paymentError)
    83164      }
    84165
    85166      const intent = result.paymentIntent
    86167      if (!intent || intent.status !== 'succeeded') {
    87         throw new Error('Payment is not completed yet')
     168        throw new Error(copy.paymentError)
    88169      }
    89170
    … …  
    96177        // ignore
    97178      }
     179      cleanupSlotted()
    98180    },
    99181  }
    … …  
    113195  if (!response.ok) {
    114196    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)
    116198  }
    117199
  • bookminto/tags/wp-1.0.5/assets/src/previewMount.js

    r3723811 r3730301  
    11import { createBookingBar } from './bookingBar.js'
    22import { createBookingModal } from './bookingModal.js'
    3 import { widgetCopy as copy } from './copy.js'
     3import { getWidgetCopy } from './copy.js'
    44import { createWidget } from './render.js'
    55import { injectStyles } from './styles.js'
    … …  
    309309 */
    310310export function mountPreviewWidget(container, config, options = {}) {
     311  const copy = getWidgetCopy(config.language)
    311312  if (!container) {
    312313    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'
     1import { servicePhotoMarkup, bindServicePhotoErrors } from './servicePhotos.js'
     2import { createDatedAvailabilitySlots, qualifiedStaffForService, serviceBlockedByStaff } from './availability.js'
     3import { isCommerceCheckout, isShopifyCommerce, isWooCommerceCommerce, submitBooking, submitWaitlistEntry } from './api.js'
    34import { buildIcsDataUrl } from './calendar.js'
    4 import { DATE_WINDOW_SIZE, widgetCopy as copy } from './copy.js'
    5 import { escapeHtml, formatDuration, formatPrice, servicePriceSummary, timezoneLabel } from './formatters.js'
     5import { DATE_WINDOW_SIZE, getWidgetCopy, widgetLocale } from './copy.js'
     6import { escapeHtml, formatDuration, formatPrice, servicePriceSummary, text, timezoneLabel } from './formatters.js'
    67import { confirmBookingPayment, getDepositPaymentSession, mountDepositPayment, showPaymentLoadError } from './payment.js'
    78import { scheduleRevealContinue } from './scroll.js'
    89import { applyThemeProperties } from './theme.js'
     10import { DIAL_CODES, PHONE_COUNTRIES, normalizePhoneE164 } from './phoneNumber.js'
    911
    1012const SHOPIFY_HOLD_NAME = 'Shopify checkout'
    … …  
    2224  '<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>'
    2325
    24 function formatDateBadge(dateKey) {
     26function formatDateBadge(dateKey, locale) {
    2527  const value = new Date(`${dateKey}T12:00:00`)
    2628  return {
    2729    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('.', ''),
    3032  }
    3133}
    3234
    33 function formatSummaryDate(dateKey) {
     35function staffFirstName(name) {
     36  return text(name, '').split(/\s+/).filter(Boolean)[0] || ''
     37}
     38
     39function 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
     49function staffChipColor(value) {
     50  const color = text(value, '')
     51  return /^#[0-9a-fA-F]{6}$/.test(color) ? color : ''
     52}
     53
     54function 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
     62function formatSummaryDate(dateKey, locale) {
    3463  if (!dateKey) {
    3564    return ''
    3665  }
    3766
    38   return new Intl.DateTimeFormat('en-US', {
     67  return new Intl.DateTimeFormat(locale || 'en-US', {
    3968    weekday: 'short',
    4069    day: 'numeric',
    … …  
    4372}
    4473
    45 function formatVisibleDateRange(dates) {
     74function formatVisibleDateRange(dates, locale) {
    4675  if (!dates.length) {
    4776    return ''
    4877  }
    4978
    50   const formatter = new Intl.DateTimeFormat('en-US', {
     79  const formatter = new Intl.DateTimeFormat(locale || 'en-US', {
    5180    day: 'numeric',
    5281    month: 'short',
    … …  
    5887}
    5988
    60 function stepperMarkup(step) {
     89function stepperMarkup(step, copy) {
    6190  const currentStep = Math.min(step + 1, copy.widgetStepLabels.length)
    6291  const activeLabel = copy.widgetStepLabels[currentStep - 1]
    … …  
    6695      class="stepper"
    6796      role="progressbar"
    68       aria-label="Booking progress"
     97      aria-label="${escapeHtml(copy.progressLabel)}"
    6998      aria-valuemin="1"
    7099      aria-valuemax="${copy.widgetStepLabels.length}"
    71100      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)}"
    73102    >
    74103      <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>
    76105        <strong>${escapeHtml(activeLabel)}</strong>
    77106      </div>
    … …  
    101130
    102131export function createWidget(config) {
     132  const copy = getWidgetCopy(config.language)
     133  const locale = widgetLocale(config.language)
    103134  const widget = document.createElement('section')
    104135  const isFloating = config.variant === 'floating'
    105136  const isOverlay = isFloating
    106137  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
    108141  let step = skipServiceStep ? 1 : 0
    109142  let selectedServiceId = config.services[0]?.id || config.services[0]?.name || ''
    110143  let selectedDate = null
    111144  let selectedSlotId = null
     145  let selectedStaffId = null
    112146  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    : ''
    114150  let messageTone = 'error'
    115151  let isSubmitting = false
    … …  
    117153  let paymentSession = null
    118154  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
    119159  let form = {
     160    answers: {},
    120161    customerEmail: '',
    121162    customerName: '',
    122163    customerPhone: '',
     164    customerPhoneCountry: '',
    123165    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)}`
    124186  }
    125187
    … …  
    140202  }
    141203
     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
    142234  function datedSlots() {
    143     return createDatedAvailabilitySlots(config, selectedService())
     235    return createDatedAvailabilitySlots(config, selectedService(), pinnedStaffId())
    144236  }
    145237
    … …  
    166258
    167259    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      )
    169277    }
    170278
    … …  
    190298
    191299    if (step === 2) {
     300      if (terminalPaymentReplacementAvailable) return copy.replacePaymentCta
    192301      return copy.confirmCta
    193302    }
    … …  
    198307  function formatDeposit(cents) {
    199308    const amount = (Number(cents) || 0) / 100
    200     return new Intl.NumberFormat('en-US', {
     309    return new Intl.NumberFormat(locale, {
    201310      style: 'currency',
    202311      currency: 'EUR',
    … …  
    210319        <div class="confirm-sub" style="text-align:left;margin-bottom:12px">${escapeHtml(copy.depositNote)}</div>
    211320        <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>
    213322        </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>
    215324      </div>
    216325    `
    … …  
    223332
    224333    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>`
    226335  }
    227336
    … …  
    245354        return `
    246355          <button type="button" class="service-card${selected}" data-service-id="${escapeHtml(id)}">
     356            ${servicePhotoMarkup(service.imageUrl, config.previewMode)}
    247357            <div class="service-info">
    248358              <div class="service-name">${escapeHtml(service.name)}</div><div class="service-price">${escapeHtml(servicePriceSummary(service))}</div>
    … …  
    256366  }
    257367
     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
    258455  function dateStep(availableDates, slotsByDate) {
    259456    const dates = visibleDates(availableDates)
    … …  
    261458    const canPrev = datePageStart > 0
    262459    const canNext = datePageStart + DATE_WINDOW_SIZE < availableDates.length
     460    const service = selectedService()
    263461    const backBtn =
    264462      skipServiceStep
    … …  
    266464        : `<button class="back-btn" type="button" data-action="back">← ${escapeHtml(copy.back)}</button>`
    267465
     466    if (serviceBlockedByStaff(config, service)) {
     467      return `
     468        ${backBtn}
     469        ${runtimeState(copy.serviceNotBookable, 'danger')}
     470      `
     471    }
     472
    268473    return `
    269474      ${backBtn}
     475      ${staffPickerMarkup()}
    270476      <p class="section-title">${escapeHtml(copy.chooseDate)}</p><p class="booking-timezone">${escapeHtml(timezoneLabel(config))}</p>
    271477      <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>
    273479        <div class="date-nav-buttons">
    274480          <button type="button" class="date-nav-btn" data-action="prev-dates" aria-label="${escapeHtml(copy.previousDates)}" ${canPrev ? '' : 'disabled'}>
    … …  
    285491            ? dates
    286492                .map((dateKey) => {
    287                   const badge = formatDateBadge(dateKey)
     493                  const badge = formatDateBadge(dateKey, locale)
    288494                  const selected = dateKey === selectedDate ? ' selected' : ''
    289495                  return `
    … …  
    309515                      .map((slot) => {
    310516                        const selected = slot.id === selectedSlotId ? ' selected' : ''
     517                        const recommended = slot.recommended ? ' recommended' : ''
    311518                        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)}">
    313520                            ${escapeHtml(slot.timeLabel)}
     521                            ${slot.recommended ? `<em class="rec-badge">${escapeHtml(copy.recommendedBadge)}</em>` : ''}
    314522                          </button>
    315523                        `
    … …  
    322530          : ''
    323531      }
     532      ${!dates.length || !daySlots.length ? waitlistMarkup(service, daySlots) : ''}
    324533    `
    325534  }
    … …  
    332541        <div class="summary-box">
    333542          <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>
    335544          </div>
    336545          <div class="summary-row">
    337546            <span>${escapeHtml(service?.name || '')} · ${escapeHtml(formatDuration(service?.durationMinutes))}</span>
    338547          </div>
     548          ${
     549            selectedStaffName()
     550              ? `<div class="summary-row"><span>${escapeHtml(copy.withStaff.replace('{name}', selectedStaffName()))}</span></div>`
     551              : ''
     552          }
    339553          ${
    340554            price !== 'Free'
    … …  
    349563            <input class="form-input" id="bm-runtime-customerName" name="customerName" autocomplete="name" required placeholder="${escapeHtml(copy.namePlaceholder)}" value="${escapeHtml(form.customerName)}" />
    350564          </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          }
    351581          <div class="form-field">
    352582            <label class="form-label" for="bm-runtime-customerPhone">${escapeHtml(copy.phoneLabel)}</label>
    … …  
    362592          <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>
    363593        </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        }
    364630      </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('')}
    365690    `
    366691  }
    … …  
    401726        <div class="summary-box">
    402727          <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>
    404729          <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          }
    405735          ${
    406736            price !== 'Free'
    … …  
    419749    const emailInput = widget.querySelector('[name="customerEmail"]')
    420750    const phoneInput = widget.querySelector('[name="customerPhone"]')
     751    const phoneCountryInput = widget.querySelector('[name="customerPhoneCountry"]')
    421752    const notesInput = widget.querySelector('[name="notes"]')
    422753
    … …  
    424755    if (emailInput) form.customerEmail = emailInput.value
    425756    if (phoneInput) form.customerPhone = phoneInput.value
     757    if (phoneCountryInput) form.customerPhoneCountry = phoneCountryInput.value
    426758    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    }
    427779  }
    428780
    … …  
    437789    selectedDate = null
    438790    selectedSlotId = null
     791    selectedStaffId = null
    439792    datePageStart = 0
    440793    message = ''
    … …  
    442795    isSubmitting = false
    443796    confirmation = null
     797    clientRequestId = newClientRequestId()
     798    terminalPaymentReplacementAvailable = false
     799    waitlistOpen = false
     800    waitlistBusy = false
     801    waitlistJoined = false
     802    waitlistForm = { customerEmail: '', customerName: '', dateFrom: '', dateTo: '' }
    444803    form = {
     804      answers: {},
    445805      customerEmail: '',
    446806      customerName: '',
    447807      customerPhone: '',
     808      customerPhoneCountry: '',
    448809      notes: '',
     810      rebookingOptIn: false,
     811      smsOptIn: false,
     812      whatsappOptIn: false,
    449813    }
    450814    render()
    … …  
    502866        </div>
    503867      </div>
    504       ${stepperMarkup(paymentSession ? 2 : step)}
     868      ${stepperMarkup(paymentSession ? 2 : step, copy)}
    505869    `
    506870
     871    const staffHint = selectedStaffName()
    507872    const serviceHint =
    508873      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>`
    510875        : ''
    511876
    … …  
    545910          accent: config.color,
    546911          clientSecret: paymentSession.clientSecret,
     912          language: config.language,
    547913          publishableKey: paymentSession.publishableKey,
    548914        })
    … …  
    553919          })
    554920          .catch((error) => {
    555             if (host.isConnected && paymentSession === session) showPaymentLoadError(host, error, render)
     921            if (host.isConnected && paymentSession === session) showPaymentLoadError(host, error, render, config.language)
    556922          })
    557923      }
    … …  
    560926
    561927  function bindEvents(availableDates, slotsByDate) {
     928    bindServicePhotoErrors(widget)
    562929    widget.querySelectorAll('[data-service-id]').forEach((button) => {
    563930      button.addEventListener('click', () => {
    … …  
    565932        selectedDate = null
    566933        selectedSlotId = null
     934        selectedStaffId = null
    567935        datePageStart = 0
    568936        message = ''
    569937        render()
    570938      })
     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()
    571981    })
    572982
    … …  
    6331043    })
    6341044
     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
    6351101    widget.querySelector('[data-action="continue"]')?.addEventListener('click', () => {
    6361102      if (isSubmitting) {
    … …  
    6951161    }
    6961162
     1163    const session = paymentSession
     1164    const controller = paymentController
    6971165    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,
    7011171        paymentIntentId,
    7021172      })
     1173      if (paymentSession !== session || paymentController !== controller) return
     1174      if (result.processing || !(result.ok || result.alreadyConfirmed)) throw new Error(copy.paymentError)
    7031175
    7041176      confirmation = {
    … …  
    7161188      render()
    7171189    } catch (error) {
     1190      if (paymentSession !== session || paymentController !== controller) return
    7181191      isSubmitting = false
    7191192      message = error instanceof Error ? error.message : copy.genericError
    … …  
    7391212    const service = selectedService()
    7401213    const slot = selectedSlot(slotsByDate)
     1214    if (Number(service?.depositCents) > 0 && config.features?.deposits !== true) return
    7411215
    7421216    if (isShopifyCommerce(config)) {
    … …  
    7511225
    7521226    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()
    7531242      return
    7541243    }
    … …  
    7651254    try {
    7661255      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,
    7671260        customerEmail: form.customerEmail,
    7681261        customerName: form.customerName,
    7691262        customerPhone: form.customerPhone,
     1263        customerPhoneCountry: form.customerPhoneCountry || undefined,
     1264        language: config.language || 'en',
    7701265        notes: form.notes,
     1266        rebookingOptIn: form.rebookingOptIn || undefined,
    7711267        serviceId: service.id || service.name,
    7721268        serviceName: service.name,
     1269        smsOptIn: config.sms?.available === true ? form.smsOptIn || undefined : undefined,
     1270        staffId: pinnedStaffId(),
    7731271        startsAt: slot.startsAt,
     1272        whatsappOptIn: config.whatsapp?.available === true ? form.whatsappOptIn || undefined : undefined,
    7741273      })
    7751274
    … …  
    7911290      }
    7921291
    793       const depositSession = getDepositPaymentSession(result)
     1292      const depositSession = getDepositPaymentSession(result, config.language)
    7941293      if (depositSession) {
     1294        terminalPaymentReplacementAvailable = false
    7951295        paymentSession = depositSession
    7961296        confirmation = {
    … …  
    8191319    } catch (error) {
    8201320      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
    8221336      messageTone = 'error'
    8231337      render()
     1338      widget.querySelector('.widget-message')?.focus()
    8241339    }
    8251340  }
  • 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.
     4export * from './availabilitySlotEngine.js'
  • bookminto/tags/wp-1.0.5/assets/src/styles.js

    r3723811 r3730301  
    22import { bookingBarModalCss } from './bookingBarModalStyles.js'
    33import { bookingBarCss } from './bookingBarStyles.js'
     4import { extensionsCss } from './extensionsStyles.js'
    45import { floatingChromeCss } from './floatingChromeStyles.js'
    56import { floatingWidgetCss } from './floatingStyles.js'
    67import { modalChromeCss } from './modalStyles.js'
     8import { nextTimesCss } from './nextTimesStyles.js'
    79
    810export const widgetCss =
    … …  
    1214  bookingBarCss +
    1315  bookingBarModalCss +
    14   bookingBarFlowCss
     16  bookingBarFlowCss +
     17  nextTimesCss +
     18  extensionsCss
    1519
    1620export 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,`&amp;`).replace(/</g,`&lt;`).replace(/>/g,`&gt;`).replace(/"/g,`&quot;`).replace(/'/g,`&#039;`)}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(`\r
    2 `))}`}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,`&amp;`).replace(/</g,`&lt;`).replace(/>/g,`&gt;`).replace(/"/g,`&quot;`).replace(/'/g,`&#039;`)}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`
    33    <div
    44      class="bw-booking-modal__stepper"
    55      role="progressbar"
    6       aria-label="Booking progress"
     6      aria-label="${S(t.progressLabel)}"
    77      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)}"
    1111    >
    1212      <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>
    1515      </div>
    1616      <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(``)}
    1818      </div>
    1919    </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}">
    2222          <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>
    2528          </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>
    2730        </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}">
    3033                <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>
    3336                </span>
    34                 <span>${t===c?`Selected`:`Choose`}</span>
     37                <span>${S(t===p?r.selected:r.choose)}</span>
    3538              </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`
    3740      <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)}">
    3942          <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>
    4245          </div>
    4346          <div class="bw-booking-modal__service-list">
    44             ${Le(t,s)}
     47            ${yt(t,f,e.previewMode,r,e.language)}
    4548          </div>
    4649        </aside>
    47         <section class="bw-booking-modal__times" aria-label="Available times">
     50        <section class="bw-booking-modal__times" aria-label="${S(r.availableTimes)}">
    4851          <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>
    5154          </div>
    5255          <div class="bw-booking-modal__slots">
    53             ${a}
     56            ${o}
    5457          </div>
    5558        </section>
    5659      </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`
    5861      <div class="bw-booking-modal__pane">
    5962        <form class="bw-booking-bar-panel__form">
    6063          <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>
    6366          </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>`:``}
    6569          <div class="bw-booking-bar-panel__form-row">
    6670            <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)}" />
    6973            </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                `:``}
    7083            <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)}" />
    7386
    7487            </label>
    7588          </div>
    7689          <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)}" />
    7992          </label>
    8093          <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>
    8396          </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>`:``}
    85111          <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>
    88114          </div>
    89115        </form>
    90116      </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`
    100126      <div class="bw-booking-modal__pane">
    101127        <div class="bw-booking-bar-panel__confirmed">
    102128          <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>
    105131          ${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>
    108134        </div>
    109135      </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`
    111137      <div class="bw-booking-modal">
    112138        <div class="bw-booking-modal__topbar">
    … …  
    114140            <span class="bw-booking-modal__brand-mark">B</span>
    115141            <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>
    118144            </span>
    119145          </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>
    121147        </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">
    126152                <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>
    129155                </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>
    131157              </div>`:``}
    132158      </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=`
    134160      <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}">
    136162          <span class="bw-booking-bar__mark">B</span>
    137163          <span>
    138             <strong>${l(e.title)}</strong>
    139             <em>${l(x)}</em>
     164            <strong>${S(e.title)}</strong>
     165            <em>${S(ee)}</em>
    140166          </span>
    141167        </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>
    143169      </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`
    145171    <div
    146172      class="stepper"
    147173      role="progressbar"
    148       aria-label="Booking progress"
     174      aria-label="${S(t.progressLabel)}"
    149175      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)}"
    153179    >
    154180      <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>
    157183      </div>
    158184      <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(``)}
    160186      </div>
    161187    </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>
    165216      </button>
    166     `}function k(e,t){return`
     217    `}function U(e,t){return`
    167218      <div class="panel-right-flow">
    168219        <div class="panel-right-scroll">${e}</div>
    169220        ${t}
    170221      </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)}
    175227                <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>`:``}
    178230                </div>
    179                 <div class="svc-badge">${l(o(e.durationMinutes))}</div>
     231                <div class="svc-badge">${S(y(t.durationMinutes))}</div>
    180232              </button>
    181233            `}).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">
    185237              <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>`:``}
    188240              </div>
    189               <div class="svc-badge">${l(o(t.durationMinutes))}</div>
     241              <div class="svc-badge">${S(y(r.durationMinutes))}</div>
    190242            </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>`:``}
    194247            </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>
    198290          <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>
    200292          </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(`
    204296          <div class="modal-intro-copy">
    205297            Choose a service on the left, then continue to pick a date and time.
    206298          </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>
    210306          <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>
    212308            <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>
    215311            </div>
    216312          </div>
    217313          <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>
    223319                        </button>
    224                       `}).join(``):T(K.emptySlots)}
     320                      `}).join(``):z(t.emptySlots)}
    225321          </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)}
    231327              `:``}
    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>`:``}
    235334          <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" />
    238337          </div>
    239338          <div class="form-row">
    240339            <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" />
    243342            </div>
    244343            <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" />
    247346            </div>
    248347          </div>
    249348          <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>
    252351          </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`
    255359      <div class="confirm-wrap">
    256360        <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>`:``}
    260364        <button class="cta cta--close-success" type="button" data-action="close-success">
    261           ${l(K.close)}
     365          ${S(t.close)}
    262366        </button>
    263367      </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=`
    265369      <div class="modal-card">
    266370        <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>
    270374        </div>
    271375        <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>
    274378        </div>
    275379        ${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>
    278382              </div>`}
    279383      </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=`
    281385.bw-floating-popover {
    282386  position: fixed !important;
    … …  
    359463  }
    360464}
    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=`
    362466.bw-widget-shadow-host[data-variant="floating"],
    363467.bw-widget-shadow-host[data-variant="modal"][data-trigger-mode="external"] {
    … …  
    369473  padding: 0 !important;
    370474  border: 0 !important;
    371   z-index: ${Y} !important;
     475  z-index: ${Ft} !important;
    372476  pointer-events: none !important;
    373477  transform: none !important;
    … …  
    406510  border: 0 !important;
    407511  overflow: visible !important;
    408   z-index: ${Y} !important;
     512  z-index: ${Ft} !important;
    409513  pointer-events: auto !important;
    410514  transform: none !important;
    … …  
    437541  box-sizing: border-box !important;
    438542}
    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`
    440558    <div
    441559      class="stepper"
    442560      role="progressbar"
    443       aria-label="Booking progress"
     561      aria-label="${S(t.progressLabel)}"
    444562      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)}"
    448566    >
    449567      <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>
    452570      </div>
    453571      <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(``)}
    455573      </div>
    456574    </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`
    458576      <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>
    461579        <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>
    463581        </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>
    465583      </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)}
    468587            <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>`:``}
    471590            </div>
    472             <div class="service-duration">${l(o(e.durationMinutes))}</div>
     591            <div class="service-duration">${S(y(t.durationMinutes))}</div>
    473592          </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>
    477643      <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>
    479645        <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`}>
    481647            <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>
    482648          </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`}>
    484650            <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>
    485651          </button>
    … …  
    487653      </div>
    488654      <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>
    494660                    </button>
    495                   `}).join(``):j(K.emptySlots)}
     661                  `}).join(``):W(t.emptySlots)}
    496662      </div>
    497663      ${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>`:``}
    503670                          </button>
    504671                        `).join(``)}
    505                   </div>`:j(K.emptySlots)}
     672                  </div>`:W(t.emptySlots)}
    506673          `:``}
    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`
    508676      <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>
    510678        <div class="summary-box">
    511679          <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>
    513681          </div>
    514682          <div class="summary-row">
    515             <span>${l(e?.name||``)} · ${l(o(e?.durationMinutes))}</span>
     683            <span>${S(r?.name||``)} · ${S(y(r?.durationMinutes))}</span>
    516684          </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>`}
    518687        </div>
    519         <p class="section-title">${l(K.detailsHeading)}</p>
     688        <p class="section-title">${S(t.detailsHeading)}</p>
    520689        <div class="form-row">
    521690          <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)}" />
    524693          </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              `:``}
    525703          <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)}" />
    528706          </div>
    529707        </div>
    530708        <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)}" />
    533711        </div>
    534712        <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>
    537781        </div>
    538782      </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}
    562786      <div class="widget-body">
    563787        <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()}
    567791        </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>
    570794                <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>
    571795              </button>`:``}
    572796      </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=`
    575799:host {
    576800  color-scheme: light;
    … …  
    8721096}
    8731097
     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
    8741107.service-info {
     1108  min-width: 0;
    8751109  flex: 1;
    8761110  text-align: left;
    … …  
    10271261}
    10281262
     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
    10291330.form-field {
    10301331  margin-bottom: 0;
    … …  
    10621363  min-height: 74px;
    10631364  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;
    10641382}
    10651383
    … …  
    23942712
    23952713@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
    23962720  .bw-booking-bar-spacer {
    23972721    height: 0;
    … …  
    26052929  border-color: rgb(31 95 58 / 0.28);
    26062930  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;
    26072944}
    26082945
    … …  
    29453282  }
    29463283}
    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  
    33 * Plugin Name: Bookminto
    44 * Description: Add Bookminto to WordPress pages with automatic display, a block, a shortcode, or WooCommerce product checkout booking.
    5  * Version: 1.0.0
     5 * Version: 1.0.5
    66 * Author: Bookminto
    77 * Author URI: https://bookminto.com/
    … …  
    2323const BOOKMINTO_OPTION = 'bookminto_options';
    2424const BOOKMINTO_OPTION_LEGACY = 'booking_widget_options';
    25 const BOOKMINTO_VERSION = '1.0.0';
     25const BOOKMINTO_VERSION = '1.0.5';
    2626const BOOKMINTO_API_URL = 'https://api.bookminto.com/v1';
    2727const BOOKMINTO_PLUGIN_DIR = __DIR__;
  • bookminto/tags/wp-1.0.5/includes/woocommerce.php

    r3723811 r3730301  
    5050    add_action('wp_ajax_bookminto_add_to_cart', 'bookminto_woo_ajax_add_to_cart');
    5151    add_action('wp_ajax_nopriv_bookminto_add_to_cart', 'bookminto_woo_ajax_add_to_cart');
     52    add_action('template_redirect', 'bookminto_woo_waitlist_checkout');
    5253
    5354    add_filter('woocommerce_add_cart_item_data', 'bookminto_woo_add_cart_item_data', 10, 3);
    … …  
    933934    }
    934935
    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. */
     948function 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.
    936954    foreach (WC()->cart->get_cart() as $key => $item) {
    937955        if ((int) ($item['product_id'] ?? 0) === $product_id && !empty($item[BOOKMINTO_WOO_BOOKING_META])) {
    … …  
    944962    $_REQUEST['bookminto_service_name'] = $service_name;
    945963    $_REQUEST['bookminto_ajax'] = '1';
     964    $_REQUEST['nonce'] = wp_create_nonce('bookminto_woo_cart');
    946965
    947966    $cart_key = WC()->cart->add_to_cart($product_id, 1);
    … …  
    950969            wc_clear_notices();
    951970        }
    952         wp_send_json_error(['message' => __('Could not add booking to cart.', 'bookminto')], 400);
     971        return '';
    953972    }
    954973
    … …  
    957976    }
    958977
    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. */
     983function 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;
    9641087}
    9651088
  • bookminto/tags/wp-1.0.5/languages/bookminto.pot

    r3723811 r3730301  
    11msgid ""
    22msgstr ""
    3 "Project-Id-Version: Bookminto 1.0.0\n"
     3"Project-Id-Version: Bookminto 1.0.5\n"
    44"Report-Msgid-Bugs-To: https://bookminto.com/\n"
    5 "POT-Creation-Date: 2026-10-01\n"
     5"POT-Creation-Date: 2026-10-06\n"
    66"MIME-Version: 1.0\n"
    77"Content-Type: text/plain; charset=UTF-8\n"
    … …  
    5757msgstr ""
    5858
    59 #: includes/admin-ui.php
    60 msgid "Booking bar, buffers, and schedule exceptions"
    61 msgstr ""
    62 
    6359#: blocks/widget/editor.js bookminto.php includes/admin-menu.php includes/admin-menu.php includes/admin-menu.php
    6460msgid "Bookminto"
    … …  
    10197msgstr ""
    10298
     99#: includes/admin-ui.php
     100msgid "Bookminto service access"
     101msgstr ""
     102
    103103#: includes/admin-menu.php
    104104msgid "Bookminto services"
    … …  
    153153msgstr ""
    154154
    155 #: includes/admin-ui.php
    156 msgid "Compare plans"
     155#: includes/woocommerce.php
     156msgid "Complete your appointment"
    157157msgstr ""
    158158
    … …  
    177177msgstr ""
    178178
     179#: includes/woocommerce.php
     180msgid "Continue to checkout"
     181msgstr ""
     182
    179183#: includes/settings.php
    180184msgid "Copy the key from Bookminto → Install → WordPress, then save it here."
    … …  
    206210
    207211#: 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."
     212msgid "Create a Bookminto account"
    209213msgstr ""
    210214
    … …  
    253257msgstr ""
    254258
    255 #: includes/admin-ui.php includes/admin-ui.php
    256 msgid "Deposits via Stripe Connect"
    257 msgstr ""
    258 
    259259#: includes/settings.php
    260260msgid "Disconnect could not be confirmed. Your local credentials were kept. Retry, or revoke this connection under Bookminto → Install."
    … …  
    275275#: includes/admin-menu.php
    276276msgid "Edit product"
    277 msgstr ""
    278 
    279 #: includes/admin-ui.php includes/admin-ui.php
    280 msgid "For growing businesses that need deposits and automations."
    281 msgstr ""
    282 
    283 #: includes/admin-ui.php
    284 msgid "For independent professionals running their bookable business."
    285277msgstr ""
    286278
    … …  
    298290
    299291#: includes/admin-ui.php
    300 msgid "Get an installation key"
    301 msgstr ""
    302 
    303 #: includes/admin-ui.php includes/admin-ui.php
    304 msgid "Get Pro"
    305 msgstr ""
    306 
    307 #: includes/admin-ui.php
    308 msgid "Get Solo"
    309 msgstr ""
    310 
    311 #: includes/admin-ui.php
    312292msgid "Help & links"
    313293msgstr ""
    … …  
    381361msgstr ""
    382362
     363#: includes/woocommerce.php
     364msgid "No"
     365msgstr ""
     366
    383367#: includes/admin-menu.php
    384368msgid "No appointments yet."
    … …  
    473457msgstr ""
    474458
    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
    480460msgid "Pro"
    481461msgstr ""
    … …  
    491471#: includes/admin-ui.php
    492472msgid "Product overview"
    493 msgstr ""
    494 
    495 #: includes/admin-ui.php
    496 msgid "Product-page checkout booking needs Solo or Pro. The block, shortcode, and site-wide widget work on Free."
    497473msgstr ""
    498474
    … …  
    510486
    511487#: includes/woocommerce.php
     488msgid "Reload this offer before continuing."
     489msgstr ""
     490
     491#: includes/woocommerce.php
    512492msgid "Retry Bookminto order synchronization"
    513493msgstr ""
    … …  
    535515#: includes/settings.php
    536516msgid "Secure connection active"
    537 msgstr ""
    538 
    539 #: includes/admin-ui.php
    540 msgid "See pricing on bookminto.com"
    541517msgstr ""
    542518
    … …  
    569545msgstr ""
    570546
    571 #: includes/admin-ui.php includes/admin-ui.php
     547#: includes/admin-ui.php
    572548msgid "Solo"
    573549msgstr ""
    … …  
    575551#: includes/admin-menu.php
    576552msgid "Source"
    577 msgstr ""
    578 
    579 #: includes/admin-ui.php
    580 msgid "Start for free"
    581553msgstr ""
    582554
    … …  
    609581msgstr ""
    610582
     583#: includes/woocommerce.php
     584msgid "The booking conditions changed. Reload this offer before continuing."
     585msgstr ""
     586
     587#: includes/woocommerce.php
     588msgid "The booking product cannot be determined. Please contact the store."
     589msgstr ""
     590
    611591#: blocks/widget/editor.js
    612592msgid "The booking widget appears here on the published page."
    … …  
    621601msgstr ""
    622602
     603#: includes/woocommerce.php
     604msgid "The cart is unavailable. Reopen this same offer to continue."
     605msgstr ""
     606
    623607#: includes/admin-menu.php
    624608msgid "The product could not be created."
     609msgstr ""
     610
     611#: includes/woocommerce.php
     612msgid "This appointment is already confirmed."
     613msgstr ""
     614
     615#: includes/woocommerce.php
     616msgid "This booking product is unavailable."
     617msgstr ""
     618
     619#: includes/admin-ui.php
     620msgid "This free WordPress plugin connects to the external Bookminto booking service. Account access, current prices, and beta terms are published on bookminto.com."
     621msgstr ""
     622
     623#: includes/woocommerce.php
     624msgid "This offer could not be reserved. Reload it before trying again."
     625msgstr ""
     626
     627#: includes/woocommerce.php
     628msgid "This offer is no longer available. Please contact the store."
    625629msgstr ""
    626630
    … …  
    641645msgstr ""
    642646
     647#: includes/woocommerce.php
     648msgid "This store is not connected to Bookminto."
     649msgstr ""
     650
    643651#: includes/settings.php
    644652msgid "To connect or rotate credentials, create a one-time WordPress connection code under Bookminto → Install. The code expires after 10 minutes."
    … …  
    653661msgstr ""
    654662
    655 #: includes/admin-ui.php
    656 msgid "Unlimited bookings"
    657 msgstr ""
    658 
    659663#: includes/admin-menu.php
    660664msgid "Updated “%s” to match the Bookminto service."
    … …  
    665669msgstr ""
    666670
    667 #: includes/admin-ui.php
    668 msgid "Upgrade to Pro"
    669 msgstr ""
    670 
    671671#: includes/settings.php
    672672msgid "Use the checkbox above for a floating button, booking bar, or modal on every page."
    … …  
    675675#: includes/woocommerce.php
    676676msgid "Used when this product creates or updates a Bookminto service."
     677msgstr ""
     678
     679#: includes/admin-ui.php
     680msgid "View current plans and beta terms"
    677681msgstr ""
    678682
    … …  
    693697msgstr ""
    694698
    695 #: includes/admin-ui.php
    696 msgid "WooCommerce checkout booking"
    697 msgstr ""
    698 
    699699#: includes/settings.php
    700700msgid "WooCommerce is securely connected to Bookminto."
    … …  
    713713msgstr ""
    714714
    715 #: includes/admin-ui.php includes/settings.php
     715#: includes/settings.php
    716716msgid "WordPress install guide"
    717717msgstr ""
    … …  
    719719#: includes/admin-menu.php
    720720msgid "Workspace"
     721msgstr ""
     722
     723#: includes/woocommerce.php
     724msgid "Yes"
    721725msgstr ""
    722726
    … …  
    729733msgstr ""
    730734
    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
     736msgid "Your appointment is confirmed after the shop payment has been verified."
     737msgstr ""
     738
  • bookminto/tags/wp-1.0.5/readme.txt

    r3724276 r3730301  
    55Tested up to: 7.1
    66Requires PHP: 7.4
    7 Stable tag: 1.0.0
     7Stable tag: 1.0.5
    88License: GPLv2 or later
    99License URI: https://www.gnu.org/licenses/gpl-2.0.html
    … …  
    3232* **Popup booking** — open the booking flow when visitors are ready to book.
    3333
    34 Customize the appearance to better match your website and brand.
     34Customize 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.
    3535
    3636Use the Bookminto block in the WordPress editor, or place the booking experience manually with a shortcode.
    … …  
    9696
    9797`https://api.bookminto.com/v1`
     98
     99Optional service photos load from Bookminto-managed Supabase Storage:
     100
     101`https://zmzskjwuiryxrwlitkqn.supabase.co/storage/v1/object/public/service-images/`
     102
     103Image requests share the visitor’s IP and browser information with storage, without booking details. See Bookminto’s Privacy Policy below.
    98104
    99105The Bookminto service is used to:
    … …  
    168174= Where can developers find the source and build instructions? =
    169175
    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.
     176The 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.
    171177
    172178= Can I place the widget manually? =
    173179
    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.
     180Use the Bookminto block or `[bookminto]` / `[booking_widget]`. Automatic rendering is skipped on singular pages containing a block or shortcode.
    175181
    176182= Does the plugin charge visitors? =
    … …  
    210216== Changelog ==
    211217
     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
    212239= 1.0.0 =
    213240
  • bookminto/tags/wp-1.0.5/uninstall.php

    r3723811 r3730301  
    1010}
    1111
    12 delete_option('bookminto_options');
    13 delete_option('booking_widget_options');
    14 delete_option('bookminto_woocommerce_credentials');
     12function bookminto_uninstall_site(): void {
     13    delete_option('bookminto_options');
     14    delete_option('booking_widget_options');
     15    delete_option('bookminto_woocommerce_credentials');
    1516
    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    }
    1825}
    19 wp_unschedule_hook('bookminto_woo_retry_order');
    2026
    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.
     29if (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();
    2338}
  • bookminto/trunk/assets/src/api.js

    r3723811 r3730301  
    11import { apiUrl as defaultApiUrl } from './constants.js'
     2import { getWidgetCopy } from './copy.js'
    23import {
    34  addBookingToShopifyCart,
    … …  
    4950        commerceCheckout: commerce || undefined,
    5051        publicKey: config.workspace,
     52        proposalSlug: config.proposalSlug || undefined,
    5153        shopifyProductGid: shopify ? config.shopifyProduct || undefined : undefined,
    5254        shopifyShopDomain: shopify ? config.shopifyShop || undefined : undefined,
    … …  
    6567  if (!response.ok) {
    6668    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
    6872  }
    6973
    7074  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  }
    7178
    72   if (shopify && (result.needsCheckout || result.bookingId)) {
     79  if (shopify && result.needsCheckout === true) {
    7380    await addBookingToShopifyCart({
    7481      bookingId: result.bookingId,
    … …  
    8188  }
    8289
    83   if (woo && (result.needsCheckout || result.bookingId)) {
     90  if (woo && result.needsCheckout === true) {
    8491    await addBookingToWooCommerceCart({
    8592      bookingId: result.bookingId,
    … …  
    97104}
    98105
     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 */
     110export 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
    99154export {
    100155  addBookingToShopifyCart,
  • bookminto/trunk/assets/src/availability.js

    r3723811 r3730301  
    11import { slotDateFormatter } from './constants.js'
     2import { widgetLocale } from './copy.js'
    23import { text } from './formatters.js'
    34import {
    … …  
    154155}
    155156
    156 function getZonedDateFormatter(timeZone) {
    157   if (!timeZone) {
     157function getZonedDateFormatter(timeZone, language) {
     158  const locale = widgetLocale(language)
     159
     160  if (!timeZone && locale === 'en-US') {
    158161    return slotDateFormatter
    159162  }
    160163
    161164  try {
    162     return new Intl.DateTimeFormat('en-US', {
     165    return new Intl.DateTimeFormat(locale, {
    163166      timeZone,
    164167      day: 'numeric',
    … …  
    171174}
    172175
    173 function getBookedStartsForService(config, service) {
     176function getBookedRanges(config) {
    174177  if (!Array.isArray(config?.bookedSlots)) {
    175178    return []
    176179  }
    177180
    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.
    180185  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).
     198export 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.
     214export 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
     223export function createAvailabilitySlots(config, service, limit = 6, staffId) {
    195224  if (!service) {
    196225    return []
    … …  
    200229  const rules = flattenAvailabilityRules(availability)
    201230
    202   if (!rules.length) {
     231  if (!rules.length && !service.bookingDates?.some(day => day.startTime && day.endTime)) {
    203232    return []
    204233  }
    205234
    206235  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  )
    208241
    209242  return buildSlots({
    210243    availability,
    211     bookedStarts: getBookedStartsForService(config, service),
     244    bookedRanges: getBookedRanges(config),
    212245    exceptions: config.exceptions || [],
    213246    formatLabel: (startsAt) => {
    … …  
    221254    },
    222255    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,
    223261    timeZone,
    224     daysAhead: 28,
     262    daysAhead,
    225263    service: {
     264      bookingDates: Array.isArray(service.bookingDates) ? service.bookingDates : [],
    226265      bufferAfterMinutes: Number(service.bufferAfterMinutes) || 0,
    227266      bufferBeforeMinutes: Number(service.bufferBeforeMinutes) || 0,
    … …  
    229268      id: service.id,
    230269      name: service.name,
     270      staffIds: Array.isArray(service.staffIds) ? service.staffIds : service.staffIds === null ? null : undefined,
    231271    },
    232272  })
    233273}
    234274
    235 function getSlotDateKey(startsAt, timeZone) {
     275export function getSlotDateKey(startsAt, timeZone) {
    236276  const date = new Date(startsAt)
    237277
    … …  
    258298}
    259299
    260 export function createDatedAvailabilitySlots(config, service) {
    261   const slots = createAvailabilitySlots(config, service, 200)
     300export function createDatedAvailabilitySlots(config, service, staffId) {
     301  const slots = createAvailabilitySlots(config, service, 200, staffId)
    262302  const timeZone = config.timezone || undefined
    263303  const byDate = new Map()
  • bookminto/trunk/assets/src/bookingBar.js

    r3723811 r3730301  
    1 import { createAvailabilitySlots } from './availability.js'
     1import { servicePhotoMarkup, bindServicePhotoErrors } from './servicePhotos.js'
     2import { createAvailabilitySlots, serviceBlockedByStaff } from './availability.js'
    23import { submitBooking } from './api.js'
    34import { buildIcsDataUrl } from './calendar.js'
    … …  
    56import { confirmBookingPayment, getDepositPaymentSession, mountDepositPayment, showPaymentLoadError } from './payment.js'
    67import { getContrastText } from './theme.js'
    7 
    8 function panelSummary(service, slot) {
     8import { getWidgetCopy } from './copy.js'
     9import { DIAL_CODES, PHONE_COUNTRIES, normalizePhoneE164 } from './phoneNumber.js'
     10
     11function panelSummary(service, slot, copy, language) {
    912  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
    1215}
    1316
    14 function stepperMarkup(currentStep) {
     17function stepperMarkup(currentStep, copy) {
    1518  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]],
    2023  ]
    2124
    … …  
    2730      class="bw-booking-modal__stepper"
    2831      role="progressbar"
    29       aria-label="Booking progress"
     32      aria-label="${escapeHtml(copy.progressLabel)}"
    3033      aria-valuemin="1"
    3134      aria-valuemax="${steps.length}"
    3235      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)}"
    3437    >
    3538      <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>
    3740        <strong>${activeLabel}</strong>
    3841      </div>
    … …  
    4649}
    4750
    48 function serviceRowsMarkup(services, activeIndex) {
     51function serviceRowsMarkup(services, activeIndex, previewMode, copy, language) {
    4952  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>`
    5154  }
    5255
    … …  
    5659        <button type="button" class="bw-booking-modal__service" data-panel-service-index="${index}" data-active="${index === activeIndex}" aria-pressed="${index === activeIndex}">
    5760          <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>
    6066          </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>
    6268        </button>
    6369      `,
    … …  
    6672}
    6773
     74// crypto.randomUUID is secure-context only — plain-HTTP embeds (common for
     75// WordPress hosts) must fall back or the bar never mounts.
     76function 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
    6887export function createBookingBar(config, mountId, target = document.body) {
     88  const copy = getWidgetCopy(config.language)
     89  let clientRequestId = newClientRequestId()
     90  let terminalPaymentReplacementAvailable = false
     91  let answers = {}
    6992  const shell = document.createElement('div')
    7093  const spacer = document.createElement('div')
    … …  
    79102  let paymentController = null
    80103  let isSubmitting = false
    81   let form = { customerName: '', customerEmail: '', customerPhone: '', notes: '' }
     104  let form = { customerName: '', customerEmail: '', customerPhone: '', customerPhoneCountry: '', notes: '' }
    82105
    83106  shell.className = 'bw-booking-bar-shell'
    … …  
    149172  function timeMarkup(services, selectedService, slots) {
    150173    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>`
    152175    }
    153176
    154177    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}`
    159182    const slotMarkup = slots.length
    160183      ? slots
    … …  
    166189                  <em>${escapeHtml(slot.capacity)}</em>
    167190                </span>
    168                 <span>${index === selectedSlotIndex ? 'Selected' : 'Choose'}</span>
     191                <span>${escapeHtml(index === selectedSlotIndex ? copy.selected : copy.choose)}</span>
    169192              </button>
    170193            `,
    171194          )
    172195          .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>`
    174197
    175198    return `
    176199      <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)}">
    178201          <div class="bw-booking-modal__section-title">
    179             <strong>Services</strong>
     202            <strong>${escapeHtml(copy.servicesLabel)}</strong>
    180203            <em>${escapeHtml(serviceCountLabel)}</em>
    181204          </div>
    182205          <div class="bw-booking-modal__service-list">
    183             ${serviceRowsMarkup(services, selectedServiceIndex)}
     206            ${serviceRowsMarkup(services, selectedServiceIndex, config.previewMode, copy, config.language)}
    184207          </div>
    185208        </aside>
    186         <section class="bw-booking-modal__times" aria-label="Available times">
     209        <section class="bw-booking-modal__times" aria-label="${escapeHtml(copy.availableTimes)}">
    187210          <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>
    189212            <em>${escapeHtml(selectedService.name)}</em>
    190213          </div>
    … …  
    199222  function detailsMarkup(selectedService, selectedSlot) {
    200223    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
    203227
    204228    return `
    … …  
    207231          <div class="bw-booking-bar-panel__summary-card">
    208232            <strong>${escapeHtml(selectedService.name)}</strong>
    209             <span>${escapeHtml(panelSummary(selectedService, selectedSlot))}</span>
     233            <span>${escapeHtml(panelSummary(selectedService, selectedSlot, copy, config.language))}</span>
    210234          </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>` : ''}
    212237          <div class="bw-booking-bar-panel__form-row">
    213238            <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)}" />
    216241            </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            }
    217258            <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)}" />
    220261
    221262            </label>
    222263          </div>
    223264          <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)}" />
    226267          </label>
    227268          <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>
    230271          </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          }
    231302          ${message ? `<div class="bw-booking-bar-panel__message" role="status">${escapeHtml(message)}</div>` : ''}
    232303          <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>
    235306          </div>
    236307        </form>
    … …  
    241312  function paymentMarkup(selectedService, selectedSlot) {
    242313    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>
    248319      ${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>
    250321    </div>`
    251322  }
    … …  
    265336    const reference =
    266337      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(
    268339            String(confirmation.bookingId).slice(0, 8),
    269340          )}</code></span>`
    270341        : ''
    271342    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>`
    273344      : ''
    274345
    … …  
    277348        <div class="bw-booking-bar-panel__confirmed">
    278349          <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>
    280351          <span>${
    281352            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)
    284355          }</span>
    285356          ${reference}
    286357          ${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>
    288359        </div>
    289360      </div>
    … …  
    310381              <em>${escapeHtml(
    311382                bookingStep === 'details'
    312                   ? 'Enter your details'
     383                  ? copy.detailsHeading
    313384                  : bookingStep === 'confirmed'
    314                     ? 'Request received'
    315                     : 'Choose a time',
     385                    ? copy.successHeading
     386                    : copy.chooseTime,
    316387              )}</em>
    317388            </span>
    318389          </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>
    320391        </div>
    321         ${stepperMarkup(bookingStep)}
     392        ${stepperMarkup(bookingStep, copy)}
    322393        <div class="bw-booking-modal__body">${content}</div>
    323394        ${
    324395          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>`
    326397            : ''
    327398        }
    … …  
    330401            ? `<div class="bw-booking-modal__footer">
    331402                <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>
    334405                </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>
    336407              </div>`
    337408            : ''
    … …  
    346417    const services = config.services
    347418    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)
    349424    const selectedSlot = slots[selectedSlotIndex]
    350425    const canContinue = Boolean(selectedService && selectedSlot)
    351426    const invitation = config.loadError
    352       ? 'Booking setup unavailable'
     427      ? copy.loadError
    353428      : services.length > 1
    354       ? 'Choose a service and time'
     429      ? copy.selectServiceAndTime
    355430      : selectedService
    356         ? serviceSummary(selectedService)
    357         : 'No services available yet'
     431        ? serviceSummary(selectedService, config.language)
     432        : copy.emptyServices
    358433
    359434    shell.dataset.open = String(isOpen)
    … …  
    369444        </button>
    370445        <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)
    372447        }</button>
    373448      </div>
    … …  
    377452    panel.className = 'bw-booking-bar-panel bw-booking-bar-panel--booking'
    378453    panel.innerHTML = bookingMarkup(selectedService, selectedSlot, slots, canContinue)
     454    bindServicePhotoErrors(panel)
    379455
    380456    shell.querySelector('.bw-booking-bar__main')?.addEventListener('click', () => {
    … …  
    382458      bookingStep = 'time'
    383459      render()
     460      if (isOpen) panel.querySelector('.bw-booking-bar-panel__close')?.focus()
     461      else shell.querySelector('.bw-booking-bar__cta')?.focus()
    384462    })
    385463    shell.querySelector('.bw-booking-bar__cta')?.addEventListener('click', () => {
    … …  
    387465      bookingStep = 'time'
    388466      render()
     467      if (isOpen) panel.querySelector('.bw-booking-bar-panel__close')?.focus()
     468      else shell.querySelector('.bw-booking-bar__cta')?.focus()
    389469    })
    390470    panel.querySelector('.bw-booking-bar-panel__close')?.addEventListener('click', () => {
    … …  
    392472      bookingStep = 'time'
    393473      render()
     474      shell.querySelector('.bw-booking-bar__cta')?.focus()
    394475    })
    395476    panel.querySelectorAll('[data-panel-service-index]').forEach((button) => {
    … …  
    397478      button.addEventListener('click', () => {
    398479        selectedServiceIndex = Number(button.getAttribute('data-panel-service-index')) || 0
     480        clientRequestId = newClientRequestId()
     481        terminalPaymentReplacementAvailable = false
     482        answers = {}
    399483        selectedSlotIndex = 0
    400484        message = ''
    … …  
    407491      button.addEventListener('click', () => {
    408492        selectedSlotIndex = Number(button.getAttribute('data-slot-index')) || 0
     493        clientRequestId = newClientRequestId()
     494        terminalPaymentReplacementAvailable = false
    409495        message = ''
    410496        render()
    … …  
    423509    })
    424510    panel.querySelector('[data-bar-done]')?.addEventListener('click', () => {
     511      clientRequestId = newClientRequestId()
     512      terminalPaymentReplacementAvailable = false
     513      answers = {}
    425514      isOpen = false
    426515      bookingStep = 'time'
    … …  
    431520      const button = panel.querySelector('[data-pay-deposit]')
    432521      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) => {
    434523        if (!host.isConnected || paymentSession !== session) { controller.destroy(); return }
    435524        paymentController = controller
    … …  
    437526        panel.querySelector('[data-payment-status]').textContent = ''
    438527      }).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) }
    440529      })
    441530      button.addEventListener('click', async () => {
    … …  
    445534        const controller = paymentController
    446535        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)
    449542          confirmation = { bookingId: session.bookingId, serviceName: selectedService?.name, durationMinutes: selectedService?.durationMinutes, startsAt: selectedSlot?.startsAt }
    450543          paymentSession = null
    … …  
    452545          message = ''
    453546        } 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
    455549        } finally {
    456550          isSubmitting = false
    457           render()
     551          if (paymentSession === session) button.disabled = false
     552          else render()
    458553        }
    459554      })
    … …  
    462557      event.preventDefault()
    463558      if (isSubmitting) return
     559      if (Number(selectedService?.depositCents) > 0 && config.features?.deposits !== true) return
    464560      isSubmitting = true
    465561      const formData = new FormData(event.currentTarget)
    466562      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
    469576      render()
    470577      const bookedService = selectedService
    … …  
    472579
    473580      submitBooking(config, {
     581        answers,
     582        clientRequestId,
     583        replaceTerminalPayment: terminalPaymentReplacementAvailable || undefined,
     584        publicationRevision: config.publicationRevision,
     585        language: config.language || 'en',
    474586        customerEmail: formData.get('customerEmail'),
    475587        customerName: formData.get('customerName'),
    476588        customerPhone: formData.get('customerPhone'),
     589        customerPhoneCountry: form.customerPhoneCountry || undefined,
    477590        notes: formData.get('notes'),
    478591        serviceId: selectedService?.id || selectedService?.name,
    479592        serviceName: selectedService?.name,
     593        smsOptIn: config.sms?.available === true ? smsOptIn || undefined : undefined,
    480594        startsAt: selectedSlot?.startsAt,
     595        whatsappOptIn: config.whatsapp?.available === true ? whatsappOptIn || undefined : undefined,
    481596      })
    482597        .then((result) => {
    … …  
    484599            return
    485600          }
    486           const depositSession = getDepositPaymentSession(result)
     601          const depositSession = getDepositPaymentSession(result, config.language)
    487602          if (depositSession) {
     603            terminalPaymentReplacementAvailable = false
    488604            paymentSession = depositSession
    489605            message = ''
    … …  
    495611            durationMinutes: bookedService?.durationMinutes,
    496612            localOnly: result?.localOnly === true,
    497             serviceName: bookedService?.name || 'Appointment',
     613            serviceName: bookedService?.name || copy.appointment,
    498614            startsAt: bookedSlot?.startsAt,
    499615          }
    … …  
    503619        })
    504620        .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
    506623          bookingStep = 'details'
    507624          render()
  • bookminto/trunk/assets/src/bookingBarModalStyles.js

    r3723811 r3730301  
    194194}
    195195
     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
    196209.bw-booking-modal__service-body strong {
    197210  overflow: hidden;
  • bookminto/trunk/assets/src/bookingBarStyles.js

    r3723811 r3730301  
    224224
    225225@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
    226232  .bw-booking-bar-spacer {
    227233    height: 0;
  • bookminto/trunk/assets/src/bookingModal.js

    r3723811 r3730301  
    1 import { widgetCopy as copy } from './copy.js'
     1import { getWidgetCopy } from './copy.js'
    22import { createModalPanel } from './modalRender.js'
    33
    … …  
    1414 */
    1515export function createBookingModal(config, mountId, root, options = {}) {
     16  const copy = getWidgetCopy(config.language)
    1617  const triggerSelector =
    1718    typeof config.trigger === 'string'
    … …  
    2021        ? config.triggerSelector.trim()
    2122        : ''
     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  }
    2234  const buttonAlign = normalizeButtonAlign(config.buttonAlign || config.buttonPosition)
    2335  const openByDefault = Boolean(options.openByDefault)
    … …  
    6880  }
    6981
    70   if (!triggerSelector) {
     82  if (!safeTriggerSelector && !options.suppressCta) {
    7183    ctaWrap = document.createElement('div')
    7284    cta = document.createElement('button')
    … …  
    102114  function onExternalClick(event) {
    103115    const target = event.target
    104     if (!triggerSelector || !target || typeof target.closest !== 'function') {
     116    if (!safeTriggerSelector || !target || typeof target.closest !== 'function') {
    105117      return
    106118    }
    107119
    108     if (target.closest(triggerSelector)) {
     120    if (target.closest(safeTriggerSelector)) {
    109121      event.preventDefault()
    110122      setOpen(true)
    … …  
    114126  if (typeof document.addEventListener === 'function') {
    115127    document.addEventListener('keydown', onKeyDown)
    116     if (triggerSelector) {
     128    if (safeTriggerSelector) {
    117129      document.addEventListener('click', onExternalClick, true)
    118130    }
    … …  
    127139      if (typeof document.removeEventListener === 'function') {
    128140        document.removeEventListener('keydown', onKeyDown)
    129         if (triggerSelector) {
     141        if (safeTriggerSelector) {
    130142          document.removeEventListener('click', onExternalClick, true)
    131143        }
    … …  
    150162      }
    151163    },
     164    panel,
    152165    setOpen,
    153166  }
  • bookminto/trunk/assets/src/commerce.js

    r3723811 r3730301  
    174174  }
    175175
    176   const checkoutUrl = result?.data?.checkoutUrl || '/checkout/'
     176  const checkoutUrl = safeNavigationUrl(result?.data?.checkoutUrl) || '/checkout/'
    177177  window.location.assign(checkoutUrl)
    178178  return { redirected: true }
    179179}
     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.
     184function 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  
    22import { readAvailability, readExceptions } from './availability.js'
    33import { normalizeColor, normalizeTimeFormat, text } from './formatters.js'
     4import { getWidgetCopy } from './copy.js'
    45
    56const localApiUrl = 'http://127.0.0.1:5174/v1'
    … …  
    5758      }
    5859
    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      }
    6067    })
    6168    .filter(Boolean)
    … …  
    6471function checkoutServices(services, commerce) {
    6572  return commerce === 'shopify' || commerce === 'woocommerce' ? services.map((service) => ({ ...service, depositCents: 0, depositEuros: 0 })) : services
     73}
     74
     75export 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.
     83function 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.
     97function 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
     127function 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).
     151function 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)
    66184}
    67185
    … …  
    72190    apiUrl: runtimeApiUrl(config.apiUrl),
    73191    bookedSlots: Array.isArray(config.bookedSlots) ? config.bookedSlots : [],
     192    bookingHorizonDays: Number(config.bookingHorizonDays) || 0,
    74193    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,
    75202    commerce,
    76203    services: checkoutServices(config.services, commerce),
    … …  
    78205    position: text(config.position, 'bottom-right'),
    79206    showBranding: optionalBoolean(config.showBranding, false),
    80     title: text(config.title, 'Schedule a booking'),
     207    title: text(config.title, getWidgetCopy(config.language).title),
    81208    timeFormat: normalizeTimeFormat(config.timeFormat),
    82209    timezone: text(config.timezone, ''),
    … …  
    90217}
    91218
     219export 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
    92234export function readServices(value) {
    93235  if (!value) {
    … …  
    105247      .map((service) => ({
    106248        description: text(service.description, ''),
     249        imageUrl: safeServiceImageUrl(service.imageUrl),
    107250        durationMinutes: Number(service.durationMinutes) || 30,
    108251        id: text(service.id, ''),
    109252        name: text(service.name, ''),
    110         price: Number(service.price) || 0,
     253        price: readServicePrice(service),
    111254        depositCents: Math.max(0, Number(service.depositCents ?? Number(service.depositEuros || 0) * 100) || 0),
    112255        bufferAfterMinutes: Number(service.bufferAfterMinutes) || 0,
    … …  
    114257        useCustomAvailability: Boolean(service.useCustomAvailability),
    115258        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),
    116272      }))
    117273      .filter((service) => service.name)
    … …  
    146302    commerce: text(config.commerce, ''),
    147303    exceptions: readExceptions(config.exceptions),
     304    // Extension A: `data-next-times` renders the compact upcoming-times strip.
     305    nextTimes: optionalBoolean(config.nextTimes, false),
    148306    position: text(config.position, ''),
     307    proposalSlug: text(config.proposalSlug, ''),
    149308    service: serviceFilter,
    150309    services: filterServicesById(
    … …  
    152311      serviceFilter,
    153312    ),
     313    staff: readStaffList(config.staff),
     314    staffAvailability: readStaffAvailability(config.staffAvailability),
     315    staffDisplayMode:
     316      text(config.staffDisplayMode, '') === 'customer_choice' ? 'customer_choice' : 'hidden',
    154317    shopifyProduct: text(config.shopifyProduct, ''),
    155318    shopifyShop: text(
    … …  
    164327    wooCartNonce: text(config.wooCartNonce || config.wooCartnonce, ''),
    165328    showBranding: optionalBoolean(config.showBranding, false),
     329    hostedPage: optionalBoolean(config.hostedPage, false),
    166330    title: text(config.title, ''),
    167331    timeFormat: text(config.timeFormat, ''),
    … …  
    204368              bufferBeforeMinutes: Number(service.bufferBeforeMinutes) || 0,
    205369              description: text(service.description, ''),
     370              imageUrl: safeServiceImageUrl(service.imageUrl),
    206371              durationMinutes: Number(service.durationMinutes) || 30,
    207372              id: text(service.id, ''),
    208373              name: text(service.name, ''),
    209               price: Number(service.price) || 0,
     374              price: readServicePrice(service),
    210375              depositCents: Math.max(0, Number(service.depositCents ?? Number(service.depositEuros || 0) * 100) || 0),
    211376              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),
    212393            }))
    213394            .filter((service) => service.name)
    … …  
    222403
    223404      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.
    226408      const variant =
    227         commerce === 'shopify' || commerce === 'woocommerce'
     409        commerce === 'shopify' || commerce === 'woocommerce' || config.hostedPage === true
    228410          ? 'inline'
    229411          : text(remoteConfig.variant || config.variant, 'inline')
     412      const language = normalizeLanguage(remoteConfig.language || config.language)
    230413
    231414      return {
    … …  
    233416        apiUrl: runtimeApiUrl(config.apiUrl),
    234417        bookedSlots: readBookedSlots(remoteConfig.bookedSlots),
     418        calendarVerification: text(remoteConfig.calendarVerification, 'none'),
     419        bookingHorizonDays: Number(remoteConfig.bookingHorizonDays) || 0,
    235420        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,
    236431        commerce,
    237432        exceptions: remoteExceptions,
    238433        position: text(remoteConfig.position || config.position, 'bottom-right'),
     434        proposalSlug: text(config.proposalSlug, ''),
    239435        service: text(config.service, ''),
    240436        services: checkoutServices(services, commerce),
    … …  
    247443        // Public credits are controlled locally. Remote SaaS configuration cannot enable them.
    248444        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),
    250453        timeFormat: normalizeTimeFormat(remoteConfig.timeFormat || config.timeFormat),
    251454        timezone: text(remoteConfig.timezone || config.timezone, ''),
    … …  
    259462        ),
    260463        variant,
     464        whatsapp: readChannelOptin(remoteConfig.whatsapp),
    261465        workspace: config.workspace,
    262466      }
    … …  
    271475
    272476export { 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.
     481function 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
     497export 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  
    11export const DATE_WINDOW_SIZE = 5
    22
    3 export const widgetCopy = {
     3const englishCopy = {
    44  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',
    524  widgetStepLabels: ['Service', 'Date', 'Details', 'Confirmed'],
    625  chooseService: 'Choose an offering',
    … …  
    2746  checkoutCta: 'Continue to checkout',
    2847  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.',
    2950  payDepositCta: 'Pay deposit',
    3051  depositHeading: 'Pay deposit to confirm',
     52  depositLabel: 'deposit',
    3153  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.',
    3255  processing: 'Processing...',
    3356  redirectingToCheckout: 'Redirecting to checkout…',
    … …  
    4265  loading: 'Loading…',
    4366  genericError: 'Something went wrong. Please try again.',
     67  closedAttempt: 'This booking attempt is closed. Choose a new time to start again.',
    4468  loadError: 'Booking setup could not be loaded.',
    4569  previewOnlyNote: 'Preview only — no booking was saved and no email was sent.',
    … …  
    4771  testModeNote:
    4872    '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
     106const 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
     208const 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
     310const 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
     413const 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
     516const 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
     619const dictionaries = {
     620  de: germanCopy,
     621  en: englishCopy,
     622  es: spanishCopy,
     623  fr: frenchCopy,
     624  it: italianCopy,
     625  nl: dutchCopy,
     626}
     627
     628export 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.
     635export function getWidgetCopy(value) {
     636  return dictionaries[normalizeWidgetLanguage(value)]
     637}
     638
     639const 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
     648export function widgetLocale(value) {
     649  return widgetLocales[normalizeWidgetLanguage(value)] || 'en-US'
     650}
  • bookminto/trunk/assets/src/floatingStyles.js

    r3723811 r3730301  
    299299}
    300300
     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
    301310.service-info {
     311  min-width: 0;
    302312  flex: 1;
    303313  text-align: left;
    … …  
    454464}
    455465
     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
    456533.form-field {
    457534  margin-bottom: 0;
    … …  
    489566  min-height: 74px;
    490567  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;
    491585}
    492586
  • bookminto/trunk/assets/src/formatters.js

    r3723811 r3730301  
    1414}
    1515
    16 export function formatDuration(minutes) {
     16const durationHourUnits = {
     17  de: 'Std.',
     18  en: 'hr',
     19  es: 'h',
     20  fr: 'h',
     21  it: 'h',
     22  nl: 'u',
     23}
     24
     25const freePriceLabels = {
     26  de: 'Kostenlos',
     27  en: 'Free',
     28  es: 'Gratis',
     29  fr: 'Gratuit',
     30  it: 'Gratuito',
     31  nl: 'Gratis',
     32}
     33
     34const 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
     43const 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
     52const 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
     61export function formatDuration(minutes, language = 'en') {
    1762  const safeMinutes = Number.isFinite(Number(minutes)) ? Number(minutes) : 30
    1863
    … …  
    2469  const remainder = safeMinutes % 60
    2570
    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`
    2773}
    2874
    29 export function formatPrice(price) {
     75export function formatPrice(price, currency = 'EUR', language = 'en') {
    3076  const safePrice = Number(price)
    3177
    3278  if (!Number.isFinite(safePrice) || safePrice === 0) {
    33     return 'Free'
     79    return freePriceLabels[language] || 'Free'
    3480  }
    3581
    36   return new Intl.NumberFormat('en-US', {
    37     currency: 'EUR',
     82  return new Intl.NumberFormat(priceLocales[language] || 'en-US', {
     83    currency,
    3884    maximumFractionDigits: 2,
    3985    minimumFractionDigits: Number.isInteger(safePrice) ? 0 : 2,
    … …  
    4288}
    4389
    44 export function serviceDetail(service) {
     90export function serviceDetail(service, language = 'en') {
    4591  const description = text(service && service.description, '')
    4692
    4793  return description
    48     ? `${formatDuration(service.durationMinutes)} · ${description}`
    49     : formatDuration(service.durationMinutes)
     94    ? `${formatDuration(service.durationMinutes, language)} · ${description}`
     95    : formatDuration(service.durationMinutes, language)
    5096}
    5197
    … …  
    59105}
    60106
    61 export function serviceSummary(service) {
     107export function serviceSummary(service, language = 'en') {
    62108  if (!service) {
    63109    return 'No services available yet'
    64110  }
    65111
    66   return `${formatDuration(service.durationMinutes)} · ${servicePriceSummary(service)}`
     112  return `${formatDuration(service.durationMinutes, language)} · ${servicePriceSummary(service, language)}`
    67113}
    68114
    69 export function servicePriceSummary(service) {
    70   const price = formatPrice(service?.price)
     115export function servicePriceSummary(service, language = 'en') {
     116  const price = formatPrice(service?.price, service?.currency || 'EUR', language)
    71117  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
    73120}
    74121
    75122export 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]
    77125}
  • bookminto/trunk/assets/src/modalRender.js

    r3723811 r3730301  
    1 import { createDatedAvailabilitySlots } from './availability.js'
    2 import { submitBooking } from './api.js'
     1import { servicePhotoMarkup, bindServicePhotoErrors } from './servicePhotos.js'
     2import { createDatedAvailabilitySlots, getSlotDateKey, qualifiedStaffForService, serviceBlockedByStaff } from './availability.js'
     3import { submitBooking, submitWaitlistEntry } from './api.js'
    34import { buildIcsDataUrl } from './calendar.js'
    4 import { DATE_WINDOW_SIZE, widgetCopy as copy } from './copy.js'
    5 import { escapeHtml, formatDuration, formatPrice, servicePriceSummary, timezoneLabel } from './formatters.js'
     5import { DATE_WINDOW_SIZE, getWidgetCopy, widgetLocale } from './copy.js'
     6import { escapeHtml, formatDuration, formatPrice, servicePriceSummary, text, timezoneLabel } from './formatters.js'
    67import { confirmBookingPayment, getDepositPaymentSession, mountDepositPayment, showPaymentLoadError } from './payment.js'
    78import { scheduleRevealContinue } from './scroll.js'
    … …  
    1920  '<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>'
    2021
    21 const modalStepLabels = ['Service', 'Date & time', 'Details', 'Confirmed']
    22 
    23 function formatDateBadge(dateKey) {
     22
     23function formatDateBadge(dateKey, locale) {
    2424  const value = new Date(`${dateKey}T12:00:00`)
    2525  return {
    2626    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('.', ''),
    2929  }
    3030}
    3131
    32 function formatSummaryDate(dateKey) {
     32function staffFirstName(name) {
     33  return text(name, '').split(/\s+/).filter(Boolean)[0] || ''
     34}
     35
     36function 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
     46function staffChipColor(value) {
     47  const color = text(value, '')
     48  return /^#[0-9a-fA-F]{6}$/.test(color) ? color : ''
     49}
     50
     51function 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
     59function formatSummaryDate(dateKey, locale) {
    3360  if (!dateKey) return ''
    34   return new Intl.DateTimeFormat('en-US', {
     61  return new Intl.DateTimeFormat(locale || 'en-US', {
    3562    weekday: 'short',
    3663    day: 'numeric',
    … …  
    3966}
    4067
    41 function formatVisibleDateRange(dates) {
     68function formatVisibleDateRange(dates, locale) {
    4269  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' })
    4471  return `${formatter.format(new Date(`${dates[0]}T12:00:00`))} - ${formatter.format(
    4572    new Date(`${dates[dates.length - 1]}T12:00:00`),
    … …  
    4774}
    4875
    49 function stepperMarkup(step) {
     76function stepperMarkup(step, copy) {
     77  const modalStepLabels = copy.widgetStepLabels
    5078  const currentStep = Math.min(step + 1, modalStepLabels.length)
    5179  const activeLabel = modalStepLabels[currentStep - 1]
    … …  
    5583      class="stepper"
    5684      role="progressbar"
    57       aria-label="Booking progress"
     85      aria-label="${escapeHtml(copy.progressLabel)}"
    5886      aria-valuemin="1"
    5987      aria-valuemax="${modalStepLabels.length}"
    6088      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)}"
    6290    >
    6391      <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>
    6593        <strong>${escapeHtml(activeLabel)}</strong>
    6694      </div>
    … …  
    78106 */
    79107export function createModalPanel(config) {
     108  const copy = getWidgetCopy(config.language)
     109  const locale = widgetLocale(config.language)
    80110  // <dialog> + open attribute often fails to paint inside preview shadow roots.
    81111  // Use a plain overlay div in preview so Appearance always shows the modal.
    … …  
    93123  let selectedDate = null
    94124  let selectedSlotId = null
     125  let selectedStaffId = null
    95126  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    : ''
    97130  let isSubmitting = false
    98131  let confirmation = null
    99132  let paymentSession = null
    100133  let paymentController = null
     134  let clientRequestId = newClientRequestId()
     135  let terminalPaymentReplacementAvailable = false
    101136  let form = {
     137    answers: {},
    102138    customerEmail: '',
    103139    customerName: '',
    104140    customerPhone: '',
    105141    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)}`
    106160  }
    107161
    … …  
    114168  }
    115169
     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
    116200  function datedSlots() {
    117     return createDatedAvailabilitySlots(config, selectedService())
     201    return createDatedAvailabilitySlots(config, selectedService(), pinnedStaffId())
    118202  }
    119203
    … …  
    130214    if (step === 0) return Boolean(selectedService())
    131215    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    }
    133225    return false
    134226  }
    … …  
    138230    if (step === 0) return copy.continueFromService
    139231    if (paymentSession) return copy.payDepositCta
    140     if (step === 2) return copy.confirmCta
     232    if (step === 2) return terminalPaymentReplacementAvailable ? copy.replacePaymentCta : copy.confirmCta
    141233    return copy.continueDefault
    142234  }
    143235
    144236  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(
    146238      (Number(cents) || 0) / 100,
    147239    )
    … …  
    152244  }
    153245
     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
    154305  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    }
    155312    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
    160330    }
    161331  }
    … …  
    165335    selectedDate = null
    166336    selectedSlotId = null
     337    selectedStaffId = null
    167338    datePageStart = 0
    168339    message = ''
    … …  
    174345      paymentController = null
    175346    }
    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 }
    177354    render()
    178355  }
    … …  
    207384            return `
    208385              <button type="button" class="svc${selected}" data-service-id="${escapeHtml(id)}">
     386                ${servicePhotoMarkup(item.imageUrl, config.previewMode)}
    209387                <div class="svc-info">
    210388                  <div class="svc-name">${escapeHtml(item.name)}</div><div class="service-price">${escapeHtml(servicePriceSummary(item))}</div>
    … …  
    235413        selectedDate && slot
    236414          ? `<div class="sumbox sumbox--compact">
    237               <div class="sumrow">${escapeHtml(formatSummaryDate(selectedDate))}</div>
     415              <div class="sumrow">${escapeHtml(formatSummaryDate(selectedDate, locale))}</div>
    238416              <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              }
    239422            </div>`
    240423          : ''
    241424      }
     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>
    242508    `
    243509  }
    … …  
    250516          <div class="modal-intro-copy">${escapeHtml(copy.depositNote)}</div>
    251517          <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>
    253519          </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>
    255521          ${message ? `<div class="runtime-state runtime-state--danger">${escapeHtml(message)}</div>` : ''}
    256522        `,
    … …  
    271537
    272538    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
    273549      const dates = visibleDates(availableDates)
    274550      const daySlots = selectedDate ? slotsByDate.get(selectedDate) || [] : []
    … …  
    278554        `
    279555          <button class="back-btn" type="button" data-action="back">← ${escapeHtml(copy.back)}</button>
     556          ${staffPickerMarkup()}
    280557          <span class="slabel">${escapeHtml(copy.chooseDate)}</span><p class="booking-timezone">${escapeHtml(timezoneLabel(config))}</p>
    281558          <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>
    283560            <div class="date-nav-buttons">
    284561              <button type="button" class="date-nav-btn" data-action="prev-dates" ${canPrev ? '' : 'disabled'}>${chevronLeftIcon}</button>
    … …  
    291568                ? dates
    292569                    .map((dateKey) => {
    293                       const badge = formatDateBadge(dateKey)
     570                      const badge = formatDateBadge(dateKey, locale)
    294571                      const selected = dateKey === selectedDate ? ' sel' : ''
    295572                      return `
    … …  
    315592                          .map((item) => {
    316593                            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>`
    318596                          })
    319597                          .join('')}
    … …  
    324602              : ''
    325603          }
     604          ${!dates.length || !daySlots.length ? waitlistMarkup() : ''}
     605          ${message ? `<div class="runtime-state runtime-state--danger">${escapeHtml(message)}</div>` : ''}
    326606        `,
    327607        continueButton(slotsByDate),
    … …  
    334614          <button class="back-btn" type="button" data-action="back">← ${escapeHtml(copy.back)}</button>
    335615          <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>` : ''}
    336617          <div class="form-field">
    337618            <label class="flabel" for="bm-modal-name">${escapeHtml(copy.nameLabel)}</label>
    … …  
    352633            <textarea class="finput finput--textarea" id="bm-modal-notes" name="notes" placeholder="${escapeHtml(copy.notesPlaceholder)}">${escapeHtml(form.notes)}</textarea>
    353634          </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          }
    354644          ${message ? `<div class="runtime-state runtime-state--danger">${escapeHtml(message)}</div>` : ''}
    355645        `,
    … …  
    429719        <div class="topbar">
    430720          <span class="topbar-title">${escapeHtml(config.title || copy.title)}</span>
    431           ${stepperMarkup(activeStep)}
     721          ${stepperMarkup(activeStep, copy)}
    432722          <button class="close-btn" type="button" data-action="close" aria-label="${escapeHtml(copy.close)}">${closeIcon}</button>
    433723        </div>
    … …  
    458748          accent: config.color,
    459749          clientSecret: paymentSession.clientSecret,
     750          language: config.language,
    460751          publishableKey: paymentSession.publishableKey,
    461752        })
    … …  
    466757          })
    467758          .catch((error) => {
    468             if (host.isConnected && paymentSession === session) showPaymentLoadError(host, error, render)
     759            if (host.isConnected && paymentSession === session) showPaymentLoadError(host, error, render, config.language)
    469760          })
    470761      }
    … …  
    473764
    474765  function bindEvents(availableDates, slotsByDate) {
     766    bindServicePhotoErrors(dialog)
    475767    dialog.querySelectorAll('[data-service-id]').forEach((button) => {
    476768      button.addEventListener('click', () => {
    … …  
    478770        selectedDate = null
    479771        selectedSlotId = null
     772        selectedStaffId = null
    480773        datePageStart = 0
    481774        message = ''
    482775        render()
    483776      })
     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()
    484818    })
    485819
    … …  
    546880    })
    547881
     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
    548936    dialog.querySelector('[data-action="continue"]')?.addEventListener('click', () => {
    549937      syncFormFromDom()
    … …  
    583971    if (cta) { cta.disabled = true; cta.textContent = copy.processing }
    584972    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, {
    587977        bookingId: session.bookingId,
    588978        paymentIntentId,
    589979      })
     980      if (paymentSession !== session || paymentController !== controller) return
     981      if (result.processing || !(result.ok || result.alreadyConfirmed)) throw new Error(copy.paymentError)
    590982      confirmation = { bookingId: session.bookingId, id: session.bookingId }
    591983      paymentSession = null
    592984      step = 3
    593985    } catch (error) {
     986      if (paymentSession !== session || paymentController !== controller) return
    594987      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
    595996    } 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() }
    5981001    }
    5991002  }
    … …  
    6021005    const service = selectedService()
    6031006    const slot = selectedSlot(slotsByDate)
    604     if (!service || !slot) return
     1007    if (!service || !slot || isSubmitting) return
     1008    if (!canContinue(slotsByDate)) return
    6051009
    6061010    isSubmitting = true
    … …  
    6101014    try {
    6111015      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,
    6121020        customerEmail: form.customerEmail.trim(),
    6131021        customerName: form.customerName.trim(),
    6141022        customerPhone: form.customerPhone.trim(),
     1023        language: config.language || 'en',
    6151024        notes: form.notes.trim(),
     1025        rebookingOptIn: form.rebookingOptIn || undefined,
    6161026        serviceId: service.id || service.name,
    6171027        serviceName: service.name,
     1028        staffId: pinnedStaffId(),
    6181029        startsAt: slot.startsAt,
    6191030      })
    … …  
    6231034      }
    6241035
    625       const depositSession = getDepositPaymentSession(result)
     1036      const depositSession = getDepositPaymentSession(result, config.language)
    6261037      if (depositSession) {
     1038        terminalPaymentReplacementAvailable = false
    6271039        paymentSession = depositSession
    6281040      } else {
    … …  
    6311043      }
    6321044    } 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      }
    6341055    } finally {
    6351056      isSubmitting = false
    … …  
    6441065    dialog.dispatchEvent(new CustomEvent('booking-widget-close', { bubbles: true }))
    6451066  })
     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  }
    6461101
    6471102  dialog.resetFlow = resetFlow
  • bookminto/trunk/assets/src/mount.js

    r3723811 r3730301  
    22import { createBookingBar } from './bookingBar.js'
    33import { createBookingModal } from './bookingModal.js'
    4 import { widgetCopy as copy } from './copy.js'
     4import { getWidgetCopy } from './copy.js'
    55import { ensureFloatingPopoverChrome } from './floatingPopoverStyles.js'
    66import { elevateBarHost, elevateOverlayHost, ensureHostOnTop, ensureOverlayHostStyles } from './overlayHost.js'
    77import { escapeHtml as escapeLauncherLabel } from './formatters.js'
     8import { createNextTimes } from './nextTimes.js'
    89import { createWidget } from './render.js'
    910import { injectStyles } from './styles.js'
    … …  
    8788
    8889export function renderMountedWidget(element, config) {
     90  const copy = getWidgetCopy(config.language)
    8991  const mountId =
    9092    element.dataset.bookingWidgetMountId ||
    … …  
    9294  element.dataset.bookingWidgetMountId = mountId
    9395
    94   findWidgetOwners(mountId).forEach((node) => node.remove())
     96  findWidgetOwners(mountId).forEach((node) => {
     97    node.__bookingWidgetDestroy?.()
     98    node.remove()
     99  })
    95100
    96101  element
    97102    .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    })
    99107
    100108  // Product-page commerce must never inherit published modal/floating chrome.
    101109  if (config.commerce === 'shopify' || config.commerce === 'woocommerce') {
    102110    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    }
    103157  }
    104158
    … …  
    116170    let open = false
    117171    let closeTimer = 0
     172    let released = false
    118173
    119174    ensureFloatingPopoverChrome()
    … …  
    144199
    145200    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      }
    146206      open = isOpen
    147207      setClassToken(launcher, 'expanded', isOpen)
    … …  
    213273
    214274    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      }
    215284      if (event.key === 'Escape' && open) {
    216285        setOpen(false)
    … …  
    220289    if (typeof document.addEventListener === 'function') {
    221290      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      }
    222301    }
    223302
    … …  
    287366    }
    288367
    289     createBookingModal(config, mountId, root)
     368    const modal = createBookingModal(config, mountId, root)
     369    host.__bookingWidgetDestroy = modal.destroy
    290370    return host
    291371  }
    … …  
    312392  if (config.workspace) {
    313393    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
    314401      if (shouldSkipAutoInlineWidget(element, remoteConfig)) {
    315402        return
    … …  
    366453    .forEach((element) => {
    367454      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'
    368461        const placeholder = document.createElement('div')
    369462        placeholder.setAttribute('data-booking-widget', '')
  • bookminto/trunk/assets/src/payment.js

    r3723811 r3730301  
    1 export function getDepositPaymentSession(result) {
     1import { getWidgetCopy } from './copy.js'
     2
     3export function getDepositPaymentSession(result, language) {
     4  const copy = getWidgetCopy(language)
    25  const payment = result?.payment || result
    36  if (!result?.needsPayment && !result?.payment) return null
    47  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)
    69  }
    710  return {
    … …  
    1316}
    1417
    15 export function showPaymentLoadError(host, error, onRetry) {
     18export function showPaymentLoadError(host, error, onRetry, language) {
     19  const copy = getWidgetCopy(language)
    1620  const message = document.createElement('p')
    1721  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
    1923  const retry = document.createElement('button')
    2024  retry.type = 'button'
    2125  retry.className = 'back-btn'
    22   retry.textContent = 'Try again'
     26  retry.textContent = copy.retryCta
    2327  retry.addEventListener('click', onRetry)
    2428  host.replaceChildren(message, retry)
    … …  
    5357
    5458export async function mountDepositPayment(host, input) {
     59  const copy = getWidgetCopy(input.language)
    5560  const StripeFactory = await loadStripeJs()
    5661  const stripe = StripeFactory(input.publishableKey)
    … …  
    6570    },
    6671    clientSecret: input.clientSecret,
     72    locale: input.language || undefined,
    6773  })
    6874  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  }
    70151
    71152  return {
    … …  
    80161
    81162      if (result.error) {
    82         throw new Error(result.error.message || 'Payment failed')
     163        throw new Error(result.error.message || copy.paymentError)
    83164      }
    84165
    85166      const intent = result.paymentIntent
    86167      if (!intent || intent.status !== 'succeeded') {
    87         throw new Error('Payment is not completed yet')
     168        throw new Error(copy.paymentError)
    88169      }
    89170
    … …  
    96177        // ignore
    97178      }
     179      cleanupSlotted()
    98180    },
    99181  }
    … …  
    113195  if (!response.ok) {
    114196    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)
    116198  }
    117199
  • bookminto/trunk/assets/src/previewMount.js

    r3723811 r3730301  
    11import { createBookingBar } from './bookingBar.js'
    22import { createBookingModal } from './bookingModal.js'
    3 import { widgetCopy as copy } from './copy.js'
     3import { getWidgetCopy } from './copy.js'
    44import { createWidget } from './render.js'
    55import { injectStyles } from './styles.js'
    … …  
    309309 */
    310310export function mountPreviewWidget(container, config, options = {}) {
     311  const copy = getWidgetCopy(config.language)
    311312  if (!container) {
    312313    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'
     1import { servicePhotoMarkup, bindServicePhotoErrors } from './servicePhotos.js'
     2import { createDatedAvailabilitySlots, qualifiedStaffForService, serviceBlockedByStaff } from './availability.js'
     3import { isCommerceCheckout, isShopifyCommerce, isWooCommerceCommerce, submitBooking, submitWaitlistEntry } from './api.js'
    34import { buildIcsDataUrl } from './calendar.js'
    4 import { DATE_WINDOW_SIZE, widgetCopy as copy } from './copy.js'
    5 import { escapeHtml, formatDuration, formatPrice, servicePriceSummary, timezoneLabel } from './formatters.js'
     5import { DATE_WINDOW_SIZE, getWidgetCopy, widgetLocale } from './copy.js'
     6import { escapeHtml, formatDuration, formatPrice, servicePriceSummary, text, timezoneLabel } from './formatters.js'
    67import { confirmBookingPayment, getDepositPaymentSession, mountDepositPayment, showPaymentLoadError } from './payment.js'
    78import { scheduleRevealContinue } from './scroll.js'
    89import { applyThemeProperties } from './theme.js'
     10import { DIAL_CODES, PHONE_COUNTRIES, normalizePhoneE164 } from './phoneNumber.js'
    911
    1012const SHOPIFY_HOLD_NAME = 'Shopify checkout'
    … …  
    2224  '<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>'
    2325
    24 function formatDateBadge(dateKey) {
     26function formatDateBadge(dateKey, locale) {
    2527  const value = new Date(`${dateKey}T12:00:00`)
    2628  return {
    2729    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('.', ''),
    3032  }
    3133}
    3234
    33 function formatSummaryDate(dateKey) {
     35function staffFirstName(name) {
     36  return text(name, '').split(/\s+/).filter(Boolean)[0] || ''
     37}
     38
     39function 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
     49function staffChipColor(value) {
     50  const color = text(value, '')
     51  return /^#[0-9a-fA-F]{6}$/.test(color) ? color : ''
     52}
     53
     54function 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
     62function formatSummaryDate(dateKey, locale) {
    3463  if (!dateKey) {
    3564    return ''
    3665  }
    3766
    38   return new Intl.DateTimeFormat('en-US', {
     67  return new Intl.DateTimeFormat(locale || 'en-US', {
    3968    weekday: 'short',
    4069    day: 'numeric',
    … …  
    4372}
    4473
    45 function formatVisibleDateRange(dates) {
     74function formatVisibleDateRange(dates, locale) {
    4675  if (!dates.length) {
    4776    return ''
    4877  }
    4978
    50   const formatter = new Intl.DateTimeFormat('en-US', {
     79  const formatter = new Intl.DateTimeFormat(locale || 'en-US', {
    5180    day: 'numeric',
    5281    month: 'short',
    … …  
    5887}
    5988
    60 function stepperMarkup(step) {
     89function stepperMarkup(step, copy) {
    6190  const currentStep = Math.min(step + 1, copy.widgetStepLabels.length)
    6291  const activeLabel = copy.widgetStepLabels[currentStep - 1]
    … …  
    6695      class="stepper"
    6796      role="progressbar"
    68       aria-label="Booking progress"
     97      aria-label="${escapeHtml(copy.progressLabel)}"
    6998      aria-valuemin="1"
    7099      aria-valuemax="${copy.widgetStepLabels.length}"
    71100      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)}"
    73102    >
    74103      <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>
    76105        <strong>${escapeHtml(activeLabel)}</strong>
    77106      </div>
    … …  
    101130
    102131export function createWidget(config) {
     132  const copy = getWidgetCopy(config.language)
     133  const locale = widgetLocale(config.language)
    103134  const widget = document.createElement('section')
    104135  const isFloating = config.variant === 'floating'
    105136  const isOverlay = isFloating
    106137  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
    108141  let step = skipServiceStep ? 1 : 0
    109142  let selectedServiceId = config.services[0]?.id || config.services[0]?.name || ''
    110143  let selectedDate = null
    111144  let selectedSlotId = null
     145  let selectedStaffId = null
    112146  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    : ''
    114150  let messageTone = 'error'
    115151  let isSubmitting = false
    … …  
    117153  let paymentSession = null
    118154  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
    119159  let form = {
     160    answers: {},
    120161    customerEmail: '',
    121162    customerName: '',
    122163    customerPhone: '',
     164    customerPhoneCountry: '',
    123165    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)}`
    124186  }
    125187
    … …  
    140202  }
    141203
     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
    142234  function datedSlots() {
    143     return createDatedAvailabilitySlots(config, selectedService())
     235    return createDatedAvailabilitySlots(config, selectedService(), pinnedStaffId())
    144236  }
    145237
    … …  
    166258
    167259    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      )
    169277    }
    170278
    … …  
    190298
    191299    if (step === 2) {
     300      if (terminalPaymentReplacementAvailable) return copy.replacePaymentCta
    192301      return copy.confirmCta
    193302    }
    … …  
    198307  function formatDeposit(cents) {
    199308    const amount = (Number(cents) || 0) / 100
    200     return new Intl.NumberFormat('en-US', {
     309    return new Intl.NumberFormat(locale, {
    201310      style: 'currency',
    202311      currency: 'EUR',
    … …  
    210319        <div class="confirm-sub" style="text-align:left;margin-bottom:12px">${escapeHtml(copy.depositNote)}</div>
    211320        <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>
    213322        </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>
    215324      </div>
    216325    `
    … …  
    223332
    224333    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>`
    226335  }
    227336
    … …  
    245354        return `
    246355          <button type="button" class="service-card${selected}" data-service-id="${escapeHtml(id)}">
     356            ${servicePhotoMarkup(service.imageUrl, config.previewMode)}
    247357            <div class="service-info">
    248358              <div class="service-name">${escapeHtml(service.name)}</div><div class="service-price">${escapeHtml(servicePriceSummary(service))}</div>
    … …  
    256366  }
    257367
     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
    258455  function dateStep(availableDates, slotsByDate) {
    259456    const dates = visibleDates(availableDates)
    … …  
    261458    const canPrev = datePageStart > 0
    262459    const canNext = datePageStart + DATE_WINDOW_SIZE < availableDates.length
     460    const service = selectedService()
    263461    const backBtn =
    264462      skipServiceStep
    … …  
    266464        : `<button class="back-btn" type="button" data-action="back">← ${escapeHtml(copy.back)}</button>`
    267465
     466    if (serviceBlockedByStaff(config, service)) {
     467      return `
     468        ${backBtn}
     469        ${runtimeState(copy.serviceNotBookable, 'danger')}
     470      `
     471    }
     472
    268473    return `
    269474      ${backBtn}
     475      ${staffPickerMarkup()}
    270476      <p class="section-title">${escapeHtml(copy.chooseDate)}</p><p class="booking-timezone">${escapeHtml(timezoneLabel(config))}</p>
    271477      <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>
    273479        <div class="date-nav-buttons">
    274480          <button type="button" class="date-nav-btn" data-action="prev-dates" aria-label="${escapeHtml(copy.previousDates)}" ${canPrev ? '' : 'disabled'}>
    … …  
    285491            ? dates
    286492                .map((dateKey) => {
    287                   const badge = formatDateBadge(dateKey)
     493                  const badge = formatDateBadge(dateKey, locale)
    288494                  const selected = dateKey === selectedDate ? ' selected' : ''
    289495                  return `
    … …  
    309515                      .map((slot) => {
    310516                        const selected = slot.id === selectedSlotId ? ' selected' : ''
     517                        const recommended = slot.recommended ? ' recommended' : ''
    311518                        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)}">
    313520                            ${escapeHtml(slot.timeLabel)}
     521                            ${slot.recommended ? `<em class="rec-badge">${escapeHtml(copy.recommendedBadge)}</em>` : ''}
    314522                          </button>
    315523                        `
    … …  
    322530          : ''
    323531      }
     532      ${!dates.length || !daySlots.length ? waitlistMarkup(service, daySlots) : ''}
    324533    `
    325534  }
    … …  
    332541        <div class="summary-box">
    333542          <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>
    335544          </div>
    336545          <div class="summary-row">
    337546            <span>${escapeHtml(service?.name || '')} · ${escapeHtml(formatDuration(service?.durationMinutes))}</span>
    338547          </div>
     548          ${
     549            selectedStaffName()
     550              ? `<div class="summary-row"><span>${escapeHtml(copy.withStaff.replace('{name}', selectedStaffName()))}</span></div>`
     551              : ''
     552          }
    339553          ${
    340554            price !== 'Free'
    … …  
    349563            <input class="form-input" id="bm-runtime-customerName" name="customerName" autocomplete="name" required placeholder="${escapeHtml(copy.namePlaceholder)}" value="${escapeHtml(form.customerName)}" />
    350564          </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          }
    351581          <div class="form-field">
    352582            <label class="form-label" for="bm-runtime-customerPhone">${escapeHtml(copy.phoneLabel)}</label>
    … …  
    362592          <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>
    363593        </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        }
    364630      </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('')}
    365690    `
    366691  }
    … …  
    401726        <div class="summary-box">
    402727          <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>
    404729          <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          }
    405735          ${
    406736            price !== 'Free'
    … …  
    419749    const emailInput = widget.querySelector('[name="customerEmail"]')
    420750    const phoneInput = widget.querySelector('[name="customerPhone"]')
     751    const phoneCountryInput = widget.querySelector('[name="customerPhoneCountry"]')
    421752    const notesInput = widget.querySelector('[name="notes"]')
    422753
    … …  
    424755    if (emailInput) form.customerEmail = emailInput.value
    425756    if (phoneInput) form.customerPhone = phoneInput.value
     757    if (phoneCountryInput) form.customerPhoneCountry = phoneCountryInput.value
    426758    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    }
    427779  }
    428780
    … …  
    437789    selectedDate = null
    438790    selectedSlotId = null
     791    selectedStaffId = null
    439792    datePageStart = 0
    440793    message = ''
    … …  
    442795    isSubmitting = false
    443796    confirmation = null
     797    clientRequestId = newClientRequestId()
     798    terminalPaymentReplacementAvailable = false
     799    waitlistOpen = false
     800    waitlistBusy = false
     801    waitlistJoined = false
     802    waitlistForm = { customerEmail: '', customerName: '', dateFrom: '', dateTo: '' }
    444803    form = {
     804      answers: {},
    445805      customerEmail: '',
    446806      customerName: '',
    447807      customerPhone: '',
     808      customerPhoneCountry: '',
    448809      notes: '',
     810      rebookingOptIn: false,
     811      smsOptIn: false,
     812      whatsappOptIn: false,
    449813    }
    450814    render()
    … …  
    502866        </div>
    503867      </div>
    504       ${stepperMarkup(paymentSession ? 2 : step)}
     868      ${stepperMarkup(paymentSession ? 2 : step, copy)}
    505869    `
    506870
     871    const staffHint = selectedStaffName()
    507872    const serviceHint =
    508873      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>`
    510875        : ''
    511876
    … …  
    545910          accent: config.color,
    546911          clientSecret: paymentSession.clientSecret,
     912          language: config.language,
    547913          publishableKey: paymentSession.publishableKey,
    548914        })
    … …  
    553919          })
    554920          .catch((error) => {
    555             if (host.isConnected && paymentSession === session) showPaymentLoadError(host, error, render)
     921            if (host.isConnected && paymentSession === session) showPaymentLoadError(host, error, render, config.language)
    556922          })
    557923      }
    … …  
    560926
    561927  function bindEvents(availableDates, slotsByDate) {
     928    bindServicePhotoErrors(widget)
    562929    widget.querySelectorAll('[data-service-id]').forEach((button) => {
    563930      button.addEventListener('click', () => {
    … …  
    565932        selectedDate = null
    566933        selectedSlotId = null
     934        selectedStaffId = null
    567935        datePageStart = 0
    568936        message = ''
    569937        render()
    570938      })
     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()
    571981    })
    572982
    … …  
    6331043    })
    6341044
     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
    6351101    widget.querySelector('[data-action="continue"]')?.addEventListener('click', () => {
    6361102      if (isSubmitting) {
    … …  
    6951161    }
    6961162
     1163    const session = paymentSession
     1164    const controller = paymentController
    6971165    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,
    7011171        paymentIntentId,
    7021172      })
     1173      if (paymentSession !== session || paymentController !== controller) return
     1174      if (result.processing || !(result.ok || result.alreadyConfirmed)) throw new Error(copy.paymentError)
    7031175
    7041176      confirmation = {
    … …  
    7161188      render()
    7171189    } catch (error) {
     1190      if (paymentSession !== session || paymentController !== controller) return
    7181191      isSubmitting = false
    7191192      message = error instanceof Error ? error.message : copy.genericError
    … …  
    7391212    const service = selectedService()
    7401213    const slot = selectedSlot(slotsByDate)
     1214    if (Number(service?.depositCents) > 0 && config.features?.deposits !== true) return
    7411215
    7421216    if (isShopifyCommerce(config)) {
    … …  
    7511225
    7521226    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()
    7531242      return
    7541243    }
    … …  
    7651254    try {
    7661255      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,
    7671260        customerEmail: form.customerEmail,
    7681261        customerName: form.customerName,
    7691262        customerPhone: form.customerPhone,
     1263        customerPhoneCountry: form.customerPhoneCountry || undefined,
     1264        language: config.language || 'en',
    7701265        notes: form.notes,
     1266        rebookingOptIn: form.rebookingOptIn || undefined,
    7711267        serviceId: service.id || service.name,
    7721268        serviceName: service.name,
     1269        smsOptIn: config.sms?.available === true ? form.smsOptIn || undefined : undefined,
     1270        staffId: pinnedStaffId(),
    7731271        startsAt: slot.startsAt,
     1272        whatsappOptIn: config.whatsapp?.available === true ? form.whatsappOptIn || undefined : undefined,
    7741273      })
    7751274
    … …  
    7911290      }
    7921291
    793       const depositSession = getDepositPaymentSession(result)
     1292      const depositSession = getDepositPaymentSession(result, config.language)
    7941293      if (depositSession) {
     1294        terminalPaymentReplacementAvailable = false
    7951295        paymentSession = depositSession
    7961296        confirmation = {
    … …  
    8191319    } catch (error) {
    8201320      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
    8221336      messageTone = 'error'
    8231337      render()
     1338      widget.querySelector('.widget-message')?.focus()
    8241339    }
    8251340  }
  • 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.
     4export * from './availabilitySlotEngine.js'
  • bookminto/trunk/assets/src/styles.js

    r3723811 r3730301  
    22import { bookingBarModalCss } from './bookingBarModalStyles.js'
    33import { bookingBarCss } from './bookingBarStyles.js'
     4import { extensionsCss } from './extensionsStyles.js'
    45import { floatingChromeCss } from './floatingChromeStyles.js'
    56import { floatingWidgetCss } from './floatingStyles.js'
    67import { modalChromeCss } from './modalStyles.js'
     8import { nextTimesCss } from './nextTimesStyles.js'
    79
    810export const widgetCss =
    … …  
    1214  bookingBarCss +
    1315  bookingBarModalCss +
    14   bookingBarFlowCss
     16  bookingBarFlowCss +
     17  nextTimesCss +
     18  extensionsCss
    1519
    1620export 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,`&amp;`).replace(/</g,`&lt;`).replace(/>/g,`&gt;`).replace(/"/g,`&quot;`).replace(/'/g,`&#039;`)}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(`\r
    2 `))}`}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,`&amp;`).replace(/</g,`&lt;`).replace(/>/g,`&gt;`).replace(/"/g,`&quot;`).replace(/'/g,`&#039;`)}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`
    33    <div
    44      class="bw-booking-modal__stepper"
    55      role="progressbar"
    6       aria-label="Booking progress"
     6      aria-label="${S(t.progressLabel)}"
    77      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)}"
    1111    >
    1212      <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>
    1515      </div>
    1616      <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(``)}
    1818      </div>
    1919    </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}">
    2222          <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>
    2528          </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>
    2730        </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}">
    3033                <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>
    3336                </span>
    34                 <span>${t===c?`Selected`:`Choose`}</span>
     37                <span>${S(t===p?r.selected:r.choose)}</span>
    3538              </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`
    3740      <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)}">
    3942          <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>
    4245          </div>
    4346          <div class="bw-booking-modal__service-list">
    44             ${Le(t,s)}
     47            ${yt(t,f,e.previewMode,r,e.language)}
    4548          </div>
    4649        </aside>
    47         <section class="bw-booking-modal__times" aria-label="Available times">
     50        <section class="bw-booking-modal__times" aria-label="${S(r.availableTimes)}">
    4851          <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>
    5154          </div>
    5255          <div class="bw-booking-modal__slots">
    53             ${a}
     56            ${o}
    5457          </div>
    5558        </section>
    5659      </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`
    5861      <div class="bw-booking-modal__pane">
    5962        <form class="bw-booking-bar-panel__form">
    6063          <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>
    6366          </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>`:``}
    6569          <div class="bw-booking-bar-panel__form-row">
    6670            <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)}" />
    6973            </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                `:``}
    7083            <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)}" />
    7386
    7487            </label>
    7588          </div>
    7689          <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)}" />
    7992          </label>
    8093          <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>
    8396          </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>`:``}
    85111          <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>
    88114          </div>
    89115        </form>
    90116      </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`
    100126      <div class="bw-booking-modal__pane">
    101127        <div class="bw-booking-bar-panel__confirmed">
    102128          <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>
    105131          ${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>
    108134        </div>
    109135      </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`
    111137      <div class="bw-booking-modal">
    112138        <div class="bw-booking-modal__topbar">
    … …  
    114140            <span class="bw-booking-modal__brand-mark">B</span>
    115141            <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>
    118144            </span>
    119145          </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>
    121147        </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">
    126152                <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>
    129155                </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>
    131157              </div>`:``}
    132158      </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=`
    134160      <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}">
    136162          <span class="bw-booking-bar__mark">B</span>
    137163          <span>
    138             <strong>${l(e.title)}</strong>
    139             <em>${l(x)}</em>
     164            <strong>${S(e.title)}</strong>
     165            <em>${S(ee)}</em>
    140166          </span>
    141167        </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>
    143169      </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`
    145171    <div
    146172      class="stepper"
    147173      role="progressbar"
    148       aria-label="Booking progress"
     174      aria-label="${S(t.progressLabel)}"
    149175      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)}"
    153179    >
    154180      <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>
    157183      </div>
    158184      <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(``)}
    160186      </div>
    161187    </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>
    165216      </button>
    166     `}function k(e,t){return`
     217    `}function U(e,t){return`
    167218      <div class="panel-right-flow">
    168219        <div class="panel-right-scroll">${e}</div>
    169220        ${t}
    170221      </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)}
    175227                <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>`:``}
    178230                </div>
    179                 <div class="svc-badge">${l(o(e.durationMinutes))}</div>
     231                <div class="svc-badge">${S(y(t.durationMinutes))}</div>
    180232              </button>
    181233            `}).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">
    185237              <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>`:``}
    188240              </div>
    189               <div class="svc-badge">${l(o(t.durationMinutes))}</div>
     241              <div class="svc-badge">${S(y(r.durationMinutes))}</div>
    190242            </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>`:``}
    194247            </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>
    198290          <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>
    200292          </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(`
    204296          <div class="modal-intro-copy">
    205297            Choose a service on the left, then continue to pick a date and time.
    206298          </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>
    210306          <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>
    212308            <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>
    215311            </div>
    216312          </div>
    217313          <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>
    223319                        </button>
    224                       `}).join(``):T(K.emptySlots)}
     320                      `}).join(``):z(t.emptySlots)}
    225321          </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)}
    231327              `:``}
    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>`:``}
    235334          <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" />
    238337          </div>
    239338          <div class="form-row">
    240339            <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" />
    243342            </div>
    244343            <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" />
    247346            </div>
    248347          </div>
    249348          <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>
    252351          </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`
    255359      <div class="confirm-wrap">
    256360        <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>`:``}
    260364        <button class="cta cta--close-success" type="button" data-action="close-success">
    261           ${l(K.close)}
     365          ${S(t.close)}
    262366        </button>
    263367      </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=`
    265369      <div class="modal-card">
    266370        <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>
    270374        </div>
    271375        <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>
    274378        </div>
    275379        ${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>
    278382              </div>`}
    279383      </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=`
    281385.bw-floating-popover {
    282386  position: fixed !important;
    … …  
    359463  }
    360464}
    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=`
    362466.bw-widget-shadow-host[data-variant="floating"],
    363467.bw-widget-shadow-host[data-variant="modal"][data-trigger-mode="external"] {
    … …  
    369473  padding: 0 !important;
    370474  border: 0 !important;
    371   z-index: ${Y} !important;
     475  z-index: ${Ft} !important;
    372476  pointer-events: none !important;
    373477  transform: none !important;
    … …  
    406510  border: 0 !important;
    407511  overflow: visible !important;
    408   z-index: ${Y} !important;
     512  z-index: ${Ft} !important;
    409513  pointer-events: auto !important;
    410514  transform: none !important;
    … …  
    437541  box-sizing: border-box !important;
    438542}
    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`
    440558    <div
    441559      class="stepper"
    442560      role="progressbar"
    443       aria-label="Booking progress"
     561      aria-label="${S(t.progressLabel)}"
    444562      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)}"
    448566    >
    449567      <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>
    452570      </div>
    453571      <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(``)}
    455573      </div>
    456574    </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`
    458576      <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>
    461579        <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>
    463581        </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>
    465583      </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)}
    468587            <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>`:``}
    471590            </div>
    472             <div class="service-duration">${l(o(e.durationMinutes))}</div>
     591            <div class="service-duration">${S(y(t.durationMinutes))}</div>
    473592          </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>
    477643      <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>
    479645        <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`}>
    481647            <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>
    482648          </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`}>
    484650            <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>
    485651          </button>
    … …  
    487653      </div>
    488654      <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>
    494660                    </button>
    495                   `}).join(``):j(K.emptySlots)}
     661                  `}).join(``):W(t.emptySlots)}
    496662      </div>
    497663      ${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>`:``}
    503670                          </button>
    504671                        `).join(``)}
    505                   </div>`:j(K.emptySlots)}
     672                  </div>`:W(t.emptySlots)}
    506673          `:``}
    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`
    508676      <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>
    510678        <div class="summary-box">
    511679          <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>
    513681          </div>
    514682          <div class="summary-row">
    515             <span>${l(e?.name||``)} · ${l(o(e?.durationMinutes))}</span>
     683            <span>${S(r?.name||``)} · ${S(y(r?.durationMinutes))}</span>
    516684          </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>`}
    518687        </div>
    519         <p class="section-title">${l(K.detailsHeading)}</p>
     688        <p class="section-title">${S(t.detailsHeading)}</p>
    520689        <div class="form-row">
    521690          <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)}" />
    524693          </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              `:``}
    525703          <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)}" />
    528706          </div>
    529707        </div>
    530708        <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)}" />
    533711        </div>
    534712        <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>
    537781        </div>
    538782      </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}
    562786      <div class="widget-body">
    563787        <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()}
    567791        </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>
    570794                <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>
    571795              </button>`:``}
    572796      </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=`
    575799:host {
    576800  color-scheme: light;
    … …  
    8721096}
    8731097
     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
    8741107.service-info {
     1108  min-width: 0;
    8751109  flex: 1;
    8761110  text-align: left;
    … …  
    10271261}
    10281262
     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
    10291330.form-field {
    10301331  margin-bottom: 0;
    … …  
    10621363  min-height: 74px;
    10631364  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;
    10641382}
    10651383
    … …  
    23942712
    23952713@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
    23962720  .bw-booking-bar-spacer {
    23972721    height: 0;
    … …  
    26052929  border-color: rgb(31 95 58 / 0.28);
    26062930  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;
    26072944}
    26082945
    … …  
    29453282  }
    29463283}
    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  
    33 * Plugin Name: Bookminto
    44 * Description: Add Bookminto to WordPress pages with automatic display, a block, a shortcode, or WooCommerce product checkout booking.
    5  * Version: 1.0.0
     5 * Version: 1.0.5
    66 * Author: Bookminto
    77 * Author URI: https://bookminto.com/
    … …  
    2323const BOOKMINTO_OPTION = 'bookminto_options';
    2424const BOOKMINTO_OPTION_LEGACY = 'booking_widget_options';
    25 const BOOKMINTO_VERSION = '1.0.0';
     25const BOOKMINTO_VERSION = '1.0.5';
    2626const BOOKMINTO_API_URL = 'https://api.bookminto.com/v1';
    2727const BOOKMINTO_PLUGIN_DIR = __DIR__;
  • bookminto/trunk/includes/woocommerce.php

    r3723811 r3730301  
    5050    add_action('wp_ajax_bookminto_add_to_cart', 'bookminto_woo_ajax_add_to_cart');
    5151    add_action('wp_ajax_nopriv_bookminto_add_to_cart', 'bookminto_woo_ajax_add_to_cart');
     52    add_action('template_redirect', 'bookminto_woo_waitlist_checkout');
    5253
    5354    add_filter('woocommerce_add_cart_item_data', 'bookminto_woo_add_cart_item_data', 10, 3);
    … …  
    933934    }
    934935
    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. */
     948function 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.
    936954    foreach (WC()->cart->get_cart() as $key => $item) {
    937955        if ((int) ($item['product_id'] ?? 0) === $product_id && !empty($item[BOOKMINTO_WOO_BOOKING_META])) {
    … …  
    944962    $_REQUEST['bookminto_service_name'] = $service_name;
    945963    $_REQUEST['bookminto_ajax'] = '1';
     964    $_REQUEST['nonce'] = wp_create_nonce('bookminto_woo_cart');
    946965
    947966    $cart_key = WC()->cart->add_to_cart($product_id, 1);
    … …  
    950969            wc_clear_notices();
    951970        }
    952         wp_send_json_error(['message' => __('Could not add booking to cart.', 'bookminto')], 400);
     971        return '';
    953972    }
    954973
    … …  
    957976    }
    958977
    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. */
     983function 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;
    9641087}
    9651088
  • bookminto/trunk/languages/bookminto.pot

    r3723811 r3730301  
    11msgid ""
    22msgstr ""
    3 "Project-Id-Version: Bookminto 1.0.0\n"
     3"Project-Id-Version: Bookminto 1.0.5\n"
    44"Report-Msgid-Bugs-To: https://bookminto.com/\n"
    5 "POT-Creation-Date: 2026-10-01\n"
     5"POT-Creation-Date: 2026-10-06\n"
    66"MIME-Version: 1.0\n"
    77"Content-Type: text/plain; charset=UTF-8\n"
    … …  
    5757msgstr ""
    5858
    59 #: includes/admin-ui.php
    60 msgid "Booking bar, buffers, and schedule exceptions"
    61 msgstr ""
    62 
    6359#: blocks/widget/editor.js bookminto.php includes/admin-menu.php includes/admin-menu.php includes/admin-menu.php
    6460msgid "Bookminto"
    … …  
    10197msgstr ""
    10298
     99#: includes/admin-ui.php
     100msgid "Bookminto service access"
     101msgstr ""
     102
    103103#: includes/admin-menu.php
    104104msgid "Bookminto services"
    … …  
    153153msgstr ""
    154154
    155 #: includes/admin-ui.php
    156 msgid "Compare plans"
     155#: includes/woocommerce.php
     156msgid "Complete your appointment"
    157157msgstr ""
    158158
    … …  
    177177msgstr ""
    178178
     179#: includes/woocommerce.php
     180msgid "Continue to checkout"
     181msgstr ""
     182
    179183#: includes/settings.php
    180184msgid "Copy the key from Bookminto → Install → WordPress, then save it here."
    … …  
    206210
    207211#: 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."
     212msgid "Create a Bookminto account"
    209213msgstr ""
    210214
    … …  
    253257msgstr ""
    254258
    255 #: includes/admin-ui.php includes/admin-ui.php
    256 msgid "Deposits via Stripe Connect"
    257 msgstr ""
    258 
    259259#: includes/settings.php
    260260msgid "Disconnect could not be confirmed. Your local credentials were kept. Retry, or revoke this connection under Bookminto → Install."
    … …  
    275275#: includes/admin-menu.php
    276276msgid "Edit product"
    277 msgstr ""
    278 
    279 #: includes/admin-ui.php includes/admin-ui.php
    280 msgid "For growing businesses that need deposits and automations."
    281 msgstr ""
    282 
    283 #: includes/admin-ui.php
    284 msgid "For independent professionals running their bookable business."
    285277msgstr ""
    286278
    … …  
    298290
    299291#: includes/admin-ui.php
    300 msgid "Get an installation key"
    301 msgstr ""
    302 
    303 #: includes/admin-ui.php includes/admin-ui.php
    304 msgid "Get Pro"
    305 msgstr ""
    306 
    307 #: includes/admin-ui.php
    308 msgid "Get Solo"
    309 msgstr ""
    310 
    311 #: includes/admin-ui.php
    312292msgid "Help & links"
    313293msgstr ""
    … …  
    381361msgstr ""
    382362
     363#: includes/woocommerce.php
     364msgid "No"
     365msgstr ""
     366
    383367#: includes/admin-menu.php
    384368msgid "No appointments yet."
    … …  
    473457msgstr ""
    474458
    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
    480460msgid "Pro"
    481461msgstr ""
    … …  
    491471#: includes/admin-ui.php
    492472msgid "Product overview"
    493 msgstr ""
    494 
    495 #: includes/admin-ui.php
    496 msgid "Product-page checkout booking needs Solo or Pro. The block, shortcode, and site-wide widget work on Free."
    497473msgstr ""
    498474
    … …  
    510486
    511487#: includes/woocommerce.php
     488msgid "Reload this offer before continuing."
     489msgstr ""
     490
     491#: includes/woocommerce.php
    512492msgid "Retry Bookminto order synchronization"
    513493msgstr ""
    … …  
    535515#: includes/settings.php
    536516msgid "Secure connection active"
    537 msgstr ""
    538 
    539 #: includes/admin-ui.php
    540 msgid "See pricing on bookminto.com"
    541517msgstr ""
    542518
    … …  
    569545msgstr ""
    570546
    571 #: includes/admin-ui.php includes/admin-ui.php
     547#: includes/admin-ui.php
    572548msgid "Solo"
    573549msgstr ""
    … …  
    575551#: includes/admin-menu.php
    576552msgid "Source"
    577 msgstr ""
    578 
    579 #: includes/admin-ui.php
    580 msgid "Start for free"
    581553msgstr ""
    582554
    … …  
    609581msgstr ""
    610582
     583#: includes/woocommerce.php
     584msgid "The booking conditions changed. Reload this offer before continuing."
     585msgstr ""
     586
     587#: includes/woocommerce.php
     588msgid "The booking product cannot be determined. Please contact the store."
     589msgstr ""
     590
    611591#: blocks/widget/editor.js
    612592msgid "The booking widget appears here on the published page."
    … …  
    621601msgstr ""
    622602
     603#: includes/woocommerce.php
     604msgid "The cart is unavailable. Reopen this same offer to continue."
     605msgstr ""
     606
    623607#: includes/admin-menu.php
    624608msgid "The product could not be created."
     609msgstr ""
     610
     611#: includes/woocommerce.php
     612msgid "This appointment is already confirmed."
     613msgstr ""
     614
     615#: includes/woocommerce.php
     616msgid "This booking product is unavailable."
     617msgstr ""
     618
     619#: includes/admin-ui.php
     620msgid "This free WordPress plugin connects to the external Bookminto booking service. Account access, current prices, and beta terms are published on bookminto.com."
     621msgstr ""
     622
     623#: includes/woocommerce.php
     624msgid "This offer could not be reserved. Reload it before trying again."
     625msgstr ""
     626
     627#: includes/woocommerce.php
     628msgid "This offer is no longer available. Please contact the store."
    625629msgstr ""
    626630
    … …  
    641645msgstr ""
    642646
     647#: includes/woocommerce.php
     648msgid "This store is not connected to Bookminto."
     649msgstr ""
     650
    643651#: includes/settings.php
    644652msgid "To connect or rotate credentials, create a one-time WordPress connection code under Bookminto → Install. The code expires after 10 minutes."
    … …  
    653661msgstr ""
    654662
    655 #: includes/admin-ui.php
    656 msgid "Unlimited bookings"
    657 msgstr ""
    658 
    659663#: includes/admin-menu.php
    660664msgid "Updated “%s” to match the Bookminto service."
    … …  
    665669msgstr ""
    666670
    667 #: includes/admin-ui.php
    668 msgid "Upgrade to Pro"
    669 msgstr ""
    670 
    671671#: includes/settings.php
    672672msgid "Use the checkbox above for a floating button, booking bar, or modal on every page."
    … …  
    675675#: includes/woocommerce.php
    676676msgid "Used when this product creates or updates a Bookminto service."
     677msgstr ""
     678
     679#: includes/admin-ui.php
     680msgid "View current plans and beta terms"
    677681msgstr ""
    678682
    … …  
    693697msgstr ""
    694698
    695 #: includes/admin-ui.php
    696 msgid "WooCommerce checkout booking"
    697 msgstr ""
    698 
    699699#: includes/settings.php
    700700msgid "WooCommerce is securely connected to Bookminto."
    … …  
    713713msgstr ""
    714714
    715 #: includes/admin-ui.php includes/settings.php
     715#: includes/settings.php
    716716msgid "WordPress install guide"
    717717msgstr ""
    … …  
    719719#: includes/admin-menu.php
    720720msgid "Workspace"
     721msgstr ""
     722
     723#: includes/woocommerce.php
     724msgid "Yes"
    721725msgstr ""
    722726
    … …  
    729733msgstr ""
    730734
    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
     736msgid "Your appointment is confirmed after the shop payment has been verified."
     737msgstr ""
     738
  • bookminto/trunk/readme.txt

    r3724276 r3730301  
    55Tested up to: 7.1
    66Requires PHP: 7.4
    7 Stable tag: 1.0.0
     7Stable tag: 1.0.5
    88License: GPLv2 or later
    99License URI: https://www.gnu.org/licenses/gpl-2.0.html
    … …  
    3232* **Popup booking** — open the booking flow when visitors are ready to book.
    3333
    34 Customize the appearance to better match your website and brand.
     34Customize 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.
    3535
    3636Use the Bookminto block in the WordPress editor, or place the booking experience manually with a shortcode.
    … …  
    9696
    9797`https://api.bookminto.com/v1`
     98
     99Optional service photos load from Bookminto-managed Supabase Storage:
     100
     101`https://zmzskjwuiryxrwlitkqn.supabase.co/storage/v1/object/public/service-images/`
     102
     103Image requests share the visitor’s IP and browser information with storage, without booking details. See Bookminto’s Privacy Policy below.
    98104
    99105The Bookminto service is used to:
    … …  
    168174= Where can developers find the source and build instructions? =
    169175
    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.
     176The 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.
    171177
    172178= Can I place the widget manually? =
    173179
    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.
     180Use the Bookminto block or `[bookminto]` / `[booking_widget]`. Automatic rendering is skipped on singular pages containing a block or shortcode.
    175181
    176182= Does the plugin charge visitors? =
    … …  
    210216== Changelog ==
    211217
     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
    212239= 1.0.0 =
    213240
  • bookminto/trunk/uninstall.php

    r3723811 r3730301  
    1010}
    1111
    12 delete_option('bookminto_options');
    13 delete_option('booking_widget_options');
    14 delete_option('bookminto_woocommerce_credentials');
     12function bookminto_uninstall_site(): void {
     13    delete_option('bookminto_options');
     14    delete_option('booking_widget_options');
     15    delete_option('bookminto_woocommerce_credentials');
    1516
    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    }
    1825}
    19 wp_unschedule_hook('bookminto_woo_retry_order');
    2026
    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.
     29if (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();
    2338}
Note: See TracChangeset for help on using the changeset viewer.