Disattiva temporaneamente form prenotazioni
This commit is contained in:
@@ -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.",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
169
backend/app/services/booking_security.py
Normal file
169
backend/app/services/booking_security.py
Normal 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]
|
||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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'invio del modulo non costituisce conferma automatica dell'appuntamento.
|
L'invio del modulo non costituisce conferma automatica dell'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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user