Disattiva temporaneamente form prenotazioni

This commit is contained in:
2026-08-21 19:02:16 +02:00
parent d9b617e706
commit 04fb49ad4b
6 changed files with 444 additions and 47 deletions

View File

@@ -1,27 +1,88 @@
from fastapi import APIRouter, HTTPException, status from fastapi import APIRouter, HTTPException, Request, status
from app.schemas.booking import BookingRequestCreate, BookingRequestResponse from app.config import settings
from app.schemas.booking import BookingCaptchaChallenge, BookingRequestCreate, BookingRequestResponse
from app.services.booking_security import (
create_captcha_challenge,
get_client_ip,
is_rate_limited,
new_request_id,
verify_captcha,
write_booking_audit_event,
)
from app.services.mailer import MailConfigurationError, send_booking_request_email from app.services.mailer import MailConfigurationError, send_booking_request_email
router = APIRouter(prefix="/api", tags=["booking"]) router = APIRouter(prefix="/api", tags=["booking"])
@router.get("/booking-captcha", response_model=BookingCaptchaChallenge)
def get_booking_captcha() -> BookingCaptchaChallenge:
return create_captcha_challenge()
@router.post("/booking-request", response_model=BookingRequestResponse) @router.post("/booking-request", response_model=BookingRequestResponse)
def create_booking_request(payload: BookingRequestCreate) -> BookingRequestResponse: def create_booking_request(request: Request, payload: BookingRequestCreate) -> BookingRequestResponse:
request_id = new_request_id()
write_booking_audit_event("received", request_id=request_id, request=request, payload=payload)
if not settings.booking_form_enabled:
write_booking_audit_event("blocked_form_disabled", request_id=request_id, request=request, payload=payload)
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Form in aggiornamento.",
)
if payload.website:
write_booking_audit_event("blocked_honeypot", request_id=request_id, request=request, payload=payload)
return BookingRequestResponse(
message="La richiesta è stata inviata correttamente e sarà presa in carico al più presto dal team.",
)
client_ip = get_client_ip(request)
if is_rate_limited(client_ip):
write_booking_audit_event("blocked_rate_limit", request_id=request_id, request=request, payload=payload)
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Sono state inviate troppe richieste. Riprova più tardi.",
)
if not verify_captcha(payload.captcha_id, payload.captcha_answer):
write_booking_audit_event("blocked_captcha", request_id=request_id, request=request, payload=payload)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Verifica anti-spam non valida. Ricarica il codice e riprova.",
)
try: try:
send_booking_request_email(payload) send_booking_request_email(payload)
except MailConfigurationError as exc: except MailConfigurationError as exc:
write_booking_audit_event(
"failed_configuration",
request_id=request_id,
request=request,
payload=payload,
error=exc,
)
raise HTTPException( raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Il servizio email non è ancora configurato.", detail="Il servizio email non è ancora configurato.",
) from exc ) from exc
except Exception as exc: # pragma: no cover - mail delivery path except Exception as exc: # pragma: no cover - mail delivery path
write_booking_audit_event(
"failed_delivery",
request_id=request_id,
request=request,
payload=payload,
error=exc,
)
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Si è verificato un problema durante l'invio della richiesta.", detail="Si è verificato un problema durante l'invio della richiesta.",
) from exc ) from exc
write_booking_audit_event("sent", request_id=request_id, request=request, payload=payload)
return BookingRequestResponse( return BookingRequestResponse(
message="La richiesta è stata inviata correttamente e sarà presa in carico al più presto dal team.", message="La richiesta è stata inviata correttamente e sarà presa in carico al più presto dal team.",
) )

View File

@@ -32,6 +32,23 @@ class Settings:
"BOOKING_EMAIL_SUBJECT_PREFIX", "BOOKING_EMAIL_SUBJECT_PREFIX",
"[Clinica Veterinaria Formiginese]", "[Clinica Veterinaria Formiginese]",
) )
self.booking_form_enabled = os.getenv("BOOKING_FORM_ENABLED", "false").lower() in {
"1",
"true",
"yes",
"on",
}
self.booking_audit_log_path = os.getenv(
"BOOKING_AUDIT_LOG_PATH",
str((BASE_DIR / "logs" / "booking_audit.log").resolve()),
)
self.booking_rate_limit_window_seconds = int(os.getenv("BOOKING_RATE_LIMIT_WINDOW_SECONDS", "900"))
self.booking_rate_limit_max_requests = int(os.getenv("BOOKING_RATE_LIMIT_MAX_REQUESTS", "5"))
self.booking_captcha_ttl_seconds = int(os.getenv("BOOKING_CAPTCHA_TTL_SECONDS", "600"))
self.booking_captcha_secret = os.getenv(
"BOOKING_CAPTCHA_SECRET",
self.smtp_password or self.app_name,
)
@property @property
def sqlite_file_path(self) -> str | None: def sqlite_file_path(self) -> str | None:

View File

@@ -3,6 +3,7 @@ from pydantic import BaseModel, Field
class BookingRequestCreate(BaseModel): class BookingRequestCreate(BaseModel):
name: str = Field(min_length=2, max_length=120) name: str = Field(min_length=2, max_length=120)
email: str = Field(min_length=5, max_length=255)
phone: str = Field(min_length=5, max_length=40) phone: str = Field(min_length=5, max_length=40)
pet_name: str = Field(default="", max_length=120) pet_name: str = Field(default="", max_length=120)
pet_type: str = Field(default="cane", max_length=40) pet_type: str = Field(default="cane", max_length=40)
@@ -11,8 +12,16 @@ class BookingRequestCreate(BaseModel):
date: str = Field(min_length=8, max_length=20) date: str = Field(min_length=8, max_length=20)
time: str = Field(default="", max_length=20) time: str = Field(default="", max_length=20)
notes: str = Field(default="", max_length=2000) notes: str = Field(default="", max_length=2000)
website: str = Field(default="", max_length=200)
captcha_id: str = Field(min_length=20, max_length=300)
captcha_answer: str = Field(min_length=1, max_length=10)
class BookingRequestResponse(BaseModel): class BookingRequestResponse(BaseModel):
success: bool = True success: bool = True
message: str message: str
class BookingCaptchaChallenge(BaseModel):
captcha_id: str
question: str

View File

@@ -0,0 +1,169 @@
from __future__ import annotations
from collections import defaultdict, deque
from datetime import UTC, datetime
import base64
import hashlib
import hmac
import json
from pathlib import Path
import random
import threading
import time
from typing import Any
from uuid import uuid4
from fastapi import Request
from app.config import settings
from app.schemas.booking import BookingCaptchaChallenge, BookingRequestCreate
_audit_lock = threading.Lock()
_rate_limit_lock = threading.Lock()
_rate_limit_hits: dict[str, deque[float]] = defaultdict(deque)
def new_request_id() -> str:
return uuid4().hex
def get_client_ip(request: Request) -> str:
forwarded_for = request.headers.get("x-forwarded-for", "")
if forwarded_for:
return forwarded_for.split(",", 1)[0].strip()
real_ip = request.headers.get("x-real-ip", "")
if real_ip:
return real_ip.strip()
if request.client:
return request.client.host
return "unknown"
def is_rate_limited(client_ip: str) -> bool:
now = time.time()
window = settings.booking_rate_limit_window_seconds
max_requests = settings.booking_rate_limit_max_requests
with _rate_limit_lock:
hits = _rate_limit_hits[client_ip]
while hits and now - hits[0] > window:
hits.popleft()
if len(hits) >= max_requests:
return True
hits.append(now)
return False
def create_captcha_challenge() -> BookingCaptchaChallenge:
left = random.randint(2, 9)
right = random.randint(2, 9)
expires_at = int(time.time()) + settings.booking_captcha_ttl_seconds
nonce = uuid4().hex
answer = str(left + right)
payload = f"{answer}:{expires_at}:{nonce}"
signature = _sign(payload)
token = _encode(f"{payload}:{signature}")
return BookingCaptchaChallenge(
captcha_id=token,
question=f"Quanto fa {left} + {right}?",
)
def verify_captcha(captcha_id: str, captcha_answer: str) -> bool:
try:
decoded = _decode(captcha_id)
answer, expires_at_value, nonce, signature = decoded.split(":", 3)
payload = f"{answer}:{expires_at_value}:{nonce}"
expected_signature = _sign(payload)
expires_at = int(expires_at_value)
except (ValueError, TypeError):
return False
if not hmac.compare_digest(signature, expected_signature):
return False
if expires_at < int(time.time()):
return False
return hmac.compare_digest(captcha_answer.strip(), answer)
def write_booking_audit_event(
event: str,
*,
request_id: str,
request: Request,
payload: BookingRequestCreate | None = None,
error: Exception | str | None = None,
) -> None:
client_ip = get_client_ip(request)
record: dict[str, Any] = {
"timestamp": datetime.now(UTC).isoformat(),
"event": event,
"request_id": request_id,
"client_ip": client_ip,
"user_agent": request.headers.get("user-agent", "")[:300],
"referer": request.headers.get("referer", "")[:300],
}
if payload is not None:
record.update(
{
"email_hash": _hash_value(payload.email.lower().strip()),
"email_domain": _email_domain(payload.email),
"phone_hash": _hash_value(payload.phone.strip()),
"name_length": len(payload.name),
"pet_type": payload.pet_type,
"doctor": payload.doctor,
"service": payload.service,
"date": payload.date,
"time": payload.time,
"notes_length": len(payload.notes or ""),
"honeypot_filled": bool(payload.website),
}
)
if error is not None:
record["error_type"] = error.__class__.__name__ if isinstance(error, Exception) else "Error"
record["error"] = str(error)[:500]
log_path = Path(settings.booking_audit_log_path)
log_path.parent.mkdir(parents=True, exist_ok=True)
with _audit_lock:
with log_path.open("a", encoding="utf-8") as log_file:
log_file.write(json.dumps(record, ensure_ascii=False) + "\n")
def _sign(payload: str) -> str:
return hmac.new(
settings.booking_captcha_secret.encode("utf-8"),
payload.encode("utf-8"),
hashlib.sha256,
).hexdigest()
def _encode(value: str) -> str:
return base64.urlsafe_b64encode(value.encode("utf-8")).decode("ascii").rstrip("=")
def _decode(value: str) -> str:
padding = "=" * (-len(value) % 4)
return base64.urlsafe_b64decode(f"{value}{padding}".encode("ascii")).decode("utf-8")
def _hash_value(value: str) -> str:
return hashlib.sha256(value.encode("utf-8")).hexdigest()
def _email_domain(email: str) -> str:
if "@" not in email:
return ""
return email.rsplit("@", 1)[1].lower()[:120]

View File

@@ -25,13 +25,14 @@ def _build_booking_html(payload: BookingRequestCreate) -> str:
<p>È stata inviata una nuova richiesta di prenotazione non vincolante dal sito web.</p> <p>È stata inviata una nuova richiesta di prenotazione non vincolante dal sito web.</p>
<table cellpadding="8" cellspacing="0" border="0" style="border-collapse: collapse;"> <table cellpadding="8" cellspacing="0" border="0" style="border-collapse: collapse;">
<tr><td><strong>Nome e cognome</strong></td><td>{payload.name}</td></tr> <tr><td><strong>Nome e cognome</strong></td><td>{payload.name}</td></tr>
<tr><td><strong>Email</strong></td><td>{payload.email}</td></tr>
<tr><td><strong>Telefono</strong></td><td>{payload.phone}</td></tr> <tr><td><strong>Telefono</strong></td><td>{payload.phone}</td></tr>
<tr><td><strong>Nome animale</strong></td><td>{payload.pet_name or "-"}</td></tr> <tr><td><strong>Nome animale</strong></td><td>{payload.pet_name or "-"}</td></tr>
<tr><td><strong>Tipo animale</strong></td><td>{payload.pet_type}</td></tr> <tr><td><strong>Tipo animale</strong></td><td>{payload.pet_type}</td></tr>
<tr><td><strong>Medico richiesto</strong></td><td>{payload.doctor}</td></tr> <tr><td><strong>Medico richiesto</strong></td><td>{payload.doctor}</td></tr>
<tr><td><strong>Tipo di visita</strong></td><td>{payload.service}</td></tr> <tr><td><strong>Tipo di visita</strong></td><td>{payload.service}</td></tr>
<tr><td><strong>Data preferita</strong></td><td>{payload.date}</td></tr> <tr><td><strong>Periodo preferito</strong></td><td>{payload.date}</td></tr>
<tr><td><strong>Orario preferito</strong></td><td>{payload.time or "Qualsiasi orario"}</td></tr> <tr><td><strong>Fascia oraria preferita</strong></td><td>{payload.time or "Qualsiasi orario"}</td></tr>
<tr><td><strong>Note</strong></td><td>{payload.notes or "-"}</td></tr> <tr><td><strong>Note</strong></td><td>{payload.notes or "-"}</td></tr>
<tr><td><strong>Inviata il</strong></td><td>{submitted_at}</td></tr> <tr><td><strong>Inviata il</strong></td><td>{submitted_at}</td></tr>
</table> </table>
@@ -49,13 +50,14 @@ def _build_booking_text(payload: BookingRequestCreate) -> str:
return ( return (
"Nuova richiesta di prenotazione visita\n\n" "Nuova richiesta di prenotazione visita\n\n"
f"Nome e cognome: {payload.name}\n" f"Nome e cognome: {payload.name}\n"
f"Email: {payload.email}\n"
f"Telefono: {payload.phone}\n" f"Telefono: {payload.phone}\n"
f"Nome animale: {payload.pet_name or '-'}\n" f"Nome animale: {payload.pet_name or '-'}\n"
f"Tipo animale: {payload.pet_type}\n" f"Tipo animale: {payload.pet_type}\n"
f"Medico richiesto: {payload.doctor}\n" f"Medico richiesto: {payload.doctor}\n"
f"Tipo di visita: {payload.service}\n" f"Tipo di visita: {payload.service}\n"
f"Data preferita: {payload.date}\n" f"Periodo preferito: {payload.date}\n"
f"Orario preferito: {payload.time or 'Qualsiasi orario'}\n" f"Fascia oraria preferita: {payload.time or 'Qualsiasi orario'}\n"
f"Note: {payload.notes or '-'}\n" f"Note: {payload.notes or '-'}\n"
f"Inviata il: {submitted_at}\n\n" f"Inviata il: {submitted_at}\n\n"
"La richiesta non costituisce conferma automatica dell'appuntamento." "La richiesta non costituisce conferma automatica dell'appuntamento."
@@ -70,6 +72,7 @@ def send_booking_request_email(payload: BookingRequestCreate) -> None:
message["Subject"] = _build_booking_subject(payload) message["Subject"] = _build_booking_subject(payload)
message["From"] = settings.booking_email_from message["From"] = settings.booking_email_from
message["To"] = settings.booking_email_to message["To"] = settings.booking_email_to
message["Reply-To"] = payload.email
message.set_content(_build_booking_text(payload)) message.set_content(_build_booking_text(payload))
message.add_alternative(_build_booking_html(payload), subtype="html") message.add_alternative(_build_booking_html(payload), subtype="html")

View File

@@ -2,12 +2,13 @@
* DESIGN: "Clinical Warmth" * DESIGN: "Clinical Warmth"
* Sezione prenotazione: form to mail con conferma non vincolante. * Sezione prenotazione: form to mail con conferma non vincolante.
*/ */
import { useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { motion, useInView } from "framer-motion"; import { motion, useInView } from "framer-motion";
import { import {
Calendar, Calendar,
CheckCircle2, CheckCircle2,
Clock, Clock,
Mail,
PawPrint, PawPrint,
Phone, Phone,
ShieldCheck, ShieldCheck,
@@ -17,6 +18,8 @@ import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
const bookingFormPaused = true;
const services = [ const services = [
"Visita clinica generale", "Visita clinica generale",
"Ecografia", "Ecografia",
@@ -45,29 +48,26 @@ const doctors = [
]; ];
const openingHours = [ const openingHours = [
{ days: "Visite: Lunedi - Venerdi", hours: "09:00 - 19:30" }, { days: "Visite: Lunedì - Venerdì", hours: "09:00 - 19:30" },
{ days: "Visite: Sabato", hours: "09:00 - 17:00" }, { days: "Visite: Sabato", hours: "09:00 - 17:00" },
{ days: "Urgenze: Lunedi - Venerdi", hours: "08:00 - 22:30" }, { days: "Urgenze: Lunedì - Venerdì", hours: "08:00 - 22:30" },
{ days: "Urgenze: Sabato e Festivi", hours: "09:00 - 20:00" }, { days: "Urgenze: Sabato e Festivi", hours: "09:00 - 20:00" },
]; ];
const timeSlots = [ const preferredPeriods = [
"09:00", "Prossima settimana",
"09:30", "Tra 2 settimane",
"10:00", "Il mese prossimo",
"10:30", ];
"11:00",
"11:30", const preferredTimeWindows = [
"14:30", "Mattino",
"15:00", "Pomeriggio",
"15:30",
"16:00",
"16:30",
"17:00",
]; ];
type BookingFormState = { type BookingFormState = {
name: string; name: string;
email: string;
phone: string; phone: string;
petName: string; petName: string;
petType: string; petType: string;
@@ -76,10 +76,13 @@ type BookingFormState = {
date: string; date: string;
time: string; time: string;
notes: string; notes: string;
website: string;
captchaAnswer: string;
}; };
const initialForm: BookingFormState = { const initialForm: BookingFormState = {
name: "", name: "",
email: "",
phone: "", phone: "",
petName: "", petName: "",
petType: "cane", petType: "cane",
@@ -88,6 +91,13 @@ const initialForm: BookingFormState = {
date: "", date: "",
time: "", time: "",
notes: "", notes: "",
website: "",
captchaAnswer: "",
};
type CaptchaChallenge = {
captcha_id: string;
question: string;
}; };
export default function BookingSection() { export default function BookingSection() {
@@ -97,15 +107,51 @@ export default function BookingSection() {
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [submittedName, setSubmittedName] = useState(""); const [submittedName, setSubmittedName] = useState("");
const [form, setForm] = useState<BookingFormState>(initialForm); const [form, setForm] = useState<BookingFormState>(initialForm);
const [captcha, setCaptcha] = useState<CaptchaChallenge | null>(null);
const [captchaLoading, setCaptchaLoading] = useState(false);
const loadCaptcha = async () => {
setCaptchaLoading(true);
try {
const response = await fetch("/api/booking-captcha");
if (!response.ok) {
throw new Error("Verifica anti-spam non disponibile");
}
const payload = await response.json();
setCaptcha(payload);
setForm((currentForm) => ({ ...currentForm, captchaAnswer: "" }));
} catch {
setCaptcha(null);
toast.error("Verifica anti-spam non disponibile", {
description: "Riprova tra qualche istante prima di inviare il modulo.",
});
} finally {
setCaptchaLoading(false);
}
};
useEffect(() => {
if (!bookingFormPaused) {
loadCaptcha();
}
}, []);
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!form.name || !form.phone || !form.doctor || !form.service || !form.date) { if (!form.name || !form.email || !form.phone || !form.doctor || !form.service || !form.date) {
toast.error("Compila tutti i campi obbligatori"); toast.error("Compila tutti i campi obbligatori");
return; return;
} }
if (!captcha || !form.captchaAnswer) {
toast.error("Completa la verifica anti-spam");
return;
}
try { try {
setSubmitting(true); setSubmitting(true);
@@ -116,6 +162,7 @@ export default function BookingSection() {
}, },
body: JSON.stringify({ body: JSON.stringify({
name: form.name, name: form.name,
email: form.email,
phone: form.phone, phone: form.phone,
pet_name: form.petName, pet_name: form.petName,
pet_type: form.petType, pet_type: form.petType,
@@ -124,6 +171,9 @@ export default function BookingSection() {
date: form.date, date: form.date,
time: form.time, time: form.time,
notes: form.notes, notes: form.notes,
website: form.website,
captcha_id: captcha.captcha_id,
captcha_answer: form.captchaAnswer,
}), }),
}); });
@@ -137,10 +187,13 @@ export default function BookingSection() {
setSubmittedName(form.name); setSubmittedName(form.name);
setSubmitted(true); setSubmitted(true);
setForm(initialForm); setForm(initialForm);
loadCaptcha();
toast.success("Richiesta inviata", { toast.success("Richiesta inviata", {
description: payload.message, description: payload.message,
}); });
} catch (error) { } catch (error) {
loadCaptcha();
const message = const message =
error instanceof Error error instanceof Error
? error.message ? error.message
@@ -189,13 +242,13 @@ export default function BookingSection() {
<p className="mb-8 text-base leading-relaxed text-white/80"> <p className="mb-8 text-base leading-relaxed text-white/80">
Compila il modulo per inviare una richiesta di appuntamento. La richiesta Compila il modulo per inviare una richiesta di appuntamento. La richiesta
non e vincolante e dovra essere confermata dallo staff della clinica, che ti non è vincolante e dovrà essere confermata dallo staff della clinica, che ti
ricontattera il prima possibile. ricontatterà il prima possibile.
</p> </p>
<div className="mb-8 rounded-xl border border-white/20 bg-white/10 p-5 backdrop-blur-sm"> <div className="mb-8 rounded-xl border border-white/20 bg-white/10 p-5 backdrop-blur-sm">
<p className="mb-2 text-sm font-semibold uppercase tracking-wide text-[#4ECDC4]"> <p className="mb-2 text-sm font-semibold uppercase tracking-wide text-[#4ECDC4]">
Urgenze 24h Urgenze
</p> </p>
<a <a
href="tel:3205322439" href="tel:3205322439"
@@ -204,7 +257,6 @@ export default function BookingSection() {
> >
320 532.24.39 320 532.24.39
</a> </a>
<p className="mt-1 text-xs text-white/60">Disponibile 7 giorni su 7</p>
</div> </div>
<div className="mb-8 rounded-2xl border border-[#4ECDC4]/30 bg-[#14384F] p-5"> <div className="mb-8 rounded-2xl border border-[#4ECDC4]/30 bg-[#14384F] p-5">
@@ -216,8 +268,8 @@ export default function BookingSection() {
<p className="text-sm font-semibold text-white">Richiesta non vincolante</p> <p className="text-sm font-semibold text-white">Richiesta non vincolante</p>
<p className="mt-1 text-sm leading-relaxed text-white/75"> <p className="mt-1 text-sm leading-relaxed text-white/75">
L&apos;invio del modulo non costituisce conferma automatica dell&apos;appuntamento. L&apos;invio del modulo non costituisce conferma automatica dell&apos;appuntamento.
Il team verifichera disponibilita, tipologia di visita e urgenza del caso prima Il team verificherà disponibilità, tipologia di visita e urgenza del caso prima
di confermare data e orario. di confermare periodo e fascia oraria.
</p> </p>
</div> </div>
</div> </div>
@@ -263,7 +315,29 @@ export default function BookingSection() {
animate={isInView ? { opacity: 1, x: 0 } : {}} animate={isInView ? { opacity: 1, x: 0 } : {}}
transition={{ duration: 0.8, delay: 0.2 }} transition={{ duration: 0.8, delay: 0.2 }}
> >
{submitted ? ( {bookingFormPaused ? (
<div className="rounded-2xl bg-white p-8 text-center shadow-2xl">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-[#4ECDC4]/15">
<Clock size={32} className="text-[#4ECDC4]" />
</div>
<h3
className="mb-3 text-[#1B4F72]"
style={{ fontFamily: "'Cormorant Garamond', serif", fontSize: "1.9rem" }}
>
Form in aggiornamento
</h3>
<p className="mb-6 text-sm leading-relaxed text-gray-600">
Il modulo di richiesta appuntamento è temporaneamente sospeso per manutenzione.
Per prenotazioni o informazioni contatta direttamente la clinica.
</p>
<a
href="tel:3205322439"
className="inline-flex items-center justify-center rounded-lg bg-[#1B4F72] px-5 py-3 text-sm font-bold text-white transition-colors hover:bg-[#163d5a]"
>
Chiama 320 532.24.39
</a>
</div>
) : submitted ? (
<div className="rounded-2xl bg-white p-8 text-center shadow-2xl"> <div className="rounded-2xl bg-white p-8 text-center shadow-2xl">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-[#4ECDC4]/15"> <div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-[#4ECDC4]/15">
<CheckCircle2 size={32} className="text-[#4ECDC4]" /> <CheckCircle2 size={32} className="text-[#4ECDC4]" />
@@ -275,11 +349,11 @@ export default function BookingSection() {
Grazie {submittedName || "per la tua richiesta"} Grazie {submittedName || "per la tua richiesta"}
</h3> </h3>
<p className="mb-3 text-sm leading-relaxed text-gray-600"> <p className="mb-3 text-sm leading-relaxed text-gray-600">
La tua richiesta di prenotazione e stata inviata correttamente. La tua richiesta di prenotazione è stata inviata correttamente.
</p> </p>
<p className="mb-6 text-sm leading-relaxed text-gray-600"> <p className="mb-6 text-sm leading-relaxed text-gray-600">
Il team della Clinica Veterinaria Formiginese la prendera in carico il prima Il team della Clinica Veterinaria Formiginese la prenderà in carico il prima
possibile e ti ricontattera per confermare disponibilita, data e orario. possibile e ti ricontatterà per confermare disponibilità, data e orario.
</p> </p>
<div className="mb-6 rounded-xl border border-[#E4D7C6] bg-[#FFF9F1] px-4 py-3 text-left text-sm text-gray-600"> <div className="mb-6 rounded-xl border border-[#E4D7C6] bg-[#FFF9F1] px-4 py-3 text-left text-sm text-gray-600">
<strong className="text-[#1B4F72]">Nota importante:</strong> la richiesta inviata <strong className="text-[#1B4F72]">Nota importante:</strong> la richiesta inviata
@@ -309,8 +383,8 @@ export default function BookingSection() {
Richiedi un appuntamento Richiedi un appuntamento
</h3> </h3>
<p className="text-sm leading-relaxed text-gray-500"> <p className="text-sm leading-relaxed text-gray-500">
Compila i campi richiesti e inviaci una proposta di data: sara lo staff a Compila i campi richiesti e inviaci una preferenza di periodo: sarà lo staff a
confermare la visita. ricontattarti per confermare la visita.
</p> </p>
</div> </div>
@@ -332,6 +406,23 @@ export default function BookingSection() {
</div> </div>
</div> </div>
<div>
<label className="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-gray-600">
Email *
</label>
<div className="relative">
<Mail size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<input
type="email"
required
placeholder="nome@email.it"
value={form.email}
onChange={(e) => setForm({ ...form, email: e.target.value })}
className="w-full rounded-lg border border-gray-200 py-2.5 pl-9 pr-3 text-sm transition-all focus:border-transparent focus:outline-none focus:ring-2 focus:ring-[#4ECDC4]"
/>
</div>
</div>
<div> <div>
<label className="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-gray-600"> <label className="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-gray-600">
Telefono * Telefono *
@@ -424,24 +515,29 @@ export default function BookingSection() {
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div> <div>
<label className="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-gray-600"> <label className="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-gray-600">
Data preferita * Periodo preferito *
</label> </label>
<div className="relative"> <div className="relative">
<Calendar size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" /> <Calendar size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<input <select
type="date"
required required
value={form.date} value={form.date}
min={new Date().toISOString().split("T")[0]}
onChange={(e) => setForm({ ...form, date: e.target.value })} onChange={(e) => setForm({ ...form, date: e.target.value })}
className="w-full rounded-lg border border-gray-200 py-2.5 pl-9 pr-3 text-sm transition-all focus:border-transparent focus:outline-none focus:ring-2 focus:ring-[#4ECDC4]" className="w-full rounded-lg border border-gray-200 bg-white py-2.5 pl-9 pr-3 text-sm transition-all focus:border-transparent focus:outline-none focus:ring-2 focus:ring-[#4ECDC4]"
/> >
<option value="">Seleziona un periodo</option>
{preferredPeriods.map((period) => (
<option key={period} value={period}>
{period}
</option>
))}
</select>
</div> </div>
</div> </div>
<div> <div>
<label className="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-gray-600"> <label className="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-gray-600">
Orario preferito Fascia oraria preferita
</label> </label>
<div className="relative"> <div className="relative">
<Clock size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" /> <Clock size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
@@ -450,8 +546,8 @@ export default function BookingSection() {
onChange={(e) => setForm({ ...form, time: e.target.value })} onChange={(e) => setForm({ ...form, time: e.target.value })}
className="w-full rounded-lg border border-gray-200 bg-white py-2.5 pl-9 pr-3 text-sm transition-all focus:border-transparent focus:outline-none focus:ring-2 focus:ring-[#4ECDC4]" className="w-full rounded-lg border border-gray-200 bg-white py-2.5 pl-9 pr-3 text-sm transition-all focus:border-transparent focus:outline-none focus:ring-2 focus:ring-[#4ECDC4]"
> >
<option value="">Qualsiasi orario</option> <option value="">Seleziona una fascia oraria</option>
{timeSlots.map((time) => ( {preferredTimeWindows.map((time) => (
<option key={time} value={time}> <option key={time} value={time}>
{time} {time}
</option> </option>
@@ -474,16 +570,58 @@ export default function BookingSection() {
/> />
</div> </div>
<div className="hidden" aria-hidden="true">
<label>
Sito web
<input
type="text"
tabIndex={-1}
autoComplete="off"
value={form.website}
onChange={(e) => setForm({ ...form, website: e.target.value })}
/>
</label>
</div>
<div className="rounded-xl border border-gray-200 bg-gray-50 px-4 py-3">
<label className="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-gray-600">
Verifica anti-spam *
</label>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<span className="text-sm font-semibold text-[#1B4F72]">
{captchaLoading ? "Caricamento..." : captcha?.question || "Verifica non disponibile"}
</span>
<input
type="text"
inputMode="numeric"
required
placeholder="Risposta"
value={form.captchaAnswer}
onChange={(e) => setForm({ ...form, captchaAnswer: e.target.value })}
disabled={!captcha || captchaLoading}
className="w-full rounded-lg border border-gray-200 px-3 py-2.5 text-sm transition-all focus:border-transparent focus:outline-none focus:ring-2 focus:ring-[#4ECDC4] disabled:cursor-not-allowed disabled:bg-gray-100 sm:max-w-32"
/>
<button
type="button"
onClick={loadCaptcha}
disabled={captchaLoading || submitting}
className="text-left text-xs font-semibold uppercase tracking-wide text-[#1B4F72] transition-colors hover:text-[#4ECDC4] disabled:cursor-wait disabled:opacity-60 sm:text-center"
>
Ricarica
</button>
</div>
</div>
<Button <Button
type="submit" type="submit"
disabled={submitting} disabled={submitting || captchaLoading || !captcha}
className="w-full bg-[#4ECDC4] py-3 text-base font-bold text-white shadow-lg shadow-[#4ECDC4]/30 transition-all duration-300 hover:bg-[#3ab5ad] hover:shadow-xl disabled:cursor-wait disabled:opacity-80" className="w-full bg-[#4ECDC4] py-3 text-base font-bold text-white shadow-lg shadow-[#4ECDC4]/30 transition-all duration-300 hover:bg-[#3ab5ad] hover:shadow-xl disabled:cursor-wait disabled:opacity-80"
> >
{submitting ? "Invio in corso..." : "Invia richiesta di prenotazione"} {submitting ? "Invio in corso..." : "Invia richiesta di prenotazione"}
</Button> </Button>
<p className="text-center text-xs text-gray-500"> <p className="text-center text-xs text-gray-500">
* Campi obbligatori. La richiesta sara valutata e confermata dallo staff. * Campi obbligatori. La richiesta sarà valutata e confermata dallo staff.
</p> </p>
</form> </form>
)} )}