@tryvitris/website-harness 1.0.2 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +76 -74
- package/agent/graph.yaml +769 -764
- package/agent/refs/admin.md +11 -11
- package/agent/refs/browser.md +6 -4
- package/agent/refs/business.md +20 -9
- package/agent/refs/implement.md +8 -6
- package/agent/refs/scheduling.md +13 -0
- package/bin/api/agent/evaluate.js +3 -3
- package/bin/api/agent/grade.js +3 -3
- package/bin/api/cli/api.js +11 -11
- package/bin/api/cli.js +13 -13
- package/bin/api/engine.js +4 -4
- package/bin/api/hosting.js +5 -5
- package/bin/api/index.js +1 -1
- package/bin/chunks/{chunk-Q6FLOYHS.js → chunk-5KRJQKHB.js} +3 -3
- package/bin/chunks/{chunk-YYK6367V.js → chunk-AD7WAHW2.js} +2 -2
- package/bin/chunks/{chunk-GO3XEQBA.js → chunk-AS74YXGM.js} +3 -3
- package/bin/chunks/{chunk-GM57SNLL.js → chunk-DDJ3QCRR.js} +9 -9
- package/bin/chunks/chunk-GPS3SAGQ.js +1020 -0
- package/bin/chunks/{chunk-ATPI2YRF.js → chunk-KR5ZUZV2.js} +2 -2
- package/bin/chunks/{chunk-YYYPSFFZ.js → chunk-MAPY3YVE.js} +2 -2
- package/bin/chunks/{chunk-OQGUEQ6F.js → chunk-MYX4FET4.js} +1 -1
- package/bin/chunks/{chunk-XJTAR7KM.js → chunk-OZD7ZWDX.js} +1 -1
- package/bin/chunks/{chunk-CPQQQ5YB.js → chunk-SBOUSO3S.js} +3 -3
- package/bin/chunks/{chunk-BMD5GVUT.js → chunk-SXPLCCO7.js} +6 -6
- package/bin/chunks/{chunk-QRIYA3GJ.js → chunk-WIT6364Y.js} +3 -3
- package/bin/chunks/{chunk-JPQCUN2J.js → chunk-WK7HJI5H.js} +1 -1
- package/bin/chunks/{design-context-store-BDYBQQCO.js → design-context-store-PGXCHUOF.js} +3 -3
- package/bin/chunks/{packets-RXFWDCK2.js → packets-B7MCJ42T.js} +3 -3
- package/bin/chunks/{preparation-ETEEMB5H.js → preparation-MVKICQPD.js} +3 -3
- package/bin/types/src/build/index.d.ts +2 -0
- package/bin/types/src/cli/admin.d.ts +1 -1
- package/bin/types/src/scheduling/config.d.ts +11 -0
- package/bin/types/src/scheduling/management.d.ts +2 -0
- package/bin/types/src/scheduling/styles.d.ts +1 -0
- package/bin/types/src/scheduling/widget.d.ts +2 -0
- package/bin/vitris-art.js +3 -3
- package/bin/vitris-build.js +2 -2
- package/bin/vitris-create-site.js +5 -5
- package/bin/vitris-cursor-hook.js +3 -3
- package/bin/vitris-eval.js +5 -5
- package/bin/vitris-go.js +5 -5
- package/bin/vitris-harvest.js +4 -4
- package/bin/vitris-knowledge.js +4 -4
- package/bin/vitris-launch-preflight.js +3 -3
- package/bin/vitris-motion-view.js +3 -3
- package/bin/vitris-references.js +2 -2
- package/bin/vitris-refresh.js +4 -4
- package/bin/vitris-stock.js +1 -1
- package/bin/vitris-themes.js +3 -3
- package/bin/vitris-upgrade.js +4 -4
- package/bin/vitris-verify-browser.js +3 -3
- package/bin/vitris-verify-performance.js +2 -2
- package/bin/vitris.js +13 -13
- package/docs/ADMIN.md +85 -85
- package/docs/CLI.md +314 -312
- package/docs/SCHEDULING.md +67 -0
- package/package.json +5 -4
- package/bin/chunks/chunk-NMTFSWPK.js +0 -332
|
@@ -0,0 +1,1020 @@
|
|
|
1
|
+
import {
|
|
2
|
+
COOKIE_COMPLIANCE_PATH,
|
|
3
|
+
beaconJs,
|
|
4
|
+
compileAllPages,
|
|
5
|
+
compileSite,
|
|
6
|
+
defaultTitleFromPath,
|
|
7
|
+
fileToPath,
|
|
8
|
+
motionAssetHref,
|
|
9
|
+
motionJs,
|
|
10
|
+
parseFrontmatter,
|
|
11
|
+
robotsTxt,
|
|
12
|
+
siteCss,
|
|
13
|
+
siteIslands,
|
|
14
|
+
sitemapXml
|
|
15
|
+
} from "./chunk-JTXFTNEA.js";
|
|
16
|
+
import {
|
|
17
|
+
mergeTheme,
|
|
18
|
+
themeCss
|
|
19
|
+
} from "./chunk-GE6UUEG2.js";
|
|
20
|
+
import {
|
|
21
|
+
escapeHtml
|
|
22
|
+
} from "./chunk-7FS67SRF.js";
|
|
23
|
+
|
|
24
|
+
// src/scheduling/management.ts
|
|
25
|
+
var managementJs = String.raw`(() => {
|
|
26
|
+
const fragment = new URLSearchParams(location.hash.slice(1));
|
|
27
|
+
let linkToken = fragment.get('token') || '';
|
|
28
|
+
const intent = fragment.get('intent') === 'cancel' ? 'cancel' : fragment.get('intent') === 'reschedule' ? 'reschedule' : '';
|
|
29
|
+
if (location.hash || location.search) history.replaceState(null, '', location.pathname);
|
|
30
|
+
window.addEventListener('hashchange', () => { if (location.hash) location.reload(); });
|
|
31
|
+
const begin = () => {
|
|
32
|
+
const root = document.querySelector('[data-vitris-customer-management]');
|
|
33
|
+
const metadata = document.querySelector('script[data-vitris-customer-config]');
|
|
34
|
+
if (!root || !metadata) return;
|
|
35
|
+
root.classList.add('vb');
|
|
36
|
+
const node = (tag, text, className) => { const el = document.createElement(tag); if (text !== undefined) el.textContent = text; if (className) el.className = className; return el; };
|
|
37
|
+
let connection;
|
|
38
|
+
try { connection = JSON.parse(metadata.textContent || ''); if (connection.siteOrigin !== location.origin) throw Error(); }
|
|
39
|
+
catch { root.append(node('p', 'Appointment management is available on the approved business website.', 'vb-error')); return; }
|
|
40
|
+
let session = '', appointment, step = 'entry', busy = false, error = '', notice = '', slots = [], selected = null, day = '', revision = 0, appointmentRevision = 0, attempt = null, uncertain = false, downloadUrl = '';
|
|
41
|
+
const time = at => new Intl.DateTimeFormat('en-US', {timeZone: appointment.timezone, hour:'numeric', minute:'2-digit', timeZoneName:'shortOffset'}).format(new Date(at));
|
|
42
|
+
const fullDate = at => new Intl.DateTimeFormat('en-US', {timeZone: appointment.timezone, weekday:'short', month:'long', day:'numeric', year:'numeric'}).format(new Date(at));
|
|
43
|
+
const dateOf = at => { const parts = new Intl.DateTimeFormat('en-US', {timeZone:appointment.timezone, year:'numeric', month:'2-digit', day:'2-digit'}).formatToParts(new Date(at)), get = key => parts.find(p => p.type === key).value; return get('year') + '-' + get('month') + '-' + get('day'); };
|
|
44
|
+
const addDays = (value, n) => new Date(Date.parse(value + 'T12:00:00Z') + n * 86400000).toISOString().slice(0,10);
|
|
45
|
+
const dayStart = value => { const target = Date.parse(value + 'T00:00:00Z'); for(let at=target-14*3600000;at<=target+14*3600000;at+=60000) if(dateOf(at)===value) return new Date(at).toISOString(); throw Error('This date is unavailable in the appointment timezone.'); };
|
|
46
|
+
const api = async (path, body, authenticated = true) => {
|
|
47
|
+
const response = await fetch(connection.apiOrigin + '/api/booking/v1/manage/' + path, {method:'POST',mode:'cors',credentials:'omit',cache:'no-store',referrerPolicy:'no-referrer',signal:AbortSignal.timeout(25000),headers:{'content-type':'application/json',...(authenticated?{authorization:'Bearer '+session}:{})},body:JSON.stringify(body)});
|
|
48
|
+
const text = await response.text(); if(text.length>1000000) throw Error('The appointment response could not be verified.');
|
|
49
|
+
let result;try{result=JSON.parse(text);}catch{throw Error('Appointment management is temporarily unavailable.');}
|
|
50
|
+
if(!response.ok) throw Object.assign(new Error(typeof result.error==='string'?result.error:'The appointment request failed.'),{status:response.status,code:result.code});
|
|
51
|
+
return result;
|
|
52
|
+
};
|
|
53
|
+
const button = (label, action, primary = false) => {const el=node('button',label,primary?'vb-primary':undefined);el.type='button';el.disabled=busy;el.dataset.focusKey=label;el.addEventListener('click',action);return el;};
|
|
54
|
+
const changeStep = value => {if(busy||uncertain)return;step=value;error='';notice='';selected=null;render(true);};
|
|
55
|
+
const detail = (list,label,value) => {const row=node('div');row.append(node('dt',label),node('dd',value));list.append(row);};
|
|
56
|
+
const price = value => {if(value.amountMinor===null)return value.label || 'Price agreed with the business';const number=new Intl.NumberFormat('en-US',{style:'currency',currency:value.currency}),digits=number.resolvedOptions().maximumFractionDigits || 0;return number.format(value.amountMinor/10**digits);};
|
|
57
|
+
const exchange = async () => {
|
|
58
|
+
busy=true;error='';render();
|
|
59
|
+
try {
|
|
60
|
+
const result=await api('exchange',{token:linkToken},false);
|
|
61
|
+
if(result.schemaVersion!==1||result.siteId!==connection.siteId||result.installationId!==connection.installationId||result.siteOrigin!==connection.siteOrigin||typeof result.sessionToken!=='string'||result.appointment?.schemaVersion!==1)throw Error('This appointment connection could not be verified.');
|
|
62
|
+
session=result.sessionToken;linkToken='';appointment=result.appointment;revision=appointment.scheduleRevision;appointmentRevision=appointment.revision;day=dateOf(appointment.startsAt);
|
|
63
|
+
step=intent==='cancel'&&appointment.canCancel?'cancel':intent==='reschedule'&&appointment.canReschedule?'reschedule':'details';
|
|
64
|
+
} catch(failure) {error=failure.message || 'This link is unavailable. Reopen the email or contact the business.';}
|
|
65
|
+
finally{busy=false;render(true);}
|
|
66
|
+
};
|
|
67
|
+
const refresh = async () => {
|
|
68
|
+
if(busy)return;busy=true;error='';
|
|
69
|
+
try{const value=await api('appointment',{});if(value.reference!==appointment.reference)throw Error('The appointment response could not be verified.');appointment=value;revision=value.scheduleRevision;appointmentRevision=value.revision;step='details';uncertain=false;attempt=null;slots=[];selected=null;}
|
|
70
|
+
catch(failure){error=failure.message || 'Reopen the link from your email or contact the business.';}
|
|
71
|
+
finally{busy=false;render(true);}
|
|
72
|
+
};
|
|
73
|
+
const findTimes = async () => {
|
|
74
|
+
if(busy||uncertain)return;busy=true;error='';notice='Checking available times...';selected=null;render();
|
|
75
|
+
try{const data=await api('availability',{startsAt:dayStart(day),endsAt:dayStart(addDays(day,1))});if(data.schemaVersion!==1||!Array.isArray(data.slots)||data.slots.length>500)throw Error('The availability response could not be verified.');slots=data.slots.filter(value=>value.startsAt!==appointment.startsAt);revision=data.revision;appointmentRevision=data.appointmentRevision;notice=slots.length?'Choose a new time. Your original appointment stays reserved until the change succeeds.':'No other times are available for this day. Try another date or contact the business.';}
|
|
76
|
+
catch(failure){error=failure.message || 'Available times could not be loaded.';notice='';slots=[];}
|
|
77
|
+
finally{busy=false;render();}
|
|
78
|
+
};
|
|
79
|
+
const submit = async kind => {
|
|
80
|
+
if(busy)return;
|
|
81
|
+
if(!attempt||!uncertain)attempt={kind,body:{idempotencyKey:crypto.randomUUID(),revision,appointmentRevision,...(kind==='reschedule'?{startsAt:selected.startsAt}:{})}};
|
|
82
|
+
busy=true;error='';notice='Saving your change...';render();
|
|
83
|
+
try{const result=await api(attempt.kind,attempt.body);if(result.schemaVersion!==1||result.reference!==appointment.reference)throw Error('The appointment response could not be verified.');appointment=result;revision=result.scheduleRevision;appointmentRevision=result.revision;uncertain=false;attempt=null;step='done';notice='Your change is saved in the business calendar.';}
|
|
84
|
+
catch(failure){error=failure.message || 'The appointment response was interrupted.';notice='';if(!failure.status||failure.status>=500){uncertain=true;error+=' The result may still be pending. Retry this same change; do not start another request.';}else{uncertain=false;attempt=null;if(failure.status===409){step='details';notice='Refresh the appointment before trying another time. Your original appointment has been kept unless the business changed it.';}}}
|
|
85
|
+
finally{busy=false;render(true);}
|
|
86
|
+
};
|
|
87
|
+
function render(focus=false){
|
|
88
|
+
const focusKey=document.activeElement?.dataset.focusKey;root.replaceChildren();
|
|
89
|
+
const panel=node('section',undefined,'vb-panel');root.append(panel);
|
|
90
|
+
const title=step==='entry'?'View your appointment':step==='cancel'?'Cancel this appointment?':step==='reschedule'?'Choose a new time':step==='review'?'Review the new appointment time':step==='done'?(appointment.status==='cancelled'?'Appointment cancelled':appointment.status==='pending_approval'?'Request time changed':'Appointment rescheduled'):'Your appointment';
|
|
91
|
+
const heading=node('h2',title);heading.tabIndex=-1;panel.append(heading);
|
|
92
|
+
if(error){const el=node('p',error,'vb-error');el.setAttribute('role','alert');panel.append(el);}
|
|
93
|
+
if(notice){const el=node('p',notice,'vb-notice');el.setAttribute('role','status');panel.append(el);}
|
|
94
|
+
if(step==='entry'){
|
|
95
|
+
panel.append(node('p','This private link can view or change only the appointment named in your email. No appointment is changed by opening this page.'));
|
|
96
|
+
if(linkToken)panel.append(button(busy?'Opening...':'View appointment',()=>{void exchange();},true));else panel.append(node('p','Open a valid link from your appointment email. If the link expired, contact the business.'));
|
|
97
|
+
}else if(appointment){
|
|
98
|
+
const list=node('dl');detail(list,'Service',appointment.serviceName);detail(list,'Current time',fullDate(appointment.startsAt)+' · '+time(appointment.startsAt));detail(list,'Location',appointment.locationName);detail(list,'Staff',appointment.providerName);detail(list,'Price',price(appointment.price));detail(list,'Status',appointment.status.replaceAll('_',' '));panel.append(list);
|
|
99
|
+
if(appointment.status==='pending_approval')panel.append(node('p','This request is still waiting for business approval. Changing its time does not confirm it.','vb-notice'));
|
|
100
|
+
if(appointment.policyText)panel.append(node('h3','Booking policy'),node('p',appointment.policyText,'vb-policy'));
|
|
101
|
+
if(step==='details'){
|
|
102
|
+
if(appointment.cancellationDeadline)panel.append(node('p','Online cancellation deadline: '+fullDate(appointment.cancellationDeadline)+' · '+time(appointment.cancellationDeadline),'vb-hint'));
|
|
103
|
+
if(appointment.rescheduleDeadline)panel.append(node('p','Online rescheduling deadline: '+fullDate(appointment.rescheduleDeadline)+' · '+time(appointment.rescheduleDeadline),'vb-hint'));
|
|
104
|
+
const actions=node('div',undefined,'vb-actions');if(appointment.canCancel)actions.append(button('Cancel appointment',()=>changeStep('cancel')));if(appointment.canReschedule)actions.append(button('Reschedule appointment',()=>changeStep('reschedule'),true));actions.append(button('Refresh appointment',()=>{void refresh();}));panel.append(actions);
|
|
105
|
+
if(!appointment.canCancel||!appointment.canReschedule)panel.append(node('p','Some online changes are unavailable or their notice deadline has passed. Contact the business for help.','vb-hint'));
|
|
106
|
+
}else if(step==='cancel'){
|
|
107
|
+
panel.append(node('p','Cancelling releases this appointment time. This action takes effect only after you confirm.'));
|
|
108
|
+
const actions=node('div',undefined,'vb-actions');if(!uncertain)actions.append(button('Keep appointment',()=>changeStep('details')));actions.append(button(busy?'Cancelling...':uncertain?'Retry same change':'Confirm cancellation',()=>{void submit('cancel');},true));panel.append(actions);
|
|
109
|
+
}else if(step==='reschedule'){
|
|
110
|
+
panel.append(node('p','Your original time remains reserved until the new time is saved. Staff, location, duration and price stay the same.','vb-notice'));
|
|
111
|
+
const input=node('input');input.type='date';input.value=day;input.min=dateOf(Date.now());input.max=dateOf(Date.now()+appointment.horizonDays*86400000);input.disabled=busy;input.dataset.focusKey='new-date';input.addEventListener('change',()=>{if(input.value){day=input.value;slots=[];selected=null;error='';notice='';render();}});const label=node('label');label.append(node('span','New appointment date'),input);panel.append(label,button('Find new times',()=>{void findTimes();}));
|
|
112
|
+
const group=node('div',undefined,'vb-times');group.setAttribute('aria-label','Available new times');for(const value of slots){const b=button(time(value.startsAt),()=>{selected=value;render();});b.dataset.focusKey=value.startsAt;b.setAttribute('aria-pressed',String(selected?.startsAt===value.startsAt));group.append(b);}panel.append(group);
|
|
113
|
+
const actions=node('div',undefined,'vb-actions'),next=button('Review new time',()=>{step='review';render(true);},true);next.disabled=!selected||busy;actions.append(button('Keep current time',()=>changeStep('details')),next);panel.append(actions);
|
|
114
|
+
}else if(step==='review'){
|
|
115
|
+
panel.append(node('h3','New time'),node('p',fullDate(selected.startsAt)+' · '+time(selected.startsAt)));
|
|
116
|
+
const actions=node('div',undefined,'vb-actions');if(!uncertain)actions.append(button('Choose another time',()=>changeStep('reschedule')));actions.append(button(busy?'Saving...':uncertain?'Retry same change':'Confirm new time',()=>{void submit('reschedule');},true));panel.append(actions);
|
|
117
|
+
}else if(step==='done'){
|
|
118
|
+
panel.append(node('p','Reference: '+appointment.reference,'vb-reference'));
|
|
119
|
+
if(downloadUrl)URL.revokeObjectURL(downloadUrl);downloadUrl=URL.createObjectURL(new Blob([appointment.calendar.content],{type:'text/calendar;charset=utf-8'}));const link=node('a','Download updated calendar file (.ics)');link.href=downloadUrl;link.download='appointment.ics';panel.append(link,node('p','Imported calendar files are snapshots and may need to be updated manually.','vb-hint'));
|
|
120
|
+
if(appointment.status!=='cancelled')panel.append(button('View appointment details',()=>changeStep('details')));
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if(focus)heading.focus();else if(focusKey)root.querySelector('[data-focus-key="'+CSS.escape(focusKey)+'"]')?.focus({preventScroll:true});
|
|
124
|
+
}
|
|
125
|
+
render();
|
|
126
|
+
window.addEventListener('beforeunload',event=>{if(uncertain||busy){event.preventDefault();event.returnValue='';}});
|
|
127
|
+
};
|
|
128
|
+
if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',begin,{once:true});else begin();
|
|
129
|
+
})();`;
|
|
130
|
+
|
|
131
|
+
// src/scheduling/config.ts
|
|
132
|
+
function schedulingConnection(value) {
|
|
133
|
+
if (value === void 0) return;
|
|
134
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw Error("Invalid scheduling connection");
|
|
135
|
+
const v = value;
|
|
136
|
+
if (Object.keys(v).some((k) => !["version", "enabled", "apiOrigin", "siteId", "installationId", "route"].includes(k)) || v.version !== 1 || typeof v.enabled !== "boolean") throw Error("Invalid scheduling connection fields");
|
|
137
|
+
const id = (x) => typeof x === "string" && /^[a-zA-Z0-9_-]{1,100}$/.test(x);
|
|
138
|
+
if (!id(v.siteId) || !id(v.installationId)) throw Error("Scheduling requires the exact public site and installation identity");
|
|
139
|
+
const apiOrigin = bookingOrigin(v.apiOrigin), route = v.route ?? "/book";
|
|
140
|
+
if (typeof route !== "string" || !/^\/[a-zA-Z0-9_-]+(?:\/[a-zA-Z0-9_-]+)*$/.test(route) || ["/api", "/assets", "/media"].some((p) => route === p || route.startsWith(p + "/"))) throw Error("Invalid booking route");
|
|
141
|
+
return { version: 1, enabled: v.enabled, apiOrigin, siteId: v.siteId, installationId: v.installationId, route };
|
|
142
|
+
}
|
|
143
|
+
function bookingOrigin(value) {
|
|
144
|
+
if (typeof value !== "string") throw Error("Scheduling requires an exact origin");
|
|
145
|
+
const url = new URL(value), local = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
|
|
146
|
+
if (url.origin !== value || url.username || url.password || url.protocol !== "https:" && !(local && url.protocol === "http:")) throw Error("Scheduling requires HTTPS or an explicit loopback origin");
|
|
147
|
+
return value;
|
|
148
|
+
}
|
|
149
|
+
function bookingPage(name) {
|
|
150
|
+
return `<header class="vb-site-header"><a href="/">${escapeHtml(name)}</a></header><main class="vb-page"><p class="vb-eyebrow">APPOINTMENTS</p><h1>Book an appointment</h1><p>Choose a service and a time that works for you.</p><div data-vitris-booking></div></main>`;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// src/scheduling/widget.ts
|
|
154
|
+
var bookingJs = `(function initializeBooking() {
|
|
155
|
+
const root = document.querySelector("[data-vitris-booking]");
|
|
156
|
+
if (!root) return;
|
|
157
|
+
const metadata = document.querySelector("script[data-vitris-booking-config]");
|
|
158
|
+
if (!metadata) return;
|
|
159
|
+
root.classList.add("vb");
|
|
160
|
+
const node = (tag, text, className) => {
|
|
161
|
+
const el = document.createElement(tag);
|
|
162
|
+
if (text !== void 0) el.textContent = text;
|
|
163
|
+
if (className) el.className = className;
|
|
164
|
+
return el;
|
|
165
|
+
};
|
|
166
|
+
const notice = (text) => {
|
|
167
|
+
const el = node("p", text, "vb-notice");
|
|
168
|
+
el.setAttribute("role", "status");
|
|
169
|
+
return el;
|
|
170
|
+
};
|
|
171
|
+
let connection;
|
|
172
|
+
try {
|
|
173
|
+
connection = JSON.parse(metadata.textContent || "");
|
|
174
|
+
if (connection.siteOrigin !== location.origin) throw Error();
|
|
175
|
+
} catch {
|
|
176
|
+
root.append(notice("Booking is available on the approved business website. This preview is not connected."));
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
let catalog, step = 0, serviceId = "", locationId = "", providerId = "", day = "", slot = null, slots = [], revision = 0, busy = false, requestSerial = 0, error = "", status = "", customer = { name: "", email: "", phone: "" }, accepted = false, challengeToken = "", challengeId, receipt, company = "", attemptKey = "", attemptPayload = "", uncertain = false;
|
|
180
|
+
let objectUrl = "";
|
|
181
|
+
const api = async (path, data) => {
|
|
182
|
+
const response = await fetch(connection.apiOrigin + "/api/booking/v1" + path, { mode: "cors", credentials: "omit", cache: "no-store", referrerPolicy: "no-referrer", signal: AbortSignal.timeout(25e3), ...data === void 0 ? {} : { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(data) } });
|
|
183
|
+
const text = await response.text();
|
|
184
|
+
if (text.length > 1e6) throw Error("The booking response could not be verified.");
|
|
185
|
+
let result;
|
|
186
|
+
try {
|
|
187
|
+
result = JSON.parse(text);
|
|
188
|
+
} catch {
|
|
189
|
+
throw Error("Booking is temporarily unavailable. Please contact the business.");
|
|
190
|
+
}
|
|
191
|
+
if (!response.ok) {
|
|
192
|
+
const failure = Object.assign(new Error(typeof result.error === "string" ? result.error : "Booking could not be completed."), { status: response.status, code: result.code });
|
|
193
|
+
throw failure;
|
|
194
|
+
}
|
|
195
|
+
return result;
|
|
196
|
+
};
|
|
197
|
+
const service = () => catalog.services.find((s) => s.id === serviceId);
|
|
198
|
+
const place = () => catalog.locations.find((l) => l.id === locationId);
|
|
199
|
+
const dateOf = (at, zone) => {
|
|
200
|
+
const parts = new Intl.DateTimeFormat("en-US", { timeZone: zone, year: "numeric", month: "2-digit", day: "2-digit" }).formatToParts(new Date(at)), get = (key) => parts.find((p) => p.type === key).value;
|
|
201
|
+
return get("year") + "-" + get("month") + "-" + get("day");
|
|
202
|
+
};
|
|
203
|
+
const addDays = (value, count) => new Date(Date.parse(value + "T12:00:00Z") + count * 864e5).toISOString().slice(0, 10);
|
|
204
|
+
const dayStart = (value, zone) => {
|
|
205
|
+
const target = Date.parse(value + "T00:00:00Z");
|
|
206
|
+
for (let at = target - 14 * 36e5; at <= target + 14 * 36e5; at += 6e4) if (dateOf(at, zone) === value) return new Date(at).toISOString();
|
|
207
|
+
throw Error("This date is unavailable in the selected timezone.");
|
|
208
|
+
};
|
|
209
|
+
const time = (at, zone) => new Intl.DateTimeFormat("en-US", { timeZone: zone, hour: "numeric", minute: "2-digit", timeZoneName: "shortOffset" }).format(new Date(at));
|
|
210
|
+
const fullDate = (at, zone) => new Intl.DateTimeFormat("en-US", { timeZone: zone, weekday: "short", month: "long", day: "numeric", year: "numeric" }).format(new Date(at));
|
|
211
|
+
const price = (value) => {
|
|
212
|
+
if (value.amountMinor === null) return value.label || "Price agreed with the business";
|
|
213
|
+
try {
|
|
214
|
+
const options2 = new Intl.NumberFormat("en-US", { style: "currency", currency: value.currency }), digits = options2.resolvedOptions().maximumFractionDigits ?? 2;
|
|
215
|
+
return options2.format(value.amountMinor / 10 ** digits);
|
|
216
|
+
} catch {
|
|
217
|
+
return value.label;
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
const button = (label, action, primary = false) => {
|
|
221
|
+
const el = node("button", label, primary ? "vb-primary" : void 0);
|
|
222
|
+
el.type = "button";
|
|
223
|
+
el.dataset.focusKey = label;
|
|
224
|
+
el.disabled = busy;
|
|
225
|
+
el.addEventListener("click", action);
|
|
226
|
+
return el;
|
|
227
|
+
};
|
|
228
|
+
const field = (label, input) => {
|
|
229
|
+
const el = node("label");
|
|
230
|
+
input.dataset.focusKey = label;
|
|
231
|
+
el.append(node("span", label), input);
|
|
232
|
+
return el;
|
|
233
|
+
};
|
|
234
|
+
const detail = (list, label, value) => {
|
|
235
|
+
const row = node("div");
|
|
236
|
+
row.append(node("dt", label), node("dd", value));
|
|
237
|
+
list.append(row);
|
|
238
|
+
};
|
|
239
|
+
const move = (next) => {
|
|
240
|
+
if (busy || uncertain) return;
|
|
241
|
+
step = next;
|
|
242
|
+
error = "";
|
|
243
|
+
status = "";
|
|
244
|
+
render(true);
|
|
245
|
+
};
|
|
246
|
+
const clearTimes = () => {
|
|
247
|
+
requestSerial++;
|
|
248
|
+
slots = [];
|
|
249
|
+
slot = null;
|
|
250
|
+
revision = 0;
|
|
251
|
+
status = "";
|
|
252
|
+
error = "";
|
|
253
|
+
attemptKey = "";
|
|
254
|
+
accepted = false;
|
|
255
|
+
};
|
|
256
|
+
const options = (select, items, value, all) => {
|
|
257
|
+
if (all) {
|
|
258
|
+
const opt = node("option", all);
|
|
259
|
+
opt.value = "";
|
|
260
|
+
select.append(opt);
|
|
261
|
+
}
|
|
262
|
+
for (const item of items) {
|
|
263
|
+
const opt = node("option", item.name);
|
|
264
|
+
opt.value = item.id;
|
|
265
|
+
select.append(opt);
|
|
266
|
+
}
|
|
267
|
+
select.value = value;
|
|
268
|
+
};
|
|
269
|
+
const cleanupChallenge = () => {
|
|
270
|
+
if (challengeId) {
|
|
271
|
+
window.turnstile?.remove(challengeId);
|
|
272
|
+
challengeId = void 0;
|
|
273
|
+
}
|
|
274
|
+
challengeToken = "";
|
|
275
|
+
};
|
|
276
|
+
const errorBlock = () => {
|
|
277
|
+
const el = node("p", error, "vb-error");
|
|
278
|
+
el.setAttribute("role", "alert");
|
|
279
|
+
return el;
|
|
280
|
+
};
|
|
281
|
+
const showSummary = (panel) => {
|
|
282
|
+
const disclosure = node("details");
|
|
283
|
+
disclosure.open = window.matchMedia("(min-width:761px)").matches;
|
|
284
|
+
disclosure.append(node("summary", "Your appointment"));
|
|
285
|
+
const list = node("dl"), s = service();
|
|
286
|
+
if (s) {
|
|
287
|
+
detail(list, "Service", s.name);
|
|
288
|
+
detail(list, "Duration", s.durationMinutes + " minutes");
|
|
289
|
+
detail(list, "Price", price(s.price));
|
|
290
|
+
detail(list, "Confirmation", s.bookingMode === "approval" ? "Business approval required" : "Confirmed when booked");
|
|
291
|
+
}
|
|
292
|
+
if (place()) detail(list, "Location", place().name);
|
|
293
|
+
if (slot) {
|
|
294
|
+
detail(list, "Time", fullDate(slot.startsAt, place().timezone) + " \\xB7 " + time(slot.startsAt, place().timezone));
|
|
295
|
+
detail(list, "Staff", catalog.providers.find((p) => p.id === slot.providerId)?.name || "Available staff");
|
|
296
|
+
}
|
|
297
|
+
disclosure.append(list);
|
|
298
|
+
panel.append(disclosure);
|
|
299
|
+
};
|
|
300
|
+
const normalizeSelection = () => {
|
|
301
|
+
const s = service();
|
|
302
|
+
if (!s) return;
|
|
303
|
+
const places = catalog.locations.filter((l) => s.locationIds.includes(l.id));
|
|
304
|
+
if (!places.some((p) => p.id === locationId)) locationId = places[0]?.id || "";
|
|
305
|
+
const staff = catalog.providers.filter((p) => s.providerIds.includes(p.id) && p.locationIds.includes(locationId));
|
|
306
|
+
if (!catalog.allowProviderChoice || !staff.some((p) => p.id === providerId)) providerId = staff.length === 1 ? staff[0].id : "";
|
|
307
|
+
if (!day && place()) day = dateOf(Date.now() + catalog.minNoticeMinutes * 6e4, place().timezone);
|
|
308
|
+
};
|
|
309
|
+
const loadTimes = async (lookAhead = false) => {
|
|
310
|
+
if (busy || uncertain) return;
|
|
311
|
+
busy = true;
|
|
312
|
+
slot = null;
|
|
313
|
+
error = "";
|
|
314
|
+
status = "Checking availability\\u2026";
|
|
315
|
+
const serial = ++requestSerial;
|
|
316
|
+
render();
|
|
317
|
+
try {
|
|
318
|
+
const zone = place().timezone, params = new URLSearchParams({ serviceId, locationId, startsAt: dayStart(day, zone), endsAt: dayStart(addDays(day, lookAhead ? 6 : 1), zone), ...providerId ? { providerId } : {} });
|
|
319
|
+
const data = await api("/availability?" + params);
|
|
320
|
+
if (serial !== requestSerial) return;
|
|
321
|
+
if (data.schemaVersion !== 1 || data.siteId !== connection.siteId || !Array.isArray(data.slots) || data.slots.length > 500 || !Number.isSafeInteger(data.revision)) throw Error("The availability response could not be verified.");
|
|
322
|
+
if (!data.terms || typeof data.policyText !== "string" || !data.location) throw Error("This service changed. Reload services before booking.");
|
|
323
|
+
const old = JSON.stringify(service());
|
|
324
|
+
Object.assign(service(), data.terms);
|
|
325
|
+
Object.assign(place(), data.location);
|
|
326
|
+
if (catalog.policyText !== data.policyText) accepted = false;
|
|
327
|
+
catalog.policyText = data.policyText;
|
|
328
|
+
slots = data.slots;
|
|
329
|
+
revision = data.revision;
|
|
330
|
+
status = slots.length ? \`\${slots.length} available \${slots.length === 1 ? "time" : "times"}. \${zone}.\` : "No available times for this date. Try another day.";
|
|
331
|
+
if (old !== JSON.stringify(service())) status += " Service details have changed; review the current price and duration.";
|
|
332
|
+
if (data.truncated) status += " Showing the first available times; narrow the date or staff selection.";
|
|
333
|
+
} catch (failure) {
|
|
334
|
+
if (serial === requestSerial) {
|
|
335
|
+
slots = [];
|
|
336
|
+
error = failure instanceof Error ? failure.message : "Availability could not be loaded.";
|
|
337
|
+
status = "";
|
|
338
|
+
}
|
|
339
|
+
} finally {
|
|
340
|
+
if (serial === requestSerial) {
|
|
341
|
+
busy = false;
|
|
342
|
+
render();
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
};
|
|
346
|
+
const mountChallenge = async (container) => {
|
|
347
|
+
if (catalog.challenge.kind === "local-fixture") {
|
|
348
|
+
container.append(notice("Local simulation: the security challenge is bypassed only for this isolated loopback preview."));
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
try {
|
|
352
|
+
const win = window;
|
|
353
|
+
if (!win.turnstile) await new Promise((resolve, reject) => {
|
|
354
|
+
let script = document.querySelector("script[data-booking-turnstile]");
|
|
355
|
+
if (!script) {
|
|
356
|
+
script = node("script");
|
|
357
|
+
script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
|
|
358
|
+
script.async = true;
|
|
359
|
+
script.dataset.bookingTurnstile = "";
|
|
360
|
+
document.head.append(script);
|
|
361
|
+
}
|
|
362
|
+
script.addEventListener("load", () => resolve(), { once: true });
|
|
363
|
+
script.addEventListener("error", () => reject(Error("The security check could not load. Please retry or contact the business.")), { once: true });
|
|
364
|
+
});
|
|
365
|
+
if (!container.isConnected || step !== 3) return;
|
|
366
|
+
challengeId = win.turnstile.render(container, { sitekey: catalog.challenge.siteKey, action: "booking", callback: (value) => {
|
|
367
|
+
challengeToken = value;
|
|
368
|
+
}, "expired-callback": () => {
|
|
369
|
+
challengeToken = "";
|
|
370
|
+
}, "error-callback": () => {
|
|
371
|
+
challengeToken = "";
|
|
372
|
+
} });
|
|
373
|
+
} catch (failure) {
|
|
374
|
+
if (container.isConnected) container.append(errorBlockMessage(failure instanceof Error ? failure.message : "Security check unavailable."));
|
|
375
|
+
}
|
|
376
|
+
};
|
|
377
|
+
const errorBlockMessage = (text) => {
|
|
378
|
+
const el = node("p", text, "vb-error");
|
|
379
|
+
el.setAttribute("role", "alert");
|
|
380
|
+
return el;
|
|
381
|
+
};
|
|
382
|
+
const submit = async () => {
|
|
383
|
+
if (busy) return;
|
|
384
|
+
if (!accepted) {
|
|
385
|
+
error = "Accept the booking policy before booking.";
|
|
386
|
+
render();
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
if (catalog.challenge.kind === "turnstile" && !challengeToken) {
|
|
390
|
+
error = "Complete the security check before booking.";
|
|
391
|
+
const existing = root.querySelector("[role=alert]");
|
|
392
|
+
if (existing) existing.textContent = error;
|
|
393
|
+
else root.querySelector(".vb-panel")?.append(errorBlock());
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
const content = { revision, serviceId, locationId, providerId: slot.providerId, startsAt: slot.startsAt, customer, acceptedPolicy: true };
|
|
397
|
+
const payload = JSON.stringify(content);
|
|
398
|
+
if (!attemptKey || attemptPayload !== payload) {
|
|
399
|
+
attemptKey = crypto.randomUUID();
|
|
400
|
+
attemptPayload = payload;
|
|
401
|
+
}
|
|
402
|
+
busy = true;
|
|
403
|
+
error = "";
|
|
404
|
+
status = "Saving your appointment\\u2026";
|
|
405
|
+
const token = challengeToken;
|
|
406
|
+
render();
|
|
407
|
+
try {
|
|
408
|
+
const result = await api("/appointments", { ...content, idempotencyKey: attemptKey, challengeToken: token, company });
|
|
409
|
+
if (result.schemaVersion !== 1 || result.siteId !== connection.siteId || !["confirmed", "pending_approval"].includes(result.status) || typeof result.reference !== "string" || typeof result.calendar?.content !== "string") throw Error("The booking response could not be verified. Keep this page open and retry the same booking.");
|
|
410
|
+
receipt = result;
|
|
411
|
+
uncertain = false;
|
|
412
|
+
step = 4;
|
|
413
|
+
status = "";
|
|
414
|
+
} catch (failure) {
|
|
415
|
+
const f = failure;
|
|
416
|
+
error = f.message || "The booking response was interrupted.";
|
|
417
|
+
status = "";
|
|
418
|
+
if (!f.status || f.status >= 500) {
|
|
419
|
+
uncertain = true;
|
|
420
|
+
error += " Your result may still be pending. Retry this same booking; do not create another appointment.";
|
|
421
|
+
} else {
|
|
422
|
+
uncertain = false;
|
|
423
|
+
if (f.status === 409) {
|
|
424
|
+
step = 1;
|
|
425
|
+
slot = null;
|
|
426
|
+
slots = [];
|
|
427
|
+
revision = 0;
|
|
428
|
+
attemptKey = "";
|
|
429
|
+
accepted = false;
|
|
430
|
+
status = "Your details are saved. Find available times again.";
|
|
431
|
+
} else if (f.status === 400 && String(f.code).startsWith("challenge")) {
|
|
432
|
+
challengeToken = "";
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
} finally {
|
|
436
|
+
busy = false;
|
|
437
|
+
render(true);
|
|
438
|
+
}
|
|
439
|
+
};
|
|
440
|
+
function render(focus = false) {
|
|
441
|
+
const focusKey = document.activeElement?.dataset.focusKey;
|
|
442
|
+
cleanupChallenge();
|
|
443
|
+
root.replaceChildren();
|
|
444
|
+
root.append(notice(catalog.emailMode === "send" ? "TEST BOOKING - Emails use the configured sender and allowed test recipients." : catalog.emailMode === "disabled" ? "TEST BOOKING - Customer emails are not enabled. Keep your booking reference." : "TEST BOOKING - This preview stores appointments in the admin calendar. Customer emails are captured, not sent."));
|
|
445
|
+
const progress = node("ol", void 0, "vb-steps");
|
|
446
|
+
progress.setAttribute("aria-label", "Booking progress");
|
|
447
|
+
["Service", "Time", "Your details", "Review", "Receipt"].forEach((name, index) => {
|
|
448
|
+
const item = node("li", index + 1 + ". " + name);
|
|
449
|
+
if (index === step) item.setAttribute("aria-current", "step");
|
|
450
|
+
progress.append(item);
|
|
451
|
+
});
|
|
452
|
+
root.append(progress);
|
|
453
|
+
const layout = node("div", void 0, "vb-layout"), panel = node("section", void 0, "vb-panel"), summary = node("aside", void 0, "vb-panel vb-summary");
|
|
454
|
+
summary.setAttribute("aria-label", "Appointment summary");
|
|
455
|
+
layout.append(panel, summary);
|
|
456
|
+
root.append(layout);
|
|
457
|
+
showSummary(summary);
|
|
458
|
+
const heading = node("h2", ["Choose a service", "Choose a time", "Your details", "Review your appointment", receipt?.status === "pending_approval" ? "Request received" : "Appointment confirmed"][step]);
|
|
459
|
+
heading.tabIndex = -1;
|
|
460
|
+
panel.append(heading);
|
|
461
|
+
if (error) panel.append(errorBlock());
|
|
462
|
+
if (status) {
|
|
463
|
+
const live = node("p", status);
|
|
464
|
+
live.setAttribute("role", "status");
|
|
465
|
+
panel.append(live);
|
|
466
|
+
}
|
|
467
|
+
if (step === 0) {
|
|
468
|
+
if (!catalog.services.length) {
|
|
469
|
+
panel.append(node("p", "No services are available for online booking. Please contact the business."));
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
const group = node("fieldset"), legend = node("legend", "Available services"), list = node("div", void 0, "vb-services");
|
|
473
|
+
group.append(legend, list);
|
|
474
|
+
for (const s of catalog.services) {
|
|
475
|
+
const radio = node("input");
|
|
476
|
+
radio.type = "radio";
|
|
477
|
+
radio.name = "booking-service";
|
|
478
|
+
radio.dataset.focusKey = "service-" + s.id;
|
|
479
|
+
radio.value = s.id;
|
|
480
|
+
radio.checked = s.id === serviceId;
|
|
481
|
+
radio.addEventListener("change", () => {
|
|
482
|
+
serviceId = s.id;
|
|
483
|
+
clearTimes();
|
|
484
|
+
normalizeSelection();
|
|
485
|
+
render();
|
|
486
|
+
});
|
|
487
|
+
const label = node("label", void 0, "vb-service"), copy = node("span");
|
|
488
|
+
copy.append(node("strong", s.name), node("small", s.durationMinutes + " minutes \\xB7 " + price(s.price)), node("small", s.bookingMode === "approval" ? "Requires business approval" : "Immediate confirmation"));
|
|
489
|
+
label.append(radio, copy);
|
|
490
|
+
list.append(label);
|
|
491
|
+
}
|
|
492
|
+
panel.append(group, button("Choose a time", () => {
|
|
493
|
+
normalizeSelection();
|
|
494
|
+
move(1);
|
|
495
|
+
}, true));
|
|
496
|
+
} else if (step === 1) {
|
|
497
|
+
const s = service(), grid = node("div", void 0, "vb-grid"), places = catalog.locations.filter((l) => s.locationIds.includes(l.id));
|
|
498
|
+
if (places.length > 1) {
|
|
499
|
+
const select = node("select");
|
|
500
|
+
options(select, places, locationId);
|
|
501
|
+
select.disabled = busy || uncertain;
|
|
502
|
+
select.addEventListener("change", () => {
|
|
503
|
+
locationId = select.value;
|
|
504
|
+
day = "";
|
|
505
|
+
clearTimes();
|
|
506
|
+
normalizeSelection();
|
|
507
|
+
render();
|
|
508
|
+
});
|
|
509
|
+
grid.append(field("Location", select));
|
|
510
|
+
}
|
|
511
|
+
const staff = catalog.providers.filter((p) => s.providerIds.includes(p.id) && p.locationIds.includes(locationId));
|
|
512
|
+
if (catalog.allowProviderChoice && staff.length > 1) {
|
|
513
|
+
const select = node("select");
|
|
514
|
+
options(select, staff, providerId, "Any available staff");
|
|
515
|
+
select.disabled = busy || uncertain;
|
|
516
|
+
select.addEventListener("change", () => {
|
|
517
|
+
providerId = select.value;
|
|
518
|
+
clearTimes();
|
|
519
|
+
render();
|
|
520
|
+
});
|
|
521
|
+
grid.append(field("Staff preference", select));
|
|
522
|
+
}
|
|
523
|
+
const date = node("input");
|
|
524
|
+
date.type = "date";
|
|
525
|
+
date.required = true;
|
|
526
|
+
date.value = day;
|
|
527
|
+
date.min = dateOf(Date.now(), place().timezone);
|
|
528
|
+
date.max = dateOf(Date.now() + catalog.horizonDays * 864e5, place().timezone);
|
|
529
|
+
date.disabled = busy || uncertain;
|
|
530
|
+
date.addEventListener("change", () => {
|
|
531
|
+
if (date.value) {
|
|
532
|
+
day = date.value;
|
|
533
|
+
clearTimes();
|
|
534
|
+
render();
|
|
535
|
+
}
|
|
536
|
+
});
|
|
537
|
+
grid.append(field("Appointment date", date));
|
|
538
|
+
panel.append(grid, node("p", "Times are shown in " + place().timezone + ". A time is reserved only after booking.", "vb-hint"), button(busy ? "Checking times\\u2026" : "Find available times", () => {
|
|
539
|
+
void loadTimes();
|
|
540
|
+
}));
|
|
541
|
+
const times = node("div", void 0, "vb-times");
|
|
542
|
+
times.setAttribute("aria-label", "Available times");
|
|
543
|
+
const seen = /* @__PURE__ */ new Set();
|
|
544
|
+
for (const value of slots) {
|
|
545
|
+
if (!providerId && seen.has(value.startsAt)) continue;
|
|
546
|
+
seen.add(value.startsAt);
|
|
547
|
+
const label = time(value.startsAt, place().timezone), b = button(label, () => {
|
|
548
|
+
slot = value;
|
|
549
|
+
render();
|
|
550
|
+
});
|
|
551
|
+
b.dataset.focusKey = value.startsAt + "-" + value.providerId;
|
|
552
|
+
b.setAttribute("aria-pressed", String(slot?.startsAt === value.startsAt && slot.providerId === value.providerId));
|
|
553
|
+
b.setAttribute("aria-label", fullDate(value.startsAt, place().timezone) + " at " + label);
|
|
554
|
+
b.append(node("small", fullDate(value.startsAt, place().timezone)));
|
|
555
|
+
times.append(b);
|
|
556
|
+
}
|
|
557
|
+
panel.append(times);
|
|
558
|
+
if (!slots.length && !busy) panel.append(button("Look ahead six days", () => {
|
|
559
|
+
void loadTimes(true);
|
|
560
|
+
}));
|
|
561
|
+
const actions = node("div", void 0, "vb-actions"), next = button("Continue to your details", () => move(2), true);
|
|
562
|
+
next.disabled = !slot || busy;
|
|
563
|
+
actions.append(button("Back to services", () => move(0)), next);
|
|
564
|
+
panel.append(actions);
|
|
565
|
+
} else if (step === 2) {
|
|
566
|
+
const form = node("form");
|
|
567
|
+
for (const [key, label, type, autocomplete] of [["name", "Full name", "text", "name"], ["email", "Email address", "email", "email"], ["phone", "Phone number (optional)", "tel", "tel"]]) {
|
|
568
|
+
const input = node("input");
|
|
569
|
+
input.name = key;
|
|
570
|
+
input.type = type;
|
|
571
|
+
input.autocomplete = autocomplete;
|
|
572
|
+
input.required = key !== "phone";
|
|
573
|
+
input.maxLength = key === "email" ? 254 : key === "name" ? 160 : 40;
|
|
574
|
+
input.value = customer[key];
|
|
575
|
+
input.addEventListener("input", () => {
|
|
576
|
+
customer[key] = input.value;
|
|
577
|
+
});
|
|
578
|
+
form.append(field(label, input));
|
|
579
|
+
}
|
|
580
|
+
const hidden = node("div", void 0, "vb-honey");
|
|
581
|
+
hidden.setAttribute("aria-hidden", "true");
|
|
582
|
+
const honey = node("input");
|
|
583
|
+
honey.name = "company";
|
|
584
|
+
honey.tabIndex = -1;
|
|
585
|
+
honey.autocomplete = "off";
|
|
586
|
+
honey.addEventListener("input", () => {
|
|
587
|
+
company = honey.value;
|
|
588
|
+
});
|
|
589
|
+
hidden.append(field("Leave this field empty", honey));
|
|
590
|
+
form.append(hidden, node("p", "Your details are used to arrange this appointment. See the test email mode above before submitting.", "vb-hint"));
|
|
591
|
+
const actions = node("div", void 0, "vb-actions"), next = button("Review appointment", () => {
|
|
592
|
+
}, true);
|
|
593
|
+
next.type = "submit";
|
|
594
|
+
actions.append(button("Back to times", () => move(1)), next);
|
|
595
|
+
form.append(actions);
|
|
596
|
+
form.addEventListener("submit", (event) => {
|
|
597
|
+
event.preventDefault();
|
|
598
|
+
if (form.reportValidity()) {
|
|
599
|
+
customer = { name: customer.name.trim(), email: customer.email.trim(), phone: customer.phone.trim() };
|
|
600
|
+
move(3);
|
|
601
|
+
}
|
|
602
|
+
});
|
|
603
|
+
panel.append(form);
|
|
604
|
+
} else if (step === 3) {
|
|
605
|
+
const list = node("dl");
|
|
606
|
+
detail(list, "Customer", customer.name);
|
|
607
|
+
detail(list, "Email", customer.email);
|
|
608
|
+
if (customer.phone) detail(list, "Phone", customer.phone);
|
|
609
|
+
panel.append(list, node("h3", "Booking policy"), node("p", catalog.policyText, "vb-policy"));
|
|
610
|
+
if(catalog.customerManagement){const m=catalog.customerManagement;panel.append(node("p","Online cancellation notice: "+m.cancellationCutoffMinutes+" minutes. Online rescheduling notice: "+m.rescheduleCutoffMinutes+" minutes. Zero permits changes until the appointment starts.","vb-hint"));}
|
|
611
|
+
const check = node("input");
|
|
612
|
+
check.type = "checkbox";
|
|
613
|
+
check.checked = accepted;
|
|
614
|
+
check.disabled = busy || uncertain;
|
|
615
|
+
check.addEventListener("change", () => {
|
|
616
|
+
accepted = check.checked;
|
|
617
|
+
});
|
|
618
|
+
const label = node("label", void 0, "vb-check");
|
|
619
|
+
label.append(check, node("span", "I have read and accept the booking policy."));
|
|
620
|
+
panel.append(label);
|
|
621
|
+
if (!busy) {
|
|
622
|
+
const challenge = node("div");
|
|
623
|
+
panel.append(challenge);
|
|
624
|
+
void mountChallenge(challenge);
|
|
625
|
+
}
|
|
626
|
+
const actions = node("div", void 0, "vb-actions");
|
|
627
|
+
if (!uncertain) actions.append(button("Back to your details", () => move(2)));
|
|
628
|
+
actions.append(button(busy ? "Booking\\u2026" : uncertain ? "Retry same booking" : service().bookingMode === "approval" ? "Request appointment" : "Confirm appointment", () => {
|
|
629
|
+
void submit();
|
|
630
|
+
}, true));
|
|
631
|
+
panel.append(actions);
|
|
632
|
+
} else if (receipt) {
|
|
633
|
+
const pending = receipt.status === "pending_approval";
|
|
634
|
+
panel.append(node("p", pending ? "The business needs to approve your request. Your time is held until the approval deadline. This request is not yet confirmed." : "Your appointment is saved in the business calendar.", "vb-status"));
|
|
635
|
+
const list = node("dl");
|
|
636
|
+
detail(list, "Service", receipt.serviceName);
|
|
637
|
+
detail(list, "When", fullDate(receipt.startsAt, receipt.timezone) + " \\xB7 " + time(receipt.startsAt, receipt.timezone));
|
|
638
|
+
detail(list, "Location", receipt.locationName);
|
|
639
|
+
detail(list, "Staff", receipt.providerName);
|
|
640
|
+
detail(list, "Price", price(receipt.price));
|
|
641
|
+
if (receipt.expiresAt) detail(list, "Approval deadline", fullDate(receipt.expiresAt, receipt.timezone) + " \\xB7 " + time(receipt.expiresAt, receipt.timezone));
|
|
642
|
+
panel.append(list, node("p", "Booking reference", "vb-hint"), node("p", receipt.reference, "vb-reference"), node("p", "Keep this reference. " + (receipt.emailStatus === "captured" ? "Your test email is captured in the admin Customer emails inbox. Its private links can cancel or reschedule this appointment." : receipt.emailStatus === "accepted" ? "Your email was accepted by the email provider. Check your inbox for private cancellation and rescheduling links." : receipt.emailStatus === "blocked" || receipt.emailStatus === "unknown" ? "Your appointment is saved, but the email needs attention. Contact the business if you do not receive it." : (catalog.emailMode === "disabled" ? "Customer emails are not enabled. Contact the business for appointment changes." : "Open your appointment email for cancellation and rescheduling links. Email delivery may still be pending.")) + "", "vb-hint"));
|
|
643
|
+
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
|
644
|
+
objectUrl = URL.createObjectURL(new Blob([receipt.calendar.content], { type: "text/calendar;charset=utf-8" }));
|
|
645
|
+
const link = node("a", "Download calendar file (.ics)");
|
|
646
|
+
link.href = objectUrl;
|
|
647
|
+
link.download = "appointment.ics";
|
|
648
|
+
panel.append(link, node("p", "A calendar file adds a copy to your calendar. It does not keep your calendar synchronized.", "vb-hint"));
|
|
649
|
+
}
|
|
650
|
+
if (focus) heading.focus({ preventScroll: false });
|
|
651
|
+
else if (focusKey) root.querySelector('[data-focus-key="' + CSS.escape(focusKey) + '"]')?.focus({ preventScroll: true });
|
|
652
|
+
}
|
|
653
|
+
root.append(notice("Loading available services\\u2026"));
|
|
654
|
+
void api("/catalog").then((data) => {
|
|
655
|
+
if (data.schemaVersion !== 1 || data.siteId !== connection.siteId || data.installationId !== connection.installationId || data.siteOrigin !== connection.siteOrigin || data.mode !== "test" || !Array.isArray(data.services) || data.services.length > 128 || !Array.isArray(data.providers) || !Array.isArray(data.locations) || !["local-fixture", "turnstile"].includes(data.challenge?.kind)) throw Error("This website booking connection could not be verified. Please contact the business.");
|
|
656
|
+
catalog = data;
|
|
657
|
+
serviceId = new URL(location.href).searchParams.get("service") || data.services[0]?.id || "";
|
|
658
|
+
if (!data.services.some((s) => s.id === serviceId)) serviceId = data.services[0]?.id || "";
|
|
659
|
+
normalizeSelection();
|
|
660
|
+
render();
|
|
661
|
+
}).catch((failure) => {
|
|
662
|
+
root.replaceChildren(errorBlockMessage(failure instanceof Error ? failure.message : "Booking is unavailable."));
|
|
663
|
+
root.append(button("Try loading services again", () => location.reload()));
|
|
664
|
+
});
|
|
665
|
+
window.addEventListener("beforeunload", (event) => {
|
|
666
|
+
if (uncertain || busy && step === 3) {
|
|
667
|
+
event.preventDefault();
|
|
668
|
+
event.returnValue = "";
|
|
669
|
+
}
|
|
670
|
+
});
|
|
671
|
+
})();`;
|
|
672
|
+
|
|
673
|
+
// src/scheduling/styles.ts
|
|
674
|
+
var bookingCss = `
|
|
675
|
+
.vb-site-header{max-width:71rem;margin:0 auto;padding:1.4rem 1.5rem;border-bottom:1px solid var(--color-border,#c3cfd3)}.vb-site-header a{font-family:var(--font-body,system-ui);font-weight:650;text-decoration:none;color:var(--color-text,#1d3340)}.vb-page{max-width:71rem;margin:0 auto;padding:2.5rem 1.5rem 4rem}.vb-page>h1{font-size:clamp(2rem,4vw,3rem);line-height:1.1;margin:.5rem 0 1rem}.vb-page>.vb-eyebrow{margin:0}.vb .vb-summary summary{font-family:inherit;font-size:1.1rem;font-weight:650;cursor:pointer}.vb h2,.vb h3{font-family:inherit}
|
|
676
|
+
|
|
677
|
+
.vb{--vb-accent:var(--color-accent,#234957);--vb-accent-text:var(--color-accent-text,#fff);color:var(--color-text,#172c36);font-family:var(--font-body,system-ui);font-size:1rem;line-height:1.5;max-width:68rem;margin:2rem auto;isolation:isolate}.vb *{box-sizing:border-box}.vb [hidden]{display:none!important}.vb h2{font-size:1.65rem;line-height:1.25;margin:0 0 .6rem}.vb h3{font-size:1.15rem;margin:.2rem 0}.vb p{margin:.5rem 0 1rem}.vb button,.vb input,.vb select{font:inherit}.vb button,.vb input,.vb select{min-height:48px}.vb button{border:1px solid var(--color-border,#bdc8cd);background:var(--color-surface,#fff);color:inherit;border-radius:var(--radius,8px);padding:.7rem 1rem;cursor:pointer;text-align:center}.vb button:disabled{opacity:.55;cursor:wait}.vb .vb-primary{background:var(--vb-accent);color:var(--vb-accent-text);border-color:var(--vb-accent)}.vb button:hover:not(:disabled){filter:brightness(.96)}.vb :focus-visible{outline:3px solid var(--color-focus,#246c9a);outline-offset:3px}.vb fieldset{min-width:0;border:0;padding:0;margin:0 0 1.5rem}.vb legend{font-size:1.1rem;font-weight:650;margin-bottom:.8rem}.vb input:not([type=checkbox]):not([type=radio]),.vb select{width:100%;border:1px solid var(--color-border,#a8bbc2);border-radius:var(--radius,8px);background:var(--color-bg,#fff);color:inherit;padding:.75rem}.vb label{display:grid;gap:.4rem;margin-bottom:1rem}.vb .vb-check{display:flex;align-items:flex-start;gap:.7rem}.vb .vb-check input{min-height:24px;width:24px;flex:0 0 auto;margin-top:.1rem;accent-color:var(--vb-accent)}.vb .vb-steps{display:flex;gap:1.2rem;list-style:none;padding:0 0 1.1rem;margin:0 0 1.5rem;border-bottom:1px solid var(--color-border,#c7d2d6);flex-wrap:wrap}.vb .vb-steps li{color:var(--color-muted,#50636c)}.vb .vb-steps [aria-current=step]{color:var(--color-text,#172c36);font-weight:700}.vb .vb-layout{display:grid;grid-template-columns:minmax(0,1fr) 19rem;gap:2rem}.vb .vb-panel{background:var(--color-surface,#fff);border:1px solid var(--color-border,#c7d2d6);border-radius:var(--radius-lg,12px);padding:1.6rem;min-width:0}.vb .vb-summary{height:max-content}.vb .vb-services{display:grid;gap:.8rem}.vb .vb-service{display:grid;grid-template-columns:24px minmax(0,1fr);align-items:start;border:1px solid var(--color-border,#c7d2d6);border-radius:var(--radius,8px);padding:1rem;gap:.7rem;margin:0}.vb .vb-service:has(input:checked){border-color:var(--vb-accent);box-shadow:inset 0 0 0 1px var(--vb-accent)}.vb .vb-service input{width:20px;min-height:24px;margin:.15rem 0;accent-color:var(--vb-accent)}.vb .vb-service strong,.vb .vb-service small{display:block;overflow-wrap:anywhere}.vb .vb-service small{font-size:.9rem;margin-top:.25rem}.vb .vb-grid{display:grid;grid-template-columns:1fr 1fr;gap:1rem}.vb .vb-times{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:.6rem;margin:1.3rem 0}.vb .vb-times button{padding:.8rem .4rem}.vb .vb-times button[aria-pressed=true]{background:var(--vb-accent);color:var(--vb-accent-text);border-color:var(--vb-accent)}.vb .vb-times small{display:block;font-size:.8rem}.vb .vb-actions{display:flex;justify-content:space-between;gap:.8rem;margin-top:1.4rem;flex-wrap:wrap}.vb .vb-notice{padding:.8rem 1rem;background:var(--color-surface-2,#eef3f5);border-left:3px solid var(--vb-accent);margin-bottom:1.5rem;font-size:.92rem}.vb .vb-error{border-left:3px solid #aa3c34;padding:.8rem 1rem;background:var(--color-surface,#fff);margin:.8rem 0;overflow-wrap:anywhere}.vb .vb-policy{white-space:pre-wrap;overflow-wrap:anywhere}.vb dl{margin:.8rem 0}.vb dl div{padding:.7rem 0;border-bottom:1px solid var(--color-border,#dce2e5)}.vb dt{font-size:.85rem;opacity:.8}.vb dd{margin:.2rem 0 0;overflow-wrap:anywhere}.vb .vb-status{font-size:.9rem;font-weight:600;letter-spacing:.04em}.vb .vb-reference{font: .85rem ui-monospace,monospace;overflow-wrap:anywhere}.vb .vb-honey{position:absolute;left:-10000px;width:1px;height:1px;overflow:hidden}.vb .vb-hint{font-size:.9rem}.vb-eyebrow{font-size:.8rem;letter-spacing:.13em;font-weight:700}@media(max-width:760px){.vb .vb-layout{grid-template-columns:1fr;gap:1rem}.vb .vb-summary{order:-1}.vb .vb-summary dl div{display:inline-block;margin-right:1.4rem;border:0;padding:.2rem 0}.vb .vb-panel{padding:1.2rem}.vb .vb-steps{gap:.8rem;font-size:.85rem}.vb .vb-times{grid-template-columns:repeat(2,minmax(0,1fr))}.vb .vb-grid{grid-template-columns:1fr}.vb .vb-actions button{flex:1}}@media(prefers-reduced-motion:reduce){.vb *{scroll-behavior:auto!important;transition:none!important;animation:none!important}}
|
|
678
|
+
`;
|
|
679
|
+
|
|
680
|
+
// src/build/index.ts
|
|
681
|
+
import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
|
|
682
|
+
import { join, relative } from "node:path";
|
|
683
|
+
import { createHash } from "node:crypto";
|
|
684
|
+
|
|
685
|
+
// src/forms/frontend.ts
|
|
686
|
+
import { parseFragment } from "parse5";
|
|
687
|
+
|
|
688
|
+
// src/forms/config.ts
|
|
689
|
+
var safeId = (s) => typeof s === "string" && /^[a-zA-Z0-9_-]{1,80}$/.test(s);
|
|
690
|
+
var safeEmail = (s) => {
|
|
691
|
+
if (typeof s !== "string" || s.length > 254 || !/^[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,63}$/.test(s)) return false;
|
|
692
|
+
const [local, domain] = s.split("@");
|
|
693
|
+
return !!local && local.length <= 64 && !local.startsWith(".") && !local.endsWith(".") && !local.includes("..") && !!domain && domain.split(".").every((label) => label.length <= 63 && /^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?$/.test(label));
|
|
694
|
+
};
|
|
695
|
+
function approvedOrigin(value) {
|
|
696
|
+
const u = new URL(value);
|
|
697
|
+
if (u.origin !== value || u.hostname.includes("*") || u.username || u.password || u.protocol !== "https:" && !(u.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(u.hostname))) throw new Error("Expected exact HTTPS origin (HTTP only on loopback)");
|
|
698
|
+
return u.origin;
|
|
699
|
+
}
|
|
700
|
+
function validateFormConfig(value) {
|
|
701
|
+
const c = value;
|
|
702
|
+
if (!c || !safeId(c.id) || !["disabled", "test", "live"].includes(c.mode)) throw new Error("Invalid form ID or mode");
|
|
703
|
+
if (!Array.isArray(c.origins) || !c.origins.length || c.origins.length > 10 || new Set(c.origins).size !== c.origins.length) throw new Error("One to ten unique exact origins required");
|
|
704
|
+
c.origins.forEach(approvedOrigin);
|
|
705
|
+
if (c.mode === "live" && c.origins.some((o) => new URL(o).protocol !== "https:" || ["localhost", "127.0.0.1", "[::1]"].includes(new URL(o).hostname))) throw new Error("Live forms cannot approve loopback origins");
|
|
706
|
+
if (!Array.isArray(c.fields) || !c.fields.length || c.fields.length > 32 || new Set(c.fields.map((f) => f.name)).size !== c.fields.length) throw new Error("One to 32 unique fields required");
|
|
707
|
+
const reserved = /* @__PURE__ */ new Set(["form_id", "tenant_id", "idempotency_key", "page_path", "vid", "company_url", "cf-turnstile-response", "recipients", "to", "from", "sender"]);
|
|
708
|
+
const maps = /* @__PURE__ */ new Set();
|
|
709
|
+
for (const f of c.fields) {
|
|
710
|
+
if (!safeId(f.name) || reserved.has(f.name) || !["text", "email", "tel", "textarea", "select", "checkbox", "radio"].includes(f.type) || !Number.isInteger(f.maxLength) || f.maxLength < 1 || f.maxLength > 8e3 || f.minLength !== void 0 && (!Number.isInteger(f.minLength) || f.minLength < 0 || f.minLength > f.maxLength) || f.label !== void 0 && (typeof f.label !== "string" || f.label.length > 200) || f.required !== void 0 && typeof f.required !== "boolean") throw new Error("Invalid field schema");
|
|
711
|
+
if (f.map !== void 0) {
|
|
712
|
+
if (!["name", "email", "phone", "message"].includes(f.map) || maps.has(f.map)) throw new Error("Invalid or duplicate mapping");
|
|
713
|
+
maps.add(f.map);
|
|
714
|
+
}
|
|
715
|
+
if ((["select", "checkbox", "radio"].includes(f.type) || f.options !== void 0) && (!Array.isArray(f.options) || !f.options.length || f.options.length > 40 || f.options.some((s) => typeof s !== "string" || !s || s.length > f.maxLength) || new Set(f.options).size !== f.options.length)) throw new Error("Choice fields need bounded options");
|
|
716
|
+
}
|
|
717
|
+
if (!c.fields.some((f) => f.map === "email" && f.type === "email" && f.required)) throw new Error("A required visitor email mapping is needed");
|
|
718
|
+
if (!Array.isArray(c.recipients) || c.recipients.length > 5 || c.recipients.some((r) => !safeEmail(r)) || new Set(c.recipients).size !== c.recipients.length) throw new Error("Invalid notification recipients");
|
|
719
|
+
if (!c.turnstile || typeof c.turnstile.sitekey !== "string" || !/^[a-zA-Z0-9_-]{1,100}$/.test(c.turnstile.sitekey) || !safeId(c.turnstile.secretRef) || !safeId(c.turnstile.action) || c.turnstile.action.length > 32) throw new Error("Invalid Turnstile configuration");
|
|
720
|
+
return c;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
// src/forms/frontend.ts
|
|
724
|
+
function inspectForms(html) {
|
|
725
|
+
const inventory = [];
|
|
726
|
+
const walk = (node, visit) => {
|
|
727
|
+
if ("tagName" in node) visit(node);
|
|
728
|
+
if ("childNodes" in node) node.childNodes.forEach((n) => walk(n, visit));
|
|
729
|
+
};
|
|
730
|
+
walk(parseFragment(html), (form) => {
|
|
731
|
+
if (form.tagName !== "form") return;
|
|
732
|
+
const fields = [], unsupported = [];
|
|
733
|
+
let eligible = form.attrs.some((a) => a.name === "data-lead-form");
|
|
734
|
+
if (form.attrs.some((a) => ["data-lead-custom", "data-no-enhance"].includes(a.name))) unsupported.push("Custom submission handler needs explicit migration");
|
|
735
|
+
walk(form, (el) => {
|
|
736
|
+
const attr = (name2) => el.attrs.find((a) => a.name === name2)?.value;
|
|
737
|
+
if (attr("data-vitrisid") === "emailSendButton") eligible = true;
|
|
738
|
+
if (!["input", "textarea", "select"].includes(el.tagName)) return;
|
|
739
|
+
const name = attr("name"), type = el.tagName === "input" ? attr("type") || "text" : el.tagName;
|
|
740
|
+
if (["hidden", "submit", "button", "reset"].includes(type)) {
|
|
741
|
+
if (type === "hidden" && name && !["vid", "page_path", "company_url", "form_id", "tenant_id", "idempotency_key", "cf-turnstile-response"].includes(name)) unsupported.push(`Unmapped hidden field: ${name}`);
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
if (!name || !safeId(name) || !["text", "email", "tel", "textarea", "select", "checkbox", "radio"].includes(type)) {
|
|
745
|
+
unsupported.push(`Unsupported field: ${name || "(unnamed)"} (${type})`);
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
748
|
+
if (attr("multiple") !== void 0 && type !== "checkbox") unsupported.push(`Unsupported multiple field: ${name}`);
|
|
749
|
+
const options = [];
|
|
750
|
+
if (type === "select") walk(el, (opt) => {
|
|
751
|
+
if (opt.tagName === "option") {
|
|
752
|
+
const value = opt.attrs.find((a) => a.name === "value")?.value ?? opt.childNodes.filter((n) => "value" in n).map((n) => n.value).join("");
|
|
753
|
+
if (value) options.push(value);
|
|
754
|
+
}
|
|
755
|
+
});
|
|
756
|
+
if (type === "checkbox" || type === "radio") options.push(attr("value") || "on");
|
|
757
|
+
const old = fields.find((f) => f.name === name);
|
|
758
|
+
if (old) {
|
|
759
|
+
if (old.type === type && ["checkbox", "radio"].includes(type)) {
|
|
760
|
+
old.options.push(...options);
|
|
761
|
+
old.required = old.required || attr("required") !== void 0;
|
|
762
|
+
} else unsupported.push(`Duplicate field: ${name}`);
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
fields.push({ name, type, label: attr("aria-label") || name, required: attr("required") !== void 0, minLength: Number(attr("minlength") || 0), maxLength: Math.min(8e3, Number(attr("maxlength") || (type === "textarea" ? 8e3 : 255))), ...["select", "checkbox", "radio"].includes(type) ? { options } : {}, ...["name", "email", "message", "phone"].includes(name) ? { map: name } : ["number", "telephone", "phone_number", "phoneNumber"].includes(name) ? { map: "phone" } : {} });
|
|
766
|
+
});
|
|
767
|
+
inventory.push({ index: inventory.length, eligible, fields, unsupported });
|
|
768
|
+
});
|
|
769
|
+
return inventory;
|
|
770
|
+
}
|
|
771
|
+
function connectForms(html, backend, pagePath, forms) {
|
|
772
|
+
const bindings = forms.filter((f) => f.pagePath === pagePath);
|
|
773
|
+
if (!bindings.length) return html;
|
|
774
|
+
approvedOrigin(backend);
|
|
775
|
+
const inventory = inspectForms(html);
|
|
776
|
+
const seen = /* @__PURE__ */ new Set();
|
|
777
|
+
for (const f of bindings) {
|
|
778
|
+
const index = f.index ?? 0, item = inventory[index];
|
|
779
|
+
if (!safeId(f.id) || !safeId(f.action) || f.action.length > 32 || !/^[a-zA-Z0-9_-]{1,100}$/.test(f.sitekey) || !item || !item.eligible || item.unsupported.length || seen.has(index)) throw new Error(`Cannot connect ${pagePath} form ${index}: ${item?.unsupported.join(", ") || "explicit contact-form marker and valid unique binding required"}`);
|
|
780
|
+
seen.add(index);
|
|
781
|
+
}
|
|
782
|
+
const located = [];
|
|
783
|
+
const walk = (node) => {
|
|
784
|
+
if ("tagName" in node && node.tagName === "form") located.push(node);
|
|
785
|
+
if ("childNodes" in node) node.childNodes.forEach(walk);
|
|
786
|
+
};
|
|
787
|
+
walk(parseFragment(html, { sourceCodeLocationInfo: true }));
|
|
788
|
+
const edits = [];
|
|
789
|
+
for (const binding of bindings) {
|
|
790
|
+
const form = located[binding.index ?? 0], location = form.sourceCodeLocation;
|
|
791
|
+
if (!location?.startTag || !location.endTag) throw new Error("Unclosed contact form requires source repair");
|
|
792
|
+
const start = location.startTag.startOffset, end = location.endTag.endOffset;
|
|
793
|
+
let open = html.slice(start, location.startTag.endOffset);
|
|
794
|
+
const remove = /* @__PURE__ */ new Set(["action", "method", "novalidate", "data-preserve-forms", "data-vitris-form", "data-vitris-sitekey", "data-vitris-action", "data-vitris-enhanced", "data-lead-form"]);
|
|
795
|
+
for (const [name, span] of Object.entries(location.attrs || {}).sort((a, b) => b[1].startOffset - a[1].startOffset)) if (remove.has(name)) open = open.slice(0, span.startOffset - start) + open.slice(span.endOffset - start);
|
|
796
|
+
let inner = html.slice(location.startTag.endOffset, location.endTag.startOffset);
|
|
797
|
+
const inputs = [];
|
|
798
|
+
const collect = (node) => {
|
|
799
|
+
if ("tagName" in node && node.tagName === "input") inputs.push(node);
|
|
800
|
+
if ("childNodes" in node) node.childNodes.forEach(collect);
|
|
801
|
+
};
|
|
802
|
+
collect(form);
|
|
803
|
+
for (const input of inputs.sort((a, b) => (b.sourceCodeLocation?.startOffset || 0) - (a.sourceCodeLocation?.startOffset || 0))) {
|
|
804
|
+
const name = input.attrs.find((a) => a.name === "name")?.value;
|
|
805
|
+
if (["form_id", "idempotency_key", "cf-turnstile-response"].includes(name || "") && input.sourceCodeLocation) {
|
|
806
|
+
const s = input.sourceCodeLocation.startOffset - location.startTag.endOffset, e = input.sourceCodeLocation.endOffset - location.startTag.endOffset;
|
|
807
|
+
inner = inner.slice(0, s) + inner.slice(e);
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
const has = (name) => inputs.some((input) => input.attrs.some((a) => a.name === "name" && a.value === name));
|
|
811
|
+
open = open.replace(/>$/, ` method="post" action="${escapeHtml(backend)}/api/leads?form_id=${binding.id}" data-lead-form data-vitris-enhanced data-preserve-forms="live" data-vitris-form="${binding.id}" data-vitris-sitekey="${binding.sitekey}" data-vitris-action="${binding.action}">`);
|
|
812
|
+
const value = `${open}<input type="hidden" name="form_id" value="${binding.id}">${has("page_path") ? "" : `<input type="hidden" name="page_path" value="${escapeHtml(pagePath)}">`}${has("company_url") ? "" : '<input type="hidden" name="company_url" value="">'}${inner}<noscript><p>This form needs JavaScript and a security check. Please use the listed contact details.</p></noscript>${html.slice(location.endTag.startOffset, end)}`;
|
|
813
|
+
edits.push({ start, end, value });
|
|
814
|
+
}
|
|
815
|
+
for (const edit of edits.sort((a, b) => b.start - a.start)) html = html.slice(0, edit.start) + edit.value + html.slice(edit.end);
|
|
816
|
+
return html;
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
// src/build/index.ts
|
|
820
|
+
async function walkHtmlFiles(dir, base = dir) {
|
|
821
|
+
const out = [];
|
|
822
|
+
let entries;
|
|
823
|
+
try {
|
|
824
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
825
|
+
} catch {
|
|
826
|
+
return out;
|
|
827
|
+
}
|
|
828
|
+
for (const entry of entries) {
|
|
829
|
+
const full = join(dir, entry.name);
|
|
830
|
+
if (entry.isDirectory()) {
|
|
831
|
+
out.push(...await walkHtmlFiles(full, base));
|
|
832
|
+
} else if (entry.isFile() && entry.name.endsWith(".html")) {
|
|
833
|
+
out.push(relative(base, full).replace(/\\/g, "/"));
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
return out;
|
|
837
|
+
}
|
|
838
|
+
async function loadSiteCatalog(siteDir) {
|
|
839
|
+
const catalog = {};
|
|
840
|
+
try {
|
|
841
|
+
const entries = await readdir(join(siteDir, "components"), { withFileTypes: true });
|
|
842
|
+
for (const entry of entries) {
|
|
843
|
+
if (!entry.isDirectory()) continue;
|
|
844
|
+
const name = String(entry.name);
|
|
845
|
+
const dir = join(siteDir, "components", name);
|
|
846
|
+
let html;
|
|
847
|
+
let css;
|
|
848
|
+
try {
|
|
849
|
+
html = await readFile(join(dir, "template.html"), "utf8");
|
|
850
|
+
css = await readFile(join(dir, "styles.css"), "utf8");
|
|
851
|
+
} catch {
|
|
852
|
+
continue;
|
|
853
|
+
}
|
|
854
|
+
let island = null;
|
|
855
|
+
try {
|
|
856
|
+
island = await readFile(join(dir, "island.js"), "utf8");
|
|
857
|
+
} catch {
|
|
858
|
+
island = null;
|
|
859
|
+
}
|
|
860
|
+
catalog[name] = { html, css, island };
|
|
861
|
+
}
|
|
862
|
+
} catch {
|
|
863
|
+
return catalog;
|
|
864
|
+
}
|
|
865
|
+
return catalog;
|
|
866
|
+
}
|
|
867
|
+
async function loadSitePages(siteDir) {
|
|
868
|
+
const pagesDir = join(siteDir, "pages");
|
|
869
|
+
const files = await walkHtmlFiles(pagesDir);
|
|
870
|
+
const pages = [];
|
|
871
|
+
for (const file of files.sort()) {
|
|
872
|
+
const raw = await readFile(join(pagesDir, file), "utf8");
|
|
873
|
+
const { meta, body } = parseFrontmatter(raw);
|
|
874
|
+
const path = fileToPath(file);
|
|
875
|
+
pages.push({
|
|
876
|
+
path,
|
|
877
|
+
file,
|
|
878
|
+
title: meta.title || defaultTitleFromPath(path),
|
|
879
|
+
description: meta.description,
|
|
880
|
+
indexing: path === "/404" ? "noindex" : meta.indexing === "noindex" || meta.indexing === "index" ? meta.indexing : void 0,
|
|
881
|
+
lang: meta.lang,
|
|
882
|
+
body
|
|
883
|
+
});
|
|
884
|
+
}
|
|
885
|
+
return pages;
|
|
886
|
+
}
|
|
887
|
+
async function loadSiteDir(dir) {
|
|
888
|
+
const site = JSON.parse(await readFile(join(dir, "site.json"), "utf8"));
|
|
889
|
+
let theme = {};
|
|
890
|
+
try {
|
|
891
|
+
theme = JSON.parse(await readFile(join(dir, "theme", "defaults.json"), "utf8"));
|
|
892
|
+
} catch {
|
|
893
|
+
theme = {};
|
|
894
|
+
}
|
|
895
|
+
const pages = await loadSitePages(dir);
|
|
896
|
+
const catalog = await loadSiteCatalog(dir);
|
|
897
|
+
return { site, theme, pages, catalog };
|
|
898
|
+
}
|
|
899
|
+
async function buildSite(input) {
|
|
900
|
+
const backendOrigin = input.backendOrigin || input.kernelOrigin || "";
|
|
901
|
+
const theme = mergeTheme(input.theme);
|
|
902
|
+
const themeHref = input.themeHref ?? "/theme.css";
|
|
903
|
+
const envName = process.env.VITRIS_SITE_ENVIRONMENT;
|
|
904
|
+
const site = envName === "preview" || envName === "production" ? { ...input.site, environment: envName } : input.site;
|
|
905
|
+
const scheduling = schedulingConnection(input.scheduling);
|
|
906
|
+
const booking = scheduling?.enabled ? scheduling : void 0;
|
|
907
|
+
const pages = input.pages.map((page) => ({ ...page }));
|
|
908
|
+
if (booking) {
|
|
909
|
+
bookingOrigin(input.publicOrigin);
|
|
910
|
+
const existing = pages.find((page) => page.path === booking.route);
|
|
911
|
+
if (existing && !/data-vitris-booking(?:[\s=>])/.test(existing.body)) throw new Error("The booking route already exists. Add the standard data-vitris-booking mount explicitly or choose another route.");
|
|
912
|
+
if (!existing) pages.push({ path: booking.route, file: booking.route.slice(1) + "/index.html", title: "Book an appointment", indexing: "noindex", body: bookingPage(site.name) });
|
|
913
|
+
const manageRoute = booking.route + "/manage";
|
|
914
|
+
const management = pages.find((page) => page.path === manageRoute);
|
|
915
|
+
if (management && !/data-vitris-customer-management(?:[\s=>])/.test(management.body)) throw new Error("The customer management route already exists. Add its standard mount or choose another booking route.");
|
|
916
|
+
if (management) management.indexing = "noindex";
|
|
917
|
+
else pages.push({ path: manageRoute, file: manageRoute.slice(1) + "/index.html", title: "Manage your appointment", indexing: "noindex", body: `<header class="vb-site-header"><a href="/">${escapeHtml(site.name)}</a></header><main class="vb-page"><p class="vb-eyebrow">APPOINTMENTS</p><h1>Manage your appointment</h1><div data-vitris-customer-management></div></main>` });
|
|
918
|
+
for (const page of pages) if (page.path !== booking.route && /data-vitris-booking(?:[\s=>])/.test(page.body)) throw new Error("The standard booking mount belongs only on the configured booking route.");
|
|
919
|
+
} else if (pages.some((page) => /data-vitris-booking(?:[\s=>])/.test(page.body))) throw new Error("A booking mount requires an explicit scheduling connection");
|
|
920
|
+
const compiled = compileAllPages(site, pages, input.catalog, backendOrigin);
|
|
921
|
+
const pageAssets = {};
|
|
922
|
+
const routeAssets = /* @__PURE__ */ new Map();
|
|
923
|
+
const asset = (body, ext) => {
|
|
924
|
+
const path = `/assets/immutable/${createHash("sha256").update(body).digest("hex")}.${ext}`;
|
|
925
|
+
routeAssets.set(path, body);
|
|
926
|
+
return path;
|
|
927
|
+
};
|
|
928
|
+
for (const page of compiled) {
|
|
929
|
+
const js = siteIslands([page], input.catalog);
|
|
930
|
+
pageAssets[page.path] = { css: asset(siteCss([page], input.catalog, site), "css"), js: js ? asset(js, "js") : null };
|
|
931
|
+
}
|
|
932
|
+
if (backendOrigin) pageAssets[COOKIE_COMPLIANCE_PATH] = { css: asset(siteCss([], input.catalog, site), "css"), js: null };
|
|
933
|
+
const files = compileSite({
|
|
934
|
+
site,
|
|
935
|
+
pages,
|
|
936
|
+
catalog: input.catalog,
|
|
937
|
+
kernelOrigin: backendOrigin,
|
|
938
|
+
themeHref,
|
|
939
|
+
publicOrigin: input.publicOrigin,
|
|
940
|
+
pageAssets,
|
|
941
|
+
webAnalyticsToken: input.webAnalyticsToken
|
|
942
|
+
}, compiled);
|
|
943
|
+
const bookingScript = booking ? asset(bookingJs, "js") : void 0;
|
|
944
|
+
const bookingStyle = booking ? asset(bookingCss, "css") : void 0;
|
|
945
|
+
const managementScript = booking ? asset(managementJs, "js") : void 0;
|
|
946
|
+
const written = [];
|
|
947
|
+
await mkdir(input.outDir, { recursive: true });
|
|
948
|
+
await mkdir(join(input.outDir, "assets/immutable"), { recursive: true });
|
|
949
|
+
for (const [path, body] of routeAssets) {
|
|
950
|
+
await writeFile(join(input.outDir, path.slice(1)), body, "utf8");
|
|
951
|
+
written.push(path.slice(1));
|
|
952
|
+
}
|
|
953
|
+
for (const file of files) {
|
|
954
|
+
const rel = file.path === "/" ? "index.html" : `${file.path.replace(/^\//, "").replace(/\/$/, "")}/index.html`;
|
|
955
|
+
const dest = join(input.outDir, rel);
|
|
956
|
+
await mkdir(join(dest, ".."), { recursive: true });
|
|
957
|
+
let html = connectForms(file.html, backendOrigin, file.path, input.forms || []);
|
|
958
|
+
if (booking && file.path === booking.route) {
|
|
959
|
+
const config = JSON.stringify({ apiOrigin: booking.apiOrigin, siteId: booking.siteId, installationId: booking.installationId, siteOrigin: input.publicOrigin }).replaceAll("<", "\\u003c");
|
|
960
|
+
if ((html.match(/data-vitris-booking(?:[\s=>])/g) || []).length !== 1) throw new Error("Use exactly one standard booking mount");
|
|
961
|
+
html = html.replace("</head>", `<link rel="stylesheet" href="${bookingStyle}"></head>`).replace("</body>", `<script type="application/json" data-vitris-booking-config>${config}</script><script src="${bookingScript}" defer></script></body>`);
|
|
962
|
+
html = html.replace(/<div([^>]*data-vitris-booking[^>]*)><\/div>/, `<div$1><noscript>Booking requires JavaScript. Please contact ${escapeHtml(site.name)} to arrange an appointment.</noscript></div>`);
|
|
963
|
+
}
|
|
964
|
+
if (booking && file.path === booking.route + "/manage") {
|
|
965
|
+
const config = JSON.stringify({ apiOrigin: booking.apiOrigin, siteId: booking.siteId, installationId: booking.installationId, siteOrigin: input.publicOrigin }).replaceAll("<", "\\u003c");
|
|
966
|
+
if ((html.match(/data-vitris-customer-management(?:[\s=>])/g) || []).length !== 1) throw new Error("Use exactly one customer management mount");
|
|
967
|
+
html = html.replace(/<script\b[^>]*src="[^"]*(?:beacon\.js|static\.cloudflareinsights\.com\/beacon\.min\.js)"[^>]*><\/script>/g, "");
|
|
968
|
+
html = html.replace("<head>", `<head><meta name="referrer" content="no-referrer"><script type="application/json" data-vitris-customer-config>${config}</script><script src="${managementScript}"></script>`).replace("</head>", `<link rel="stylesheet" href="${bookingStyle}"></head>`);
|
|
969
|
+
html = html.replace(/<div([^>]*data-vitris-customer-management[^>]*)><\/div>/, "<div$1><noscript>Appointment management requires JavaScript. Please contact the business.</noscript></div>");
|
|
970
|
+
}
|
|
971
|
+
await writeFile(dest, html, "utf8");
|
|
972
|
+
written.push(rel);
|
|
973
|
+
}
|
|
974
|
+
await writeFile(join(input.outDir, "site.css"), siteCss(compiled, input.catalog, site), "utf8");
|
|
975
|
+
await writeFile(join(input.outDir, "theme.css"), themeCss(theme, site.typography), "utf8");
|
|
976
|
+
if (backendOrigin) {
|
|
977
|
+
await writeFile(join(input.outDir, "beacon.js"), beaconJs, "utf8");
|
|
978
|
+
written.push("beacon.js");
|
|
979
|
+
}
|
|
980
|
+
await writeFile(join(input.outDir, "robots.txt"), robotsTxt(input.publicOrigin), "utf8");
|
|
981
|
+
await writeFile(
|
|
982
|
+
join(input.outDir, "sitemap.xml"),
|
|
983
|
+
sitemapXml(
|
|
984
|
+
compiled.map((p) => p.path),
|
|
985
|
+
input.publicOrigin,
|
|
986
|
+
[COOKIE_COMPLIANCE_PATH, ...site.indexing === "noindex" ? compiled.map((page) => page.path) : compiled.filter((page) => page.indexing === "noindex").map((page) => page.path)],
|
|
987
|
+
Boolean(backendOrigin)
|
|
988
|
+
),
|
|
989
|
+
"utf8"
|
|
990
|
+
);
|
|
991
|
+
const islands = siteIslands(compiled, input.catalog);
|
|
992
|
+
if (islands) await writeFile(join(input.outDir, "islands.js"), islands, "utf8");
|
|
993
|
+
if (compiled.some((page) => page.usesMotion)) {
|
|
994
|
+
await writeFile(join(input.outDir, motionAssetHref.slice(1)), motionJs, "utf8");
|
|
995
|
+
written.push(motionAssetHref.slice(1));
|
|
996
|
+
}
|
|
997
|
+
if (islands) written.push("islands.js");
|
|
998
|
+
written.push("site.css", "theme.css", "robots.txt", "sitemap.xml");
|
|
999
|
+
return written;
|
|
1000
|
+
}
|
|
1001
|
+
async function fetchLiveContent(kernelOrigin, slug, token) {
|
|
1002
|
+
const res = await fetch(`${kernelOrigin.replace(/\/$/, "")}/api/deploy/${encodeURIComponent(slug)}`, {
|
|
1003
|
+
headers: { authorization: `Bearer ${token}` }
|
|
1004
|
+
});
|
|
1005
|
+
if (!res.ok) return null;
|
|
1006
|
+
return await res.json();
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
export {
|
|
1010
|
+
safeId,
|
|
1011
|
+
approvedOrigin,
|
|
1012
|
+
validateFormConfig,
|
|
1013
|
+
inspectForms,
|
|
1014
|
+
connectForms,
|
|
1015
|
+
loadSiteCatalog,
|
|
1016
|
+
loadSitePages,
|
|
1017
|
+
loadSiteDir,
|
|
1018
|
+
buildSite,
|
|
1019
|
+
fetchLiveContent
|
|
1020
|
+
};
|