/* * A booking page on your own website, through the CrenoSync booking API * (https://crenosync.ca/en/developers): an activity, a date and a group, * the open times, the price, a hold while the visitor types their details, * then the checkout: booked at once, an emailed code, or CrenoSync's payment * page and back to merci.html. Plain JavaScript, no framework or build. * Settings in config.js. */ (function () { 'use strict'; const config = window.CRENOSYNC; const words = { fr: { title: 'Réserver', choose: 'Votre activité', activity: 'Activité', date: 'Date', group: 'Votre groupe', difficulty: 'Niveau de difficulté', promo: 'Code promotionnel (facultatif)', search: 'Voir les heures libres', no_times: 'Aucune heure libre ce jour-là pour ce groupe.', places: 'places', price: 'Le prix', hold: 'Retenir cette heure', details: 'Vos coordonnées', held: 'Heure retenue pour vous encore', name: 'Nom', email: 'Courriel', phone: 'Téléphone', accept: 'J’accepte ce texte : %s', book: 'Réserver', release: 'Choisir une autre heure', code_title: 'Le code reçu par courriel', code: 'Code à 6 chiffres', confirm: 'Confirmer', resend: 'Recevoir un nouveau code', code_sent: 'Un nouveau code est en route.', booked: 'C’est réservé', reference: 'Numéro', when: 'Quand', people: 'Personnes', paid: 'Payé', balance: 'À payer sur place', discount: 'Rabais', total: 'Total', due_now: 'À payer maintenant', due_later: 'À payer sur place', new_price: 'Le prix a changé : %s. Continuer ?', expired: 'Le temps est écoulé : choisissez de nouveau une heure.', return_title: 'Votre réservation', confirming: 'Votre paiement est en cours de confirmation…', still_confirming: 'Votre paiement est en cours de confirmation. Vous recevrez un courriel ; ne payez pas de nouveau.', attention: 'Votre paiement est reçu. L’entreprise finalise votre réservation et vous écrira ; ne payez pas de nouveau.', not_booked: 'La réservation n’a pas été faite. Vous pouvez recommencer.', nothing: 'Aucune réservation à afficher.', again: 'Faire une autre réservation', network: 'Le service ne répond pas. Réessayez dans un moment.', }, en: { title: 'Book', choose: 'Your activity', activity: 'Activity', date: 'Date', group: 'Your group', difficulty: 'Difficulty level', promo: 'Promotion code (optional)', search: 'See open times', no_times: 'No open time that day for this group.', places: 'places', price: 'The price', hold: 'Hold this time', details: 'Your details', held: 'Time held for you for', name: 'Name', email: 'Email', phone: 'Phone', accept: 'I accept this text: %s', book: 'Book', release: 'Choose another time', code_title: 'The code we emailed you', code: '6-digit code', confirm: 'Confirm', resend: 'Get a new code', code_sent: 'A new code is on its way.', booked: 'You\'re booked', reference: 'Number', when: 'When', people: 'People', paid: 'Paid', balance: 'To pay at the venue', discount: 'Discount', total: 'Total', due_now: 'To pay now', due_later: 'To pay at the venue', new_price: 'The price changed: %s. Continue?', expired: 'Time ran out: please choose a time again.', return_title: 'Your booking', confirming: 'Your payment is being confirmed…', still_confirming: 'Your payment is being confirmed. You will receive an email; don\'t pay again.', attention: 'Your payment was received. The business is finishing your booking and will write to you; don\'t pay again.', not_booked: 'The booking was not made. You can start again.', nothing: 'No booking to show.', again: 'Make another booking', network: 'The service isn\'t answering. Try again in a moment.', }, }[config.language]; const $ = (id) => document.getElementById(id); const sleep = (seconds) => new Promise((done) => setTimeout(done, seconds * 1000)); document.querySelectorAll('[data-text]').forEach((element) => { element.textContent = words[element.dataset.text]; }); /** * One API request. Throws an Error with the API's code, details, * Retry-After and Request-Id; an answer that isn't JSON (a host's error * page) or no answer at all has the code `host_error`. */ async function api(method, path, { body, guestToken, returnToken, idempotencyKey } = {}) { const headers = { Authorization: `Bearer ${config.key}`, Accept: 'application/json', 'Accept-Language': config.language }; if (body) headers['Content-Type'] = 'application/json'; if (guestToken) headers['CrenoSync-Guest-Token'] = guestToken; if (returnToken) headers['CrenoSync-Return-Token'] = returnToken; if (idempotencyKey) headers['Idempotency-Key'] = idempotencyKey; let response; try { response = await fetch(config.api + path, { method, headers, body: body ? JSON.stringify(body) : undefined }); } catch (network) { const error = new Error(words.network); error.code = 'host_error'; throw error; } if (response.status === 204) return null; const json = (response.headers.get('Content-Type') || '').includes('application/json'); const data = json ? await response.json() : null; if (response.ok) return data; const error = new Error(data && data.error ? data.error.message : words.network); error.status = response.status; error.code = data && data.error ? data.error.code : 'host_error'; error.details = (data && data.error) || {}; error.retryAfter = Number(response.headers.get('Retry-After')) || null; error.requestId = response.headers.get('Request-Id'); throw error; } /** Sends the request up to three times in all, waiting after a temporary failure (429, 5xx, no answer). */ async function withRetries(send) { for (let attempt = 1; ; attempt++) { try { return await send(); } catch (error) { const temporary = error.status === 429 || error.status >= 500 || error.code === 'host_error'; if (!temporary || attempt === 3) throw error; await sleep(error.retryAfter || 2 ** attempt); } } } const locale = config.language === 'fr' ? 'fr-CA' : 'en-CA'; const money = (value) => new Intl.NumberFormat(locale, { style: 'currency', currency: value.currency }).format(value.amount / 100); function showError(error) { $('error').hidden = false; $('error').textContent = error.message + (error.requestId ? ` (${error.requestId})` : ''); } function clearError() { $('error').hidden = true; } /** A term and its value in the booking's list. */ function addRow(list, term, value) { const dt = document.createElement('dt'); const dd = document.createElement('dd'); dt.textContent = term; dd.textContent = value; list.append(dt, dd); } function showBooking(booking) { const list = $('booking'); list.replaceChildren(); addRow(list, words.reference, booking.reference); addRow(list, words.activity, booking.activity.name); addRow(list, words.when, new Date(booking.starts_at).toLocaleString(locale, { dateStyle: 'full', timeStyle: 'short' })); addRow(list, words.people, String(booking.group_size)); addRow(list, words.total, money(booking.price.total)); addRow(list, words.paid, money(booking.paid)); addRow(list, words.balance, money(booking.balance)); $('done').hidden = false; } if ($('status')) { returnPage(); } else { bookingPage(); } /** * merci.html: back from CrenoSync's page. The checkout's ID and return * token come after `#`; they are taken out of the address at once. */ async function returnPage() { const fragment = new URLSearchParams(window.location.hash.slice(1)); const checkoutId = fragment.get('checkout_id'); const returnToken = fragment.get('return_token'); history.replaceState(null, '', window.location.pathname + window.location.search); if (!checkoutId || !returnToken) { $('status').textContent = words.nothing; return; } try { $('status').textContent = words.confirming; const read = () => withRetries(() => api('GET', `/checkouts/${encodeURIComponent(checkoutId)}`, { returnToken })); let checkout = await read(); for (let wait = 2; checkout.status === 'processing' && wait <= 32; wait *= 2) { await sleep(wait); checkout = await read(); } if (checkout.status === 'confirmed') { $('status').textContent = ''; showBooking(await api('GET', `/bookings/${encodeURIComponent(checkout.booking.reference)}`, { returnToken })); } else if (checkout.status === 'attention_required') { $('status').textContent = words.attention; } else if (checkout.status === 'expired' || checkout.status === 'cancelled') { $('status').textContent = words.not_booked; } else { $('status').textContent = words.still_confirming; } } catch (error) { $('status').textContent = ''; showError(error); } } /** index.html: from the activity to the checkout. */ async function bookingPage() { // token: the guest token of the booking under way, from its price to its checkout const state = { activities: [], activity: null, token: null, quote: null, hold: null, checkout: null, policies: null, timer: null }; let session = null; /** * A guest token for a new booking: this visitor's booking session, kept in * memory and in sessionStorage when the browser allows it, or a new one * when it has less than 20 minutes left (the hold and its emailed code fit * in them; the payment page needs no guest token). */ async function guestToken() { if (!session) { try { session = JSON.parse(sessionStorage.getItem('crenosync_guest') || 'null'); } catch (ignored) { /* no storage */ } } if (session && new Date(session.expires_at) - Date.now() > 20 * 60 * 1000) return session.token; session = await withRetries(() => api('POST', '/booking-sessions')); try { sessionStorage.setItem('crenosync_guest', JSON.stringify(session)); } catch (ignored) { /* kept in memory only */ } return session.token; } /** An error shown, or the page started over when the booking session has ended. */ function failed(error) { if (error.code === 'guest_token_expired') { session = null; reset(words.expired); } else { showError(error); } } const counted = () => state.activity.price_categories.filter((category) => category.counts_toward_group) .reduce((sum, category) => sum + Number($(`count-${category.id}`).value || 0), 0); const participants = () => state.activity.price_categories .map((category) => ({ category_id: category.id, quantity: Number($(`count-${category.id}`).value || 0) })) .filter((participant) => participant.quantity > 0); function showActivity() { state.activity = state.activities.find((activity) => activity.id === $('activity').value); const activity = state.activity; $('activity-info').textContent = [activity.description, `${activity.duration_minutes} min`].filter(Boolean).join(' · '); const group = $('group'); group.querySelectorAll('label').forEach((label) => label.remove()); activity.price_categories.forEach((category, index) => { const label = document.createElement('label'); const name = document.createElement('span'); const input = document.createElement('input'); name.textContent = `${category.name} (${money(category.price)})`; input.type = 'number'; input.id = `count-${category.id}`; input.min = '0'; input.max = String(category.max_per_booking || activity.group.max || 50); input.value = index === 0 ? String(activity.group.min) : '0'; label.append(name, input); group.append(label); }); const levels = activity.difficulty ? activity.difficulty.levels : []; $('difficulty-field').hidden = levels.length === 0; $('difficulty').replaceChildren(...levels.map((level) => new Option(level.name, String(level.level)))); $('times').replaceChildren(); } async function search() { clearError(); $('price').hidden = true; const date = $('date').value; const query = new URLSearchParams({ from: date, to: date, group_size: String(counted()) }); try { const availability = await withRetries(() => api('GET', `/activities/${encodeURIComponent(state.activity.id)}/availability?${query}`)); const times = availability.days.length ? availability.days[0].times : []; const list = $('times'); list.replaceChildren(); if (times.length === 0) { const none = document.createElement('li'); none.textContent = words.no_times; list.append(none); } times.forEach((time) => { const item = document.createElement('li'); const button = document.createElement('button'); button.type = 'button'; button.setAttribute('aria-pressed', 'false'); button.textContent = time.places_left === null ? time.time : `${time.time} · ${time.places_left} ${words.places}`; button.addEventListener('click', () => { list.querySelectorAll('button').forEach((other) => other.setAttribute('aria-pressed', 'false')); button.setAttribute('aria-pressed', 'true'); price(date, time.time); }); item.append(button); list.append(item); }); } catch (error) { showError(error); } } function showPrice(quote) { const table = $('lines'); table.replaceChildren(); const row = (label, value, className) => { const tr = table.insertRow(); if (className) tr.className = className; tr.insertCell().textContent = label; tr.insertCell().textContent = value; }; quote.lines.forEach((line) => row(`${line.quantity} × ${line.name}`, money(line.amount))); if (quote.discount) row(`${words.discount} (${quote.discount.label})`, `−${money(quote.discount.amount)}`); quote.taxes.forEach((tax) => row(`${tax.name} (${tax.rate.toLocaleString(locale)} %)`, money(tax.amount))); row(words.total, money(quote.total), 'total'); if (quote.due_now.amount > 0) { row(words.due_now, money(quote.due_now)); row(words.due_later, money(quote.due_later)); } } async function price(date, time) { clearError(); try { await release(); state.token = await guestToken(); state.quote = await withRetries(() => api('POST', '/quotes', { guestToken: state.token, body: { activity_id: state.activity.id, date, time, participants: participants(), difficulty_level: $('difficulty-field').hidden ? null : Number($('difficulty').value), promo_code: $('promo').value.trim() || null, }, })); showPrice(state.quote); $('price').hidden = false; } catch (error) { failed(error); } } async function hold() { clearError(); $('hold').disabled = true; try { const idempotencyKey = crypto.randomUUID(); state.hold = await withRetries(() => api('POST', '/holds', { guestToken: state.token, idempotencyKey, body: { quote_id: state.quote.id } })); state.policies = await withRetries(() => api('GET', '/policies')); showPolicies(state.policies); $('choose').hidden = true; $('hold').hidden = true; $('details').hidden = false; countdown(state.hold.expires_at); } catch (error) { failed(error); } finally { $('hold').disabled = false; } } /** The hold's (then the payment window's) time left, and the page reset when it runs out. */ function countdown(until) { clearInterval(state.timer); const tick = () => { const seconds = Math.max(0, Math.round((new Date(until) - Date.now()) / 1000)); $('countdown').textContent = `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`; if (seconds === 0) { clearInterval(state.timer); reset(words.expired); } }; tick(); state.timer = setInterval(tick, 1000); } /** The policies' boxes; their texts are sanitized HTML from CrenoSync (GET /policies). */ function showPolicies(policies) { const box = $('policies'); box.replaceChildren(); [['privacy', policies.privacy], ['terms', policies.terms]].forEach(([key, policy]) => { if (!policy) return; const details = document.createElement('details'); const summary = document.createElement('summary'); const text = document.createElement('div'); summary.textContent = policy.title; text.innerHTML = policy.html; details.append(summary, text); const label = document.createElement('label'); const checkbox = document.createElement('input'); checkbox.type = 'checkbox'; checkbox.id = `accept-${key}`; checkbox.required = true; label.append(checkbox, words.accept.replace('%s', policy.title)); box.append(details, label); }); } /** The Book button: disabled while the checkout runs, given back whatever happens. */ async function checkOut(event) { event.preventDefault(); clearError(); $('book').disabled = true; try { const token = state.token; const policies = state.policies; const body = { hold_id: state.hold.id, quote_id: state.quote.id, customer: { name: $('name').value.trim(), email: $('email').value.trim(), phone: $('phone').value.trim(), language: config.language }, consent: { privacy_version_id: policies.privacy ? policies.privacy.version_id : null, privacy_accepted: true, terms_version_id: policies.terms ? policies.terms.version_id : null, terms_accepted: policies.terms ? true : null, }, return_url: config.returnUrl, }; let idempotencyKey = crypto.randomUUID(); for (let attempt = 1; ; attempt++) { try { state.checkout = await withRetries(() => api('POST', '/checkouts', { guestToken: token, idempotencyKey, body })); break; } catch (error) { const running = error.code === 'checkout_in_progress' || error.code === 'idempotency_in_progress'; if (running && attempt < 5) { await sleep(Math.min(error.retryAfter || 2, 10)); } else if (error.code === 'quote_changed') { state.quote = error.details.quote; showPrice(state.quote); $('price').hidden = false; if (!window.confirm(words.new_price.replace('%s', money(state.quote.total)))) return; body.quote_id = state.quote.id; idempotencyKey = crypto.randomUUID(); } else if (error.code === 'policy_changed') { state.policies = error.details.policies; showPolicies(state.policies); throw error; } else { throw error; } } } await next(state.checkout, token); } catch (error) { failed(error); } finally { $('book').disabled = false; } } /** What the checkout's answer asks for next. */ async function next(checkout, token) { if (checkout.status === 'confirmed') { clearInterval(state.timer); $('details').hidden = true; $('code').hidden = true; $('price').hidden = true; showBooking(await api('GET', `/bookings/${encodeURIComponent(checkout.booking.reference)}`, { guestToken: token })); } else if (checkout.next_action && checkout.next_action.type === 'verify_email') { $('details').hidden = true; $('code').hidden = false; $('code-hint').textContent = `${checkout.next_action.email_hint} (${checkout.next_action.attempts_left})`; countdown(checkout.next_action.expires_at); } else if (checkout.next_action) { // CrenoSync's payment page, or a step this page doesn't know: in the whole window window.location.assign(checkout.next_action.url); } } async function verify(event) { event.preventDefault(); clearError(); const button = event.submitter || $('code-form').querySelector('[type="submit"]'); button.disabled = true; try { const checkout = await api('POST', `/checkouts/${encodeURIComponent(state.checkout.id)}/verify-email`, { guestToken: state.token, body: { code: $('code-input').value.trim() }, }); await next(checkout, state.token); } catch (error) { if (error.code === 'verification_locked') reset(error.message); else failed(error); } finally { button.disabled = false; } } async function resend() { clearError(); $('resend').disabled = true; try { await api('POST', `/checkouts/${encodeURIComponent(state.checkout.id)}/resend-code`, { guestToken: state.token }); $('code-hint').textContent = words.code_sent; } catch (error) { failed(error); } finally { $('resend').disabled = false; } } /** Gives the held time back, when there is one. */ async function release() { if (!state.hold) return; const hold = state.hold; state.hold = null; clearInterval(state.timer); try { await api('DELETE', `/holds/${encodeURIComponent(hold.id)}`, { guestToken: state.token }); } catch (ignored) { /* it ends on its own */ } } function reset(message) { clearInterval(state.timer); state.hold = null; state.checkout = null; ['price', 'details', 'code'].forEach((id) => { $(id).hidden = true; }); $('choose').hidden = false; $('hold').hidden = false; $('times').replaceChildren(); if (message) showError(new Error(message)); } $('activity').addEventListener('change', showActivity); $('search').addEventListener('click', search); $('hold').addEventListener('click', hold); $('checkout-form').addEventListener('submit', checkOut); $('code-form').addEventListener('submit', verify); $('resend').addEventListener('click', resend); $('release').addEventListener('click', async () => { await release(); reset(); }); const today = new Date(); const local = new Date(today.getTime() - today.getTimezoneOffset() * 60000).toISOString().slice(0, 10); $('date').value = local; $('date').min = local; try { state.activities = (await withRetries(() => api('GET', '/activities'))).data; $('activity').replaceChildren(...state.activities.map((activity) => new Option(activity.name, activity.id))); if (state.activities.length) showActivity(); } catch (error) { showError(error); } } })();