Compare commits
26 Commits
0ca0306a34
...
backend_in
| Author | SHA1 | Date | |
|---|---|---|---|
| 753f0ec346 | |||
| ae31cbaaba | |||
| 017b6a905c | |||
| 16bfcb8b47 | |||
| cc70144183 | |||
| ba4a73d5ca | |||
| 8d83f0c619 | |||
| f8171bb3dd | |||
| 04fb49ad4b | |||
| d9b617e706 | |||
| 580a659b5a | |||
| bd5b845e43 | |||
| 83fb4b8c1e | |||
| 57367b75c8 | |||
| f8e6daa084 | |||
| 35de87511d | |||
| d4a5f736b9 | |||
| 3545d988ce | |||
| f5d0b65dfa | |||
| 3d7b4a5fcc | |||
| bc44ee497f | |||
| 680974994b | |||
| b9a5d7f36f | |||
| c73efb7680 | |||
| 051b9c2102 | |||
| 8fb5ff41c3 |
17
.gitignore
vendored
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# Python local environments
|
||||||
|
backend/.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
|
||||||
|
# Local environment files
|
||||||
|
.env
|
||||||
|
backend/.env
|
||||||
|
perl_mail.txt
|
||||||
|
|
||||||
|
# Local SQLite databases
|
||||||
|
data/*.db
|
||||||
|
data/*.db-*
|
||||||
|
|
||||||
|
# Logs and temporary files
|
||||||
|
*.log
|
||||||
|
*.tmp
|
||||||
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
|
- `clinica-app/` - frontend React + Vite e build pubblica del sito
|
||||||
- **assets/** - Tutte le 7 immagini (animali + clinica)
|
- `backend/` - backend FastAPI, schema prenotazione e servizio email
|
||||||
- **SETUP_ISTRUZIONI.md** - Guida dettagliata di installazione
|
- `assets/` - materiali grafici di supporto
|
||||||
- **README.md** - Questo file
|
- `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
|
- Frontend: React 19, TypeScript, Tailwind CSS, Vite
|
||||||
# 1. Estrai il pacchetto
|
- Backend: FastAPI, Uvicorn
|
||||||
unzip clinica-veterinaria-deploy.zip
|
- Web server online: Apache
|
||||||
cd clinica-deploy
|
- Certificati: Let's Encrypt / Certbot
|
||||||
|
|
||||||
# 2. Copia le immagini
|
## Ambiente locale
|
||||||
cp -r assets/* clinica-app/client/public/images/
|
|
||||||
mkdir -p clinica-app/client/public/images
|
|
||||||
|
|
||||||
# 3. Installa e avvia
|
### Repository di lavoro
|
||||||
cd clinica-app
|
|
||||||
npm install
|
Percorso:
|
||||||
npm run build
|
|
||||||
npm start
|
```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:
|
Nota: la porta di sviluppo Vite e stata spostata a `3001`, per evitare conflitti con Gitea in locale.
|
||||||
- Requisiti di sistema
|
|
||||||
- Installazione passo-passo
|
|
||||||
- Configurazione avanzata (Nginx, PM2, ecc.)
|
|
||||||
- Personalizzazione (colori, testi, immagini)
|
|
||||||
- Troubleshooting
|
|
||||||
|
|
||||||
## 🎯 Caratteristiche
|
### Repository di deploy locale
|
||||||
|
|
||||||
✅ Homepage completa con carousel hero (7 immagini)
|
Percorso:
|
||||||
✅ 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
|
|
||||||
|
|
||||||
## 🛠️ Stack Tecnologico
|
```txt
|
||||||
|
C:\deploy\clinica_veterinaria_formiginese
|
||||||
|
```
|
||||||
|
|
||||||
- **Frontend**: React 19 + TypeScript + Tailwind CSS 4
|
Il sito locale servito da Caddy legge questa copia, non quella in `C:\devel\...`.
|
||||||
- **Backend**: Node.js + Express
|
|
||||||
- **Build Tool**: Vite
|
|
||||||
- **UI Components**: shadcn/ui
|
|
||||||
- **Animazioni**: Framer Motion
|
|
||||||
|
|
||||||
## 📞 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
|
## Build frontend
|
||||||
**Data**: 31 Marzo 2026
|
|
||||||
**Licenza**: Privato
|
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/cavia.png
Normal file
|
After Width: | Height: | Size: 2.3 MiB |
|
Before Width: | Height: | Size: 2.5 MiB After Width: | Height: | Size: 2.3 MiB |
BIN
assets/coniglio.png
Normal file
|
After Width: | Height: | Size: 2.6 MiB |
BIN
assets/logo.png
Normal file
|
After Width: | Height: | Size: 252 KiB |
BIN
assets/logo_high.png
Normal file
|
After Width: | Height: | Size: 120 KiB |
BIN
assets/logo_low.png
Normal file
|
After Width: | Height: | Size: 104 KiB |
BIN
assets/news_consulenza.jpg
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
assets/news_cucciolo.jpg
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
assets/news_gatto.jpg
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
assets/ortovet.webp
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
assets/services_cardiologia.jpg
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
assets/services_dermatologia.jpg
Normal file
|
After Width: | Height: | Size: 67 KiB |
BIN
assets/services_ecografia.jpg
Normal file
|
After Width: | Height: | Size: 148 KiB |
BIN
assets/services_endoscopia.jpg
Normal file
|
After Width: | Height: | Size: 152 KiB |
BIN
assets/services_endoscopia.webp
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
assets/services_laboratorio.jpg
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
BIN
assets/services_laparoscopia.jpg
Normal file
|
After Width: | Height: | Size: 2.4 MiB |
BIN
assets/services_oculistica.jpg
Normal file
|
After Width: | Height: | Size: 148 KiB |
BIN
assets/services_radiologia.jpg
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
BIN
assets/services_visite_cliniche.jpg
Normal file
|
After Width: | Height: | Size: 97 KiB |
27
backend/.env.example
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
APP_NAME=Clinica Veterinaria Formiginese API
|
||||||
|
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=
|
||||||
22
backend/activate_backend_env.bat
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
@echo off
|
||||||
|
setlocal
|
||||||
|
|
||||||
|
set "BACKEND_DIR=C:\devel\clinica_veterinaria_formiginese\backend"
|
||||||
|
set "VENV_ACTIVATE=%BACKEND_DIR%\.venv\Scripts\activate.bat"
|
||||||
|
|
||||||
|
if not exist "%VENV_ACTIVATE%" (
|
||||||
|
echo ERRORE: virtual environment non trovato.
|
||||||
|
echo Crea prima il venv con:
|
||||||
|
echo py -3.13 -m venv .venv
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
cd /d "%BACKEND_DIR%"
|
||||||
|
call "%VENV_ACTIVATE%"
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo Ambiente backend attivo in %BACKEND_DIR%
|
||||||
|
echo Per uscire usa: deactivate
|
||||||
|
echo.
|
||||||
|
|
||||||
|
cmd /k
|
||||||
1
backend/app/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Backend package for the Clinica Veterinaria Formiginese project.
|
||||||
1
backend/app/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# API routers package.
|
||||||
88
backend/app/api/booking.py
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
from fastapi import APIRouter, HTTPException, Request, status
|
||||||
|
|
||||||
|
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(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.",
|
||||||
|
)
|
||||||
29
backend/app/api/health.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.db.session import get_db
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api", tags=["health"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/health")
|
||||||
|
def healthcheck() -> dict[str, str]:
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"environment": settings.app_env,
|
||||||
|
"app_name": settings.app_name,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/db-health")
|
||||||
|
def database_healthcheck(db: Session = Depends(get_db)) -> dict[str, str | int]:
|
||||||
|
result = db.execute(text("SELECT 1")).scalar_one()
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"database": "connected",
|
||||||
|
"result": result,
|
||||||
|
"sqlite_file": settings.sqlite_file_path or "not-applicable",
|
||||||
|
}
|
||||||
98
backend/app/config.py
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
import os
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
PROJECT_ROOT = BASE_DIR.parent
|
||||||
|
ENV_FILE = BASE_DIR / ".env"
|
||||||
|
ROOT_ENV_FILE = PROJECT_ROOT / ".env"
|
||||||
|
|
||||||
|
load_dotenv(ROOT_ENV_FILE)
|
||||||
|
load_dotenv(ENV_FILE, override=True)
|
||||||
|
|
||||||
|
|
||||||
|
class Settings:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.app_name = os.getenv("APP_NAME", "Clinica Veterinaria Formiginese API")
|
||||||
|
self.app_env = os.getenv("APP_ENV", "development")
|
||||||
|
self.app_host = os.getenv("APP_HOST", "127.0.0.1")
|
||||||
|
self.app_port = int(os.getenv("APP_PORT", "8000"))
|
||||||
|
default_sqlite_path = (PROJECT_ROOT / "data" / "clinica.db").resolve()
|
||||||
|
self.database_url = os.getenv("DATABASE_URL", f"sqlite:///{default_sqlite_path.as_posix()}")
|
||||||
|
self.smtp_host = os.getenv("SMTP_HOST", "smtp.gmail.com")
|
||||||
|
self.smtp_port = int(os.getenv("SMTP_PORT", "587"))
|
||||||
|
self.smtp_username = os.getenv("SMTP_USERNAME", "")
|
||||||
|
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:
|
||||||
|
sqlite_prefix = "sqlite:///"
|
||||||
|
if self.database_url.startswith(sqlite_prefix):
|
||||||
|
return self.database_url.removeprefix(sqlite_prefix)
|
||||||
|
return None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def booking_mail_enabled(self) -> bool:
|
||||||
|
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()
|
||||||
1
backend/app/db/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Database package.
|
||||||
5
backend/app/db/base.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
24
backend/app/db/init_db.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import hashlib
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.db.base import Base
|
||||||
|
from app.db.session import SessionLocal, engine
|
||||||
|
from app.models import TestUser
|
||||||
|
|
||||||
|
|
||||||
|
def init_db() -> None:
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
seed_test_data()
|
||||||
|
|
||||||
|
|
||||||
|
def seed_test_data() -> None:
|
||||||
|
with SessionLocal() as db:
|
||||||
|
existing_user = db.scalar(select(TestUser).where(TestUser.username == "admin_test"))
|
||||||
|
if existing_user:
|
||||||
|
return
|
||||||
|
|
||||||
|
password_hash = hashlib.sha256("change_me_123".encode("utf-8")).hexdigest()
|
||||||
|
db.add(TestUser(username="admin_test", password_hash=password_hash))
|
||||||
|
db.commit()
|
||||||
25
backend/app/db/session.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
from collections.abc import Generator
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
if settings.sqlite_file_path:
|
||||||
|
Path(settings.sqlite_file_path).parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
engine = create_engine(
|
||||||
|
settings.database_url,
|
||||||
|
connect_args={"check_same_thread": False} if settings.database_url.startswith("sqlite") else {},
|
||||||
|
)
|
||||||
|
|
||||||
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
|
|
||||||
|
|
||||||
|
def get_db() -> Generator[Session, None, None]:
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
13
backend/app/main.py
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
from fastapi import FastAPI
|
||||||
|
|
||||||
|
from app.api.booking import router as booking_router
|
||||||
|
from app.api.health import router as health_router
|
||||||
|
from app.config import settings
|
||||||
|
from app.db.init_db import init_db
|
||||||
|
|
||||||
|
|
||||||
|
init_db()
|
||||||
|
|
||||||
|
app = FastAPI(title=settings.app_name)
|
||||||
|
app.include_router(health_router)
|
||||||
|
app.include_router(booking_router)
|
||||||
3
backend/app/models/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from app.models.test_user import TestUser
|
||||||
|
|
||||||
|
__all__ = ["TestUser"]
|
||||||
12
backend/app/models/test_user.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
from sqlalchemy import String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class TestUser(Base):
|
||||||
|
__tablename__ = "test_users"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
|
username: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, index=True)
|
||||||
|
password_hash: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||||
1
backend/app/repositories/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Data access layer package.
|
||||||
1
backend/app/schemas/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Pydantic schemas package.
|
||||||
27
backend/app/schemas/booking.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
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
|
||||||
1
backend/app/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Business services package.
|
||||||
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]
|
||||||
183
backend/app/services/mailer.py
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
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>{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;">
|
||||||
|
Questa richiesta <strong>non costituisce conferma automatica dell'appuntamento</strong> e richiede
|
||||||
|
presa in carico da parte dello staff.
|
||||||
|
</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _build_booking_text(payload: BookingRequestCreate) -> str:
|
||||||
|
submitted_at = datetime.now().strftime("%d/%m/%Y %H:%M")
|
||||||
|
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"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."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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.")
|
||||||
|
|
||||||
|
message = EmailMessage()
|
||||||
|
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")
|
||||||
|
|
||||||
|
with smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=30) as server:
|
||||||
|
if settings.smtp_use_tls:
|
||||||
|
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]}")
|
||||||
7
backend/requirements.txt
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
fastapi
|
||||||
|
uvicorn[standard]
|
||||||
|
sqlalchemy
|
||||||
|
pydantic
|
||||||
|
python-dotenv
|
||||||
|
aiosqlite
|
||||||
|
pydantic[email]
|
||||||
17
backend/run_backend.bat
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
@echo off
|
||||||
|
setlocal
|
||||||
|
|
||||||
|
set "BACKEND_DIR=C:\devel\clinica_veterinaria_formiginese\backend"
|
||||||
|
set "PYTHON_EXE=%BACKEND_DIR%\.venv\Scripts\python.exe"
|
||||||
|
|
||||||
|
if not exist "%PYTHON_EXE%" (
|
||||||
|
echo ERRORE: python del virtual environment non trovato.
|
||||||
|
echo Crea prima il venv con:
|
||||||
|
echo py -3.13 -m venv .venv
|
||||||
|
echo Poi installa le dipendenze con:
|
||||||
|
echo pip install -r requirements.txt
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
cd /d "%BACKEND_DIR%"
|
||||||
|
"%PYTHON_EXE%" -m uvicorn app.main:app --host 127.0.0.1 --port 8000 --reload
|
||||||
@@ -12,9 +12,5 @@
|
|||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
<script
|
|
||||||
defer
|
|
||||||
src="%VITE_ANALYTICS_ENDPOINT%/umami"
|
|
||||||
data-website-id="%VITE_ANALYTICS_WEBSITE_ID%"></script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
BIN
clinica-app/client/public/images/cavia.png
Normal file
|
After Width: | Height: | Size: 2.3 MiB |
|
Before Width: | Height: | Size: 2.5 MiB After Width: | Height: | Size: 2.3 MiB |
BIN
clinica-app/client/public/images/coniglio.png
Normal file
|
After Width: | Height: | Size: 2.6 MiB |
BIN
clinica-app/client/public/images/logo.png
Normal file
|
After Width: | Height: | Size: 252 KiB |
BIN
clinica-app/client/public/images/logo_high.png
Normal file
|
After Width: | Height: | Size: 120 KiB |
BIN
clinica-app/client/public/images/logo_low.png
Normal file
|
After Width: | Height: | Size: 104 KiB |
BIN
clinica-app/client/public/images/news_consulenza.jpg
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
clinica-app/client/public/images/news_cucciolo.jpg
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
clinica-app/client/public/images/news_gatto.jpg
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
clinica-app/client/public/images/ortovet.webp
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
clinica-app/client/public/images/services_cardiologia.jpg
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
clinica-app/client/public/images/services_dermatologia.jpg
Normal file
|
After Width: | Height: | Size: 67 KiB |
BIN
clinica-app/client/public/images/services_ecografia.jpg
Normal file
|
After Width: | Height: | Size: 148 KiB |
BIN
clinica-app/client/public/images/services_endoscopia.jpg
Normal file
|
After Width: | Height: | Size: 152 KiB |
BIN
clinica-app/client/public/images/services_endoscopia.webp
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
clinica-app/client/public/images/services_laboratorio.jpg
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
BIN
clinica-app/client/public/images/services_laparoscopia.jpg
Normal file
|
After Width: | Height: | Size: 2.4 MiB |
BIN
clinica-app/client/public/images/services_oculistica.jpg
Normal file
|
After Width: | Height: | Size: 148 KiB |
BIN
clinica-app/client/public/images/services_radiologia.jpg
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
BIN
clinica-app/client/public/images/services_visite_cliniche.jpg
Normal file
|
After Width: | Height: | Size: 97 KiB |
@@ -1,6 +1,9 @@
|
|||||||
import { Toaster } from "@/components/ui/sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
|
import CookiePolicyPage from "@/pages/CookiePolicyPage";
|
||||||
|
import LegalNotesPage from "@/pages/LegalNotesPage";
|
||||||
import NotFound from "@/pages/NotFound";
|
import NotFound from "@/pages/NotFound";
|
||||||
|
import PrivacyPolicyPage from "@/pages/PrivacyPolicyPage";
|
||||||
import { Route, Switch } from "wouter";
|
import { Route, Switch } from "wouter";
|
||||||
import ErrorBoundary from "./components/ErrorBoundary";
|
import ErrorBoundary from "./components/ErrorBoundary";
|
||||||
import { ThemeProvider } from "./contexts/ThemeContext";
|
import { ThemeProvider } from "./contexts/ThemeContext";
|
||||||
@@ -10,6 +13,9 @@ function Router() {
|
|||||||
return (
|
return (
|
||||||
<Switch>
|
<Switch>
|
||||||
<Route path={"/"} component={Home} />
|
<Route path={"/"} component={Home} />
|
||||||
|
<Route path={"/privacy-policy"} component={PrivacyPolicyPage} />
|
||||||
|
<Route path={"/cookie-policy"} component={CookiePolicyPage} />
|
||||||
|
<Route path={"/note-legali"} component={LegalNotesPage} />
|
||||||
<Route path={"/404"} component={NotFound} />
|
<Route path={"/404"} component={NotFound} />
|
||||||
<Route component={NotFound} />
|
<Route component={NotFound} />
|
||||||
</Switch>
|
</Switch>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { CheckCircle2, Award, Heart } from "lucide-react";
|
|||||||
|
|
||||||
const values = [
|
const values = [
|
||||||
{ icon: Heart, text: "Cura e attenzione per ogni paziente" },
|
{ 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" },
|
{ 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">
|
<section id="chi-siamo" className="py-20 md:py-28 bg-white overflow-hidden">
|
||||||
<div className="container">
|
<div className="container">
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-16 items-center">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-16 items-center">
|
||||||
{/* Colonna testo */}
|
|
||||||
<motion.div
|
<motion.div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
initial={{ opacity: 0, x: -40 }}
|
initial={{ opacity: 0, x: -40 }}
|
||||||
@@ -46,14 +45,14 @@ export default function AboutSection() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Una clinica veterinaria{" "}
|
Una clinica veterinaria{" "}
|
||||||
<span className="italic">al servizio della vita</span>
|
<span className="italic">per la salute e il benessere animale</span>
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<p className="text-gray-600 leading-relaxed mb-4 text-base">
|
<p className="text-gray-600 leading-relaxed mb-4 text-base">
|
||||||
La Clinica Veterinaria Formiginese è un punto di riferimento per la cura degli animali
|
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
|
domestici nel territorio di Formigine e della provincia di Modena. Fondata con la
|
||||||
missione di offrire cure veterinarie di eccellenza, la nostra struttura riunisce
|
missione di offrire cure veterinarie di eccellenza, la nostra struttura riunisce
|
||||||
sei professionisti specializzati, ciascuno con competenze specifiche.
|
professionisti specializzati, ciascuno con competenze specifiche.
|
||||||
</p>
|
</p>
|
||||||
<p className="text-gray-600 leading-relaxed mb-8 text-base">
|
<p className="text-gray-600 leading-relaxed mb-8 text-base">
|
||||||
Crediamo che ogni animale meriti la migliore assistenza medica possibile. Per questo
|
Crediamo che ogni animale meriti la migliore assistenza medica possibile. Per questo
|
||||||
@@ -61,7 +60,6 @@ export default function AboutSection() {
|
|||||||
a proprio agio sia i pazienti che i loro proprietari.
|
a proprio agio sia i pazienti che i loro proprietari.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Valori */}
|
|
||||||
<div className="space-y-4 mb-8">
|
<div className="space-y-4 mb-8">
|
||||||
{values.map((item) => (
|
{values.map((item) => (
|
||||||
<div key={item.text} className="flex items-start gap-3">
|
<div key={item.text} className="flex items-start gap-3">
|
||||||
@@ -73,52 +71,40 @@ export default function AboutSection() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Indirizzo */}
|
|
||||||
<div className="bg-[#F5F0E8] rounded-xl p-5 border-l-4 border-[#1B4F72]">
|
<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-[#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">
|
<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
|
clinicaveterinariaformiginese@gmail.com
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Colonna immagine */}
|
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, x: 40 }}
|
initial={{ opacity: 0, x: 40 }}
|
||||||
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 }}
|
||||||
className="relative"
|
className="relative"
|
||||||
>
|
>
|
||||||
{/* Immagine principale */}
|
|
||||||
<div className="relative rounded-2xl overflow-hidden shadow-2xl">
|
<div className="relative rounded-2xl overflow-hidden shadow-2xl">
|
||||||
<img
|
<img
|
||||||
src="https://d2xsxph8kpxj0f.cloudfront.net/310519663483796440/DLUbzKbSnCG3dLeob4sLeC/hero_dog_cat_30af7cf4.jpg"
|
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"
|
className="w-full h-[480px] object-cover"
|
||||||
/>
|
/>
|
||||||
{/* Overlay leggero */}
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-[#1B4F72]/30 to-transparent" />
|
<div className="absolute inset-0 bg-gradient-to-t from-[#1B4F72]/30 to-transparent" />
|
||||||
</div>
|
</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 -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" />
|
<div className="absolute -bottom-8 right-8 w-16 h-16 rounded-full bg-[#1B4F72]/10 -z-10" />
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
@@ -1,65 +1,76 @@
|
|||||||
/*
|
/*
|
||||||
* DESIGN: "Clinical Warmth"
|
* DESIGN: "Clinical Warmth"
|
||||||
* Sezione registrazione/login: tabs con form eleganti
|
* Sezione registrazione/login visibile ma non ancora attiva.
|
||||||
* Sfondo bianco, accenti blu petrolio e verde acqua
|
* Gli elementi restano leggibili, ma ogni interazione mostra
|
||||||
|
* il messaggio "Servizio in corso di attivazione".
|
||||||
*/
|
*/
|
||||||
import { useState } from "react";
|
import { motion, useInView } from "framer-motion";
|
||||||
import { motion } from "framer-motion";
|
|
||||||
import { useInView } from "framer-motion";
|
|
||||||
import { useRef } from "react";
|
import { useRef } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { User, Mail, Lock, Eye, EyeOff, CheckCircle2, PawPrint } from "lucide-react";
|
import { User, Mail, Lock, Eye, CheckCircle2, PawPrint, Clock3 } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
const benefits = [
|
||||||
|
"Storico visite e referti digitali",
|
||||||
|
"Promemoria vaccinazioni automatici",
|
||||||
|
"Prenotazioni online prioritarie",
|
||||||
|
"Comunicazioni dirette con il veterinario",
|
||||||
|
"Gestione di più animali domestici",
|
||||||
|
];
|
||||||
|
|
||||||
|
function showActivationToast() {
|
||||||
|
toast.info("Servizio in corso di attivazione", {
|
||||||
|
description: "L'area riservata sarà disponibile a breve.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function DisabledInput({
|
||||||
|
icon,
|
||||||
|
type,
|
||||||
|
placeholder,
|
||||||
|
}: {
|
||||||
|
icon: React.ComponentType<{ size?: number; className?: string }>;
|
||||||
|
type: string;
|
||||||
|
placeholder: string;
|
||||||
|
}) {
|
||||||
|
const Icon = icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<Icon size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
placeholder={placeholder}
|
||||||
|
disabled
|
||||||
|
className="w-full cursor-not-allowed rounded-lg border border-gray-200 bg-gray-50/90 py-2.5 pl-9 pr-3 text-sm text-gray-400 opacity-90"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function AuthSection() {
|
export default function AuthSection() {
|
||||||
const ref = useRef(null);
|
const ref = useRef(null);
|
||||||
const isInView = useInView(ref, { once: true, margin: "-80px" });
|
const isInView = useInView(ref, { once: true, margin: "-80px" });
|
||||||
const [tab, setTab] = useState<"login" | "register">("register");
|
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
|
||||||
const [registered, setRegistered] = useState(false);
|
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (tab === "register") {
|
|
||||||
setRegistered(true);
|
|
||||||
toast.success("Registrazione completata!", {
|
|
||||||
description: "Benvenuto nella Clinica Veterinaria Formiginese.",
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
toast.success("Accesso effettuato!", {
|
|
||||||
description: "Bentornato nella tua area personale.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const benefits = [
|
|
||||||
"Storico visite e referti digitali",
|
|
||||||
"Promemoria vaccinazioni automatici",
|
|
||||||
"Prenotazioni online prioritarie",
|
|
||||||
"Comunicazioni dirette con il veterinario",
|
|
||||||
"Gestione di più animali domestici",
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section id="registrazione" className="py-20 md:py-28 bg-white">
|
<section id="registrazione" className="bg-white py-20 md:py-28">
|
||||||
<div className="container">
|
<div className="container">
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-16 items-center">
|
<div className="grid grid-cols-1 items-center gap-16 lg:grid-cols-2">
|
||||||
{/* Colonna sinistra: benefici */}
|
|
||||||
<motion.div
|
<motion.div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
initial={{ opacity: 0, x: -30 }}
|
initial={{ opacity: 0, x: -30 }}
|
||||||
animate={isInView ? { opacity: 1, x: 0 } : {}}
|
animate={isInView ? { opacity: 1, x: 0 } : {}}
|
||||||
transition={{ duration: 0.8 }}
|
transition={{ duration: 0.8 }}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3 mb-4">
|
<div className="mb-4 flex items-center gap-3">
|
||||||
<div className="w-12 h-0.5 bg-[#4ECDC4]" />
|
<div className="h-0.5 w-12 bg-[#4ECDC4]" />
|
||||||
<span className="text-[#4ECDC4] text-sm font-semibold uppercase tracking-widest">
|
<span className="text-sm font-semibold uppercase tracking-widest text-[#4ECDC4]">
|
||||||
Area Personale
|
Area Personale
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2
|
<h2
|
||||||
className="text-[#1B4F72] mb-6"
|
className="mb-6 text-[#1B4F72]"
|
||||||
style={{
|
style={{
|
||||||
fontFamily: "'Cormorant Garamond', serif",
|
fontFamily: "'Cormorant Garamond', serif",
|
||||||
fontSize: "clamp(2rem, 4vw, 3rem)",
|
fontSize: "clamp(2rem, 4vw, 3rem)",
|
||||||
@@ -67,210 +78,148 @@ export default function AuthSection() {
|
|||||||
lineHeight: 1.2,
|
lineHeight: 1.2,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Registrati e gestisci{" "}
|
Accedi e gestisci{" "}
|
||||||
<span className="italic">la salute del tuo animale</span>
|
<span className="italic">la salute del tuo animale</span>
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<p className="text-gray-600 leading-relaxed mb-8 text-base">
|
<p className="mb-8 text-base leading-relaxed text-gray-600">
|
||||||
Crea il tuo profilo personale per accedere a tutti i servizi digitali della clinica.
|
L'area riservata e la registrazione online sono in fase di attivazione.
|
||||||
Tieni traccia della storia clinica del tuo animale, ricevi promemoria e prenota
|
Qui i clienti potranno gestire i propri dati, consultare lo storico e accedere
|
||||||
le visite in pochi click.
|
ai servizi digitali della clinica in uno spazio personale dedicato.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Benefits list */}
|
<div className="mb-8 space-y-3">
|
||||||
<div className="space-y-3 mb-8">
|
|
||||||
{benefits.map((benefit) => (
|
{benefits.map((benefit) => (
|
||||||
<div key={benefit} className="flex items-center gap-3">
|
<div key={benefit} className="flex items-center gap-3">
|
||||||
<div className="w-6 h-6 rounded-full bg-[#4ECDC4]/15 flex items-center justify-center flex-shrink-0">
|
<div className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-[#4ECDC4]/15">
|
||||||
<CheckCircle2 size={14} className="text-[#4ECDC4]" />
|
<CheckCircle2 size={14} className="text-[#4ECDC4]" />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-gray-700 text-sm">{benefit}</span>
|
<span className="text-sm text-gray-700">{benefit}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Decorazione */}
|
<div className="flex items-center gap-4 rounded-2xl bg-[#F5F0E8] p-6">
|
||||||
<div className="bg-[#F5F0E8] rounded-2xl p-6 flex items-center gap-4">
|
<div className="flex h-14 w-14 flex-shrink-0 items-center justify-center rounded-full bg-[#1B4F72]">
|
||||||
<div className="w-14 h-14 rounded-full bg-[#1B4F72] flex items-center justify-center flex-shrink-0">
|
|
||||||
<PawPrint size={24} className="text-[#4ECDC4]" />
|
<PawPrint size={24} className="text-[#4ECDC4]" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-[#1B4F72] font-semibold text-sm">Già più di 500 famiglie</p>
|
<p className="text-sm font-semibold text-[#1B4F72]">Servizio in attivazione</p>
|
||||||
<p className="text-gray-600 text-xs mt-0.5">
|
<p className="mt-0.5 text-xs text-gray-600">
|
||||||
si affidano alla nostra clinica per la cura dei loro animali
|
La clinica sta completando la configurazione dell'area riservata per offrirti
|
||||||
|
un accesso semplice e sicuro.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Colonna destra: form */}
|
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, x: 30 }}
|
initial={{ opacity: 0, x: 30 }}
|
||||||
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 }}
|
||||||
>
|
>
|
||||||
{registered ? (
|
<div className="relative overflow-hidden rounded-2xl border border-gray-100 bg-white shadow-lg">
|
||||||
<div className="bg-[#F5F0E8] rounded-2xl p-8 text-center shadow-sm">
|
<div className="flex border-b border-gray-100">
|
||||||
<div className="w-16 h-16 rounded-full bg-[#4ECDC4]/15 flex items-center justify-center mx-auto mb-4">
|
<button
|
||||||
<CheckCircle2 size={32} className="text-[#4ECDC4]" />
|
type="button"
|
||||||
</div>
|
disabled
|
||||||
<h3
|
className="flex-1 cursor-not-allowed border-b-2 border-[#4ECDC4] bg-[#F5F0E8]/50 py-4 text-sm font-semibold text-[#1B4F72]"
|
||||||
className="text-[#1B4F72] mb-2"
|
|
||||||
style={{ fontFamily: "'Cormorant Garamond', serif", fontSize: "1.8rem" }}
|
|
||||||
>
|
>
|
||||||
Benvenuto!
|
Registrati
|
||||||
</h3>
|
</button>
|
||||||
<p className="text-gray-600 text-sm mb-6">
|
<button
|
||||||
La tua registrazione è avvenuta con successo. Ora puoi accedere a tutti i servizi
|
type="button"
|
||||||
della tua area personale.
|
disabled
|
||||||
</p>
|
className="flex-1 cursor-not-allowed py-4 text-sm font-semibold text-gray-400"
|
||||||
<Button
|
|
||||||
className="bg-[#1B4F72] hover:bg-[#163d5a] text-white"
|
|
||||||
onClick={() => setRegistered(false)}
|
|
||||||
>
|
>
|
||||||
Vai all'area personale
|
Accedi
|
||||||
</Button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<div className="bg-white rounded-2xl shadow-lg border border-gray-100 overflow-hidden">
|
<div className="p-6 md:p-8">
|
||||||
{/* Tabs */}
|
<div className="mb-5 flex items-center gap-3 rounded-xl border border-[#E4D7C6] bg-[#FFF9F1] px-4 py-3">
|
||||||
<div className="flex border-b border-gray-100">
|
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-[#A95F3A]/12">
|
||||||
{(["register", "login"] as const).map((t) => (
|
<Clock3 size={18} className="text-[#A95F3A]" />
|
||||||
<button
|
</div>
|
||||||
key={t}
|
<div>
|
||||||
onClick={() => setTab(t)}
|
<p className="text-sm font-semibold text-[#1B4F72]">Servizio in corso di attivazione</p>
|
||||||
className={`flex-1 py-4 text-sm font-semibold transition-all duration-200 ${
|
<p className="text-xs text-gray-600">
|
||||||
tab === t
|
Registrazione e accesso saranno disponibili a breve.
|
||||||
? "text-[#1B4F72] border-b-2 border-[#4ECDC4] bg-[#F5F0E8]/50"
|
</p>
|
||||||
: "text-gray-400 hover:text-gray-600"
|
</div>
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{t === "register" ? "Registrati" : "Accedi"}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="p-6 md:p-8 space-y-4">
|
<div className="space-y-4 opacity-75">
|
||||||
{tab === "register" && (
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div>
|
||||||
<div>
|
<label className="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-gray-600">
|
||||||
<label className="text-xs font-semibold text-gray-600 uppercase tracking-wide block mb-1.5">
|
Nome
|
||||||
Nome
|
</label>
|
||||||
</label>
|
<DisabledInput icon={User} type="text" placeholder="Mario" />
|
||||||
<div className="relative">
|
|
||||||
<User size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
required
|
|
||||||
placeholder="Mario"
|
|
||||||
className="w-full pl-9 pr-3 py-2.5 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-[#4ECDC4] focus:border-transparent transition-all"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-xs font-semibold text-gray-600 uppercase tracking-wide block mb-1.5">
|
|
||||||
Cognome
|
|
||||||
</label>
|
|
||||||
<div className="relative">
|
|
||||||
<User size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
required
|
|
||||||
placeholder="Rossi"
|
|
||||||
className="w-full pl-9 pr-3 py-2.5 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-[#4ECDC4] focus:border-transparent transition-all"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div>
|
||||||
|
<label className="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-gray-600">
|
||||||
<div>
|
Cognome
|
||||||
<label className="text-xs font-semibold text-gray-600 uppercase tracking-wide block mb-1.5">
|
</label>
|
||||||
Email
|
<DisabledInput icon={User} type="text" placeholder="Rossi" />
|
||||||
</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="mario.rossi@email.it"
|
|
||||||
className="w-full pl-9 pr-3 py-2.5 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-[#4ECDC4] focus:border-transparent transition-all"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs font-semibold text-gray-600 uppercase tracking-wide block mb-1.5">
|
<label className="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-gray-600">
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<DisabledInput icon={Mail} type="email" placeholder="mario.rossi@email.it" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-gray-600">
|
||||||
Password
|
Password
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Lock size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
<Lock size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||||
<input
|
<input
|
||||||
type={showPassword ? "text" : "password"}
|
type="password"
|
||||||
required
|
|
||||||
placeholder="••••••••"
|
placeholder="••••••••"
|
||||||
className="w-full pl-9 pr-10 py-2.5 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-[#4ECDC4] focus:border-transparent transition-all"
|
disabled
|
||||||
|
className="w-full cursor-not-allowed rounded-lg border border-gray-200 bg-gray-50/90 py-2.5 pl-9 pr-10 text-sm text-gray-400 opacity-90"
|
||||||
/>
|
/>
|
||||||
<button
|
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400">
|
||||||
type="button"
|
<Eye size={15} />
|
||||||
onClick={() => setShowPassword(!showPassword)}
|
</span>
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
|
||||||
>
|
|
||||||
{showPassword ? <EyeOff size={15} /> : <Eye size={15} />}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{tab === "register" && (
|
<div className="flex items-start gap-2 pt-1">
|
||||||
<div className="flex items-start gap-2 pt-1">
|
<input
|
||||||
<input
|
type="checkbox"
|
||||||
type="checkbox"
|
disabled
|
||||||
required
|
className="mt-0.5 cursor-not-allowed accent-[#4ECDC4]"
|
||||||
id="privacy"
|
/>
|
||||||
className="mt-0.5 accent-[#4ECDC4]"
|
<p className="text-xs leading-relaxed text-gray-500">
|
||||||
/>
|
Accetto la Privacy Policy e i Termini di Servizio.
|
||||||
<label htmlFor="privacy" className="text-xs text-gray-500 leading-relaxed">
|
</p>
|
||||||
Accetto la{" "}
|
</div>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="text-[#4ECDC4] hover:underline"
|
|
||||||
onClick={() => toast.info("Privacy policy in arrivo")}
|
|
||||||
>
|
|
||||||
Privacy Policy
|
|
||||||
</button>{" "}
|
|
||||||
e i{" "}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="text-[#4ECDC4] hover:underline"
|
|
||||||
onClick={() => toast.info("Termini di servizio in arrivo")}
|
|
||||||
>
|
|
||||||
Termini di Servizio
|
|
||||||
</button>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="button"
|
||||||
className="w-full bg-[#1B4F72] hover:bg-[#163d5a] text-white font-bold py-3 text-base transition-all duration-300"
|
disabled
|
||||||
|
className="w-full cursor-not-allowed bg-[#1B4F72] py-3 text-base font-bold text-white opacity-70"
|
||||||
>
|
>
|
||||||
{tab === "register" ? "Crea il tuo account" : "Accedi all'area personale"}
|
Crea il tuo account
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{tab === "login" && (
|
<p className="text-center text-xs text-gray-400">Password dimenticata?</p>
|
||||||
<p className="text-center text-xs text-gray-400">
|
</div>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="text-[#4ECDC4] hover:underline"
|
|
||||||
onClick={() => toast.info("Recupero password in arrivo")}
|
|
||||||
>
|
|
||||||
Password dimenticata?
|
|
||||||
</button>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Servizio in corso di attivazione"
|
||||||
|
onClick={showActivationToast}
|
||||||
|
className="absolute inset-0 z-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,68 +1,218 @@
|
|||||||
/*
|
/*
|
||||||
* DESIGN: "Clinical Warmth"
|
* DESIGN: "Clinical Warmth"
|
||||||
* Sezione prenotazione: form elegante su sfondo blu petrolio
|
* Sezione prenotazione: form to mail con conferma non vincolante.
|
||||||
* Layout: testo a sinistra + form a destra
|
|
||||||
*/
|
*/
|
||||||
import { useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { motion } from "framer-motion";
|
import { motion, useInView } from "framer-motion";
|
||||||
import { useInView } from "framer-motion";
|
import {
|
||||||
import { useRef } from "react";
|
Calendar,
|
||||||
import { Button } from "@/components/ui/button";
|
CheckCircle2,
|
||||||
import { Calendar, Clock, User, Phone, PawPrint, CheckCircle2 } from "lucide-react";
|
Clock,
|
||||||
|
Mail,
|
||||||
|
PawPrint,
|
||||||
|
Phone,
|
||||||
|
ShieldCheck,
|
||||||
|
User,
|
||||||
|
} from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
const bookingFormPaused = false;
|
||||||
|
|
||||||
const services = [
|
const services = [
|
||||||
"Visita generale",
|
"Visita clinica generale",
|
||||||
"Radiologia / Ecografia",
|
"Ecografia",
|
||||||
"Chirurgia (consulenza)",
|
"Radiologia",
|
||||||
"Laboratorio analisi",
|
"Laboratorio",
|
||||||
"Vaccinazione",
|
"Vaccinazione",
|
||||||
"Dermatologia",
|
|
||||||
"Odontoiatria",
|
|
||||||
"Oncologia",
|
"Oncologia",
|
||||||
|
"Dermatologia",
|
||||||
|
"Oculistica",
|
||||||
|
"Nutrizione",
|
||||||
|
"Ortopedia",
|
||||||
|
"Endoscopia",
|
||||||
|
"Laparoscopia",
|
||||||
];
|
];
|
||||||
|
|
||||||
const timeSlots = [
|
const doctors = [
|
||||||
"09:00", "09:30", "10:00", "10:30", "11:00", "11:30",
|
"Dott. Paolo Parmeggiani",
|
||||||
"14:30", "15:00", "15:30", "16:00", "16:30", "17:00",
|
"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() {
|
export default function BookingSection() {
|
||||||
const ref = useRef(null);
|
const ref = useRef(null);
|
||||||
const isInView = useInView(ref, { once: true, margin: "-80px" });
|
const isInView = useInView(ref, { once: true, margin: "-80px" });
|
||||||
const [submitted, setSubmitted] = useState(false);
|
const [submitted, setSubmitted] = useState(false);
|
||||||
const [form, setForm] = useState({
|
const [submitting, setSubmitting] = useState(false);
|
||||||
name: "",
|
const [submittedName, setSubmittedName] = useState("");
|
||||||
phone: "",
|
const [form, setForm] = useState<BookingFormState>(initialForm);
|
||||||
petName: "",
|
const [captcha, setCaptcha] = useState<CaptchaChallenge | null>(null);
|
||||||
petType: "cane",
|
const [captchaLoading, setCaptchaLoading] = useState(false);
|
||||||
service: "",
|
|
||||||
date: "",
|
|
||||||
time: "",
|
|
||||||
notes: "",
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
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();
|
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");
|
toast.error("Compila tutti i campi obbligatori");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSubmitted(true);
|
|
||||||
toast.success("Richiesta inviata!", {
|
if (!captcha || !form.captchaAnswer) {
|
||||||
description: "Ti contatteremo entro 24 ore per confermare l'appuntamento.",
|
toast.error("Completa la verifica anti-spam");
|
||||||
});
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setSubmitting(true);
|
||||||
|
|
||||||
|
const response = await fetch("/api/booking-request", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
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,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorPayload = await response.json().catch(() => null);
|
||||||
|
throw new Error(errorPayload?.detail || "Invio non riuscito");
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await response.json();
|
||||||
|
|
||||||
|
setSubmittedName(form.name);
|
||||||
|
setSubmitted(true);
|
||||||
|
setForm(initialForm);
|
||||||
|
loadCaptcha();
|
||||||
|
toast.success("Richiesta inviata", {
|
||||||
|
description: payload.message,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
loadCaptcha();
|
||||||
|
|
||||||
|
const message =
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Si e verificato un problema durante l'invio.";
|
||||||
|
|
||||||
|
toast.error("Invio non riuscito", {
|
||||||
|
description: message,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section id="prenotazione" className="py-20 md:py-28 bg-[#1B4F72] relative overflow-hidden">
|
<section id="prenotazione" className="relative overflow-hidden bg-[#1B4F72] py-20 md:py-28">
|
||||||
{/* Decorazioni di sfondo */}
|
<div className="absolute right-0 top-0 h-96 w-96 translate-x-1/2 -translate-y-1/2 rounded-full bg-white/5" />
|
||||||
<div className="absolute top-0 right-0 w-96 h-96 rounded-full bg-white/5 -translate-y-1/2 translate-x-1/2" />
|
<div className="absolute bottom-0 left-0 h-64 w-64 -translate-x-1/2 translate-y-1/2 rounded-full bg-[#4ECDC4]/10" />
|
||||||
<div className="absolute bottom-0 left-0 w-64 h-64 rounded-full bg-[#4ECDC4]/10 translate-y-1/2 -translate-x-1/2" />
|
|
||||||
|
|
||||||
<div className="container relative z-10">
|
<div className="container relative z-10">
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-16 items-start">
|
<div className="grid grid-cols-1 items-start gap-16 lg:grid-cols-2">
|
||||||
{/* Colonna sinistra: testo */}
|
|
||||||
<motion.div
|
<motion.div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
initial={{ opacity: 0, x: -30 }}
|
initial={{ opacity: 0, x: -30 }}
|
||||||
@@ -70,15 +220,15 @@ export default function BookingSection() {
|
|||||||
transition={{ duration: 0.8 }}
|
transition={{ duration: 0.8 }}
|
||||||
className="text-white"
|
className="text-white"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3 mb-4">
|
<div className="mb-4 flex items-center gap-3">
|
||||||
<div className="w-12 h-0.5 bg-[#4ECDC4]" />
|
<div className="h-0.5 w-12 bg-[#4ECDC4]" />
|
||||||
<span className="text-[#4ECDC4] text-sm font-semibold uppercase tracking-widest">
|
<span className="text-sm font-semibold uppercase tracking-widest text-[#4ECDC4]">
|
||||||
Prenotazioni
|
Prenotazioni
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2
|
<h2
|
||||||
className="text-white mb-6"
|
className="mb-6 text-white"
|
||||||
style={{
|
style={{
|
||||||
fontFamily: "'Cormorant Garamond', serif",
|
fontFamily: "'Cormorant Garamond', serif",
|
||||||
fontSize: "clamp(2rem, 4vw, 3rem)",
|
fontSize: "clamp(2rem, 4vw, 3rem)",
|
||||||
@@ -86,93 +236,160 @@ export default function BookingSection() {
|
|||||||
lineHeight: 1.2,
|
lineHeight: 1.2,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Prenota la tua visita{" "}
|
Richiedi una visita <span className="italic text-[#4ECDC4]">online</span>
|
||||||
<span className="italic text-[#4ECDC4]">online</span>
|
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<p className="text-white/80 leading-relaxed mb-8 text-base">
|
<p className="mb-8 text-base leading-relaxed text-white/80">
|
||||||
Compila il modulo per richiedere un appuntamento. Ti contatteremo entro 24 ore
|
Compila il modulo per inviare una richiesta di appuntamento. La richiesta
|
||||||
per confermare la data e l'orario. Per urgenze, chiama direttamente il numero
|
non è vincolante e dovrà essere confermata dallo staff della clinica, che ti
|
||||||
dedicato disponibile 24 ore su 24.
|
ricontatterà il prima possibile.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Info box urgenze */}
|
<div className="mb-8 rounded-xl border border-white/20 bg-white/10 p-5 backdrop-blur-sm">
|
||||||
<div className="bg-white/10 backdrop-blur-sm rounded-xl p-5 border border-white/20 mb-8">
|
<p className="mb-2 text-sm font-semibold uppercase tracking-wide text-[#4ECDC4]">
|
||||||
<p className="text-[#4ECDC4] font-semibold text-sm mb-2 uppercase tracking-wide">
|
Urgenze
|
||||||
Urgenze 24h
|
|
||||||
</p>
|
</p>
|
||||||
<a
|
<a
|
||||||
href="tel:3205322439"
|
href="tel:3205322439"
|
||||||
className="text-white text-2xl font-bold hover:text-[#4ECDC4] transition-colors"
|
className="text-2xl font-bold text-white transition-colors hover:text-[#4ECDC4]"
|
||||||
style={{ fontFamily: "'Cormorant Garamond', serif" }}
|
style={{ fontFamily: "'Cormorant Garamond', serif" }}
|
||||||
>
|
>
|
||||||
320 532.24.39
|
320 532.24.39
|
||||||
</a>
|
</a>
|
||||||
<p className="text-white/60 text-xs mt-1">Disponibile 7 giorni su 7</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Orari */}
|
<div className="mb-8 rounded-2xl border border-[#4ECDC4]/30 bg-[#14384F] p-5">
|
||||||
<div className="space-y-3">
|
<div className="flex items-start gap-3">
|
||||||
<p className="text-white/70 text-sm font-semibold uppercase tracking-wide mb-3">
|
<div className="mt-0.5 flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-[#4ECDC4]/15">
|
||||||
Orari di apertura
|
<ShieldCheck size={18} className="text-[#4ECDC4]" />
|
||||||
</p>
|
|
||||||
{[
|
|
||||||
{ days: "Lunedì — Venerdì", 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 justify-between items-center text-sm">
|
|
||||||
<span className="text-white/70">{slot.days}</span>
|
|
||||||
<span className="text-white font-medium">{slot.hours}</span>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-white">Richiesta non vincolante</p>
|
||||||
|
<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 periodo e fascia oraria.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Colonna destra: form */}
|
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, x: 30 }}
|
initial={{ opacity: 0, x: 30 }}
|
||||||
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="bg-white rounded-2xl p-8 text-center shadow-2xl">
|
<div className="rounded-2xl bg-white p-8 text-center shadow-2xl">
|
||||||
<div className="w-16 h-16 rounded-full bg-[#4ECDC4]/15 flex items-center justify-center mx-auto mb-4">
|
<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]" />
|
<CheckCircle2 size={32} className="text-[#4ECDC4]" />
|
||||||
</div>
|
</div>
|
||||||
<h3
|
<h3
|
||||||
className="text-[#1B4F72] mb-2"
|
className="mb-2 text-[#1B4F72]"
|
||||||
style={{ fontFamily: "'Cormorant Garamond', serif", fontSize: "1.8rem" }}
|
style={{ fontFamily: "'Cormorant Garamond', serif", fontSize: "1.9rem" }}
|
||||||
>
|
>
|
||||||
Richiesta inviata!
|
Grazie {submittedName || "per la tua richiesta"}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600 text-sm mb-6">
|
<p className="mb-3 text-sm leading-relaxed text-gray-600">
|
||||||
Abbiamo ricevuto la tua richiesta di appuntamento. Ti contatteremo entro 24 ore
|
La tua richiesta di prenotazione è stata inviata correttamente.
|
||||||
per confermare data e orario.
|
|
||||||
</p>
|
</p>
|
||||||
|
<p className="mb-6 text-sm leading-relaxed text-gray-600">
|
||||||
|
Il team della Clinica Veterinaria Formiginese la prenderà in carico il prima
|
||||||
|
possibile e ti ricontatterà per confermare disponibilità, data e orario.
|
||||||
|
</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 gia confermata.
|
||||||
|
</div>
|
||||||
<Button
|
<Button
|
||||||
className="bg-[#1B4F72] hover:bg-[#163d5a] text-white"
|
className="bg-[#1B4F72] text-white hover:bg-[#163d5a]"
|
||||||
onClick={() => setSubmitted(false)}
|
onClick={() => setSubmitted(false)}
|
||||||
>
|
>
|
||||||
Nuova prenotazione
|
Invia una nuova richiesta
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<form
|
<form
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
className="bg-white rounded-2xl p-6 md:p-8 shadow-2xl space-y-4"
|
className="space-y-4 rounded-2xl bg-white p-6 shadow-2xl md:p-8"
|
||||||
>
|
>
|
||||||
<h3
|
<div>
|
||||||
className="text-[#1B4F72] mb-2"
|
<h3
|
||||||
style={{ fontFamily: "'Cormorant Garamond', serif", fontSize: "1.5rem", fontWeight: 600 }}
|
className="mb-2 text-[#1B4F72]"
|
||||||
>
|
style={{
|
||||||
Richiedi un appuntamento
|
fontFamily: "'Cormorant Garamond', serif",
|
||||||
</h3>
|
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 preferenza di periodo: sarà lo staff a
|
||||||
|
ricontattarti per confermare la visita.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Nome e telefono */}
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs font-semibold text-gray-600 uppercase tracking-wide block mb-1.5">
|
<label className="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-gray-600">
|
||||||
Nome e Cognome *
|
Nome e Cognome *
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@@ -183,12 +400,30 @@ export default function BookingSection() {
|
|||||||
placeholder="Mario Rossi"
|
placeholder="Mario Rossi"
|
||||||
value={form.name}
|
value={form.name}
|
||||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||||
className="w-full pl-9 pr-3 py-2.5 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-[#4ECDC4] focus:border-transparent transition-all"
|
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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs font-semibold text-gray-600 uppercase tracking-wide block mb-1.5">
|
<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 *
|
Telefono *
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@@ -199,17 +434,16 @@ export default function BookingSection() {
|
|||||||
placeholder="333 123 4567"
|
placeholder="333 123 4567"
|
||||||
value={form.phone}
|
value={form.phone}
|
||||||
onChange={(e) => setForm({ ...form, phone: e.target.value })}
|
onChange={(e) => setForm({ ...form, phone: e.target.value })}
|
||||||
className="w-full pl-9 pr-3 py-2.5 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-[#4ECDC4] focus:border-transparent transition-all"
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Animale */}
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs font-semibold text-gray-600 uppercase tracking-wide block mb-1.5">
|
<label className="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-gray-600">
|
||||||
Nome dell'animale
|
Nome dell'animale
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<PawPrint size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
<PawPrint size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||||
@@ -218,18 +452,19 @@ export default function BookingSection() {
|
|||||||
placeholder="Fido"
|
placeholder="Fido"
|
||||||
value={form.petName}
|
value={form.petName}
|
||||||
onChange={(e) => setForm({ ...form, petName: e.target.value })}
|
onChange={(e) => setForm({ ...form, petName: e.target.value })}
|
||||||
className="w-full pl-9 pr-3 py-2.5 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-[#4ECDC4] focus:border-transparent transition-all"
|
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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs font-semibold text-gray-600 uppercase tracking-wide block mb-1.5">
|
<label className="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-gray-600">
|
||||||
Tipo di animale
|
Tipo di animale
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
value={form.petType}
|
value={form.petType}
|
||||||
onChange={(e) => setForm({ ...form, petType: e.target.value })}
|
onChange={(e) => setForm({ ...form, petType: e.target.value })}
|
||||||
className="w-full px-3 py-2.5 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-[#4ECDC4] focus:border-transparent transition-all bg-white"
|
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="cane">Cane</option>
|
<option value="cane">Cane</option>
|
||||||
<option value="gatto">Gatto</option>
|
<option value="gatto">Gatto</option>
|
||||||
@@ -238,65 +473,91 @@ export default function BookingSection() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Servizio */}
|
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs font-semibold text-gray-600 uppercase tracking-wide block mb-1.5">
|
<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 *
|
Tipo di visita *
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
required
|
required
|
||||||
value={form.service}
|
value={form.service}
|
||||||
onChange={(e) => setForm({ ...form, service: e.target.value })}
|
onChange={(e) => setForm({ ...form, service: e.target.value })}
|
||||||
className="w-full px-3 py-2.5 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-[#4ECDC4] focus:border-transparent transition-all bg-white"
|
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>
|
<option value="">Seleziona un servizio</option>
|
||||||
{services.map((s) => (
|
{services.map((service) => (
|
||||||
<option key={s} value={s}>{s}</option>
|
<option key={service} value={service}>
|
||||||
|
{service}
|
||||||
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Data e ora */}
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs font-semibold text-gray-600 uppercase tracking-wide block mb-1.5">
|
<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 pl-9 pr-3 py-2.5 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-[#4ECDC4] focus:border-transparent transition-all"
|
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="text-xs font-semibold text-gray-600 uppercase tracking-wide block mb-1.5">
|
<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" />
|
||||||
<select
|
<select
|
||||||
value={form.time}
|
value={form.time}
|
||||||
onChange={(e) => setForm({ ...form, time: e.target.value })}
|
onChange={(e) => setForm({ ...form, time: e.target.value })}
|
||||||
className="w-full pl-9 pr-3 py-2.5 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-[#4ECDC4] focus:border-transparent transition-all bg-white"
|
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((t) => (
|
{preferredTimeWindows.map((time) => (
|
||||||
<option key={t} value={t}>{t}</option>
|
<option key={time} value={time}>
|
||||||
|
{time}
|
||||||
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Note */}
|
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs font-semibold text-gray-600 uppercase tracking-wide block mb-1.5">
|
<label className="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-gray-600">
|
||||||
Note aggiuntive
|
Note aggiuntive
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -304,19 +565,62 @@ export default function BookingSection() {
|
|||||||
placeholder="Descrivi brevemente il motivo della visita..."
|
placeholder="Descrivi brevemente il motivo della visita..."
|
||||||
value={form.notes}
|
value={form.notes}
|
||||||
onChange={(e) => setForm({ ...form, notes: e.target.value })}
|
onChange={(e) => setForm({ ...form, notes: e.target.value })}
|
||||||
className="w-full px-3 py-2.5 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-[#4ECDC4] focus:border-transparent transition-all resize-none"
|
className="w-full resize-none 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]"
|
||||||
/>
|
/>
|
||||||
</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"
|
||||||
className="w-full bg-[#4ECDC4] hover:bg-[#3ab5ad] text-white font-bold py-3 text-base shadow-lg shadow-[#4ECDC4]/30 transition-all duration-300 hover:shadow-xl"
|
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"
|
||||||
>
|
>
|
||||||
Invia Richiesta di Appuntamento
|
{submitting ? "Invio in corso..." : "Invia richiesta di prenotazione"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<p className="text-xs text-gray-400 text-center">
|
<p className="text-center text-xs text-gray-500">
|
||||||
* Campi obbligatori. Ti contatteremo entro 24 ore per confermare.
|
* Campi obbligatori. La richiesta sarà valutata e confermata dallo staff.
|
||||||
</p>
|
</p>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -3,67 +3,23 @@
|
|||||||
* Footer scuro con informazioni di contatto, link utili e disclaimer legale
|
* Footer scuro con informazioni di contatto, link utili e disclaimer legale
|
||||||
* Sfondo blu petrolio scuro, testo bianco/grigio chiaro
|
* 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";
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
const quickLinks = [
|
const quickLinks = [
|
||||||
{ label: "Chi Siamo", href: "#chi-siamo" },
|
{ label: "Chi Siamo", href: "/#chi-siamo" },
|
||||||
{ label: "Radiologia", href: "#servizi" },
|
{ label: "Radiologia", href: "/#servizi" },
|
||||||
{ label: "Chirurgia", href: "#servizi" },
|
{ label: "Chirurgia", href: "/#servizi" },
|
||||||
{ label: "Laboratorio", href: "#servizi" },
|
{ label: "Laboratorio", href: "/#servizi" },
|
||||||
{ label: "Il Team", href: "#team" },
|
{ label: "Il Team", href: "/#team" },
|
||||||
{ label: "News & Blog", href: "#news" },
|
{ label: "Prenota Visita", href: "/#prenotazione" },
|
||||||
{ label: "Prenota Visita", href: "#prenotazione" },
|
{ label: "Area Personale", href: "/#registrazione" },
|
||||||
{ label: "Area Personale", href: "#registrazione" },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function Footer() {
|
export default function Footer() {
|
||||||
return (
|
return (
|
||||||
<footer id="contatti" className="bg-[#0d2b3e] text-white">
|
<footer id="contatti" className="bg-[#0d2b3e] text-white">
|
||||||
{/* Main footer */}
|
|
||||||
<div className="container py-16">
|
<div className="container py-16">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-10">
|
<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="flex items-center gap-3 mb-4">
|
|
||||||
<div className="w-10 h-10 rounded-full bg-[#1B4F72] flex items-center justify-center">
|
|
||||||
<svg viewBox="0 0 40 40" fill="none" className="w-6 h-6">
|
|
||||||
<path d="M20 8c-1.5 0-2.5 1.2-2.5 2.5S18.5 13 20 13s2.5-1.2 2.5-2.5S21.5 8 20 8z" fill="#4ECDC4"/>
|
|
||||||
<path d="M13 11c-1.2 0-2 1-2 2.2S11.8 15.5 13 15.5s2-1 2-2.2S14.2 11 13 11z" fill="#4ECDC4"/>
|
|
||||||
<path d="M27 11c-1.2 0-2 1-2 2.2S25.8 15.5 27 15.5s2-1 2-2.2S28.2 11 27 11z" fill="#4ECDC4"/>
|
|
||||||
<path d="M10 17c-1.2 0-2 1-2 2.2S8.8 21.5 10 21.5s2-1 2-2.2S11.2 17 10 17z" fill="#4ECDC4"/>
|
|
||||||
<path d="M30 17c-1.2 0-2 1-2 2.2S28.8 21.5 30 21.5s2-1 2-2.2S31.2 17 30 17z" fill="#4ECDC4"/>
|
|
||||||
<path d="M20 17c-5 0-9 3.5-9 7.5 0 2.5 1.5 4.5 3.5 5.5.5.3 1 .5 1.5.5h8c.5 0 1-.2 1.5-.5 2-1 3.5-3 3.5-5.5 0-4-4-7.5-9-7.5z" fill="white"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<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>
|
|
||||||
</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>
|
<div>
|
||||||
<h4
|
<h4
|
||||||
className="text-white font-semibold mb-4 text-sm uppercase tracking-widest"
|
className="text-white font-semibold mb-4 text-sm uppercase tracking-widest"
|
||||||
@@ -78,7 +34,10 @@ export default function Footer() {
|
|||||||
href={link.href}
|
href={link.href}
|
||||||
className="text-white/60 hover:text-[#4ECDC4] transition-colors text-sm flex items-center gap-1.5 group"
|
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}
|
{link.label}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
@@ -86,7 +45,6 @@ export default function Footer() {
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Contatti */}
|
|
||||||
<div>
|
<div>
|
||||||
<h4
|
<h4
|
||||||
className="text-white font-semibold mb-4 text-sm uppercase tracking-widest"
|
className="text-white font-semibold mb-4 text-sm uppercase tracking-widest"
|
||||||
@@ -105,7 +63,10 @@ export default function Footer() {
|
|||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Phone size={15} className="text-[#4ECDC4] flex-shrink-0" />
|
<Phone size={15} className="text-[#4ECDC4] flex-shrink-0" />
|
||||||
<div>
|
<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
|
059 839.62.63
|
||||||
</a>
|
</a>
|
||||||
<p className="text-white/60 text-xs">Urgenze: 320 532.24.39</p>
|
<p className="text-white/60 text-xs">Urgenze: 320 532.24.39</p>
|
||||||
@@ -123,7 +84,6 @@ export default function Footer() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Orari */}
|
|
||||||
<div>
|
<div>
|
||||||
<h4
|
<h4
|
||||||
className="text-white font-semibold mb-4 text-sm uppercase tracking-widest"
|
className="text-white font-semibold mb-4 text-sm uppercase tracking-widest"
|
||||||
@@ -133,9 +93,10 @@ export default function Footer() {
|
|||||||
</h4>
|
</h4>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{[
|
{[
|
||||||
{ days: "Lunedì — Venerdì", hours: "09:00 — 12:30\n14:30 — 19:00" },
|
{ days: "Visite: Lunedì - Venerdì", hours: "09:00 - 19:30" },
|
||||||
{ days: "Sabato", hours: "09:00 — 12:30" },
|
{ days: "Visite: Sabato", hours: "09:00 - 17:00" },
|
||||||
{ days: "Domenica", hours: "Solo urgenze" },
|
{ days: "Urgenze: Lunedì - Venerdì", hours: "08:00 - 22:30" },
|
||||||
|
{ days: "Urgenze: Sabato e Festivi", hours: "09:00 - 20:00" },
|
||||||
].map((slot) => (
|
].map((slot) => (
|
||||||
<div key={slot.days} className="flex items-start gap-2">
|
<div key={slot.days} className="flex items-start gap-2">
|
||||||
<Clock size={13} className="text-[#4ECDC4] flex-shrink-0 mt-0.5" />
|
<Clock size={13} className="text-[#4ECDC4] flex-shrink-0 mt-0.5" />
|
||||||
@@ -146,54 +107,36 @@ export default function Footer() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Badge reperibilità */}
|
|
||||||
<div className="mt-4 bg-[#4ECDC4]/15 border border-[#4ECDC4]/30 rounded-lg p-3">
|
|
||||||
<p className="text-[#4ECDC4] text-xs font-semibold uppercase tracking-wide">
|
|
||||||
Reperibilità 24h
|
|
||||||
</p>
|
|
||||||
<p className="text-white/60 text-xs mt-0.5">7 giorni su 7</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bottom bar */}
|
|
||||||
<div className="border-t border-white/10">
|
<div className="border-t border-white/10">
|
||||||
<div className="container py-6">
|
<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="flex flex-col md:flex-row justify-between items-center gap-4 text-xs text-white/40">
|
||||||
<div className="text-center md:text-left">
|
<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">
|
<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
|
Autorizzazione n. 43560 del 30/12/2024
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
<button
|
<a href="/privacy-policy" className="hover:text-white/70 transition-colors">
|
||||||
onClick={() => toast.info("Privacy Policy in arrivo")}
|
|
||||||
className="hover:text-white/70 transition-colors"
|
|
||||||
>
|
|
||||||
Privacy Policy
|
Privacy Policy
|
||||||
</button>
|
</a>
|
||||||
<button
|
<a href="/cookie-policy" className="hover:text-white/70 transition-colors">
|
||||||
onClick={() => toast.info("Cookie Policy in arrivo")}
|
|
||||||
className="hover:text-white/70 transition-colors"
|
|
||||||
>
|
|
||||||
Cookie Policy
|
Cookie Policy
|
||||||
</button>
|
</a>
|
||||||
<button
|
<a href="/note-legali" className="hover:text-white/70 transition-colors">
|
||||||
onClick={() => toast.info("Note legali in arrivo")}
|
|
||||||
className="hover:text-white/70 transition-colors"
|
|
||||||
>
|
|
||||||
Note Legali
|
Note Legali
|
||||||
</button>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-white/25 text-xs mt-3 text-center md:text-left">
|
<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
|
Il messaggio informativo di questo sito è diramato nel rispetto delle norme vigenti ed
|
||||||
direttive del codice deontologico. L'attivazione del sito è stata comunicata all'Ordine Provinciale dei
|
in conformità alle direttive del codice deontologico. L'attivazione del sito è stata
|
||||||
Veterinari della Provincia di Modena.
|
comunicata all'Ordine Provinciale dei Veterinari della Provincia di Modena.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
/*
|
/*
|
||||||
* DESIGN: "Clinical Warmth"
|
* DESIGN: "Clinical Warmth"
|
||||||
* Hero a schermo intero con immagine animale + overlay gradiente + testo sovrapposto
|
* Hero a schermo intero con immagine animale + overlay gradiente + testo sovrapposto
|
||||||
* Immagine: hero_dog_cat.jpg (golden retriever + gatto in prato soleggiato)
|
* Testo elegante su overlay scuro, card orari raffinata, CTA verdi acqua
|
||||||
* Testo bianco su overlay scuro, CTA verde acqua
|
|
||||||
*/
|
*/
|
||||||
import { useState, useEffect } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { ChevronDown, Calendar, Phone } from "lucide-react";
|
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
|
import { Calendar, ChevronDown, Clock, Phone } from "lucide-react";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
const heroImages = [
|
const heroImages = [
|
||||||
{
|
{
|
||||||
@@ -18,6 +18,10 @@ const heroImages = [
|
|||||||
url: "/images/hero_dog_cat.jpg",
|
url: "/images/hero_dog_cat.jpg",
|
||||||
alt: "Golden retriever e gatto tabby insieme in un prato soleggiato",
|
alt: "Golden retriever e gatto tabby insieme in un prato soleggiato",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
url: "/images/cavia.png",
|
||||||
|
alt: "Ritratto di una cavia",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
url: "/images/clinica_ingresso1.png",
|
url: "/images/clinica_ingresso1.png",
|
||||||
alt: "Clinica Veterinaria Formiginese - Ingresso e sala d'attesa",
|
alt: "Clinica Veterinaria Formiginese - Ingresso e sala d'attesa",
|
||||||
@@ -34,26 +38,36 @@ const heroImages = [
|
|||||||
url: "/images/hero_cat.jpg",
|
url: "/images/hero_cat.jpg",
|
||||||
alt: "Ritratto maestoso di un Maine Coon",
|
alt: "Ritratto maestoso di un Maine Coon",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
url: "/images/coniglio.png",
|
||||||
|
alt: "Ritratto di un coniglio",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
url: "/images/clinica_ingresso3.webp",
|
url: "/images/clinica_ingresso3.webp",
|
||||||
alt: "Clinica Veterinaria Formiginese - Ingresso principale",
|
alt: "Clinica Veterinaria Formiginese - Ingresso principale",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
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() {
|
export default function HeroSection() {
|
||||||
const [currentImage, setCurrentImage] = useState(0);
|
const [currentImage, setCurrentImage] = useState(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 3 secondi di visualizzazione + 2 secondi di dissolvenza = 5 secondi totali
|
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
setCurrentImage((prev) => (prev + 1) % heroImages.length);
|
setCurrentImage((prev) => (prev + 1) % heroImages.length);
|
||||||
}, 5000);
|
}, 5000);
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="relative h-[92vh] min-h-[600px] max-h-[900px] overflow-hidden">
|
<section className="relative h-[92vh] min-h-[600px] max-h-[900px] overflow-hidden">
|
||||||
{/* Background images con crossfade */}
|
|
||||||
{heroImages.map((img, index) => (
|
{heroImages.map((img, index) => (
|
||||||
<div
|
<div
|
||||||
key={img.url}
|
key={img.url}
|
||||||
@@ -63,78 +77,95 @@ export default function HeroSection() {
|
|||||||
<img
|
<img
|
||||||
src={img.url}
|
src={img.url}
|
||||||
alt={img.alt}
|
alt={img.alt}
|
||||||
className="w-full h-full object-cover"
|
className="h-full w-full object-cover"
|
||||||
style={{ transform: "scale(1.05)" }}
|
style={{ transform: "scale(1.05)" }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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-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" />
|
<div className="absolute inset-0 bg-gradient-to-t from-[#0d2b3e]/50 via-transparent to-transparent" />
|
||||||
|
|
||||||
{/* Contenuto hero */}
|
<div className="relative z-10 flex h-full items-center">
|
||||||
<div className="relative z-10 h-full flex items-center">
|
|
||||||
<div className="container">
|
<div className="container">
|
||||||
<div className="max-w-2xl">
|
<div className="max-w-3xl">
|
||||||
{/* Badge */}
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ duration: 0.6, delay: 0.2 }}
|
|
||||||
className="inline-flex items-center gap-2 bg-[#4ECDC4]/20 border border-[#4ECDC4]/40 text-[#4ECDC4] text-sm font-semibold px-4 py-1.5 rounded-full mb-6 backdrop-blur-sm"
|
|
||||||
>
|
|
||||||
<span className="w-2 h-2 rounded-full bg-[#4ECDC4] animate-pulse" />
|
|
||||||
Reperibilità 24h · 7 giorni su 7
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* Titolo principale */}
|
|
||||||
<motion.h1
|
<motion.h1
|
||||||
initial={{ opacity: 0, y: 30 }}
|
initial={{ opacity: 0, y: 30 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.8, delay: 0.3 }}
|
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={{
|
style={{
|
||||||
fontFamily: "'Cormorant Garamond', serif",
|
fontFamily: "'Cormorant Garamond', serif",
|
||||||
fontSize: "clamp(2.5rem, 6vw, 4.5rem)",
|
fontSize: "clamp(3.15rem, 6.4vw, 5.5rem)",
|
||||||
fontWeight: 600,
|
fontWeight: 500,
|
||||||
|
letterSpacing: "0.03em",
|
||||||
|
textShadow: "0 14px 34px rgba(0,0,0,0.24)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
La salute del tuo animale,{" "}
|
<span className="block text-white/92">Clinica Veterinaria</span>
|
||||||
<span className="italic text-[#4ECDC4]">la nostra missione</span>
|
<span className="block italic text-white">Formiginese</span>
|
||||||
</motion.h1>
|
</motion.h1>
|
||||||
|
|
||||||
{/* Sottotitolo */}
|
<motion.div
|
||||||
<motion.p
|
initial={{ opacity: 0, y: 22 }}
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.7, delay: 0.5 }}
|
transition={{ duration: 0.7, delay: 0.55 }}
|
||||||
className="text-white/85 text-lg mb-8 leading-relaxed max-w-xl"
|
className="mb-14 max-w-[39rem]"
|
||||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
|
||||||
>
|
>
|
||||||
Clinica veterinaria specialistica a Formigine. Un team di 6 professionisti
|
<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">
|
||||||
dedicati alla cura di cani e gatti, con tecnologie diagnostiche avanzate.
|
<h4
|
||||||
</motion.p>
|
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>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
{/* CTA buttons */}
|
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.7, delay: 0.7 }}
|
transition={{ duration: 0.7, delay: 0.75 }}
|
||||||
className="flex flex-col sm:flex-row gap-4"
|
className="flex flex-col gap-4 pt-1 sm:flex-row"
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
size="lg"
|
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"
|
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" })}
|
onClick={() =>
|
||||||
|
document.getElementById("prenotazione")?.scrollIntoView({ behavior: "smooth" })
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Calendar size={18} className="mr-2" />
|
<Calendar size={18} className="mr-2" />
|
||||||
Prenota una Visita
|
Prenota una Visita
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
size="lg"
|
size="lg"
|
||||||
variant="outline"
|
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
|
asChild
|
||||||
>
|
>
|
||||||
<a href="tel:0598396263">
|
<a href="tel:0598396263">
|
||||||
@@ -143,54 +174,24 @@ export default function HeroSection() {
|
|||||||
</a>
|
</a>
|
||||||
</Button>
|
</Button>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Stats */}
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
transition={{ duration: 0.8, delay: 1.0 }}
|
|
||||||
className="flex gap-8 mt-12 pt-8 border-t border-white/20"
|
|
||||||
>
|
|
||||||
{[
|
|
||||||
{ value: "6", label: "Specialisti" },
|
|
||||||
{ value: "15+", label: "Anni di esperienza" },
|
|
||||||
{ value: "24h", label: "Reperibilità" },
|
|
||||||
].map((stat) => (
|
|
||||||
<div key={stat.label} className="text-center">
|
|
||||||
<div
|
|
||||||
className="text-[#4ECDC4] font-bold"
|
|
||||||
style={{ fontFamily: "'Cormorant Garamond', serif", fontSize: "2rem" }}
|
|
||||||
>
|
|
||||||
{stat.value}
|
|
||||||
</div>
|
|
||||||
<div className="text-white/70 text-xs uppercase tracking-widest mt-0.5">
|
|
||||||
{stat.label}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Indicatori slideshow */}
|
<div className="absolute bottom-8 left-1/2 z-10 flex -translate-x-1/2 gap-2">
|
||||||
<div className="absolute bottom-8 left-1/2 -translate-x-1/2 flex gap-2 z-10">
|
|
||||||
{heroImages.map((_, index) => (
|
{heroImages.map((_, index) => (
|
||||||
<button
|
<button
|
||||||
key={index}
|
key={index}
|
||||||
onClick={() => setCurrentImage(index)}
|
onClick={() => setCurrentImage(index)}
|
||||||
className={`transition-all duration-300 rounded-full ${
|
className={`rounded-full transition-all duration-300 ${
|
||||||
index === currentImage
|
index === currentImage ? "h-2 w-8 bg-[#4ECDC4]" : "h-2 w-2 bg-white/50 hover:bg-white/80"
|
||||||
? "w-8 h-2 bg-[#4ECDC4]"
|
|
||||||
: "w-2 h-2 bg-white/50 hover:bg-white/80"
|
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Scroll indicator */}
|
<div className="absolute bottom-8 right-8 z-10 hidden flex-col items-center gap-2 text-white/60 md:flex">
|
||||||
<div className="absolute bottom-8 right-8 z-10 hidden md:flex flex-col items-center gap-2 text-white/60">
|
<span className="mb-2 rotate-90 text-xs uppercase tracking-widest">Scroll</span>
|
||||||
<span className="text-xs uppercase tracking-widest rotate-90 mb-2">Scroll</span>
|
|
||||||
<ChevronDown size={16} className="animate-bounce" />
|
<ChevronDown size={16} className="animate-bounce" />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
83
clinica-app/client/src/components/LegalPageLayout.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import Footer from "@/components/Footer";
|
||||||
|
import { ArrowLeft } from "lucide-react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
type LegalPageLayoutProps = {
|
||||||
|
eyebrow: string;
|
||||||
|
title: string;
|
||||||
|
intro: string;
|
||||||
|
updatedAt: string;
|
||||||
|
children: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function LegalPageLayout({
|
||||||
|
eyebrow,
|
||||||
|
title,
|
||||||
|
intro,
|
||||||
|
updatedAt,
|
||||||
|
children,
|
||||||
|
}: LegalPageLayoutProps) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-[#F8F4EC] text-[#18364A]">
|
||||||
|
<header className="border-b border-[#d8cec0] bg-white/95 backdrop-blur">
|
||||||
|
<div className="container flex items-center justify-between py-4">
|
||||||
|
<a href="/" className="inline-flex items-center gap-2 text-sm font-semibold text-[#1B4F72] hover:text-[#4ECDC4] transition-colors">
|
||||||
|
<ArrowLeft size={16} />
|
||||||
|
Torna alla home
|
||||||
|
</a>
|
||||||
|
<div className="text-right">
|
||||||
|
<div
|
||||||
|
className="leading-tight text-[#1B4F72]"
|
||||||
|
style={{ fontFamily: "'Cormorant Garamond', serif", fontSize: "1rem", fontWeight: 700 }}
|
||||||
|
>
|
||||||
|
Clinica Veterinaria
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] font-semibold uppercase tracking-[0.24em] text-[#4ECDC4]">
|
||||||
|
Formiginese
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<section className="border-b border-[#e4d8c9] bg-white">
|
||||||
|
<div className="container py-14 md:py-18">
|
||||||
|
<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]">
|
||||||
|
{eyebrow}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-6 lg:grid-cols-[minmax(0,2fr)_minmax(280px,1fr)] lg:items-end">
|
||||||
|
<div>
|
||||||
|
<h1
|
||||||
|
className="text-[#1B4F72]"
|
||||||
|
style={{ fontFamily: "'Cormorant Garamond', serif", fontSize: "clamp(2.2rem, 5vw, 4rem)", fontWeight: 600, lineHeight: 1.06 }}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-5 max-w-3xl text-base leading-relaxed text-gray-600">
|
||||||
|
{intro}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-[#e6ddcf] bg-[#F8F4EC] px-5 py-4 text-sm text-gray-600 shadow-sm">
|
||||||
|
<p className="font-semibold text-[#1B4F72]">Ultimo aggiornamento</p>
|
||||||
|
<p className="mt-1">{updatedAt}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="container py-12 md:py-16">
|
||||||
|
<div className="rounded-[28px] border border-[#e9dece] bg-white px-6 py-8 shadow-sm md:px-10 md:py-10">
|
||||||
|
<div className="legal-content prose prose-slate max-w-none prose-headings:text-[#1B4F72] prose-headings:font-semibold prose-p:text-gray-600 prose-li:text-gray-600 prose-strong:text-[#1B4F72]">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,13 +5,49 @@
|
|||||||
*/
|
*/
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Menu, X, Phone, MapPin } from "lucide-react";
|
import { ChevronDown, Menu, X, Phone, MapPin } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
const navLinks = [
|
type NavLink = {
|
||||||
|
label: string;
|
||||||
|
href: string;
|
||||||
|
children?: Array<{
|
||||||
|
label: string;
|
||||||
|
href: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const navLinks: NavLink[] = [
|
||||||
{ label: "Chi Siamo", href: "#chi-siamo" },
|
{ label: "Chi Siamo", href: "#chi-siamo" },
|
||||||
{ label: "Servizi", href: "#servizi" },
|
{
|
||||||
|
label: "Servizi",
|
||||||
|
href: "#servizi",
|
||||||
|
children: [
|
||||||
|
{ label: "Visite cliniche e medicina preventiva", href: "#servizi" },
|
||||||
|
{ label: "Ecografia", href: "#servizi" },
|
||||||
|
{ label: "Radiologia", href: "#servizi" },
|
||||||
|
{ label: "Laboratorio", href: "#servizi" },
|
||||||
|
{ label: "Ematologia", href: "#servizi" },
|
||||||
|
{ label: "Biochimica", href: "#servizi" },
|
||||||
|
{ label: "Urine", href: "#servizi" },
|
||||||
|
{ label: "Citologia", href: "#servizi" },
|
||||||
|
{ label: "Coagulazione", href: "#servizi" },
|
||||||
|
{ label: "Endoscopia", href: "#servizi" },
|
||||||
|
{ label: "Laparoscopia", href: "#servizi" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Visite Specialistiche",
|
||||||
|
href: "#servizi",
|
||||||
|
children: [
|
||||||
|
{ label: "Oncologia", href: "#servizi" },
|
||||||
|
{ label: "Dermatologia", href: "#servizi" },
|
||||||
|
{ label: "Oculistica", href: "#servizi" },
|
||||||
|
{ label: "Nutrizione", href: "#servizi" },
|
||||||
|
{ label: "Ortopedia", href: "#servizi" },
|
||||||
|
],
|
||||||
|
},
|
||||||
{ label: "Il Team", href: "#team" },
|
{ label: "Il Team", href: "#team" },
|
||||||
{ label: "News", href: "#news" },
|
|
||||||
{ label: "Contatti", href: "#contatti" },
|
{ label: "Contatti", href: "#contatti" },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -19,6 +55,12 @@ export default function Navbar() {
|
|||||||
const [scrolled, setScrolled] = useState(false);
|
const [scrolled, setScrolled] = useState(false);
|
||||||
const [mobileOpen, setMobileOpen] = useState(false);
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
|
|
||||||
|
const showActivationToast = () => {
|
||||||
|
toast.info("Servizio in corso di attivazione", {
|
||||||
|
description: "L'area riservata sarà disponibile a breve.",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleScroll = () => setScrolled(window.scrollY > 80);
|
const handleScroll = () => setScrolled(window.scrollY > 80);
|
||||||
window.addEventListener("scroll", handleScroll);
|
window.addEventListener("scroll", handleScroll);
|
||||||
@@ -37,12 +79,10 @@ export default function Navbar() {
|
|||||||
</span>
|
</span>
|
||||||
<span className="flex items-center gap-1.5">
|
<span className="flex items-center gap-1.5">
|
||||||
<Phone size={13} className="text-[#4ECDC4]" />
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[#4ECDC4] font-semibold tracking-wide text-xs uppercase">
|
|
||||||
Reperibilità 24h · 7 giorni su 7
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -56,45 +96,55 @@ export default function Navbar() {
|
|||||||
>
|
>
|
||||||
<div className="container flex items-center justify-between h-16 md:h-20">
|
<div className="container flex items-center justify-between h-16 md:h-20">
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<a href="#" className="flex items-center gap-3 group">
|
<a href="#" className="flex items-center gap-2 md:gap-3 group shrink-0">
|
||||||
<div className="w-10 h-10 rounded-full bg-[#1B4F72] flex items-center justify-center flex-shrink-0">
|
<img
|
||||||
<svg viewBox="0 0 40 40" fill="none" className="w-6 h-6">
|
src="/images/logo_high.png"
|
||||||
<path d="M20 8c-1.5 0-2.5 1.2-2.5 2.5S18.5 13 20 13s2.5-1.2 2.5-2.5S21.5 8 20 8z" fill="#4ECDC4"/>
|
alt="Simbolo Clinica Veterinaria Formiginese"
|
||||||
<path d="M13 11c-1.2 0-2 1-2 2.2S11.8 15.5 13 15.5s2-1 2-2.2S14.2 11 13 11z" fill="#4ECDC4"/>
|
className="h-10 md:h-14 w-auto object-contain"
|
||||||
<path d="M27 11c-1.2 0-2 1-2 2.2S25.8 15.5 27 15.5s2-1 2-2.2S28.2 11 27 11z" fill="#4ECDC4"/>
|
/>
|
||||||
<path d="M10 17c-1.2 0-2 1-2 2.2S8.8 21.5 10 21.5s2-1 2-2.2S11.2 17 10 17z" fill="#4ECDC4"/>
|
<img
|
||||||
<path d="M30 17c-1.2 0-2 1-2 2.2S28.8 21.5 30 21.5s2-1 2-2.2S31.2 17 30 17z" fill="#4ECDC4"/>
|
src="/images/logo_low.png"
|
||||||
<path d="M20 17c-5 0-9 3.5-9 7.5 0 2.5 1.5 4.5 3.5 5.5.5.3 1 .5 1.5.5h8c.5 0 1-.2 1.5-.5 2-1 3.5-3 3.5-5.5 0-4-4-7.5-9-7.5z" fill="white"/>
|
alt="Clinica Veterinaria Formiginese"
|
||||||
</svg>
|
className="h-8 md:h-11 w-auto object-contain"
|
||||||
</div>
|
/>
|
||||||
<div>
|
|
||||||
<div
|
|
||||||
className="font-bold text-[#1B4F72] leading-tight"
|
|
||||||
style={{ fontFamily: "'Cormorant Garamond', serif", fontSize: "1.1rem" }}
|
|
||||||
>
|
|
||||||
Clinica Veterinaria
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="text-[#4ECDC4] font-semibold leading-tight tracking-widest uppercase text-xs"
|
|
||||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
|
||||||
>
|
|
||||||
Formiginese
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
{/* Desktop menu */}
|
{/* Desktop menu */}
|
||||||
<div className="hidden md:flex items-center gap-8">
|
<div className="hidden md:flex items-center gap-6">
|
||||||
{navLinks.map((link) => (
|
{navLinks.map((link) => (
|
||||||
<a
|
<div key={link.label} className="relative group py-7">
|
||||||
key={link.href}
|
<a
|
||||||
href={link.href}
|
href={link.href}
|
||||||
className="text-[#1B4F72] font-medium text-sm hover:text-[#4ECDC4] transition-colors duration-200 relative group"
|
className="text-[#1B4F72] font-medium text-sm hover:text-[#4ECDC4] transition-colors duration-200 relative inline-flex items-center gap-1.5"
|
||||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||||
>
|
>
|
||||||
{link.label}
|
{link.label}
|
||||||
<span className="absolute -bottom-1 left-0 w-0 h-0.5 bg-[#4ECDC4] transition-all duration-300 group-hover:w-full" />
|
{link.children && (
|
||||||
</a>
|
<ChevronDown
|
||||||
|
size={14}
|
||||||
|
className="transition-transform duration-200 group-hover:rotate-180"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="absolute -bottom-1 left-0 w-0 h-0.5 bg-[#4ECDC4] transition-all duration-300 group-hover:w-full" />
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{link.children && (
|
||||||
|
<div className="absolute left-0 top-full w-72 rounded-2xl border border-gray-100 bg-white/95 p-3 shadow-xl backdrop-blur-md opacity-0 invisible translate-y-2 group-hover:opacity-100 group-hover:visible group-hover:translate-y-0 transition-all duration-200">
|
||||||
|
<div className="grid gap-1">
|
||||||
|
{link.children.map((child) => (
|
||||||
|
<a
|
||||||
|
key={child.label}
|
||||||
|
href={child.href}
|
||||||
|
className="rounded-xl px-3 py-2 text-sm text-[#1B4F72]/80 hover:bg-[#F5F0E8] hover:text-[#1B4F72] transition-colors"
|
||||||
|
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||||
|
>
|
||||||
|
{child.label}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -104,7 +154,7 @@ export default function Navbar() {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="border-[#1B4F72] text-[#1B4F72] hover:bg-[#1B4F72] hover:text-white transition-all duration-200"
|
className="border-[#1B4F72] text-[#1B4F72] hover:bg-[#1B4F72] hover:text-white transition-all duration-200"
|
||||||
onClick={() => document.getElementById("registrazione")?.scrollIntoView({ behavior: "smooth" })}
|
onClick={showActivationToast}
|
||||||
>
|
>
|
||||||
Accedi
|
Accedi
|
||||||
</Button>
|
</Button>
|
||||||
@@ -131,20 +181,36 @@ export default function Navbar() {
|
|||||||
{mobileOpen && (
|
{mobileOpen && (
|
||||||
<div className="md:hidden bg-white border-t border-gray-100 px-4 py-4 flex flex-col gap-4 shadow-lg">
|
<div className="md:hidden bg-white border-t border-gray-100 px-4 py-4 flex flex-col gap-4 shadow-lg">
|
||||||
{navLinks.map((link) => (
|
{navLinks.map((link) => (
|
||||||
<a
|
<div key={link.label} className="border-b border-gray-50 pb-2">
|
||||||
key={link.href}
|
<a
|
||||||
href={link.href}
|
href={link.href}
|
||||||
className="text-[#1B4F72] font-medium py-2 border-b border-gray-50 hover:text-[#4ECDC4] transition-colors"
|
className="text-[#1B4F72] font-medium py-2 hover:text-[#4ECDC4] transition-colors flex items-center justify-between"
|
||||||
onClick={() => setMobileOpen(false)}
|
onClick={() => setMobileOpen(false)}
|
||||||
>
|
>
|
||||||
{link.label}
|
{link.label}
|
||||||
</a>
|
{link.children && <ChevronDown size={15} className="text-[#4ECDC4]" />}
|
||||||
|
</a>
|
||||||
|
{link.children && (
|
||||||
|
<div className="grid gap-1 pl-3 pt-1">
|
||||||
|
{link.children.map((child) => (
|
||||||
|
<a
|
||||||
|
key={child.label}
|
||||||
|
href={child.href}
|
||||||
|
className="rounded-lg px-3 py-1.5 text-sm text-[#1B4F72]/70 hover:bg-[#F5F0E8] hover:text-[#1B4F72] transition-colors"
|
||||||
|
onClick={() => setMobileOpen(false)}
|
||||||
|
>
|
||||||
|
{child.label}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
<div className="flex flex-col gap-2 pt-2">
|
<div className="flex flex-col gap-2 pt-2">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="border-[#1B4F72] text-[#1B4F72] w-full"
|
className="border-[#1B4F72] text-[#1B4F72] w-full"
|
||||||
onClick={() => { setMobileOpen(false); document.getElementById("registrazione")?.scrollIntoView({ behavior: "smooth" }); }}
|
onClick={() => { setMobileOpen(false); showActivationToast(); }}
|
||||||
>
|
>
|
||||||
Accedi / Registrati
|
Accedi / Registrati
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ const news = [
|
|||||||
"Con l'arrivo della primavera è il momento ideale per aggiornare il piano vaccinale del tuo cane o gatto. Scopri quali vaccini sono essenziali e perché.",
|
"Con l'arrivo della primavera è il momento ideale per aggiornare il piano vaccinale del tuo cane o gatto. Scopri quali vaccini sono essenziali e perché.",
|
||||||
date: "15 Marzo 2026",
|
date: "15 Marzo 2026",
|
||||||
readTime: "4 min",
|
readTime: "4 min",
|
||||||
image: "https://images.unsplash.com/photo-1587300003388-59208cc962cb?w=600&q=80",
|
image: "/images/news_gatto.jpg",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 2,
|
id: 2,
|
||||||
@@ -30,7 +30,7 @@ const news = [
|
|||||||
"La dieta è fondamentale per la salute del tuo felino. I nostri veterinari spiegano come scegliere il cibo giusto e le quantità raccomandate per ogni fase della vita.",
|
"La dieta è fondamentale per la salute del tuo felino. I nostri veterinari spiegano come scegliere il cibo giusto e le quantità raccomandate per ogni fase della vita.",
|
||||||
date: "8 Marzo 2026",
|
date: "8 Marzo 2026",
|
||||||
readTime: "6 min",
|
readTime: "6 min",
|
||||||
image: "https://images.unsplash.com/photo-1514888286974-6c03e2ca1dba?w=600&q=80",
|
image: "/images/news_consulenza.jpg",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 3,
|
id: 3,
|
||||||
@@ -41,7 +41,7 @@ const news = [
|
|||||||
"La sterilizzazione è una delle procedure più comuni nella medicina veterinaria. Ecco perché è importante, quando eseguirla e cosa aspettarsi nel post-operatorio.",
|
"La sterilizzazione è una delle procedure più comuni nella medicina veterinaria. Ecco perché è importante, quando eseguirla e cosa aspettarsi nel post-operatorio.",
|
||||||
date: "1 Marzo 2026",
|
date: "1 Marzo 2026",
|
||||||
readTime: "5 min",
|
readTime: "5 min",
|
||||||
image: "https://images.unsplash.com/photo-1548767797-d8c844163c4c?w=600&q=80",
|
image: "/images/news_cucciolo.jpg",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -1,74 +1,219 @@
|
|||||||
/*
|
/*
|
||||||
* DESIGN: "Clinical Warmth"
|
* DESIGN: "Clinical Warmth"
|
||||||
* Sezione servizi con 3 card principali (radiologia, chirurgia, laboratorio)
|
* Sezione servizi con due livelli:
|
||||||
* Layout: immagine in alto, bordo superiore colorato, hover lift
|
* - Servizi Clinici
|
||||||
* Sfondo sabbia calda per contrasto con la sezione hero
|
* - Visite Specialistiche
|
||||||
*/
|
*/
|
||||||
import { motion } from "framer-motion";
|
import { motion, useInView } from "framer-motion";
|
||||||
import { useInView } from "framer-motion";
|
|
||||||
import { useRef } from "react";
|
import { useRef } from "react";
|
||||||
import { ArrowRight, Scan, Scissors, FlaskConical } from "lucide-react";
|
import {
|
||||||
import { toast } from "sonner";
|
Activity,
|
||||||
|
Apple,
|
||||||
|
Bone,
|
||||||
|
Eye,
|
||||||
|
FlaskConical,
|
||||||
|
HeartPulse,
|
||||||
|
Scan,
|
||||||
|
Sparkles,
|
||||||
|
Stethoscope,
|
||||||
|
Video,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
const services = [
|
type ServiceItem = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
description: string;
|
||||||
|
image: string;
|
||||||
|
imageClassName?: string;
|
||||||
|
imageHoverClassName?: string;
|
||||||
|
icon: React.ComponentType<{ size?: number; className?: string }>;
|
||||||
|
color: string;
|
||||||
|
features: string[];
|
||||||
|
tone?: "default" | "specialist";
|
||||||
|
};
|
||||||
|
|
||||||
|
const clinicalServices: ServiceItem[] = [
|
||||||
|
{
|
||||||
|
id: "visite-cliniche",
|
||||||
|
title: "Visite Cliniche",
|
||||||
|
subtitle: "Medicina preventiva",
|
||||||
|
description:
|
||||||
|
"Percorsi di prevenzione e visite cliniche complete per monitorare lo stato di salute di cani e gatti in ogni fase della vita.",
|
||||||
|
image: "/images/services_visite_cliniche.jpg",
|
||||||
|
icon: Stethoscope,
|
||||||
|
color: "#1B4F72",
|
||||||
|
features: ["Check-up periodici", "Vaccinazioni", "Profilassi antiparassitaria", "Controlli senior"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ecografia",
|
||||||
|
title: "Ecografia",
|
||||||
|
subtitle: "Diagnostica non invasiva",
|
||||||
|
description:
|
||||||
|
"Indagini ecografiche rapide per approfondire apparato addominale, urinario e riproduttivo con maggiore precisione clinica.",
|
||||||
|
image: "/images/services_ecografia.jpg",
|
||||||
|
icon: Activity,
|
||||||
|
color: "#4ECDC4",
|
||||||
|
features: ["Ecografia addominale", "Controlli gravidanza", "Valutazioni urinarie", "Follow-up terapeutici"],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "radiologia",
|
id: "radiologia",
|
||||||
title: "Radiologia",
|
title: "Radiologia",
|
||||||
subtitle: "Diagnostica per immagini",
|
subtitle: "Diagnostica per immagini",
|
||||||
description:
|
description:
|
||||||
"Tecnologia digitale di ultima generazione per radiografie, ecografie e diagnostica avanzata. Refertazione rapida e precisa per supportare le decisioni terapeutiche con la massima accuratezza diagnostica.",
|
"Radiografie digitali e diagnostica per immagini per apparato scheletrico, torace, addome e approfondimenti d'urgenza.",
|
||||||
image: "https://d2xsxph8kpxj0f.cloudfront.net/310519663483796440/DLUbzKbSnCG3dLeob4sLeC/radiology_39785f88.jpg",
|
image: "/images/services_radiologia.jpg",
|
||||||
icon: Scan,
|
icon: Scan,
|
||||||
color: "#1B4F72",
|
color: "#2E86AB",
|
||||||
features: ["Radiografia digitale", "Ecografia", "Ecocardiografia", "Refertazione immediata"],
|
features: ["Radiografia digitale", "Studi toracici", "Valutazioni ortopediche", "Diagnostica d'urgenza"],
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "chirurgia",
|
|
||||||
title: "Chirurgia",
|
|
||||||
subtitle: "Sala operatoria specialistica",
|
|
||||||
description:
|
|
||||||
"Sala operatoria completamente attrezzata per interventi di chirurgia generale, ortopedica e d'urgenza. Il nostro team chirurgico garantisce le massime condizioni di sicurezza e sterilità.",
|
|
||||||
image: "https://d2xsxph8kpxj0f.cloudfront.net/310519663483796440/DLUbzKbSnCG3dLeob4sLeC/surgery_e92904ed.jpg",
|
|
||||||
icon: Scissors,
|
|
||||||
color: "#4ECDC4",
|
|
||||||
features: ["Chirurgia generale", "Chirurgia ortopedica", "Chirurgia d'urgenza", "Anestesia monitorata"],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "laboratorio",
|
id: "laboratorio",
|
||||||
title: "Laboratorio",
|
title: "Laboratorio",
|
||||||
subtitle: "Analisi e diagnostica",
|
subtitle: "Analisi interne",
|
||||||
description:
|
description:
|
||||||
"Laboratorio analisi interno per esami ematologici, biochimici, urinari e citologici. Risultati in tempi rapidi per diagnosi tempestive e trattamenti mirati.",
|
"Laboratorio interno per esami rapidi e mirati, utile per diagnosi tempestive, monitoraggi terapeutici e controlli pre-operatori.",
|
||||||
image: "https://d2xsxph8kpxj0f.cloudfront.net/310519663483796440/DLUbzKbSnCG3dLeob4sLeC/laboratory_5a1c4119.jpg",
|
image: "/images/services_laboratorio.jpg",
|
||||||
icon: FlaskConical,
|
icon: FlaskConical,
|
||||||
|
color: "#1B4F72",
|
||||||
|
features: ["Ematologia", "Biochimica", "Urine", "Citologia", "Coagulazione"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "endoscopia",
|
||||||
|
title: "Endoscopia",
|
||||||
|
subtitle: "Esplorazione mini-invasiva",
|
||||||
|
description:
|
||||||
|
"Tecniche endoscopiche per esplorare apparato digerente e vie respiratorie riducendo invasivita e tempi di recupero.",
|
||||||
|
image: "/images/services_endoscopia.webp",
|
||||||
|
icon: Video,
|
||||||
|
color: "#4ECDC4",
|
||||||
|
features: ["Valutazioni gastroenteriche", "Corpi estranei", "Biopsie mirate", "Esplorazioni respiratorie"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "laparoscopia",
|
||||||
|
title: "Laparoscopia",
|
||||||
|
subtitle: "Chirurgia mini-invasiva",
|
||||||
|
description:
|
||||||
|
"Procedure mini-invasive con incisioni ridotte, recuperi più confortevoli e maggiore delicatezza nei passaggi operatori.",
|
||||||
|
image: "/images/services_laparoscopia.jpg",
|
||||||
|
icon: Activity,
|
||||||
color: "#2E86AB",
|
color: "#2E86AB",
|
||||||
features: ["Ematologia completa", "Biochimica sierica", "Esame urine", "Citologia"],
|
features: ["Procedure mini-invasive", "Biopsie laparoscopiche", "Riduzione del dolore", "Recupero post-operatorio"],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
function ServiceCard({ service, index }: { service: typeof services[0]; index: number }) {
|
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",
|
||||||
|
title: "Oncologia",
|
||||||
|
subtitle: "Percorsi dedicati",
|
||||||
|
description:
|
||||||
|
"Valutazioni specialistiche per definire iter diagnostici e strategie terapeutiche personalizzate nei casi oncologici.",
|
||||||
|
image: "/images/hero_cat.jpg",
|
||||||
|
icon: Stethoscope,
|
||||||
|
color: "#A95F3A",
|
||||||
|
features: ["Inquadramento clinico", "Piani terapeutici", "Monitoraggi periodici"],
|
||||||
|
tone: "specialist",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "dermatologia",
|
||||||
|
title: "Dermatologia",
|
||||||
|
subtitle: "Cute e mantello",
|
||||||
|
description:
|
||||||
|
"Approfondimenti per prurito, alopecie, otiti ricorrenti e alterazioni cutanee che richiedono un percorso mirato.",
|
||||||
|
image: "/images/services_dermatologia.jpg",
|
||||||
|
icon: Sparkles,
|
||||||
|
color: "#B76E79",
|
||||||
|
features: ["Allergie", "Citologie cutanee", "Otiti croniche"],
|
||||||
|
tone: "specialist",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "oculistica",
|
||||||
|
title: "Oculistica",
|
||||||
|
subtitle: "Vista e benessere oculare",
|
||||||
|
description:
|
||||||
|
"Controlli specialistici per disturbi oculari, lacrimazione, arrossamenti e valutazioni funzionali della vista.",
|
||||||
|
image: "/images/services_oculistica.jpg",
|
||||||
|
icon: Eye,
|
||||||
|
color: "#4C7A9F",
|
||||||
|
features: ["Esame del segmento anteriore", "Pressione oculare", "Follow-up oculari"],
|
||||||
|
tone: "specialist",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "nutrizione",
|
||||||
|
title: "Nutrizione",
|
||||||
|
subtitle: "Equilibrio alimentare",
|
||||||
|
description:
|
||||||
|
"Consulenze per impostare piani nutrizionali su misura in base a eta, patologie, stile di vita e obiettivi clinici.",
|
||||||
|
image: "/images/hero_dog.jpg",
|
||||||
|
imageClassName: "object-[center_22%]",
|
||||||
|
icon: Apple,
|
||||||
|
color: "#7EA55A",
|
||||||
|
features: ["Diete personalizzate", "Gestione del peso", "Supporto nutrizionale"],
|
||||||
|
tone: "specialist",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ortopedia",
|
||||||
|
title: "Ortopedia",
|
||||||
|
subtitle: "Movimento e postura",
|
||||||
|
description:
|
||||||
|
"Valutazioni specialistiche per zoppie, dolore articolare, traumi e impostazione del corretto iter ortopedico.",
|
||||||
|
image: "/images/ortovet.webp",
|
||||||
|
imageClassName: "scale-[0.5]",
|
||||||
|
imageHoverClassName: "group-hover:scale-[0.7]",
|
||||||
|
icon: Bone,
|
||||||
|
color: "#6D7B8C",
|
||||||
|
features: ["Valutazioni zoppia", "Controlli articolari", "Percorsi post-trauma"],
|
||||||
|
tone: "specialist",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function ServiceCard({
|
||||||
|
service,
|
||||||
|
index,
|
||||||
|
className = "",
|
||||||
|
}: {
|
||||||
|
service: ServiceItem;
|
||||||
|
index: number;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
const ref = useRef(null);
|
const ref = useRef(null);
|
||||||
const isInView = useInView(ref, { once: true, margin: "-50px" });
|
const isInView = useInView(ref, { once: true, margin: "-50px" });
|
||||||
|
const specialist = service.tone === "specialist";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
initial={{ opacity: 0, y: 40 }}
|
initial={{ opacity: 0, y: 40 }}
|
||||||
animate={isInView ? { opacity: 1, y: 0 } : {}}
|
animate={isInView ? { opacity: 1, y: 0 } : {}}
|
||||||
transition={{ duration: 0.6, delay: index * 0.15 }}
|
transition={{ duration: 0.6, delay: index * 0.1 }}
|
||||||
className="group bg-white rounded-2xl overflow-hidden shadow-md hover:shadow-xl transition-all duration-400 hover:-translate-y-1"
|
className={`group overflow-hidden rounded-2xl transition-all duration-400 hover:-translate-y-1 ${className} ${
|
||||||
|
specialist
|
||||||
|
? "border border-[#D7C1A8] bg-gradient-to-b from-[#fffaf2] to-white shadow-[0_18px_45px_rgba(169,95,58,0.12)] hover:shadow-[0_22px_50px_rgba(169,95,58,0.18)]"
|
||||||
|
: "bg-white shadow-md hover:shadow-xl"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{/* Bordo superiore colorato */}
|
<div className="h-1.5" style={{ backgroundColor: service.color }} />
|
||||||
<div className="h-1" style={{ backgroundColor: service.color }} />
|
|
||||||
|
|
||||||
{/* Immagine */}
|
|
||||||
<div className="relative h-52 overflow-hidden">
|
<div className="relative h-52 overflow-hidden">
|
||||||
<img
|
<img
|
||||||
src={service.image}
|
src={service.image}
|
||||||
alt={service.title}
|
alt={service.title}
|
||||||
className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-105"
|
className={`h-full w-full object-cover transition-transform duration-700 ${service.imageHoverClassName ?? "group-hover:scale-105"} ${service.imageClassName ?? ""}`}
|
||||||
/>
|
/>
|
||||||
{/* Icon overlay */}
|
|
||||||
<div
|
<div
|
||||||
className="absolute top-4 right-4 w-12 h-12 rounded-full flex items-center justify-center shadow-lg"
|
className="absolute top-4 right-4 w-12 h-12 rounded-full flex items-center justify-center shadow-lg"
|
||||||
style={{ backgroundColor: service.color }}
|
style={{ backgroundColor: service.color }}
|
||||||
@@ -77,40 +222,27 @@ function ServiceCard({ service, index }: { service: typeof services[0]; index: n
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Contenuto */}
|
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<div className="text-xs uppercase tracking-widest font-semibold mb-1" style={{ color: service.color }}>
|
<div className="mb-1 text-xs font-semibold uppercase tracking-widest" style={{ color: service.color }}>
|
||||||
{service.subtitle}
|
{service.subtitle}
|
||||||
</div>
|
</div>
|
||||||
<h3
|
<h3
|
||||||
className="text-[#1B4F72] mb-3"
|
className="mb-3 text-[#1B4F72]"
|
||||||
style={{ fontFamily: "'Cormorant Garamond', serif", fontSize: "1.75rem", fontWeight: 600 }}
|
style={{ fontFamily: "'Cormorant Garamond', serif", fontSize: "1.75rem", fontWeight: 600 }}
|
||||||
>
|
>
|
||||||
{service.title}
|
{service.title}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600 text-sm leading-relaxed mb-4">{service.description}</p>
|
<p className="mb-4 text-sm leading-relaxed text-gray-600">{service.description}</p>
|
||||||
|
|
||||||
{/* Features */}
|
<ul className="mb-5 space-y-1.5">
|
||||||
<ul className="space-y-1.5 mb-5">
|
|
||||||
{service.features.map((feat) => (
|
{service.features.map((feat) => (
|
||||||
<li key={feat} className="flex items-center gap-2 text-sm text-gray-600">
|
<li key={feat} className="flex items-center gap-2 text-sm text-gray-600">
|
||||||
<span className="w-1.5 h-1.5 rounded-full flex-shrink-0" style={{ backgroundColor: service.color }} />
|
<span className="h-1.5 w-1.5 flex-shrink-0 rounded-full" style={{ backgroundColor: service.color }} />
|
||||||
{feat}
|
{feat}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
{/* Link */}
|
|
||||||
<button
|
|
||||||
className="flex items-center gap-1.5 text-sm font-semibold transition-all duration-200 group/btn"
|
|
||||||
style={{ color: service.color }}
|
|
||||||
onClick={() => {
|
|
||||||
toast("Sezione in arrivo", { description: `La pagina dedicata a ${service.title} sarà disponibile a breve.` });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Scopri di più
|
|
||||||
<ArrowRight size={15} className="transition-transform duration-200 group-hover/btn:translate-x-1" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
@@ -118,12 +250,13 @@ function ServiceCard({ service, index }: { service: typeof services[0]; index: n
|
|||||||
|
|
||||||
export default function ServicesSection() {
|
export default function ServicesSection() {
|
||||||
const ref = useRef(null);
|
const ref = useRef(null);
|
||||||
|
const specialistRef = useRef(null);
|
||||||
const isInView = useInView(ref, { once: true, margin: "-80px" });
|
const isInView = useInView(ref, { once: true, margin: "-80px" });
|
||||||
|
const specialistInView = useInView(specialistRef, { once: true, margin: "-80px" });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section id="servizi" className="py-20 md:py-28" style={{ backgroundColor: "#F5F0E8" }}>
|
<section id="servizi" className="py-20 md:py-28" style={{ backgroundColor: "#F5F0E8" }}>
|
||||||
<div className="container">
|
<div className="container">
|
||||||
{/* Header sezione */}
|
|
||||||
<motion.div
|
<motion.div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
initial={{ opacity: 0, y: 30 }}
|
initial={{ opacity: 0, y: 30 }}
|
||||||
@@ -131,48 +264,63 @@ export default function ServicesSection() {
|
|||||||
transition={{ duration: 0.7 }}
|
transition={{ duration: 0.7 }}
|
||||||
className="mb-14"
|
className="mb-14"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3 mb-4">
|
|
||||||
<div className="w-12 h-0.5 bg-[#4ECDC4]" />
|
|
||||||
<span className="text-[#4ECDC4] text-sm font-semibold uppercase tracking-widest">
|
|
||||||
Specializzazioni
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<h2
|
<h2
|
||||||
className="text-[#1B4F72] mb-4"
|
className="mb-4 text-[#1B4F72]"
|
||||||
style={{ fontFamily: "'Cormorant Garamond', serif", fontSize: "clamp(2rem, 4vw, 3rem)", fontWeight: 600 }}
|
style={{ fontFamily: "'Cormorant Garamond', serif", fontSize: "clamp(2rem, 4vw, 3rem)", fontWeight: 600 }}
|
||||||
>
|
>
|
||||||
Servizi Specialistici
|
Servizi
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-gray-600 max-w-xl text-base leading-relaxed">
|
<p className="max-w-xl text-base leading-relaxed text-gray-600">
|
||||||
Tre aree di eccellenza clinica per garantire diagnosi precise e trattamenti efficaci
|
|
||||||
ai tuoi animali domestici, con tecnologie all'avanguardia e professionisti esperti.
|
|
||||||
</p>
|
</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Grid servizi */}
|
<div className="grid grid-cols-1 gap-8 md:grid-cols-2 xl:grid-cols-3">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
{clinicalServices.map((service, index) => (
|
||||||
{services.map((service, index) => (
|
|
||||||
<ServiceCard key={service.id} service={service} index={index} />
|
<ServiceCard key={service.id} service={service} index={index} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Link a tutti i servizi */}
|
<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
|
<motion.div
|
||||||
initial={{ opacity: 0 }}
|
ref={specialistRef}
|
||||||
animate={isInView ? { opacity: 1 } : {}}
|
initial={{ opacity: 0, y: 30 }}
|
||||||
transition={{ duration: 0.7, delay: 0.6 }}
|
animate={specialistInView ? { opacity: 1, y: 0 } : {}}
|
||||||
className="text-center mt-12"
|
transition={{ duration: 0.7, delay: 0.2 }}
|
||||||
|
className="mt-20"
|
||||||
>
|
>
|
||||||
<button
|
<h3
|
||||||
className="inline-flex items-center gap-2 text-[#1B4F72] font-semibold border-2 border-[#1B4F72] px-8 py-3 rounded-full hover:bg-[#1B4F72] hover:text-white transition-all duration-300"
|
className="mb-4 text-[#1B4F72]"
|
||||||
onClick={() => {
|
style={{ fontFamily: "'Cormorant Garamond', serif", fontSize: "clamp(1.9rem, 3.7vw, 2.8rem)", fontWeight: 600 }}
|
||||||
toast("Tutti i servizi", { description: "La pagina completa dei servizi sarà disponibile a breve." });
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Tutti i servizi
|
Visite Specialistiche
|
||||||
<ArrowRight size={16} />
|
</h3>
|
||||||
</button>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
|
<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}
|
||||||
|
service={service}
|
||||||
|
index={index}
|
||||||
|
className={index < 3 ? "xl:col-span-2" : "xl:col-span-3"}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,152 +1,258 @@
|
|||||||
/*
|
/*
|
||||||
* DESIGN: "Clinical Warmth"
|
* DESIGN: "Clinical Warmth"
|
||||||
* Sezione team: griglia di 6 professionisti con card eleganti
|
* Sezione team: due aree distinte per team medico e collaborazioni
|
||||||
* Sfondo sabbia calda, foto placeholder con iniziali, specializzazioni colorate
|
* Stessa grammatica visiva delle card precedenti, con schede pronte
|
||||||
|
* ad accogliere progressivamente i profili completi.
|
||||||
*/
|
*/
|
||||||
import { motion } from "framer-motion";
|
import { motion, useInView } from "framer-motion";
|
||||||
import { useInView } from "framer-motion";
|
|
||||||
import { useRef } from "react";
|
import { useRef } from "react";
|
||||||
|
|
||||||
const team = [
|
type TeamMember = {
|
||||||
|
name: string;
|
||||||
|
role: string;
|
||||||
|
specialization?: string;
|
||||||
|
color: string;
|
||||||
|
bio?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const medicalTeam: TeamMember[] = [
|
||||||
{
|
{
|
||||||
name: "Dott. Paolo Parmeggiani",
|
name: "Dott. Paolo Parmeggiani",
|
||||||
role: "Direttore Sanitario",
|
role: "Direttore sanitario",
|
||||||
specialization: "Oncologia Veterinaria",
|
specialization: "Oncologia veterinaria",
|
||||||
color: "#1B4F72",
|
color: "#1B4F72",
|
||||||
initials: "PP",
|
bio: [
|
||||||
bio: "Master in Oncologia Veterinaria. Direttore sanitario della clinica con oltre 20 anni di esperienza.",
|
"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.",
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Dott.ssa Elena Rossi",
|
name: "Dott.ssa Irene Paganelli",
|
||||||
role: "Medico Veterinario",
|
role: "Medico veterinario",
|
||||||
specialization: "Chirurgia",
|
specialization: "Ecografia ed esotici",
|
||||||
color: "#4ECDC4",
|
color: "#4ECDC4",
|
||||||
initials: "ER",
|
bio: [
|
||||||
bio: "Specializzata in chirurgia ortopedica e dei tessuti molli. Referente per gli interventi d'urgenza.",
|
"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.",
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Dott. Marco Bianchi",
|
name: "Dott. Simone Tinti",
|
||||||
role: "Medico Veterinario",
|
role: "Medico veterinario",
|
||||||
specialization: "Radiologia",
|
specialization: "Profilo in aggiornamento",
|
||||||
color: "#2E86AB",
|
color: "#2E86AB",
|
||||||
initials: "MB",
|
|
||||||
bio: "Esperto in diagnostica per immagini, ecografia e radiologia digitale veterinaria.",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Dott.ssa Sara Ferrari",
|
name: "Dott.ssa Michela Sghedoni",
|
||||||
role: "Medico Veterinario",
|
role: "Medico veterinario",
|
||||||
specialization: "Medicina Interna",
|
specialization: "Medicina interna, patologia clinica e citologia",
|
||||||
color: "#E8A838",
|
color: "#B76E79",
|
||||||
initials: "SF",
|
bio: [
|
||||||
bio: "Specializzata in medicina interna felina e canina, con focus su malattie metaboliche.",
|
"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 Moretti",
|
name: "Dott. Luca Pietri",
|
||||||
role: "Medico Veterinario",
|
role: "Medico veterinario",
|
||||||
specialization: "Dermatologia",
|
specialization: "Profilo in aggiornamento",
|
||||||
color: "#6B8F71",
|
color: "#6B8F71",
|
||||||
initials: "LM",
|
|
||||||
bio: "Esperto in dermatologia e allergologia veterinaria. Referente per le patologie cutanee.",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Dott.ssa Anna Conti",
|
name: "Dott.ssa Sara Casali",
|
||||||
role: "Medico Veterinario",
|
role: "Medico veterinario",
|
||||||
specialization: "Odontoiatria",
|
specialization: "Profilo in aggiornamento",
|
||||||
color: "#9B5DE5",
|
color: "#C58C63",
|
||||||
initials: "AC",
|
},
|
||||||
bio: "Specializzata in odontoiatria veterinaria e chirurgia orale per cani e gatti.",
|
{
|
||||||
|
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",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
function TeamCard({ member, index }: { member: typeof team[0]; index: number }) {
|
const collaborators: TeamMember[] = [
|
||||||
|
{
|
||||||
|
name: "Dott. Andrea Arrigoni",
|
||||||
|
role: "Collaborazione specialistica",
|
||||||
|
specialization: "Endoscopia, citologia e medicina interna",
|
||||||
|
color: "#A95F3A",
|
||||||
|
bio: [
|
||||||
|
"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: "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. Boris Dal Pozzo",
|
||||||
|
role: "Collaborazione specialistica",
|
||||||
|
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 Silvia Palladini",
|
||||||
|
role: "Collaborazione specialistica",
|
||||||
|
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.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function getInitials(name: string) {
|
||||||
|
return name
|
||||||
|
.replaceAll(".", "")
|
||||||
|
.split(" ")
|
||||||
|
.filter((part) => part.length > 2)
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((part) => part[0]?.toUpperCase())
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function TeamCard({
|
||||||
|
member,
|
||||||
|
index,
|
||||||
|
}: {
|
||||||
|
member: TeamMember;
|
||||||
|
index: number;
|
||||||
|
}) {
|
||||||
const ref = useRef(null);
|
const ref = useRef(null);
|
||||||
const isInView = useInView(ref, { once: true, margin: "-40px" });
|
const isInView = useInView(ref, { once: true, margin: "-40px" });
|
||||||
|
const hasBio = Boolean(member.bio?.length);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
initial={{ opacity: 0, y: 30 }}
|
initial={{ opacity: 0, y: 30 }}
|
||||||
animate={isInView ? { opacity: 1, y: 0 } : {}}
|
animate={isInView ? { opacity: 1, y: 0 } : {}}
|
||||||
transition={{ duration: 0.6, delay: index * 0.1 }}
|
transition={{ duration: 0.6, delay: index * 0.08 }}
|
||||||
className="group bg-white rounded-2xl overflow-hidden shadow-sm hover:shadow-lg transition-all duration-300 hover:-translate-y-1"
|
className="group h-full overflow-hidden rounded-2xl bg-white shadow-sm transition-all duration-300 hover:-translate-y-1 hover:shadow-lg"
|
||||||
>
|
>
|
||||||
{/* Bordo superiore */}
|
|
||||||
<div className="h-1" style={{ backgroundColor: member.color }} />
|
<div className="h-1" style={{ backgroundColor: member.color }} />
|
||||||
|
|
||||||
{/* Avatar */}
|
<div className="p-6 pb-5">
|
||||||
<div className="p-6 pb-4">
|
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
<div
|
<div
|
||||||
className="w-16 h-16 rounded-full flex items-center justify-center text-white font-bold text-xl flex-shrink-0 shadow-md"
|
className="flex h-16 w-16 flex-shrink-0 items-center justify-center rounded-full text-xl font-bold text-white shadow-md"
|
||||||
style={{ backgroundColor: member.color, fontFamily: "'Cormorant Garamond', serif" }}
|
style={{ backgroundColor: member.color, fontFamily: "'Cormorant Garamond', serif" }}
|
||||||
>
|
>
|
||||||
{member.initials}
|
{getInitials(member.name)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="min-w-0 flex-1">
|
||||||
<h3
|
<h3
|
||||||
className="text-[#1B4F72] font-semibold leading-tight"
|
className="leading-tight text-[#1B4F72]"
|
||||||
style={{ fontFamily: "'Cormorant Garamond', serif", fontSize: "1.15rem" }}
|
style={{ fontFamily: "'Cormorant Garamond', serif", fontSize: "1.15rem", fontWeight: 600 }}
|
||||||
>
|
>
|
||||||
{member.name}
|
{member.name}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-500 text-xs mt-0.5">{member.role}</p>
|
<p className="mt-0.5 text-xs text-gray-500">{member.role}</p>
|
||||||
<span
|
{member.specialization && (
|
||||||
className="inline-block text-xs font-semibold px-2.5 py-0.5 rounded-full mt-2"
|
<span
|
||||||
style={{ backgroundColor: `${member.color}18`, color: member.color }}
|
className="mt-2 inline-block rounded-full px-2.5 py-0.5 text-xs font-semibold"
|
||||||
>
|
style={{ backgroundColor: `${member.color}18`, color: member.color }}
|
||||||
{member.specialization}
|
>
|
||||||
</span>
|
{member.specialization}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-gray-600 text-sm leading-relaxed mt-4">{member.bio}</p>
|
{hasBio ? (
|
||||||
|
<div className="mt-4 space-y-3">
|
||||||
|
{member.bio?.map((paragraph) => (
|
||||||
|
<p key={paragraph} className="text-sm leading-relaxed text-gray-600">
|
||||||
|
{paragraph}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-4 rounded-xl border border-dashed border-gray-200 bg-[#F9F6F1] px-4 py-5">
|
||||||
|
<p className="text-sm italic leading-relaxed text-gray-400">
|
||||||
|
Profilo in aggiornamento.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TeamSection() {
|
function TeamGroup({
|
||||||
|
eyebrow,
|
||||||
|
members,
|
||||||
|
}: {
|
||||||
|
eyebrow: string;
|
||||||
|
members: TeamMember[];
|
||||||
|
}) {
|
||||||
const ref = useRef(null);
|
const ref = useRef(null);
|
||||||
const isInView = useInView(ref, { once: true, margin: "-80px" });
|
const isInView = useInView(ref, { once: true, margin: "-80px" });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section id="team" className="py-20 md:py-28 bg-white">
|
<div className="mt-14 first:mt-0">
|
||||||
<div className="container">
|
<motion.div
|
||||||
{/* Header */}
|
ref={ref}
|
||||||
<motion.div
|
initial={{ opacity: 0, y: 30 }}
|
||||||
ref={ref}
|
animate={isInView ? { opacity: 1, y: 0 } : {}}
|
||||||
initial={{ opacity: 0, y: 30 }}
|
transition={{ duration: 0.7 }}
|
||||||
animate={isInView ? { opacity: 1, y: 0 } : {}}
|
className="mb-10"
|
||||||
transition={{ duration: 0.7 }}
|
>
|
||||||
className="mb-14"
|
<div className="mb-3 flex items-center gap-3">
|
||||||
>
|
<div className="h-0.5 w-10 bg-[#4ECDC4]" />
|
||||||
<div className="flex items-center gap-3 mb-4">
|
<span className="text-xs font-semibold uppercase tracking-[0.2em] text-[#4ECDC4]">
|
||||||
<div className="w-12 h-0.5 bg-[#4ECDC4]" />
|
{eyebrow}
|
||||||
<span className="text-[#4ECDC4] text-sm font-semibold uppercase tracking-widest">
|
</span>
|
||||||
Il Nostro Team
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col md:flex-row md:items-end md:justify-between gap-4">
|
|
||||||
<h2
|
|
||||||
className="text-[#1B4F72]"
|
|
||||||
style={{ fontFamily: "'Cormorant Garamond', serif", fontSize: "clamp(2rem, 4vw, 3rem)", fontWeight: 600 }}
|
|
||||||
>
|
|
||||||
Sei professionisti,{" "}
|
|
||||||
<span className="italic">un'unica passione</span>
|
|
||||||
</h2>
|
|
||||||
<p className="text-gray-600 max-w-sm text-sm leading-relaxed">
|
|
||||||
Ogni membro del nostro team porta competenze specialistiche uniche,
|
|
||||||
unite dalla stessa dedizione al benessere animale.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* Grid team */}
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
||||||
{team.map((member, index) => (
|
|
||||||
<TeamCard key={member.name} member={member} index={index} />
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{members.map((member, index) => (
|
||||||
|
<TeamCard key={member.name} member={member} index={index} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TeamSection() {
|
||||||
|
return (
|
||||||
|
<section id="team" className="bg-white py-20 md:py-28">
|
||||||
|
<div className="container">
|
||||||
|
<TeamGroup eyebrow="Team medico" members={medicalTeam} />
|
||||||
|
|
||||||
|
<TeamGroup eyebrow="Collaborazioni" members={collaborators} />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
78
clinica-app/client/src/pages/CookiePolicyPage.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import LegalPageLayout from "@/components/LegalPageLayout";
|
||||||
|
|
||||||
|
export default function CookiePolicyPage() {
|
||||||
|
return (
|
||||||
|
<LegalPageLayout
|
||||||
|
eyebrow="Informativa cookie"
|
||||||
|
title="Cookie Policy"
|
||||||
|
intro="Questa pagina descrive l'uso di cookie e di altri strumenti tecnici da parte del sito della Clinica Veterinaria Formiginese."
|
||||||
|
updatedAt="23 maggio 2026"
|
||||||
|
>
|
||||||
|
<h2>1. Cosa sono i cookie</h2>
|
||||||
|
<p>
|
||||||
|
I cookie sono piccoli file di testo che i siti visitati possono salvare sul dispositivo
|
||||||
|
dell'utente per consentire il funzionamento del sito, migliorare la navigazione o memorizzare
|
||||||
|
preferenze e impostazioni.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>2. Tipologie di strumenti attualmente utilizzati</h2>
|
||||||
|
<p>Allo stato attuale il sito utilizza esclusivamente strumenti tecnici o assimilabili ai tecnici, necessari a:</p>
|
||||||
|
<ul>
|
||||||
|
<li>erogare correttamente le pagine e i contenuti richiesti;</li>
|
||||||
|
<li>gestire eventuali elementi tecnici indispensabili al funzionamento dell'interfaccia;</li>
|
||||||
|
<li>garantire sicurezza, stabilità e corretta erogazione del servizio.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>3. Cookie tecnici</h2>
|
||||||
|
<p>
|
||||||
|
I cookie tecnici sono quelli strettamente necessari alla navigazione o alla fornitura di un
|
||||||
|
servizio esplicitamente richiesto dall'utente. Per tali strumenti, secondo la normativa
|
||||||
|
applicabile, non è richiesto il consenso preventivo dell'utente, fermo restando l'obbligo
|
||||||
|
di fornire un'informativa adeguata.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>4. Cookie di profilazione e analytics</h2>
|
||||||
|
<p>
|
||||||
|
Alla data di aggiornamento di questa pagina il sito non utilizza cookie di profilazione,
|
||||||
|
strumenti di marketing né sistemi di analytics attivi configurati per il monitoraggio del
|
||||||
|
comportamento degli utenti a fini non tecnici.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Qualora in futuro venissero introdotti strumenti di tracciamento non tecnici, la presente
|
||||||
|
policy verrà aggiornata e, ove necessario, sarà implementato un meccanismo di raccolta del
|
||||||
|
consenso conforme alla normativa applicabile.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>5. Risorse di terze parti</h2>
|
||||||
|
<p>
|
||||||
|
Il sito può richiamare risorse esterne di supporto, come librerie o font distribuiti da terzi.
|
||||||
|
In tali casi il browser dell'utente può effettuare richieste ai rispettivi server per
|
||||||
|
scaricare le risorse necessarie alla visualizzazione della pagina.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>6. Futuri servizi con autenticazione</h2>
|
||||||
|
<p>
|
||||||
|
In occasione della futura attivazione dell'area riservata potranno essere utilizzati cookie
|
||||||
|
o identificatori tecnici di sessione strettamente necessari all'autenticazione e alla gestione
|
||||||
|
sicura dell'accesso. Anche tali strumenti, se limitati alla funzione tecnica richiesta
|
||||||
|
dall'utente, rientrano normalmente tra quelli esenti da consenso preventivo.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>7. Come gestire i cookie</h2>
|
||||||
|
<p>
|
||||||
|
L'utente può controllare e cancellare i cookie attraverso le impostazioni del proprio browser.
|
||||||
|
La disattivazione dei cookie tecnici potrebbe tuttavia compromettere il corretto funzionamento
|
||||||
|
di alcune funzionalità del sito.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>8. Contatti</h2>
|
||||||
|
<p>
|
||||||
|
Per qualsiasi chiarimento relativo all'uso dei cookie e degli altri strumenti tecnici è possibile
|
||||||
|
contattare la Clinica Veterinaria Formiginese all'indirizzo{" "}
|
||||||
|
<a href="mailto:clinicaveterinariaformiginese@gmail.com">
|
||||||
|
clinicaveterinariaformiginese@gmail.com
|
||||||
|
</a>.
|
||||||
|
</p>
|
||||||
|
</LegalPageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,7 +9,6 @@ import HeroSection from "@/components/HeroSection";
|
|||||||
import ServicesSection from "@/components/ServicesSection";
|
import ServicesSection from "@/components/ServicesSection";
|
||||||
import AboutSection from "@/components/AboutSection";
|
import AboutSection from "@/components/AboutSection";
|
||||||
import TeamSection from "@/components/TeamSection";
|
import TeamSection from "@/components/TeamSection";
|
||||||
import NewsSection from "@/components/NewsSection";
|
|
||||||
import BookingSection from "@/components/BookingSection";
|
import BookingSection from "@/components/BookingSection";
|
||||||
import AuthSection from "@/components/AuthSection";
|
import AuthSection from "@/components/AuthSection";
|
||||||
import Footer from "@/components/Footer";
|
import Footer from "@/components/Footer";
|
||||||
@@ -23,7 +22,6 @@ export default function Home() {
|
|||||||
<ServicesSection />
|
<ServicesSection />
|
||||||
<AboutSection />
|
<AboutSection />
|
||||||
<TeamSection />
|
<TeamSection />
|
||||||
<NewsSection />
|
|
||||||
<BookingSection />
|
<BookingSection />
|
||||||
<AuthSection />
|
<AuthSection />
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
78
clinica-app/client/src/pages/LegalNotesPage.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import LegalPageLayout from "@/components/LegalPageLayout";
|
||||||
|
|
||||||
|
export default function LegalNotesPage() {
|
||||||
|
return (
|
||||||
|
<LegalPageLayout
|
||||||
|
eyebrow="Note legali"
|
||||||
|
title="Note Legali"
|
||||||
|
intro="Le presenti note disciplinano l'accesso e l'utilizzo del sito della Clinica Veterinaria Formiginese e forniscono informazioni generali sull'uso dei contenuti pubblicati."
|
||||||
|
updatedAt="23 maggio 2026"
|
||||||
|
>
|
||||||
|
<h2>1. Informazioni generali</h2>
|
||||||
|
<p>
|
||||||
|
Il presente sito è dedicato alla presentazione della Clinica Veterinaria Formiginese,
|
||||||
|
dei suoi servizi e delle modalità di contatto con la struttura. I contenuti pubblicati
|
||||||
|
hanno finalità prevalentemente informative e organizzative.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>2. Utilizzo del sito</h2>
|
||||||
|
<p>
|
||||||
|
L'utente si impegna a utilizzare il sito in modo conforme alla legge, al buon costume e
|
||||||
|
alle finalità per cui esso è messo a disposizione, evitando qualsiasi uso improprio,
|
||||||
|
fraudolento o lesivo dei diritti del titolare o di terzi.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>3. Contenuti informativi</h2>
|
||||||
|
<p>
|
||||||
|
Le informazioni presenti sul sito, incluse quelle relative ad attività cliniche, visite,
|
||||||
|
servizi e aggiornamenti divulgativi, non sostituiscono in alcun modo una valutazione
|
||||||
|
veterinaria diretta e personalizzata. Per esigenze cliniche specifiche è sempre necessario
|
||||||
|
rivolgersi direttamente alla struttura o al professionista competente.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>4. Prenotazioni e richieste online</h2>
|
||||||
|
<p>
|
||||||
|
Eventuali richieste di registrazione o di prenotazione visita inoltrate attraverso il sito
|
||||||
|
hanno natura informativa o pre-organizzativa e non costituiscono, salvo diversa conferma da
|
||||||
|
parte della clinica, appuntamento automaticamente confermato né accettazione vincolante della
|
||||||
|
prestazione richiesta.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>5. Proprietà intellettuale</h2>
|
||||||
|
<p>
|
||||||
|
Salvo diversa indicazione, testi, immagini, elementi grafici, marchi, struttura del sito e
|
||||||
|
altri contenuti presenti nel sito sono protetti dalle norme applicabili in materia di proprietà
|
||||||
|
intellettuale e non possono essere copiati, riprodotti, diffusi o riutilizzati senza preventiva
|
||||||
|
autorizzazione del titolare o dei rispettivi aventi diritto.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>6. Link a siti esterni</h2>
|
||||||
|
<p>
|
||||||
|
Il sito può contenere collegamenti a risorse esterne, gestite da soggetti terzi. La presenza di
|
||||||
|
tali link non implica approvazione o controllo costante sui relativi contenuti. La clinica non
|
||||||
|
risponde dei contenuti, delle policy o del funzionamento dei siti esterni.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>7. Limitazione di responsabilità</h2>
|
||||||
|
<p>
|
||||||
|
Pur ponendo la massima attenzione nell'aggiornamento dei contenuti, la clinica non garantisce
|
||||||
|
che tutte le informazioni presenti sul sito siano sempre complete, prive di errori o aggiornate
|
||||||
|
in tempo reale. Nei limiti consentiti dalla legge, il titolare non potrà essere ritenuto
|
||||||
|
responsabile per danni derivanti dall'utilizzo del sito o dall'affidamento riposto nelle
|
||||||
|
informazioni ivi contenute.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>8. Modifiche</h2>
|
||||||
|
<p>
|
||||||
|
Il titolare si riserva il diritto di aggiornare, modificare o rimuovere in qualsiasi momento
|
||||||
|
contenuti, servizi, condizioni d'uso e presenti note legali, senza obbligo di preavviso.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>9. Legge applicabile</h2>
|
||||||
|
<p>
|
||||||
|
L'utilizzo del sito è regolato dalla legge italiana. Restano salve le tutele eventualmente
|
||||||
|
previste dalla normativa inderogabile applicabile agli utenti.
|
||||||
|
</p>
|
||||||
|
</LegalPageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
108
clinica-app/client/src/pages/PrivacyPolicyPage.tsx
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import LegalPageLayout from "@/components/LegalPageLayout";
|
||||||
|
|
||||||
|
export default function PrivacyPolicyPage() {
|
||||||
|
return (
|
||||||
|
<LegalPageLayout
|
||||||
|
eyebrow="Informativa privacy"
|
||||||
|
title="Privacy Policy"
|
||||||
|
intro="Questa informativa descrive come vengono trattati i dati personali degli utenti che consultano il sito della Clinica Veterinaria Formiginese, contattano la struttura o utilizzano i servizi online via via attivati."
|
||||||
|
updatedAt="23 maggio 2026"
|
||||||
|
>
|
||||||
|
<h2>1. Titolare del trattamento</h2>
|
||||||
|
<p>
|
||||||
|
Il titolare del trattamento dei dati personali trattati attraverso questo sito è la
|
||||||
|
Clinica Veterinaria Formiginese, con sede operativa in Via Quattro Passi, 16 -
|
||||||
|
41043 Formigine (MO), contattabile all'indirizzo email{" "}
|
||||||
|
<a href="mailto:clinicaveterinariaformiginese@gmail.com">
|
||||||
|
clinicaveterinariaformiginese@gmail.com
|
||||||
|
</a>{" "}
|
||||||
|
e al numero <a href="tel:0598396263">059 839.62.63</a>.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Eventuali ulteriori dati identificativi del titolare e i riferimenti amministrativi
|
||||||
|
potranno essere integrati in questa pagina in occasione dei successivi aggiornamenti.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>2. Tipologie di dati trattati</h2>
|
||||||
|
<p>Il sito può trattare le seguenti categorie di dati:</p>
|
||||||
|
<ul>
|
||||||
|
<li>dati di navigazione e dati tecnici necessari al funzionamento del sito;</li>
|
||||||
|
<li>dati comunicati spontaneamente tramite email, telefono o moduli di contatto;</li>
|
||||||
|
<li>dati inseriti nei servizi online di registrazione e richiesta di prenotazione;</li>
|
||||||
|
<li>dati necessari alla gestione delle richieste informative e organizzative.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>3. Finalità del trattamento</h2>
|
||||||
|
<p>I dati possono essere trattati per:</p>
|
||||||
|
<ul>
|
||||||
|
<li>consentire la navigazione e la sicurezza del sito;</li>
|
||||||
|
<li>rispondere a richieste di informazioni inviate dagli utenti;</li>
|
||||||
|
<li>gestire richieste di registrazione all'area riservata;</li>
|
||||||
|
<li>gestire richieste di prenotazione visita non vincolanti e i relativi contatti di follow-up;</li>
|
||||||
|
<li>adempiere a obblighi di legge, amministrativi o di tutela dei diritti del titolare.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>4. Base giuridica</h2>
|
||||||
|
<p>
|
||||||
|
Il trattamento è effettuato, a seconda dei casi, sulla base dell'art. 6, par. 1, lett. b)
|
||||||
|
del Regolamento (UE) 2016/679 per l'esecuzione di misure precontrattuali richieste
|
||||||
|
dall'interessato, dell'art. 6, par. 1, lett. c) per obblighi di legge, e dell'art. 6,
|
||||||
|
par. 1, lett. f) per il legittimo interesse del titolare alla sicurezza del sito e alla
|
||||||
|
gestione organizzativa delle richieste.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>5. Modalità di trattamento</h2>
|
||||||
|
<p>
|
||||||
|
I dati sono trattati con strumenti elettronici e organizzativi idonei a garantirne
|
||||||
|
riservatezza, integrità, disponibilità e aggiornamento, nel rispetto dei principi di
|
||||||
|
liceità, correttezza, trasparenza e minimizzazione.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>6. Conservazione dei dati</h2>
|
||||||
|
<p>
|
||||||
|
I dati sono conservati per il tempo strettamente necessario alle finalità per cui sono
|
||||||
|
raccolti e, successivamente, nei limiti previsti dalle norme applicabili o comunque per
|
||||||
|
il tempo necessario alla tutela dei diritti del titolare.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Le richieste di contatto o prenotazione possono essere conservate per il tempo necessario
|
||||||
|
alla loro gestione e per l'eventuale definizione dei successivi rapporti organizzativi o
|
||||||
|
amministrativi con l'utente.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>7. Destinatari dei dati</h2>
|
||||||
|
<p>
|
||||||
|
I dati possono essere trattati da personale autorizzato della clinica e, ove necessario,
|
||||||
|
da fornitori di servizi tecnici e informatici che operano quali responsabili o soggetti
|
||||||
|
autorizzati al trattamento, nei limiti delle rispettive competenze.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>8. Trasferimenti verso Paesi terzi</h2>
|
||||||
|
<p>
|
||||||
|
Alcune risorse tecniche di terze parti eventualmente richiamate dal sito, come servizi di
|
||||||
|
distribuzione di font o contenuti esterni, possono comportare il trattamento di dati tecnici
|
||||||
|
da parte dei relativi fornitori. Tali trattamenti avvengono secondo le condizioni e le
|
||||||
|
garanzie previste dai rispettivi operatori.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>9. Diritti degli interessati</h2>
|
||||||
|
<p>
|
||||||
|
Gli interessati possono esercitare i diritti previsti dagli articoli 15 e seguenti del
|
||||||
|
Regolamento (UE) 2016/679, tra cui accesso, rettifica, cancellazione, limitazione del
|
||||||
|
trattamento, opposizione e, ove applicabile, portabilità dei dati, contattando il titolare
|
||||||
|
ai recapiti sopra indicati.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Resta ferma la possibilità di proporre reclamo al Garante per la protezione dei dati
|
||||||
|
personali, qualora si ritenga che il trattamento avvenga in violazione della normativa
|
||||||
|
applicabile.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>10. Natura del conferimento</h2>
|
||||||
|
<p>
|
||||||
|
Il conferimento dei dati eventualmente richiesti nei moduli del sito è facoltativo, ma il
|
||||||
|
mancato conferimento può impedire alla clinica di dare seguito alla richiesta dell'utente.
|
||||||
|
</p>
|
||||||
|
</LegalPageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -150,7 +150,15 @@ function vitePluginManusDebugCollector(): Plugin {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const plugins = [react(), tailwindcss(), jsxLocPlugin(), vitePluginManusRuntime(), vitePluginManusDebugCollector()];
|
const isProductionBuild = process.env.NODE_ENV === "production";
|
||||||
|
|
||||||
|
const plugins = [
|
||||||
|
react(),
|
||||||
|
tailwindcss(),
|
||||||
|
jsxLocPlugin(),
|
||||||
|
!isProductionBuild && vitePluginManusRuntime(),
|
||||||
|
!isProductionBuild && vitePluginManusDebugCollector(),
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins,
|
plugins,
|
||||||
@@ -168,7 +176,7 @@ export default defineConfig({
|
|||||||
emptyOutDir: true,
|
emptyOutDir: true,
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
port: 3000,
|
port: 3001,
|
||||||
strictPort: false, // Will find next available port if 3000 is busy
|
strictPort: false, // Will find next available port if 3000 is busy
|
||||||
host: true,
|
host: true,
|
||||||
allowedHosts: [
|
allowedHosts: [
|
||||||
|
|||||||
309
coming-soon/index.html
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="it">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Clinica Veterinaria Formiginese | Coming Soon</title>
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="Il nuovo sito della Clinica Veterinaria Formiginese sarà presto online."
|
||||||
|
/>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--ink: #15384f;
|
||||||
|
--ink-soft: #315f78;
|
||||||
|
--aqua: #54c8bf;
|
||||||
|
--sand: #f4ede2;
|
||||||
|
--paper: #fffdf9;
|
||||||
|
--mist: rgba(255, 255, 255, 0.72);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
font-family: "Segoe UI", Arial, sans-serif;
|
||||||
|
color: var(--ink);
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgba(84, 200, 191, 0.22), transparent 38%),
|
||||||
|
radial-gradient(circle at bottom right, rgba(21, 56, 79, 0.18), transparent 32%),
|
||||||
|
linear-gradient(135deg, #f8f4ec 0%, #fefcf9 48%, #eef7f7 100%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell {
|
||||||
|
width: min(1120px, 100%);
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.1fr 0.9fr;
|
||||||
|
gap: 28px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
background: var(--mist);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
border: 1px solid rgba(21, 56, 79, 0.08);
|
||||||
|
border-radius: 30px;
|
||||||
|
box-shadow: 0 24px 70px rgba(21, 56, 79, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy {
|
||||||
|
padding: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(84, 200, 191, 0.16);
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow::before {
|
||||||
|
content: "";
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--aqua);
|
||||||
|
box-shadow: 0 0 0 5px rgba(84, 200, 191, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 28px 0 16px;
|
||||||
|
font-size: clamp(2.6rem, 5vw, 4.8rem);
|
||||||
|
line-height: 0.95;
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 span {
|
||||||
|
display: block;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
max-width: 46ch;
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1.75;
|
||||||
|
color: rgba(21, 56, 79, 0.82);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chips {
|
||||||
|
margin-top: 28px;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, 0.72);
|
||||||
|
border: 1px solid rgba(21, 56, 79, 0.1);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.note {
|
||||||
|
margin-top: 34px;
|
||||||
|
padding: 18px 20px;
|
||||||
|
border-radius: 20px;
|
||||||
|
background: rgba(21, 56, 79, 0.06);
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.visual {
|
||||||
|
padding: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artboard {
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 26px;
|
||||||
|
min-height: 560px;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.7), rgba(255, 255, 255, 0.3)),
|
||||||
|
linear-gradient(160deg, #dff5f1 0%, #f7f0e7 48%, #dcecf8 100%);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artboard::before,
|
||||||
|
.artboard::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
border-radius: 50%;
|
||||||
|
filter: blur(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.artboard::before {
|
||||||
|
width: 210px;
|
||||||
|
height: 210px;
|
||||||
|
background: rgba(84, 200, 191, 0.2);
|
||||||
|
top: -60px;
|
||||||
|
right: -40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artboard::after {
|
||||||
|
width: 260px;
|
||||||
|
height: 260px;
|
||||||
|
background: rgba(21, 56, 79, 0.12);
|
||||||
|
bottom: -80px;
|
||||||
|
left: -70px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stamp {
|
||||||
|
position: absolute;
|
||||||
|
top: 22px;
|
||||||
|
left: 22px;
|
||||||
|
z-index: 2;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, 0.82);
|
||||||
|
border: 1px solid rgba(21, 56, 79, 0.08);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.shell {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy {
|
||||||
|
padding: 34px 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.visual {
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artboard {
|
||||||
|
min-height: 420px;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="shell">
|
||||||
|
<section class="panel copy">
|
||||||
|
<div class="eyebrow">Nuovo sito in preparazione</div>
|
||||||
|
<h1>
|
||||||
|
Clinica Veterinaria
|
||||||
|
<span>Formiginese</span>
|
||||||
|
</h1>
|
||||||
|
<p>
|
||||||
|
Stiamo preparando una nuova esperienza digitale pensata per presentare al meglio la
|
||||||
|
clinica, il team medico e i servizi dedicati alla salute dei vostri animali.
|
||||||
|
</p>
|
||||||
|
<div class="chips">
|
||||||
|
<div class="chip">Servizi specialistici</div>
|
||||||
|
<div class="chip">Team clinico</div>
|
||||||
|
<div class="chip">Prenotazioni online</div>
|
||||||
|
<div class="chip">Area clienti dedicata</div>
|
||||||
|
</div>
|
||||||
|
<div class="note">
|
||||||
|
Il sito completo sarà pubblicato a breve. Nel frattempo la clinica continua a essere
|
||||||
|
disponibile ai consueti recapiti telefonici.
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel visual">
|
||||||
|
<div class="artboard">
|
||||||
|
<div class="stamp">Coming Soon</div>
|
||||||
|
<svg viewBox="0 0 680 760" aria-hidden="true">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="card" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#ffffff" />
|
||||||
|
<stop offset="100%" stop-color="#f4ede2" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="teal" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#68d7cf" />
|
||||||
|
<stop offset="100%" stop-color="#2d9f98" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="blue" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#264f68" />
|
||||||
|
<stop offset="100%" stop-color="#16384f" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<g opacity="0.9">
|
||||||
|
<circle cx="540" cy="122" r="68" fill="#ffffff" />
|
||||||
|
<circle cx="578" cy="146" r="12" fill="#54c8bf" opacity="0.5" />
|
||||||
|
<circle cx="500" cy="106" r="14" fill="#15384f" opacity="0.08" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<g transform="translate(62 118)">
|
||||||
|
<rect width="556" height="506" rx="38" fill="url(#card)" />
|
||||||
|
<rect x="24" y="22" width="508" height="462" rx="28" fill="#ffffff" opacity="0.78" />
|
||||||
|
|
||||||
|
<rect x="56" y="58" width="214" height="264" rx="26" fill="url(#teal)" opacity="0.18" />
|
||||||
|
<rect x="304" y="58" width="196" height="108" rx="24" fill="#edf5f7" />
|
||||||
|
<rect x="304" y="184" width="196" height="138" rx="24" fill="#f8f3ec" />
|
||||||
|
<rect x="56" y="348" width="444" height="84" rx="22" fill="#15384f" opacity="0.06" />
|
||||||
|
|
||||||
|
<circle cx="164" cy="172" r="82" fill="url(#blue)" />
|
||||||
|
<ellipse cx="162" cy="207" rx="104" ry="88" fill="#1c445d" />
|
||||||
|
<ellipse cx="392" cy="108" rx="46" ry="30" fill="#dce9ee" />
|
||||||
|
<ellipse cx="392" cy="234" rx="70" ry="42" fill="#efe4d4" />
|
||||||
|
|
||||||
|
<path d="M155 104c-18-28-45-43-76-43 8 20 21 42 39 62" fill="#0f344b" />
|
||||||
|
<path d="M174 104c18-28 45-43 76-43-8 20-21 42-39 62" fill="#0f344b" />
|
||||||
|
<ellipse cx="139" cy="192" rx="18" ry="14" fill="#ffffff" />
|
||||||
|
<ellipse cx="185" cy="192" rx="18" ry="14" fill="#ffffff" />
|
||||||
|
<circle cx="139" cy="192" r="7" fill="#15384f" />
|
||||||
|
<circle cx="185" cy="192" r="7" fill="#15384f" />
|
||||||
|
<path d="M151 222c12 14 26 14 38 0" stroke="#54c8bf" stroke-width="8" stroke-linecap="round" fill="none" />
|
||||||
|
<path d="M162 212l-12 12h24z" fill="#54c8bf" />
|
||||||
|
|
||||||
|
<g transform="translate(344 74)">
|
||||||
|
<rect width="146" height="18" rx="9" fill="#15384f" opacity="0.12" />
|
||||||
|
<rect y="34" width="108" height="14" rx="7" fill="#54c8bf" opacity="0.32" />
|
||||||
|
<rect y="60" width="122" height="14" rx="7" fill="#15384f" opacity="0.08" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<g transform="translate(332 212)">
|
||||||
|
<rect width="144" height="16" rx="8" fill="#15384f" opacity="0.12" />
|
||||||
|
<rect y="34" width="122" height="14" rx="7" fill="#54c8bf" opacity="0.26" />
|
||||||
|
<rect y="60" width="138" height="14" rx="7" fill="#15384f" opacity="0.08" />
|
||||||
|
<rect y="86" width="104" height="14" rx="7" fill="#15384f" opacity="0.08" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<g transform="translate(82 370)">
|
||||||
|
<rect width="84" height="40" rx="20" fill="#54c8bf" />
|
||||||
|
<rect x="100" width="112" height="40" rx="20" fill="#ffffff" stroke="#15384f" opacity="0.28" />
|
||||||
|
<rect x="228" width="160" height="40" rx="20" fill="#15384f" opacity="0.08" />
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
71
deploy_to_local_server.bat
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
@echo off
|
||||||
|
setlocal
|
||||||
|
|
||||||
|
set "DEPLOY_ROOT=C:\deploy\clinica_veterinaria_formiginese"
|
||||||
|
set "APP_DIR=%DEPLOY_ROOT%\clinica-app"
|
||||||
|
set "DEPLOY_BRANCH="
|
||||||
|
|
||||||
|
echo === Deploy demo Clinica Veterinaria Formiginese ===
|
||||||
|
echo.
|
||||||
|
|
||||||
|
if not exist "%DEPLOY_ROOT%\" (
|
||||||
|
echo ERRORE: cartella deploy non trovata: "%DEPLOY_ROOT%"
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
if not exist "%APP_DIR%\" (
|
||||||
|
echo ERRORE: cartella app non trovata: "%APP_DIR%"
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
echo [1/3] Aggiornamento repository di deploy...
|
||||||
|
pushd "%DEPLOY_ROOT%" || exit /b 1
|
||||||
|
|
||||||
|
for /f "delims=" %%i in ('git branch --show-current') do set "DEPLOY_BRANCH=%%i"
|
||||||
|
|
||||||
|
if "%DEPLOY_BRANCH%"=="" (
|
||||||
|
echo.
|
||||||
|
echo ERRORE: impossibile determinare il branch corrente nella copia di deploy.
|
||||||
|
popd
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
echo Branch deploy attivo: %DEPLOY_BRANCH%
|
||||||
|
|
||||||
|
git fetch origin
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo.
|
||||||
|
echo ERRORE: git fetch non riuscito.
|
||||||
|
popd
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
git reset --hard origin/%DEPLOY_BRANCH%
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo.
|
||||||
|
echo ERRORE: git reset del branch di deploy non riuscito.
|
||||||
|
popd
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
popd
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo [2/3] Build frontend...
|
||||||
|
pushd "%APP_DIR%" || exit /b 1
|
||||||
|
call pnpm run build
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo.
|
||||||
|
echo ERRORE: build non riuscita.
|
||||||
|
popd
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
popd
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo [3/3] Deploy completato.
|
||||||
|
echo I file aggiornati sono serviti da Caddy da:
|
||||||
|
echo %APP_DIR%\dist\public
|
||||||
|
echo.
|
||||||
|
|
||||||
|
endlocal
|
||||||
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/
|
||||||
|
```
|
||||||
21
istruzioni.txt
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
Ho aggiunto due file nel repository locale:
|
||||||
|
|
||||||
|
publish_demo.bat
|
||||||
|
coming-soon/index.html
|
||||||
|
Come funziona:
|
||||||
|
|
||||||
|
publish_demo.bat true pubblica la demo completa
|
||||||
|
publish_demo.bat false mostra la pagina coming soon
|
||||||
|
lo script aggiorna il blocco del dominio nel C:\caddy\Caddyfile e ricarica Caddy
|
||||||
|
Prima di usarlo, fai una volta sola il normale flusso repo -> deploy, così la cartella coming-soon arriva anche in:
|
||||||
|
C:\deploy\clinica_veterinaria_formiginese
|
||||||
|
|
||||||
|
Quindi:
|
||||||
|
|
||||||
|
fai git add ., git commit, git push nel repo locale
|
||||||
|
lancia deploy_to_local_server.bat
|
||||||
|
poi puoi usare:
|
||||||
|
publish_demo.bat false
|
||||||
|
oppure
|
||||||
|
|
||||||
|
publish_demo.bat true
|
||||||
47
menu_sito.txt
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
- servizi
|
||||||
|
- visite clincihe e medicina preventiva
|
||||||
|
- ecografia
|
||||||
|
- radiologia
|
||||||
|
- laboratorio
|
||||||
|
- ematologia
|
||||||
|
- biochimica
|
||||||
|
- urine
|
||||||
|
- citologia
|
||||||
|
- coagulazione
|
||||||
|
- endoscopia
|
||||||
|
- laparoscopia
|
||||||
|
|
||||||
|
- visite specialistiche
|
||||||
|
- oncologia
|
||||||
|
- dermatologia
|
||||||
|
- oculistica
|
||||||
|
- nutrizione
|
||||||
|
- ortopedia
|
||||||
|
|
||||||
|
- Team medico
|
||||||
|
|
||||||
|
Dott. Paolo Parmeggiani
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Dott.ssa Irene Paganelli
|
||||||
|
Dott. Simone Tinti
|
||||||
|
Dott.ssa Michela Sghedoni
|
||||||
|
Dott. Luca Pietri
|
||||||
|
|
||||||
|
-Collaborazioni
|
||||||
|
|
||||||
|
Dott. Andrea Arrigoni
|
||||||
|
Dott. Alessandro Poli
|
||||||
|
Dott.ssa Elena Venturelli
|
||||||
|
Dott.ssa Cinzia Pellegrini
|
||||||
|
|
||||||
|
-Team Tecnici Veterinari
|
||||||
|
|
||||||
|
Alessia Succi
|
||||||
|
Francesca Polato
|
||||||
|
Cristina Vecchi
|
||||||
|
Giada Palumbo
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
76
publish_demo.bat
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
@echo off
|
||||||
|
setlocal
|
||||||
|
|
||||||
|
if "%~1"=="" goto usage
|
||||||
|
|
||||||
|
set "MODE=%~1"
|
||||||
|
set "CADDYFILE=C:\caddy\Caddyfile"
|
||||||
|
set "DOMAIN=www.clinicaveterinariaformiginese.it"
|
||||||
|
set "LIVE_ROOT=C:\deploy\clinica_veterinaria_formiginese\clinica-app\dist\public"
|
||||||
|
set "COMING_SOON_ROOT=C:\deploy\clinica_veterinaria_formiginese\coming-soon"
|
||||||
|
|
||||||
|
if /I "%MODE%"=="true" goto publish_live
|
||||||
|
if /I "%MODE%"=="false" goto publish_coming_soon
|
||||||
|
goto usage
|
||||||
|
|
||||||
|
:publish_live
|
||||||
|
echo Modalita selezionata: DEMO PUBBLICATA
|
||||||
|
powershell -NoProfile -ExecutionPolicy Bypass -Command ^
|
||||||
|
"$path = '%CADDYFILE%';" ^
|
||||||
|
"$content = Get-Content -Raw $path;" ^
|
||||||
|
"$pattern = '(?ms)^%DOMAIN% \{[\s\S]*?^\}';" ^
|
||||||
|
"$replacement = '%DOMAIN% {' + [Environment]::NewLine + ' route {' + [Environment]::NewLine + ' handle /api/* {' + [Environment]::NewLine + ' reverse_proxy 127.0.0.1:8000' + [Environment]::NewLine + ' }' + [Environment]::NewLine + ' handle {' + [Environment]::NewLine + ' root * %LIVE_ROOT%' + [Environment]::NewLine + ' try_files {path} /index.html' + [Environment]::NewLine + ' file_server' + [Environment]::NewLine + ' }' + [Environment]::NewLine + ' }' + [Environment]::NewLine + '}';" ^
|
||||||
|
"$updated = [regex]::Replace($content, $pattern, $replacement);" ^
|
||||||
|
"Set-Content -Path $path -Value $updated -Encoding UTF8;"
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo ERRORE: aggiornamento del Caddyfile non riuscito.
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
goto reload_caddy
|
||||||
|
|
||||||
|
:publish_coming_soon
|
||||||
|
echo Modalita selezionata: COMING SOON
|
||||||
|
powershell -NoProfile -ExecutionPolicy Bypass -Command ^
|
||||||
|
"$path = '%CADDYFILE%';" ^
|
||||||
|
"$content = Get-Content -Raw $path;" ^
|
||||||
|
"$pattern = '(?ms)^%DOMAIN% \{[\s\S]*?^\}';" ^
|
||||||
|
"$replacement = '%DOMAIN% {' + [Environment]::NewLine + ' route {' + [Environment]::NewLine + ' handle /api/* {' + [Environment]::NewLine + ' reverse_proxy 127.0.0.1:8000' + [Environment]::NewLine + ' }' + [Environment]::NewLine + ' handle {' + [Environment]::NewLine + ' root * %COMING_SOON_ROOT%' + [Environment]::NewLine + ' file_server' + [Environment]::NewLine + ' }' + [Environment]::NewLine + ' }' + [Environment]::NewLine + '}';" ^
|
||||||
|
"$updated = [regex]::Replace($content, $pattern, $replacement);" ^
|
||||||
|
"Set-Content -Path $path -Value $updated -Encoding UTF8;"
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo ERRORE: aggiornamento del Caddyfile non riuscito.
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
:reload_caddy
|
||||||
|
echo.
|
||||||
|
echo Ricarico Caddy...
|
||||||
|
C:\caddy\caddy.exe validate --config "%CADDYFILE%" --adapter caddyfile
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo ERRORE: Caddyfile non valido. Nessun reload eseguito.
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
C:\caddy\caddy.exe reload --config "%CADDYFILE%" --adapter caddyfile
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo ERRORE: reload di Caddy non riuscito.
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
echo.
|
||||||
|
if /I "%MODE%"=="true" (
|
||||||
|
echo Demo online: https://%DOMAIN%
|
||||||
|
) else (
|
||||||
|
echo Coming soon online: https://%DOMAIN%
|
||||||
|
)
|
||||||
|
echo Operazione completata.
|
||||||
|
exit /b 0
|
||||||
|
|
||||||
|
:usage
|
||||||
|
echo Uso:
|
||||||
|
echo publish_demo.bat true
|
||||||
|
echo publish_demo.bat false
|
||||||
|
echo.
|
||||||
|
echo true = pubblica la demo completa
|
||||||
|
echo false = mostra la pagina coming soon
|
||||||
|
exit /b 1
|
||||||