Files
mivanchenko 2515c8835b
Test backoffice (smb-crm) / test (push) Successful in 1m37s
Fix: public booking page mixed both Filialen's barbers before any was picked
applyLocationFilter()'s "!selectedLocation" check was meant to mean "only
one Filiale exists, nothing to filter" but also fired in the multi-Filiale
case before the customer had clicked one yet, showing every barber from
every location mixed together in Mitarbeiter. Now keyed on LOCATIONS.length
instead, so with 2+ Filialen nothing shows until one is actually selected.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 03:07:11 +02:00

377 lines
15 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>
{% if locations|length > 1 %}
<section>
<h2>Filiale</h2>
<div class="options" id="location-options">
{% for l in locations %}
<button type="button" class="opt" data-location-id="{{ l.location_id }}">{{ l.name }}</button>
{% endfor %}
</div>
</section>
{% endif %}
<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 id="resource-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 }}"
data-location-id="{{ r.location_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 LOCATIONS = {{ locations|tojson }};
var RESOURCES = {{ resources|tojson }};
var services = {{ services|tojson }};
var selectedService = null;
var selectedLocation = LOCATIONS.length === 1 ? LOCATIONS[0].location_id : null;
var selectedResource = RESOURCES.length === 1 ? RESOURCES[0].resource_id : null;
var selectedSlot = null;
var resourceSection = document.getElementById('resource-section');
var resourceOptionsEl = document.getElementById('resource-options');
// Filters the pre-rendered Mitarbeiter buttons down to the selected
// Filiale (all of them if there's only one Filiale, i.e. selectedLocation
// is null), auto-selecting/hiding the whole section when exactly one
// barber matches -- same "no extra click" behavior as the single-
// resource case already had before Filialen existed.
function applyLocationFilter() {
if (!resourceOptionsEl) {
return;
}
var visible = [];
resourceOptionsEl.querySelectorAll('.opt').forEach(function (btn) {
// With a single Filiale (or none at all) there's nothing to filter
// by, same as before Filialen existed. With multiple, a barber only
// matches once its own Filiale is actually selected -- before that,
// nothing should show (mixing unrelated locations' staff together
// would be actively misleading, not just unfiltered).
var matches = LOCATIONS.length <= 1
|| (!!selectedLocation && btn.getAttribute('data-location-id') === selectedLocation);
btn.hidden = !matches;
if (matches) {
visible.push(btn);
}
});
if (resourceSection) {
resourceSection.style.display = visible.length > 1 ? '' : 'none';
}
if (visible.length === 1) {
selectedResource = visible[0].getAttribute('data-resource-id');
} else if (selectedResource && !visible.some(function (b) {
return b.getAttribute('data-resource-id') === selectedResource;
})) {
selectedResource = 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('location-options'), function (btn) {
selectedLocation = btn.getAttribute('data-location-id');
selectedResource = null;
selectedSlot = null;
applyLocationFilter();
loadSlots();
});
wireOptionGroup(document.getElementById('service-options'), function (btn) {
selectedService = btn.getAttribute('data-service-id');
selectedSlot = null;
loadSlots();
});
wireOptionGroup(resourceOptionsEl, function (btn) {
selectedResource = btn.getAttribute('data-resource-id');
selectedSlot = null;
loadSlots();
});
applyLocationFilter();
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>