Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 753f0ec346 | |||
| ae31cbaaba | |||
| 017b6a905c | |||
| 16bfcb8b47 | |||
| cc70144183 | |||
| ba4a73d5ca | |||
| 8d83f0c619 | |||
| f8171bb3dd | |||
| 04fb49ad4b | |||
| d9b617e706 | |||
| 580a659b5a | |||
| bd5b845e43 | |||
| 83fb4b8c1e | |||
| 57367b75c8 | |||
| f8e6daa084 |
1
.gitignore
vendored
@@ -6,6 +6,7 @@ __pycache__/
|
||||
# Local environment files
|
||||
.env
|
||||
backend/.env
|
||||
perl_mail.txt
|
||||
|
||||
# Local SQLite databases
|
||||
data/*.db
|
||||
|
||||
250
README.md
@@ -1,66 +1,222 @@
|
||||
# 🏥 Clinica Veterinaria Formiginese - Deploy Package
|
||||
# Clinica Veterinaria Formiginese
|
||||
|
||||
Questo pacchetto contiene il codice sorgente completo, le immagini e le istruzioni per deployare il sito della Clinica Veterinaria Formiginese su un server privato con Node.js.
|
||||
Repository operativo del sito della Clinica Veterinaria Formiginese, con frontend React/Vite e backend FastAPI per la gestione delle richieste di prenotazione.
|
||||
|
||||
## 📋 Contenuti del Pacchetto
|
||||
## Struttura
|
||||
|
||||
- **clinica-app/** - Progetto React completo con sorgenti
|
||||
- **assets/** - Tutte le 7 immagini (animali + clinica)
|
||||
- **SETUP_ISTRUZIONI.md** - Guida dettagliata di installazione
|
||||
- **README.md** - Questo file
|
||||
- `clinica-app/` - frontend React + Vite e build pubblica del sito
|
||||
- `backend/` - backend FastAPI, schema prenotazione e servizio email
|
||||
- `assets/` - materiali grafici di supporto
|
||||
- `deploy_to_local_server.bat` - aggiorna la copia locale di deploy partendo da Gitea
|
||||
- `SETUP_ISTRUZIONI.md` - note di installazione storiche
|
||||
|
||||
## 🚀 Quick Start
|
||||
## Stack
|
||||
|
||||
```bash
|
||||
# 1. Estrai il pacchetto
|
||||
unzip clinica-veterinaria-deploy.zip
|
||||
cd clinica-deploy
|
||||
- Frontend: React 19, TypeScript, Tailwind CSS, Vite
|
||||
- Backend: FastAPI, Uvicorn
|
||||
- Web server online: Apache
|
||||
- Certificati: Let's Encrypt / Certbot
|
||||
|
||||
# 2. Copia le immagini
|
||||
cp -r assets/* clinica-app/client/public/images/
|
||||
mkdir -p clinica-app/client/public/images
|
||||
## Ambiente locale
|
||||
|
||||
# 3. Installa e avvia
|
||||
cd clinica-app
|
||||
npm install
|
||||
npm run build
|
||||
npm start
|
||||
### Repository di lavoro
|
||||
|
||||
Percorso:
|
||||
|
||||
```txt
|
||||
C:\devel\clinica_veterinaria_formiginese
|
||||
```
|
||||
|
||||
Il sito sarà disponibile a `http://localhost:3000`
|
||||
Per sviluppo rapido:
|
||||
|
||||
## 📖 Documentazione Completa
|
||||
```bash
|
||||
cd C:\devel\clinica_veterinaria_formiginese\clinica-app
|
||||
pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
Leggi il file **SETUP_ISTRUZIONI.md** per:
|
||||
- Requisiti di sistema
|
||||
- Installazione passo-passo
|
||||
- Configurazione avanzata (Nginx, PM2, ecc.)
|
||||
- Personalizzazione (colori, testi, immagini)
|
||||
- Troubleshooting
|
||||
Nota: la porta di sviluppo Vite e stata spostata a `3001`, per evitare conflitti con Gitea in locale.
|
||||
|
||||
## 🎯 Caratteristiche
|
||||
### Repository di deploy locale
|
||||
|
||||
✅ Homepage completa con carousel hero (7 immagini)
|
||||
✅ Sezioni: Chi Siamo, Servizi, Team, Blog, Prenotazioni, Registrazione
|
||||
✅ Design "Clinical Warmth" con blu petrolio e verde acqua
|
||||
✅ Responsive design (mobile, tablet, desktop)
|
||||
✅ Animazioni fluide e transizioni eleganti
|
||||
✅ Performance ottimizzate
|
||||
Percorso:
|
||||
|
||||
## 🛠️ Stack Tecnologico
|
||||
```txt
|
||||
C:\deploy\clinica_veterinaria_formiginese
|
||||
```
|
||||
|
||||
- **Frontend**: React 19 + TypeScript + Tailwind CSS 4
|
||||
- **Backend**: Node.js + Express
|
||||
- **Build Tool**: Vite
|
||||
- **UI Components**: shadcn/ui
|
||||
- **Animazioni**: Framer Motion
|
||||
Il sito locale servito da Caddy legge questa copia, non quella in `C:\devel\...`.
|
||||
|
||||
## 📞 Supporto
|
||||
Per aggiornare il deploy locale:
|
||||
|
||||
Per problemi durante l'installazione, consulta la sezione "Troubleshooting" in SETUP_ISTRUZIONI.md
|
||||
```bat
|
||||
deploy_to_local_server.bat
|
||||
```
|
||||
|
||||
---
|
||||
Attenzione: lo script riallinea la copia locale di deploy a cio che e stato pushato su Gitea.
|
||||
|
||||
**Versione**: 1.0.0
|
||||
**Data**: 31 Marzo 2026
|
||||
**Licenza**: Privato
|
||||
## Build frontend
|
||||
|
||||
Da eseguire dentro `clinica-app/`:
|
||||
|
||||
```bash
|
||||
pnpm build
|
||||
```
|
||||
|
||||
## Prenotazioni
|
||||
|
||||
La form di prenotazione usa anche il campo `doctor` (medico richiesto).
|
||||
L'invio email passa da Mailjet API, con `Reply-To` valorizzato con l'indirizzo inserito da chi compila il modulo.
|
||||
Il backend mantiene protezioni anti-abuso: flag di attivazione, CAPTCHA, honeypot, rate limit e audit log JSONL.
|
||||
|
||||
Quando si pubblicano modifiche che toccano la form, vanno aggiornati insieme:
|
||||
|
||||
- frontend React
|
||||
- backend FastAPI
|
||||
|
||||
In particolare:
|
||||
|
||||
- `backend/app/schemas/booking.py`
|
||||
- `backend/app/services/mailer.py`
|
||||
|
||||
## Deploy online
|
||||
|
||||
Server attuale:
|
||||
|
||||
```txt
|
||||
ubuntu@129.152.31.221
|
||||
```
|
||||
|
||||
Percorso progetto:
|
||||
|
||||
```txt
|
||||
/var/www/clinicaveterinariaformiginese.it
|
||||
```
|
||||
|
||||
### Aggiornamento codice
|
||||
|
||||
La cartella online non contiene una working copy Git. Le modifiche vanno quindi
|
||||
pubblicate copiando i file via WinSCP/SFTP dopo commit e push su Gitea.
|
||||
|
||||
### Build frontend
|
||||
|
||||
```bash
|
||||
cd /var/www/clinicaveterinariaformiginese.it/clinica-app
|
||||
pnpm build
|
||||
```
|
||||
|
||||
### Riavvio servizi
|
||||
|
||||
```bash
|
||||
sudo systemctl restart clinicaveterinaria-backend.service
|
||||
sudo systemctl reload apache2
|
||||
```
|
||||
|
||||
### Verifiche utili
|
||||
|
||||
```bash
|
||||
systemctl status clinicaveterinaria-backend.service --no-pager
|
||||
journalctl -u clinicaveterinaria-backend.service -n 50 --no-pager
|
||||
curl -I https://www.clinicaveterinariaformiginese.it/
|
||||
```
|
||||
|
||||
## Nota importante su Vite
|
||||
|
||||
Nel build di produzione il runtime Manus deve restare disattivato. La configurazione e gia stata corretta in:
|
||||
|
||||
```txt
|
||||
clinica-app/vite.config.ts
|
||||
```
|
||||
|
||||
I plugin Manus sono attivi solo in sviluppo, non nel build di produzione.
|
||||
|
||||
## Redirect domini veterinarioformigine
|
||||
|
||||
Sono stati configurati 5 domini accessori per reindirizzare sempre verso:
|
||||
|
||||
```txt
|
||||
https://www.centrofaunaselvatica.it/
|
||||
```
|
||||
|
||||
Domini gestiti:
|
||||
|
||||
- `veterinarioformigine.it`
|
||||
- `veterinarioformigine.com`
|
||||
- `veterinarioformigine.eu`
|
||||
- `veterinarioformigine.info`
|
||||
- `veterinarioformigine.cloud`
|
||||
|
||||
Per ciascuno sono attivi anche i rispettivi host `www`.
|
||||
|
||||
### Comportamento atteso
|
||||
|
||||
Tutti questi URL devono restituire:
|
||||
|
||||
```txt
|
||||
301 Moved Permanently
|
||||
Location: https://www.centrofaunaselvatica.it/
|
||||
```
|
||||
|
||||
### Configurazione Apache
|
||||
|
||||
I virtual host HTTP sono stati creati in:
|
||||
|
||||
```txt
|
||||
/etc/apache2/sites-available/veterinarioformigine.*.conf
|
||||
```
|
||||
|
||||
I virtual host HTTPS creati da Certbot sono in:
|
||||
|
||||
```txt
|
||||
/etc/apache2/sites-available/veterinarioformigine.*-le-ssl.conf
|
||||
```
|
||||
|
||||
### Certificato
|
||||
|
||||
Certificato emesso con:
|
||||
|
||||
```bash
|
||||
sudo certbot --apache \
|
||||
-d veterinarioformigine.it -d www.veterinarioformigine.it \
|
||||
-d veterinarioformigine.com -d www.veterinarioformigine.com \
|
||||
-d veterinarioformigine.eu -d www.veterinarioformigine.eu \
|
||||
-d veterinarioformigine.info -d www.veterinarioformigine.info \
|
||||
-d veterinarioformigine.cloud -d www.veterinarioformigine.cloud
|
||||
```
|
||||
|
||||
Scadenza attuale del certificato:
|
||||
|
||||
```txt
|
||||
2026-08-25
|
||||
```
|
||||
|
||||
### Verifica redirect domini accessori
|
||||
|
||||
```bash
|
||||
curl -I https://veterinarioformigine.it
|
||||
curl -I https://www.veterinarioformigine.it
|
||||
curl -I https://veterinarioformigine.com
|
||||
curl -I https://www.veterinarioformigine.com
|
||||
curl -I https://veterinarioformigine.eu
|
||||
curl -I https://www.veterinarioformigine.eu
|
||||
curl -I https://veterinarioformigine.info
|
||||
curl -I https://www.veterinarioformigine.info
|
||||
curl -I https://veterinarioformigine.cloud
|
||||
curl -I https://www.veterinarioformigine.cloud
|
||||
```
|
||||
|
||||
## Git
|
||||
|
||||
Branch operativo corrente:
|
||||
|
||||
```txt
|
||||
backend_init
|
||||
```
|
||||
|
||||
Flusso consigliato:
|
||||
|
||||
1. Modifiche in `C:\devel\clinica_veterinaria_formiginese`
|
||||
2. Test con `pnpm dev`
|
||||
3. Commit e push su Gitea
|
||||
4. Deploy locale con `deploy_to_local_server.bat`
|
||||
5. Deploy online su Linux quando approvato
|
||||
|
||||
BIN
assets/news_consulenza.jpg
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
assets/news_cucciolo.jpg
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
assets/news_gatto.jpg
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
assets/ortovet.webp
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
assets/services_cardiologia.jpg
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
assets/services_dermatologia.jpg
Normal file
|
After Width: | Height: | Size: 67 KiB |
BIN
assets/services_ecografia.jpg
Normal file
|
After Width: | Height: | Size: 148 KiB |
BIN
assets/services_endoscopia.jpg
Normal file
|
After Width: | Height: | Size: 152 KiB |
BIN
assets/services_endoscopia.webp
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
assets/services_laboratorio.jpg
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
BIN
assets/services_laparoscopia.jpg
Normal file
|
After Width: | Height: | Size: 2.4 MiB |
BIN
assets/services_oculistica.jpg
Normal file
|
After Width: | Height: | Size: 148 KiB |
BIN
assets/services_radiologia.jpg
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
BIN
assets/services_visite_cliniche.jpg
Normal file
|
After Width: | Height: | Size: 97 KiB |
@@ -3,3 +3,25 @@ APP_ENV=development
|
||||
APP_HOST=127.0.0.1
|
||||
APP_PORT=8000
|
||||
DATABASE_URL=sqlite:///C:/devel/clinica_veterinaria_formiginese/data/clinica.db
|
||||
|
||||
MAIL_PROVIDER=mailjet
|
||||
MAILJET_API_KEY=
|
||||
MAILJET_API_SECRET=
|
||||
MAILJET_API_URL=https://api.mailjet.com/v3.1/send
|
||||
MAILJET_SANDBOX_MODE=true
|
||||
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USERNAME=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_USE_TLS=true
|
||||
BOOKING_EMAIL_FROM=
|
||||
BOOKING_EMAIL_FROM_NAME=Clinica Veterinaria Formiginese
|
||||
BOOKING_EMAIL_TO=
|
||||
BOOKING_EMAIL_SUBJECT_PREFIX=[Clinica Veterinaria Formiginese]
|
||||
BOOKING_FORM_ENABLED=false
|
||||
BOOKING_AUDIT_LOG_PATH=
|
||||
BOOKING_RATE_LIMIT_WINDOW_SECONDS=900
|
||||
BOOKING_RATE_LIMIT_MAX_REQUESTS=5
|
||||
BOOKING_CAPTCHA_TTL_SECONDS=600
|
||||
BOOKING_CAPTCHA_SECRET=
|
||||
|
||||
88
backend/app/api/booking.py
Normal file
@@ -0,0 +1,88 @@
|
||||
from fastapi import APIRouter, HTTPException, Request, status
|
||||
|
||||
from app.config import settings
|
||||
from app.schemas.booking import BookingCaptchaChallenge, BookingRequestCreate, BookingRequestResponse
|
||||
from app.services.booking_security import (
|
||||
create_captcha_challenge,
|
||||
get_client_ip,
|
||||
is_rate_limited,
|
||||
new_request_id,
|
||||
verify_captcha,
|
||||
write_booking_audit_event,
|
||||
)
|
||||
from app.services.mailer import MailConfigurationError, send_booking_request_email
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["booking"])
|
||||
|
||||
|
||||
@router.get("/booking-captcha", response_model=BookingCaptchaChallenge)
|
||||
def get_booking_captcha() -> BookingCaptchaChallenge:
|
||||
return create_captcha_challenge()
|
||||
|
||||
|
||||
@router.post("/booking-request", response_model=BookingRequestResponse)
|
||||
def create_booking_request(request: Request, payload: BookingRequestCreate) -> BookingRequestResponse:
|
||||
request_id = new_request_id()
|
||||
write_booking_audit_event("received", request_id=request_id, request=request, payload=payload)
|
||||
|
||||
if not settings.booking_form_enabled:
|
||||
write_booking_audit_event("blocked_form_disabled", request_id=request_id, request=request, payload=payload)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Form in aggiornamento.",
|
||||
)
|
||||
|
||||
if payload.website:
|
||||
write_booking_audit_event("blocked_honeypot", request_id=request_id, request=request, payload=payload)
|
||||
return BookingRequestResponse(
|
||||
message="La richiesta è stata inviata correttamente e sarà presa in carico al più presto dal team.",
|
||||
)
|
||||
|
||||
client_ip = get_client_ip(request)
|
||||
if is_rate_limited(client_ip):
|
||||
write_booking_audit_event("blocked_rate_limit", request_id=request_id, request=request, payload=payload)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail="Sono state inviate troppe richieste. Riprova più tardi.",
|
||||
)
|
||||
|
||||
if not verify_captcha(payload.captcha_id, payload.captcha_answer):
|
||||
write_booking_audit_event("blocked_captcha", request_id=request_id, request=request, payload=payload)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Verifica anti-spam non valida. Ricarica il codice e riprova.",
|
||||
)
|
||||
|
||||
try:
|
||||
send_booking_request_email(payload)
|
||||
except MailConfigurationError as exc:
|
||||
write_booking_audit_event(
|
||||
"failed_configuration",
|
||||
request_id=request_id,
|
||||
request=request,
|
||||
payload=payload,
|
||||
error=exc,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Il servizio email non è ancora configurato.",
|
||||
) from exc
|
||||
except Exception as exc: # pragma: no cover - mail delivery path
|
||||
write_booking_audit_event(
|
||||
"failed_delivery",
|
||||
request_id=request_id,
|
||||
request=request,
|
||||
payload=payload,
|
||||
error=exc,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Si è verificato un problema durante l'invio della richiesta.",
|
||||
) from exc
|
||||
|
||||
write_booking_audit_event("sent", request_id=request_id, request=request, payload=payload)
|
||||
|
||||
return BookingRequestResponse(
|
||||
message="La richiesta è stata inviata correttamente e sarà presa in carico al più presto dal team.",
|
||||
)
|
||||
@@ -6,9 +6,11 @@ from dotenv import load_dotenv
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.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:
|
||||
@@ -19,6 +21,45 @@ class Settings:
|
||||
self.app_port = int(os.getenv("APP_PORT", "8000"))
|
||||
default_sqlite_path = (PROJECT_ROOT / "data" / "clinica.db").resolve()
|
||||
self.database_url = os.getenv("DATABASE_URL", f"sqlite:///{default_sqlite_path.as_posix()}")
|
||||
self.smtp_host = os.getenv("SMTP_HOST", "smtp.gmail.com")
|
||||
self.smtp_port = int(os.getenv("SMTP_PORT", "587"))
|
||||
self.smtp_username = os.getenv("SMTP_USERNAME", "")
|
||||
self.smtp_password = os.getenv("SMTP_PASSWORD", "")
|
||||
self.smtp_use_tls = os.getenv("SMTP_USE_TLS", "true").lower() in {"1", "true", "yes", "on"}
|
||||
self.booking_email_from = os.getenv("BOOKING_EMAIL_FROM", self.smtp_username)
|
||||
self.booking_email_from_name = os.getenv("BOOKING_EMAIL_FROM_NAME", "Clinica Veterinaria Formiginese")
|
||||
self.booking_email_to = os.getenv("BOOKING_EMAIL_TO", "")
|
||||
self.booking_email_subject_prefix = os.getenv(
|
||||
"BOOKING_EMAIL_SUBJECT_PREFIX",
|
||||
"[Clinica Veterinaria Formiginese]",
|
||||
)
|
||||
self.mail_provider = os.getenv("MAIL_PROVIDER", "mailjet").lower()
|
||||
self.mailjet_api_key = os.getenv("MAILJET_API_KEY", "")
|
||||
self.mailjet_api_secret = os.getenv("MAILJET_API_SECRET", "")
|
||||
self.mailjet_api_url = os.getenv("MAILJET_API_URL", "https://api.mailjet.com/v3.1/send")
|
||||
self.mailjet_sandbox_mode = os.getenv("MAILJET_SANDBOX_MODE", "false").lower() in {
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
"on",
|
||||
}
|
||||
self.booking_form_enabled = os.getenv("BOOKING_FORM_ENABLED", "false").lower() in {
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
"on",
|
||||
}
|
||||
self.booking_audit_log_path = os.getenv(
|
||||
"BOOKING_AUDIT_LOG_PATH",
|
||||
str((BASE_DIR / "logs" / "booking_audit.log").resolve()),
|
||||
)
|
||||
self.booking_rate_limit_window_seconds = int(os.getenv("BOOKING_RATE_LIMIT_WINDOW_SECONDS", "900"))
|
||||
self.booking_rate_limit_max_requests = int(os.getenv("BOOKING_RATE_LIMIT_MAX_REQUESTS", "5"))
|
||||
self.booking_captcha_ttl_seconds = int(os.getenv("BOOKING_CAPTCHA_TTL_SECONDS", "600"))
|
||||
self.booking_captcha_secret = os.getenv(
|
||||
"BOOKING_CAPTCHA_SECRET",
|
||||
self.smtp_password or self.app_name,
|
||||
)
|
||||
|
||||
@property
|
||||
def sqlite_file_path(self) -> str | None:
|
||||
@@ -27,5 +68,31 @@ class Settings:
|
||||
return self.database_url.removeprefix(sqlite_prefix)
|
||||
return None
|
||||
|
||||
@property
|
||||
def booking_mail_enabled(self) -> bool:
|
||||
if self.mail_provider == "mailjet":
|
||||
return all(
|
||||
[
|
||||
self.mailjet_api_key,
|
||||
self.mailjet_api_secret,
|
||||
self.booking_email_from,
|
||||
self.booking_email_to,
|
||||
]
|
||||
)
|
||||
|
||||
if self.mail_provider == "smtp":
|
||||
return all(
|
||||
[
|
||||
self.smtp_host,
|
||||
self.smtp_port,
|
||||
self.smtp_username,
|
||||
self.smtp_password,
|
||||
self.booking_email_from,
|
||||
self.booking_email_to,
|
||||
]
|
||||
)
|
||||
|
||||
return False
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from fastapi import FastAPI
|
||||
|
||||
from app.api.booking import router as booking_router
|
||||
from app.api.health import router as health_router
|
||||
from app.config import settings
|
||||
from app.db.init_db import init_db
|
||||
@@ -9,3 +10,4 @@ init_db()
|
||||
|
||||
app = FastAPI(title=settings.app_name)
|
||||
app.include_router(health_router)
|
||||
app.include_router(booking_router)
|
||||
|
||||
27
backend/app/schemas/booking.py
Normal file
@@ -0,0 +1,27 @@
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class BookingRequestCreate(BaseModel):
|
||||
name: str = Field(min_length=2, max_length=120)
|
||||
email: str = Field(min_length=5, max_length=255)
|
||||
phone: str = Field(min_length=5, max_length=40)
|
||||
pet_name: str = Field(default="", max_length=120)
|
||||
pet_type: str = Field(default="cane", max_length=40)
|
||||
doctor: str = Field(min_length=2, max_length=120)
|
||||
service: str = Field(min_length=2, max_length=120)
|
||||
date: str = Field(min_length=8, max_length=20)
|
||||
time: str = Field(default="", max_length=20)
|
||||
notes: str = Field(default="", max_length=2000)
|
||||
website: str = Field(default="", max_length=200)
|
||||
captcha_id: str = Field(min_length=20, max_length=300)
|
||||
captcha_answer: str = Field(min_length=1, max_length=10)
|
||||
|
||||
|
||||
class BookingRequestResponse(BaseModel):
|
||||
success: bool = True
|
||||
message: str
|
||||
|
||||
|
||||
class BookingCaptchaChallenge(BaseModel):
|
||||
captcha_id: str
|
||||
question: str
|
||||
169
backend/app/services/booking_security.py
Normal file
@@ -0,0 +1,169 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict, deque
|
||||
from datetime import UTC, datetime
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
from pathlib import Path
|
||||
import random
|
||||
import threading
|
||||
import time
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import Request
|
||||
|
||||
from app.config import settings
|
||||
from app.schemas.booking import BookingCaptchaChallenge, BookingRequestCreate
|
||||
|
||||
|
||||
_audit_lock = threading.Lock()
|
||||
_rate_limit_lock = threading.Lock()
|
||||
_rate_limit_hits: dict[str, deque[float]] = defaultdict(deque)
|
||||
|
||||
|
||||
def new_request_id() -> str:
|
||||
return uuid4().hex
|
||||
|
||||
|
||||
def get_client_ip(request: Request) -> str:
|
||||
forwarded_for = request.headers.get("x-forwarded-for", "")
|
||||
if forwarded_for:
|
||||
return forwarded_for.split(",", 1)[0].strip()
|
||||
|
||||
real_ip = request.headers.get("x-real-ip", "")
|
||||
if real_ip:
|
||||
return real_ip.strip()
|
||||
|
||||
if request.client:
|
||||
return request.client.host
|
||||
|
||||
return "unknown"
|
||||
|
||||
|
||||
def is_rate_limited(client_ip: str) -> bool:
|
||||
now = time.time()
|
||||
window = settings.booking_rate_limit_window_seconds
|
||||
max_requests = settings.booking_rate_limit_max_requests
|
||||
|
||||
with _rate_limit_lock:
|
||||
hits = _rate_limit_hits[client_ip]
|
||||
while hits and now - hits[0] > window:
|
||||
hits.popleft()
|
||||
|
||||
if len(hits) >= max_requests:
|
||||
return True
|
||||
|
||||
hits.append(now)
|
||||
return False
|
||||
|
||||
|
||||
def create_captcha_challenge() -> BookingCaptchaChallenge:
|
||||
left = random.randint(2, 9)
|
||||
right = random.randint(2, 9)
|
||||
expires_at = int(time.time()) + settings.booking_captcha_ttl_seconds
|
||||
nonce = uuid4().hex
|
||||
answer = str(left + right)
|
||||
payload = f"{answer}:{expires_at}:{nonce}"
|
||||
signature = _sign(payload)
|
||||
token = _encode(f"{payload}:{signature}")
|
||||
|
||||
return BookingCaptchaChallenge(
|
||||
captcha_id=token,
|
||||
question=f"Quanto fa {left} + {right}?",
|
||||
)
|
||||
|
||||
|
||||
def verify_captcha(captcha_id: str, captcha_answer: str) -> bool:
|
||||
try:
|
||||
decoded = _decode(captcha_id)
|
||||
answer, expires_at_value, nonce, signature = decoded.split(":", 3)
|
||||
payload = f"{answer}:{expires_at_value}:{nonce}"
|
||||
expected_signature = _sign(payload)
|
||||
expires_at = int(expires_at_value)
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
if not hmac.compare_digest(signature, expected_signature):
|
||||
return False
|
||||
|
||||
if expires_at < int(time.time()):
|
||||
return False
|
||||
|
||||
return hmac.compare_digest(captcha_answer.strip(), answer)
|
||||
|
||||
|
||||
def write_booking_audit_event(
|
||||
event: str,
|
||||
*,
|
||||
request_id: str,
|
||||
request: Request,
|
||||
payload: BookingRequestCreate | None = None,
|
||||
error: Exception | str | None = None,
|
||||
) -> None:
|
||||
client_ip = get_client_ip(request)
|
||||
record: dict[str, Any] = {
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
"event": event,
|
||||
"request_id": request_id,
|
||||
"client_ip": client_ip,
|
||||
"user_agent": request.headers.get("user-agent", "")[:300],
|
||||
"referer": request.headers.get("referer", "")[:300],
|
||||
}
|
||||
|
||||
if payload is not None:
|
||||
record.update(
|
||||
{
|
||||
"email_hash": _hash_value(payload.email.lower().strip()),
|
||||
"email_domain": _email_domain(payload.email),
|
||||
"phone_hash": _hash_value(payload.phone.strip()),
|
||||
"name_length": len(payload.name),
|
||||
"pet_type": payload.pet_type,
|
||||
"doctor": payload.doctor,
|
||||
"service": payload.service,
|
||||
"date": payload.date,
|
||||
"time": payload.time,
|
||||
"notes_length": len(payload.notes or ""),
|
||||
"honeypot_filled": bool(payload.website),
|
||||
}
|
||||
)
|
||||
|
||||
if error is not None:
|
||||
record["error_type"] = error.__class__.__name__ if isinstance(error, Exception) else "Error"
|
||||
record["error"] = str(error)[:500]
|
||||
|
||||
log_path = Path(settings.booking_audit_log_path)
|
||||
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with _audit_lock:
|
||||
with log_path.open("a", encoding="utf-8") as log_file:
|
||||
log_file.write(json.dumps(record, ensure_ascii=False) + "\n")
|
||||
|
||||
|
||||
def _sign(payload: str) -> str:
|
||||
return hmac.new(
|
||||
settings.booking_captcha_secret.encode("utf-8"),
|
||||
payload.encode("utf-8"),
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
|
||||
|
||||
def _encode(value: str) -> str:
|
||||
return base64.urlsafe_b64encode(value.encode("utf-8")).decode("ascii").rstrip("=")
|
||||
|
||||
|
||||
def _decode(value: str) -> str:
|
||||
padding = "=" * (-len(value) % 4)
|
||||
return base64.urlsafe_b64decode(f"{value}{padding}".encode("ascii")).decode("utf-8")
|
||||
|
||||
|
||||
def _hash_value(value: str) -> str:
|
||||
return hashlib.sha256(value.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _email_domain(email: str) -> str:
|
||||
if "@" not in email:
|
||||
return ""
|
||||
return email.rsplit("@", 1)[1].lower()[:120]
|
||||
183
backend/app/services/mailer.py
Normal file
@@ -0,0 +1,183 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from datetime import datetime
|
||||
from email.message import EmailMessage
|
||||
from html import escape
|
||||
import json
|
||||
import smtplib
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
from app.config import settings
|
||||
from app.schemas.booking import BookingRequestCreate
|
||||
|
||||
|
||||
class MailConfigurationError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class MailDeliveryError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _build_booking_subject(payload: BookingRequestCreate) -> str:
|
||||
return f"{settings.booking_email_subject_prefix} Nuova richiesta visita - {payload.name}"
|
||||
|
||||
|
||||
def _build_booking_html(payload: BookingRequestCreate) -> str:
|
||||
submitted_at = datetime.now().strftime("%d/%m/%Y %H:%M")
|
||||
name = escape(payload.name)
|
||||
email = escape(payload.email)
|
||||
phone = escape(payload.phone)
|
||||
pet_name = escape(payload.pet_name or "-")
|
||||
pet_type = escape(payload.pet_type)
|
||||
doctor = escape(payload.doctor)
|
||||
service = escape(payload.service)
|
||||
date = escape(payload.date)
|
||||
time = escape(payload.time or "Qualsiasi orario")
|
||||
notes = escape(payload.notes or "-")
|
||||
return f"""
|
||||
<html>
|
||||
<body style="font-family: Arial, sans-serif; color: #1f2937; line-height: 1.6;">
|
||||
<h2 style="color: #1B4F72;">Nuova richiesta di prenotazione visita</h2>
|
||||
<p>È stata inviata una nuova richiesta di prenotazione non vincolante dal sito web.</p>
|
||||
<table cellpadding="8" cellspacing="0" border="0" style="border-collapse: collapse;">
|
||||
<tr><td><strong>Nome e cognome</strong></td><td>{name}</td></tr>
|
||||
<tr><td><strong>Email</strong></td><td>{email}</td></tr>
|
||||
<tr><td><strong>Telefono</strong></td><td>{phone}</td></tr>
|
||||
<tr><td><strong>Nome animale</strong></td><td>{pet_name}</td></tr>
|
||||
<tr><td><strong>Tipo animale</strong></td><td>{pet_type}</td></tr>
|
||||
<tr><td><strong>Medico richiesto</strong></td><td>{doctor}</td></tr>
|
||||
<tr><td><strong>Tipo di visita</strong></td><td>{service}</td></tr>
|
||||
<tr><td><strong>Periodo preferito</strong></td><td>{date}</td></tr>
|
||||
<tr><td><strong>Fascia oraria preferita</strong></td><td>{time}</td></tr>
|
||||
<tr><td><strong>Note</strong></td><td>{notes}</td></tr>
|
||||
<tr><td><strong>Inviata il</strong></td><td>{submitted_at}</td></tr>
|
||||
</table>
|
||||
<p style="margin-top: 20px;">
|
||||
Questa richiesta <strong>non costituisce conferma automatica dell'appuntamento</strong> e richiede
|
||||
presa in carico da parte dello staff.
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
def _build_booking_text(payload: BookingRequestCreate) -> str:
|
||||
submitted_at = datetime.now().strftime("%d/%m/%Y %H:%M")
|
||||
return (
|
||||
"Nuova richiesta di prenotazione visita\n\n"
|
||||
f"Nome e cognome: {payload.name}\n"
|
||||
f"Email: {payload.email}\n"
|
||||
f"Telefono: {payload.phone}\n"
|
||||
f"Nome animale: {payload.pet_name or '-'}\n"
|
||||
f"Tipo animale: {payload.pet_type}\n"
|
||||
f"Medico richiesto: {payload.doctor}\n"
|
||||
f"Tipo di visita: {payload.service}\n"
|
||||
f"Periodo preferito: {payload.date}\n"
|
||||
f"Fascia oraria preferita: {payload.time or 'Qualsiasi orario'}\n"
|
||||
f"Note: {payload.notes or '-'}\n"
|
||||
f"Inviata il: {submitted_at}\n\n"
|
||||
"La richiesta non costituisce conferma automatica dell'appuntamento."
|
||||
)
|
||||
|
||||
|
||||
def send_booking_request_email(payload: BookingRequestCreate) -> None:
|
||||
if not settings.booking_mail_enabled:
|
||||
raise MailConfigurationError(f"Configurazione email incompleta per provider {settings.mail_provider}.")
|
||||
|
||||
if settings.mail_provider == "mailjet":
|
||||
_send_booking_request_email_mailjet(payload)
|
||||
return
|
||||
|
||||
if settings.mail_provider == "smtp":
|
||||
_send_booking_request_email_smtp(payload)
|
||||
return
|
||||
|
||||
raise MailConfigurationError(f"Provider email non supportato: {settings.mail_provider}")
|
||||
|
||||
|
||||
def _send_booking_request_email_smtp(payload: BookingRequestCreate) -> None:
|
||||
if not settings.booking_mail_enabled:
|
||||
raise MailConfigurationError("Configurazione SMTP incompleta.")
|
||||
|
||||
message = EmailMessage()
|
||||
message["Subject"] = _build_booking_subject(payload)
|
||||
message["From"] = settings.booking_email_from
|
||||
message["To"] = settings.booking_email_to
|
||||
message["Reply-To"] = payload.email
|
||||
message.set_content(_build_booking_text(payload))
|
||||
message.add_alternative(_build_booking_html(payload), subtype="html")
|
||||
|
||||
with smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=30) as server:
|
||||
if settings.smtp_use_tls:
|
||||
server.starttls()
|
||||
server.login(settings.smtp_username, settings.smtp_password)
|
||||
server.send_message(message)
|
||||
|
||||
|
||||
def _send_booking_request_email_mailjet(payload: BookingRequestCreate) -> None:
|
||||
mailjet_payload = {
|
||||
"Messages": [
|
||||
{
|
||||
"From": {
|
||||
"Email": settings.booking_email_from,
|
||||
"Name": settings.booking_email_from_name,
|
||||
},
|
||||
"To": [
|
||||
{
|
||||
"Email": settings.booking_email_to,
|
||||
"Name": "Clinica Veterinaria Formiginese",
|
||||
}
|
||||
],
|
||||
"ReplyTo": {
|
||||
"Email": payload.email,
|
||||
"Name": payload.name,
|
||||
},
|
||||
"Subject": _build_booking_subject(payload),
|
||||
"TextPart": _build_booking_text(payload),
|
||||
"HTMLPart": _build_booking_html(payload),
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
if settings.mailjet_sandbox_mode:
|
||||
mailjet_payload["SandboxMode"] = True
|
||||
|
||||
auth_token = base64.b64encode(
|
||||
f"{settings.mailjet_api_key}:{settings.mailjet_api_secret}".encode("utf-8")
|
||||
).decode("ascii")
|
||||
request = Request(
|
||||
settings.mailjet_api_url,
|
||||
data=json.dumps(mailjet_payload).encode("utf-8"),
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
"Authorization": f"Basic {auth_token}",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
|
||||
try:
|
||||
with urlopen(request, timeout=30) as response:
|
||||
response_body = response.read().decode("utf-8")
|
||||
response_status = response.status
|
||||
except HTTPError as exc:
|
||||
error_body = exc.read().decode("utf-8", errors="replace")
|
||||
raise MailDeliveryError(f"Mailjet API HTTP {exc.code}: {error_body[:500]}") from exc
|
||||
except URLError as exc:
|
||||
raise MailDeliveryError(f"Mailjet API connection error: {exc.reason}") from exc
|
||||
|
||||
if response_status < 200 or response_status >= 300:
|
||||
raise MailDeliveryError(f"Mailjet API HTTP {response_status}: {response_body[:500]}")
|
||||
|
||||
try:
|
||||
response_payload = json.loads(response_body)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise MailDeliveryError(f"Mailjet API response non JSON: {response_body[:500]}") from exc
|
||||
|
||||
messages = response_payload.get("Messages", [])
|
||||
message_status = messages[0].get("Status") if messages else None
|
||||
if message_status != "success":
|
||||
raise MailDeliveryError(f"Mailjet API delivery status {message_status}: {response_body[:500]}")
|
||||
@@ -4,3 +4,4 @@ sqlalchemy
|
||||
pydantic
|
||||
python-dotenv
|
||||
aiosqlite
|
||||
pydantic[email]
|
||||
|
||||
@@ -12,9 +12,5 @@
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
<script
|
||||
defer
|
||||
src="%VITE_ANALYTICS_ENDPOINT%/umami"
|
||||
data-website-id="%VITE_ANALYTICS_WEBSITE_ID%"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
BIN
clinica-app/client/public/images/news_consulenza.jpg
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
clinica-app/client/public/images/news_cucciolo.jpg
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
clinica-app/client/public/images/news_gatto.jpg
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
clinica-app/client/public/images/ortovet.webp
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
clinica-app/client/public/images/services_cardiologia.jpg
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
clinica-app/client/public/images/services_dermatologia.jpg
Normal file
|
After Width: | Height: | Size: 67 KiB |
BIN
clinica-app/client/public/images/services_ecografia.jpg
Normal file
|
After Width: | Height: | Size: 148 KiB |
BIN
clinica-app/client/public/images/services_endoscopia.jpg
Normal file
|
After Width: | Height: | Size: 152 KiB |
BIN
clinica-app/client/public/images/services_endoscopia.webp
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
clinica-app/client/public/images/services_laboratorio.jpg
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
BIN
clinica-app/client/public/images/services_laparoscopia.jpg
Normal file
|
After Width: | Height: | Size: 2.4 MiB |
BIN
clinica-app/client/public/images/services_oculistica.jpg
Normal file
|
After Width: | Height: | Size: 148 KiB |
BIN
clinica-app/client/public/images/services_radiologia.jpg
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
BIN
clinica-app/client/public/images/services_visite_cliniche.jpg
Normal file
|
After Width: | Height: | Size: 97 KiB |
@@ -1,6 +1,9 @@
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import CookiePolicyPage from "@/pages/CookiePolicyPage";
|
||||
import LegalNotesPage from "@/pages/LegalNotesPage";
|
||||
import NotFound from "@/pages/NotFound";
|
||||
import PrivacyPolicyPage from "@/pages/PrivacyPolicyPage";
|
||||
import { Route, Switch } from "wouter";
|
||||
import ErrorBoundary from "./components/ErrorBoundary";
|
||||
import { ThemeProvider } from "./contexts/ThemeContext";
|
||||
@@ -10,6 +13,9 @@ function Router() {
|
||||
return (
|
||||
<Switch>
|
||||
<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 component={NotFound} />
|
||||
</Switch>
|
||||
|
||||
@@ -10,7 +10,7 @@ import { CheckCircle2, Award, Heart } from "lucide-react";
|
||||
|
||||
const values = [
|
||||
{ icon: Heart, text: "Cura e attenzione per ogni paziente" },
|
||||
{ icon: Award, text: "Dir. San. Dott. Paolo Parmeggiani — Master in Oncologia Veterinaria" },
|
||||
{ icon: Award, text: "Dir. San. Dott. Paolo Parmeggiani - Master in Oncologia Veterinaria" },
|
||||
{ icon: CheckCircle2, text: "Autorizzazione sanitaria n. 43560 del 30/12/2024" },
|
||||
];
|
||||
|
||||
@@ -22,7 +22,6 @@ export default function AboutSection() {
|
||||
<section id="chi-siamo" className="py-20 md:py-28 bg-white overflow-hidden">
|
||||
<div className="container">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-16 items-center">
|
||||
{/* Colonna testo */}
|
||||
<motion.div
|
||||
ref={ref}
|
||||
initial={{ opacity: 0, x: -40 }}
|
||||
@@ -46,14 +45,14 @@ export default function AboutSection() {
|
||||
}}
|
||||
>
|
||||
Una clinica veterinaria{" "}
|
||||
<span className="italic">al servizio della vita</span>
|
||||
<span className="italic">per la salute e il benessere animale</span>
|
||||
</h2>
|
||||
|
||||
<p className="text-gray-600 leading-relaxed mb-4 text-base">
|
||||
La Clinica Veterinaria Formiginese è un punto di riferimento per la cura degli animali
|
||||
domestici nel territorio di Formigine e della provincia di Modena. Fondata con la
|
||||
missione di offrire cure veterinarie di eccellenza, la nostra struttura riunisce
|
||||
sei professionisti specializzati, ciascuno con competenze specifiche.
|
||||
professionisti specializzati, ciascuno con competenze specifiche.
|
||||
</p>
|
||||
<p className="text-gray-600 leading-relaxed mb-8 text-base">
|
||||
Crediamo che ogni animale meriti la migliore assistenza medica possibile. Per questo
|
||||
@@ -61,7 +60,6 @@ export default function AboutSection() {
|
||||
a proprio agio sia i pazienti che i loro proprietari.
|
||||
</p>
|
||||
|
||||
{/* Valori */}
|
||||
<div className="space-y-4 mb-8">
|
||||
{values.map((item) => (
|
||||
<div key={item.text} className="flex items-start gap-3">
|
||||
@@ -73,52 +71,40 @@ export default function AboutSection() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Indirizzo */}
|
||||
<div className="bg-[#F5F0E8] rounded-xl p-5 border-l-4 border-[#1B4F72]">
|
||||
<p className="text-[#1B4F72] font-semibold text-sm mb-1">Dove siamo</p>
|
||||
<p className="text-gray-700 text-sm">Via Quattro Passi, 16 — Formigine (MO)</p>
|
||||
<p className="text-gray-700 text-sm">Via Quattro Passi, 16 - Formigine (MO)</p>
|
||||
<p className="text-gray-600 text-sm mt-1">
|
||||
Tel: <a href="tel:0598396263" className="text-[#4ECDC4] font-semibold hover:underline">059 839.62.63</a>
|
||||
Tel:{" "}
|
||||
<a href="tel:0598396263" className="text-[#4ECDC4] font-semibold hover:underline">
|
||||
059 839.62.63
|
||||
</a>
|
||||
{" "}·{" "}
|
||||
<a href="mailto:clinicaveterinariaformiginese@gmail.com" className="text-[#4ECDC4] font-semibold hover:underline text-xs">
|
||||
<a
|
||||
href="mailto:clinicaveterinariaformiginese@gmail.com"
|
||||
className="text-[#4ECDC4] font-semibold hover:underline text-xs"
|
||||
>
|
||||
clinicaveterinariaformiginese@gmail.com
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Colonna immagine */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 40 }}
|
||||
animate={isInView ? { opacity: 1, x: 0 } : {}}
|
||||
transition={{ duration: 0.8, delay: 0.2 }}
|
||||
className="relative"
|
||||
>
|
||||
{/* Immagine principale */}
|
||||
<div className="relative rounded-2xl overflow-hidden shadow-2xl">
|
||||
<img
|
||||
src="https://d2xsxph8kpxj0f.cloudfront.net/310519663483796440/DLUbzKbSnCG3dLeob4sLeC/hero_dog_cat_30af7cf4.jpg"
|
||||
alt="Cane e gatto insieme — la nostra missione"
|
||||
src="/images/hero_dog_cat.jpg"
|
||||
alt="Cane e gatto insieme - la nostra missione"
|
||||
className="w-full h-[480px] object-cover"
|
||||
/>
|
||||
{/* Overlay leggero */}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-[#1B4F72]/30 to-transparent" />
|
||||
</div>
|
||||
|
||||
{/* Badge flottante */}
|
||||
<div className="absolute -bottom-6 -left-6 bg-white rounded-2xl shadow-xl p-5 max-w-[200px]">
|
||||
<div
|
||||
className="text-[#1B4F72] font-bold mb-1"
|
||||
style={{ fontFamily: "'Cormorant Garamond', serif", fontSize: "2.5rem" }}
|
||||
>
|
||||
15+
|
||||
</div>
|
||||
<div className="text-gray-600 text-xs leading-tight">
|
||||
Anni di esperienza nella cura degli animali
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Decorazione */}
|
||||
<div className="absolute -top-4 -right-4 w-24 h-24 rounded-full bg-[#4ECDC4]/10 -z-10" />
|
||||
<div className="absolute -bottom-8 right-8 w-16 h-16 rounded-full bg-[#1B4F72]/10 -z-10" />
|
||||
</motion.div>
|
||||
|
||||
@@ -1,37 +1,15 @@
|
||||
/*
|
||||
* DESIGN: "Clinical Warmth"
|
||||
* Sezione registrazione/login: tabs con form eleganti
|
||||
* Sfondo bianco, accenti blu petrolio e verde acqua
|
||||
* Sezione registrazione/login visibile ma non ancora attiva.
|
||||
* Gli elementi restano leggibili, ma ogni interazione mostra
|
||||
* il messaggio "Servizio in corso di attivazione".
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { useInView } from "framer-motion";
|
||||
import { motion, useInView } from "framer-motion";
|
||||
import { useRef } from "react";
|
||||
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";
|
||||
|
||||
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 = [
|
||||
"Storico visite e referti digitali",
|
||||
"Promemoria vaccinazioni automatici",
|
||||
@@ -40,26 +18,59 @@ export default function AuthSection() {
|
||||
"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 (
|
||||
<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="grid grid-cols-1 lg:grid-cols-2 gap-16 items-center">
|
||||
{/* Colonna sinistra: benefici */}
|
||||
<div className="grid grid-cols-1 items-center gap-16 lg:grid-cols-2">
|
||||
<motion.div
|
||||
ref={ref}
|
||||
initial={{ opacity: 0, x: -30 }}
|
||||
animate={isInView ? { opacity: 1, x: 0 } : {}}
|
||||
transition={{ duration: 0.8 }}
|
||||
>
|
||||
<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">
|
||||
<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]">
|
||||
Area Personale
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h2
|
||||
className="text-[#1B4F72] mb-6"
|
||||
className="mb-6 text-[#1B4F72]"
|
||||
style={{
|
||||
fontFamily: "'Cormorant Garamond', serif",
|
||||
fontSize: "clamp(2rem, 4vw, 3rem)",
|
||||
@@ -67,210 +78,148 @@ export default function AuthSection() {
|
||||
lineHeight: 1.2,
|
||||
}}
|
||||
>
|
||||
Registrati e gestisci{" "}
|
||||
Accedi e gestisci{" "}
|
||||
<span className="italic">la salute del tuo animale</span>
|
||||
</h2>
|
||||
|
||||
<p className="text-gray-600 leading-relaxed mb-8 text-base">
|
||||
Crea il tuo profilo personale per accedere a tutti i servizi digitali della clinica.
|
||||
Tieni traccia della storia clinica del tuo animale, ricevi promemoria e prenota
|
||||
le visite in pochi click.
|
||||
<p className="mb-8 text-base leading-relaxed text-gray-600">
|
||||
L'area riservata e la registrazione online sono in fase di attivazione.
|
||||
Qui i clienti potranno gestire i propri dati, consultare lo storico e accedere
|
||||
ai servizi digitali della clinica in uno spazio personale dedicato.
|
||||
</p>
|
||||
|
||||
{/* Benefits list */}
|
||||
<div className="space-y-3 mb-8">
|
||||
<div className="mb-8 space-y-3">
|
||||
{benefits.map((benefit) => (
|
||||
<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]" />
|
||||
</div>
|
||||
<span className="text-gray-700 text-sm">{benefit}</span>
|
||||
<span className="text-sm text-gray-700">{benefit}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Decorazione */}
|
||||
<div className="bg-[#F5F0E8] rounded-2xl p-6 flex items-center gap-4">
|
||||
<div className="w-14 h-14 rounded-full bg-[#1B4F72] flex items-center justify-center flex-shrink-0">
|
||||
<div className="flex items-center gap-4 rounded-2xl bg-[#F5F0E8] p-6">
|
||||
<div className="flex h-14 w-14 flex-shrink-0 items-center justify-center rounded-full bg-[#1B4F72]">
|
||||
<PawPrint size={24} className="text-[#4ECDC4]" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[#1B4F72] font-semibold text-sm">Già più di 500 famiglie</p>
|
||||
<p className="text-gray-600 text-xs mt-0.5">
|
||||
si affidano alla nostra clinica per la cura dei loro animali
|
||||
<p className="text-sm font-semibold text-[#1B4F72]">Servizio in attivazione</p>
|
||||
<p className="mt-0.5 text-xs text-gray-600">
|
||||
La clinica sta completando la configurazione dell'area riservata per offrirti
|
||||
un accesso semplice e sicuro.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Colonna destra: form */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 30 }}
|
||||
animate={isInView ? { opacity: 1, x: 0 } : {}}
|
||||
transition={{ duration: 0.8, delay: 0.2 }}
|
||||
>
|
||||
{registered ? (
|
||||
<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="relative overflow-hidden rounded-2xl border border-gray-100 bg-white shadow-lg">
|
||||
<div className="flex border-b border-gray-100">
|
||||
{(["register", "login"] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setTab(t)}
|
||||
className={`flex-1 py-4 text-sm font-semibold transition-all duration-200 ${
|
||||
tab === t
|
||||
? "text-[#1B4F72] border-b-2 border-[#4ECDC4] bg-[#F5F0E8]/50"
|
||||
: "text-gray-400 hover:text-gray-600"
|
||||
}`}
|
||||
type="button"
|
||||
disabled
|
||||
className="flex-1 cursor-not-allowed border-b-2 border-[#4ECDC4] bg-[#F5F0E8]/50 py-4 text-sm font-semibold text-[#1B4F72]"
|
||||
>
|
||||
{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>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-6 md:p-8 space-y-4">
|
||||
{tab === "register" && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="p-6 md:p-8">
|
||||
<div className="mb-5 flex items-center gap-3 rounded-xl border border-[#E4D7C6] bg-[#FFF9F1] px-4 py-3">
|
||||
<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>
|
||||
<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
|
||||
</label>
|
||||
<div className="relative">
|
||||
<User size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
placeholder="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>
|
||||
<DisabledInput icon={User} type="text" placeholder="Mario" />
|
||||
</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
|
||||
</label>
|
||||
<div className="relative">
|
||||
<User size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
placeholder="Rossi"
|
||||
className="w-full pl-9 pr-3 py-2.5 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-[#4ECDC4] focus:border-transparent transition-all"
|
||||
/>
|
||||
<DisabledInput icon={User} type="text" placeholder="Rossi" />
|
||||
</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="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>
|
||||
<DisabledInput icon={Mail} type="email" placeholder="mario.rossi@email.it" />
|
||||
</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
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Lock size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type={showPassword ? "text" : "password"}
|
||||
required
|
||||
type="password"
|
||||
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
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
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>
|
||||
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400">
|
||||
<Eye size={15} />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tab === "register" && (
|
||||
<div className="flex items-start gap-2 pt-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
required
|
||||
id="privacy"
|
||||
className="mt-0.5 accent-[#4ECDC4]"
|
||||
disabled
|
||||
className="mt-0.5 cursor-not-allowed accent-[#4ECDC4]"
|
||||
/>
|
||||
<label htmlFor="privacy" className="text-xs text-gray-500 leading-relaxed">
|
||||
Accetto la{" "}
|
||||
<button
|
||||
type="button"
|
||||
className="text-[#4ECDC4] hover:underline"
|
||||
onClick={() => toast.info("Privacy policy in arrivo")}
|
||||
>
|
||||
Privacy Policy
|
||||
</button>{" "}
|
||||
e i{" "}
|
||||
<button
|
||||
type="button"
|
||||
className="text-[#4ECDC4] hover:underline"
|
||||
onClick={() => toast.info("Termini di servizio in arrivo")}
|
||||
>
|
||||
Termini di Servizio
|
||||
</button>
|
||||
</label>
|
||||
<p className="text-xs leading-relaxed text-gray-500">
|
||||
Accetto la Privacy Policy e i Termini di Servizio.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full bg-[#1B4F72] hover:bg-[#163d5a] text-white font-bold py-3 text-base transition-all duration-300"
|
||||
type="button"
|
||||
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>
|
||||
|
||||
{tab === "login" && (
|
||||
<p className="text-center text-xs text-gray-400">
|
||||
<p className="text-center text-xs text-gray-400">Password dimenticata?</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="text-[#4ECDC4] hover:underline"
|
||||
onClick={() => toast.info("Recupero password in arrivo")}
|
||||
>
|
||||
Password dimenticata?
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
aria-label="Servizio in corso di attivazione"
|
||||
onClick={showActivationToast}
|
||||
className="absolute inset-0 z-10"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,68 +1,218 @@
|
||||
/*
|
||||
* DESIGN: "Clinical Warmth"
|
||||
* Sezione prenotazione: form elegante su sfondo blu petrolio
|
||||
* Layout: testo a sinistra + form a destra
|
||||
* Sezione prenotazione: form to mail con conferma non vincolante.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { useInView } from "framer-motion";
|
||||
import { useRef } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Calendar, Clock, User, Phone, PawPrint, CheckCircle2 } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { motion, useInView } from "framer-motion";
|
||||
import {
|
||||
Calendar,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Mail,
|
||||
PawPrint,
|
||||
Phone,
|
||||
ShieldCheck,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const bookingFormPaused = false;
|
||||
|
||||
const services = [
|
||||
"Visita generale",
|
||||
"Radiologia / Ecografia",
|
||||
"Chirurgia (consulenza)",
|
||||
"Laboratorio analisi",
|
||||
"Visita clinica generale",
|
||||
"Ecografia",
|
||||
"Radiologia",
|
||||
"Laboratorio",
|
||||
"Vaccinazione",
|
||||
"Dermatologia",
|
||||
"Odontoiatria",
|
||||
"Oncologia",
|
||||
"Dermatologia",
|
||||
"Oculistica",
|
||||
"Nutrizione",
|
||||
"Ortopedia",
|
||||
"Endoscopia",
|
||||
"Laparoscopia",
|
||||
];
|
||||
|
||||
const timeSlots = [
|
||||
"09:00", "09:30", "10:00", "10:30", "11:00", "11:30",
|
||||
"14:30", "15:00", "15:30", "16:00", "16:30", "17:00",
|
||||
const doctors = [
|
||||
"Dott. Paolo Parmeggiani",
|
||||
"Dott.ssa Irene Paganelli",
|
||||
"Dott. Simone Tinti",
|
||||
"Dott.ssa Michela Sghedoni",
|
||||
"Dott.ssa Sara Casali",
|
||||
"Dott. Riccardo Suffritti",
|
||||
];
|
||||
|
||||
const openingHours = [
|
||||
{ days: "Visite: Lunedì - Venerdì", hours: "09:00 - 19:30" },
|
||||
{ days: "Visite: Sabato", hours: "09:00 - 17:00" },
|
||||
{ days: "Urgenze: Lunedì - Venerdì", hours: "08:00 - 22:30" },
|
||||
{ days: "Urgenze: Sabato e Festivi", hours: "09:00 - 20:00" },
|
||||
];
|
||||
|
||||
const preferredPeriods = [
|
||||
"Questa settimana",
|
||||
"Prossima settimana",
|
||||
"Tra 2 settimane",
|
||||
"Il mese prossimo",
|
||||
];
|
||||
|
||||
const preferredTimeWindows = [
|
||||
"Mattino",
|
||||
"Pausa pranzo",
|
||||
"Pomeriggio",
|
||||
];
|
||||
|
||||
type BookingFormState = {
|
||||
name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
petName: string;
|
||||
petType: string;
|
||||
doctor: string;
|
||||
service: string;
|
||||
date: string;
|
||||
time: string;
|
||||
notes: string;
|
||||
website: string;
|
||||
captchaAnswer: string;
|
||||
};
|
||||
|
||||
const initialForm: BookingFormState = {
|
||||
name: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
petName: "",
|
||||
petType: "cane",
|
||||
doctor: "",
|
||||
service: "",
|
||||
date: "",
|
||||
time: "",
|
||||
notes: "",
|
||||
website: "",
|
||||
captchaAnswer: "",
|
||||
};
|
||||
|
||||
type CaptchaChallenge = {
|
||||
captcha_id: string;
|
||||
question: string;
|
||||
};
|
||||
|
||||
export default function BookingSection() {
|
||||
const ref = useRef(null);
|
||||
const isInView = useInView(ref, { once: true, margin: "-80px" });
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [form, setForm] = useState({
|
||||
name: "",
|
||||
phone: "",
|
||||
petName: "",
|
||||
petType: "cane",
|
||||
service: "",
|
||||
date: "",
|
||||
time: "",
|
||||
notes: "",
|
||||
});
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [submittedName, setSubmittedName] = useState("");
|
||||
const [form, setForm] = useState<BookingFormState>(initialForm);
|
||||
const [captcha, setCaptcha] = useState<CaptchaChallenge | null>(null);
|
||||
const [captchaLoading, setCaptchaLoading] = useState(false);
|
||||
|
||||
const 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();
|
||||
if (!form.name || !form.phone || !form.service || !form.date) {
|
||||
|
||||
if (!form.name || !form.email || !form.phone || !form.doctor || !form.service || !form.date) {
|
||||
toast.error("Compila tutti i campi obbligatori");
|
||||
return;
|
||||
}
|
||||
setSubmitted(true);
|
||||
toast.success("Richiesta inviata!", {
|
||||
description: "Ti contatteremo entro 24 ore per confermare l'appuntamento.",
|
||||
|
||||
if (!captcha || !form.captchaAnswer) {
|
||||
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 (
|
||||
<section id="prenotazione" className="py-20 md:py-28 bg-[#1B4F72] relative overflow-hidden">
|
||||
{/* Decorazioni di sfondo */}
|
||||
<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 w-64 h-64 rounded-full bg-[#4ECDC4]/10 translate-y-1/2 -translate-x-1/2" />
|
||||
<section id="prenotazione" className="relative overflow-hidden bg-[#1B4F72] py-20 md:py-28">
|
||||
<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 bottom-0 left-0 h-64 w-64 -translate-x-1/2 translate-y-1/2 rounded-full bg-[#4ECDC4]/10" />
|
||||
|
||||
<div className="container relative z-10">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-16 items-start">
|
||||
{/* Colonna sinistra: testo */}
|
||||
<div className="grid grid-cols-1 items-start gap-16 lg:grid-cols-2">
|
||||
<motion.div
|
||||
ref={ref}
|
||||
initial={{ opacity: 0, x: -30 }}
|
||||
@@ -70,15 +220,15 @@ export default function BookingSection() {
|
||||
transition={{ duration: 0.8 }}
|
||||
className="text-white"
|
||||
>
|
||||
<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">
|
||||
<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]">
|
||||
Prenotazioni
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h2
|
||||
className="text-white mb-6"
|
||||
className="mb-6 text-white"
|
||||
style={{
|
||||
fontFamily: "'Cormorant Garamond', serif",
|
||||
fontSize: "clamp(2rem, 4vw, 3rem)",
|
||||
@@ -86,93 +236,160 @@ export default function BookingSection() {
|
||||
lineHeight: 1.2,
|
||||
}}
|
||||
>
|
||||
Prenota la tua visita{" "}
|
||||
<span className="italic text-[#4ECDC4]">online</span>
|
||||
Richiedi una visita <span className="italic text-[#4ECDC4]">online</span>
|
||||
</h2>
|
||||
|
||||
<p className="text-white/80 leading-relaxed mb-8 text-base">
|
||||
Compila il modulo per richiedere un appuntamento. Ti contatteremo entro 24 ore
|
||||
per confermare la data e l'orario. Per urgenze, chiama direttamente il numero
|
||||
dedicato disponibile 24 ore su 24.
|
||||
<p className="mb-8 text-base leading-relaxed text-white/80">
|
||||
Compila il modulo per inviare una richiesta di appuntamento. La richiesta
|
||||
non è vincolante e dovrà essere confermata dallo staff della clinica, che ti
|
||||
ricontatterà il prima possibile.
|
||||
</p>
|
||||
|
||||
{/* Info box urgenze */}
|
||||
<div className="bg-white/10 backdrop-blur-sm rounded-xl p-5 border border-white/20 mb-8">
|
||||
<p className="text-[#4ECDC4] font-semibold text-sm mb-2 uppercase tracking-wide">
|
||||
Urgenze 24h
|
||||
<div className="mb-8 rounded-xl border border-white/20 bg-white/10 p-5 backdrop-blur-sm">
|
||||
<p className="mb-2 text-sm font-semibold uppercase tracking-wide text-[#4ECDC4]">
|
||||
Urgenze
|
||||
</p>
|
||||
<a
|
||||
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" }}
|
||||
>
|
||||
320 532.24.39
|
||||
</a>
|
||||
<p className="text-white/60 text-xs mt-1">Disponibile 7 giorni su 7</p>
|
||||
</div>
|
||||
|
||||
{/* Orari */}
|
||||
<div className="space-y-3">
|
||||
<p className="text-white/70 text-sm font-semibold uppercase tracking-wide mb-3">
|
||||
Orari di apertura
|
||||
<div className="mb-8 rounded-2xl border border-[#4ECDC4]/30 bg-[#14384F] p-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-[#4ECDC4]/15">
|
||||
<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'invio del modulo non costituisce conferma automatica dell'appuntamento.
|
||||
Il team verificherà disponibilità, tipologia di visita e urgenza del caso prima
|
||||
di confermare periodo e fascia oraria.
|
||||
</p>
|
||||
{[
|
||||
{ days: "Lunedì — Venerdì", hours: "09:00 — 12:30 · 14:30 — 19:00" },
|
||||
{ days: "Sabato", hours: "09:00 — 12:30" },
|
||||
{ days: "Domenica", hours: "Solo urgenze" },
|
||||
].map((slot) => (
|
||||
<div key={slot.days} className="flex justify-between items-center text-sm">
|
||||
<span className="text-white/70">{slot.days}</span>
|
||||
<span className="text-white font-medium">{slot.hours}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-[39rem]">
|
||||
<div className="rounded-[26px] border border-white/20 bg-white/10 px-6 py-6 shadow-[0_18px_45px_rgba(0,0,0,0.18)] backdrop-blur-md sm:px-7 sm:py-7">
|
||||
<h4
|
||||
className="mb-5 text-white/95 uppercase tracking-[0.28em]"
|
||||
style={{
|
||||
fontFamily: "'Nunito Sans', sans-serif",
|
||||
fontSize: "0.8rem",
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
Orari di apertura
|
||||
</h4>
|
||||
|
||||
<div className="space-y-4">
|
||||
{openingHours.map((slot) => (
|
||||
<div key={slot.days} className="flex items-start gap-3.5">
|
||||
<div className="mt-0.5 rounded-full bg-[#4ECDC4]/20 p-2">
|
||||
<Clock size={14} className="text-[#7ce3dc]" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-white/92 sm:text-[0.98rem]">
|
||||
{slot.days}
|
||||
</p>
|
||||
<p className="mt-0.5 text-sm text-white/72 sm:text-[0.96rem]">
|
||||
{slot.hours}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Colonna destra: form */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 30 }}
|
||||
animate={isInView ? { opacity: 1, x: 0 } : {}}
|
||||
transition={{ duration: 0.8, delay: 0.2 }}
|
||||
>
|
||||
{submitted ? (
|
||||
<div className="bg-white rounded-2xl 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">
|
||||
{bookingFormPaused ? (
|
||||
<div className="rounded-2xl bg-white p-8 text-center shadow-2xl">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-[#4ECDC4]/15">
|
||||
<Clock size={32} className="text-[#4ECDC4]" />
|
||||
</div>
|
||||
<h3
|
||||
className="mb-3 text-[#1B4F72]"
|
||||
style={{ fontFamily: "'Cormorant Garamond', serif", fontSize: "1.9rem" }}
|
||||
>
|
||||
Form in aggiornamento
|
||||
</h3>
|
||||
<p className="mb-6 text-sm leading-relaxed text-gray-600">
|
||||
Il modulo di richiesta appuntamento è temporaneamente sospeso per manutenzione.
|
||||
Per prenotazioni o informazioni contatta direttamente la clinica.
|
||||
</p>
|
||||
<a
|
||||
href="tel:3205322439"
|
||||
className="inline-flex items-center justify-center rounded-lg bg-[#1B4F72] px-5 py-3 text-sm font-bold text-white transition-colors hover:bg-[#163d5a]"
|
||||
>
|
||||
Chiama 320 532.24.39
|
||||
</a>
|
||||
</div>
|
||||
) : submitted ? (
|
||||
<div className="rounded-2xl bg-white p-8 text-center shadow-2xl">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-[#4ECDC4]/15">
|
||||
<CheckCircle2 size={32} className="text-[#4ECDC4]" />
|
||||
</div>
|
||||
<h3
|
||||
className="text-[#1B4F72] mb-2"
|
||||
style={{ fontFamily: "'Cormorant Garamond', serif", fontSize: "1.8rem" }}
|
||||
className="mb-2 text-[#1B4F72]"
|
||||
style={{ fontFamily: "'Cormorant Garamond', serif", fontSize: "1.9rem" }}
|
||||
>
|
||||
Richiesta inviata!
|
||||
Grazie {submittedName || "per la tua richiesta"}
|
||||
</h3>
|
||||
<p className="text-gray-600 text-sm mb-6">
|
||||
Abbiamo ricevuto la tua richiesta di appuntamento. Ti contatteremo entro 24 ore
|
||||
per confermare data e orario.
|
||||
<p className="mb-3 text-sm leading-relaxed text-gray-600">
|
||||
La tua richiesta di prenotazione è stata inviata correttamente.
|
||||
</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
|
||||
className="bg-[#1B4F72] hover:bg-[#163d5a] text-white"
|
||||
className="bg-[#1B4F72] text-white hover:bg-[#163d5a]"
|
||||
onClick={() => setSubmitted(false)}
|
||||
>
|
||||
Nuova prenotazione
|
||||
Invia una nuova richiesta
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<form
|
||||
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
|
||||
className="text-[#1B4F72] mb-2"
|
||||
style={{ fontFamily: "'Cormorant Garamond', serif", fontSize: "1.5rem", fontWeight: 600 }}
|
||||
className="mb-2 text-[#1B4F72]"
|
||||
style={{
|
||||
fontFamily: "'Cormorant Garamond', serif",
|
||||
fontSize: "1.5rem",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Richiedi un appuntamento
|
||||
</h3>
|
||||
<p className="text-sm leading-relaxed text-gray-500">
|
||||
Compila i campi richiesti e inviaci una preferenza di periodo: sarà lo staff a
|
||||
ricontattarti per confermare la visita.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Nome e telefono */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<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 *
|
||||
</label>
|
||||
<div className="relative">
|
||||
@@ -183,12 +400,30 @@ export default function BookingSection() {
|
||||
placeholder="Mario Rossi"
|
||||
value={form.name}
|
||||
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>
|
||||
<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 *
|
||||
</label>
|
||||
<div className="relative">
|
||||
@@ -199,17 +434,16 @@ export default function BookingSection() {
|
||||
placeholder="333 123 4567"
|
||||
value={form.phone}
|
||||
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>
|
||||
|
||||
{/* Animale */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-gray-600 uppercase tracking-wide block mb-1.5">
|
||||
Nome dell'animale
|
||||
<label className="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-gray-600">
|
||||
Nome dell'animale
|
||||
</label>
|
||||
<div className="relative">
|
||||
<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"
|
||||
value={form.petName}
|
||||
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>
|
||||
<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
|
||||
</label>
|
||||
<select
|
||||
value={form.petType}
|
||||
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="gatto">Gatto</option>
|
||||
@@ -238,65 +473,91 @@ export default function BookingSection() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Servizio */}
|
||||
<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 *
|
||||
</label>
|
||||
<select
|
||||
required
|
||||
value={form.service}
|
||||
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>
|
||||
{services.map((s) => (
|
||||
<option key={s} value={s}>{s}</option>
|
||||
{services.map((service) => (
|
||||
<option key={service} value={service}>
|
||||
{service}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Data e ora */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-gray-600 uppercase tracking-wide block mb-1.5">
|
||||
Data preferita *
|
||||
<label className="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-gray-600">
|
||||
Periodo preferito *
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Calendar size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="date"
|
||||
<select
|
||||
required
|
||||
value={form.date}
|
||||
min={new Date().toISOString().split("T")[0]}
|
||||
onChange={(e) => setForm({ ...form, date: e.target.value })}
|
||||
className="w-full 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>
|
||||
<label className="text-xs font-semibold text-gray-600 uppercase tracking-wide block mb-1.5">
|
||||
Orario preferito
|
||||
<label className="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-gray-600">
|
||||
Fascia oraria preferita
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Clock size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||
<select
|
||||
value={form.time}
|
||||
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>
|
||||
{timeSlots.map((t) => (
|
||||
<option key={t} value={t}>{t}</option>
|
||||
<option value="">Seleziona una fascia oraria</option>
|
||||
{preferredTimeWindows.map((time) => (
|
||||
<option key={time} value={time}>
|
||||
{time}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Note */}
|
||||
<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
|
||||
</label>
|
||||
<textarea
|
||||
@@ -304,19 +565,62 @@ export default function BookingSection() {
|
||||
placeholder="Descrivi brevemente il motivo della visita..."
|
||||
value={form.notes}
|
||||
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 className="hidden" aria-hidden="true">
|
||||
<label>
|
||||
Sito web
|
||||
<input
|
||||
type="text"
|
||||
tabIndex={-1}
|
||||
autoComplete="off"
|
||||
value={form.website}
|
||||
onChange={(e) => setForm({ ...form, website: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-gray-200 bg-gray-50 px-4 py-3">
|
||||
<label className="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-gray-600">
|
||||
Verifica anti-spam *
|
||||
</label>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<span className="text-sm font-semibold text-[#1B4F72]">
|
||||
{captchaLoading ? "Caricamento..." : captcha?.question || "Verifica non disponibile"}
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
required
|
||||
placeholder="Risposta"
|
||||
value={form.captchaAnswer}
|
||||
onChange={(e) => setForm({ ...form, captchaAnswer: e.target.value })}
|
||||
disabled={!captcha || captchaLoading}
|
||||
className="w-full rounded-lg border border-gray-200 px-3 py-2.5 text-sm transition-all focus:border-transparent focus:outline-none focus:ring-2 focus:ring-[#4ECDC4] disabled:cursor-not-allowed disabled:bg-gray-100 sm:max-w-32"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={loadCaptcha}
|
||||
disabled={captchaLoading || submitting}
|
||||
className="text-left text-xs font-semibold uppercase tracking-wide text-[#1B4F72] transition-colors hover:text-[#4ECDC4] disabled:cursor-wait disabled:opacity-60 sm:text-center"
|
||||
>
|
||||
Ricarica
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
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>
|
||||
|
||||
<p className="text-xs text-gray-400 text-center">
|
||||
* Campi obbligatori. Ti contatteremo entro 24 ore per confermare.
|
||||
<p className="text-center text-xs text-gray-500">
|
||||
* Campi obbligatori. La richiesta sarà valutata e confermata dallo staff.
|
||||
</p>
|
||||
</form>
|
||||
)}
|
||||
|
||||
@@ -3,55 +3,23 @@
|
||||
* Footer scuro con informazioni di contatto, link utili e disclaimer legale
|
||||
* Sfondo blu petrolio scuro, testo bianco/grigio chiaro
|
||||
*/
|
||||
import { MapPin, Phone, Mail, Facebook, Clock, ArrowRight } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { MapPin, Phone, Mail, Clock, ArrowRight } from "lucide-react";
|
||||
|
||||
const quickLinks = [
|
||||
{ label: "Chi Siamo", href: "#chi-siamo" },
|
||||
{ label: "Radiologia", href: "#servizi" },
|
||||
{ label: "Chirurgia", href: "#servizi" },
|
||||
{ label: "Laboratorio", href: "#servizi" },
|
||||
{ label: "Il Team", href: "#team" },
|
||||
{ label: "News & Blog", href: "#news" },
|
||||
{ label: "Prenota Visita", href: "#prenotazione" },
|
||||
{ label: "Area Personale", href: "#registrazione" },
|
||||
{ label: "Chi Siamo", href: "/#chi-siamo" },
|
||||
{ label: "Radiologia", href: "/#servizi" },
|
||||
{ label: "Chirurgia", href: "/#servizi" },
|
||||
{ label: "Laboratorio", href: "/#servizi" },
|
||||
{ label: "Il Team", href: "/#team" },
|
||||
{ label: "Prenota Visita", href: "/#prenotazione" },
|
||||
{ label: "Area Personale", href: "/#registrazione" },
|
||||
];
|
||||
|
||||
export default function Footer() {
|
||||
return (
|
||||
<footer id="contatti" className="bg-[#0d2b3e] text-white">
|
||||
{/* Main footer */}
|
||||
<div className="container py-16">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-10">
|
||||
{/* Brand */}
|
||||
<div className="lg:col-span-1">
|
||||
<div className="mb-4">
|
||||
<div
|
||||
className="font-bold text-white leading-tight"
|
||||
style={{ fontFamily: "'Cormorant Garamond', serif", fontSize: "1rem" }}
|
||||
>
|
||||
Clinica Veterinaria
|
||||
</div>
|
||||
<div className="text-[#4ECDC4] text-xs font-semibold tracking-widest uppercase">
|
||||
Formiginese
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-white/60 text-sm leading-relaxed mb-4">
|
||||
Cura specialistica per cani e gatti a Formigine.
|
||||
Un team di professionisti al servizio della salute dei tuoi animali.
|
||||
</p>
|
||||
<a
|
||||
href="https://it-it.facebook.com/clinicaveterinariaformiginese/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 text-[#4ECDC4] hover:text-white transition-colors text-sm"
|
||||
>
|
||||
<Facebook size={16} />
|
||||
Seguici su Facebook
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Link rapidi */}
|
||||
<div>
|
||||
<h4
|
||||
className="text-white font-semibold mb-4 text-sm uppercase tracking-widest"
|
||||
@@ -66,7 +34,10 @@ export default function Footer() {
|
||||
href={link.href}
|
||||
className="text-white/60 hover:text-[#4ECDC4] transition-colors text-sm flex items-center gap-1.5 group"
|
||||
>
|
||||
<ArrowRight size={12} className="opacity-0 group-hover:opacity-100 transition-opacity -ml-4 group-hover:ml-0 duration-200" />
|
||||
<ArrowRight
|
||||
size={12}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity -ml-4 group-hover:ml-0 duration-200"
|
||||
/>
|
||||
{link.label}
|
||||
</a>
|
||||
</li>
|
||||
@@ -74,7 +45,6 @@ export default function Footer() {
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Contatti */}
|
||||
<div>
|
||||
<h4
|
||||
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">
|
||||
<Phone size={15} className="text-[#4ECDC4] flex-shrink-0" />
|
||||
<div>
|
||||
<a href="tel:0598396263" className="text-white/80 text-sm hover:text-[#4ECDC4] transition-colors">
|
||||
<a
|
||||
href="tel:0598396263"
|
||||
className="text-white/80 text-sm hover:text-[#4ECDC4] transition-colors"
|
||||
>
|
||||
059 839.62.63
|
||||
</a>
|
||||
<p className="text-white/60 text-xs">Urgenze: 320 532.24.39</p>
|
||||
@@ -111,7 +84,6 @@ export default function Footer() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Orari */}
|
||||
<div>
|
||||
<h4
|
||||
className="text-white font-semibold mb-4 text-sm uppercase tracking-widest"
|
||||
@@ -121,9 +93,10 @@ export default function Footer() {
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{[
|
||||
{ days: "Lunedì — Venerdì", hours: "09:00 — 12:30\n14:30 — 19:00" },
|
||||
{ days: "Sabato", hours: "09:00 — 12:30" },
|
||||
{ days: "Domenica", hours: "Solo urgenze" },
|
||||
{ days: "Visite: Lunedì - Venerdì", hours: "09:00 - 19:30" },
|
||||
{ days: "Visite: Sabato", hours: "09:00 - 17:00" },
|
||||
{ days: "Urgenze: Lunedì - Venerdì", hours: "08:00 - 22:30" },
|
||||
{ days: "Urgenze: Sabato e Festivi", hours: "09:00 - 20:00" },
|
||||
].map((slot) => (
|
||||
<div key={slot.days} className="flex items-start gap-2">
|
||||
<Clock size={13} className="text-[#4ECDC4] flex-shrink-0 mt-0.5" />
|
||||
@@ -134,54 +107,36 @@ export default function Footer() {
|
||||
</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>
|
||||
|
||||
{/* Bottom bar */}
|
||||
<div className="border-t border-white/10">
|
||||
<div className="container py-6">
|
||||
<div className="flex flex-col md:flex-row justify-between items-center gap-4 text-xs text-white/40">
|
||||
<div className="text-center md:text-left">
|
||||
<p>© 2026 Clinica Veterinaria Formiginese — Tutti i diritti riservati</p>
|
||||
<p>© 2026 Clinica Veterinaria Formiginese - Tutti i diritti riservati</p>
|
||||
<p className="mt-1">
|
||||
Dir. San. Dott. Paolo Parmeggiani (n. 715 MO) — Master in Oncologia Veterinaria ·
|
||||
Dir. San. Dott. Paolo Parmeggiani (n. 715 MO) - Master in Oncologia Veterinaria ·
|
||||
Autorizzazione n. 43560 del 30/12/2024
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
onClick={() => toast.info("Privacy Policy in arrivo")}
|
||||
className="hover:text-white/70 transition-colors"
|
||||
>
|
||||
<a href="/privacy-policy" className="hover:text-white/70 transition-colors">
|
||||
Privacy Policy
|
||||
</button>
|
||||
<button
|
||||
onClick={() => toast.info("Cookie Policy in arrivo")}
|
||||
className="hover:text-white/70 transition-colors"
|
||||
>
|
||||
</a>
|
||||
<a href="/cookie-policy" className="hover:text-white/70 transition-colors">
|
||||
Cookie Policy
|
||||
</button>
|
||||
<button
|
||||
onClick={() => toast.info("Note legali in arrivo")}
|
||||
className="hover:text-white/70 transition-colors"
|
||||
>
|
||||
</a>
|
||||
<a href="/note-legali" className="hover:text-white/70 transition-colors">
|
||||
Note Legali
|
||||
</button>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-white/25 text-xs mt-3 text-center md:text-left">
|
||||
Il messaggio informativo di questo sito è diramato nel rispetto delle norme vigenti ed in conformità alle
|
||||
direttive del codice deontologico. L'attivazione del sito è stata comunicata all'Ordine Provinciale dei
|
||||
Veterinari della Provincia di Modena.
|
||||
Il messaggio informativo di questo sito è diramato nel rispetto delle norme vigenti ed
|
||||
in conformità alle direttive del codice deontologico. L'attivazione del sito è stata
|
||||
comunicata all'Ordine Provinciale dei Veterinari della Provincia di Modena.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
/*
|
||||
* DESIGN: "Clinical Warmth"
|
||||
* Hero a schermo intero con immagine animale + overlay gradiente + testo sovrapposto
|
||||
* Immagine: hero_dog_cat.jpg (golden retriever + gatto in prato soleggiato)
|
||||
* Testo bianco su overlay scuro, CTA verde acqua
|
||||
* Testo elegante su overlay scuro, card orari raffinata, CTA verdi acqua
|
||||
*/
|
||||
import { useState, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ChevronDown, Calendar, Phone } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { Calendar, ChevronDown, Clock, Phone } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const heroImages = [
|
||||
{
|
||||
@@ -48,20 +48,26 @@ const heroImages = [
|
||||
},
|
||||
];
|
||||
|
||||
const openingHours = [
|
||||
{ days: "Visite: Lunedì - Venerdì", hours: "09:00 - 19:30" },
|
||||
{ days: "Visite: Sabato", hours: "09:00 - 17:00" },
|
||||
{ days: "Urgenze: Lunedì - Venerdì", hours: "08:00 - 22:30" },
|
||||
{ days: "Urgenze: Sabato e Festivi", hours: "09:00 - 20:00" },
|
||||
];
|
||||
|
||||
export default function HeroSection() {
|
||||
const [currentImage, setCurrentImage] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
// 3 secondi di visualizzazione + 2 secondi di dissolvenza = 5 secondi totali
|
||||
const interval = setInterval(() => {
|
||||
setCurrentImage((prev) => (prev + 1) % heroImages.length);
|
||||
}, 5000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section className="relative h-[92vh] min-h-[600px] max-h-[900px] overflow-hidden">
|
||||
{/* Background images con crossfade */}
|
||||
{heroImages.map((img, index) => (
|
||||
<div
|
||||
key={img.url}
|
||||
@@ -71,78 +77,95 @@ export default function HeroSection() {
|
||||
<img
|
||||
src={img.url}
|
||||
alt={img.alt}
|
||||
className="w-full h-full object-cover"
|
||||
className="h-full w-full object-cover"
|
||||
style={{ transform: "scale(1.05)" }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Overlay gradiente scuro */}
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-[#0d2b3e]/85 via-[#1B4F72]/60 to-transparent" />
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-[#0d2b3e]/50 via-transparent to-transparent" />
|
||||
|
||||
{/* Contenuto hero */}
|
||||
<div className="relative z-10 h-full flex items-center">
|
||||
<div className="relative z-10 flex h-full items-center">
|
||||
<div className="container">
|
||||
<div className="max-w-2xl">
|
||||
{/* Badge */}
|
||||
<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 */}
|
||||
<div className="max-w-3xl">
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8, delay: 0.3 }}
|
||||
className="text-white leading-tight mb-4"
|
||||
className="mb-10 max-w-2xl text-white leading-[0.92]"
|
||||
style={{
|
||||
fontFamily: "'Cormorant Garamond', serif",
|
||||
fontSize: "clamp(2.5rem, 6vw, 4.5rem)",
|
||||
fontWeight: 600,
|
||||
fontSize: "clamp(3.15rem, 6.4vw, 5.5rem)",
|
||||
fontWeight: 500,
|
||||
letterSpacing: "0.03em",
|
||||
textShadow: "0 14px 34px rgba(0,0,0,0.24)",
|
||||
}}
|
||||
>
|
||||
La salute del tuo animale,{" "}
|
||||
<span className="italic text-[#4ECDC4]">la nostra missione</span>
|
||||
<span className="block text-white/92">Clinica Veterinaria</span>
|
||||
<span className="block italic text-white">Formiginese</span>
|
||||
</motion.h1>
|
||||
|
||||
{/* Sottotitolo */}
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 22 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.7, delay: 0.5 }}
|
||||
className="text-white/85 text-lg mb-8 leading-relaxed max-w-xl"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
transition={{ duration: 0.7, delay: 0.55 }}
|
||||
className="mb-14 max-w-[39rem]"
|
||||
>
|
||||
Clinica veterinaria specialistica a Formigine. Un team di 6 professionisti
|
||||
dedicati alla cura di cani e gatti, con tecnologie diagnostiche avanzate.
|
||||
</motion.p>
|
||||
<div className="rounded-[26px] border border-white/20 bg-white/10 px-6 py-6 shadow-[0_18px_45px_rgba(0,0,0,0.18)] backdrop-blur-md sm:px-7 sm:py-7">
|
||||
<h4
|
||||
className="mb-5 text-white/95 uppercase tracking-[0.28em]"
|
||||
style={{
|
||||
fontFamily: "'Nunito Sans', sans-serif",
|
||||
fontSize: "0.8rem",
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
Orari di apertura
|
||||
</h4>
|
||||
|
||||
<div className="space-y-4">
|
||||
{openingHours.map((slot) => (
|
||||
<div key={slot.days} className="flex items-start gap-3.5">
|
||||
<div className="mt-0.5 rounded-full bg-[#4ECDC4]/20 p-2">
|
||||
<Clock size={14} className="text-[#7ce3dc]" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-white/92 sm:text-[0.98rem]">
|
||||
{slot.days}
|
||||
</p>
|
||||
<p className="mt-0.5 text-sm text-white/72 sm:text-[0.96rem]">
|
||||
{slot.hours}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* CTA buttons */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.7, delay: 0.7 }}
|
||||
className="flex flex-col sm:flex-row gap-4"
|
||||
transition={{ duration: 0.7, delay: 0.75 }}
|
||||
className="flex flex-col gap-4 pt-1 sm:flex-row"
|
||||
>
|
||||
<Button
|
||||
size="lg"
|
||||
className="bg-[#4ECDC4] hover:bg-[#3ab5ad] text-white font-bold text-base px-8 py-6 shadow-lg shadow-[#4ECDC4]/30 transition-all duration-300 hover:shadow-xl hover:shadow-[#4ECDC4]/40 hover:-translate-y-0.5"
|
||||
onClick={() => document.getElementById("prenotazione")?.scrollIntoView({ behavior: "smooth" })}
|
||||
className="bg-[#4ECDC4] px-8 py-6 text-base font-bold text-white shadow-lg shadow-[#4ECDC4]/30 transition-all duration-300 hover:-translate-y-0.5 hover:bg-[#3ab5ad] hover:shadow-xl hover:shadow-[#4ECDC4]/40"
|
||||
onClick={() =>
|
||||
document.getElementById("prenotazione")?.scrollIntoView({ behavior: "smooth" })
|
||||
}
|
||||
>
|
||||
<Calendar size={18} className="mr-2" />
|
||||
Prenota una Visita
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="lg"
|
||||
variant="outline"
|
||||
className="border-2 border-white text-white hover:bg-white hover:text-[#1B4F72] font-semibold text-base px-8 py-6 transition-all duration-300 bg-transparent backdrop-blur-sm"
|
||||
className="border-2 border-white bg-transparent px-8 py-6 text-base font-semibold text-white backdrop-blur-sm transition-all duration-300 hover:bg-white hover:text-[#1B4F72]"
|
||||
asChild
|
||||
>
|
||||
<a href="tel:0598396263">
|
||||
@@ -151,54 +174,24 @@ export default function HeroSection() {
|
||||
</a>
|
||||
</Button>
|
||||
</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>
|
||||
|
||||
{/* Indicatori slideshow */}
|
||||
<div className="absolute bottom-8 left-1/2 -translate-x-1/2 flex gap-2 z-10">
|
||||
<div className="absolute bottom-8 left-1/2 z-10 flex -translate-x-1/2 gap-2">
|
||||
{heroImages.map((_, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => setCurrentImage(index)}
|
||||
className={`transition-all duration-300 rounded-full ${
|
||||
index === currentImage
|
||||
? "w-8 h-2 bg-[#4ECDC4]"
|
||||
: "w-2 h-2 bg-white/50 hover:bg-white/80"
|
||||
className={`rounded-full transition-all duration-300 ${
|
||||
index === currentImage ? "h-2 w-8 bg-[#4ECDC4]" : "h-2 w-2 bg-white/50 hover:bg-white/80"
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Scroll indicator */}
|
||||
<div className="absolute bottom-8 right-8 z-10 hidden md:flex flex-col items-center gap-2 text-white/60">
|
||||
<span className="text-xs uppercase tracking-widest rotate-90 mb-2">Scroll</span>
|
||||
<div className="absolute bottom-8 right-8 z-10 hidden flex-col items-center gap-2 text-white/60 md:flex">
|
||||
<span className="mb-2 rotate-90 text-xs uppercase tracking-widest">Scroll</span>
|
||||
<ChevronDown size={16} className="animate-bounce" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
83
clinica-app/client/src/components/LegalPageLayout.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import Footer from "@/components/Footer";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
type LegalPageLayoutProps = {
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
intro: string;
|
||||
updatedAt: string;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export default function LegalPageLayout({
|
||||
eyebrow,
|
||||
title,
|
||||
intro,
|
||||
updatedAt,
|
||||
children,
|
||||
}: LegalPageLayoutProps) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F8F4EC] text-[#18364A]">
|
||||
<header className="border-b border-[#d8cec0] bg-white/95 backdrop-blur">
|
||||
<div className="container flex items-center justify-between py-4">
|
||||
<a href="/" className="inline-flex items-center gap-2 text-sm font-semibold text-[#1B4F72] hover:text-[#4ECDC4] transition-colors">
|
||||
<ArrowLeft size={16} />
|
||||
Torna alla home
|
||||
</a>
|
||||
<div className="text-right">
|
||||
<div
|
||||
className="leading-tight text-[#1B4F72]"
|
||||
style={{ fontFamily: "'Cormorant Garamond', serif", fontSize: "1rem", fontWeight: 700 }}
|
||||
>
|
||||
Clinica Veterinaria
|
||||
</div>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.24em] text-[#4ECDC4]">
|
||||
Formiginese
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section className="border-b border-[#e4d8c9] bg-white">
|
||||
<div className="container py-14 md:py-18">
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<div className="h-0.5 w-12 bg-[#4ECDC4]" />
|
||||
<span className="text-sm font-semibold uppercase tracking-widest text-[#4ECDC4]">
|
||||
{eyebrow}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid gap-6 lg:grid-cols-[minmax(0,2fr)_minmax(280px,1fr)] lg:items-end">
|
||||
<div>
|
||||
<h1
|
||||
className="text-[#1B4F72]"
|
||||
style={{ fontFamily: "'Cormorant Garamond', serif", fontSize: "clamp(2.2rem, 5vw, 4rem)", fontWeight: 600, lineHeight: 1.06 }}
|
||||
>
|
||||
{title}
|
||||
</h1>
|
||||
<p className="mt-5 max-w-3xl text-base leading-relaxed text-gray-600">
|
||||
{intro}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-[#e6ddcf] bg-[#F8F4EC] px-5 py-4 text-sm text-gray-600 shadow-sm">
|
||||
<p className="font-semibold text-[#1B4F72]">Ultimo aggiornamento</p>
|
||||
<p className="mt-1">{updatedAt}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="container py-12 md:py-16">
|
||||
<div className="rounded-[28px] border border-[#e9dece] bg-white px-6 py-8 shadow-sm md:px-10 md:py-10">
|
||||
<div className="legal-content prose prose-slate max-w-none prose-headings:text-[#1B4F72] prose-headings:font-semibold prose-p:text-gray-600 prose-li:text-gray-600 prose-strong:text-[#1B4F72]">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ChevronDown, Menu, X, Phone, MapPin } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
type NavLink = {
|
||||
label: string;
|
||||
@@ -47,7 +48,6 @@ const navLinks: NavLink[] = [
|
||||
],
|
||||
},
|
||||
{ label: "Il Team", href: "#team" },
|
||||
{ label: "News", href: "#news" },
|
||||
{ label: "Contatti", href: "#contatti" },
|
||||
];
|
||||
|
||||
@@ -55,6 +55,12 @@ export default function Navbar() {
|
||||
const [scrolled, setScrolled] = 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(() => {
|
||||
const handleScroll = () => setScrolled(window.scrollY > 80);
|
||||
window.addEventListener("scroll", handleScroll);
|
||||
@@ -73,12 +79,10 @@ export default function Navbar() {
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Phone size={13} className="text-[#4ECDC4]" />
|
||||
059 839.62.63 | 320 532.24.39 (urgenze 24h)
|
||||
059 839.62.63 | 320 532.24.39 (urgenze)
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[#4ECDC4] font-semibold tracking-wide text-xs uppercase">
|
||||
Reperibilità 24h · 7 giorni su 7
|
||||
</span>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -150,7 +154,7 @@ export default function Navbar() {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
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
|
||||
</Button>
|
||||
@@ -206,7 +210,7 @@ export default function Navbar() {
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-[#1B4F72] text-[#1B4F72] w-full"
|
||||
onClick={() => { setMobileOpen(false); document.getElementById("registrazione")?.scrollIntoView({ behavior: "smooth" }); }}
|
||||
onClick={() => { setMobileOpen(false); showActivationToast(); }}
|
||||
>
|
||||
Accedi / Registrati
|
||||
</Button>
|
||||
|
||||
@@ -19,7 +19,7 @@ const news = [
|
||||
"Con l'arrivo della primavera è il momento ideale per aggiornare il piano vaccinale del tuo cane o gatto. Scopri quali vaccini sono essenziali e perché.",
|
||||
date: "15 Marzo 2026",
|
||||
readTime: "4 min",
|
||||
image: "https://images.unsplash.com/photo-1587300003388-59208cc962cb?w=600&q=80",
|
||||
image: "/images/news_gatto.jpg",
|
||||
},
|
||||
{
|
||||
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.",
|
||||
date: "8 Marzo 2026",
|
||||
readTime: "6 min",
|
||||
image: "https://images.unsplash.com/photo-1514888286974-6c03e2ca1dba?w=600&q=80",
|
||||
image: "/images/news_consulenza.jpg",
|
||||
},
|
||||
{
|
||||
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.",
|
||||
date: "1 Marzo 2026",
|
||||
readTime: "5 min",
|
||||
image: "https://images.unsplash.com/photo-1548767797-d8c844163c4c?w=600&q=80",
|
||||
image: "/images/news_cucciolo.jpg",
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -1,32 +1,57 @@
|
||||
/*
|
||||
* DESIGN: "Clinical Warmth"
|
||||
* Sezione servizi con 6 card principali allineate al menu della clinica.
|
||||
* Layout: immagine in alto, bordo superiore colorato, hover lift.
|
||||
* Sezione servizi con due livelli:
|
||||
* - Servizi Clinici
|
||||
* - Visite Specialistiche
|
||||
*/
|
||||
import { motion, useInView } from "framer-motion";
|
||||
import { useRef } from "react";
|
||||
import { Activity, ArrowRight, FlaskConical, Scan, Stethoscope, Video } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Activity,
|
||||
Apple,
|
||||
Bone,
|
||||
Eye,
|
||||
FlaskConical,
|
||||
HeartPulse,
|
||||
Scan,
|
||||
Sparkles,
|
||||
Stethoscope,
|
||||
Video,
|
||||
} from "lucide-react";
|
||||
|
||||
const services = [
|
||||
type ServiceItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
description: string;
|
||||
image: string;
|
||||
imageClassName?: string;
|
||||
imageHoverClassName?: string;
|
||||
icon: React.ComponentType<{ size?: number; className?: string }>;
|
||||
color: string;
|
||||
features: string[];
|
||||
tone?: "default" | "specialist";
|
||||
};
|
||||
|
||||
const clinicalServices: ServiceItem[] = [
|
||||
{
|
||||
id: "visite-cliniche",
|
||||
title: "Visite Cliniche",
|
||||
subtitle: "Medicina preventiva",
|
||||
description:
|
||||
"Percorsi di prevenzione e visite cliniche complete per monitorare lo stato di salute di cani e gatti in ogni fase della vita. Un approccio attento, continuativo e personalizzato.",
|
||||
image: "https://images.unsplash.com/photo-1628009368231-7bb7cfcb0def?w=900&q=85",
|
||||
"Percorsi di prevenzione e visite cliniche complete per monitorare lo stato di salute di cani e gatti in ogni fase della vita.",
|
||||
image: "/images/services_visite_cliniche.jpg",
|
||||
icon: Stethoscope,
|
||||
color: "#1B4F72",
|
||||
features: ["Check-up periodici", "Vaccinazioni", "Profilassi antiparassitaria", "Piani prevenzione senior"],
|
||||
features: ["Check-up periodici", "Vaccinazioni", "Profilassi antiparassitaria", "Controlli senior"],
|
||||
},
|
||||
{
|
||||
id: "ecografia",
|
||||
title: "Ecografia",
|
||||
subtitle: "Diagnostica non invasiva",
|
||||
description:
|
||||
"Indagini ecografiche per approfondire in modo rapido e non invasivo organi addominali, apparato urinario, apparato riproduttivo e principali sospetti clinici.",
|
||||
image: "https://images.unsplash.com/photo-1579684385127-1ef15d508118?w=900&q=85",
|
||||
"Indagini ecografiche rapide per approfondire apparato addominale, urinario e riproduttivo con maggiore precisione clinica.",
|
||||
image: "/images/services_ecografia.jpg",
|
||||
icon: Activity,
|
||||
color: "#4ECDC4",
|
||||
features: ["Ecografia addominale", "Controlli gravidanza", "Valutazioni urinarie", "Follow-up terapeutici"],
|
||||
@@ -36,8 +61,8 @@ const services = [
|
||||
title: "Radiologia",
|
||||
subtitle: "Diagnostica per immagini",
|
||||
description:
|
||||
"Radiografie digitali e diagnostica per immagini a supporto della visita clinica, utili per valutare apparato scheletrico, torace, addome e urgenze.",
|
||||
image: "https://d2xsxph8kpxj0f.cloudfront.net/310519663483796440/DLUbzKbSnCG3dLeob4sLeC/radiology_39785f88.jpg",
|
||||
"Radiografie digitali e diagnostica per immagini per apparato scheletrico, torace, addome e approfondimenti d'urgenza.",
|
||||
image: "/images/services_radiologia.jpg",
|
||||
icon: Scan,
|
||||
color: "#2E86AB",
|
||||
features: ["Radiografia digitale", "Studi toracici", "Valutazioni ortopediche", "Diagnostica d'urgenza"],
|
||||
@@ -47,8 +72,8 @@ const services = [
|
||||
title: "Laboratorio",
|
||||
subtitle: "Analisi interne",
|
||||
description:
|
||||
"Laboratorio interno per esami rapidi e mirati, fondamentale per supportare diagnosi tempestive, monitoraggi terapeutici e controlli pre-operatori.",
|
||||
image: "https://d2xsxph8kpxj0f.cloudfront.net/310519663483796440/DLUbzKbSnCG3dLeob4sLeC/laboratory_5a1c4119.jpg",
|
||||
"Laboratorio interno per esami rapidi e mirati, utile per diagnosi tempestive, monitoraggi terapeutici e controlli pre-operatori.",
|
||||
image: "/images/services_laboratorio.jpg",
|
||||
icon: FlaskConical,
|
||||
color: "#1B4F72",
|
||||
features: ["Ematologia", "Biochimica", "Urine", "Citologia", "Coagulazione"],
|
||||
@@ -58,8 +83,8 @@ const services = [
|
||||
title: "Endoscopia",
|
||||
subtitle: "Esplorazione mini-invasiva",
|
||||
description:
|
||||
"Tecniche endoscopiche per esplorare apparato digerente e vie respiratorie, riducendo invasivita e tempi di recupero quando il quadro clinico lo consente.",
|
||||
image: "https://images.unsplash.com/photo-1530026405186-ed1f139313f8?w=900&q=85",
|
||||
"Tecniche endoscopiche per esplorare apparato digerente e vie respiratorie riducendo invasivita e tempi di recupero.",
|
||||
image: "/images/services_endoscopia.webp",
|
||||
icon: Video,
|
||||
color: "#4ECDC4",
|
||||
features: ["Valutazioni gastroenteriche", "Corpi estranei", "Biopsie mirate", "Esplorazioni respiratorie"],
|
||||
@@ -69,33 +94,125 @@ const services = [
|
||||
title: "Laparoscopia",
|
||||
subtitle: "Chirurgia mini-invasiva",
|
||||
description:
|
||||
"Procedure mini-invasive che permettono interventi piu delicati, incisioni ridotte e recuperi piu confortevoli rispetto alla chirurgia tradizionale.",
|
||||
image: "https://d2xsxph8kpxj0f.cloudfront.net/310519663483796440/DLUbzKbSnCG3dLeob4sLeC/surgery_e92904ed.jpg",
|
||||
"Procedure mini-invasive con incisioni ridotte, recuperi più confortevoli e maggiore delicatezza nei passaggi operatori.",
|
||||
image: "/images/services_laparoscopia.jpg",
|
||||
icon: Activity,
|
||||
color: "#2E86AB",
|
||||
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 isInView = useInView(ref, { once: true, margin: "-50px" });
|
||||
const specialist = service.tone === "specialist";
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
ref={ref}
|
||||
initial={{ opacity: 0, y: 40 }}
|
||||
animate={isInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.6, delay: index * 0.12 }}
|
||||
className="group bg-white rounded-2xl overflow-hidden shadow-md hover:shadow-xl transition-all duration-400 hover:-translate-y-1"
|
||||
transition={{ duration: 0.6, delay: index * 0.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">
|
||||
<img
|
||||
src={service.image}
|
||||
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
|
||||
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 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}
|
||||
</div>
|
||||
<h3
|
||||
className="text-[#1B4F72] mb-3"
|
||||
className="mb-3 text-[#1B4F72]"
|
||||
style={{ fontFamily: "'Cormorant Garamond', serif", fontSize: "1.75rem", fontWeight: 600 }}
|
||||
>
|
||||
{service.title}
|
||||
</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) => (
|
||||
<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}
|
||||
</li>
|
||||
))}
|
||||
</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>
|
||||
</motion.div>
|
||||
);
|
||||
@@ -143,7 +250,9 @@ function ServiceCard({ service, index }: { service: typeof services[0]; index: n
|
||||
|
||||
export default function ServicesSection() {
|
||||
const ref = useRef(null);
|
||||
const specialistRef = useRef(null);
|
||||
const isInView = useInView(ref, { once: true, margin: "-80px" });
|
||||
const specialistInView = useInView(specialistRef, { once: true, margin: "-80px" });
|
||||
|
||||
return (
|
||||
<section id="servizi" className="py-20 md:py-28" style={{ backgroundColor: "#F5F0E8" }}>
|
||||
@@ -155,46 +264,63 @@ export default function ServicesSection() {
|
||||
transition={{ duration: 0.7 }}
|
||||
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
|
||||
className="text-[#1B4F72] mb-4"
|
||||
className="mb-4 text-[#1B4F72]"
|
||||
style={{ fontFamily: "'Cormorant Garamond', serif", fontSize: "clamp(2rem, 4vw, 3rem)", fontWeight: 600 }}
|
||||
>
|
||||
Servizi Specialistici
|
||||
Servizi
|
||||
</h2>
|
||||
<p className="text-gray-600 max-w-xl text-base leading-relaxed">
|
||||
Sei aree di eccellenza clinica per accompagnare prevenzione, diagnosi e trattamento
|
||||
dei tuoi animali domestici, con tecnologie avanzate e professionisti esperti.
|
||||
<p className="max-w-xl text-base leading-relaxed text-gray-600">
|
||||
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-8">
|
||||
{services.map((service, index) => (
|
||||
<div className="grid grid-cols-1 gap-8 md:grid-cols-2 xl:grid-cols-3">
|
||||
{clinicalServices.map((service, index) => (
|
||||
<ServiceCard key={service.id} service={service} index={index} />
|
||||
))}
|
||||
</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
|
||||
initial={{ opacity: 0 }}
|
||||
animate={isInView ? { opacity: 1 } : {}}
|
||||
transition={{ duration: 0.7, delay: 0.6 }}
|
||||
className="text-center mt-12"
|
||||
ref={specialistRef}
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={specialistInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.7, delay: 0.2 }}
|
||||
className="mt-20"
|
||||
>
|
||||
<button
|
||||
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"
|
||||
onClick={() => {
|
||||
toast("Tutti i servizi", { description: "La pagina completa dei servizi sara disponibile a breve." });
|
||||
}}
|
||||
<h3
|
||||
className="mb-4 text-[#1B4F72]"
|
||||
style={{ fontFamily: "'Cormorant Garamond', serif", fontSize: "clamp(1.9rem, 3.7vw, 2.8rem)", fontWeight: 600 }}
|
||||
>
|
||||
Tutti i servizi
|
||||
<ArrowRight size={16} />
|
||||
</button>
|
||||
Visite Specialistiche
|
||||
</h3>
|
||||
</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>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -1,153 +1,259 @@
|
||||
/*
|
||||
* DESIGN: "Clinical Warmth"
|
||||
* Sezione team: griglia di 6 professionisti con card eleganti
|
||||
* Sfondo sabbia calda, foto placeholder con iniziali, specializzazioni colorate
|
||||
* Sezione team: due aree distinte per team medico e collaborazioni
|
||||
* Stessa grammatica visiva delle card precedenti, con schede pronte
|
||||
* ad accogliere progressivamente i profili completi.
|
||||
*/
|
||||
import { motion } from "framer-motion";
|
||||
import { useInView } from "framer-motion";
|
||||
import { motion, useInView } from "framer-motion";
|
||||
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",
|
||||
role: "Direttore Sanitario",
|
||||
specialization: "Oncologia Veterinaria",
|
||||
role: "Direttore sanitario",
|
||||
specialization: "Oncologia veterinaria",
|
||||
color: "#1B4F72",
|
||||
initials: "PP",
|
||||
bio: "Master in Oncologia Veterinaria. Direttore sanitario della clinica con oltre 20 anni di esperienza.",
|
||||
bio: [
|
||||
"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",
|
||||
role: "Medico Veterinario",
|
||||
specialization: "Chirurgia",
|
||||
name: "Dott.ssa Irene Paganelli",
|
||||
role: "Medico veterinario",
|
||||
specialization: "Ecografia ed esotici",
|
||||
color: "#4ECDC4",
|
||||
initials: "ER",
|
||||
bio: "Specializzata in chirurgia ortopedica e dei tessuti molli. Referente per gli interventi d'urgenza.",
|
||||
bio: [
|
||||
"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",
|
||||
role: "Medico Veterinario",
|
||||
specialization: "Radiologia",
|
||||
name: "Dott. Simone Tinti",
|
||||
role: "Medico veterinario",
|
||||
specialization: "Profilo in aggiornamento",
|
||||
color: "#2E86AB",
|
||||
initials: "MB",
|
||||
bio: "Esperto in diagnostica per immagini, ecografia e radiologia digitale veterinaria.",
|
||||
},
|
||||
{
|
||||
name: "Dott.ssa Sara Ferrari",
|
||||
role: "Medico Veterinario",
|
||||
specialization: "Medicina Interna",
|
||||
color: "#E8A838",
|
||||
initials: "SF",
|
||||
bio: "Specializzata in medicina interna felina e canina, con focus su malattie metaboliche.",
|
||||
name: "Dott.ssa Michela Sghedoni",
|
||||
role: "Medico veterinario",
|
||||
specialization: "Medicina interna, patologia clinica e citologia",
|
||||
color: "#B76E79",
|
||||
bio: [
|
||||
"Laureata presso l’Università degli Studi di Parma nel 2009. Dopo la laurea approfondisce gli studi di Medicina Interna e Patologia Clinica, aggiungendo successivamente anche la Citologia.",
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Dott. Luca Moretti",
|
||||
role: "Medico Veterinario",
|
||||
specialization: "Dermatologia",
|
||||
name: "Dott. Luca Pietri",
|
||||
role: "Medico veterinario",
|
||||
specialization: "Profilo in aggiornamento",
|
||||
color: "#6B8F71",
|
||||
initials: "LM",
|
||||
bio: "Esperto in dermatologia e allergologia veterinaria. Referente per le patologie cutanee.",
|
||||
},
|
||||
{
|
||||
name: "Dott.ssa Anna Conti",
|
||||
role: "Medico Veterinario",
|
||||
specialization: "Odontoiatria",
|
||||
color: "#9B5DE5",
|
||||
initials: "AC",
|
||||
bio: "Specializzata in odontoiatria veterinaria e chirurgia orale per cani e gatti.",
|
||||
name: "Dott.ssa Sara Casali",
|
||||
role: "Medico veterinario",
|
||||
specialization: "Profilo in aggiornamento",
|
||||
color: "#C58C63",
|
||||
},
|
||||
{
|
||||
name: "Dott. Riccardo Suffritti",
|
||||
role: "Medico veterinario",
|
||||
specialization: "Medicina interna, medicina d'urgenza, anestesiologia e dermatologia",
|
||||
color: "#7A8FA8",
|
||||
bio: [
|
||||
"Laureato in Medicina Veterinaria all'Università di Bologna a pieni voti nel 2020. Dopo la laurea approfondisce gli studi di Medicina Interna e Medicina d'Urgenza, aggiungendo successivamente anche Anestesiologia e Dermatologia.",
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Dott.ssa Elena Venturelli",
|
||||
role: "Medico veterinario",
|
||||
specialization: "Cardiologia e diagnostica ecografica",
|
||||
color: "#4C7A9F",
|
||||
bio: [
|
||||
"Laureata in Medicina Veterinaria all’Università di Bologna nel 2011. Dopo l’esperienza in medicina d’urgenza, approfondisce cardiologia e diagnostica ecografica con percorsi SCIVAC, internship specialistico in cardiologia con il dott. Claudio Bussadori, consegue GPCert Cardiology, Master universitari di II livello in Ultrasonologia e Cardiologia Veterinaria ed è abilitata FSA per la certificazione delle cardiopatie ereditarie. Nel 2024 consegue Master di II livello in Cardiologia Veterinaria presso l’Università di Camerino.",
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Dott.ssa Cinzia Pellegrini",
|
||||
role: "Medico veterinario",
|
||||
specialization: "Profilo in aggiornamento",
|
||||
color: "#8E6C88",
|
||||
},
|
||||
];
|
||||
|
||||
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 isInView = useInView(ref, { once: true, margin: "-40px" });
|
||||
const hasBio = Boolean(member.bio?.length);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
ref={ref}
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={isInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.6, delay: index * 0.1 }}
|
||||
className="group bg-white rounded-2xl overflow-hidden shadow-sm hover:shadow-lg transition-all duration-300 hover:-translate-y-1"
|
||||
transition={{ duration: 0.6, delay: index * 0.08 }}
|
||||
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 }} />
|
||||
|
||||
{/* Avatar */}
|
||||
<div className="p-6 pb-4">
|
||||
<div className="p-6 pb-5">
|
||||
<div className="flex items-start gap-4">
|
||||
<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" }}
|
||||
>
|
||||
{member.initials}
|
||||
{getInitials(member.name)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3
|
||||
className="text-[#1B4F72] font-semibold leading-tight"
|
||||
style={{ fontFamily: "'Cormorant Garamond', serif", fontSize: "1.15rem" }}
|
||||
className="leading-tight text-[#1B4F72]"
|
||||
style={{ fontFamily: "'Cormorant Garamond', serif", fontSize: "1.15rem", fontWeight: 600 }}
|
||||
>
|
||||
{member.name}
|
||||
</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
|
||||
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 }}
|
||||
>
|
||||
{member.specialization}
|
||||
</span>
|
||||
)}
|
||||
</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>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TeamSection() {
|
||||
function TeamGroup({
|
||||
eyebrow,
|
||||
members,
|
||||
}: {
|
||||
eyebrow: string;
|
||||
members: TeamMember[];
|
||||
}) {
|
||||
const ref = useRef(null);
|
||||
const isInView = useInView(ref, { once: true, margin: "-80px" });
|
||||
|
||||
return (
|
||||
<section id="team" className="py-20 md:py-28 bg-white">
|
||||
<div className="container">
|
||||
{/* Header */}
|
||||
<div className="mt-14 first:mt-0">
|
||||
<motion.div
|
||||
ref={ref}
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={isInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.7 }}
|
||||
className="mb-14"
|
||||
className="mb-10"
|
||||
>
|
||||
<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">
|
||||
Il Nostro Team
|
||||
<div className="mb-3 flex items-center gap-3">
|
||||
<div className="h-0.5 w-10 bg-[#4ECDC4]" />
|
||||
<span className="text-xs font-semibold uppercase tracking-[0.2em] text-[#4ECDC4]">
|
||||
{eyebrow}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col md:flex-row md:items-end md:justify-between gap-4">
|
||||
<h2
|
||||
className="text-[#1B4F72]"
|
||||
style={{ fontFamily: "'Cormorant Garamond', serif", fontSize: "clamp(2rem, 4vw, 3rem)", fontWeight: 600 }}
|
||||
>
|
||||
Sei professionisti,{" "}
|
||||
<span className="italic">un'unica passione</span>
|
||||
</h2>
|
||||
<p className="text-gray-600 max-w-sm text-sm leading-relaxed">
|
||||
Ogni membro del nostro team porta competenze specialistiche uniche,
|
||||
unite dalla stessa dedizione al benessere animale.
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Grid team */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{team.map((member, index) => (
|
||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{members.map((member, index) => (
|
||||
<TeamCard key={member.name} member={member} index={index} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TeamSection() {
|
||||
return (
|
||||
<section id="team" className="bg-white py-20 md:py-28">
|
||||
<div className="container">
|
||||
<TeamGroup eyebrow="Team medico" members={medicalTeam} />
|
||||
|
||||
<TeamGroup eyebrow="Collaborazioni" members={collaborators} />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
78
clinica-app/client/src/pages/CookiePolicyPage.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import LegalPageLayout from "@/components/LegalPageLayout";
|
||||
|
||||
export default function CookiePolicyPage() {
|
||||
return (
|
||||
<LegalPageLayout
|
||||
eyebrow="Informativa cookie"
|
||||
title="Cookie Policy"
|
||||
intro="Questa pagina descrive l'uso di cookie e di altri strumenti tecnici da parte del sito della Clinica Veterinaria Formiginese."
|
||||
updatedAt="23 maggio 2026"
|
||||
>
|
||||
<h2>1. Cosa sono i cookie</h2>
|
||||
<p>
|
||||
I cookie sono piccoli file di testo che i siti visitati possono salvare sul dispositivo
|
||||
dell'utente per consentire il funzionamento del sito, migliorare la navigazione o memorizzare
|
||||
preferenze e impostazioni.
|
||||
</p>
|
||||
|
||||
<h2>2. Tipologie di strumenti attualmente utilizzati</h2>
|
||||
<p>Allo stato attuale il sito utilizza esclusivamente strumenti tecnici o assimilabili ai tecnici, necessari a:</p>
|
||||
<ul>
|
||||
<li>erogare correttamente le pagine e i contenuti richiesti;</li>
|
||||
<li>gestire eventuali elementi tecnici indispensabili al funzionamento dell'interfaccia;</li>
|
||||
<li>garantire sicurezza, stabilità e corretta erogazione del servizio.</li>
|
||||
</ul>
|
||||
|
||||
<h2>3. Cookie tecnici</h2>
|
||||
<p>
|
||||
I cookie tecnici sono quelli strettamente necessari alla navigazione o alla fornitura di un
|
||||
servizio esplicitamente richiesto dall'utente. Per tali strumenti, secondo la normativa
|
||||
applicabile, non è richiesto il consenso preventivo dell'utente, fermo restando l'obbligo
|
||||
di fornire un'informativa adeguata.
|
||||
</p>
|
||||
|
||||
<h2>4. Cookie di profilazione e analytics</h2>
|
||||
<p>
|
||||
Alla data di aggiornamento di questa pagina il sito non utilizza cookie di profilazione,
|
||||
strumenti di marketing né sistemi di analytics attivi configurati per il monitoraggio del
|
||||
comportamento degli utenti a fini non tecnici.
|
||||
</p>
|
||||
<p>
|
||||
Qualora in futuro venissero introdotti strumenti di tracciamento non tecnici, la presente
|
||||
policy verrà aggiornata e, ove necessario, sarà implementato un meccanismo di raccolta del
|
||||
consenso conforme alla normativa applicabile.
|
||||
</p>
|
||||
|
||||
<h2>5. Risorse di terze parti</h2>
|
||||
<p>
|
||||
Il sito può richiamare risorse esterne di supporto, come librerie o font distribuiti da terzi.
|
||||
In tali casi il browser dell'utente può effettuare richieste ai rispettivi server per
|
||||
scaricare le risorse necessarie alla visualizzazione della pagina.
|
||||
</p>
|
||||
|
||||
<h2>6. Futuri servizi con autenticazione</h2>
|
||||
<p>
|
||||
In occasione della futura attivazione dell'area riservata potranno essere utilizzati cookie
|
||||
o identificatori tecnici di sessione strettamente necessari all'autenticazione e alla gestione
|
||||
sicura dell'accesso. Anche tali strumenti, se limitati alla funzione tecnica richiesta
|
||||
dall'utente, rientrano normalmente tra quelli esenti da consenso preventivo.
|
||||
</p>
|
||||
|
||||
<h2>7. Come gestire i cookie</h2>
|
||||
<p>
|
||||
L'utente può controllare e cancellare i cookie attraverso le impostazioni del proprio browser.
|
||||
La disattivazione dei cookie tecnici potrebbe tuttavia compromettere il corretto funzionamento
|
||||
di alcune funzionalità del sito.
|
||||
</p>
|
||||
|
||||
<h2>8. Contatti</h2>
|
||||
<p>
|
||||
Per qualsiasi chiarimento relativo all'uso dei cookie e degli altri strumenti tecnici è possibile
|
||||
contattare la Clinica Veterinaria Formiginese all'indirizzo{" "}
|
||||
<a href="mailto:clinicaveterinariaformiginese@gmail.com">
|
||||
clinicaveterinariaformiginese@gmail.com
|
||||
</a>.
|
||||
</p>
|
||||
</LegalPageLayout>
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import HeroSection from "@/components/HeroSection";
|
||||
import ServicesSection from "@/components/ServicesSection";
|
||||
import AboutSection from "@/components/AboutSection";
|
||||
import TeamSection from "@/components/TeamSection";
|
||||
import NewsSection from "@/components/NewsSection";
|
||||
import BookingSection from "@/components/BookingSection";
|
||||
import AuthSection from "@/components/AuthSection";
|
||||
import Footer from "@/components/Footer";
|
||||
@@ -23,7 +22,6 @@ export default function Home() {
|
||||
<ServicesSection />
|
||||
<AboutSection />
|
||||
<TeamSection />
|
||||
<NewsSection />
|
||||
<BookingSection />
|
||||
<AuthSection />
|
||||
</main>
|
||||
|
||||
78
clinica-app/client/src/pages/LegalNotesPage.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import LegalPageLayout from "@/components/LegalPageLayout";
|
||||
|
||||
export default function LegalNotesPage() {
|
||||
return (
|
||||
<LegalPageLayout
|
||||
eyebrow="Note legali"
|
||||
title="Note Legali"
|
||||
intro="Le presenti note disciplinano l'accesso e l'utilizzo del sito della Clinica Veterinaria Formiginese e forniscono informazioni generali sull'uso dei contenuti pubblicati."
|
||||
updatedAt="23 maggio 2026"
|
||||
>
|
||||
<h2>1. Informazioni generali</h2>
|
||||
<p>
|
||||
Il presente sito è dedicato alla presentazione della Clinica Veterinaria Formiginese,
|
||||
dei suoi servizi e delle modalità di contatto con la struttura. I contenuti pubblicati
|
||||
hanno finalità prevalentemente informative e organizzative.
|
||||
</p>
|
||||
|
||||
<h2>2. Utilizzo del sito</h2>
|
||||
<p>
|
||||
L'utente si impegna a utilizzare il sito in modo conforme alla legge, al buon costume e
|
||||
alle finalità per cui esso è messo a disposizione, evitando qualsiasi uso improprio,
|
||||
fraudolento o lesivo dei diritti del titolare o di terzi.
|
||||
</p>
|
||||
|
||||
<h2>3. Contenuti informativi</h2>
|
||||
<p>
|
||||
Le informazioni presenti sul sito, incluse quelle relative ad attività cliniche, visite,
|
||||
servizi e aggiornamenti divulgativi, non sostituiscono in alcun modo una valutazione
|
||||
veterinaria diretta e personalizzata. Per esigenze cliniche specifiche è sempre necessario
|
||||
rivolgersi direttamente alla struttura o al professionista competente.
|
||||
</p>
|
||||
|
||||
<h2>4. Prenotazioni e richieste online</h2>
|
||||
<p>
|
||||
Eventuali richieste di registrazione o di prenotazione visita inoltrate attraverso il sito
|
||||
hanno natura informativa o pre-organizzativa e non costituiscono, salvo diversa conferma da
|
||||
parte della clinica, appuntamento automaticamente confermato né accettazione vincolante della
|
||||
prestazione richiesta.
|
||||
</p>
|
||||
|
||||
<h2>5. Proprietà intellettuale</h2>
|
||||
<p>
|
||||
Salvo diversa indicazione, testi, immagini, elementi grafici, marchi, struttura del sito e
|
||||
altri contenuti presenti nel sito sono protetti dalle norme applicabili in materia di proprietà
|
||||
intellettuale e non possono essere copiati, riprodotti, diffusi o riutilizzati senza preventiva
|
||||
autorizzazione del titolare o dei rispettivi aventi diritto.
|
||||
</p>
|
||||
|
||||
<h2>6. Link a siti esterni</h2>
|
||||
<p>
|
||||
Il sito può contenere collegamenti a risorse esterne, gestite da soggetti terzi. La presenza di
|
||||
tali link non implica approvazione o controllo costante sui relativi contenuti. La clinica non
|
||||
risponde dei contenuti, delle policy o del funzionamento dei siti esterni.
|
||||
</p>
|
||||
|
||||
<h2>7. Limitazione di responsabilità</h2>
|
||||
<p>
|
||||
Pur ponendo la massima attenzione nell'aggiornamento dei contenuti, la clinica non garantisce
|
||||
che tutte le informazioni presenti sul sito siano sempre complete, prive di errori o aggiornate
|
||||
in tempo reale. Nei limiti consentiti dalla legge, il titolare non potrà essere ritenuto
|
||||
responsabile per danni derivanti dall'utilizzo del sito o dall'affidamento riposto nelle
|
||||
informazioni ivi contenute.
|
||||
</p>
|
||||
|
||||
<h2>8. Modifiche</h2>
|
||||
<p>
|
||||
Il titolare si riserva il diritto di aggiornare, modificare o rimuovere in qualsiasi momento
|
||||
contenuti, servizi, condizioni d'uso e presenti note legali, senza obbligo di preavviso.
|
||||
</p>
|
||||
|
||||
<h2>9. Legge applicabile</h2>
|
||||
<p>
|
||||
L'utilizzo del sito è regolato dalla legge italiana. Restano salve le tutele eventualmente
|
||||
previste dalla normativa inderogabile applicabile agli utenti.
|
||||
</p>
|
||||
</LegalPageLayout>
|
||||
);
|
||||
}
|
||||
108
clinica-app/client/src/pages/PrivacyPolicyPage.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import LegalPageLayout from "@/components/LegalPageLayout";
|
||||
|
||||
export default function PrivacyPolicyPage() {
|
||||
return (
|
||||
<LegalPageLayout
|
||||
eyebrow="Informativa privacy"
|
||||
title="Privacy Policy"
|
||||
intro="Questa informativa descrive come vengono trattati i dati personali degli utenti che consultano il sito della Clinica Veterinaria Formiginese, contattano la struttura o utilizzano i servizi online via via attivati."
|
||||
updatedAt="23 maggio 2026"
|
||||
>
|
||||
<h2>1. Titolare del trattamento</h2>
|
||||
<p>
|
||||
Il titolare del trattamento dei dati personali trattati attraverso questo sito è la
|
||||
Clinica Veterinaria Formiginese, con sede operativa in Via Quattro Passi, 16 -
|
||||
41043 Formigine (MO), contattabile all'indirizzo email{" "}
|
||||
<a href="mailto:clinicaveterinariaformiginese@gmail.com">
|
||||
clinicaveterinariaformiginese@gmail.com
|
||||
</a>{" "}
|
||||
e al numero <a href="tel:0598396263">059 839.62.63</a>.
|
||||
</p>
|
||||
<p>
|
||||
Eventuali ulteriori dati identificativi del titolare e i riferimenti amministrativi
|
||||
potranno essere integrati in questa pagina in occasione dei successivi aggiornamenti.
|
||||
</p>
|
||||
|
||||
<h2>2. Tipologie di dati trattati</h2>
|
||||
<p>Il sito può trattare le seguenti categorie di dati:</p>
|
||||
<ul>
|
||||
<li>dati di navigazione e dati tecnici necessari al funzionamento del sito;</li>
|
||||
<li>dati comunicati spontaneamente tramite email, telefono o moduli di contatto;</li>
|
||||
<li>dati inseriti nei servizi online di registrazione e richiesta di prenotazione;</li>
|
||||
<li>dati necessari alla gestione delle richieste informative e organizzative.</li>
|
||||
</ul>
|
||||
|
||||
<h2>3. Finalità del trattamento</h2>
|
||||
<p>I dati possono essere trattati per:</p>
|
||||
<ul>
|
||||
<li>consentire la navigazione e la sicurezza del sito;</li>
|
||||
<li>rispondere a richieste di informazioni inviate dagli utenti;</li>
|
||||
<li>gestire richieste di registrazione all'area riservata;</li>
|
||||
<li>gestire richieste di prenotazione visita non vincolanti e i relativi contatti di follow-up;</li>
|
||||
<li>adempiere a obblighi di legge, amministrativi o di tutela dei diritti del titolare.</li>
|
||||
</ul>
|
||||
|
||||
<h2>4. Base giuridica</h2>
|
||||
<p>
|
||||
Il trattamento è effettuato, a seconda dei casi, sulla base dell'art. 6, par. 1, lett. b)
|
||||
del Regolamento (UE) 2016/679 per l'esecuzione di misure precontrattuali richieste
|
||||
dall'interessato, dell'art. 6, par. 1, lett. c) per obblighi di legge, e dell'art. 6,
|
||||
par. 1, lett. f) per il legittimo interesse del titolare alla sicurezza del sito e alla
|
||||
gestione organizzativa delle richieste.
|
||||
</p>
|
||||
|
||||
<h2>5. Modalità di trattamento</h2>
|
||||
<p>
|
||||
I dati sono trattati con strumenti elettronici e organizzativi idonei a garantirne
|
||||
riservatezza, integrità, disponibilità e aggiornamento, nel rispetto dei principi di
|
||||
liceità, correttezza, trasparenza e minimizzazione.
|
||||
</p>
|
||||
|
||||
<h2>6. Conservazione dei dati</h2>
|
||||
<p>
|
||||
I dati sono conservati per il tempo strettamente necessario alle finalità per cui sono
|
||||
raccolti e, successivamente, nei limiti previsti dalle norme applicabili o comunque per
|
||||
il tempo necessario alla tutela dei diritti del titolare.
|
||||
</p>
|
||||
<p>
|
||||
Le richieste di contatto o prenotazione possono essere conservate per il tempo necessario
|
||||
alla loro gestione e per l'eventuale definizione dei successivi rapporti organizzativi o
|
||||
amministrativi con l'utente.
|
||||
</p>
|
||||
|
||||
<h2>7. Destinatari dei dati</h2>
|
||||
<p>
|
||||
I dati possono essere trattati da personale autorizzato della clinica e, ove necessario,
|
||||
da fornitori di servizi tecnici e informatici che operano quali responsabili o soggetti
|
||||
autorizzati al trattamento, nei limiti delle rispettive competenze.
|
||||
</p>
|
||||
|
||||
<h2>8. Trasferimenti verso Paesi terzi</h2>
|
||||
<p>
|
||||
Alcune risorse tecniche di terze parti eventualmente richiamate dal sito, come servizi di
|
||||
distribuzione di font o contenuti esterni, possono comportare il trattamento di dati tecnici
|
||||
da parte dei relativi fornitori. Tali trattamenti avvengono secondo le condizioni e le
|
||||
garanzie previste dai rispettivi operatori.
|
||||
</p>
|
||||
|
||||
<h2>9. Diritti degli interessati</h2>
|
||||
<p>
|
||||
Gli interessati possono esercitare i diritti previsti dagli articoli 15 e seguenti del
|
||||
Regolamento (UE) 2016/679, tra cui accesso, rettifica, cancellazione, limitazione del
|
||||
trattamento, opposizione e, ove applicabile, portabilità dei dati, contattando il titolare
|
||||
ai recapiti sopra indicati.
|
||||
</p>
|
||||
<p>
|
||||
Resta ferma la possibilità di proporre reclamo al Garante per la protezione dei dati
|
||||
personali, qualora si ritenga che il trattamento avvenga in violazione della normativa
|
||||
applicabile.
|
||||
</p>
|
||||
|
||||
<h2>10. Natura del conferimento</h2>
|
||||
<p>
|
||||
Il conferimento dei dati eventualmente richiesti nei moduli del sito è facoltativo, ma il
|
||||
mancato conferimento può impedire alla clinica di dare seguito alla richiesta dell'utente.
|
||||
</p>
|
||||
</LegalPageLayout>
|
||||
);
|
||||
}
|
||||
@@ -150,7 +150,15 @@ function vitePluginManusDebugCollector(): Plugin {
|
||||
};
|
||||
}
|
||||
|
||||
const plugins = [react(), tailwindcss(), jsxLocPlugin(), vitePluginManusRuntime(), vitePluginManusDebugCollector()];
|
||||
const isProductionBuild = process.env.NODE_ENV === "production";
|
||||
|
||||
const plugins = [
|
||||
react(),
|
||||
tailwindcss(),
|
||||
jsxLocPlugin(),
|
||||
!isProductionBuild && vitePluginManusRuntime(),
|
||||
!isProductionBuild && vitePluginManusDebugCollector(),
|
||||
].filter(Boolean);
|
||||
|
||||
export default defineConfig({
|
||||
plugins,
|
||||
@@ -168,7 +176,7 @@ export default defineConfig({
|
||||
emptyOutDir: true,
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
port: 3001,
|
||||
strictPort: false, // Will find next available port if 3000 is busy
|
||||
host: true,
|
||||
allowedHosts: [
|
||||
|
||||
90
docs/apache-ip-lockdown.md
Normal file
@@ -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/
|
||||
```
|
||||
@@ -11,13 +11,37 @@
|
||||
- endoscopia
|
||||
- laparoscopia
|
||||
|
||||
- viite specialistiche
|
||||
- visite specialistiche
|
||||
- oncologia
|
||||
- dermatologia
|
||||
- oculistica
|
||||
- nutrizione
|
||||
- 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
|
||||
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ powershell -NoProfile -ExecutionPolicy Bypass -Command ^
|
||||
"$path = '%CADDYFILE%';" ^
|
||||
"$content = Get-Content -Raw $path;" ^
|
||||
"$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);" ^
|
||||
"Set-Content -Path $path -Value $updated -Encoding UTF8;"
|
||||
if errorlevel 1 (
|
||||
@@ -34,7 +34,7 @@ powershell -NoProfile -ExecutionPolicy Bypass -Command ^
|
||||
"$path = '%CADDYFILE%';" ^
|
||||
"$content = Get-Content -Raw $path;" ^
|
||||
"$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);" ^
|
||||
"Set-Content -Path $path -Value $updated -Encoding UTF8;"
|
||||
if errorlevel 1 (
|
||||
|
||||