Files
smb-online/backoffice/app/templates/book.html
T
mivanchenko b895663c3a Public booking page + iframe embed (#17)
Adds the customer-facing /book/<slug> page: service/slot picker, booking
form, and confirmation screen, built on #16's existing booking JSON API.
Includes iframe auto-fit height reporting (mirroring
deploy/booking/booking_layout.js's eaBookingHeight message), brand-color
theming via a ?color= query param, a honeypot field with a fake-success
response indistinguishable from a real booking, and a clear "just taken"
message on slot-conflict. Caddy per-IP rate limiting is documented in
deploy/booking/RATE_LIMIT.md for manual application (no Caddyfile is
tracked in this repo).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 15:15:29 +02:00

316 lines
13 KiB
HTML

<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Termin buchen — {{ business_name }}</title>
<style>
:root {
--brand: {{ brand_color }};
--bg: #fff; --ink: #16302f; --muted: #5d716f; --line: #e3eae9;
--danger: #b3261e; --danger-bg: #fdecea;
}
* { box-sizing: border-box; }
body { margin: 0; font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
background: var(--bg); color: var(--ink); padding: 20px; }
h1 { font-size: 1.2rem; margin: 0 0 18px; }
h2 { font-size: .95rem; margin: 0 0 10px; color: var(--muted);
text-transform: uppercase; letter-spacing: .5px; }
section { margin-bottom: 22px; }
.options { display: flex; flex-wrap: wrap; gap: 8px; }
.opt { border: 1px solid var(--line); border-radius: 9px; padding: 10px 14px;
background: #fff; cursor: pointer; font: inherit; font-size: .88rem; }
.opt.selected { background: var(--brand); border-color: var(--brand); color: #fff; }
.opt:disabled { opacity: .4; cursor: not-allowed; }
label { display: block; font-size: .82rem; color: var(--muted); margin-bottom: 5px; }
input[type=date], input[type=text], input[type=email] {
width: 100%; max-width: 320px; padding: 9px 12px; border: 1px solid var(--line);
border-radius: 9px; font: inherit; font-size: .9rem; }
.field { margin-bottom: 14px; }
.btn { background: var(--brand); color: #fff; border: 0; border-radius: 9px;
padding: 11px 20px; font: inherit; font-size: .92rem; font-weight: 600; cursor: pointer; }
.btn:disabled { opacity: .5; cursor: not-allowed; }
.error { background: var(--danger-bg); color: var(--danger); border-radius: 9px;
padding: 10px 14px; font-size: .88rem; margin-bottom: 14px; display: none; }
.confirmation { display: none; border: 1px solid var(--line); border-radius: 12px;
padding: 20px; background: #f6faf9; }
.confirmation h2 { color: var(--ink); text-transform: none; letter-spacing: normal; font-size: 1.05rem; }
.muted { color: var(--muted); font-size: .85rem; }
/* Honeypot: hidden from sighted users and from screen readers, but present
in the DOM/markup and tab order so a scripted client filling every
visible-looking field still trips it. */
.hp-field { position: absolute; left: -9999px; width: 1px; height: 1px; overflow: hidden; }
</style>
</head>
<body>
<div id="booking-root">
<h1>Termin buchen — {{ business_name }}</h1>
<div class="error" id="error-banner"></div>
<section>
<h2>Leistung</h2>
<div class="options" id="service-options">
{% for s in services %}
<button type="button" class="opt" data-service-id="{{ s.service_id }}"
data-duration="{{ s.duration_minutes }}">
{{ s.name }} ({{ s.duration_minutes }} min{% if s.price is not none %}, {{ "%.2f"|format(s.price) }} €{% endif %})
</button>
{% endfor %}
</div>
</section>
{% if resources|length > 1 %}
<section>
<h2>Mitarbeiter</h2>
<div class="options" id="resource-options">
{% for r in resources %}
<button type="button" class="opt" data-resource-id="{{ r.resource_id }}">{{ r.name }}</button>
{% endfor %}
</div>
</section>
{% endif %}
<section>
<h2>Datum</h2>
<div class="field">
<input type="date" id="date-input" />
</div>
<div class="options" id="slot-options"></div>
</section>
<section id="details-section">
<h2>Ihre Daten</h2>
<div class="field">
<label for="customer-name">Name</label>
<input type="text" id="customer-name" autocomplete="name" />
</div>
<div class="field">
<label for="customer-contact">E-Mail oder Telefon</label>
<input type="text" id="customer-contact" autocomplete="email" />
</div>
<!-- Honeypot: real customers never see this field; bots that fill every
field in the DOM do, and get silently rejected server-side. -->
<div class="field hp-field" aria-hidden="true">
<label for="website">Website</label>
<input type="text" id="website" name="website" tabindex="-1" autocomplete="off" />
</div>
<button type="button" class="btn" id="submit-btn" disabled>Termin buchen</button>
</section>
<div class="confirmation" id="confirmation">
<h2>Termin bestätigt</h2>
<p id="confirmation-details"></p>
<p class="muted" id="confirmation-status"></p>
</div>
</div>
<script>
(function () {
var CLIENT_ID = {{ client_id|tojson }};
var RESOURCES = {{ resources|tojson }};
var services = {{ services|tojson }};
var selectedService = null;
var selectedResource = RESOURCES.length === 1 ? RESOURCES[0].resource_id : null;
var selectedSlot = null;
var errorBanner = document.getElementById('error-banner');
var dateInput = document.getElementById('date-input');
var slotOptions = document.getElementById('slot-options');
var submitBtn = document.getElementById('submit-btn');
var confirmation = document.getElementById('confirmation');
var today = new Date();
dateInput.value = today.toISOString().slice(0, 10);
dateInput.min = today.toISOString().slice(0, 10);
function showError(msg) {
errorBanner.textContent = msg;
errorBanner.style.display = msg ? 'block' : 'none';
}
function updateSubmitState() {
var name = document.getElementById('customer-name').value.trim();
var contact = document.getElementById('customer-contact').value.trim();
submitBtn.disabled = !(selectedService && selectedResource && selectedSlot
&& name && contact);
}
// Wires an "options" button group (service/resource pickers) so
// clicking a button marks it selected, clears its siblings, and hands
// the button to onPick -- both groups share this exact select-one shape.
function wireOptionGroup(container, onPick) {
if (!container) {
return;
}
container.querySelectorAll('.opt').forEach(function (btn) {
btn.addEventListener('click', function () {
container.querySelectorAll('.opt').forEach(function (b) {
b.classList.remove('selected');
});
btn.classList.add('selected');
onPick(btn);
});
});
}
wireOptionGroup(document.getElementById('service-options'), function (btn) {
selectedService = btn.getAttribute('data-service-id');
selectedSlot = null;
loadSlots();
});
wireOptionGroup(document.getElementById('resource-options'), function (btn) {
selectedResource = btn.getAttribute('data-resource-id');
selectedSlot = null;
loadSlots();
});
dateInput.addEventListener('change', function () {
selectedSlot = null;
loadSlots();
});
function loadSlots() {
slotOptions.innerHTML = '';
updateSubmitState();
if (!selectedService || !selectedResource || !dateInput.value) {
return;
}
var day = dateInput.value;
var params = new URLSearchParams({
client_id: CLIENT_ID, resource_id: selectedResource,
service_id: selectedService, date_from: day, date_to: day,
});
fetch('/api/booking/slots?' + params.toString())
.then(function (r) { return r.json(); })
.then(function (data) {
if (!data.slots || data.slots.length === 0) {
slotOptions.innerHTML = '<p class="muted">Keine freien Termine an diesem Tag.</p>';
return;
}
data.slots.forEach(function (iso) {
var btn = document.createElement('button');
btn.type = 'button';
btn.className = 'opt';
var d = new Date(iso);
btn.textContent = d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
btn.addEventListener('click', function () {
slotOptions.querySelectorAll('.opt').forEach(function (b) {
b.classList.remove('selected');
});
btn.classList.add('selected');
selectedSlot = iso;
updateSubmitState();
});
slotOptions.appendChild(btn);
});
})
.catch(function () {
showError('Termine konnten nicht geladen werden. Bitte versuchen Sie es erneut.');
});
}
document.getElementById('customer-name').addEventListener('input', updateSubmitState);
document.getElementById('customer-contact').addEventListener('input', updateSubmitState);
submitBtn.addEventListener('click', function () {
showError('');
submitBtn.disabled = true;
fetch('/api/booking', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
client_id: CLIENT_ID, resource_id: selectedResource,
service_id: selectedService, start_time: selectedSlot,
customer_name: document.getElementById('customer-name').value.trim(),
customer_contact: document.getElementById('customer-contact').value.trim(),
website: document.getElementById('website').value,
}),
})
.then(function (r) { return r.json().then(function (body) { return { ok: r.ok, status: r.status, body: body }; }); })
.then(function (res) {
if (!res.ok) {
if (res.status === 409) {
showError('Dieser Termin wurde gerade eben vergeben. Bitte wählen Sie einen anderen.');
selectedSlot = null;
loadSlots();
} else {
showError(res.body.error || 'Die Buchung ist fehlgeschlagen. Bitte versuchen Sie es erneut.');
}
updateSubmitState();
return;
}
var service = services.find(function (s) { return s.service_id === selectedService; });
document.getElementById('booking-root').querySelectorAll('section').forEach(function (s) {
s.style.display = 'none';
});
errorBanner.style.display = 'none';
document.getElementById('confirmation-details').textContent =
(service ? service.name : 'Termin') + ' am '
+ new Date(selectedSlot).toLocaleString('de-DE', {
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric',
hour: '2-digit', minute: '2-digit',
});
document.getElementById('confirmation-status').textContent =
res.body.status === 'pending'
? 'Ihre Buchung wartet noch auf Bestätigung.'
: 'Ihre Buchung ist bestätigt.';
confirmation.style.display = 'block';
})
.catch(function () {
showError('Die Buchung ist fehlgeschlagen. Bitte versuchen Sie es erneut.');
updateSubmitState();
});
});
loadSlots();
})();
</script>
<!-- Iframe auto-fit: report rendered height to the parent frame so an
embedding landing page can size the iframe without an inner scrollbar.
Mirrors deploy/booking/booking_layout.js's approach (same message key,
eaBookingHeight, so an existing embedding page's listener needs no
change when its src is repointed at this page). -->
<script>
(function () {
function reportHeight() {
var el = document.getElementById('booking-root');
var h = el ? Math.ceil(el.getBoundingClientRect().height) + 40 : document.body.scrollHeight;
if (h && h > 0) {
try { window.parent.postMessage({ eaBookingHeight: h }, '*'); } catch (e) { /* not embedded */ }
}
}
function start() {
if (window.parent === window) {
return;
}
reportHeight();
var target = document.getElementById('booking-root') || document.body;
if (window.ResizeObserver) {
new ResizeObserver(reportHeight).observe(target);
}
if (window.MutationObserver) {
new MutationObserver(reportHeight).observe(target, { subtree: true, childList: true, attributes: true });
}
window.addEventListener('resize', reportHeight);
var ticks = 0;
var iv = setInterval(function () {
reportHeight();
if (++ticks > 25) { clearInterval(iv); }
}, 250);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', start);
} else {
start();
}
})();
</script>
</body>
</html>