← 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();
})();