← 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 %}