← back to Dw Theme Frontpage Cta

sections/appointment-booking.liquid

300 lines

{%- comment -%}
  appointment-booking.liquid — Designer Wallcoverings appointment scheduler.

  Self-contained OS-2.0 section. All CSS/JS scoped to #appt-{{ section.id }} so it
  never collides with the theme. Talks to the DW appointments API (the mountable
  router in shopify/quote-api/appointments) via fetch — availability + book.

  Settings (Theme Editor):
    api_base           backend origin, e.g. https://www.designerwallcoverings.com
    owner_email        calendar owner (info@designerwallcoverings.com)
    heading / intro    page copy
    in_person_note     small print under the In-person option
    days_ahead         how many days forward to offer
{%- endcomment -%}

{%- assign api_base = section.settings.api_base | default: 'https://api.designerwallcoverings.com' | strip -%}
{%- assign owner_email = section.settings.owner_email | default: 'info@designerwallcoverings.com' | strip -%}
{%- assign days_ahead = section.settings.days_ahead | default: 21 -%}

<section id="appt-{{ section.id }}" class="appt-root" data-api="{{ api_base }}" data-owner="{{ owner_email }}" data-days="{{ days_ahead }}">
  <div class="appt-wrap">
    <p class="appt-kicker">{{ section.settings.kicker | default: 'Designer Wallcoverings' }}</p>
    <h2 class="appt-h">{{ section.settings.heading | default: 'Book an appointment' }}</h2>
    {%- if section.settings.intro != blank -%}<p class="appt-intro">{{ section.settings.intro }}</p>{%- endif -%}

    <div class="appt-card">
      <!-- STEP 1 — type -->
      <div class="appt-step">
        <div class="appt-step-t"><span>1</span> How would you like to meet?</div>
        <div class="appt-types" role="radiogroup" aria-label="Appointment type">
          <button type="button" class="appt-type is-active" data-type="phone" role="radio" aria-checked="true">
            <span class="appt-type-ic" aria-hidden="true">☎</span>
            <span class="appt-type-l">Phone call</span>
            <span class="appt-type-s">We call you · 1 hour</span>
          </button>
          <button type="button" class="appt-type" data-type="in_person" role="radio" aria-checked="false">
            <span class="appt-type-ic" aria-hidden="true">⌖</span>
            <span class="appt-type-l">In person at DW</span>
            <span class="appt-type-s">{{ section.settings.in_person_note | default: 'At our Sherman Oaks showroom · 1 hour' }}</span>
          </button>
        </div>
      </div>

      <!-- STEP 2 — day -->
      <div class="appt-step">
        <div class="appt-step-t"><span>2</span> Choose a day</div>
        <div class="appt-pager">
          <button type="button" class="appt-pg" data-pg="prev" aria-label="Earlier days">‹</button>
          <span class="appt-range" aria-live="polite"></span>
          <button type="button" class="appt-pg" data-pg="next" aria-label="Later days">›</button>
        </div>
        <div class="appt-days" role="listbox" aria-label="Available days"></div>
      </div>

      <!-- STEP 3 — time -->
      <div class="appt-step">
        <div class="appt-step-t"><span>3</span> Choose a time <em>(Pacific)</em></div>
        <div class="appt-slots" aria-live="polite"><p class="appt-hint">Pick a day above to see open times.</p></div>
      </div>

      <!-- STEP 4 — details -->
      <form class="appt-form" novalidate>
        <div class="appt-step-t"><span>4</span> Your details</div>
        <div class="appt-grid">
          <label class="appt-field"><span>Name</span><input name="name" required autocomplete="name"></label>
          <label class="appt-field"><span>Email</span><input name="email" type="email" required autocomplete="email"></label>
          <label class="appt-field" data-phone><span>Phone <em class="appt-req">(required)</em></span><input name="phone" type="tel" autocomplete="tel"></label>
          <label class="appt-field appt-span"><span>What would you like to discuss? <em>(optional)</em></span><textarea name="notes" rows="3" placeholder="Rooms, patterns, square footage, timeline…"></textarea></label>
        </div>
        <div class="appt-foot">
          <p class="appt-sel"><em>Select a type, day and time above.</em></p>
          <button type="submit" class="appt-book" disabled>Confirm appointment</button>
        </div>
        <div class="appt-msg" role="status" aria-live="assertive"></div>
      </form>
    </div>

    <!-- SUCCESS -->
    <div class="appt-done" hidden>
      <div class="appt-done-ic" aria-hidden="true">✓</div>
      <h3>You're booked.</h3>
      <p class="appt-done-when"></p>
      <div class="appt-done-actions">
        <a class="appt-book appt-ics" href="#">Add to calendar</a>
        <a class="appt-ghost appt-conf" href="#" target="_blank" rel="noopener">View confirmation</a>
      </div>
      <button type="button" class="appt-again">Book another</button>
    </div>
  </div>
</section>

<style>
  #appt-{{ section.id }}.appt-root{
    --bg:#f7f4ef;--card:#fff;--ink:#2a2622;--soft:#6b6357;--rule:#e7e1d7;
    --gold:#9c7a3c;--accent:#2a2622;--accent-ink:#fff;--active:#f3ece0;
    background:var(--bg);color:var(--ink);padding:48px 18px;
    font-family:{{ section.settings.body_font_stack | default: "-apple-system,'Segoe UI',Roboto,Helvetica,Arial,sans-serif" }};
  }
  #appt-{{ section.id }} *{box-sizing:border-box}
  #appt-{{ section.id }} .appt-wrap{max-width:760px;margin:0 auto}
  #appt-{{ section.id }} .appt-kicker{font-size:11px;letter-spacing:.34em;text-transform:uppercase;color:var(--gold);font-weight:600;margin:0 0 8px}
  #appt-{{ section.id }} .appt-h{font-family:'Cormorant Garamond',Georgia,serif;font-weight:600;font-size:40px;line-height:1.05;margin:0 0 10px}
  #appt-{{ section.id }} .appt-intro{color:var(--soft);font-size:16px;margin:0 0 26px;max-width:54ch}
  #appt-{{ section.id }} .appt-card{background:var(--card);border:1px solid var(--rule);border-radius:16px;padding:28px;box-shadow:0 30px 60px -42px rgba(42,38,34,.45)}
  #appt-{{ section.id }} .appt-step{padding:6px 0 22px;border-bottom:1px solid var(--rule);margin-bottom:22px}
  #appt-{{ section.id }} .appt-step-t{display:flex;align-items:center;gap:10px;font-size:13px;letter-spacing:.12em;text-transform:uppercase;color:var(--ink);font-weight:600;margin-bottom:14px}
  #appt-{{ section.id }} .appt-step-t em{font-style:normal;color:var(--soft);text-transform:none;letter-spacing:0;font-weight:400}
  #appt-{{ section.id }} .appt-step-t span{display:inline-flex;align-items:center;justify-content:center;width:22px;height:22px;border-radius:50%;background:var(--accent);color:var(--accent-ink);font-size:12px}
  #appt-{{ section.id }} .appt-types{display:grid;grid-template-columns:1fr 1fr;gap:12px}
  #appt-{{ section.id }} .appt-type{display:flex;flex-direction:column;align-items:flex-start;gap:3px;text-align:left;padding:16px;border:1px solid var(--rule);border-radius:12px;background:#fff;cursor:pointer;transition:.15s}
  #appt-{{ section.id }} .appt-type:hover{border-color:var(--gold)}
  #appt-{{ section.id }} .appt-type.is-active{border-color:var(--accent);background:var(--active);box-shadow:inset 0 0 0 1px var(--accent)}
  #appt-{{ section.id }} .appt-type-ic{font-size:20px}
  #appt-{{ section.id }} .appt-type-l{font-weight:600;font-size:15px}
  #appt-{{ section.id }} .appt-type-s{font-size:12.5px;color:var(--soft)}
  #appt-{{ section.id }} .appt-pager{display:flex;align-items:center;gap:14px;margin-bottom:12px}
  #appt-{{ section.id }} .appt-pg{width:34px;height:34px;border:1px solid var(--rule);background:#fff;border-radius:8px;cursor:pointer;font-size:18px;line-height:1;color:var(--ink)}
  #appt-{{ section.id }} .appt-pg:disabled{opacity:.32;cursor:not-allowed}
  #appt-{{ section.id }} .appt-range{font-size:13px;color:var(--soft);letter-spacing:.04em}
  #appt-{{ section.id }} .appt-days{display:grid;grid-template-columns:repeat(7,1fr);gap:7px}
  #appt-{{ section.id }} .appt-day{padding:10px 2px;border:1px solid var(--rule);border-radius:9px;background:#fff;text-align:center;cursor:pointer;transition:.12s}
  #appt-{{ section.id }} .appt-day:hover:not(.is-off){border-color:var(--gold)}
  #appt-{{ section.id }} .appt-day.is-active{border-color:var(--accent);background:var(--active);box-shadow:inset 0 0 0 1px var(--accent)}
  #appt-{{ section.id }} .appt-day.is-off{opacity:.34;cursor:not-allowed}
  #appt-{{ section.id }} .appt-day .dow{font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:var(--soft)}
  #appt-{{ section.id }} .appt-day .num{font-family:'Cormorant Garamond',Georgia,serif;font-size:22px;line-height:1;margin-top:3px}
  #appt-{{ section.id }} .appt-slots{display:grid;grid-template-columns:repeat(auto-fill,minmax(108px,1fr));gap:8px;min-height:46px}
  #appt-{{ section.id }} .appt-slot{padding:11px 6px;border:1px solid var(--rule);border-radius:8px;background:#fff;cursor:pointer;font-size:14px;text-align:center;transition:.12s}
  #appt-{{ section.id }} .appt-slot:hover{border-color:var(--gold)}
  #appt-{{ section.id }} .appt-slot.is-active{border-color:var(--accent);background:var(--accent);color:var(--accent-ink)}
  #appt-{{ section.id }} .appt-hint,#appt-{{ section.id }} .appt-empty{grid-column:1/-1;color:var(--soft);font-style:italic;font-size:14px;padding:6px 0}
  #appt-{{ section.id }} .appt-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px}
  #appt-{{ section.id }} .appt-span{grid-column:1/-1}
  #appt-{{ section.id }} .appt-field{display:block}
  #appt-{{ section.id }} .appt-field>span{display:block;font-size:11px;letter-spacing:.1em;text-transform:uppercase;color:var(--soft);margin-bottom:6px}
  #appt-{{ section.id }} .appt-field em{text-transform:none;letter-spacing:0;color:var(--soft)}
  #appt-{{ section.id }} .appt-field input,#appt-{{ section.id }} .appt-field textarea{width:100%;padding:12px;border:1px solid var(--rule);border-radius:9px;font:inherit;font-size:15px;color:var(--ink);background:#fff}
  #appt-{{ section.id }} .appt-field input:focus,#appt-{{ section.id }} .appt-field textarea:focus{outline:none;border-color:var(--accent)}
  #appt-{{ section.id }} .appt-foot{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-top:20px;flex-wrap:wrap}
  #appt-{{ section.id }} .appt-sel{font-size:14px;color:var(--soft);margin:0}
  #appt-{{ section.id }} .appt-sel strong{color:var(--ink)}
  #appt-{{ section.id }} .appt-book{background:var(--accent);color:var(--accent-ink);border:none;border-radius:9px;padding:14px 26px;font-size:13px;letter-spacing:.06em;font-weight:600;cursor:pointer;text-decoration:none;display:inline-block}
  #appt-{{ section.id }} .appt-book:disabled{opacity:.4;cursor:not-allowed}
  #appt-{{ section.id }} .appt-ghost{border:1px solid var(--rule);border-radius:9px;padding:14px 26px;font-size:13px;color:var(--ink);text-decoration:none;display:inline-block}
  #appt-{{ section.id }} .appt-msg{margin-top:14px;font-size:14px}
  #appt-{{ section.id }} .appt-msg.is-err{color:#9a3b3b}
  #appt-{{ section.id }} .appt-done{text-align:center;background:var(--card);border:1px solid var(--rule);border-radius:16px;padding:44px 28px}
  #appt-{{ section.id }} .appt-done-ic{width:54px;height:54px;border-radius:50%;background:#eef7ee;color:#2f6b2f;font-size:26px;display:flex;align-items:center;justify-content:center;margin:0 auto 14px}
  #appt-{{ section.id }} .appt-done h3{font-family:'Cormorant Garamond',Georgia,serif;font-size:30px;margin:0 0 6px}
  #appt-{{ section.id }} .appt-done-when{color:var(--soft);margin:0 0 22px}
  #appt-{{ section.id }} .appt-done-actions{display:flex;gap:12px;justify-content:center;flex-wrap:wrap}
  #appt-{{ section.id }} .appt-again{margin-top:18px;background:none;border:none;color:var(--gold);cursor:pointer;font-size:13px;text-decoration:underline}
  @media (max-width:560px){
    #appt-{{ section.id }} .appt-h{font-size:30px}
    #appt-{{ section.id }} .appt-types,#appt-{{ section.id }} .appt-grid{grid-template-columns:1fr}
    #appt-{{ section.id }} .appt-days{grid-template-columns:repeat(4,1fr)}
  }
</style>

<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@500;600&display=swap" media="print" onload="this.media='all'">

<script>
(function(){
  var root = document.getElementById('appt-{{ section.id }}');
  if(!root || root.dataset.bound) return; root.dataset.bound='1';
  var API = root.dataset.api.replace(/\/+$/,''), OWNER = root.dataset.owner, DAYS = parseInt(root.dataset.days,10)||21;
  var $ = function(s){return root.querySelector(s)}, $$ = function(s){return [].slice.call(root.querySelectorAll(s))};
  var state = {type:'phone', date:null, slot:null, pageStart:0};
  var availCache = {}; // iso -> slots[]

  function fmtTime(iso){return new Date(iso).toLocaleTimeString([], {hour:'numeric',minute:'2-digit',timeZone:'America/Los_Angeles'});}
  function fmtLong(iso){return new Date(iso).toLocaleString([], {weekday:'long',month:'long',day:'numeric',hour:'numeric',minute:'2-digit',timeZone:'America/Los_Angeles'});}
  function isoOf(d){return d.getFullYear()+'-'+String(d.getMonth()+1).padStart(2,'0')+'-'+String(d.getDate()).padStart(2,'0');}

  // ── type ──
  $$('.appt-type').forEach(function(b){b.addEventListener('click',function(){
    state.type=b.dataset.type;
    $$('.appt-type').forEach(function(x){var on=x===b;x.classList.toggle('is-active',on);x.setAttribute('aria-checked',on);});
    var pf=$('.appt-field[data-phone]'); var req=pf.querySelector('.appt-req');
    if(state.type==='phone'){req.textContent='(required)';} else {req.textContent='(optional)';}
    updateFoot();
  });});

  // ── days ──
  function renderDays(){
    var wrap=$('.appt-days'), now=new Date(), html='', first=null, last=null;
    for(var i=0;i<7;i++){
      var idx=state.pageStart+i; if(idx>=DAYS) break;
      var d=new Date(now.getFullYear(),now.getMonth(),now.getDate()+idx);
      var iso=isoOf(d), dow=['Sun','Mon','Tue','Wed','Thu','Fri','Sat'][d.getDay()];
      var off=(d.getDay()===0||d.getDay()===6); // weekends closed (9–4 Mon–Fri)
      if(!first)first=d; last=d;
      html+='<div class="appt-day'+(off?' is-off':'')+(state.date===iso?' is-active':'')+'" data-iso="'+iso+'" role="option" aria-selected="'+(state.date===iso)+'"'+(off?'':' tabindex="0"')+'><div class="dow">'+dow+'</div><div class="num">'+d.getDate()+'</div></div>';
    }
    wrap.innerHTML=html;
    $('.appt-range').textContent = first&&last ? first.toLocaleDateString([],{month:'short',day:'numeric'})+' – '+last.toLocaleDateString([],{month:'short',day:'numeric'}) : '';
    $('.appt-pg[data-pg="prev"]').disabled = state.pageStart===0;
    $('.appt-pg[data-pg="next"]').disabled = state.pageStart+7>=DAYS;
    $$('.appt-day').forEach(function(el){
      if(el.classList.contains('is-off')) return;
      var pick=function(){selectDay(el.dataset.iso);};
      el.addEventListener('click',pick);
      el.addEventListener('keydown',function(e){if(e.key==='Enter'||e.key===' '){e.preventDefault();pick();}});
    });
  }
  $('.appt-pg[data-pg="prev"]').addEventListener('click',function(){state.pageStart=Math.max(0,state.pageStart-7);renderDays();});
  $('.appt-pg[data-pg="next"]').addEventListener('click',function(){if(state.pageStart+7<DAYS){state.pageStart+=7;renderDays();}});

  function selectDay(iso){
    state.date=iso; state.slot=null; renderDays(); updateFoot();
    var box=$('.appt-slots'); box.innerHTML='<p class="appt-hint">Loading open times…</p>';
    var done=function(slots){availCache[iso]=slots;renderSlots(slots);};
    if(availCache[iso]) return done(availCache[iso]);
    fetch(API+'/api/appointments/availability?owner_email='+encodeURIComponent(OWNER)+'&date='+iso)
      .then(function(r){return r.json();})
      .then(function(j){ if(!j.ok) throw new Error(j.error||'load failed'); done(j.slots||[]); })
      .catch(function(e){ box.innerHTML='<p class="appt-empty">Could not load times ('+e.message+'). Please try again.</p>'; });
  }

  function renderSlots(slots){
    var box=$('.appt-slots');
    if(!slots.length){ box.innerHTML='<p class="appt-empty">No open times that day — try another.</p>'; return; }
    box.innerHTML = slots.map(function(s){
      var on=state.slot&&state.slot.slot_start===s.slot_start;
      return '<button type="button" class="appt-slot'+(on?' is-active':'')+'" data-s="'+s.slot_start+'" data-e="'+s.slot_end+'">'+fmtTime(s.slot_start)+'</button>';
    }).join('');
    $$('.appt-slot').forEach(function(el){el.addEventListener('click',function(){
      state.slot={slot_start:el.dataset.s,slot_end:el.dataset.e};
      $$('.appt-slot').forEach(function(x){x.classList.toggle('is-active',x===el);});
      updateFoot();
    });});
  }

  function updateFoot(){
    var sel=$('.appt-sel'), btn=$('.appt-book');
    if(state.slot){
      sel.innerHTML='Selected: <strong>'+fmtLong(state.slot.slot_start)+'</strong> Pacific';
      btn.disabled=false;
    } else { sel.innerHTML='<em>Select a type, day and time above.</em>'; btn.disabled=true; }
  }

  // ── submit ──
  $('.appt-form').addEventListener('submit',function(ev){
    ev.preventDefault();
    if(!state.slot) return;
    var f=ev.target, msg=$('.appt-msg'); msg.textContent=''; msg.className='appt-msg';
    var name=f.name.value.trim(), email=f.email.value.trim(), phone=f.phone.value.trim(), notes=f.notes.value.trim();
    if(!name||!email){ msg.textContent='Please enter your name and email.'; msg.className='appt-msg is-err'; return; }
    if(state.type==='phone'&&!phone){ msg.textContent='A phone number is required for a phone appointment.'; msg.className='appt-msg is-err'; f.phone.focus(); return; }
    var btn=$('.appt-book'); btn.disabled=true; var prev=btn.textContent; btn.textContent='Booking…';
    fetch(API+'/api/appointments/book',{method:'POST',headers:{'Content-Type':'application/json'},
      body:JSON.stringify({name:name,email:email,phone:phone,notes:notes,appointment_type:state.type,
        owner_email:OWNER,slot_start:state.slot.slot_start,slot_end:state.slot.slot_end})})
    .then(function(r){return r.json();})
    .then(function(j){
      if(!j.ok) throw new Error(j.error||'Could not book');
      var d=root.querySelector('.appt-done');
      d.querySelector('.appt-done-when').textContent=fmtLong(state.slot.slot_start)+' Pacific · '+(state.type==='in_person'?'In person at DW':'Phone call');
      d.querySelector('.appt-ics').setAttribute('href', j.ics_url);
      d.querySelector('.appt-conf').setAttribute('href', j.confirmation_url);
      root.querySelector('.appt-card').hidden=true; d.hidden=false;
      d.scrollIntoView({behavior:'smooth',block:'center'});
    })
    .catch(function(e){
      msg.textContent=e.message; msg.className='appt-msg is-err'; btn.disabled=false; btn.textContent=prev;
      if(/taken|available/i.test(e.message)&&state.date){ delete availCache[state.date]; selectDay(state.date); }
    });
  });

  $('.appt-again').addEventListener('click',function(){
    state.slot=null; availCache={}; root.querySelector('.appt-done').hidden=true;
    root.querySelector('.appt-card').hidden=false; $('.appt-form').reset();
    $('.appt-slots').innerHTML='<p class="appt-hint">Pick a day above to see open times.</p>';
    if(state.date) selectDay(state.date); updateFoot();
  });

  renderDays();
})();
</script>

{% schema %}
{
  "name": "Appointment Booking",
  "tag": "section",
  "class": "section-appointment-booking",
  "settings": [
    { "type": "header", "content": "Backend" },
    { "type": "text", "id": "api_base", "label": "Appointments API base URL", "default": "https://api.designerwallcoverings.com", "info": "Origin of the DW appointments service (Kamatera koroseal-quote-api behind api.designerwallcoverings.com). CORS is handled server-side." },
    { "type": "text", "id": "owner_email", "label": "Calendar owner email", "default": "info@designerwallcoverings.com" },
    { "type": "range", "id": "days_ahead", "min": 7, "max": 63, "step": 7, "unit": "d", "label": "Days bookable ahead", "default": 21 },
    { "type": "header", "content": "Copy" },
    { "type": "text", "id": "kicker", "label": "Kicker", "default": "Designer Wallcoverings" },
    { "type": "text", "id": "heading", "label": "Heading", "default": "Book an appointment" },
    { "type": "richtext", "id": "intro", "label": "Intro", "default": "<p>Speak with a Designer Wallcoverings consultant by phone, or visit us in person. Appointments run one hour, weekdays 9 AM–4 PM Pacific.</p>" },
    { "type": "text", "id": "in_person_note", "label": "In-person sub-label", "default": "At our Sherman Oaks showroom · 1 hour" }
  ],
  "presets": [{ "name": "Appointment Booking" }]
}
{% endschema %}