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
This commit is contained in:
10
Dockerfile
10
Dockerfile
@@ -2,12 +2,16 @@ FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Dipendenze sistema per weasyprint (pango, cairo, fonts)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libpango-1.0-0 libpangoft2-1.0-0 libcairo2 libgdk-pixbuf-2.0-0 \
|
||||
libffi-dev shared-mime-info fonts-dejavu-core fonts-liberation
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY app/ app/
|
||||
COPY frontend/ frontend/
|
||||
COPY app ./app
|
||||
COPY frontend ./frontend
|
||||
|
||||
EXPOSE 8020
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8020"]
|
||||
|
||||
@@ -14,6 +14,8 @@ class Service(Base):
|
||||
duration_min = Column(Integer, nullable=False, default=15)
|
||||
description = Column(Text)
|
||||
category = Column(Text, default="generale")
|
||||
price_cents = Column(Integer)
|
||||
availability_text = Column(Text)
|
||||
active = Column(Boolean, default=True)
|
||||
sort_order = Column(Integer, default=0)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
@@ -63,6 +65,8 @@ class Booking(Base):
|
||||
end_at = Column(DateTime(timezone=True), nullable=False)
|
||||
status = Column(Text, default="confirmed")
|
||||
google_event_id = Column(Text)
|
||||
receipt_number = Column(Text)
|
||||
receipt_token = Column(Text)
|
||||
notes = Column(Text)
|
||||
reminder_sent = Column(Boolean, default=False)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
"""Endpoint pubblici — no auth. Usati dal widget di prenotazione."""
|
||||
import secrets
|
||||
from datetime import date, datetime, timedelta
|
||||
from zoneinfo import ZoneInfo
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
from app.database import get_db
|
||||
from app.models import Service, Booking, ProviderService
|
||||
from app.schemas import ServiceOut, BookingCreate, BookingOut, TimeSlot
|
||||
from app.services.availability import get_available_slots
|
||||
from app.services.notifications import notify_booking_confirmed, notify_operator
|
||||
from fastapi.responses import Response
|
||||
from app.services.receipt import render_receipt_pdf
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["public"])
|
||||
TZ = ZoneInfo("Europe/Rome")
|
||||
@@ -62,6 +66,7 @@ async def create_booking(data: BookingCreate, db: Session = Depends(get_db)):
|
||||
end_at=end_at,
|
||||
notes=data.notes,
|
||||
)
|
||||
booking.receipt_token = secrets.token_urlsafe(24)
|
||||
db.add(booking)
|
||||
db.commit()
|
||||
db.refresh(booking)
|
||||
@@ -111,3 +116,30 @@ def cancel_booking(booking_id: int, phone: str, db: Session = Depends(get_db)):
|
||||
booking.status = "cancelled"
|
||||
db.commit()
|
||||
return {"ok": True, "message": "Prenotazione cancellata"}
|
||||
|
||||
|
||||
@router.get("/receipts/{token}")
|
||||
def get_receipt(token: str, db: Session = Depends(get_db)):
|
||||
"""Restituisce il PDF della ricevuta. Token random anti-enumeration."""
|
||||
booking = (
|
||||
db.query(Booking)
|
||||
.options(joinedload(Booking.service), joinedload(Booking.provider))
|
||||
.filter(Booking.receipt_token == token)
|
||||
.first()
|
||||
)
|
||||
if not booking:
|
||||
raise HTTPException(404, "Ricevuta non trovata")
|
||||
if booking.status not in ("confirmed", "completed"):
|
||||
raise HTTPException(410, "Ricevuta non disponibile per questa prenotazione")
|
||||
|
||||
# Settings → dict
|
||||
settings_rows = db.execute(text("SELECT key, value FROM settings")).fetchall()
|
||||
settings_map = {k: v for k, v in settings_rows}
|
||||
|
||||
pdf_bytes = render_receipt_pdf(booking, booking.service, booking.provider, settings_map)
|
||||
fname = f"ricevuta-{booking.receipt_number or booking.id}.pdf"
|
||||
return Response(
|
||||
content=pdf_bytes,
|
||||
media_type="application/pdf",
|
||||
headers={"Content-Disposition": f'inline; filename="{fname}"'},
|
||||
)
|
||||
|
||||
@@ -11,6 +11,8 @@ class ServiceOut(BaseModel):
|
||||
duration_min: int
|
||||
description: Optional[str] = None
|
||||
category: str
|
||||
price_cents: Optional[int] = None
|
||||
availability_text: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -22,6 +24,8 @@ class ServiceCreate(BaseModel):
|
||||
duration_min: int = 15
|
||||
description: Optional[str] = None
|
||||
category: str = "generale"
|
||||
price_cents: Optional[int] = None
|
||||
availability_text: Optional[str] = None
|
||||
|
||||
|
||||
# === Providers ===
|
||||
@@ -77,6 +81,8 @@ class BookingOut(BaseModel):
|
||||
status: str
|
||||
notes: Optional[str] = None
|
||||
created_at: datetime
|
||||
receipt_number: Optional[str] = None
|
||||
receipt_token: Optional[str] = None
|
||||
|
||||
service: Optional[ServiceOut] = None
|
||||
provider: Optional[ProviderOut] = None
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"""Calcolo slot disponibili per un servizio in una data.
|
||||
"""Calcolo slot disponibili — modello SALA UNICA.
|
||||
|
||||
Logica: regole orario provider - prenotazioni esistenti - busy Google Calendar.
|
||||
Google Calendar è opzionale (fase 2).
|
||||
La sala dei servizi è unica: nessun overlap a livello globale, indipendentemente
|
||||
da provider o servizio. Le availability_rules sono per coppia (provider,service)
|
||||
e servono a definire le fasce orarie in cui un certo servizio può essere prenotato.
|
||||
Lo slot è confermato libero solo se nessun altro booking confirmed/completed lo intersecano.
|
||||
"""
|
||||
from datetime import datetime, date, timedelta, time
|
||||
from zoneinfo import ZoneInfo
|
||||
@@ -18,9 +20,8 @@ def get_available_slots(
|
||||
target_date: date,
|
||||
duration_min: int,
|
||||
) -> list[TimeSlot]:
|
||||
"""Restituisce tutti gli slot liberi per un servizio in una data."""
|
||||
"""Slot liberi per service_id in target_date, considerando la sala unica."""
|
||||
|
||||
# 1. Trova tutti i provider che erogano questo servizio
|
||||
ps_list = (
|
||||
db.query(ProviderService)
|
||||
.filter(ProviderService.service_id == service_id)
|
||||
@@ -30,71 +31,58 @@ def get_available_slots(
|
||||
return []
|
||||
|
||||
weekday = target_date.weekday() # 0=lunedì
|
||||
all_slots = []
|
||||
|
||||
# 1. Genero candidati: per ogni provider attivo, secondo le regole del giorno
|
||||
# Mappa: datetime → primo (provider_id, provider_name) che lo offre
|
||||
candidate_provider = {}
|
||||
for ps in ps_list:
|
||||
if not ps.provider.active:
|
||||
if not ps.provider or not ps.provider.active:
|
||||
continue
|
||||
|
||||
# 2. Filtra le regole di disponibilità per il giorno della settimana
|
||||
rules = ps.availability_rules or []
|
||||
day_rules = [r for r in rules if r.get("weekday") == weekday]
|
||||
for rule in [r for r in rules if r.get("weekday") == weekday]:
|
||||
sh, sm = map(int, rule["start"].split(":"))
|
||||
eh, em = map(int, rule["end"].split(":"))
|
||||
t = datetime.combine(target_date, time(sh, sm), tzinfo=TZ)
|
||||
limit = datetime.combine(target_date, time(eh, em), tzinfo=TZ)
|
||||
while t + timedelta(minutes=duration_min) <= limit:
|
||||
candidate_provider.setdefault(t, (ps.provider_id, ps.provider.name))
|
||||
t += timedelta(minutes=duration_min)
|
||||
|
||||
if not day_rules:
|
||||
continue
|
||||
if not candidate_provider:
|
||||
return []
|
||||
|
||||
# 3. Genera tutti gli slot possibili dalle regole
|
||||
raw_slots = []
|
||||
for rule in day_rules:
|
||||
start_h, start_m = map(int, rule["start"].split(":"))
|
||||
end_h, end_m = map(int, rule["end"].split(":"))
|
||||
|
||||
slot_start = datetime.combine(target_date, time(start_h, start_m), tzinfo=TZ)
|
||||
slot_end_limit = datetime.combine(target_date, time(end_h, end_m), tzinfo=TZ)
|
||||
|
||||
while slot_start + timedelta(minutes=duration_min) <= slot_end_limit:
|
||||
raw_slots.append(slot_start)
|
||||
slot_start += timedelta(minutes=duration_min)
|
||||
|
||||
# 4. Filtra via le prenotazioni esistenti (confirmed)
|
||||
day_start = datetime.combine(target_date, time(0, 0), tzinfo=TZ)
|
||||
day_end = day_start + timedelta(days=1)
|
||||
|
||||
existing = (
|
||||
db.query(Booking)
|
||||
.filter(
|
||||
Booking.provider_id == ps.provider_id,
|
||||
Booking.start_at >= day_start,
|
||||
Booking.start_at < day_end,
|
||||
Booking.status == "confirmed",
|
||||
)
|
||||
.all()
|
||||
# 2. Tutte le prenotazioni del giorno con stato che blocca la sala (SALA UNICA)
|
||||
day_start = datetime.combine(target_date, time(0, 0), tzinfo=TZ)
|
||||
day_end = day_start + timedelta(days=1)
|
||||
busy_rows = (
|
||||
db.query(Booking.start_at, Booking.end_at)
|
||||
.filter(
|
||||
Booking.start_at >= day_start,
|
||||
Booking.start_at < day_end,
|
||||
Booking.status.in_(["confirmed", "completed"]),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
busy = [(s, e) for s, e in busy_rows]
|
||||
|
||||
busy_ranges = [(b.start_at, b.end_at) for b in existing]
|
||||
# 3. Tengo i candidati non in conflitto
|
||||
slots: list[TimeSlot] = []
|
||||
for slot_start in sorted(candidate_provider.keys()):
|
||||
slot_end = slot_start + timedelta(minutes=duration_min)
|
||||
if any(slot_start < be and slot_end > bs for bs, be in busy):
|
||||
continue
|
||||
pid, pname = candidate_provider[slot_start]
|
||||
slots.append(TimeSlot(
|
||||
start=slot_start.strftime("%H:%M"),
|
||||
end=slot_end.strftime("%H:%M"),
|
||||
provider_id=pid,
|
||||
provider_name=pname,
|
||||
))
|
||||
|
||||
for slot_start in raw_slots:
|
||||
slot_end = slot_start + timedelta(minutes=duration_min)
|
||||
conflict = any(
|
||||
slot_start < busy_end and slot_end > busy_start
|
||||
for busy_start, busy_end in busy_ranges
|
||||
)
|
||||
if not conflict:
|
||||
all_slots.append(
|
||||
TimeSlot(
|
||||
start=slot_start.strftime("%H:%M"),
|
||||
end=slot_end.strftime("%H:%M"),
|
||||
provider_id=ps.provider_id,
|
||||
provider_name=ps.provider.name,
|
||||
)
|
||||
)
|
||||
|
||||
# 5. Ordina per orario
|
||||
all_slots.sort(key=lambda s: s.start)
|
||||
|
||||
# 6. Filtra slot nel passato se target_date è oggi
|
||||
# 4. Se oggi, scarto orari già passati
|
||||
now = datetime.now(TZ)
|
||||
if target_date == now.date():
|
||||
all_slots = [s for s in all_slots if s.start > now.strftime("%H:%M")]
|
||||
cur = now.strftime("%H:%M")
|
||||
slots = [s for s in slots if s.start > cur]
|
||||
|
||||
return all_slots
|
||||
return slots
|
||||
|
||||
54
app/services/receipt.py
Normal file
54
app/services/receipt.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""Generazione ricevuta PDF per booking."""
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from zoneinfo import ZoneInfo
|
||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||
from weasyprint import HTML
|
||||
|
||||
TZ = ZoneInfo("Europe/Rome")
|
||||
TEMPLATE_DIR = Path(__file__).resolve().parent.parent / "templates"
|
||||
|
||||
_env = Environment(
|
||||
loader=FileSystemLoader(str(TEMPLATE_DIR)),
|
||||
autoescape=select_autoescape(["html"]),
|
||||
)
|
||||
|
||||
_WEEKDAY_IT = ["lunedì", "martedì", "mercoledì", "giovedì", "venerdì", "sabato", "domenica"]
|
||||
_MONTH_IT = ["", "gennaio", "febbraio", "marzo", "aprile", "maggio", "giugno",
|
||||
"luglio", "agosto", "settembre", "ottobre", "novembre", "dicembre"]
|
||||
|
||||
|
||||
def _fmt_datetime(dt: datetime) -> str:
|
||||
d = dt.astimezone(TZ)
|
||||
return f"{_WEEKDAY_IT[d.weekday()]} {d.day} {_MONTH_IT[d.month]} {d.year} alle ore {d:%H:%M}"
|
||||
|
||||
|
||||
def _fmt_now() -> str:
|
||||
d = datetime.now(TZ)
|
||||
return f"{d.day} {_MONTH_IT[d.month]} {d.year} alle ore {d:%H:%M}"
|
||||
|
||||
|
||||
def _price_str(price_cents):
|
||||
if price_cents is None:
|
||||
return "—"
|
||||
return f"€ {price_cents/100:,.2f}".replace(",", "X").replace(".", ",").replace("X", ".")
|
||||
|
||||
|
||||
def render_receipt_pdf(booking, service, provider, settings_map: dict) -> bytes:
|
||||
"""Costruisce il PDF della ricevuta. settings_map è {key: value} dalla tabella settings."""
|
||||
template = _env.get_template("receipt.html")
|
||||
html = template.render(
|
||||
booking=booking,
|
||||
service=service,
|
||||
provider=provider,
|
||||
pharmacy={
|
||||
"name": settings_map.get("pharmacy_name", "Farmacia Ianni SNC"),
|
||||
"address": settings_map.get("pharmacy_address", ""),
|
||||
"phone": settings_map.get("pharmacy_phone", ""),
|
||||
"email": settings_map.get("smtp_from", ""),
|
||||
},
|
||||
start_str=_fmt_datetime(booking.start_at),
|
||||
now_str=_fmt_now(),
|
||||
price_str=_price_str(service.price_cents),
|
||||
)
|
||||
return HTML(string=html).write_pdf()
|
||||
97
app/templates/receipt.html
Normal file
97
app/templates/receipt.html
Normal file
@@ -0,0 +1,97 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Ricevuta {{ booking.receipt_number }}</title>
|
||||
<style>
|
||||
@page { size: A4; margin: 18mm 16mm; }
|
||||
* { box-sizing: border-box; }
|
||||
body { font-family: 'DejaVu Sans', 'Liberation Sans', Arial, sans-serif; font-size: 10.5pt; color: #111; margin: 0; }
|
||||
.header { display: flex; justify-content: space-between; align-items: flex-start; border-bottom: 3px solid #002C50; padding-bottom: 12px; margin-bottom: 22px; }
|
||||
.brand { color: #002C50; }
|
||||
.brand h1 { margin: 0; font-size: 18pt; letter-spacing: 0.5px; }
|
||||
.brand .sub { color: #6A971F; font-weight: 600; font-size: 10pt; letter-spacing: 0.3px; }
|
||||
.pharm { text-align: right; font-size: 9.5pt; color: #444; line-height: 1.45; }
|
||||
.pharm strong { color: #002C50; font-size: 11pt; display:block; margin-bottom: 2px; }
|
||||
.receipt-block { background: #f5f7fa; border-left: 4px solid #6A971F; padding: 10px 14px; margin-bottom: 22px; }
|
||||
.receipt-block .num { font-size: 13pt; font-weight: 700; color: #002C50; }
|
||||
.receipt-block .meta { font-size: 9.5pt; color: #555; margin-top: 3px; }
|
||||
h2 { color: #002C50; font-size: 12pt; margin: 18px 0 8px; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
table.kv { width: 100%; border-collapse: collapse; margin-bottom: 12px; }
|
||||
table.kv td { padding: 6px 0; vertical-align: top; }
|
||||
table.kv td.label { color: #666; width: 32%; }
|
||||
table.kv td.val { color: #111; font-weight: 500; }
|
||||
.service-box { border: 1px solid #d8dde3; border-radius: 4px; padding: 14px; margin-bottom: 16px; }
|
||||
.service-box .name { font-weight: 700; color: #002C50; font-size: 12pt; margin-bottom: 4px; }
|
||||
.service-box .desc { color: #555; font-size: 9.5pt; margin-bottom: 8px; }
|
||||
.price-row { display: flex; justify-content: space-between; align-items: center; border-top: 2px solid #002C50; margin-top: 12px; padding-top: 10px; }
|
||||
.price-row .label { font-size: 11pt; color: #002C50; font-weight: 600; }
|
||||
.price-row .amount { font-size: 16pt; color: #6A971F; font-weight: 700; }
|
||||
.note { font-size: 8.5pt; color: #777; line-height: 1.4; margin-top: 26px; padding-top: 10px; border-top: 1px dashed #d8dde3; }
|
||||
.footer { font-size: 8pt; color: #999; text-align: center; margin-top: 30px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="header">
|
||||
<div class="brand">
|
||||
<h1>Farmacia Ianni</h1>
|
||||
<div class="sub">Salute · Servizi · Prevenzione</div>
|
||||
</div>
|
||||
<div class="pharm">
|
||||
<strong>{{ pharmacy.name }}</strong>
|
||||
{{ pharmacy.address }}<br>
|
||||
Tel. {{ pharmacy.phone }}<br>
|
||||
{{ pharmacy.email }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="receipt-block">
|
||||
<div class="num">Ricevuta di prenotazione N° {{ booking.receipt_number or '—' }}</div>
|
||||
<div class="meta">Emessa il {{ now_str }} · {{ pharmacy.name }}</div>
|
||||
</div>
|
||||
|
||||
<h2>Cliente</h2>
|
||||
<table class="kv">
|
||||
<tr><td class="label">Nome e cognome</td><td class="val">{{ booking.customer_name }}</td></tr>
|
||||
<tr><td class="label">Telefono</td><td class="val">{{ booking.customer_phone }}</td></tr>
|
||||
{% if booking.customer_email %}<tr><td class="label">Email</td><td class="val">{{ booking.customer_email }}</td></tr>{% endif %}
|
||||
</table>
|
||||
|
||||
<h2>Appuntamento</h2>
|
||||
<table class="kv">
|
||||
<tr><td class="label">Data e ora</td><td class="val">{{ start_str }}</td></tr>
|
||||
<tr><td class="label">Durata</td><td class="val">{{ service.duration_min }} minuti</td></tr>
|
||||
<tr><td class="label">Operatore</td><td class="val">{{ provider.name }}</td></tr>
|
||||
<tr><td class="label">Sede</td><td class="val">{{ pharmacy.address }}</td></tr>
|
||||
</table>
|
||||
|
||||
<h2>Servizio prenotato</h2>
|
||||
<div class="service-box">
|
||||
<div class="name">{{ service.name }}</div>
|
||||
{% if service.description %}<div class="desc">{{ service.description }}</div>{% endif %}
|
||||
<table class="kv" style="margin-bottom:0">
|
||||
<tr><td class="label">Categoria</td><td class="val">{{ service.category|capitalize }}</td></tr>
|
||||
{% if service.availability_text %}<tr><td class="label">Fascia di erogazione</td><td class="val">{{ service.availability_text }}</td></tr>{% endif %}
|
||||
</table>
|
||||
<div class="price-row">
|
||||
<div class="label">Importo previsto</div>
|
||||
<div class="amount">{{ price_str }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if booking.notes %}
|
||||
<h2>Note</h2>
|
||||
<div style="background:#fff8e1;border-left:3px solid #f0c040;padding:8px 12px;font-size:10pt;">{{ booking.notes }}</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="note">
|
||||
<strong>Avvertenze.</strong> Questa è una ricevuta di prenotazione: documenta l'appuntamento e l'importo previsto, ma NON sostituisce lo scontrino fiscale o la fattura sanitaria che verranno emessi dal personale di farmacia al momento dell'esecuzione del servizio. I servizi sanitari di diagnostica e prevenzione sono esenti IVA ai sensi dell'art. 10 DPR 633/1972. Per disdire l'appuntamento contattare la farmacia con almeno 24 ore di anticipo.
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
Token verifica: {{ booking.receipt_token[:8] }}… · Generato automaticamente da bookingservizi.farmaciaianni.it
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -148,11 +148,15 @@ function renderServices(m) {
|
||||
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-center justify-between p-4 mb-2 rounded-2xl bg-white/50 border-2 border-transparent" onclick="pickSvc('+s.id+')">';
|
||||
html += '<div><div class="font-semibold text-[15px] text-gray-900">'+s.name+'</div>';
|
||||
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 += '<span class="text-xs font-semibold text-teal-600 bg-teal-50 px-3 py-1.5 rounded-full ml-4 whitespace-nowrap">'+s.duration_min+' min</span>';
|
||||
html += '</div>';
|
||||
}
|
||||
}
|
||||
@@ -310,6 +314,7 @@ async function submitBooking() {
|
||||
})
|
||||
});
|
||||
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>';
|
||||
@@ -326,9 +331,9 @@ function renderConfirm(m) {
|
||||
'<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>' +
|
||||
'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>' +
|
||||
'<a href="https://wa.me/393930579002" target="_blank" class="inline-flex items-center gap-2 mt-6 px-6 py-3 rounded-xl text-white font-semibold text-sm" style="background:#25d366;text-decoration:none">💬 Contattaci su WhatsApp</a>' +
|
||||
'<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>';
|
||||
}
|
||||
|
||||
@@ -13,3 +13,4 @@ apscheduler>=3.10.4
|
||||
authlib>=1.3.0
|
||||
python-jose[cryptography]>=3.3.0
|
||||
itsdangerous>=2.1.0
|
||||
weasyprint>=63.0
|
||||
|
||||
Reference in New Issue
Block a user