15 Commits

58 changed files with 2340 additions and 732 deletions

1
.gitignore vendored
View File

@@ -6,6 +6,7 @@ __pycache__/
# Local environment files # Local environment files
.env .env
backend/.env backend/.env
perl_mail.txt
# Local SQLite databases # Local SQLite databases
data/*.db data/*.db

250
README.md
View File

@@ -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/news_consulenza.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

BIN
assets/news_cucciolo.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

BIN
assets/news_gatto.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

BIN
assets/ortovet.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

View File

@@ -3,3 +3,25 @@ APP_ENV=development
APP_HOST=127.0.0.1 APP_HOST=127.0.0.1
APP_PORT=8000 APP_PORT=8000
DATABASE_URL=sqlite:///C:/devel/clinica_veterinaria_formiginese/data/clinica.db 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=

View 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.",
)

View File

@@ -6,9 +6,11 @@ from dotenv import load_dotenv
BASE_DIR = Path(__file__).resolve().parent.parent BASE_DIR = Path(__file__).resolve().parent.parent
PROJECT_ROOT = BASE_DIR.parent PROJECT_ROOT = BASE_DIR.parent
ENV_FILE = PROJECT_ROOT / ".env" ENV_FILE = BASE_DIR / ".env"
ROOT_ENV_FILE = PROJECT_ROOT / ".env"
load_dotenv(ENV_FILE) load_dotenv(ROOT_ENV_FILE)
load_dotenv(ENV_FILE, override=True)
class Settings: class Settings:
@@ -19,6 +21,45 @@ class Settings:
self.app_port = int(os.getenv("APP_PORT", "8000")) self.app_port = int(os.getenv("APP_PORT", "8000"))
default_sqlite_path = (PROJECT_ROOT / "data" / "clinica.db").resolve() default_sqlite_path = (PROJECT_ROOT / "data" / "clinica.db").resolve()
self.database_url = os.getenv("DATABASE_URL", f"sqlite:///{default_sqlite_path.as_posix()}") 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 @property
def sqlite_file_path(self) -> str | None: def sqlite_file_path(self) -> str | None:
@@ -27,5 +68,31 @@ class Settings:
return self.database_url.removeprefix(sqlite_prefix) return self.database_url.removeprefix(sqlite_prefix)
return None 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() settings = Settings()

View File

@@ -1,5 +1,6 @@
from fastapi import FastAPI from fastapi import FastAPI
from app.api.booking import router as booking_router
from app.api.health import router as health_router from app.api.health import router as health_router
from app.config import settings from app.config import settings
from app.db.init_db import init_db from app.db.init_db import init_db
@@ -9,3 +10,4 @@ init_db()
app = FastAPI(title=settings.app_name) app = FastAPI(title=settings.app_name)
app.include_router(health_router) app.include_router(health_router)
app.include_router(booking_router)

View 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

View File

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

View File

@@ -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]}")

View File

@@ -4,3 +4,4 @@ sqlalchemy
pydantic pydantic
python-dotenv python-dotenv
aiosqlite aiosqlite
pydantic[email]

View File

@@ -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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

View File

@@ -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>

View File

@@ -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>

View File

@@ -1,37 +1,15 @@
/* /*
* 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";
export default function AuthSection() {
const ref = useRef(null);
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 = [ const benefits = [
"Storico visite e referti digitali", "Storico visite e referti digitali",
"Promemoria vaccinazioni automatici", "Promemoria vaccinazioni automatici",
@@ -40,26 +18,59 @@ export default function AuthSection() {
"Gestione di più animali domestici", "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 ( return (
<section id="registrazione" className="py-20 md:py-28 bg-white"> <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() {
const ref = useRef(null);
const isInView = useInView(ref, { once: true, margin: "-80px" });
return (
<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&apos;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&apos;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="w-16 h-16 rounded-full bg-[#4ECDC4]/15 flex items-center justify-center mx-auto mb-4">
<CheckCircle2 size={32} className="text-[#4ECDC4]" />
</div>
<h3
className="text-[#1B4F72] mb-2"
style={{ fontFamily: "'Cormorant Garamond', serif", fontSize: "1.8rem" }}
>
Benvenuto!
</h3>
<p className="text-gray-600 text-sm mb-6">
La tua registrazione è avvenuta con successo. Ora puoi accedere a tutti i servizi
della tua area personale.
</p>
<Button
className="bg-[#1B4F72] hover:bg-[#163d5a] text-white"
onClick={() => setRegistered(false)}
>
Vai all'area personale
</Button>
</div>
) : (
<div className="bg-white rounded-2xl shadow-lg border border-gray-100 overflow-hidden">
{/* Tabs */}
<div className="flex border-b border-gray-100"> <div className="flex border-b border-gray-100">
{(["register", "login"] as const).map((t) => (
<button <button
key={t} type="button"
onClick={() => setTab(t)} disabled
className={`flex-1 py-4 text-sm font-semibold transition-all duration-200 ${ className="flex-1 cursor-not-allowed border-b-2 border-[#4ECDC4] bg-[#F5F0E8]/50 py-4 text-sm font-semibold text-[#1B4F72]"
tab === t
? "text-[#1B4F72] border-b-2 border-[#4ECDC4] bg-[#F5F0E8]/50"
: "text-gray-400 hover:text-gray-600"
}`}
> >
{t === "register" ? "Registrati" : "Accedi"} Registrati
</button>
<button
type="button"
disabled
className="flex-1 cursor-not-allowed py-4 text-sm font-semibold text-gray-400"
>
Accedi
</button> </button>
))}
</div> </div>
<form onSubmit={handleSubmit} className="p-6 md:p-8 space-y-4"> <div className="p-6 md:p-8">
{tab === "register" && ( <div className="mb-5 flex items-center gap-3 rounded-xl border border-[#E4D7C6] bg-[#FFF9F1] px-4 py-3">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-[#A95F3A]/12">
<Clock3 size={18} className="text-[#A95F3A]" />
</div>
<div> <div>
<label className="text-xs font-semibold text-gray-600 uppercase tracking-wide block mb-1.5"> <p className="text-sm font-semibold text-[#1B4F72]">Servizio in corso di attivazione</p>
<p className="text-xs text-gray-600">
Registrazione e accesso saranno disponibili a breve.
</p>
</div>
</div>
<div className="space-y-4 opacity-75">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label className="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-gray-600">
Nome Nome
</label> </label>
<div className="relative"> <DisabledInput icon={User} type="text" placeholder="Mario" />
<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>
<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">
Cognome Cognome
</label> </label>
<div className="relative"> <DisabledInput icon={User} type="text" placeholder="Rossi" />
<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>
)}
<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 Email
</label> </label>
<div className="relative"> <DisabledInput icon={Mail} type="email" placeholder="mario.rossi@email.it" />
<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>
<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">
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"
required disabled
id="privacy" className="mt-0.5 cursor-not-allowed accent-[#4ECDC4]"
className="mt-0.5 accent-[#4ECDC4]"
/> />
<label htmlFor="privacy" className="text-xs text-gray-500 leading-relaxed"> <p className="text-xs leading-relaxed text-gray-500">
Accetto la{" "} Accetto la Privacy Policy e i Termini di Servizio.
<button </p>
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> </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>
</div>
<button <button
type="button" type="button"
className="text-[#4ECDC4] hover:underline" aria-label="Servizio in corso di attivazione"
onClick={() => toast.info("Recupero password in arrivo")} onClick={showActivationToast}
> className="absolute inset-0 z-10"
Password dimenticata? />
</button>
</p>
)}
</form>
</div> </div>
)}
</motion.div> </motion.div>
</div> </div>
</div> </div>

View File

@@ -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]" />
</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&apos;invio del modulo non costituisce conferma automatica dell&apos;appuntamento.
Il team verificherà disponibilità, tipologia di visita e urgenza del caso prima
di confermare periodo e fascia oraria.
</p> </p>
{[ </div>
{ days: "Lunedì — Venerdì", hours: "09:00 — 12:30 · 14:30 — 19:00" }, </div>
{ days: "Sabato", hours: "09:00 — 12:30" }, </div>
{ days: "Domenica", hours: "Solo urgenze" },
].map((slot) => ( <div className="max-w-[39rem]">
<div key={slot.days} className="flex justify-between items-center text-sm"> <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">
<span className="text-white/70">{slot.days}</span> <h4
<span className="text-white font-medium">{slot.hours}</span> 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>
</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"
> >
<div>
<h3 <h3
className="text-[#1B4F72] mb-2" className="mb-2 text-[#1B4F72]"
style={{ fontFamily: "'Cormorant Garamond', serif", fontSize: "1.5rem", fontWeight: 600 }} style={{
fontFamily: "'Cormorant Garamond', serif",
fontSize: "1.5rem",
fontWeight: 600,
}}
> >
Richiedi un appuntamento Richiedi un appuntamento
</h3> </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&apos;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>
)} )}

View File

@@ -3,55 +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="mb-4">
<div
className="font-bold text-white leading-tight"
style={{ fontFamily: "'Cormorant Garamond', serif", fontSize: "1rem" }}
>
Clinica Veterinaria
</div>
<div className="text-[#4ECDC4] text-xs font-semibold tracking-widest uppercase">
Formiginese
</div>
</div>
<p className="text-white/60 text-sm leading-relaxed mb-4">
Cura specialistica per cani e gatti a Formigine.
Un team di professionisti al servizio della salute dei tuoi animali.
</p>
<a
href="https://it-it.facebook.com/clinicaveterinariaformiginese/"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 text-[#4ECDC4] hover:text-white transition-colors text-sm"
>
<Facebook size={16} />
Seguici su Facebook
</a>
</div>
{/* Link rapidi */}
<div> <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"
@@ -66,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>
@@ -74,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"
@@ -93,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>
@@ -111,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"
@@ -121,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" />
@@ -134,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>

View File

@@ -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 = [
{ {
@@ -48,20 +48,26 @@ const heroImages = [
}, },
]; ];
const openingHours = [
{ days: "Visite: Lunedì - Venerdì", hours: "09:00 - 19:30" },
{ days: "Visite: Sabato", hours: "09:00 - 17:00" },
{ days: "Urgenze: Lunedì - Venerdì", hours: "08:00 - 22:30" },
{ days: "Urgenze: Sabato e Festivi", hours: "09:00 - 20:00" },
];
export default function HeroSection() { 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}
@@ -71,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">
@@ -151,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>

View 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>
);
}

View File

@@ -6,6 +6,7 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ChevronDown, Menu, X, Phone, MapPin } from "lucide-react"; import { ChevronDown, Menu, X, Phone, MapPin } from "lucide-react";
import { toast } from "sonner";
type NavLink = { type NavLink = {
label: string; label: string;
@@ -47,7 +48,6 @@ const navLinks: NavLink[] = [
], ],
}, },
{ label: "Il Team", href: "#team" }, { label: "Il Team", href: "#team" },
{ label: "News", href: "#news" },
{ label: "Contatti", href: "#contatti" }, { label: "Contatti", href: "#contatti" },
]; ];
@@ -55,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);
@@ -73,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 &nbsp;|&nbsp; 320 532.24.39 (urgenze 24h) 059 839.62.63 &nbsp;|&nbsp; 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>
@@ -150,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>
@@ -206,7 +210,7 @@ export default function Navbar() {
<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>

View File

@@ -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",
}, },
]; ];

View File

@@ -1,32 +1,57 @@
/* /*
* DESIGN: "Clinical Warmth" * DESIGN: "Clinical Warmth"
* Sezione servizi con 6 card principali allineate al menu della clinica. * Sezione servizi con due livelli:
* Layout: immagine in alto, bordo superiore colorato, hover lift. * - Servizi Clinici
* - Visite Specialistiche
*/ */
import { motion, useInView } from "framer-motion"; import { motion, useInView } from "framer-motion";
import { useRef } from "react"; import { useRef } from "react";
import { Activity, ArrowRight, FlaskConical, Scan, Stethoscope, Video } 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", id: "visite-cliniche",
title: "Visite Cliniche", title: "Visite Cliniche",
subtitle: "Medicina preventiva", subtitle: "Medicina preventiva",
description: description:
"Percorsi di prevenzione e visite cliniche complete per monitorare lo stato di salute di cani e gatti in ogni fase della vita. Un approccio attento, continuativo e personalizzato.", "Percorsi di prevenzione e visite cliniche complete per monitorare lo stato di salute di cani e gatti in ogni fase della vita.",
image: "https://images.unsplash.com/photo-1628009368231-7bb7cfcb0def?w=900&q=85", image: "/images/services_visite_cliniche.jpg",
icon: Stethoscope, icon: Stethoscope,
color: "#1B4F72", color: "#1B4F72",
features: ["Check-up periodici", "Vaccinazioni", "Profilassi antiparassitaria", "Piani prevenzione senior"], features: ["Check-up periodici", "Vaccinazioni", "Profilassi antiparassitaria", "Controlli senior"],
}, },
{ {
id: "ecografia", id: "ecografia",
title: "Ecografia", title: "Ecografia",
subtitle: "Diagnostica non invasiva", subtitle: "Diagnostica non invasiva",
description: description:
"Indagini ecografiche per approfondire in modo rapido e non invasivo organi addominali, apparato urinario, apparato riproduttivo e principali sospetti clinici.", "Indagini ecografiche rapide per approfondire apparato addominale, urinario e riproduttivo con maggiore precisione clinica.",
image: "https://images.unsplash.com/photo-1579684385127-1ef15d508118?w=900&q=85", image: "/images/services_ecografia.jpg",
icon: Activity, icon: Activity,
color: "#4ECDC4", color: "#4ECDC4",
features: ["Ecografia addominale", "Controlli gravidanza", "Valutazioni urinarie", "Follow-up terapeutici"], features: ["Ecografia addominale", "Controlli gravidanza", "Valutazioni urinarie", "Follow-up terapeutici"],
@@ -36,8 +61,8 @@ const services = [
title: "Radiologia", title: "Radiologia",
subtitle: "Diagnostica per immagini", subtitle: "Diagnostica per immagini",
description: description:
"Radiografie digitali e diagnostica per immagini a supporto della visita clinica, utili per valutare apparato scheletrico, torace, addome e urgenze.", "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: "#2E86AB", color: "#2E86AB",
features: ["Radiografia digitale", "Studi toracici", "Valutazioni ortopediche", "Diagnostica d'urgenza"], features: ["Radiografia digitale", "Studi toracici", "Valutazioni ortopediche", "Diagnostica d'urgenza"],
@@ -47,8 +72,8 @@ const services = [
title: "Laboratorio", title: "Laboratorio",
subtitle: "Analisi interne", subtitle: "Analisi interne",
description: description:
"Laboratorio interno per esami rapidi e mirati, fondamentale per supportare diagnosi tempestive, monitoraggi terapeutici e controlli pre-operatori.", "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", color: "#1B4F72",
features: ["Ematologia", "Biochimica", "Urine", "Citologia", "Coagulazione"], features: ["Ematologia", "Biochimica", "Urine", "Citologia", "Coagulazione"],
@@ -58,8 +83,8 @@ const services = [
title: "Endoscopia", title: "Endoscopia",
subtitle: "Esplorazione mini-invasiva", subtitle: "Esplorazione mini-invasiva",
description: description:
"Tecniche endoscopiche per esplorare apparato digerente e vie respiratorie, riducendo invasivita e tempi di recupero quando il quadro clinico lo consente.", "Tecniche endoscopiche per esplorare apparato digerente e vie respiratorie riducendo invasivita e tempi di recupero.",
image: "https://images.unsplash.com/photo-1530026405186-ed1f139313f8?w=900&q=85", image: "/images/services_endoscopia.webp",
icon: Video, icon: Video,
color: "#4ECDC4", color: "#4ECDC4",
features: ["Valutazioni gastroenteriche", "Corpi estranei", "Biopsie mirate", "Esplorazioni respiratorie"], features: ["Valutazioni gastroenteriche", "Corpi estranei", "Biopsie mirate", "Esplorazioni respiratorie"],
@@ -69,33 +94,125 @@ const services = [
title: "Laparoscopia", title: "Laparoscopia",
subtitle: "Chirurgia mini-invasiva", subtitle: "Chirurgia mini-invasiva",
description: description:
"Procedure mini-invasive che permettono interventi piu delicati, incisioni ridotte e recuperi piu confortevoli rispetto alla chirurgia tradizionale.", "Procedure mini-invasive con incisioni ridotte, recuperi più confortevoli e maggiore delicatezza nei passaggi operatori.",
image: "https://d2xsxph8kpxj0f.cloudfront.net/310519663483796440/DLUbzKbSnCG3dLeob4sLeC/surgery_e92904ed.jpg", image: "/images/services_laparoscopia.jpg",
icon: Activity, icon: Activity,
color: "#2E86AB", color: "#2E86AB",
features: ["Procedure mini-invasive", "Biopsie laparoscopiche", "Riduzione del dolore", "Recupero post-operatorio"], 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.12 }} 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"
}`}
> >
<div className="h-1" style={{ backgroundColor: service.color }} /> <div className="h-1.5" style={{ backgroundColor: service.color }} />
<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 ?? ""}`}
/> />
<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"
@@ -106,36 +223,26 @@ function ServiceCard({ service, index }: { service: typeof services[0]; index: n
</div> </div>
<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>
<ul className="space-y-1.5 mb-5"> <ul className="mb-5 space-y-1.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>
<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} sara disponibile a breve.` });
}}
>
Scopri di piu
<ArrowRight size={15} className="transition-transform duration-200 group-hover/btn:translate-x-1" />
</button>
</div> </div>
</motion.div> </motion.div>
); );
@@ -143,7 +250,9 @@ 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" }}>
@@ -155,46 +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">
Sei aree di eccellenza clinica per accompagnare prevenzione, diagnosi e trattamento
dei tuoi animali domestici, con tecnologie avanzate e professionisti esperti.
</p> </p>
</motion.div> </motion.div>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-8"> <div className="grid grid-cols-1 gap-8 md:grid-cols-2 xl:grid-cols-3">
{services.map((service, index) => ( {clinicalServices.map((service, index) => (
<ServiceCard key={service.id} service={service} index={index} /> <ServiceCard key={service.id} service={service} index={index} />
))} ))}
</div> </div>
<div className="mt-8 xl:hidden flex justify-center">
<ServiceCard
service={cardiologyService}
index={clinicalServices.length}
className="w-full md:w-[calc(50%-1rem)]"
/>
</div>
<div className="mt-8 hidden xl:grid xl:grid-cols-3 xl:gap-8">
<div />
<ServiceCard service={cardiologyService} index={clinicalServices.length} className="w-full" />
<div />
</div>
<motion.div <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 sara 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>
); );

View File

@@ -1,153 +1,259 @@
/* /*
* 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>
{member.specialization && (
<span <span
className="inline-block text-xs font-semibold px-2.5 py-0.5 rounded-full mt-2" 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 }} style={{ backgroundColor: `${member.color}18`, color: member.color }}
> >
{member.specialization} {member.specialization}
</span> </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">
{/* Header */}
<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.7 }} transition={{ duration: 0.7 }}
className="mb-14" className="mb-10"
> >
<div className="flex items-center gap-3 mb-4"> <div className="mb-3 flex items-center gap-3">
<div className="w-12 h-0.5 bg-[#4ECDC4]" /> <div className="h-0.5 w-10 bg-[#4ECDC4]" />
<span className="text-[#4ECDC4] text-sm font-semibold uppercase tracking-widest"> <span className="text-xs font-semibold uppercase tracking-[0.2em] text-[#4ECDC4]">
Il Nostro Team {eyebrow}
</span> </span>
</div> </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> </motion.div>
{/* Grid team */} <div className="grid grid-cols-1 gap-6 sm:grid-cols-2 xl:grid-cols-3">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6"> {members.map((member, index) => (
{team.map((member, index) => (
<TeamCard key={member.name} member={member} index={index} /> <TeamCard key={member.name} member={member} index={index} />
))} ))}
</div> </div>
</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>
</section> </section>
); );
} }

View 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&apos;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&apos;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&apos;utente. Per tali strumenti, secondo la normativa
applicabile, non è richiesto il consenso preventivo dell&apos;utente, fermo restando l&apos;obbligo
di fornire un&apos;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&apos;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&apos;area riservata potranno essere utilizzati cookie
o identificatori tecnici di sessione strettamente necessari all&apos;autenticazione e alla gestione
sicura dell&apos;accesso. Anche tali strumenti, se limitati alla funzione tecnica richiesta
dall&apos;utente, rientrano normalmente tra quelli esenti da consenso preventivo.
</p>
<h2>7. Come gestire i cookie</h2>
<p>
L&apos;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&apos;uso dei cookie e degli altri strumenti tecnici è possibile
contattare la Clinica Veterinaria Formiginese all&apos;indirizzo{" "}
<a href="mailto:clinicaveterinariaformiginese@gmail.com">
clinicaveterinariaformiginese@gmail.com
</a>.
</p>
</LegalPageLayout>
);
}

View File

@@ -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>

View 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&apos;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&apos;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&apos;utilizzo del sito o dall&apos;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&apos;uso e presenti note legali, senza obbligo di preavviso.
</p>
<h2>9. Legge applicabile</h2>
<p>
L&apos;utilizzo del sito è regolato dalla legge italiana. Restano salve le tutele eventualmente
previste dalla normativa inderogabile applicabile agli utenti.
</p>
</LegalPageLayout>
);
}

View 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&apos;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&apos;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&apos;art. 6, par. 1, lett. b)
del Regolamento (UE) 2016/679 per l&apos;esecuzione di misure precontrattuali richieste
dall&apos;interessato, dell&apos;art. 6, par. 1, lett. c) per obblighi di legge, e dell&apos;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&apos;eventuale definizione dei successivi rapporti organizzativi o
amministrativi con l&apos;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&apos;utente.
</p>
</LegalPageLayout>
);
}

View File

@@ -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: [

View 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/
```

View File

@@ -11,13 +11,37 @@
- endoscopia - endoscopia
- laparoscopia - laparoscopia
- viite specialistiche - visite specialistiche
- oncologia - oncologia
- dermatologia - dermatologia
- oculistica - oculistica
- nutrizione - nutrizione
- ortopedia - ortopedia
- team medico - 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

View File

@@ -19,7 +19,7 @@ powershell -NoProfile -ExecutionPolicy Bypass -Command ^
"$path = '%CADDYFILE%';" ^ "$path = '%CADDYFILE%';" ^
"$content = Get-Content -Raw $path;" ^ "$content = Get-Content -Raw $path;" ^
"$pattern = '(?ms)^%DOMAIN% \{[\s\S]*?^\}';" ^ "$pattern = '(?ms)^%DOMAIN% \{[\s\S]*?^\}';" ^
"$replacement = '%DOMAIN% {' + [Environment]::NewLine + ' root * %LIVE_ROOT%' + [Environment]::NewLine + ' try_files {path} /index.html' + [Environment]::NewLine + ' file_server' + [Environment]::NewLine + '}';" ^ "$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);" ^ "$updated = [regex]::Replace($content, $pattern, $replacement);" ^
"Set-Content -Path $path -Value $updated -Encoding UTF8;" "Set-Content -Path $path -Value $updated -Encoding UTF8;"
if errorlevel 1 ( if errorlevel 1 (
@@ -34,7 +34,7 @@ powershell -NoProfile -ExecutionPolicy Bypass -Command ^
"$path = '%CADDYFILE%';" ^ "$path = '%CADDYFILE%';" ^
"$content = Get-Content -Raw $path;" ^ "$content = Get-Content -Raw $path;" ^
"$pattern = '(?ms)^%DOMAIN% \{[\s\S]*?^\}';" ^ "$pattern = '(?ms)^%DOMAIN% \{[\s\S]*?^\}';" ^
"$replacement = '%DOMAIN% {' + [Environment]::NewLine + ' root * %COMING_SOON_ROOT%' + [Environment]::NewLine + ' file_server' + [Environment]::NewLine + '}';" ^ "$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);" ^ "$updated = [regex]::Replace($content, $pattern, $replacement);" ^
"Set-Content -Path $path -Value $updated -Encoding UTF8;" "Set-Content -Path $path -Value $updated -Encoding UTF8;"
if errorlevel 1 ( if errorlevel 1 (