Files
booking-service/frontend/index.html
ECO a3f1d3291a v1.4.0 — 16 servizi reali Ianni, sala unica, ricevuta PDF
- Migration: services.price_cents, services.availability_text
- Migration: bookings.receipt_number (trigger annuale IANNI-YYYY-NNNN) + receipt_token
- Constraint EXCLUDE bookings_no_overlap (sala unica, status confirmed/completed)
- availability.py: calcolo slot globale (non più per-provider)
- 16 servizi reali da Servizi.xls inseriti, 9 demo archiviati con FK preservata
- provider_services: 3 profili orari (lun-sab 9-19, lun-mar 9-13, lun-gio 9-13)
- Endpoint GET /api/receipts/{token} → PDF WeasyPrint
- Template HTML ricevuta con palette Ianni
- Dockerfile: deps sistema weasyprint (pango/cairo/fonts)
- requirements: +weasyprint>=63.0
- Frontend index.html: prezzo + fascia oraria nelle card servizio, link Scarica ricevuta nella conferma
2026-05-14 12:31:45 +00:00

345 lines
20 KiB
HTML

<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<title>Prenota — Farmacia Ianni</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.3/css/all.min.css">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<script>
tailwind.config = {
theme: {
extend: {
fontFamily: {
inter: ['Inter', 'sans-serif'],
},
colors: {
teal: {
50: '#f0faf4',
100: '#d1f0df',
500: '#3a9d6a',
600: '#2d8a5e',
700: '#1f6d48',
}
}
}
}
}
</script>
<style>
body { font-family: 'Inter', sans-serif; }
.bg-blob-1 { background: radial-gradient(ellipse at 20% 50%, rgba(167, 225, 195, 0.4) 0%, transparent 60%); }
.bg-blob-2 { background: radial-gradient(ellipse at 80% 70%, rgba(120, 200, 170, 0.35) 0%, transparent 55%); }
.bg-blob-3 { background: radial-gradient(ellipse at 90% 30%, rgba(167, 225, 195, 0.3) 0%, transparent 50%); }
.time-slot { transition: all 0.15s ease; }
.time-slot:hover { border-color: #2d8a5e; background-color: #f0faf4; }
.time-slot.selected { background-color: #2d8a5e; color: white; border-color: #2d8a5e; }
.svc-card { transition: all 0.2s ease; cursor: pointer; }
.svc-card:hover { border-color: #2d8a5e; background: rgba(255,255,255,0.85); }
.fade { animation: fadeIn 0.3s ease; }
@keyframes fadeIn { from{opacity:0;transform:translateY(6px)} to{opacity:1;transform:translateY(0)} }
input:focus { outline:none; border-color:#2d8a5e; box-shadow:0 0 0 3px rgba(45,138,94,0.1); }
</style>
</head>
<body class="min-h-screen flex flex-col items-center justify-center p-6" style="background-color: #ffffff;">
<!-- Background blobs -->
<div class="fixed inset-0 bg-blob-1 pointer-events-none"></div>
<div class="fixed inset-0 bg-blob-2 pointer-events-none"></div>
<div class="fixed inset-0 bg-blob-3 pointer-events-none"></div>
<!-- Logo centrato sopra la card -->
<div class="mb-6">
<img src="/static/logo.jpg" alt="Farmacia Ianni" class="h-auto w-auto max-h-[80px]">
</div>
<!-- Main Card -->
<div class="relative w-full max-w-[1020px] bg-white/60 backdrop-blur-sm rounded-3xl shadow-lg overflow-hidden flex min-h-[580px]">
<!-- Left Sidebar -->
<div class="w-[280px] flex-shrink-0 px-10 py-10 flex flex-col justify-between border-r border-white/40" style="background: linear-gradient(135deg, rgba(255,255,255,0.5) 0%, rgba(200,230,215,0.25) 100%);">
<div>
<!-- Logo reale Farmacia Ianni -->
<!-- Stepper dinamico -->
<div class="flex flex-col" id="stepper"></div>
</div>
<!-- Help -->
<div class="flex items-center gap-2 text-gray-500 text-sm mt-6">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 015.83 1c0 2-3 3-3 3"/><circle cx="12" cy="17" r="0.5" fill="currentColor"/></svg>
<a href="https://wa.me/393930579002" target="_blank" style="color:inherit;text-decoration:none">Serve aiuto?</a>
</div>
</div>
<!-- Main Content -->
<div class="flex-1 px-10 py-10 flex flex-col relative overflow-hidden">
<!-- Decorative gradient blobs in content area -->
<div class="absolute top-0 right-0 w-64 h-64 rounded-full opacity-30 pointer-events-none" style="background: radial-gradient(circle, rgba(120,200,170,0.5) 0%, transparent 70%); transform: translate(30%, -20%);"></div>
<div class="absolute bottom-0 right-0 w-80 h-80 rounded-full opacity-25 pointer-events-none" style="background: radial-gradient(circle, rgba(100,190,160,0.6) 0%, transparent 65%); transform: translate(20%, 30%);"></div>
<div id="main" class="relative z-10 flex-1 flex flex-col"></div>
</div>
</div>
<script>
const API = location.origin;
const STEPS = ['Servizio', 'Data e ora', 'I tuoi dati', 'Conferma'];
const MO = ['Gennaio','Febbraio','Marzo','Aprile','Maggio','Giugno','Luglio','Agosto','Settembre','Ottobre','Novembre','Dicembre'];
const DL = ['Domenica','Lunedì','Martedì','Mercoledì','Giovedì','Venerdì','Sabato'];
const DS = ['Lun','Mar','Mer','Gio','Ven','Sab','Dom'];
let step=0, svcs=[], sel=null, weekStart, selDate=null, slots=[], selSlot=null, lastPhone='';
const fd = d => d.getFullYear()+'-'+String(d.getMonth()+1).padStart(2,'0')+'-'+String(d.getDate()).padStart(2,'0');
const fl = d => DL[d.getDay()]+' '+d.getDate()+' '+MO[d.getMonth()];
function dayLabel(d) {
const t=new Date(); t.setHours(0,0,0,0);
const tm=new Date(t); tm.setDate(tm.getDate()+1);
if(d.toDateString()===t.toDateString()) return 'Oggi';
if(d.toDateString()===tm.toDateString()) return 'Domani';
return DS[d.getDay()===0?6:d.getDay()-1];
}
const CHK = '<svg class="w-4 h-4 text-white" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>';
const CHKBIG = '<svg class="w-7 h-7 text-white" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>';
// ── Stepper ──
function renderStepper() {
document.getElementById('stepper').innerHTML = STEPS.map((s,i) => {
const done=i<step, cur=i===step, fut=i>step;
let dot;
if(done) dot = '<div class="w-8 h-8 rounded-full bg-teal-600 flex items-center justify-center flex-shrink-0">'+CHK+'</div>';
else if(cur) dot = '<div class="w-8 h-8 rounded-full bg-teal-600 flex items-center justify-center flex-shrink-0"><span class="text-white font-semibold text-sm">'+(i+1)+'</span></div>';
else dot = '<div class="w-8 h-8 rounded-full border-2 border-gray-300 flex items-center justify-center flex-shrink-0 bg-white/50"><span class="text-gray-400 font-medium text-sm">'+(i+1)+'</span></div>';
let label = '<span class="'+(cur?'text-gray-900 font-semibold':done?'text-gray-700 font-medium':'text-gray-400 font-medium')+' text-[15px]">'+s+'</span>';
let line = i<3 ? '<div class="ml-[15px] w-[2px] h-8 '+(done?'bg-teal-500':'bg-gray-300')+'"></div>' : '';
return '<div class="flex items-center gap-4">'+dot+label+'</div>'+line;
}).join('');
}
// ── Init ──
async function init() {
weekStart = new Date(); weekStart.setHours(0,0,0,0);
try { svcs = await (await fetch(API+'/api/services')).json(); } catch(e) { svcs = []; }
go(0);
}
function go(n) { step=n; renderStepper(); render(); }
function render() {
const m = document.getElementById('main');
if(step===0) renderServices(m);
else if(step===1) renderDatetime(m);
else if(step===2) renderForm(m);
else renderConfirm(m);
}
// ── STEP 0: Servizi ──
function renderServices(m) {
const g = {};
svcs.forEach(s => (g[s.category] = g[s.category]||[]).push(s));
let html = '<div class="fade">';
html += '<h1 class="text-2xl font-bold text-gray-900 mb-6">Scegli il servizio</h1>';
for (const [cat, list] of Object.entries(g)) {
html += '<div class="text-xs font-bold uppercase tracking-widest text-teal-600 mb-2 mt-5">'+cat+'</div>';
for (const s of list) {
html += '<div class="svc-card flex items-start justify-between p-4 mb-2 rounded-2xl bg-white/50 border-2 border-transparent" onclick="pickSvc('+s.id+')">';
html += '<div class="flex-1 pr-3"><div class="font-semibold text-[15px] text-gray-900">'+s.name+'</div>';
if(s.description) html += '<div class="text-[13px] text-gray-500 mt-0.5">'+s.description+'</div>';
if(s.availability_text) html += '<div class="text-[12px] text-gray-400 mt-1">⏱ '+s.availability_text+'</div>';
html += '</div>';
html += '<div class="flex flex-col items-end gap-1 ml-2 whitespace-nowrap">';
if(s.price_cents != null) html += '<span class="text-[15px] font-bold text-gray-900">€ '+(s.price_cents/100).toFixed(2).replace(".",",")+'</span>';
html += '<span class="text-xs font-semibold text-teal-600 bg-teal-50 px-2.5 py-1 rounded-full">'+s.duration_min+' min</span>';
html += '</div>';
html += '</div>';
}
}
html += '</div>';
m.innerHTML = html;
}
function pickSvc(id) { sel=svcs.find(s=>s.id===id); selDate=null; selSlot=null; slots=[]; go(1); }
// ── STEP 1: Data e Ora (Data e Ora) ──
function renderDatetime(m) {
const wk = [];
for(let i=0; i<7; i++) { const d=new Date(weekStart); d.setDate(d.getDate()+i); wk.push(d); }
const today = new Date(); today.setHours(0,0,0,0);
const monthLabel = MO[wk[3].getMonth()]+', '+wk[3].getFullYear();
// Slot HTML
let slotsHtml = '';
if(selDate && slots.length > 0) {
// Righe da 7 7 per riga
const rows = [];
for(let i=0; i<slots.length; i+=7) rows.push(slots.slice(i, i+7));
slotsHtml = rows.map(row =>
'<div class="flex flex-wrap gap-2.5 mb-2.5">' +
row.map(s => '<button class="time-slot px-4 py-2 rounded-full border '+(selSlot&&selSlot.start===s.start ? 'border-teal-600 bg-teal-600 text-white selected' : 'border-gray-200 bg-white text-gray-700')+' text-sm font-medium" onclick="pickSlot(\''+s.start+'\',\''+s.end+'\','+s.provider_id+')">'+s.start+'</button>').join('') +
'</div>'
).join('');
} else if(selDate && slots.length === 0) {
slotsHtml = '<div class="text-center py-8 text-gray-500"><div class="text-3xl mb-2">😔</div><div class="font-medium text-gray-700">Nessun orario disponibile</div><div class="text-sm mt-1">Prova un altro giorno</div></div>';
}
// Selected box
let selectedBox = '';
if(selSlot) {
selectedBox = '<div class="bg-amber-50/80 border border-amber-200/60 rounded-xl px-5 py-4 mb-8">' +
'<p class="text-sm text-gray-600 mb-1">Hai scelto:</p>' +
'<div class="flex items-center gap-2">' +
'<svg class="w-4 h-4 text-gray-500" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg>' +
'<span class="text-sm font-medium text-gray-800">'+fl(selDate)+', '+selSlot.start+' - '+selSlot.end+'</span>' +
'</div></div>';
}
// Day strip
let dayStrip = wk.map(d => {
const dis = d < today || d.getDay() === 0;
const act = selDate && d.toDateString() === selDate.toDateString();
return '<div class="flex flex-col items-center px-3 py-2 '+(dis?'opacity-30 cursor-default':act?'cursor-pointer rounded-xl border-b-2 border-gray-800':'cursor-pointer rounded-xl hover:bg-white/60')+'" '+(dis?'':'onclick="pickDate(\''+fd(d)+'\')"')+'>' +
'<span class="text-xs '+(act?'font-semibold text-gray-800':'text-gray-500')+' mb-1">'+dayLabel(d)+'</span>' +
'<span class="text-lg '+(act?'font-bold text-gray-900':'font-medium text-gray-600')+'">'+d.getDate()+'</span>' +
'</div>';
}).join('');
m.innerHTML = '<div class="fade flex-1 flex flex-col">' +
// Header con nav mese
'<div class="flex items-center justify-between mb-8">' +
'<h1 class="text-2xl font-bold text-gray-900">Scegli data e ora</h1>' +
'<div class="flex items-center gap-3 bg-white rounded-full px-4 py-2 shadow-sm border border-gray-100">' +
'<button class="text-gray-500 hover:text-gray-700 text-sm" onclick="navWeek(-1)"><i class="fas fa-chevron-left"></i></button>' +
'<span class="text-sm font-medium text-gray-800">'+monthLabel+'</span>' +
'<button class="text-gray-500 hover:text-gray-700 text-sm" onclick="navWeek(1)"><i class="fas fa-chevron-right"></i></button>' +
'</div>' +
'</div>' +
// Date selector
'<div class="flex items-center gap-1 mb-8">' +
'<button class="text-gray-400 hover:text-gray-600 px-1" onclick="navWeek(-1)"><i class="fas fa-chevron-left text-xs"></i></button>' +
'<div class="flex-1 flex justify-between px-2">'+dayStrip+'</div>' +
'<button class="text-gray-400 hover:text-gray-600 px-1" onclick="navWeek(1)"><i class="fas fa-chevron-right text-xs"></i></button>' +
'</div>' +
// Time slots
'<div class="relative z-10 mb-3">'+slotsHtml+'</div>' +
// Currently selected
selectedBox +
// Navigation buttons
'<div class="flex justify-end gap-4 mt-auto">' +
'<button class="px-10 py-3 border border-gray-300 rounded-xl text-gray-700 font-medium bg-white hover:bg-gray-50 transition-colors" onclick="go(0)">Indietro</button>' +
'<button class="px-10 py-3 '+(selSlot?'bg-teal-600 text-white hover:bg-teal-700 cursor-pointer':'bg-gray-200 text-white cursor-default')+' rounded-xl font-medium transition-colors" onclick="'+(selSlot?'go(2)':'')+'">Avanti</button>' +
'</div>' +
'</div>';
}
function navWeek(dir) {
const d = new Date(weekStart);
d.setDate(d.getDate() + dir*7);
const t = new Date(); t.setHours(0,0,0,0);
if(d < t && dir < 0) return;
weekStart = d;
render();
}
async function pickDate(ds) {
selDate = new Date(ds+'T00:00:00');
selSlot = null;
slots = [];
render();
try {
const r = await fetch(API+'/api/services/'+sel.id+'/slots?date='+ds);
const d = await r.json();
slots = d.slots || [];
} catch(e) { slots = []; }
render();
}
function pickSlot(start, end, pid) {
selSlot = {start: start, end: end, provider_id: pid};
render();
}
// ── STEP 2: Form dati ──
function renderForm(m) {
m.innerHTML = '<div class="fade flex-1 flex flex-col">' +
'<h1 class="text-2xl font-bold text-gray-900 mb-1">I tuoi dati</h1>' +
'<p class="text-sm text-gray-500 mb-6">'+sel.name+' · '+fl(selDate)+' alle '+selSlot.start+'</p>' +
'<div class="space-y-4 mb-6">' +
'<div><label class="block text-xs font-semibold text-gray-700 mb-1.5 uppercase tracking-wide">Nome e cognome *</label>' +
'<input id="fn" type="text" placeholder="Mario Rossi" autocomplete="name" class="w-full px-4 py-3 rounded-xl border border-gray-200 bg-white/80 text-[15px]"></div>' +
'<div><label class="block text-xs font-semibold text-gray-700 mb-1.5 uppercase tracking-wide">Cellulare *</label>' +
'<input id="fp" type="tel" placeholder="333 1234567" autocomplete="tel" class="w-full px-4 py-3 rounded-xl border border-gray-200 bg-white/80 text-[15px]">' +
'<p class="text-xs text-gray-500 mt-1.5">📱 Riceverai conferma e promemoria su WhatsApp</p></div>' +
'<div><label class="block text-xs font-semibold text-gray-500 mb-1.5 uppercase tracking-wide">Email (opzionale)</label>' +
'<input id="fe" type="email" placeholder="mario@email.it" autocomplete="email" class="w-full px-4 py-3 rounded-xl border border-gray-200 bg-white/80 text-[15px]"></div>' +
'<div><label class="block text-xs font-semibold text-gray-500 mb-1.5 uppercase tracking-wide">Note (opzionale)</label>' +
'<input id="fno" type="text" placeholder="Richieste particolari..." class="w-full px-4 py-3 rounded-xl border border-gray-200 bg-white/80 text-[15px]"></div>' +
'</div>' +
'<div id="err"></div>' +
'<div class="flex justify-end gap-4 mt-auto">' +
'<button class="px-10 py-3 border border-gray-300 rounded-xl text-gray-700 font-medium bg-white hover:bg-gray-50 transition-colors" onclick="go(1)">Indietro</button>' +
'<button class="px-10 py-3 bg-teal-600 text-white rounded-xl font-medium hover:bg-teal-700 transition-colors" onclick="submitBooking()" id="bsub">Conferma</button>' +
'</div>' +
'</div>';
}
async function submitBooking() {
const n = document.getElementById('fn').value.trim();
const p = document.getElementById('fp').value.trim();
if(!n || !p) {
document.getElementById('err').innerHTML = '<div class="text-red-600 text-sm mb-4 p-3 bg-red-50 rounded-xl">Inserisci nome e cellulare</div>';
return;
}
lastPhone = p;
const btn = document.getElementById('bsub');
btn.textContent = 'Invio in corso...';
btn.disabled = true;
try {
const r = await fetch(API+'/api/bookings', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
service_id: sel.id,
provider_id: selSlot.provider_id,
start_at: fd(selDate)+'T'+selSlot.start+':00+02:00',
customer_name: n,
customer_phone: p,
customer_email: document.getElementById('fe').value.trim() || null,
notes: document.getElementById('fno').value.trim() || null
})
});
if(!r.ok) { const e = await r.json(); throw new Error(e.detail || 'Errore'); }
lastBooking = await r.json();
go(3);
} catch(e) {
document.getElementById('err').innerHTML = '<div class="text-red-600 text-sm mb-4 p-3 bg-red-50 rounded-xl">'+e.message+'</div>';
btn.textContent = 'Conferma';
btn.disabled = false;
}
}
// ── STEP 3: Conferma ──
function renderConfirm(m) {
m.innerHTML = '<div class="fade text-center pt-16">' +
'<div class="w-16 h-16 rounded-full bg-teal-600 flex items-center justify-center mx-auto mb-6" style="box-shadow:0 8px 24px rgba(45,138,94,0.3)">'+CHKBIG+'</div>' +
'<h1 class="text-2xl font-bold text-gray-900 mb-3">Prenotazione confermata!</h1>' +
'<p class="text-sm text-gray-500 leading-relaxed">' +
'<strong class="text-gray-900">'+sel.name+'</strong><br>' +
fl(selDate)+' alle <strong class="text-gray-900">'+selSlot.start+'</strong><br><br>' +
'Riceverai conferma su WhatsApp<br>al numero <strong class="text-gray-900">'+lastPhone+'</strong>' +(lastBooking && lastBooking.receipt_number ? '<br><br><span class="text-[11px] uppercase tracking-wider text-gray-400">Ricevuta</span><br><strong class="text-gray-900 text-sm">'+lastBooking.receipt_number+'</strong>' : '') +
'</p>' +
'<div class="mt-6 flex flex-col items-center gap-3">' +(lastBooking && lastBooking.receipt_token ? '<a href="'+API+'/api/receipts/'+lastBooking.receipt_token+'" target="_blank" class="inline-flex items-center gap-2 px-6 py-3 rounded-xl font-semibold text-sm" style="background:#002C50;color:white;text-decoration:none">📄 Scarica la ricevuta (PDF)</a>' : '') +'<a href="https://wa.me/393930579002" target="_blank" class="inline-flex items-center gap-2 px-6 py-3 rounded-xl text-white font-semibold text-sm" style="background:#25d366;text-decoration:none">💬 Contattaci su WhatsApp</a>' +'</div>' +
'<div class="mt-5"><button onclick="location.reload()" class="text-teal-600 text-sm font-semibold hover:underline cursor-pointer" style="background:none;border:none">Prenota un altro servizio</button></div>' +
'</div>';
}
init();
</script>
</body>
</html>