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>
This commit is contained in:
@@ -0,0 +1,227 @@
|
||||
<!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>
|
||||
Reference in New Issue
Block a user