← back to NationalPaperHangers
public/js/calendar-consumer.js
212 lines
// Consumer-side slot picker. Loads /api/installers/:slug/slots and renders
// clickable buttons grouped by day. On submit, posts the booking.
(function () {
const root = document.querySelector('.book-calendar');
const form = document.getElementById('book-form');
if (!root || !form) return;
const slug = root.dataset.installerSlug;
const slotsEl = root.querySelector('[data-cal-slots]');
const rangeEl = root.querySelector('[data-cal-range]');
const selectedEl = form.querySelector('[data-selected-slot]');
const submit = form.querySelector('[data-submit]');
const startInput = form.querySelector('#scheduled_start');
const endInput = form.querySelector('#scheduled_end');
let windowStart = new Date();
windowStart.setHours(0, 0, 0, 0);
function csrf() {
const m = document.querySelector('meta[name="csrf-token"]');
return m ? m.getAttribute('content') : '';
}
// ---------- Stripe Elements (deposit card field) ----------
// Mounted at page load if NPH_STRIPE_PK is present. We use card-element
// (PaymentElement requires server-rendered intent on init; this is simpler
// for the booking-then-confirm flow we want).
let stripe = null;
let cardEl = null;
const cardMount = document.getElementById('deposit-card-element');
const cardErrors = document.getElementById('deposit-card-errors');
if (window.NPH_STRIPE_PK && window.Stripe && cardMount) {
try {
stripe = window.Stripe(window.NPH_STRIPE_PK);
const elements = stripe.elements();
cardEl = elements.create('card', {
hidePostalCode: false,
style: {
base: {
color: getComputedStyle(document.body).color || '#eee',
fontFamily: 'Inter, system-ui, sans-serif',
fontSize: '15px',
'::placeholder': { color: '#888' }
},
invalid: { color: '#e2615a' }
}
});
cardEl.mount(cardMount);
cardEl.on('change', (e) => {
if (cardErrors) cardErrors.textContent = e.error ? e.error.message : '';
});
} catch (e) {
console.warn('[stripe] elements init failed', e.message);
}
}
async function load() {
slotsEl.innerHTML = '<p class="slot-loading">Loading available slots…</p>';
const from = windowStart.toISOString().slice(0, 10);
const toDate = new Date(windowStart);
toDate.setDate(toDate.getDate() + 14);
const to = toDate.toISOString().slice(0, 10);
rangeEl.textContent = fmtDate(windowStart) + ' – ' + fmtDate(toDate);
try {
const r = await fetch(`/api/installers/${encodeURIComponent(slug)}/slots?from=${from}&to=${to}`);
const j = await r.json();
if (!j.calendarEnabled) {
slotsEl.innerHTML = '<p class="slot-loading">This studio is not on calendar booking.</p>';
return;
}
render(j.slots || []);
} catch (e) {
slotsEl.innerHTML = '<p class="slot-loading">Could not load slots. Please try again.</p>';
}
}
function render(slots) {
if (!slots.length) {
slotsEl.innerHTML = '<p class="slot-loading">No openings in this range. Try a later window.</p>';
return;
}
const byDay = {};
for (const s of slots) {
const d = new Date(s.start);
const key = d.toDateString();
(byDay[key] = byDay[key] || []).push(s);
}
const html = Object.keys(byDay).map(day => {
const items = byDay[day].map(s => {
const t = new Date(s.start);
return `<button type="button" class="slot" data-start="${s.start}" data-end="${s.end}">${fmtTime(t)}</button>`;
}).join('');
return `<div class="day-group">${fmtDayLong(new Date(day))}</div>${items}`;
}).join('');
slotsEl.innerHTML = html;
slotsEl.querySelectorAll('.slot').forEach(btn => {
btn.addEventListener('click', () => {
slotsEl.querySelectorAll('.slot.is-selected').forEach(s => s.classList.remove('is-selected'));
btn.classList.add('is-selected');
startInput.value = btn.dataset.start;
endInput.value = btn.dataset.end;
const t = new Date(btn.dataset.start);
selectedEl.textContent = fmtDayLong(t) + ' at ' + fmtTime(t);
submit.disabled = false;
// Progressive disclosure — only reveal project + address fields once
// a slot is locked in. Reduces perceived form length by ~60%.
const step2 = form.querySelector('[data-step-2]');
if (step2) step2.hidden = false;
});
});
}
root.querySelector('[data-cal-prev]').addEventListener('click', () => {
const prev = new Date(windowStart);
prev.setDate(prev.getDate() - 14);
if (prev >= new Date(new Date().setHours(0, 0, 0, 0))) {
windowStart = prev;
load();
}
});
root.querySelector('[data-cal-next]').addEventListener('click', () => {
windowStart = new Date(windowStart);
windowStart.setDate(windowStart.getDate() + 14);
load();
});
const submitOriginalText = submit.textContent.trim();
function setError(msg) {
if (cardErrors) cardErrors.textContent = msg || '';
if (msg) submit.textContent = submitOriginalText;
submit.disabled = !!msg;
}
form.addEventListener('submit', async (e) => {
e.preventDefault();
if (!startInput.value) return;
setError('');
submit.disabled = true;
submit.textContent = 'Reserving…';
const data = Object.fromEntries(new FormData(form).entries());
// Step 1: create the booking + payment intent.
let j;
try {
const r = await fetch(`/api/installers/${encodeURIComponent(slug)}/book`, {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-csrf-token': csrf() },
body: JSON.stringify(data)
});
j = await r.json();
if (!r.ok) {
alert(j.error || 'Could not book. Please try a different slot.');
submit.disabled = false;
submit.textContent = submitOriginalText;
return;
}
} catch (err) {
alert('Network error. Please retry.');
submit.disabled = false;
submit.textContent = submitOriginalText;
return;
}
const deposit = j.deposit || null;
const qs = j.t ? `?t=${encodeURIComponent(j.t)}` : '';
const successUrl = `/bookings/${j.uuid}${qs}`;
// Step 2a: mocked deposit (no Stripe key on server) — skip card, redirect.
if (!deposit || deposit.mocked || !stripe || !cardEl || !deposit.client_secret) {
window.location = successUrl + (qs ? '&' : '?') + 'deposit=' + encodeURIComponent(deposit ? deposit.mode || 'mock' : 'none');
return;
}
// Step 2b: confirm card payment with the returned client_secret.
submit.textContent = 'Charging deposit…';
try {
const result = await stripe.confirmCardPayment(deposit.client_secret, {
payment_method: {
card: cardEl,
billing_details: {
name: data.customer_name || '',
email: data.customer_email || '',
phone: data.customer_phone || ''
}
}
});
if (result.error) {
setError(result.error.message || 'Card declined.');
submit.disabled = false;
submit.textContent = submitOriginalText;
return;
}
// Webhook flips deposit_status to 'paid'; redirect immediately.
window.location = successUrl;
} catch (err) {
setError('Payment failed. Please try a different card.');
submit.disabled = false;
submit.textContent = submitOriginalText;
}
});
function fmtDate(d) { return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); }
function fmtDayLong(d) { return d.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' }); }
function fmtTime(d) { return d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }); }
load();
})();