Compare commits
12 Commits
modifiche0
...
backend_in
| Author | SHA1 | Date | |
|---|---|---|---|
| 753f0ec346 | |||
| ae31cbaaba | |||
| 017b6a905c | |||
| 16bfcb8b47 | |||
| cc70144183 | |||
| ba4a73d5ca | |||
| 8d83f0c619 | |||
| f8171bb3dd | |||
| 04fb49ad4b | |||
| d9b617e706 | |||
| 580a659b5a | |||
| bd5b845e43 |
250
README.md
250
README.md
@@ -1,66 +1,222 @@
|
||||
# 🏥 Clinica Veterinaria Formiginese - Deploy Package
|
||||
# Clinica Veterinaria Formiginese
|
||||
|
||||
Questo pacchetto contiene il codice sorgente completo, le immagini e le istruzioni per deployare il sito della Clinica Veterinaria Formiginese su un server privato con Node.js.
|
||||
Repository operativo del sito della Clinica Veterinaria Formiginese, con frontend React/Vite e backend FastAPI per la gestione delle richieste di prenotazione.
|
||||
|
||||
## 📋 Contenuti del Pacchetto
|
||||
## Struttura
|
||||
|
||||
- **clinica-app/** - Progetto React completo con sorgenti
|
||||
- **assets/** - Tutte le 7 immagini (animali + clinica)
|
||||
- **SETUP_ISTRUZIONI.md** - Guida dettagliata di installazione
|
||||
- **README.md** - Questo file
|
||||
- `clinica-app/` - frontend React + Vite e build pubblica del sito
|
||||
- `backend/` - backend FastAPI, schema prenotazione e servizio email
|
||||
- `assets/` - materiali grafici di supporto
|
||||
- `deploy_to_local_server.bat` - aggiorna la copia locale di deploy partendo da Gitea
|
||||
- `SETUP_ISTRUZIONI.md` - note di installazione storiche
|
||||
|
||||
## 🚀 Quick Start
|
||||
## Stack
|
||||
|
||||
```bash
|
||||
# 1. Estrai il pacchetto
|
||||
unzip clinica-veterinaria-deploy.zip
|
||||
cd clinica-deploy
|
||||
- Frontend: React 19, TypeScript, Tailwind CSS, Vite
|
||||
- Backend: FastAPI, Uvicorn
|
||||
- Web server online: Apache
|
||||
- Certificati: Let's Encrypt / Certbot
|
||||
|
||||
# 2. Copia le immagini
|
||||
cp -r assets/* clinica-app/client/public/images/
|
||||
mkdir -p clinica-app/client/public/images
|
||||
## Ambiente locale
|
||||
|
||||
# 3. Installa e avvia
|
||||
cd clinica-app
|
||||
npm install
|
||||
npm run build
|
||||
npm start
|
||||
### Repository di lavoro
|
||||
|
||||
Percorso:
|
||||
|
||||
```txt
|
||||
C:\devel\clinica_veterinaria_formiginese
|
||||
```
|
||||
|
||||
Il sito sarà disponibile a `http://localhost:3000`
|
||||
Per sviluppo rapido:
|
||||
|
||||
## 📖 Documentazione Completa
|
||||
```bash
|
||||
cd C:\devel\clinica_veterinaria_formiginese\clinica-app
|
||||
pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
Leggi il file **SETUP_ISTRUZIONI.md** per:
|
||||
- Requisiti di sistema
|
||||
- Installazione passo-passo
|
||||
- Configurazione avanzata (Nginx, PM2, ecc.)
|
||||
- Personalizzazione (colori, testi, immagini)
|
||||
- Troubleshooting
|
||||
Nota: la porta di sviluppo Vite e stata spostata a `3001`, per evitare conflitti con Gitea in locale.
|
||||
|
||||
## 🎯 Caratteristiche
|
||||
### Repository di deploy locale
|
||||
|
||||
✅ Homepage completa con carousel hero (7 immagini)
|
||||
✅ Sezioni: Chi Siamo, Servizi, Team, Blog, Prenotazioni, Registrazione
|
||||
✅ Design "Clinical Warmth" con blu petrolio e verde acqua
|
||||
✅ Responsive design (mobile, tablet, desktop)
|
||||
✅ Animazioni fluide e transizioni eleganti
|
||||
✅ Performance ottimizzate
|
||||
Percorso:
|
||||
|
||||
## 🛠️ Stack Tecnologico
|
||||
```txt
|
||||
C:\deploy\clinica_veterinaria_formiginese
|
||||
```
|
||||
|
||||
- **Frontend**: React 19 + TypeScript + Tailwind CSS 4
|
||||
- **Backend**: Node.js + Express
|
||||
- **Build Tool**: Vite
|
||||
- **UI Components**: shadcn/ui
|
||||
- **Animazioni**: Framer Motion
|
||||
Il sito locale servito da Caddy legge questa copia, non quella in `C:\devel\...`.
|
||||
|
||||
## 📞 Supporto
|
||||
Per aggiornare il deploy locale:
|
||||
|
||||
Per problemi durante l'installazione, consulta la sezione "Troubleshooting" in SETUP_ISTRUZIONI.md
|
||||
```bat
|
||||
deploy_to_local_server.bat
|
||||
```
|
||||
|
||||
---
|
||||
Attenzione: lo script riallinea la copia locale di deploy a cio che e stato pushato su Gitea.
|
||||
|
||||
**Versione**: 1.0.0
|
||||
**Data**: 31 Marzo 2026
|
||||
**Licenza**: Privato
|
||||
## Build frontend
|
||||
|
||||
Da eseguire dentro `clinica-app/`:
|
||||
|
||||
```bash
|
||||
pnpm build
|
||||
```
|
||||
|
||||
## Prenotazioni
|
||||
|
||||
La form di prenotazione usa anche il campo `doctor` (medico richiesto).
|
||||
L'invio email passa da Mailjet API, con `Reply-To` valorizzato con l'indirizzo inserito da chi compila il modulo.
|
||||
Il backend mantiene protezioni anti-abuso: flag di attivazione, CAPTCHA, honeypot, rate limit e audit log JSONL.
|
||||
|
||||
Quando si pubblicano modifiche che toccano la form, vanno aggiornati insieme:
|
||||
|
||||
- frontend React
|
||||
- backend FastAPI
|
||||
|
||||
In particolare:
|
||||
|
||||
- `backend/app/schemas/booking.py`
|
||||
- `backend/app/services/mailer.py`
|
||||
|
||||
## Deploy online
|
||||
|
||||
Server attuale:
|
||||
|
||||
```txt
|
||||
ubuntu@129.152.31.221
|
||||
```
|
||||
|
||||
Percorso progetto:
|
||||
|
||||
```txt
|
||||
/var/www/clinicaveterinariaformiginese.it
|
||||
```
|
||||
|
||||
### Aggiornamento codice
|
||||
|
||||
La cartella online non contiene una working copy Git. Le modifiche vanno quindi
|
||||
pubblicate copiando i file via WinSCP/SFTP dopo commit e push su Gitea.
|
||||
|
||||
### Build frontend
|
||||
|
||||
```bash
|
||||
cd /var/www/clinicaveterinariaformiginese.it/clinica-app
|
||||
pnpm build
|
||||
```
|
||||
|
||||
### Riavvio servizi
|
||||
|
||||
```bash
|
||||
sudo systemctl restart clinicaveterinaria-backend.service
|
||||
sudo systemctl reload apache2
|
||||
```
|
||||
|
||||
### Verifiche utili
|
||||
|
||||
```bash
|
||||
systemctl status clinicaveterinaria-backend.service --no-pager
|
||||
journalctl -u clinicaveterinaria-backend.service -n 50 --no-pager
|
||||
curl -I https://www.clinicaveterinariaformiginese.it/
|
||||
```
|
||||
|
||||
## Nota importante su Vite
|
||||
|
||||
Nel build di produzione il runtime Manus deve restare disattivato. La configurazione e gia stata corretta in:
|
||||
|
||||
```txt
|
||||
clinica-app/vite.config.ts
|
||||
```
|
||||
|
||||
I plugin Manus sono attivi solo in sviluppo, non nel build di produzione.
|
||||
|
||||
## Redirect domini veterinarioformigine
|
||||
|
||||
Sono stati configurati 5 domini accessori per reindirizzare sempre verso:
|
||||
|
||||
```txt
|
||||
https://www.centrofaunaselvatica.it/
|
||||
```
|
||||
|
||||
Domini gestiti:
|
||||
|
||||
- `veterinarioformigine.it`
|
||||
- `veterinarioformigine.com`
|
||||
- `veterinarioformigine.eu`
|
||||
- `veterinarioformigine.info`
|
||||
- `veterinarioformigine.cloud`
|
||||
|
||||
Per ciascuno sono attivi anche i rispettivi host `www`.
|
||||
|
||||
### Comportamento atteso
|
||||
|
||||
Tutti questi URL devono restituire:
|
||||
|
||||
```txt
|
||||
301 Moved Permanently
|
||||
Location: https://www.centrofaunaselvatica.it/
|
||||
```
|
||||
|
||||
### Configurazione Apache
|
||||
|
||||
I virtual host HTTP sono stati creati in:
|
||||
|
||||
```txt
|
||||
/etc/apache2/sites-available/veterinarioformigine.*.conf
|
||||
```
|
||||
|
||||
I virtual host HTTPS creati da Certbot sono in:
|
||||
|
||||
```txt
|
||||
/etc/apache2/sites-available/veterinarioformigine.*-le-ssl.conf
|
||||
```
|
||||
|
||||
### Certificato
|
||||
|
||||
Certificato emesso con:
|
||||
|
||||
```bash
|
||||
sudo certbot --apache \
|
||||
-d veterinarioformigine.it -d www.veterinarioformigine.it \
|
||||
-d veterinarioformigine.com -d www.veterinarioformigine.com \
|
||||
-d veterinarioformigine.eu -d www.veterinarioformigine.eu \
|
||||
-d veterinarioformigine.info -d www.veterinarioformigine.info \
|
||||
-d veterinarioformigine.cloud -d www.veterinarioformigine.cloud
|
||||
```
|
||||
|
||||
Scadenza attuale del certificato:
|
||||
|
||||
```txt
|
||||
2026-08-25
|
||||
```
|
||||
|
||||
### Verifica redirect domini accessori
|
||||
|
||||
```bash
|
||||
curl -I https://veterinarioformigine.it
|
||||
curl -I https://www.veterinarioformigine.it
|
||||
curl -I https://veterinarioformigine.com
|
||||
curl -I https://www.veterinarioformigine.com
|
||||
curl -I https://veterinarioformigine.eu
|
||||
curl -I https://www.veterinarioformigine.eu
|
||||
curl -I https://veterinarioformigine.info
|
||||
curl -I https://www.veterinarioformigine.info
|
||||
curl -I https://veterinarioformigine.cloud
|
||||
curl -I https://www.veterinarioformigine.cloud
|
||||
```
|
||||
|
||||
## Git
|
||||
|
||||
Branch operativo corrente:
|
||||
|
||||
```txt
|
||||
backend_init
|
||||
```
|
||||
|
||||
Flusso consigliato:
|
||||
|
||||
1. Modifiche in `C:\devel\clinica_veterinaria_formiginese`
|
||||
2. Test con `pnpm dev`
|
||||
3. Commit e push su Gitea
|
||||
4. Deploy locale con `deploy_to_local_server.bat`
|
||||
5. Deploy online su Linux quando approvato
|
||||
|
||||
BIN
assets/services_cardiologia.jpg
Normal file
BIN
assets/services_cardiologia.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
@@ -3,11 +3,25 @@ APP_ENV=development
|
||||
APP_HOST=127.0.0.1
|
||||
APP_PORT=8000
|
||||
DATABASE_URL=sqlite:///C:/devel/clinica_veterinaria_formiginese/data/clinica.db
|
||||
|
||||
MAIL_PROVIDER=mailjet
|
||||
MAILJET_API_KEY=
|
||||
MAILJET_API_SECRET=
|
||||
MAILJET_API_URL=https://api.mailjet.com/v3.1/send
|
||||
MAILJET_SANDBOX_MODE=true
|
||||
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USERNAME=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_USE_TLS=true
|
||||
BOOKING_EMAIL_FROM=
|
||||
BOOKING_EMAIL_FROM_NAME=Clinica Veterinaria Formiginese
|
||||
BOOKING_EMAIL_TO=
|
||||
BOOKING_EMAIL_SUBJECT_PREFIX=[Clinica Veterinaria Formiginese]
|
||||
BOOKING_FORM_ENABLED=false
|
||||
BOOKING_AUDIT_LOG_PATH=
|
||||
BOOKING_RATE_LIMIT_WINDOW_SECONDS=900
|
||||
BOOKING_RATE_LIMIT_MAX_REQUESTS=5
|
||||
BOOKING_CAPTCHA_TTL_SECONDS=600
|
||||
BOOKING_CAPTCHA_SECRET=
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
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)
|
||||
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:
|
||||
send_booking_request_email(payload)
|
||||
except MailConfigurationError as exc:
|
||||
write_booking_audit_event(
|
||||
"failed_configuration",
|
||||
request_id=request_id,
|
||||
request=request,
|
||||
payload=payload,
|
||||
error=exc,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Il servizio email non è ancora configurato.",
|
||||
) from exc
|
||||
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(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Si è verificato un problema durante l'invio della richiesta.",
|
||||
) from exc
|
||||
|
||||
write_booking_audit_event("sent", 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.",
|
||||
)
|
||||
|
||||
@@ -27,11 +27,39 @@ class Settings:
|
||||
self.smtp_password = os.getenv("SMTP_PASSWORD", "")
|
||||
self.smtp_use_tls = os.getenv("SMTP_USE_TLS", "true").lower() in {"1", "true", "yes", "on"}
|
||||
self.booking_email_from = os.getenv("BOOKING_EMAIL_FROM", self.smtp_username)
|
||||
self.booking_email_from_name = os.getenv("BOOKING_EMAIL_FROM_NAME", "Clinica Veterinaria Formiginese")
|
||||
self.booking_email_to = os.getenv("BOOKING_EMAIL_TO", "")
|
||||
self.booking_email_subject_prefix = os.getenv(
|
||||
"BOOKING_EMAIL_SUBJECT_PREFIX",
|
||||
"[Clinica Veterinaria Formiginese]",
|
||||
)
|
||||
self.mail_provider = os.getenv("MAIL_PROVIDER", "mailjet").lower()
|
||||
self.mailjet_api_key = os.getenv("MAILJET_API_KEY", "")
|
||||
self.mailjet_api_secret = os.getenv("MAILJET_API_SECRET", "")
|
||||
self.mailjet_api_url = os.getenv("MAILJET_API_URL", "https://api.mailjet.com/v3.1/send")
|
||||
self.mailjet_sandbox_mode = os.getenv("MAILJET_SANDBOX_MODE", "false").lower() in {
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
"on",
|
||||
}
|
||||
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
|
||||
def sqlite_file_path(self) -> str | None:
|
||||
@@ -42,16 +70,29 @@ class Settings:
|
||||
|
||||
@property
|
||||
def booking_mail_enabled(self) -> bool:
|
||||
return all(
|
||||
[
|
||||
self.smtp_host,
|
||||
self.smtp_port,
|
||||
self.smtp_username,
|
||||
self.smtp_password,
|
||||
self.booking_email_from,
|
||||
self.booking_email_to,
|
||||
]
|
||||
)
|
||||
if self.mail_provider == "mailjet":
|
||||
return all(
|
||||
[
|
||||
self.mailjet_api_key,
|
||||
self.mailjet_api_secret,
|
||||
self.booking_email_from,
|
||||
self.booking_email_to,
|
||||
]
|
||||
)
|
||||
|
||||
if self.mail_provider == "smtp":
|
||||
return all(
|
||||
[
|
||||
self.smtp_host,
|
||||
self.smtp_port,
|
||||
self.smtp_username,
|
||||
self.smtp_password,
|
||||
self.booking_email_from,
|
||||
self.booking_email_to,
|
||||
]
|
||||
)
|
||||
|
||||
return False
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
@@ -3,15 +3,25 @@ from pydantic import BaseModel, Field
|
||||
|
||||
class BookingRequestCreate(BaseModel):
|
||||
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)
|
||||
pet_name: str = Field(default="", max_length=120)
|
||||
pet_type: str = Field(default="cane", max_length=40)
|
||||
doctor: str = Field(min_length=2, max_length=120)
|
||||
service: str = Field(min_length=2, max_length=120)
|
||||
date: str = Field(min_length=8, max_length=20)
|
||||
time: str = Field(default="", max_length=20)
|
||||
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):
|
||||
success: bool = True
|
||||
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]
|
||||
@@ -1,8 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from datetime import datetime
|
||||
from email.message import EmailMessage
|
||||
from html import escape
|
||||
import json
|
||||
import smtplib
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
from app.config import settings
|
||||
from app.schemas.booking import BookingRequestCreate
|
||||
@@ -12,26 +17,42 @@ class MailConfigurationError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class MailDeliveryError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _build_booking_subject(payload: BookingRequestCreate) -> str:
|
||||
return f"{settings.booking_email_subject_prefix} Nuova richiesta visita - {payload.name}"
|
||||
|
||||
|
||||
def _build_booking_html(payload: BookingRequestCreate) -> str:
|
||||
submitted_at = datetime.now().strftime("%d/%m/%Y %H:%M")
|
||||
name = escape(payload.name)
|
||||
email = escape(payload.email)
|
||||
phone = escape(payload.phone)
|
||||
pet_name = escape(payload.pet_name or "-")
|
||||
pet_type = escape(payload.pet_type)
|
||||
doctor = escape(payload.doctor)
|
||||
service = escape(payload.service)
|
||||
date = escape(payload.date)
|
||||
time = escape(payload.time or "Qualsiasi orario")
|
||||
notes = escape(payload.notes or "-")
|
||||
return f"""
|
||||
<html>
|
||||
<body style="font-family: Arial, sans-serif; color: #1f2937; line-height: 1.6;">
|
||||
<h2 style="color: #1B4F72;">Nuova richiesta di prenotazione visita</h2>
|
||||
<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;">
|
||||
<tr><td><strong>Nome e cognome</strong></td><td>{payload.name}</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>Tipo animale</strong></td><td>{payload.pet_type}</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>Orario preferito</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>Nome e cognome</strong></td><td>{name}</td></tr>
|
||||
<tr><td><strong>Email</strong></td><td>{email}</td></tr>
|
||||
<tr><td><strong>Telefono</strong></td><td>{phone}</td></tr>
|
||||
<tr><td><strong>Nome animale</strong></td><td>{pet_name}</td></tr>
|
||||
<tr><td><strong>Tipo animale</strong></td><td>{pet_type}</td></tr>
|
||||
<tr><td><strong>Medico richiesto</strong></td><td>{doctor}</td></tr>
|
||||
<tr><td><strong>Tipo di visita</strong></td><td>{service}</td></tr>
|
||||
<tr><td><strong>Periodo preferito</strong></td><td>{date}</td></tr>
|
||||
<tr><td><strong>Fascia oraria preferita</strong></td><td>{time}</td></tr>
|
||||
<tr><td><strong>Note</strong></td><td>{notes}</td></tr>
|
||||
<tr><td><strong>Inviata il</strong></td><td>{submitted_at}</td></tr>
|
||||
</table>
|
||||
<p style="margin-top: 20px;">
|
||||
@@ -48,12 +69,14 @@ def _build_booking_text(payload: BookingRequestCreate) -> str:
|
||||
return (
|
||||
"Nuova richiesta di prenotazione visita\n\n"
|
||||
f"Nome e cognome: {payload.name}\n"
|
||||
f"Email: {payload.email}\n"
|
||||
f"Telefono: {payload.phone}\n"
|
||||
f"Nome animale: {payload.pet_name or '-'}\n"
|
||||
f"Tipo animale: {payload.pet_type}\n"
|
||||
f"Medico richiesto: {payload.doctor}\n"
|
||||
f"Tipo di visita: {payload.service}\n"
|
||||
f"Data preferita: {payload.date}\n"
|
||||
f"Orario preferito: {payload.time or 'Qualsiasi orario'}\n"
|
||||
f"Periodo preferito: {payload.date}\n"
|
||||
f"Fascia oraria preferita: {payload.time or 'Qualsiasi orario'}\n"
|
||||
f"Note: {payload.notes or '-'}\n"
|
||||
f"Inviata il: {submitted_at}\n\n"
|
||||
"La richiesta non costituisce conferma automatica dell'appuntamento."
|
||||
@@ -61,6 +84,21 @@ def _build_booking_text(payload: BookingRequestCreate) -> str:
|
||||
|
||||
|
||||
def send_booking_request_email(payload: BookingRequestCreate) -> None:
|
||||
if not settings.booking_mail_enabled:
|
||||
raise MailConfigurationError(f"Configurazione email incompleta per provider {settings.mail_provider}.")
|
||||
|
||||
if settings.mail_provider == "mailjet":
|
||||
_send_booking_request_email_mailjet(payload)
|
||||
return
|
||||
|
||||
if settings.mail_provider == "smtp":
|
||||
_send_booking_request_email_smtp(payload)
|
||||
return
|
||||
|
||||
raise MailConfigurationError(f"Provider email non supportato: {settings.mail_provider}")
|
||||
|
||||
|
||||
def _send_booking_request_email_smtp(payload: BookingRequestCreate) -> None:
|
||||
if not settings.booking_mail_enabled:
|
||||
raise MailConfigurationError("Configurazione SMTP incompleta.")
|
||||
|
||||
@@ -68,6 +106,7 @@ def send_booking_request_email(payload: BookingRequestCreate) -> None:
|
||||
message["Subject"] = _build_booking_subject(payload)
|
||||
message["From"] = settings.booking_email_from
|
||||
message["To"] = settings.booking_email_to
|
||||
message["Reply-To"] = payload.email
|
||||
message.set_content(_build_booking_text(payload))
|
||||
message.add_alternative(_build_booking_html(payload), subtype="html")
|
||||
|
||||
@@ -76,3 +115,69 @@ def send_booking_request_email(payload: BookingRequestCreate) -> None:
|
||||
server.starttls()
|
||||
server.login(settings.smtp_username, settings.smtp_password)
|
||||
server.send_message(message)
|
||||
|
||||
|
||||
def _send_booking_request_email_mailjet(payload: BookingRequestCreate) -> None:
|
||||
mailjet_payload = {
|
||||
"Messages": [
|
||||
{
|
||||
"From": {
|
||||
"Email": settings.booking_email_from,
|
||||
"Name": settings.booking_email_from_name,
|
||||
},
|
||||
"To": [
|
||||
{
|
||||
"Email": settings.booking_email_to,
|
||||
"Name": "Clinica Veterinaria Formiginese",
|
||||
}
|
||||
],
|
||||
"ReplyTo": {
|
||||
"Email": payload.email,
|
||||
"Name": payload.name,
|
||||
},
|
||||
"Subject": _build_booking_subject(payload),
|
||||
"TextPart": _build_booking_text(payload),
|
||||
"HTMLPart": _build_booking_html(payload),
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
if settings.mailjet_sandbox_mode:
|
||||
mailjet_payload["SandboxMode"] = True
|
||||
|
||||
auth_token = base64.b64encode(
|
||||
f"{settings.mailjet_api_key}:{settings.mailjet_api_secret}".encode("utf-8")
|
||||
).decode("ascii")
|
||||
request = Request(
|
||||
settings.mailjet_api_url,
|
||||
data=json.dumps(mailjet_payload).encode("utf-8"),
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
"Authorization": f"Basic {auth_token}",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
|
||||
try:
|
||||
with urlopen(request, timeout=30) as response:
|
||||
response_body = response.read().decode("utf-8")
|
||||
response_status = response.status
|
||||
except HTTPError as exc:
|
||||
error_body = exc.read().decode("utf-8", errors="replace")
|
||||
raise MailDeliveryError(f"Mailjet API HTTP {exc.code}: {error_body[:500]}") from exc
|
||||
except URLError as exc:
|
||||
raise MailDeliveryError(f"Mailjet API connection error: {exc.reason}") from exc
|
||||
|
||||
if response_status < 200 or response_status >= 300:
|
||||
raise MailDeliveryError(f"Mailjet API HTTP {response_status}: {response_body[:500]}")
|
||||
|
||||
try:
|
||||
response_payload = json.loads(response_body)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise MailDeliveryError(f"Mailjet API response non JSON: {response_body[:500]}") from exc
|
||||
|
||||
messages = response_payload.get("Messages", [])
|
||||
message_status = messages[0].get("Status") if messages else None
|
||||
if message_status != "success":
|
||||
raise MailDeliveryError(f"Mailjet API delivery status {message_status}: {response_body[:500]}")
|
||||
|
||||
BIN
clinica-app/client/public/images/services_cardiologia.jpg
Normal file
BIN
clinica-app/client/public/images/services_cardiologia.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
@@ -10,7 +10,7 @@ import { CheckCircle2, Award, Heart } from "lucide-react";
|
||||
|
||||
const values = [
|
||||
{ icon: Heart, text: "Cura e attenzione per ogni paziente" },
|
||||
{ icon: Award, text: "Dir. San. Dott. Paolo Parmeggiani — Master in Oncologia Veterinaria" },
|
||||
{ icon: Award, text: "Dir. San. Dott. Paolo Parmeggiani - Master in Oncologia Veterinaria" },
|
||||
{ icon: CheckCircle2, text: "Autorizzazione sanitaria n. 43560 del 30/12/2024" },
|
||||
];
|
||||
|
||||
@@ -22,7 +22,6 @@ export default function AboutSection() {
|
||||
<section id="chi-siamo" className="py-20 md:py-28 bg-white overflow-hidden">
|
||||
<div className="container">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-16 items-center">
|
||||
{/* Colonna testo */}
|
||||
<motion.div
|
||||
ref={ref}
|
||||
initial={{ opacity: 0, x: -40 }}
|
||||
@@ -46,22 +45,21 @@ export default function AboutSection() {
|
||||
}}
|
||||
>
|
||||
Una clinica veterinaria{" "}
|
||||
<span className="italic">al servizio della vita</span>
|
||||
<span className="italic">per la salute e il benessere animale</span>
|
||||
</h2>
|
||||
|
||||
<p className="text-gray-600 leading-relaxed mb-4 text-base">
|
||||
La Clinica Veterinaria Formiginese è un punto di riferimento per la cura degli animali
|
||||
domestici nel territorio di Formigine e della provincia di Modena. Fondata con la
|
||||
missione di offrire cure veterinarie di eccellenza, la nostra struttura riunisce
|
||||
sei professionisti specializzati, ciascuno con competenze specifiche.
|
||||
La Clinica Veterinaria Formiginese è un punto di riferimento per la cura degli animali
|
||||
domestici nel territorio di Formigine e della provincia di Modena. Fondata con la
|
||||
missione di offrire cure veterinarie di eccellenza, la nostra struttura riunisce
|
||||
professionisti specializzati, ciascuno con competenze specifiche.
|
||||
</p>
|
||||
<p className="text-gray-600 leading-relaxed mb-8 text-base">
|
||||
Crediamo che ogni animale meriti la migliore assistenza medica possibile. Per questo
|
||||
investiamo continuamente in formazione, tecnologia e in un ambiente clinico che metta
|
||||
Crediamo che ogni animale meriti la migliore assistenza medica possibile. Per questo
|
||||
investiamo continuamente in formazione, tecnologia e in un ambiente clinico che metta
|
||||
a proprio agio sia i pazienti che i loro proprietari.
|
||||
</p>
|
||||
|
||||
{/* Valori */}
|
||||
<div className="space-y-4 mb-8">
|
||||
{values.map((item) => (
|
||||
<div key={item.text} className="flex items-start gap-3">
|
||||
@@ -73,52 +71,40 @@ export default function AboutSection() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Indirizzo */}
|
||||
<div className="bg-[#F5F0E8] rounded-xl p-5 border-l-4 border-[#1B4F72]">
|
||||
<p className="text-[#1B4F72] font-semibold text-sm mb-1">Dove siamo</p>
|
||||
<p className="text-gray-700 text-sm">Via Quattro Passi, 16 — Formigine (MO)</p>
|
||||
<p className="text-gray-700 text-sm">Via Quattro Passi, 16 - Formigine (MO)</p>
|
||||
<p className="text-gray-600 text-sm mt-1">
|
||||
Tel: <a href="tel:0598396263" className="text-[#4ECDC4] font-semibold hover:underline">059 839.62.63</a>
|
||||
Tel:{" "}
|
||||
<a href="tel:0598396263" className="text-[#4ECDC4] font-semibold hover:underline">
|
||||
059 839.62.63
|
||||
</a>
|
||||
{" "}·{" "}
|
||||
<a href="mailto:clinicaveterinariaformiginese@gmail.com" className="text-[#4ECDC4] font-semibold hover:underline text-xs">
|
||||
<a
|
||||
href="mailto:clinicaveterinariaformiginese@gmail.com"
|
||||
className="text-[#4ECDC4] font-semibold hover:underline text-xs"
|
||||
>
|
||||
clinicaveterinariaformiginese@gmail.com
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Colonna immagine */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 40 }}
|
||||
animate={isInView ? { opacity: 1, x: 0 } : {}}
|
||||
transition={{ duration: 0.8, delay: 0.2 }}
|
||||
className="relative"
|
||||
>
|
||||
{/* Immagine principale */}
|
||||
<div className="relative rounded-2xl overflow-hidden shadow-2xl">
|
||||
<img
|
||||
src="/images/hero_dog_cat.jpg"
|
||||
alt="Cane e gatto insieme — la nostra missione"
|
||||
alt="Cane e gatto insieme - la nostra missione"
|
||||
className="w-full h-[480px] object-cover"
|
||||
/>
|
||||
{/* Overlay leggero */}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-[#1B4F72]/30 to-transparent" />
|
||||
</div>
|
||||
|
||||
{/* Badge flottante */}
|
||||
<div className="absolute -bottom-6 -left-6 bg-white rounded-2xl shadow-xl p-5 max-w-[200px]">
|
||||
<div
|
||||
className="text-[#1B4F72] font-bold mb-1"
|
||||
style={{ fontFamily: "'Cormorant Garamond', serif", fontSize: "2.5rem" }}
|
||||
>
|
||||
15+
|
||||
</div>
|
||||
<div className="text-gray-600 text-xs leading-tight">
|
||||
Anni di esperienza nella cura degli animali
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Decorazione */}
|
||||
<div className="absolute -top-4 -right-4 w-24 h-24 rounded-full bg-[#4ECDC4]/10 -z-10" />
|
||||
<div className="absolute -bottom-8 right-8 w-16 h-16 rounded-full bg-[#1B4F72]/10 -z-10" />
|
||||
</motion.div>
|
||||
|
||||
@@ -15,12 +15,12 @@ const benefits = [
|
||||
"Promemoria vaccinazioni automatici",
|
||||
"Prenotazioni online prioritarie",
|
||||
"Comunicazioni dirette con il veterinario",
|
||||
"Gestione di piu animali domestici",
|
||||
"Gestione di più animali domestici",
|
||||
];
|
||||
|
||||
function showActivationToast() {
|
||||
toast.info("Servizio in corso di attivazione", {
|
||||
description: "L'area riservata sara disponibile a breve.",
|
||||
description: "L'area riservata sarà disponibile a breve.",
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -2,13 +2,24 @@
|
||||
* DESIGN: "Clinical Warmth"
|
||||
* Sezione prenotazione: form to mail con conferma non vincolante.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { motion, useInView } from "framer-motion";
|
||||
import { useRef } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Calendar, Clock, User, Phone, PawPrint, CheckCircle2, ShieldCheck } from "lucide-react";
|
||||
import {
|
||||
Calendar,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Mail,
|
||||
PawPrint,
|
||||
Phone,
|
||||
ShieldCheck,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const bookingFormPaused = false;
|
||||
|
||||
const services = [
|
||||
"Visita clinica generale",
|
||||
"Ecografia",
|
||||
@@ -24,41 +35,68 @@ const services = [
|
||||
"Laparoscopia",
|
||||
];
|
||||
|
||||
const timeSlots = [
|
||||
"09:00",
|
||||
"09:30",
|
||||
"10:00",
|
||||
"10:30",
|
||||
"11:00",
|
||||
"11:30",
|
||||
"14:30",
|
||||
"15:00",
|
||||
"15:30",
|
||||
"16:00",
|
||||
"16:30",
|
||||
"17:00",
|
||||
const doctors = [
|
||||
"Dott. Paolo Parmeggiani",
|
||||
"Dott.ssa Irene Paganelli",
|
||||
"Dott. Simone Tinti",
|
||||
"Dott.ssa Michela Sghedoni",
|
||||
"Dott.ssa Sara Casali",
|
||||
"Dott. Riccardo Suffritti",
|
||||
];
|
||||
|
||||
const openingHours = [
|
||||
{ days: "Visite: Lunedì - Venerdì", hours: "09:00 - 19:30" },
|
||||
{ days: "Visite: Sabato", hours: "09:00 - 17:00" },
|
||||
{ days: "Urgenze: Lunedì - Venerdì", hours: "08:00 - 22:30" },
|
||||
{ days: "Urgenze: Sabato e Festivi", hours: "09:00 - 20:00" },
|
||||
];
|
||||
|
||||
const preferredPeriods = [
|
||||
"Questa settimana",
|
||||
"Prossima settimana",
|
||||
"Tra 2 settimane",
|
||||
"Il mese prossimo",
|
||||
];
|
||||
|
||||
const preferredTimeWindows = [
|
||||
"Mattino",
|
||||
"Pausa pranzo",
|
||||
"Pomeriggio",
|
||||
];
|
||||
|
||||
type BookingFormState = {
|
||||
name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
petName: string;
|
||||
petType: string;
|
||||
doctor: string;
|
||||
service: string;
|
||||
date: string;
|
||||
time: string;
|
||||
notes: string;
|
||||
website: string;
|
||||
captchaAnswer: string;
|
||||
};
|
||||
|
||||
const initialForm: BookingFormState = {
|
||||
name: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
petName: "",
|
||||
petType: "cane",
|
||||
doctor: "",
|
||||
service: "",
|
||||
date: "",
|
||||
time: "",
|
||||
notes: "",
|
||||
website: "",
|
||||
captchaAnswer: "",
|
||||
};
|
||||
|
||||
type CaptchaChallenge = {
|
||||
captcha_id: string;
|
||||
question: string;
|
||||
};
|
||||
|
||||
export default function BookingSection() {
|
||||
@@ -68,15 +106,51 @@ export default function BookingSection() {
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [submittedName, setSubmittedName] = useState("");
|
||||
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) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!form.name || !form.phone || !form.service || !form.date) {
|
||||
if (!form.name || !form.email || !form.phone || !form.doctor || !form.service || !form.date) {
|
||||
toast.error("Compila tutti i campi obbligatori");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!captcha || !form.captchaAnswer) {
|
||||
toast.error("Completa la verifica anti-spam");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setSubmitting(true);
|
||||
|
||||
@@ -87,13 +161,18 @@ export default function BookingSection() {
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: form.name,
|
||||
email: form.email,
|
||||
phone: form.phone,
|
||||
pet_name: form.petName,
|
||||
pet_type: form.petType,
|
||||
doctor: form.doctor,
|
||||
service: form.service,
|
||||
date: form.date,
|
||||
time: form.time,
|
||||
notes: form.notes,
|
||||
website: form.website,
|
||||
captcha_id: captcha.captcha_id,
|
||||
captcha_answer: form.captchaAnswer,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -107,11 +186,18 @@ export default function BookingSection() {
|
||||
setSubmittedName(form.name);
|
||||
setSubmitted(true);
|
||||
setForm(initialForm);
|
||||
loadCaptcha();
|
||||
toast.success("Richiesta inviata", {
|
||||
description: payload.message,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Si è verificato un problema durante l'invio.";
|
||||
loadCaptcha();
|
||||
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Si e verificato un problema durante l'invio.";
|
||||
|
||||
toast.error("Invio non riuscito", {
|
||||
description: message,
|
||||
});
|
||||
@@ -150,8 +236,7 @@ export default function BookingSection() {
|
||||
lineHeight: 1.2,
|
||||
}}
|
||||
>
|
||||
Richiedi una visita{" "}
|
||||
<span className="italic text-[#4ECDC4]">online</span>
|
||||
Richiedi una visita <span className="italic text-[#4ECDC4]">online</span>
|
||||
</h2>
|
||||
|
||||
<p className="mb-8 text-base leading-relaxed text-white/80">
|
||||
@@ -162,7 +247,7 @@ export default function BookingSection() {
|
||||
|
||||
<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]">
|
||||
Urgenze 24h
|
||||
Urgenze
|
||||
</p>
|
||||
<a
|
||||
href="tel:3205322439"
|
||||
@@ -171,7 +256,6 @@ export default function BookingSection() {
|
||||
>
|
||||
320 532.24.39
|
||||
</a>
|
||||
<p className="mt-1 text-xs text-white/60">Disponibile 7 giorni su 7</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-8 rounded-2xl border border-[#4ECDC4]/30 bg-[#14384F] p-5">
|
||||
@@ -184,26 +268,44 @@ export default function BookingSection() {
|
||||
<p className="mt-1 text-sm leading-relaxed text-white/75">
|
||||
L'invio del modulo non costituisce conferma automatica dell'appuntamento.
|
||||
Il team verificherà disponibilità, tipologia di visita e urgenza del caso prima
|
||||
di confermare data e orario.
|
||||
di confermare periodo e fascia oraria.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<p className="mb-3 text-sm font-semibold uppercase tracking-wide text-white/70">
|
||||
Orari di apertura
|
||||
</p>
|
||||
{[
|
||||
{ days: "Lunedi - Venerdi", hours: "09:00 - 12:30 · 14:30 - 19:00" },
|
||||
{ days: "Sabato", hours: "09:00 - 12:30" },
|
||||
{ days: "Domenica", hours: "Solo urgenze" },
|
||||
].map((slot) => (
|
||||
<div key={slot.days} className="flex items-center justify-between text-sm">
|
||||
<span className="text-white/70">{slot.days}</span>
|
||||
<span className="font-medium text-white">{slot.hours}</span>
|
||||
<div className="max-w-[39rem]">
|
||||
<div className="rounded-[26px] border border-white/20 bg-white/10 px-6 py-6 shadow-[0_18px_45px_rgba(0,0,0,0.18)] backdrop-blur-md sm:px-7 sm:py-7">
|
||||
<h4
|
||||
className="mb-5 text-white/95 uppercase tracking-[0.28em]"
|
||||
style={{
|
||||
fontFamily: "'Nunito Sans', sans-serif",
|
||||
fontSize: "0.8rem",
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
Orari di apertura
|
||||
</h4>
|
||||
|
||||
<div className="space-y-4">
|
||||
{openingHours.map((slot) => (
|
||||
<div key={slot.days} className="flex items-start gap-3.5">
|
||||
<div className="mt-0.5 rounded-full bg-[#4ECDC4]/20 p-2">
|
||||
<Clock size={14} className="text-[#7ce3dc]" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-white/92 sm:text-[0.98rem]">
|
||||
{slot.days}
|
||||
</p>
|
||||
<p className="mt-0.5 text-sm text-white/72 sm:text-[0.96rem]">
|
||||
{slot.hours}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
@@ -212,7 +314,29 @@ export default function BookingSection() {
|
||||
animate={isInView ? { opacity: 1, x: 0 } : {}}
|
||||
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="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-[#4ECDC4]/15">
|
||||
<CheckCircle2 size={32} className="text-[#4ECDC4]" />
|
||||
@@ -232,7 +356,7 @@ export default function BookingSection() {
|
||||
</p>
|
||||
<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
|
||||
non equivale a una prenotazione già confermata.
|
||||
non equivale a una prenotazione gia confermata.
|
||||
</div>
|
||||
<Button
|
||||
className="bg-[#1B4F72] text-white hover:bg-[#163d5a]"
|
||||
@@ -249,13 +373,17 @@ export default function BookingSection() {
|
||||
<div>
|
||||
<h3
|
||||
className="mb-2 text-[#1B4F72]"
|
||||
style={{ fontFamily: "'Cormorant Garamond', serif", fontSize: "1.5rem", fontWeight: 600 }}
|
||||
style={{
|
||||
fontFamily: "'Cormorant Garamond', serif",
|
||||
fontSize: "1.5rem",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Richiedi un appuntamento
|
||||
</h3>
|
||||
<p className="text-sm leading-relaxed text-gray-500">
|
||||
Compila i campi richiesti e inviaci una proposta di data: sarà lo staff a
|
||||
confermare la visita.
|
||||
Compila i campi richiesti e inviaci una preferenza di periodo: sarà lo staff a
|
||||
ricontattarti per confermare la visita.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -276,6 +404,24 @@ export default function BookingSection() {
|
||||
/>
|
||||
</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>
|
||||
<label className="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-gray-600">
|
||||
Telefono *
|
||||
@@ -310,6 +456,7 @@ export default function BookingSection() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-gray-600">
|
||||
Tipo di animale
|
||||
@@ -326,6 +473,25 @@ export default function BookingSection() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-gray-600">
|
||||
Medico richiesto *
|
||||
</label>
|
||||
<select
|
||||
required
|
||||
value={form.doctor}
|
||||
onChange={(e) => setForm({ ...form, doctor: e.target.value })}
|
||||
className="w-full rounded-lg border border-gray-200 bg-white px-3 py-2.5 text-sm transition-all focus:border-transparent focus:outline-none focus:ring-2 focus:ring-[#4ECDC4]"
|
||||
>
|
||||
<option value="">Seleziona un medico</option>
|
||||
{doctors.map((doctor) => (
|
||||
<option key={doctor} value={doctor}>
|
||||
{doctor}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-gray-600">
|
||||
Tipo di visita *
|
||||
@@ -337,9 +503,9 @@ export default function BookingSection() {
|
||||
className="w-full rounded-lg border border-gray-200 bg-white px-3 py-2.5 text-sm transition-all focus:border-transparent focus:outline-none focus:ring-2 focus:ring-[#4ECDC4]"
|
||||
>
|
||||
<option value="">Seleziona un servizio</option>
|
||||
{services.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
{services.map((service) => (
|
||||
<option key={service} value={service}>
|
||||
{service}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -348,23 +514,29 @@ export default function BookingSection() {
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-gray-600">
|
||||
Data preferita *
|
||||
Periodo preferito *
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Calendar size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="date"
|
||||
<select
|
||||
required
|
||||
value={form.date}
|
||||
min={new Date().toISOString().split("T")[0]}
|
||||
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>
|
||||
<label className="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-gray-600">
|
||||
Orario preferito
|
||||
Fascia oraria preferita
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Clock size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||
@@ -373,10 +545,10 @@ export default function BookingSection() {
|
||||
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]"
|
||||
>
|
||||
<option value="">Qualsiasi orario</option>
|
||||
{timeSlots.map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{t}
|
||||
<option value="">Seleziona una fascia oraria</option>
|
||||
{preferredTimeWindows.map((time) => (
|
||||
<option key={time} value={time}>
|
||||
{time}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -397,9 +569,51 @@ export default function BookingSection() {
|
||||
/>
|
||||
</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
|
||||
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"
|
||||
>
|
||||
{submitting ? "Invio in corso..." : "Invia richiesta di prenotazione"}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Footer scuro con informazioni di contatto, link utili e disclaimer legale
|
||||
* Sfondo blu petrolio scuro, testo bianco/grigio chiaro
|
||||
*/
|
||||
import { MapPin, Phone, Mail, Facebook, Clock, ArrowRight } from "lucide-react";
|
||||
import { MapPin, Phone, Mail, Clock, ArrowRight } from "lucide-react";
|
||||
|
||||
const quickLinks = [
|
||||
{ label: "Chi Siamo", href: "/#chi-siamo" },
|
||||
@@ -11,7 +11,6 @@ const quickLinks = [
|
||||
{ label: "Chirurgia", href: "/#servizi" },
|
||||
{ label: "Laboratorio", href: "/#servizi" },
|
||||
{ label: "Il Team", href: "/#team" },
|
||||
{ label: "News & Blog", href: "/#news" },
|
||||
{ label: "Prenota Visita", href: "/#prenotazione" },
|
||||
{ label: "Area Personale", href: "/#registrazione" },
|
||||
];
|
||||
@@ -19,38 +18,8 @@ const quickLinks = [
|
||||
export default function Footer() {
|
||||
return (
|
||||
<footer id="contatti" className="bg-[#0d2b3e] text-white">
|
||||
{/* Main footer */}
|
||||
<div className="container py-16">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-10">
|
||||
{/* Brand
|
||||
<div className="lg:col-span-1">
|
||||
<div className="mb-4">
|
||||
<div
|
||||
className="font-bold text-white leading-tight"
|
||||
style={{ fontFamily: "'Cormorant Garamond', serif", fontSize: "1rem" }}
|
||||
>
|
||||
Clinica Veterinaria
|
||||
</div>
|
||||
<div className="text-[#4ECDC4] text-xs font-semibold tracking-widest uppercase">
|
||||
Formiginese
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-white/60 text-sm leading-relaxed mb-4">
|
||||
Cura specialistica per cani e gatti a Formigine.
|
||||
Un team di professionisti al servizio della salute dei tuoi animali.
|
||||
</p>
|
||||
<a
|
||||
href="https://it-it.facebook.com/clinicaveterinariaformiginese/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 text-[#4ECDC4] hover:text-white transition-colors text-sm"
|
||||
>
|
||||
<Facebook size={16} />
|
||||
Seguici su Facebook
|
||||
</a>
|
||||
</div>*/}
|
||||
|
||||
{/* Link rapidi */}
|
||||
<div>
|
||||
<h4
|
||||
className="text-white font-semibold mb-4 text-sm uppercase tracking-widest"
|
||||
@@ -65,7 +34,10 @@ export default function Footer() {
|
||||
href={link.href}
|
||||
className="text-white/60 hover:text-[#4ECDC4] transition-colors text-sm flex items-center gap-1.5 group"
|
||||
>
|
||||
<ArrowRight size={12} className="opacity-0 group-hover:opacity-100 transition-opacity -ml-4 group-hover:ml-0 duration-200" />
|
||||
<ArrowRight
|
||||
size={12}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity -ml-4 group-hover:ml-0 duration-200"
|
||||
/>
|
||||
{link.label}
|
||||
</a>
|
||||
</li>
|
||||
@@ -73,7 +45,6 @@ export default function Footer() {
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Contatti */}
|
||||
<div>
|
||||
<h4
|
||||
className="text-white font-semibold mb-4 text-sm uppercase tracking-widest"
|
||||
@@ -92,7 +63,10 @@ export default function Footer() {
|
||||
<div className="flex items-center gap-3">
|
||||
<Phone size={15} className="text-[#4ECDC4] flex-shrink-0" />
|
||||
<div>
|
||||
<a href="tel:0598396263" className="text-white/80 text-sm hover:text-[#4ECDC4] transition-colors">
|
||||
<a
|
||||
href="tel:0598396263"
|
||||
className="text-white/80 text-sm hover:text-[#4ECDC4] transition-colors"
|
||||
>
|
||||
059 839.62.63
|
||||
</a>
|
||||
<p className="text-white/60 text-xs">Urgenze: 320 532.24.39</p>
|
||||
@@ -110,7 +84,6 @@ export default function Footer() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Orari */}
|
||||
<div>
|
||||
<h4
|
||||
className="text-white font-semibold mb-4 text-sm uppercase tracking-widest"
|
||||
@@ -120,11 +93,10 @@ export default function Footer() {
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{[
|
||||
{ days: "Visite: Lunedì — Venerdì", hours: "09:00 — 19:30" },
|
||||
{ days: "Visite: Sabato", hours: "09:00 — 17:00" },
|
||||
{ days: "Urgenze: Lunedì — Venerdì", hours: "08:00 — 22:30" },
|
||||
{ days: "Urgenze: Sabato e Festivi", hours: "09:00 — 20:00" },
|
||||
|
||||
{ days: "Visite: Lunedì - Venerdì", hours: "09:00 - 19:30" },
|
||||
{ days: "Visite: Sabato", hours: "09:00 - 17:00" },
|
||||
{ days: "Urgenze: Lunedì - Venerdì", hours: "08:00 - 22:30" },
|
||||
{ days: "Urgenze: Sabato e Festivi", hours: "09:00 - 20:00" },
|
||||
].map((slot) => (
|
||||
<div key={slot.days} className="flex items-start gap-2">
|
||||
<Clock size={13} className="text-[#4ECDC4] flex-shrink-0 mt-0.5" />
|
||||
@@ -135,21 +107,17 @@ export default function Footer() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Badge reperibilità */}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom bar */}
|
||||
<div className="border-t border-white/10">
|
||||
<div className="container py-6">
|
||||
<div className="flex flex-col md:flex-row justify-between items-center gap-4 text-xs text-white/40">
|
||||
<div className="text-center md:text-left">
|
||||
<p>© 2026 Clinica Veterinaria Formiginese — Tutti i diritti riservati</p>
|
||||
<p>© 2026 Clinica Veterinaria Formiginese - Tutti i diritti riservati</p>
|
||||
<p className="mt-1">
|
||||
Dir. San. Dott. Paolo Parmeggiani (n. 715 MO) — Master in Oncologia Veterinaria ·
|
||||
Dir. San. Dott. Paolo Parmeggiani (n. 715 MO) - Master in Oncologia Veterinaria ·
|
||||
Autorizzazione n. 43560 del 30/12/2024
|
||||
</p>
|
||||
</div>
|
||||
@@ -166,9 +134,9 @@ export default function Footer() {
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-white/25 text-xs mt-3 text-center md:text-left">
|
||||
Il messaggio informativo di questo sito è diramato nel rispetto delle norme vigenti ed in conformità alle
|
||||
direttive del codice deontologico. L'attivazione del sito è stata comunicata all'Ordine Provinciale dei
|
||||
Veterinari della Provincia di Modena.
|
||||
Il messaggio informativo di questo sito è diramato nel rispetto delle norme vigenti ed
|
||||
in conformità alle direttive del codice deontologico. L'attivazione del sito è stata
|
||||
comunicata all'Ordine Provinciale dei Veterinari della Provincia di Modena.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
/*
|
||||
* DESIGN: "Clinical Warmth"
|
||||
* Hero a schermo intero con immagine animale + overlay gradiente + testo sovrapposto
|
||||
* Immagine: hero_dog_cat.jpg (golden retriever + gatto in prato soleggiato)
|
||||
* Testo bianco su overlay scuro, CTA verde acqua
|
||||
* Testo elegante su overlay scuro, card orari raffinata, CTA verdi acqua
|
||||
*/
|
||||
import { useState, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ChevronDown, Calendar, Phone, Clock } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { Calendar, ChevronDown, Clock, Phone } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const heroImages = [
|
||||
{
|
||||
@@ -48,20 +48,26 @@ const heroImages = [
|
||||
},
|
||||
];
|
||||
|
||||
const openingHours = [
|
||||
{ days: "Visite: Lunedì - Venerdì", hours: "09:00 - 19:30" },
|
||||
{ days: "Visite: Sabato", hours: "09:00 - 17:00" },
|
||||
{ days: "Urgenze: Lunedì - Venerdì", hours: "08:00 - 22:30" },
|
||||
{ days: "Urgenze: Sabato e Festivi", hours: "09:00 - 20:00" },
|
||||
];
|
||||
|
||||
export default function HeroSection() {
|
||||
const [currentImage, setCurrentImage] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
// 3 secondi di visualizzazione + 2 secondi di dissolvenza = 5 secondi totali
|
||||
const interval = setInterval(() => {
|
||||
setCurrentImage((prev) => (prev + 1) % heroImages.length);
|
||||
}, 5000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section className="relative h-[92vh] min-h-[600px] max-h-[900px] overflow-hidden">
|
||||
{/* Background images con crossfade */}
|
||||
{heroImages.map((img, index) => (
|
||||
<div
|
||||
key={img.url}
|
||||
@@ -71,104 +77,95 @@ export default function HeroSection() {
|
||||
<img
|
||||
src={img.url}
|
||||
alt={img.alt}
|
||||
className="w-full h-full object-cover"
|
||||
className="h-full w-full object-cover"
|
||||
style={{ transform: "scale(1.05)" }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Overlay gradiente scuro */}
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-[#0d2b3e]/85 via-[#1B4F72]/60 to-transparent" />
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-[#0d2b3e]/50 via-transparent to-transparent" />
|
||||
|
||||
{/* Contenuto hero */}
|
||||
<div className="relative z-10 h-full flex items-center">
|
||||
<div className="relative z-10 flex h-full items-center">
|
||||
<div className="container">
|
||||
<div className="max-w-2xl">
|
||||
{/* Badge */}
|
||||
|
||||
|
||||
{/* Titolo principale */}
|
||||
<div className="max-w-3xl">
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8, delay: 0.3 }}
|
||||
className="text-white leading-tight mb-4"
|
||||
className="mb-10 max-w-2xl text-white leading-[0.92]"
|
||||
style={{
|
||||
fontFamily: "'Cormorant Garamond', serif",
|
||||
fontSize: "clamp(2.5rem, 6vw, 4.5rem)",
|
||||
fontWeight: 600,
|
||||
fontSize: "clamp(3.15rem, 6.4vw, 5.5rem)",
|
||||
fontWeight: 500,
|
||||
letterSpacing: "0.03em",
|
||||
textShadow: "0 14px 34px rgba(0,0,0,0.24)",
|
||||
}}
|
||||
>
|
||||
CLINICA VETERINARIA FORMIGINESE{" "}
|
||||
|
||||
<span className="block text-white/92">Clinica Veterinaria</span>
|
||||
<span className="block italic text-white">Formiginese</span>
|
||||
</motion.h1>
|
||||
|
||||
{/* Sottotitolo */}
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 22 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.7, delay: 0.5 }}
|
||||
className="text-white/85 text-lg mb-8 leading-relaxed max-w-xl"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
transition={{ duration: 0.7, delay: 0.55 }}
|
||||
className="mb-14 max-w-[39rem]"
|
||||
>
|
||||
|
||||
</motion.p>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.7, delay: 0.7 }}
|
||||
className="flex flex-col sm:flex-row gap-4"
|
||||
>
|
||||
<div>
|
||||
<h4
|
||||
className="text-white font-semibold mb-4 text-sm uppercase tracking-widest"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
>
|
||||
Orari
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{[
|
||||
{ days: "Visite: Lunedì — Venerdì", hours: "09:00 — 19:30" },
|
||||
{ days: "Visite: Sabato", hours: "09:00 — 17:00" },
|
||||
{ days: "Urgenze: Lunedì — Venerdì", hours: "08:00 — 22:30" },
|
||||
{ days: "Urgenze: Sabato e Festivi", hours: "09:00 — 20:00" },
|
||||
|
||||
].map((slot) => (
|
||||
<div key={slot.days} className="flex items-start gap-2">
|
||||
<Clock size={13} className="text-[#4ECDC4] flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-white/70 text-xs">{slot.days}</p>
|
||||
<p className="text-white/50 text-xs whitespace-pre-line">{slot.hours}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="rounded-[26px] border border-white/20 bg-white/10 px-6 py-6 shadow-[0_18px_45px_rgba(0,0,0,0.18)] backdrop-blur-md sm:px-7 sm:py-7">
|
||||
<h4
|
||||
className="mb-5 text-white/95 uppercase tracking-[0.28em]"
|
||||
style={{
|
||||
fontFamily: "'Nunito Sans', sans-serif",
|
||||
fontSize: "0.8rem",
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
Orari di apertura
|
||||
</h4>
|
||||
|
||||
{/* Badge reperibilità */}
|
||||
|
||||
</div>
|
||||
</motion.div>
|
||||
<div className="space-y-4">
|
||||
{openingHours.map((slot) => (
|
||||
<div key={slot.days} className="flex items-start gap-3.5">
|
||||
<div className="mt-0.5 rounded-full bg-[#4ECDC4]/20 p-2">
|
||||
<Clock size={14} className="text-[#7ce3dc]" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-white/92 sm:text-[0.98rem]">
|
||||
{slot.days}
|
||||
</p>
|
||||
<p className="mt-0.5 text-sm text-white/72 sm:text-[0.96rem]">
|
||||
{slot.hours}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* CTA buttons */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.7, delay: 0.7 }}
|
||||
className="flex flex-col sm:flex-row gap-4"
|
||||
transition={{ duration: 0.7, delay: 0.75 }}
|
||||
className="flex flex-col gap-4 pt-1 sm:flex-row"
|
||||
>
|
||||
<Button
|
||||
size="lg"
|
||||
className="bg-[#4ECDC4] hover:bg-[#3ab5ad] text-white font-bold text-base px-8 py-6 shadow-lg shadow-[#4ECDC4]/30 transition-all duration-300 hover:shadow-xl hover:shadow-[#4ECDC4]/40 hover:-translate-y-0.5"
|
||||
onClick={() => document.getElementById("prenotazione")?.scrollIntoView({ behavior: "smooth" })}
|
||||
className="bg-[#4ECDC4] px-8 py-6 text-base font-bold text-white shadow-lg shadow-[#4ECDC4]/30 transition-all duration-300 hover:-translate-y-0.5 hover:bg-[#3ab5ad] hover:shadow-xl hover:shadow-[#4ECDC4]/40"
|
||||
onClick={() =>
|
||||
document.getElementById("prenotazione")?.scrollIntoView({ behavior: "smooth" })
|
||||
}
|
||||
>
|
||||
<Calendar size={18} className="mr-2" />
|
||||
Prenota una Visita
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="lg"
|
||||
variant="outline"
|
||||
className="border-2 border-white text-white hover:bg-white hover:text-[#1B4F72] font-semibold text-base px-8 py-6 transition-all duration-300 bg-transparent backdrop-blur-sm"
|
||||
className="border-2 border-white bg-transparent px-8 py-6 text-base font-semibold text-white backdrop-blur-sm transition-all duration-300 hover:bg-white hover:text-[#1B4F72]"
|
||||
asChild
|
||||
>
|
||||
<a href="tel:0598396263">
|
||||
@@ -177,32 +174,24 @@ export default function HeroSection() {
|
||||
</a>
|
||||
</Button>
|
||||
</motion.div>
|
||||
|
||||
{/* Stats */}
|
||||
{/* Orari */}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Indicatori slideshow */}
|
||||
<div className="absolute bottom-8 left-1/2 -translate-x-1/2 flex gap-2 z-10">
|
||||
<div className="absolute bottom-8 left-1/2 z-10 flex -translate-x-1/2 gap-2">
|
||||
{heroImages.map((_, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => setCurrentImage(index)}
|
||||
className={`transition-all duration-300 rounded-full ${
|
||||
index === currentImage
|
||||
? "w-8 h-2 bg-[#4ECDC4]"
|
||||
: "w-2 h-2 bg-white/50 hover:bg-white/80"
|
||||
className={`rounded-full transition-all duration-300 ${
|
||||
index === currentImage ? "h-2 w-8 bg-[#4ECDC4]" : "h-2 w-2 bg-white/50 hover:bg-white/80"
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Scroll indicator */}
|
||||
<div className="absolute bottom-8 right-8 z-10 hidden md:flex flex-col items-center gap-2 text-white/60">
|
||||
<span className="text-xs uppercase tracking-widest rotate-90 mb-2">Scroll</span>
|
||||
<div className="absolute bottom-8 right-8 z-10 hidden flex-col items-center gap-2 text-white/60 md:flex">
|
||||
<span className="mb-2 rotate-90 text-xs uppercase tracking-widest">Scroll</span>
|
||||
<ChevronDown size={16} className="animate-bounce" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -48,7 +48,6 @@ const navLinks: NavLink[] = [
|
||||
],
|
||||
},
|
||||
{ label: "Il Team", href: "#team" },
|
||||
{ label: "News", href: "#news" },
|
||||
{ label: "Contatti", href: "#contatti" },
|
||||
];
|
||||
|
||||
@@ -58,7 +57,7 @@ export default function Navbar() {
|
||||
|
||||
const showActivationToast = () => {
|
||||
toast.info("Servizio in corso di attivazione", {
|
||||
description: "L'area riservata sara disponibile a breve.",
|
||||
description: "L'area riservata sarà disponibile a breve.",
|
||||
});
|
||||
};
|
||||
|
||||
@@ -80,7 +79,7 @@ export default function Navbar() {
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Phone size={13} className="text-[#4ECDC4]" />
|
||||
059 839.62.63 | 320 532.24.39 (urgenze 24h)
|
||||
059 839.62.63 | 320 532.24.39 (urgenze)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -9,16 +9,15 @@ import { useRef } from "react";
|
||||
import {
|
||||
Activity,
|
||||
Apple,
|
||||
ArrowRight,
|
||||
Bone,
|
||||
Eye,
|
||||
FlaskConical,
|
||||
HeartPulse,
|
||||
Scan,
|
||||
Sparkles,
|
||||
Stethoscope,
|
||||
Video,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
type ServiceItem = {
|
||||
id: string;
|
||||
@@ -95,7 +94,7 @@ const clinicalServices: ServiceItem[] = [
|
||||
title: "Laparoscopia",
|
||||
subtitle: "Chirurgia mini-invasiva",
|
||||
description:
|
||||
"Procedure mini-invasive con incisioni ridotte, recuperi piu confortevoli e maggiore delicatezza nei passaggi operatori.",
|
||||
"Procedure mini-invasive con incisioni ridotte, recuperi più confortevoli e maggiore delicatezza nei passaggi operatori.",
|
||||
image: "/images/services_laparoscopia.jpg",
|
||||
icon: Activity,
|
||||
color: "#2E86AB",
|
||||
@@ -103,6 +102,19 @@ const clinicalServices: ServiceItem[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const cardiologyService: ServiceItem = {
|
||||
id: "cardiologia",
|
||||
title: "Cardiologia",
|
||||
subtitle: "Diagnostica cardiaca",
|
||||
description:
|
||||
"Approfondimenti dedicati alla funzionalità cardiaca con esami mirati e monitoraggi utili nei percorsi diagnostici più delicati.",
|
||||
image: "/images/services_cardiologia.jpg",
|
||||
icon: HeartPulse,
|
||||
color: "#8C5A7A",
|
||||
features: ["Ecocardio", "ECG", "Holter", "Test malattie ereditarie"],
|
||||
tone: "specialist",
|
||||
};
|
||||
|
||||
const specialistVisits: ServiceItem[] = [
|
||||
{
|
||||
id: "oncologia",
|
||||
@@ -208,11 +220,6 @@ function ServiceCard({
|
||||
>
|
||||
<service.icon size={22} className="text-white" />
|
||||
</div>
|
||||
{specialist && (
|
||||
<div className="absolute left-4 top-4 rounded-full bg-white/90 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-[#7d5233] shadow-sm">
|
||||
Alta specializzazione
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
@@ -236,16 +243,6 @@ function ServiceCard({
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<button
|
||||
className="group/btn flex items-center gap-1.5 text-sm font-semibold transition-all duration-200"
|
||||
style={{ color: service.color }}
|
||||
onClick={() => {
|
||||
toast("Sezione in arrivo", { description: `La pagina dedicata a ${service.title} sara disponibile a breve.` });
|
||||
}}
|
||||
>
|
||||
Approfondisci
|
||||
<ArrowRight size={15} className="transition-transform duration-200 group-hover/btn:translate-x-1" />
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
@@ -267,12 +264,6 @@ export default function ServicesSection() {
|
||||
transition={{ duration: 0.7 }}
|
||||
className="mb-14"
|
||||
>
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<div className="h-0.5 w-12 bg-[#4ECDC4]" />
|
||||
<span className="text-sm font-semibold uppercase tracking-widest text-[#4ECDC4]">
|
||||
Specializzazioni
|
||||
</span>
|
||||
</div>
|
||||
<h2
|
||||
className="mb-4 text-[#1B4F72]"
|
||||
style={{ fontFamily: "'Cormorant Garamond', serif", fontSize: "clamp(2rem, 4vw, 3rem)", fontWeight: 600 }}
|
||||
@@ -290,6 +281,20 @@ export default function ServicesSection() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-8 xl:hidden flex justify-center">
|
||||
<ServiceCard
|
||||
service={cardiologyService}
|
||||
index={clinicalServices.length}
|
||||
className="w-full md:w-[calc(50%-1rem)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 hidden xl:grid xl:grid-cols-3 xl:gap-8">
|
||||
<div />
|
||||
<ServiceCard service={cardiologyService} index={clinicalServices.length} className="w-full" />
|
||||
<div />
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
ref={specialistRef}
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
@@ -297,25 +302,15 @@ export default function ServicesSection() {
|
||||
transition={{ duration: 0.7, delay: 0.2 }}
|
||||
className="mt-20"
|
||||
>
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<div className="h-0.5 w-12 bg-[#A95F3A]" />
|
||||
<span className="text-sm font-semibold uppercase tracking-widest text-[#A95F3A]">
|
||||
Alta specializzazione
|
||||
</span>
|
||||
</div>
|
||||
<h3
|
||||
className="mb-4 text-[#1B4F72]"
|
||||
style={{ fontFamily: "'Cormorant Garamond', serif", fontSize: "clamp(1.9rem, 3.7vw, 2.8rem)", fontWeight: 600 }}
|
||||
>
|
||||
Visite Specialistiche
|
||||
</h3>
|
||||
<p className="max-w-2xl text-base leading-relaxed text-gray-600">
|
||||
Un secondo livello di consulenza clinica per casi che richiedono competenze dedicate,
|
||||
approfondimenti mirati e percorsi di valutazione ad alto valore aggiunto.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<div className="mt-10 grid grid-cols-1 gap-8 md:grid-cols-2 xl:grid-cols-6">
|
||||
<div className="mt-8 grid grid-cols-1 gap-8 md:grid-cols-2 xl:grid-cols-6">
|
||||
{specialistVisits.map((service, index) => (
|
||||
<ServiceCard
|
||||
key={service.id}
|
||||
@@ -326,22 +321,6 @@ export default function ServicesSection() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={specialistInView ? { opacity: 1 } : {}}
|
||||
transition={{ duration: 0.7, delay: 0.6 }}
|
||||
className="mt-12 text-center"
|
||||
>
|
||||
<button
|
||||
className="inline-flex items-center gap-2 rounded-full border-2 border-[#1B4F72] px-8 py-3 font-semibold text-[#1B4F72] transition-all duration-300 hover:bg-[#1B4F72] hover:text-white"
|
||||
onClick={() => {
|
||||
toast("Tutti i servizi", { description: "La pagina completa dei servizi sara disponibile a breve." });
|
||||
}}
|
||||
>
|
||||
Tutti i servizi
|
||||
<ArrowRight size={16} />
|
||||
</button>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -22,8 +22,8 @@ const medicalTeam: TeamMember[] = [
|
||||
specialization: "Oncologia veterinaria",
|
||||
color: "#1B4F72",
|
||||
bio: [
|
||||
"Laureato a pieni voti presso l'Universita degli Studi di Milano, ha conseguito un Master di II livello in Oncologia Veterinaria presso l'Universita di Pisa e ha svolto un tirocinio di chirurgia e medicina interna presso la facolta di Sao Joao da Boa Vista, in Brasile.",
|
||||
"Iscritto all'Ordine dei Medici Veterinari di Modena (n. 715), e socio SCIVAC e SIONCOV.",
|
||||
"Laureato a pieni voti presso l'Università degli Studi di Milano, ha conseguito un Master di II livello in Oncologia Veterinaria presso l'Università di Pisa e ha svolto un tirocinio di chirurgia e medicina interna presso la facoltà di São João da Boa Vista, in Brasile.",
|
||||
"Iscritto all'Ordine dei Medici Veterinari di Modena (n. 715), è socio SCIVAC e SIONCOV.",
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -32,8 +32,8 @@ const medicalTeam: TeamMember[] = [
|
||||
specialization: "Ecografia ed esotici",
|
||||
color: "#4ECDC4",
|
||||
bio: [
|
||||
"Laureata a pieni voti con lode presso l'Universita di Bologna, ha svolto un tirocinio in medicina interna e chirurgia presso l'Ospedale Veterinario dell'Universita di Copenhagen.",
|
||||
"Iscritta all'Ordine dei Medici Veterinari di Modena (n. 749), e socia SCIVAC e SIVAE. Ha seguito diversi corsi di perfezionamento in ecografia addominale ed ecocardiografia del cane e del gatto.",
|
||||
"Laureata a pieni voti con lode presso l'Università di Bologna, ha svolto un tirocinio in medicina interna e chirurgia presso l'Ospedale Veterinario dell'Università di Copenhagen.",
|
||||
"Iscritta all'Ordine dei Medici Veterinari di Modena (n. 749). Ha seguito diversi corsi di perfezionamento in ecografia addominale ed ecocardiografia del cane e del gatto.",
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -45,8 +45,11 @@ const medicalTeam: TeamMember[] = [
|
||||
{
|
||||
name: "Dott.ssa Michela Sghedoni",
|
||||
role: "Medico veterinario",
|
||||
specialization: "Profilo in aggiornamento",
|
||||
specialization: "Medicina interna, patologia clinica e citologia",
|
||||
color: "#B76E79",
|
||||
bio: [
|
||||
"Laureata presso l’Università degli Studi di Parma nel 2009. Dopo la laurea approfondisce gli studi di Medicina Interna e Patologia Clinica, aggiungendo successivamente anche la Citologia.",
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Dott. Luca Pietri",
|
||||
@@ -60,6 +63,30 @@ const medicalTeam: TeamMember[] = [
|
||||
specialization: "Profilo in aggiornamento",
|
||||
color: "#C58C63",
|
||||
},
|
||||
{
|
||||
name: "Dott. Riccardo Suffritti",
|
||||
role: "Medico veterinario",
|
||||
specialization: "Medicina interna, medicina d'urgenza, anestesiologia e dermatologia",
|
||||
color: "#7A8FA8",
|
||||
bio: [
|
||||
"Laureato in Medicina Veterinaria all'Università di Bologna a pieni voti nel 2020. Dopo la laurea approfondisce gli studi di Medicina Interna e Medicina d'Urgenza, aggiungendo successivamente anche Anestesiologia e Dermatologia.",
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Dott.ssa Elena Venturelli",
|
||||
role: "Medico veterinario",
|
||||
specialization: "Cardiologia e diagnostica ecografica",
|
||||
color: "#4C7A9F",
|
||||
bio: [
|
||||
"Laureata in Medicina Veterinaria all’Università di Bologna nel 2011. Dopo l’esperienza in medicina d’urgenza, approfondisce cardiologia e diagnostica ecografica con percorsi SCIVAC, internship specialistico in cardiologia con il dott. Claudio Bussadori, consegue GPCert Cardiology, Master universitari di II livello in Ultrasonologia e Cardiologia Veterinaria ed è abilitata FSA per la certificazione delle cardiopatie ereditarie. Nel 2024 consegue Master di II livello in Cardiologia Veterinaria presso l’Università di Camerino.",
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Dott.ssa Cinzia Pellegrini",
|
||||
role: "Medico veterinario",
|
||||
specialization: "Profilo in aggiornamento",
|
||||
color: "#8E6C88",
|
||||
},
|
||||
];
|
||||
|
||||
const collaborators: TeamMember[] = [
|
||||
@@ -69,27 +96,39 @@ const collaborators: TeamMember[] = [
|
||||
specialization: "Endoscopia, citologia e medicina interna",
|
||||
color: "#A95F3A",
|
||||
bio: [
|
||||
"Laureato presso l'Universita degli Studi di Bologna, ha trascorso due anni come studente interno nel dipartimento di Clinica Chirurgica della stessa universita.",
|
||||
"Dal 2004 al 2017 ha collaborato con cliniche veterinarie del territorio modenese e romagnolo in medicina interna, endoscopia e citologia. Dal 2012 opera come freelance di endoscopia e citologia in numerose strutture veterinarie dell'Emilia Romagna. Dal 2017 entra a far parte di ENDOVET.",
|
||||
"Laureato presso l'Università degli Studi di Bologna, ha trascorso due anni come studente interno nel dipartimento di Clinica Chirurgica della stessa università.",
|
||||
"Dal 2004 al 2017 ha collaborato con cliniche veterinarie del territorio modenese e romagnolo in medicina interna, endoscopia e citologia. Dal 2012 opera come freelance di endoscopia e citologia in numerose strutture veterinarie dell'Emilia-Romagna. Dal 2017 entra a far parte di ENDOVET.",
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Dott. Alessandro Poli",
|
||||
role: "Collaborazione specialistica",
|
||||
specialization: "Profilo in aggiornamento",
|
||||
specialization: "Chirurgia dei tessuti molli, endoscopia e chirurgia mini-invasiva",
|
||||
color: "#7EA55A",
|
||||
bio: [
|
||||
"Laureato con lode presso l’Università di Bologna nel 2016, consegue nello stesso anno l’Esame di Stato per l’abilitazione professionale. Collabora con diverse strutture veterinarie sviluppando un interesse specifico per chirurgia dei tessuti molli, endoscopia e chirurgia mini-invasiva.",
|
||||
"Nel 2017 frequenta il Corso Pratico di Endoscopia Flessibile nel Cane e nel Gatto al Jesus Uson Center for Minimally Invasion Surgery di Caceres, in Spagna. Nel 2019 ottiene il titolo di Esperto Universitario in Chirurgia dei Tessuti Molli presso la Cardinal Herrera University di Valencia. Dal 2020 svolge attività libero-professionale in diverse strutture e dal 2023 prosegue il percorso verso il GPcert in Endoscopia e Chirurgia Mini-invasiva, con formazione periodica presso AURA Vet Surgery Center nel Regno Unito.",
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Dott.ssa Elena Venturelli",
|
||||
name: "Dott. Boris Dal Pozzo",
|
||||
role: "Collaborazione specialistica",
|
||||
specialization: "Profilo in aggiornamento",
|
||||
color: "#4C7A9F",
|
||||
specialization: "Chirurgia e medicina d’urgenza",
|
||||
color: "#6E8FA7",
|
||||
bio: [
|
||||
"Laureato nel 2005 presso la Facoltà di Medicina Veterinaria di Bologna, il Dott. Boris Dal Pozzo intraprende un percorso orientato alla chirurgia e alla medicina d’urgenza. Dal 2006 al 2009 lavora nella sezione di Chirurgia della stessa Facoltà, dove nel 2009 consegue il titolo di Dottore di Ricerca in Medicina d’urgenza e terapia intensiva.",
|
||||
"Nel 2008 svolge un periodo di formazione di otto mesi presso la sezione di Chirurgia dei Piccoli Animali dell’Università di Zurigo, esperienza che rafforza le competenze in ambito chirurgico. Dal 2012 segue ulteriori periodi di aggiornamento presso la Clinica Chirurgica Veterinaria dell’Università di Torino sotto la supervisione del Prof. Buracco.",
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Dott.ssa Cinzia Pellegrini",
|
||||
name: "Dott.ssa Silvia Palladini",
|
||||
role: "Collaborazione specialistica",
|
||||
specialization: "Profilo in aggiornamento",
|
||||
color: "#8E6C88",
|
||||
specialization: "Nutrizione, alimentazione e fisioterapia veterinaria",
|
||||
color: "#B6876F",
|
||||
bio: [
|
||||
"La Dott.ssa Silvia Palladini si laurea in Medicina Veterinaria all’Università di Parma nel 2008, con una tesi in fisioterapia riabilitativa del cane e del gatto. Approfondisce poi fisioterapia veterinaria, nutrizione clinica e alimentazione del cane e del gatto, conseguendo il CCRP presso l’Università del Tennessee e due Master universitari a Teramo.",
|
||||
"Completa inoltre percorsi in gastroenterologia, fitoterapia, micoterapia e nutrizione sistemica veterinaria, ed è inserita nell’elenco FNOVI per particolari competenze in nutrizione, alimentazione e dietologia clinica degli animali da compagnia.",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -173,13 +212,9 @@ function TeamCard({
|
||||
|
||||
function TeamGroup({
|
||||
eyebrow,
|
||||
title,
|
||||
description,
|
||||
members,
|
||||
}: {
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
description: string;
|
||||
members: TeamMember[];
|
||||
}) {
|
||||
const ref = useRef(null);
|
||||
@@ -200,15 +235,6 @@ function TeamGroup({
|
||||
{eyebrow}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
|
||||
<h3
|
||||
className="text-[#1B4F72]"
|
||||
style={{ fontFamily: "'Cormorant Garamond', serif", fontSize: "clamp(1.8rem, 3.2vw, 2.5rem)", fontWeight: 600 }}
|
||||
>
|
||||
{title}
|
||||
</h3>
|
||||
<p className="max-w-xl text-sm leading-relaxed text-gray-600">{description}</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 xl:grid-cols-3">
|
||||
@@ -224,19 +250,9 @@ export default function TeamSection() {
|
||||
return (
|
||||
<section id="team" className="bg-white py-20 md:py-28">
|
||||
<div className="container">
|
||||
<TeamGroup
|
||||
eyebrow="Team medico"
|
||||
title="Team medico"
|
||||
description="I professionisti che seguono l'attivita clinica quotidiana della struttura, con competenze diverse e percorsi di aggiornamento continui."
|
||||
members={medicalTeam}
|
||||
/>
|
||||
<TeamGroup eyebrow="Team medico" members={medicalTeam} />
|
||||
|
||||
<TeamGroup
|
||||
eyebrow="Collaborazioni"
|
||||
title="Collaboratori"
|
||||
description="Una rete di competenze specialistiche che amplia le possibilita diagnostiche e terapeutiche disponibili per i pazienti della clinica."
|
||||
members={collaborators}
|
||||
/>
|
||||
<TeamGroup eyebrow="Collaborazioni" members={collaborators} />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -9,7 +9,6 @@ import HeroSection from "@/components/HeroSection";
|
||||
import ServicesSection from "@/components/ServicesSection";
|
||||
import AboutSection from "@/components/AboutSection";
|
||||
import TeamSection from "@/components/TeamSection";
|
||||
import NewsSection from "@/components/NewsSection";
|
||||
import BookingSection from "@/components/BookingSection";
|
||||
import AuthSection from "@/components/AuthSection";
|
||||
import Footer from "@/components/Footer";
|
||||
@@ -23,7 +22,6 @@ export default function Home() {
|
||||
<ServicesSection />
|
||||
<AboutSection />
|
||||
<TeamSection />
|
||||
<NewsSection />
|
||||
<BookingSection />
|
||||
<AuthSection />
|
||||
</main>
|
||||
|
||||
@@ -176,7 +176,7 @@ export default defineConfig({
|
||||
emptyOutDir: true,
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
port: 3001,
|
||||
strictPort: false, // Will find next available port if 3000 is busy
|
||||
host: true,
|
||||
allowedHosts: [
|
||||
|
||||
90
docs/apache-ip-lockdown.md
Normal file
90
docs/apache-ip-lockdown.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# Blocco directory listing su accesso via IP
|
||||
|
||||
Problema verificato:
|
||||
|
||||
```bash
|
||||
curl -k https://129.152.31.221/
|
||||
```
|
||||
|
||||
Risposta non desiderata:
|
||||
|
||||
```txt
|
||||
200 OK
|
||||
Index of /
|
||||
```
|
||||
|
||||
L'accesso diretto all'IP non deve mostrare `/var/www` o l'elenco dei file.
|
||||
|
||||
## Correzione rapida
|
||||
|
||||
Sul server Linux:
|
||||
|
||||
```bash
|
||||
sudo cp /etc/apache2/apache2.conf /etc/apache2/apache2.conf.bak.$(date +%Y%m%d-%H%M%S)
|
||||
sudo sed -i 's/Options Indexes FollowSymLinks/Options -Indexes +FollowSymLinks/g' /etc/apache2/apache2.conf
|
||||
sudo apache2ctl configtest
|
||||
sudo systemctl reload apache2
|
||||
```
|
||||
|
||||
Verifica:
|
||||
|
||||
```bash
|
||||
curl -k -I https://129.152.31.221/
|
||||
```
|
||||
|
||||
Il risultato atteso non deve essere `200 OK` con directory listing. Un `403 Forbidden`
|
||||
e accettabile per l'accesso diretto via IP.
|
||||
|
||||
## Correzione consigliata
|
||||
|
||||
Creare un virtual host di default che blocchi ogni richiesta non associata ai domini
|
||||
configurati.
|
||||
|
||||
File:
|
||||
|
||||
```txt
|
||||
/etc/apache2/sites-available/000-ip-block.conf
|
||||
```
|
||||
|
||||
Contenuto HTTP:
|
||||
|
||||
```apache
|
||||
<VirtualHost *:80>
|
||||
ServerName 129.152.31.221
|
||||
Redirect 301 / https://www.clinicaveterinariaformiginese.it/
|
||||
</VirtualHost>
|
||||
```
|
||||
|
||||
Per HTTPS, se sul server e gia attivo un certificato valido per il dominio principale,
|
||||
si puo creare un virtual host catch-all usando gli stessi certificati e restituire 403:
|
||||
|
||||
```apache
|
||||
<IfModule mod_ssl.c>
|
||||
<VirtualHost _default_:443>
|
||||
ServerName 129.152.31.221
|
||||
|
||||
SSLEngine on
|
||||
SSLCertificateFile /etc/letsencrypt/live/www.clinicaveterinariaformiginese.it/fullchain.pem
|
||||
SSLCertificateKeyFile /etc/letsencrypt/live/www.clinicaveterinariaformiginese.it/privkey.pem
|
||||
|
||||
<Location />
|
||||
Require all denied
|
||||
</Location>
|
||||
</VirtualHost>
|
||||
</IfModule>
|
||||
```
|
||||
|
||||
Attivazione:
|
||||
|
||||
```bash
|
||||
sudo a2ensite 000-ip-block.conf
|
||||
sudo apache2ctl configtest
|
||||
sudo systemctl reload apache2
|
||||
```
|
||||
|
||||
Verifiche:
|
||||
|
||||
```bash
|
||||
curl -k -I https://129.152.31.221/
|
||||
curl -I https://www.clinicaveterinariaformiginese.it/
|
||||
```
|
||||
Reference in New Issue
Block a user