Files
smb-online/backoffice/app/templates/manage.html
T
mivanchenko 644c99ee30 Booking confirmation email + customer self-service cancel/reschedule (#18)
Sends a confirmation email (best-effort, fire-and-forget SMTP via mailer.py)
on booking creation, with a manage-booking link embedding the ticket-2 signed
token. Adds /manage/<token>, a stateless cancel/reschedule page that reuses
the existing slot-picker against booking_api's create/cancel/reschedule API,
distinguishing an invalid/expired link from an already-cancelled one. Sender
address uses the client's own domain when configured, falling back to a
mivanchenko.de address otherwise.

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

228 lines
8.7 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 verwalten</title>
<style>
:root {
--brand: #0f8a7e;
--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; max-width: 480px; }
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; }
input[type=date] { 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; }
.btn-danger { background: var(--danger); }
.error { background: var(--danger-bg); color: var(--danger); border-radius: 9px;
padding: 10px 14px; font-size: .88rem; margin-bottom: 14px; display: none; }
.card { border: 1px solid var(--line); border-radius: 12px; padding: 20px;
background: #f6faf9; }
.muted { color: var(--muted); font-size: .85rem; }
</style>
</head>
<body>
{% if state == "invalid" %}
<h1>Termin verwalten</h1>
<div class="card">
<p>Dieser Link ist ungültig oder abgelaufen.</p>
<p class="muted">Bitte wenden Sie sich an das Unternehmen, wenn Sie Ihren Termin ändern
möchten.</p>
</div>
{% elif state == "used" %}
<h1>Termin verwalten</h1>
<div class="card">
<p>Diese Buchung wurde bereits storniert.</p>
<p class="muted">Dieser Link kann nicht mehr verwendet werden.</p>
</div>
{% else %}
<div id="manage-root">
<h1>Termin verwalten</h1>
<div class="error" id="error-banner"></div>
<section id="current-section">
<h2>Ihr Termin</h2>
<p id="current-details">
{{ service }} am {{ start_local.strftime('%d.%m.%Y') }} um
{{ start_local.strftime('%H:%M') }} Uhr
({{ 'wartet noch auf Bestätigung' if status == 'pending' else 'bestätigt' }})
</p>
<button type="button" class="btn btn-danger" id="cancel-btn">Termin stornieren</button>
</section>
<section id="reschedule-section">
<h2>Termin verschieben</h2>
<div class="field">
<input type="date" id="date-input" />
</div>
<div class="options" id="slot-options"></div>
<button type="button" class="btn" id="reschedule-btn" disabled style="margin-top:12px;">
Auf neuen Termin verschieben
</button>
</section>
<div class="card" id="confirmation" style="display:none;">
<h2>Erledigt</h2>
<p id="confirmation-message"></p>
</div>
</div>
<script>
(function () {
var TOKEN = {{ token|tojson }};
var CLIENT_ID = {{ client_id|tojson }};
var RESOURCE_ID = {{ resource_id|tojson }};
var BOOKING_ID = {{ booking_id|tojson }};
var DURATION_MINUTES = {{ duration_minutes|tojson }};
var selectedSlot = null;
var errorBanner = document.getElementById('error-banner');
var dateInput = document.getElementById('date-input');
var slotOptions = document.getElementById('slot-options');
var rescheduleBtn = document.getElementById('reschedule-btn');
var cancelBtn = document.getElementById('cancel-btn');
var confirmation = document.getElementById('confirmation');
var confirmationMessage = document.getElementById('confirmation-message');
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 showDone(msg) {
document.getElementById('manage-root').querySelectorAll('section').forEach(function (s) {
s.style.display = 'none';
});
errorBanner.style.display = 'none';
confirmationMessage.textContent = msg;
confirmation.style.display = 'block';
}
function loadSlots() {
slotOptions.innerHTML = '';
selectedSlot = null;
rescheduleBtn.disabled = true;
if (!dateInput.value) {
return;
}
var day = dateInput.value;
var params = new URLSearchParams({
client_id: CLIENT_ID, resource_id: RESOURCE_ID,
duration_minutes: DURATION_MINUTES, exclude_booking_id: BOOKING_ID,
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;
rescheduleBtn.disabled = false;
});
slotOptions.appendChild(btn);
});
})
.catch(function () {
showError('Termine konnten nicht geladen werden. Bitte versuchen Sie es erneut.');
});
}
dateInput.addEventListener('change', loadSlots);
cancelBtn.addEventListener('click', function () {
showError('');
cancelBtn.disabled = true;
fetch('/api/booking/cancel', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: TOKEN }),
})
.then(function (r) { return r.json().then(function (body) { return { ok: r.ok, body: body }; }); })
.then(function (res) {
if (!res.ok) {
showError(res.body.error || 'Die Stornierung ist fehlgeschlagen.');
cancelBtn.disabled = false;
return;
}
showDone('Ihr Termin wurde storniert.');
})
.catch(function () {
showError('Die Stornierung ist fehlgeschlagen. Bitte versuchen Sie es erneut.');
cancelBtn.disabled = false;
});
});
rescheduleBtn.addEventListener('click', function () {
showError('');
rescheduleBtn.disabled = true;
fetch('/api/booking/reschedule', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: TOKEN, start_time: selectedSlot }),
})
.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.');
loadSlots();
} else {
showError(res.body.error || 'Die Verschiebung ist fehlgeschlagen.');
}
rescheduleBtn.disabled = false;
return;
}
showDone('Ihr Termin wurde verschoben auf '
+ new Date(selectedSlot).toLocaleString('de-DE', {
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric',
hour: '2-digit', minute: '2-digit',
}) + '.');
})
.catch(function () {
showError('Die Verschiebung ist fehlgeschlagen. Bitte versuchen Sie es erneut.');
rescheduleBtn.disabled = false;
});
});
loadSlots();
})();
</script>
{% endif %}
</body>
</html>