v1.3.0 — pannello admin completo, auth localStorage, Baileys WA, customers, calendario, paginazione, dashboard 7gg

This commit is contained in:
2026-04-12 17:46:08 +00:00
commit c33ec8450e
31 changed files with 3072 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
__pycache__/
*.pyc
.env
*.log
wa-service/node_modules/

13
Dockerfile Normal file
View File

@@ -0,0 +1,13 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app/ app/
COPY frontend/ frontend/
EXPOSE 8020
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8020"]

0
app/__init__.py Normal file
View File

52
app/config.py Normal file
View File

@@ -0,0 +1,52 @@
from pydantic_settings import BaseSettings
from functools import lru_cache
class Settings(BaseSettings):
# Database
database_url: str = "postgresql://booking:booking2026@db:5432/booking"
# App
app_name: str = "BookingService"
app_url: str = "https://booking.scan360.app"
api_key: str = "changeme"
cors_origins: list[str] = ["https://farmaciaianni.it", "http://localhost:3000"]
# Google OAuth
google_client_id: str = ""
google_client_secret: str = ""
google_redirect_uri: str = "https://booking.scan360.app/auth/callback"
allowed_domains: str = "kitzanos.com,farmaciaianni.it"
# JWT
jwt_secret: str = "xab-booking-jwt-secret-2026"
jwt_algorithm: str = "HS256"
jwt_expire_hours: int = 24
# Google Calendar
google_credentials_json: str = ""
# Notifications
smtp_host: str = "smtp.gmail.com"
smtp_port: int = 587
smtp_user: str = ""
smtp_pass: str = ""
smtp_from: str = "prenotazioni@farmaciaianni.it"
# WhatsApp gateway
wa_gateway_url: str = "http://host.docker.internal:18800/api/wa/send"
wa_enabled: bool = True
# Timezone
timezone: str = "Europe/Rome"
model_config = {"env_prefix": "BOOKING_", "env_file": ".env", "extra": "ignore"}
@property
def allowed_domains_list(self) -> list[str]:
return [d.strip() for d in self.allowed_domains.split(",") if d.strip()]
@lru_cache()
def get_settings() -> Settings:
return Settings()

19
app/database.py Normal file
View File

@@ -0,0 +1,19 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, DeclarativeBase
from app.config import get_settings
settings = get_settings()
engine = create_engine(settings.database_url, pool_pre_ping=True)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
class Base(DeclarativeBase):
pass
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()

55
app/main.py Normal file
View File

@@ -0,0 +1,55 @@
"""BookingService — microservizio prenotazioni FastAPI.
Container Docker autonomo, DB dedicato, zero dipendenze dal resto.
Google OAuth2 per admin con vincolo dominio.
"""
import logging
from pathlib import Path
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
from app.config import get_settings
from app.routers import public, admin, auth, customers
from app.routers import settings as settings_router
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s")
settings = get_settings()
app = FastAPI(
title=settings.app_name,
version="1.3.0",
docs_url="/api/docs",
redoc_url=None,
)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(auth.router)
app.include_router(public.router)
app.include_router(admin.router)
app.include_router(customers.router)
app.include_router(settings_router.router)
@app.get("/api/health")
def health():
return {"status": "ok", "service": settings.app_name, "version": "1.3.0"}
FRONTEND_DIR = Path("/app/frontend")
if FRONTEND_DIR.exists():
app.mount("/static", StaticFiles(directory=str(FRONTEND_DIR)), name="static")
@app.get("/")
def serve_index():
return FileResponse(str(FRONTEND_DIR / "index.html"))
@app.get("/admin")
def serve_admin():
return FileResponse(str(FRONTEND_DIR / "admin.html"))

71
app/models.py Normal file
View File

@@ -0,0 +1,71 @@
from sqlalchemy import Column, Integer, String, Boolean, Text, DateTime, ForeignKey, UniqueConstraint
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.database import Base
class Service(Base):
__tablename__ = "services"
id = Column(Integer, primary_key=True)
name = Column(Text, nullable=False)
slug = Column(Text, nullable=False, unique=True)
duration_min = Column(Integer, nullable=False, default=15)
description = Column(Text)
category = Column(Text, default="generale")
active = Column(Boolean, default=True)
sort_order = Column(Integer, default=0)
created_at = Column(DateTime(timezone=True), server_default=func.now())
provider_services = relationship("ProviderService", back_populates="service")
bookings = relationship("Booking", back_populates="service")
class Provider(Base):
__tablename__ = "providers"
id = Column(Integer, primary_key=True)
name = Column(Text, nullable=False)
email = Column(Text)
phone = Column(Text)
google_calendar_id = Column(Text)
active = Column(Boolean, default=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
provider_services = relationship("ProviderService", back_populates="provider")
bookings = relationship("Booking", back_populates="provider")
class ProviderService(Base):
__tablename__ = "provider_services"
__table_args__ = (UniqueConstraint("provider_id", "service_id"),)
id = Column(Integer, primary_key=True)
provider_id = Column(Integer, ForeignKey("providers.id", ondelete="CASCADE"))
service_id = Column(Integer, ForeignKey("services.id", ondelete="CASCADE"))
availability_rules = Column(JSONB, nullable=False, default=[])
provider = relationship("Provider", back_populates="provider_services")
service = relationship("Service", back_populates="provider_services")
class Booking(Base):
__tablename__ = "bookings"
id = Column(Integer, primary_key=True)
service_id = Column(Integer, ForeignKey("services.id"))
provider_id = Column(Integer, ForeignKey("providers.id"))
customer_name = Column(Text, nullable=False)
customer_phone = Column(Text, nullable=False)
customer_email = Column(Text)
start_at = Column(DateTime(timezone=True), nullable=False)
end_at = Column(DateTime(timezone=True), nullable=False)
status = Column(Text, default="confirmed")
google_event_id = Column(Text)
notes = Column(Text)
reminder_sent = Column(Boolean, default=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
service = relationship("Service", back_populates="bookings")
provider = relationship("Provider", back_populates="bookings")

0
app/routers/__init__.py Normal file
View File

258
app/routers/admin.py Normal file
View File

@@ -0,0 +1,258 @@
"""Endpoint admin — protetti con API key. Vista operatore."""
from datetime import date, datetime, timedelta
from zoneinfo import ZoneInfo
from fastapi import APIRouter, Depends, HTTPException, Header
from sqlalchemy.orm import Session, joinedload
from sqlalchemy import text
from app.database import get_db
from app.models import Service, Provider, ProviderService, Booking
from app.schemas import ServiceCreate, ServiceOut, ProviderCreate, ProviderOut, BookingOut, BookingUpdate
from app.config import get_settings
router = APIRouter(prefix="/api/admin", tags=["admin"])
settings = get_settings()
TZ = ZoneInfo("Europe/Rome")
DAYS = ['Lun','Mar','Mer','Gio','Ven','Sab','Dom']
def verify_api_key(x_api_key: str = Header(...)):
if x_api_key != settings.api_key:
raise HTTPException(401, "API key non valida")
return True
# === Bookings ===
@router.get("/bookings", dependencies=[Depends(verify_api_key)])
def list_bookings(
date: date | None = None,
from_date: date | None = None,
to_date: date | None = None,
status: str | None = None,
provider_id: int | None = None,
page: int = 1,
per_page: int = 20,
db: Session = Depends(get_db),
):
q = db.query(Booking).options(joinedload(Booking.service), joinedload(Booking.provider))
if date:
day_start = datetime.combine(date, datetime.min.time(), tzinfo=TZ)
q = q.filter(Booking.start_at >= day_start, Booking.start_at < day_start + timedelta(days=1))
elif from_date and to_date:
q = q.filter(
Booking.start_at >= datetime.combine(from_date, datetime.min.time(), tzinfo=TZ),
Booking.start_at < datetime.combine(to_date + timedelta(days=1), datetime.min.time(), tzinfo=TZ),
)
if status:
q = q.filter(Booking.status == status)
if provider_id:
q = q.filter(Booking.provider_id == provider_id)
total = q.count()
items = q.order_by(Booking.start_at.desc()).offset((page-1)*per_page).limit(per_page).all()
return {"items": [BookingOut.model_validate(b) for b in items], "total": total, "page": page, "per_page": per_page, "pages": (total + per_page - 1) // per_page}
@router.put("/bookings/{booking_id}", response_model=BookingOut, dependencies=[Depends(verify_api_key)])
def update_booking(booking_id: int, data: BookingUpdate, db: Session = Depends(get_db)):
booking = db.query(Booking).filter(Booking.id == booking_id).first()
if not booking:
raise HTTPException(404)
if data.status:
booking.status = data.status
if data.notes is not None:
booking.notes = data.notes
db.commit()
db.refresh(booking)
return db.query(Booking).options(
joinedload(Booking.service), joinedload(Booking.provider)
).filter(Booking.id == booking_id).first()
# === Services CRUD ===
@router.get("/services", response_model=list[ServiceOut], dependencies=[Depends(verify_api_key)])
def admin_list_services(db: Session = Depends(get_db)):
return db.query(Service).order_by(Service.sort_order, Service.name).all()
@router.post("/services", response_model=ServiceOut, dependencies=[Depends(verify_api_key)])
def create_service(data: ServiceCreate, db: Session = Depends(get_db)):
s = Service(**data.model_dump())
db.add(s)
db.commit()
db.refresh(s)
return s
@router.put("/services/{service_id}", response_model=ServiceOut, dependencies=[Depends(verify_api_key)])
def update_service(service_id: int, data: ServiceCreate, db: Session = Depends(get_db)):
s = db.query(Service).filter(Service.id == service_id).first()
if not s:
raise HTTPException(404)
for k, v in data.model_dump().items():
setattr(s, k, v)
db.commit()
db.refresh(s)
return s
@router.delete("/services/{service_id}", dependencies=[Depends(verify_api_key)])
def delete_service(service_id: int, db: Session = Depends(get_db)):
s = db.query(Service).filter(Service.id == service_id).first()
if not s:
raise HTTPException(404)
db.delete(s)
db.commit()
return {"ok": True}
# === Providers CRUD ===
@router.get("/providers", response_model=list[ProviderOut], dependencies=[Depends(verify_api_key)])
def admin_list_providers(db: Session = Depends(get_db)):
return db.query(Provider).filter(Provider.active == True).all()
@router.post("/providers", dependencies=[Depends(verify_api_key)])
def create_provider(data: ProviderCreate, db: Session = Depends(get_db)):
p = Provider(**data.model_dump())
db.add(p)
db.commit()
db.refresh(p)
return {"id": p.id, "name": p.name}
# === Provider detail with service assignments ===
@router.get("/providers/detail", dependencies=[Depends(verify_api_key)])
def providers_detail(db: Session = Depends(get_db)):
"""Tutti i provider con i loro servizi assegnati e regole orarie."""
providers = db.query(Provider).filter(Provider.active == True).order_by(Provider.id).all()
result = []
for p in providers:
assignments = db.query(ProviderService).filter(ProviderService.provider_id == p.id).all()
services = []
for a in assignments:
svc = db.query(Service).filter(Service.id == a.service_id).first()
if svc:
services.append({
"assignment_id": a.id,
"service_id": svc.id,
"service_name": svc.name,
"service_slug": svc.slug,
"duration_min": svc.duration_min,
"category": svc.category,
"availability_rules": a.availability_rules or [],
})
result.append({
"id": p.id,
"name": p.name,
"email": p.email,
"phone": p.phone,
"services": services,
})
return result
# === Provider-Service assignment CRUD ===
@router.post("/providers/{provider_id}/services/{service_id}", dependencies=[Depends(verify_api_key)])
def assign_service(provider_id: int, service_id: int, rules: list[dict], db: Session = Depends(get_db)):
"""Assegna servizio a operatore con regole orarie.
Body: [{"weekday":0,"start":"09:00","end":"13:00"}, ...]
"""
existing = db.query(ProviderService).filter(
ProviderService.provider_id == provider_id,
ProviderService.service_id == service_id
).first()
if existing:
existing.availability_rules = rules
else:
ps = ProviderService(provider_id=provider_id, service_id=service_id, availability_rules=rules)
db.add(ps)
db.commit()
return {"ok": True}
@router.put("/providers/{provider_id}/services/{service_id}", dependencies=[Depends(verify_api_key)])
def update_assignment(provider_id: int, service_id: int, rules: list[dict], db: Session = Depends(get_db)):
"""Aggiorna le regole orarie di un'assegnazione."""
ps = db.query(ProviderService).filter(
ProviderService.provider_id == provider_id,
ProviderService.service_id == service_id
).first()
if not ps:
raise HTTPException(404, "Assegnazione non trovata")
ps.availability_rules = rules
db.commit()
return {"ok": True}
@router.delete("/providers/{provider_id}/services/{service_id}", dependencies=[Depends(verify_api_key)])
def remove_assignment(provider_id: int, service_id: int, db: Session = Depends(get_db)):
"""Rimuovi assegnazione servizio da operatore."""
ps = db.query(ProviderService).filter(
ProviderService.provider_id == provider_id,
ProviderService.service_id == service_id
).first()
if not ps:
raise HTTPException(404)
db.delete(ps)
db.commit()
return {"ok": True}
# === Calendar view ===
@router.get("/calendar", dependencies=[Depends(verify_api_key)])
def calendar_view(
from_date: date,
to_date: date,
provider_id: int | None = None,
db: Session = Depends(get_db),
):
"""Prenotazioni per vista calendario, raggruppate per provider e giorno."""
q = db.query(Booking).options(joinedload(Booking.service), joinedload(Booking.provider)).filter(
Booking.start_at >= datetime.combine(from_date, datetime.min.time(), tzinfo=TZ),
Booking.start_at < datetime.combine(to_date + timedelta(days=1), datetime.min.time(), tzinfo=TZ),
Booking.status.in_(["confirmed", "completed"]),
)
if provider_id:
q = q.filter(Booking.provider_id == provider_id)
bookings = q.order_by(Booking.start_at).all()
# Group by date then provider
cal = {}
for b in bookings:
day = b.start_at.astimezone(TZ).strftime("%Y-%m-%d")
if day not in cal:
cal[day] = []
cal[day].append({
"id": b.id,
"start": b.start_at.astimezone(TZ).strftime("%H:%M"),
"end": b.end_at.astimezone(TZ).strftime("%H:%M"),
"service": b.service.name if b.service else "",
"service_slug": b.service.slug if b.service else "",
"category": b.service.category if b.service else "",
"provider_id": b.provider_id,
"provider": b.provider.name if b.provider else "",
"customer": b.customer_name,
"phone": b.customer_phone,
"status": b.status,
"notes": b.notes,
})
return cal
# === Stats ===
@router.get("/stats", dependencies=[Depends(verify_api_key)])
def booking_stats(db: Session = Depends(get_db)):
today_start = datetime.combine(datetime.now(TZ).date(), datetime.min.time(), tzinfo=TZ)
today_end = today_start + timedelta(days=1)
week_start = today_start - timedelta(days=today_start.weekday())
week_end = week_start + timedelta(days=7)
return {
"today": db.query(Booking).filter(Booking.start_at >= today_start, Booking.start_at < today_end, Booking.status == "confirmed").count(),
"this_week": db.query(Booking).filter(Booking.start_at >= week_start, Booking.start_at < week_end, Booking.status == "confirmed").count(),
"total_confirmed": db.query(Booking).filter(Booking.status == "confirmed").count(),
"total_no_shows": db.query(Booking).filter(Booking.status == "no_show").count(),
}

135
app/routers/auth.py Normal file
View File

@@ -0,0 +1,135 @@
"""Auth — dual mode: cookie (legacy) + localStorage token via URL param.
Flow: /auth/login → Google → /auth/callback → /admin?token=JWT
Il JS salva in localStorage, le API leggono da Authorization header O cookie.
"""
import logging
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Request, HTTPException, Header
from fastapi.responses import RedirectResponse, HTMLResponse
from jose import jwt
import httpx
from typing import Optional
from app.config import get_settings
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/auth", tags=["auth"])
GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"
GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"
GOOGLE_USERINFO_URL = "https://www.googleapis.com/oauth2/v2/userinfo"
COOKIE_NAME = "booking_token"
def create_jwt(email: str, name: str, picture: str = "") -> str:
settings = get_settings()
return jwt.encode({
"sub": email, "name": name, "picture": picture,
"exp": datetime.now(timezone.utc) + timedelta(hours=settings.jwt_expire_hours),
"iat": datetime.now(timezone.utc),
}, settings.jwt_secret, algorithm=settings.jwt_algorithm)
def verify_jwt(token: str) -> dict | None:
settings = get_settings()
try:
return jwt.decode(token, settings.jwt_secret, algorithms=[settings.jwt_algorithm])
except Exception:
return None
def get_current_user(request: Request) -> dict | None:
# 1. Check Authorization header (localStorage flow)
auth = request.headers.get("authorization", "")
if auth.startswith("Bearer "):
payload = verify_jwt(auth[7:])
if payload:
return payload
# 2. Fallback: check cookie
token = request.cookies.get(COOKIE_NAME)
if token:
return verify_jwt(token)
return None
@router.get("/login")
def login():
settings = get_settings()
params = {
"client_id": settings.google_client_id,
"redirect_uri": settings.google_redirect_uri,
"response_type": "code",
"scope": "openid email profile",
"access_type": "offline",
"prompt": "select_account",
}
url = GOOGLE_AUTH_URL + "?" + "&".join(f"{k}={v}" for k, v in params.items())
return RedirectResponse(url)
@router.get("/dev")
def dev_login(key: str = ""):
"""Dev bypass."""
settings = get_settings()
if key != settings.api_key:
raise HTTPException(403, "Chiave non valida")
token = create_jwt("mancosu@kitzanos.com", "Carlo Mancosu")
logger.info("Dev login: mancosu@kitzanos.com")
return RedirectResponse(url=f"/admin?token={token}", status_code=302)
@router.get("/callback")
async def callback(code: str):
"""Callback da Google → redirect a /admin?token=JWT"""
settings = get_settings()
async with httpx.AsyncClient() as client:
token_resp = await client.post(GOOGLE_TOKEN_URL, data={
"code": code,
"client_id": settings.google_client_id,
"client_secret": settings.google_client_secret,
"redirect_uri": settings.google_redirect_uri,
"grant_type": "authorization_code",
})
if token_resp.status_code != 200:
logger.error(f"Google token error: {token_resp.text}")
raise HTTPException(401, "Autenticazione Google fallita")
access_token = token_resp.json().get("access_token")
async with httpx.AsyncClient() as client:
user_resp = await client.get(GOOGLE_USERINFO_URL,
headers={"Authorization": f"Bearer {access_token}"})
if user_resp.status_code != 200:
raise HTTPException(401, "Impossibile ottenere info utente")
user_info = user_resp.json()
email = user_info.get("email", "")
name = user_info.get("name", "")
picture = user_info.get("picture", "")
domain = email.split("@")[-1].lower() if "@" in email else ""
if domain not in settings.allowed_domains_list:
logger.warning(f"Accesso negato: {email}")
return RedirectResponse(url="/admin?error=domain", status_code=302)
token = create_jwt(email, name, picture)
logger.info(f"Login riuscito: {email}")
return RedirectResponse(url=f"/admin?token={token}", status_code=302)
@router.get("/me")
def me(request: Request):
user = get_current_user(request)
if not user:
raise HTTPException(401, "Non autenticato")
return {"email": user["sub"], "name": user.get("name", ""), "picture": user.get("picture", "")}
@router.get("/logout")
def logout():
resp = RedirectResponse(url="/admin?logout=1", status_code=302)
resp.delete_cookie(COOKIE_NAME, path="/")
return resp

161
app/routers/customers.py Normal file
View File

@@ -0,0 +1,161 @@
"""Endpoint clienti e dashboard avanzata."""
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
from fastapi import APIRouter, Depends, HTTPException, Header
from sqlalchemy.orm import Session
from sqlalchemy import text, func
from app.database import get_db
from app.models import Booking, Service, Provider
from app.routers.admin import verify_api_key
router = APIRouter(prefix="/api/admin", tags=["customers"])
TZ = ZoneInfo("Europe/Rome")
# === Customers ===
@router.get("/customers", dependencies=[Depends(verify_api_key)])
def list_customers(q: str = "", page: int = 1, per_page: int = 20, db: Session = Depends(get_db)):
"""Lista clienti con ricerca e paginazione."""
where = ""
params = {}
if q:
where = " WHERE name ILIKE :q OR phone ILIKE :q OR email ILIKE :q"
params["q"] = f"%{q}%"
total = db.execute(text("SELECT COUNT(*) FROM customers" + where), params).scalar()
sql = "SELECT id, name, phone, email, notes, total_visits, last_visit_at, first_visit_at, created_at FROM customers" + where
sql += " ORDER BY total_visits DESC, last_visit_at DESC NULLS LAST"
sql += f" LIMIT {per_page} OFFSET {(page-1)*per_page}"
rows = db.execute(text(sql), params).fetchall()
return {"items": [dict(r._mapping) for r in rows], "total": total, "page": page, "per_page": per_page, "pages": max(1,(total + per_page - 1) // per_page)}
@router.get("/customers/{customer_id}", dependencies=[Depends(verify_api_key)])
def customer_detail(customer_id: int, db: Session = Depends(get_db)):
"""Dettaglio cliente con storico prenotazioni."""
c = db.execute(text("SELECT * FROM customers WHERE id = :id"), {"id": customer_id}).fetchone()
if not c:
raise HTTPException(404)
customer = dict(c._mapping)
# Storico prenotazioni
bookings = db.execute(text("""
SELECT b.id, b.start_at, b.end_at, b.status, b.notes,
s.name as service, p.name as provider
FROM bookings b
LEFT JOIN services s ON s.id = b.service_id
LEFT JOIN providers p ON p.id = b.provider_id
WHERE b.customer_id = :cid
ORDER BY b.start_at DESC
"""), {"cid": customer_id}).fetchall()
customer["bookings"] = [dict(r._mapping) for r in bookings]
# Servizi più usati
top_svcs = db.execute(text("""
SELECT s.name, COUNT(*) as cnt
FROM bookings b JOIN services s ON s.id = b.service_id
WHERE b.customer_id = :cid
GROUP BY s.name ORDER BY cnt DESC LIMIT 5
"""), {"cid": customer_id}).fetchall()
customer["top_services"] = [{"name": r[0], "count": r[1]} for r in top_svcs]
return customer
@router.put("/customers/{customer_id}", dependencies=[Depends(verify_api_key)])
def update_customer(customer_id: int, data: dict, db: Session = Depends(get_db)):
"""Aggiorna note cliente."""
db.execute(text("UPDATE customers SET notes = :notes WHERE id = :id"),
{"notes": data.get("notes", ""), "id": customer_id})
db.commit()
return {"ok": True}
# === Dashboard avanzata ===
@router.get("/dashboard", dependencies=[Depends(verify_api_key)])
def dashboard_data(db: Session = Depends(get_db)):
"""Dati completi per la dashboard."""
now = datetime.now(TZ)
today = now.date()
today_start = datetime.combine(today, datetime.min.time(), tzinfo=TZ)
next7_end = today_start + timedelta(days=7)
# KPI base
today_count = db.execute(text(
"SELECT COUNT(*) FROM bookings WHERE start_at >= :s AND start_at < :e AND status = 'confirmed'"
), {"s": today_start, "e": today_start + timedelta(days=1)}).scalar()
week_count = db.execute(text(
"SELECT COUNT(*) FROM bookings WHERE start_at >= :s AND start_at < :e AND status IN ('confirmed','completed')"
), {"s": today_start, "e": next7_end}).scalar()
# Domani
tomorrow_start = today_start + timedelta(days=1)
tomorrow_count = db.execute(text(
"SELECT COUNT(*) FROM bookings WHERE start_at >= :s AND start_at < :e AND status = 'confirmed'"
), {"s": tomorrow_start, "e": tomorrow_start + timedelta(days=1)}).scalar()
total = db.execute(text("SELECT COUNT(*) FROM bookings WHERE status IN ('confirmed','completed')")).scalar()
no_shows = db.execute(text("SELECT COUNT(*) FROM bookings WHERE status = 'no_show'")).scalar()
total_customers = db.execute(text("SELECT COUNT(*) FROM customers")).scalar()
# Prenotazioni per giorno questa settimana (per chart)
week_daily = []
for d in range(7):
day = today_start + timedelta(days=d)
day_end = day + timedelta(days=1)
cnt = db.execute(text(
"SELECT COUNT(*) FROM bookings WHERE start_at >= :s AND start_at < :e AND status IN ('confirmed','completed')"
), {"s": day, "e": day_end}).scalar()
weekday_idx = (today.weekday() + d) % 7
week_daily.append({"day": ['Lun','Mar','Mer','Gio','Ven','Sab','Dom'][weekday_idx], "date": day.strftime("%Y-%m-%d"), "count": cnt, "is_today": d == 0})
# Top servizi (ultimi 30gg)
top_services = db.execute(text("""
SELECT s.name, COUNT(*) as cnt FROM bookings b
JOIN services s ON s.id = b.service_id
WHERE b.start_at >= :s AND b.status IN ('confirmed','completed')
GROUP BY s.name ORDER BY cnt DESC LIMIT 5
"""), {"s": today_start - timedelta(days=30)}).fetchall()
# Top operatori (ultimi 30gg)
top_providers = db.execute(text("""
SELECT p.name, COUNT(*) as cnt FROM bookings b
JOIN providers p ON p.id = b.provider_id
WHERE b.start_at >= :s AND b.status IN ('confirmed','completed')
GROUP BY p.name ORDER BY cnt DESC LIMIT 5
"""), {"s": today_start - timedelta(days=30)}).fetchall()
# Prossime prenotazioni (prossimi 7 giorni)
upcoming = db.execute(text("""
SELECT b.id, b.start_at, b.customer_name, s.name as service, p.name as provider, b.status
FROM bookings b
LEFT JOIN services s ON s.id = b.service_id
LEFT JOIN providers p ON p.id = b.provider_id
WHERE b.start_at >= :now AND b.start_at < :week_end AND b.status = 'confirmed'
ORDER BY b.start_at LIMIT 8
"""), {"now": now, "week_end": today_start + timedelta(days=7)}).fetchall()
# Attività recenti (ultime 10 prenotazioni create)
recent = db.execute(text("""
SELECT b.id, b.created_at, b.customer_name, s.name as service, b.status
FROM bookings b LEFT JOIN services s ON s.id = b.service_id
ORDER BY b.created_at DESC LIMIT 8
""")).fetchall()
return {
"kpi": {
"today": today_count,
"week": week_count,
"tomorrow": tomorrow_count,
"total": total,
"no_shows": no_shows,
"customers": total_customers,
},
"week_chart": week_daily,
"top_services": [{"name": r[0], "count": r[1]} for r in top_services],
"top_providers": [{"name": r[0], "count": r[1]} for r in top_providers],
"upcoming": [dict(r._mapping) for r in upcoming],
"recent": [dict(r._mapping) for r in recent],
}

113
app/routers/public.py Normal file
View File

@@ -0,0 +1,113 @@
"""Endpoint pubblici — no auth. Usati dal widget di prenotazione."""
from datetime import date, datetime, timedelta
from zoneinfo import ZoneInfo
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session, joinedload
from app.database import get_db
from app.models import Service, Booking, ProviderService
from app.schemas import ServiceOut, BookingCreate, BookingOut, TimeSlot
from app.services.availability import get_available_slots
from app.services.notifications import notify_booking_confirmed, notify_operator
router = APIRouter(prefix="/api", tags=["public"])
TZ = ZoneInfo("Europe/Rome")
@router.get("/services", response_model=list[ServiceOut])
def list_services(db: Session = Depends(get_db)):
"""Lista servizi attivi."""
return (
db.query(Service)
.filter(Service.active == True)
.order_by(Service.sort_order, Service.name)
.all()
)
@router.get("/services/{service_id}/slots")
def get_slots(service_id: int, date: date, db: Session = Depends(get_db)):
"""Slot disponibili per un servizio in una data."""
service = db.query(Service).filter(Service.id == service_id).first()
if not service:
raise HTTPException(404, "Servizio non trovato")
slots = get_available_slots(db, service_id, date, service.duration_min)
return {"service": service.name, "date": str(date), "duration_min": service.duration_min, "slots": slots}
@router.post("/bookings", response_model=BookingOut)
async def create_booking(data: BookingCreate, db: Session = Depends(get_db)):
"""Crea una prenotazione. No login richiesto."""
# Verifica servizio
service = db.query(Service).filter(Service.id == data.service_id).first()
if not service:
raise HTTPException(404, "Servizio non trovato")
# Verifica che lo slot sia ancora libero
slots = get_available_slots(db, data.service_id, data.start_at.date(), service.duration_min)
slot_time = data.start_at.astimezone(TZ).strftime("%H:%M")
available = [s for s in slots if s.start == slot_time and s.provider_id == data.provider_id]
if not available:
raise HTTPException(409, "Lo slot non è più disponibile")
# Crea prenotazione
end_at = data.start_at + timedelta(minutes=service.duration_min)
booking = Booking(
service_id=data.service_id,
provider_id=data.provider_id,
customer_name=data.customer_name,
customer_phone=data.customer_phone,
customer_email=data.customer_email,
start_at=data.start_at,
end_at=end_at,
notes=data.notes,
)
db.add(booking)
db.commit()
db.refresh(booking)
# Carica relazioni per la risposta
booking = db.query(Booking).options(
joinedload(Booking.service), joinedload(Booking.provider)
).filter(Booking.id == booking.id).first()
# Notifiche async
await notify_booking_confirmed(booking, service.name, booking.provider.name)
if booking.provider.email:
await notify_operator(booking, service.name, booking.provider.email)
return booking
@router.get("/bookings/my")
def my_bookings(phone: str, db: Session = Depends(get_db)):
"""Le prenotazioni di un numero di telefono (per check 'ho già prenotato?')."""
clean = phone.replace("+", "").replace(" ", "").replace("-", "")
bookings = (
db.query(Booking)
.options(joinedload(Booking.service), joinedload(Booking.provider))
.filter(
Booking.customer_phone.contains(clean),
Booking.status == "confirmed",
Booking.start_at >= datetime.now(TZ),
)
.order_by(Booking.start_at)
.all()
)
return bookings
@router.delete("/bookings/{booking_id}")
def cancel_booking(booking_id: int, phone: str, db: Session = Depends(get_db)):
"""Cancella una prenotazione (verifica phone per sicurezza)."""
booking = db.query(Booking).filter(Booking.id == booking_id).first()
if not booking:
raise HTTPException(404, "Prenotazione non trovata")
clean = phone.replace("+", "").replace(" ", "").replace("-", "")
if clean not in booking.customer_phone:
raise HTTPException(403, "Numero non corrispondente")
booking.status = "cancelled"
db.commit()
return {"ok": True, "message": "Prenotazione cancellata"}

85
app/routers/settings.py Normal file
View File

@@ -0,0 +1,85 @@
"""Endpoint impostazioni — protetti con API key."""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from sqlalchemy import text
from app.database import get_db
from app.routers.admin import verify_api_key
router = APIRouter(prefix="/api/admin/settings", tags=["settings"], dependencies=[Depends(verify_api_key)])
@router.get("")
def get_settings(db: Session = Depends(get_db)):
"""Tutte le impostazioni come dict."""
rows = db.execute(text("SELECT key, value FROM settings ORDER BY key")).fetchall()
return {r[0]: r[1] for r in rows}
@router.put("")
def update_settings(data: dict, db: Session = Depends(get_db)):
"""Aggiorna una o più impostazioni. Body: {"key": "value", ...}"""
for k, v in data.items():
db.execute(
text("INSERT INTO settings (key, value, updated_at) VALUES (:k, :v, NOW()) "
"ON CONFLICT (key) DO UPDATE SET value = :v, updated_at = NOW()"),
{"k": k, "v": str(v)}
)
db.commit()
return get_settings(db)
@router.post("/test-email")
async def test_email(db: Session = Depends(get_db)):
"""Invia email di test con le impostazioni correnti."""
rows = db.execute(text("SELECT key, value FROM settings WHERE key LIKE 'smtp_%'")).fetchall()
cfg = {r[0]: r[1] for r in rows}
if not cfg.get("smtp_user"):
raise HTTPException(400, "Credenziali SMTP non configurate")
import smtplib
from email.mime.text import MIMEText
try:
msg = MIMEText("<h2>Test notifiche Farmacia Ianni</h2><p>Le impostazioni email funzionano correttamente.</p>", "html")
msg["Subject"] = "Test — Booking Farmacia Ianni"
msg["From"] = cfg.get("smtp_from", cfg["smtp_user"])
msg["To"] = cfg["smtp_user"]
with smtplib.SMTP(cfg.get("smtp_host", "smtp.gmail.com"), int(cfg.get("smtp_port", 587))) as server:
server.starttls()
server.login(cfg["smtp_user"], cfg.get("smtp_pass", ""))
server.sendmail(msg["From"], cfg["smtp_user"], msg.as_string())
return {"ok": True, "message": f"Email di test inviata a {cfg['smtp_user']}"}
except Exception as e:
raise HTTPException(500, f"Errore invio: {str(e)}")
@router.get("/wa-status")
async def wa_status(db: Session = Depends(get_db)):
"""Stato connessione WhatsApp Baileys."""
import httpx
rows = db.execute(text("SELECT value FROM settings WHERE key = 'wa_service_url'")).fetchone()
url = rows[0] if rows else "http://booking-wa:3100"
try:
async with httpx.AsyncClient(timeout=5) as client:
r = await client.get(f"{url}/status")
return r.json()
except Exception as e:
return {"connected": False, "error": str(e), "message": "Servizio WhatsApp non raggiungibile"}
@router.get("/wa-qr")
async def wa_qr(db: Session = Depends(get_db)):
"""QR code per collegare WhatsApp."""
import httpx
rows = db.execute(text("SELECT value FROM settings WHERE key = 'wa_service_url'")).fetchone()
url = rows[0] if rows else "http://booking-wa:3100"
try:
async with httpx.AsyncClient(timeout=10) as client:
r = await client.get(f"{url}/qr")
return r.json()
except Exception as e:
return {"qr": None, "error": str(e)}

90
app/schemas.py Normal file
View File

@@ -0,0 +1,90 @@
from pydantic import BaseModel, Field
from datetime import datetime, date
from typing import Optional
# === Services ===
class ServiceOut(BaseModel):
id: int
name: str
slug: str
duration_min: int
description: Optional[str] = None
category: str
class Config:
from_attributes = True
class ServiceCreate(BaseModel):
name: str
slug: str
duration_min: int = 15
description: Optional[str] = None
category: str = "generale"
# === Providers ===
class ProviderOut(BaseModel):
id: int
name: str
class Config:
from_attributes = True
class ProviderCreate(BaseModel):
name: str
email: Optional[str] = None
phone: Optional[str] = None
google_calendar_id: Optional[str] = None
# === Availability ===
class TimeSlot(BaseModel):
start: str # "09:00"
end: str # "09:30"
provider_id: int
provider_name: str
class AvailabilityRule(BaseModel):
weekday: int # 0=lun, 6=dom
start: str # "09:00"
end: str # "13:00"
# === Bookings ===
class BookingCreate(BaseModel):
service_id: int
provider_id: int
start_at: datetime
customer_name: str
customer_phone: str
customer_email: Optional[str] = None
notes: Optional[str] = None
class BookingOut(BaseModel):
id: int
service_id: int
provider_id: int
customer_name: str
customer_phone: str
customer_email: Optional[str] = None
start_at: datetime
end_at: datetime
status: str
notes: Optional[str] = None
created_at: datetime
service: Optional[ServiceOut] = None
provider: Optional[ProviderOut] = None
class Config:
from_attributes = True
class BookingUpdate(BaseModel):
status: Optional[str] = None
notes: Optional[str] = None

0
app/services/__init__.py Normal file
View File

View File

@@ -0,0 +1,100 @@
"""Calcolo slot disponibili per un servizio in una data.
Logica: regole orario provider - prenotazioni esistenti - busy Google Calendar.
Google Calendar è opzionale (fase 2).
"""
from datetime import datetime, date, timedelta, time
from zoneinfo import ZoneInfo
from sqlalchemy.orm import Session
from app.models import ProviderService, Booking
from app.schemas import TimeSlot
TZ = ZoneInfo("Europe/Rome")
def get_available_slots(
db: Session,
service_id: int,
target_date: date,
duration_min: int,
) -> list[TimeSlot]:
"""Restituisce tutti gli slot liberi per un servizio in una data."""
# 1. Trova tutti i provider che erogano questo servizio
ps_list = (
db.query(ProviderService)
.filter(ProviderService.service_id == service_id)
.all()
)
if not ps_list:
return []
weekday = target_date.weekday() # 0=lunedì
all_slots = []
for ps in ps_list:
if not ps.provider.active:
continue
# 2. Filtra le regole di disponibilità per il giorno della settimana
rules = ps.availability_rules or []
day_rules = [r for r in rules if r.get("weekday") == weekday]
if not day_rules:
continue
# 3. Genera tutti gli slot possibili dalle regole
raw_slots = []
for rule in day_rules:
start_h, start_m = map(int, rule["start"].split(":"))
end_h, end_m = map(int, rule["end"].split(":"))
slot_start = datetime.combine(target_date, time(start_h, start_m), tzinfo=TZ)
slot_end_limit = datetime.combine(target_date, time(end_h, end_m), tzinfo=TZ)
while slot_start + timedelta(minutes=duration_min) <= slot_end_limit:
raw_slots.append(slot_start)
slot_start += timedelta(minutes=duration_min)
# 4. Filtra via le prenotazioni esistenti (confirmed)
day_start = datetime.combine(target_date, time(0, 0), tzinfo=TZ)
day_end = day_start + timedelta(days=1)
existing = (
db.query(Booking)
.filter(
Booking.provider_id == ps.provider_id,
Booking.start_at >= day_start,
Booking.start_at < day_end,
Booking.status == "confirmed",
)
.all()
)
busy_ranges = [(b.start_at, b.end_at) for b in existing]
for slot_start in raw_slots:
slot_end = slot_start + timedelta(minutes=duration_min)
conflict = any(
slot_start < busy_end and slot_end > busy_start
for busy_start, busy_end in busy_ranges
)
if not conflict:
all_slots.append(
TimeSlot(
start=slot_start.strftime("%H:%M"),
end=slot_end.strftime("%H:%M"),
provider_id=ps.provider_id,
provider_name=ps.provider.name,
)
)
# 5. Ordina per orario
all_slots.sort(key=lambda s: s.start)
# 6. Filtra slot nel passato se target_date è oggi
now = datetime.now(TZ)
if target_date == now.date():
all_slots = [s for s in all_slots if s.start > now.strftime("%H:%M")]
return all_slots

View File

@@ -0,0 +1,114 @@
"""Notifiche: email SMTP + WhatsApp via gateway XAB."""
import smtplib
import logging
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from datetime import datetime
import httpx
from app.config import get_settings
log = logging.getLogger(__name__)
settings = get_settings()
async def send_wa_message(phone: str, text: str) -> bool:
"""Invia messaggio WhatsApp via gateway XAB su APP:18800."""
if not settings.wa_enabled:
log.info(f"WA disabled, skip: {phone}")
return False
try:
# Normalizza numero: +39 3xx -> 39 3xx
clean = phone.replace("+", "").replace(" ", "").replace("-", "")
if not clean.startswith("39"):
clean = "39" + clean
async with httpx.AsyncClient(timeout=10) as client:
r = await client.post(
settings.wa_gateway_url,
json={"to": clean, "text": text},
)
if r.status_code == 200:
log.info(f"WA sent to {clean}")
return True
else:
log.error(f"WA error {r.status_code}: {r.text}")
return False
except Exception as e:
log.error(f"WA exception: {e}")
return False
def send_email(to: str, subject: str, html_body: str) -> bool:
"""Invia email via SMTP."""
if not settings.smtp_user:
log.info(f"SMTP not configured, skip: {to}")
return False
try:
msg = MIMEMultipart("alternative")
msg["Subject"] = subject
msg["From"] = settings.smtp_from
msg["To"] = to
msg.attach(MIMEText(html_body, "html"))
with smtplib.SMTP(settings.smtp_host, settings.smtp_port) as server:
server.starttls()
server.login(settings.smtp_user, settings.smtp_pass)
server.sendmail(settings.smtp_from, to, msg.as_string())
log.info(f"Email sent to {to}")
return True
except Exception as e:
log.error(f"Email exception: {e}")
return False
async def notify_booking_confirmed(booking, service_name: str, provider_name: str):
"""Notifica al cliente: conferma prenotazione."""
dt = booking.start_at.strftime("%d/%m/%Y alle %H:%M")
# WhatsApp al cliente
wa_text = (
f"✅ Prenotazione confermata!\n\n"
f"Servizio: {service_name}\n"
f"Data: {dt}\n"
f"Operatore: {provider_name}\n\n"
f"Farmacia Ianni - Via Cassia 940, Roma\n"
f"Per cancellare, rispondi CANCELLA."
)
await send_wa_message(booking.customer_phone, wa_text)
# Email se fornita
if booking.customer_email:
html = f"""
<h2>Prenotazione confermata</h2>
<p><strong>Servizio:</strong> {service_name}</p>
<p><strong>Data:</strong> {dt}</p>
<p><strong>Operatore:</strong> {provider_name}</p>
<hr>
<p>Farmacia Ianni - Via Cassia 940, Roma</p>
"""
send_email(booking.customer_email, f"Prenotazione {service_name} - {dt}", html)
async def notify_operator(booking, service_name: str, provider_email: str):
"""Notifica all'operatore: nuova prenotazione."""
dt = booking.start_at.strftime("%d/%m/%Y alle %H:%M")
html = f"""
<h2>Nuova prenotazione</h2>
<p><strong>Cliente:</strong> {booking.customer_name}</p>
<p><strong>Telefono:</strong> {booking.customer_phone}</p>
<p><strong>Servizio:</strong> {service_name}</p>
<p><strong>Data:</strong> {dt}</p>
"""
if provider_email:
send_email(provider_email, f"Nuova prenotazione: {booking.customer_name} - {dt}", html)
async def send_reminder(booking, service_name: str):
"""Reminder 24h prima via WhatsApp."""
dt = booking.start_at.strftime("%d/%m/%Y alle %H:%M")
text = (
f"📅 Promemoria: domani hai un appuntamento\n\n"
f"Servizio: {service_name}\n"
f"Data: {dt}\n\n"
f"Farmacia Ianni - Via Cassia 940, Roma"
)
await send_wa_message(booking.customer_phone, text)

52
docker-compose.yml Normal file
View File

@@ -0,0 +1,52 @@
services:
db:
image: postgres:16-alpine
container_name: booking-db
environment:
POSTGRES_DB: booking
POSTGRES_USER: booking
POSTGRES_PASSWORD: booking2026
volumes:
- booking_pgdata:/var/lib/postgresql/data
- ./init.sql:/docker-entrypoint-initdb.d/init.sql
ports:
- "5440:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U booking"]
interval: 5s
timeout: 3s
retries: 5
app:
build: .
container_name: booking-app
env_file:
- .env
environment:
BOOKING_DATABASE_URL: postgresql://booking:booking2026@db:5432/booking
BOOKING_API_KEY: ianni-booking-2026
BOOKING_APP_URL: https://booking.scan360.app
BOOKING_WA_ENABLED: "true"
BOOKING_WA_GATEWAY_URL: http://wa:3100/send
BOOKING_TIMEZONE: Europe/Rome
ports:
- "8020:8020"
depends_on:
db:
condition: service_healthy
extra_hosts:
- "host.docker.internal:host-gateway"
restart: unless-stopped
wa:
build: ./wa-service
container_name: booking-wa
volumes:
- wa_auth:/data/auth
environment:
PORT: "3100"
restart: unless-stopped
volumes:
booking_pgdata:
wa_auth:

773
frontend/admin.html Normal file
View File

@@ -0,0 +1,773 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Gestionale — Farmacia Ianni</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:opsz,wght@9..40,300;9..40,400;9..40,500;9..40,600;9..40,700;9..40,800&display=swap" rel="stylesheet">
<style>
:root{
/* ── Farmacia Ianni — colori esatti dal logo SVG ── */
--navy-50:#e9eff4;--navy-100:#c7d5e2;--navy-200:#8dabc4;--navy-300:#5381a5;
--navy-400:#1b5786;--navy-500:#002c50;--navy-600:#002544;--navy-700:#001e38;
--navy-800:#00172c;--navy-900:#001020;
--green-50:#f2f8eb;--green-100:#ddecc9;--green-200:#b3d878;--green-300:#8ec44a;
--green-400:#80ba27;--green-500:#6a971f;--green-600:#577c19;--green-700:#446113;
/* ── Neutrals ── */
--n0:#fff;--n50:#f8f9fc;--n100:#f0f1f6;--n200:#e2e4ed;--n300:#c8cade;
--n400:#9699b8;--n500:#6a6d8e;--n600:#474a6a;--n700:#2f3154;--n800:#1e2040;--n900:#0f1028;
/* ── Semantic ── */
--ok-bg:#ecfaf3;--ok:#1a8c52;--warn-bg:#fff8eb;--warn:#b87d00;
--err-bg:#fff0f0;--err:#c52b2b;--info-bg:#eef6ff;--info:#1c6fd4;
/* ── System ── */
--font:'DM Sans',system-ui,sans-serif;
--r:8px;--r-lg:12px;--r-xl:16px;
--sh-sm:0 1px 3px rgba(0,44,80,.08),0 1px 2px rgba(0,0,0,.04);
--sh-md:0 4px 14px rgba(0,44,80,.10),0 2px 4px rgba(0,0,0,.04);
--sh-lg:0 8px 28px rgba(0,44,80,.14),0 4px 8px rgba(0,0,0,.06);
--sidebar-w:260px;
}
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
html{font-size:16px;-webkit-font-smoothing:antialiased}
body{font-family:var(--font);font-size:1rem;color:var(--n800);background:var(--n50);line-height:1.6}
button{font-family:inherit;cursor:pointer;border:none;background:none}
input,select,textarea{font-family:inherit;font-size:.9375rem}
/* ═══ LOGIN ═══ */
#login-screen{display:flex;align-items:center;justify-content:center;min-height:100vh;background:linear-gradient(145deg,var(--navy-900) 0%,var(--navy-500) 100%)}
.login-card{background:#fff;border-radius:var(--r-xl);padding:3.5rem 3rem;max-width:420px;width:100%;box-shadow:var(--sh-lg);text-align:center}
.login-card h1{font-size:1.5rem;font-weight:700;margin-bottom:.5rem;color:var(--navy-500)}
.login-card p{font-size:.9375rem;color:var(--n500);margin-bottom:2.5rem}
.login-card .logo-wrap{margin:0 auto 2rem;width:200px}
.login-card .logo-wrap img{width:100%;height:auto}
.btn-google{display:inline-flex;align-items:center;gap:1rem;padding:1rem 2rem;border:2px solid var(--n200);border-radius:var(--r-lg);font-weight:600;font-size:1rem;color:var(--n700);transition:.2s}
.btn-google:hover{border-color:var(--navy-400);box-shadow:var(--sh-sm)}
.login-hint{font-size:.8125rem;color:var(--n400);margin-top:2rem}
/* ═══ APP SHELL ═══ */
#app{display:none}
.shell{display:flex;height:100vh;overflow:hidden}
/* Sidebar */
.sidebar{width:var(--sidebar-w);background:var(--navy-500);display:flex;flex-direction:column;flex-shrink:0;overflow-y:auto}
.sidebar::-webkit-scrollbar{width:3px}
.sidebar::-webkit-scrollbar-thumb{background:rgba(255,255,255,.1);border-radius:2px}
.sb-brand{padding:1.75rem 1.75rem 1.5rem;border-bottom:1px solid rgba(255,255,255,.1)}
.sb-brand .logo-sidebar{width:160px;height:auto}
.sb-tag{display:block;font-size:.6875rem;font-weight:700;text-transform:uppercase;letter-spacing:.12em;color:rgba(255,255,255,.3);margin-top:.75rem}
.sb-nav{flex:1;padding:1rem 0}
.sb-section{font-size:.625rem;font-weight:700;text-transform:uppercase;letter-spacing:.14em;color:rgba(255,255,255,.2);padding:1.25rem 1.75rem .5rem}
.sb-item{display:flex;align-items:center;gap:1rem;padding:.875rem 1.5rem .875rem calc(1.75rem - 3px);font-size:1rem;font-weight:500;color:rgba(255,255,255,.5);border-left:3px solid transparent;cursor:pointer;transition:.15s}
.sb-item:hover{background:rgba(255,255,255,.06);color:rgba(255,255,255,.85)}
.sb-item.active{color:#fff;border-left-color:var(--green-400);background:rgba(255,255,255,.1);font-weight:600}
.sb-item svg{width:20px;height:20px;flex-shrink:0}
.sb-footer{padding:1.25rem 1.75rem;border-top:1px solid rgba(255,255,255,.1)}
.sb-user{font-size:.875rem;color:rgba(255,255,255,.5)}
.sb-user strong{display:block;color:rgba(255,255,255,.9);font-weight:600;font-size:.9375rem}
.btn-logout{font-size:.8125rem;color:rgba(255,255,255,.3);margin-top:.5rem;text-decoration:underline}
.btn-logout:hover{color:rgba(255,255,255,.7)}
/* Main */
.main{flex:1;display:flex;flex-direction:column;overflow:hidden}
.topbar{height:68px;background:#fff;border-bottom:1px solid var(--n200);display:flex;align-items:center;padding:0 2.5rem;flex-shrink:0}
.topbar h2{font-size:1.375rem;font-weight:700;color:var(--navy-500)}
.topbar .pill{margin-left:1rem;font-size:.8125rem;font-weight:700;background:var(--green-50);color:var(--green-600);padding:4px 14px;border-radius:999px}
.content{flex:1;overflow-y:auto;padding:2rem 2.5rem 4rem}
/* ═══ KPI ═══ */
.kpi-row{display:grid;grid-template-columns:repeat(4,1fr);gap:1.25rem;margin-bottom:2rem}
.kpi{background:#fff;border-radius:var(--r-lg);padding:1.75rem 2rem;box-shadow:var(--sh-sm);border:1px solid var(--n200)}
.kpi--hi{border-left:4px solid var(--green-400)}
.kpi__lbl{font-size:.8125rem;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:var(--n500);margin-bottom:.5rem}
.kpi__val{font-size:2.5rem;font-weight:800;color:var(--navy-500);line-height:1.1}
.kpi__sub{font-size:.875rem;color:var(--n400);margin-top:.25rem}
/* ═══ CARD / TABLE ═══ */
.card{background:#fff;border-radius:var(--r-lg);box-shadow:var(--sh-sm);border:1px solid var(--n200);overflow:hidden}
.card__hd{display:flex;align-items:center;justify-content:space-between;padding:1.25rem 1.75rem;border-bottom:1px solid var(--n100)}
.card__ti{font-size:1.0625rem;font-weight:700;color:var(--navy-500)}
.toolbar{display:flex;gap:.625rem;align-items:center}
table{width:100%;border-collapse:collapse}
th{font-size:.8125rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--n500);padding:1rem 1.25rem;text-align:left;background:var(--n50);border-bottom:1px solid var(--n200)}
td{padding:1rem 1.25rem;font-size:1rem;border-bottom:1px solid var(--n100);vertical-align:middle}
tr:last-child td{border-bottom:none}
tr:hover{background:var(--n50)}
/* ═══ BADGES ═══ */
.bs{display:inline-block;font-size:.8125rem;font-weight:600;padding:4px 14px;border-radius:999px}
.bs-confirmed{background:var(--ok-bg);color:var(--ok)}
.bs-completed{background:var(--info-bg);color:var(--info)}
.bs-cancelled{background:var(--err-bg);color:var(--err)}
.bs-no_show{background:var(--warn-bg);color:var(--warn)}
/* ═══ BUTTONS ═══ */
.btn{display:inline-flex;align-items:center;gap:.5rem;padding:.75rem 1.5rem;border-radius:var(--r);font-size:1rem;font-weight:600;transition:.15s}
.btn-p{background:var(--green-500);color:#fff}
.btn-p:hover{background:var(--green-600)}
.btn-s{padding:.5rem 1rem;font-size:.9375rem}
.btn-g{color:var(--n500);padding:.5rem .75rem;font-size:1rem}
.btn-g:hover{background:var(--n100);color:var(--n700)}
.btn-d{color:var(--err)}
.btn-d:hover{background:var(--err-bg)}
/* ═══ INPUTS ═══ */
.inp{padding:.75rem 1rem;border:1px solid var(--n200);border-radius:var(--r);outline:none;font-size:1rem;transition:.15s}
.inp:focus{border-color:var(--navy-400);box-shadow:0 0 0 3px rgba(0,44,80,.1)}
.inp-s{padding:.5rem .875rem;font-size:.9375rem}
select.inp{cursor:pointer}
/* ═══ MODAL ═══ */
.mo{position:fixed;inset:0;background:rgba(0,16,32,.45);display:flex;align-items:center;justify-content:center;z-index:1000;opacity:0;pointer-events:none;transition:.2s}
.mo.open{opacity:1;pointer-events:auto}
.mo__box{background:#fff;border-radius:var(--r-xl);padding:2.5rem;width:100%;max-width:540px;box-shadow:var(--sh-lg)}
.mo__box h3{font-size:1.25rem;font-weight:700;margin-bottom:1.5rem;color:var(--navy-500)}
.fg{margin-bottom:1.25rem}
.fg label{display:block;font-size:.875rem;font-weight:600;color:var(--n600);margin-bottom:.375rem}
.fg .inp{width:100%}
.fg textarea.inp{resize:vertical}
.f-actions{display:flex;justify-content:flex-end;gap:.75rem;margin-top:2rem}
/* ═══ MISC ═══ */
.prov-card{background:#fff;border-radius:var(--r-lg);box-shadow:var(--sh-sm);border:1px solid var(--n200);margin-bottom:1.25rem;overflow:hidden}
.prov-card__hd{display:flex;align-items:center;justify-content:space-between;padding:1.25rem 1.75rem;border-bottom:1px solid var(--n100);background:var(--n50)}
.prov-card__name{font-size:1.0625rem;font-weight:700;color:var(--navy-500)}
.prov-card__meta{font-size:.8125rem;color:var(--n400)}
.svc-row{display:flex;align-items:center;gap:1rem;padding:.875rem 1.75rem;border-bottom:1px solid var(--n100)}
.svc-row:last-child{border-bottom:none}
.svc-name{font-weight:600;min-width:180px}
.svc-days{display:flex;gap:.375rem;flex-wrap:wrap}
.svc-day{font-size:.75rem;font-weight:600;padding:2px 8px;border-radius:999px;background:var(--navy-50);color:var(--navy-500)}
.svc-time{font-size:.8125rem;color:var(--n500)}
.cal-grid{display:grid;grid-template-columns:80px repeat(7,1fr);min-height:500px}
.cal-hdr{font-size:.75rem;font-weight:700;text-transform:uppercase;color:var(--n500);padding:.75rem .5rem;text-align:center;border-bottom:2px solid var(--n200);background:var(--n50)}
.cal-hdr.today{background:var(--green-50);border-bottom-color:var(--green-400)}
.cal-time{font-size:.6875rem;color:var(--n400);padding:.25rem .5rem;text-align:right;border-right:1px solid var(--n200);height:40px;display:flex;align-items:start;justify-content:flex-end}
.cal-cell{border-right:1px solid var(--n100);border-bottom:1px solid var(--n100);height:40px;position:relative;padding:0 2px}
.cal-ev{position:absolute;left:2px;right:2px;border-radius:4px;padding:2px 4px;font-size:.625rem;font-weight:600;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;cursor:pointer;z-index:1;line-height:1.3}
.cal-ev:hover{z-index:10;overflow:visible;white-space:normal;box-shadow:var(--sh-md)}
.cat-diagnostica{background:#e3f2fd;color:#1565c0;border-left:3px solid #1565c0}
.cat-consulenza{background:#f3e5f5;color:#7b1fa2;border-left:3px solid #7b1fa2}
.cat-galenica{background:#fff3e0;color:#e65100;border-left:3px solid #e65100}
.cat-generale{background:var(--green-50);color:var(--green-700);border-left:3px solid var(--green-500)}
.empty{text-align:center;padding:4rem;color:var(--n400);font-size:1.0625rem}
.toast{position:fixed;bottom:2rem;right:2rem;background:var(--navy-500);color:#fff;padding:1rem 1.75rem;border-radius:var(--r-lg);font-size:1rem;font-weight:500;z-index:2000;transform:translateY(100px);opacity:0;transition:.3s}
.toast.show{transform:translateY(0);opacity:1}
.pgn{display:flex;align-items:center;justify-content:center;gap:.5rem;padding:1rem 1.5rem;border-top:1px solid var(--n100)}
.pgn button{padding:.375rem .75rem;border-radius:var(--r);font-size:.875rem;font-weight:600;color:var(--n500);background:var(--n0);border:1px solid var(--n200);cursor:pointer;transition:.15s}
.pgn button:hover:not(:disabled){background:var(--n100);color:var(--navy-500)}
.pgn button:disabled{opacity:.3;cursor:default}
.pgn button.active{background:var(--navy-500);color:#fff;border-color:var(--navy-500)}
.pgn span{font-size:.8125rem;color:var(--n400)}
.tab{display:none}.tab.active{display:block}
</style>
</head>
<body>
<!-- LOGIN -->
<div id="login-screen">
<div class="login-card">
<div class="logo-wrap"><img src="/static/logo.svg" alt="Farmacia Ianni"></div>
<h1>Area gestionale</h1>
<p>Gestione prenotazioni e servizi</p>
<a href="/auth/login" class="btn-google">
<svg width="22" height="22" viewBox="0 0 48 48"><path fill="#EA4335" d="M24 9.5c3.54 0 6.71 1.22 9.21 3.6l6.85-6.85C35.9 2.38 30.47 0 24 0 14.62 0 6.51 5.38 2.56 13.22l7.98 6.19C12.43 13.72 17.74 9.5 24 9.5z"/><path fill="#4285F4" d="M46.98 24.55c0-1.57-.15-3.09-.38-4.55H24v9.02h12.94c-.58 2.96-2.26 5.48-4.78 7.18l7.73 6c4.51-4.18 7.09-10.36 7.09-17.65z"/><path fill="#FBBC05" d="M10.53 28.59c-.48-1.45-.76-2.99-.76-4.59s.27-3.14.76-4.59l-7.98-6.19C.92 16.46 0 20.12 0 24c0 3.88.92 7.54 2.56 10.78l7.97-6.19z"/><path fill="#34A853" d="M24 48c6.48 0 11.93-2.13 15.89-5.81l-7.73-6c-2.15 1.45-4.92 2.3-8.16 2.3-6.26 0-11.57-4.22-13.47-9.91l-7.98 6.19C6.51 42.62 14.62 48 24 48z"/></svg>
Accedi con Google
</a>
<p class="login-hint">Accesso riservato @kitzanos.com e @farmaciaianni.it</p>
</div>
</div>
<!-- APP -->
<div id="app"><div class="shell">
<aside class="sidebar">
<div class="sb-brand">
<img src="/static/logo-white.svg" class="logo-sidebar" alt="Farmacia Ianni">
<span class="sb-tag">Gestionale prenotazioni</span>
</div>
<div class="sb-section">Pannello</div>
<nav class="sb-nav">
<div class="sb-item active" data-tab="dashboard" onclick="go('dashboard')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>Dashboard</div>
<div class="sb-item" data-tab="bookings" onclick="go('bookings')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>Prenotazioni</div>
<div class="sb-item" data-tab="calendar" onclick="go('calendar')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M3 10h18"/><path d="M8 2v4"/><path d="M16 2v4"/><circle cx="8" cy="15" r="1"/><circle cx="12" cy="15" r="1"/><circle cx="16" cy="15" r="1"/></svg>Calendario</div>
<div class="sb-item" data-tab="services" onclick="go('services')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>Servizi</div>
<div class="sb-item" data-tab="providers" onclick="go('providers')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4-4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 00-3-3.87"/><path d="M16 3.13a4 4 0 010 7.75"/></svg>Operatori</div>
<div class="sb-item" data-tab="customers" onclick="go('customers')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4-4v2"/><circle cx="12" cy="7" r="4"/></svg>Clienti</div>
<div class="sb-section" style="margin-top:.5rem">Sistema</div>
<div class="sb-item" data-tab="settings" onclick="go('settings')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/></svg>Impostazioni</div>
</nav>
<div class="sb-footer"><div class="sb-user"><strong id="u-name"></strong><span id="u-email"></span></div><button class="btn-logout" onclick="logout()">Esci</button></div>
</aside>
<div class="main">
<div class="topbar"><h2 id="tb-title">Dashboard</h2><span class="pill" id="tb-pill"></span></div>
<div class="content">
<!-- DASHBOARD -->
<div class="tab active" id="t-dashboard">
<!-- KPI row -->
<div style="display:grid;grid-template-columns:repeat(5,1fr);gap:1.25rem;margin-bottom:2rem">
<div class="kpi kpi--hi"><div class="kpi__lbl">Oggi</div><div class="kpi__val" id="k-today"></div><div class="kpi__sub">confermate</div></div>
<div class="kpi"><div class="kpi__lbl">Domani</div><div class="kpi__val" id="k-tomorrow"></div><div class="kpi__sub">confermate</div></div>
<div class="kpi"><div class="kpi__lbl">Prossimi 7gg</div><div class="kpi__val" id="k-week"></div><div class="kpi__sub">conf. + completate</div></div>
<div class="kpi"><div class="kpi__lbl">Clienti</div><div class="kpi__val" id="k-customers"></div><div class="kpi__sub">nel sistema</div></div>
<div class="kpi"><div class="kpi__lbl">No-show</div><div class="kpi__val" id="k-noshow"></div><div class="kpi__sub">non presentati</div></div>
</div>
<!-- Week chart + Top services -->
<div style="display:grid;grid-template-columns:2fr 1fr;gap:1.25rem;margin-bottom:1.5rem">
<div class="card"><div class="card__hd"><span class="card__ti">Prossimi 7 giorni</span></div>
<div id="d-weekchart" style="padding:1.25rem 1.5rem;height:180px;display:flex;align-items:flex-end;gap:.5rem"></div>
</div>
<div class="card"><div class="card__hd"><span class="card__ti">Top servizi (30gg)</span></div>
<div id="d-topsvcs" style="padding:1rem 1.5rem"></div>
</div>
</div>
<!-- Upcoming + Recent -->
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1.25rem">
<div class="card"><div class="card__hd"><span class="card__ti">Prossimi appuntamenti</span></div><div id="d-upcoming"></div></div>
<div class="card"><div class="card__hd"><span class="card__ti">Attività recenti</span></div><div id="d-recent"></div></div>
</div>
</div>
<!-- PRENOTAZIONI -->
<div class="tab" id="t-bookings">
<div class="card">
<div class="card__hd">
<span class="card__ti">Tutte le prenotazioni</span>
<div class="toolbar">
<input type="date" id="f-date" class="inp inp-s" onchange="loadBookings(1)">
<select id="f-status" class="inp inp-s" onchange="loadBookings(1)"><option value="">Tutti gli stati</option><option value="confirmed">Confermate</option><option value="completed">Completate</option><option value="cancelled">Cancellate</option><option value="no_show">No-show</option></select>
<select id="f-prov" class="inp inp-s" onchange="loadBookings(1)"><option value="">Tutti gli operatori</option></select>
</div>
</div>
<div id="d-bookings"></div>
</div>
</div>
<!-- SERVIZI -->
<div class="tab" id="t-services">
<div class="card"><div class="card__hd"><span class="card__ti">Servizi</span><button class="btn btn-p btn-s" onclick="openSvcModal()">+ Nuovo servizio</button></div><div id="d-services"></div></div>
</div>
<!-- OPERATORI -->
<div class="tab" id="t-providers">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1.5rem">
<div></div>
<button class="btn btn-p btn-s" onclick="openProvModal()">+ Nuovo operatore</button>
</div>
<div id="d-providers-detail"></div>
</div>
<!-- CLIENTI -->
<div class="tab" id="t-customers">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1.5rem">
<input type="text" id="cust-search" class="inp" placeholder="Cerca per nome, telefono o email..." style="width:350px" oninput="searchCustomers()">
<span id="cust-count" style="font-size:.875rem;color:var(--n400)"></span>
</div>
<div class="card"><div id="d-customers"></div></div>
</div>
<!-- CALENDARIO -->
<div class="tab" id="t-calendar">
<div style="display:flex;align-items:center;gap:1rem;margin-bottom:1.5rem">
<button class="btn btn-s btn-g" onclick="calNav(-7)">← Sett. prec.</button>
<h3 id="cal-range" style="font-size:1.0625rem;font-weight:700;color:var(--navy-500)"></h3>
<button class="btn btn-s btn-g" onclick="calNav(7)">Sett. succ. →</button>
<select id="cal-prov-filter" class="inp inp-s" onchange="loadCalendar()" style="margin-left:auto">
<option value="">Tutti gli operatori</option>
</select>
</div>
<div class="card" style="overflow-x:auto">
<div id="d-calendar" style="min-width:800px"></div>
</div>
</div>
<!-- IMPOSTAZIONI -->
<div class="tab" id="t-settings">
<!-- Farmacia -->
<div class="card" style="margin-bottom:1.5rem">
<div class="card__hd"><span class="card__ti">Dati farmacia</span></div>
<div style="padding:1.5rem 1.75rem">
<div class="fg"><label>Nome</label><input class="inp" id="cfg-pharmacy_name"></div>
<div class="fg"><label>Indirizzo</label><input class="inp" id="cfg-pharmacy_address"></div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem">
<div class="fg"><label>Telefono</label><input class="inp" id="cfg-pharmacy_phone"></div>
<div class="fg"><label>WhatsApp</label><input class="inp" id="cfg-pharmacy_wa"></div>
</div>
<button class="btn btn-p btn-s" onclick="saveSettings(['pharmacy_name','pharmacy_address','pharmacy_phone','pharmacy_wa'])">Salva dati farmacia</button>
</div>
</div>
<!-- WhatsApp -->
<div class="card" style="margin-bottom:1.5rem">
<div class="card__hd"><span class="card__ti">WhatsApp (Baileys)</span><span class="bs" id="wa-badge"></span></div>
<div style="padding:1.5rem 1.75rem">
<div id="wa-status-box" style="margin-bottom:1.25rem">
<p style="color:var(--n500);font-size:.9375rem">Caricamento stato...</p>
</div>
<div id="wa-qr-box" style="text-align:center;margin-bottom:1.25rem;display:none">
<p style="font-size:.875rem;color:var(--n500);margin-bottom:.75rem">Scansiona il QR con WhatsApp → Dispositivi collegati → Collega dispositivo</p>
<div style="position:relative;display:inline-block">
<img id="wa-qr-img" style="max-width:260px;border-radius:var(--r-lg);border:1px solid var(--n200)">
<div id="wa-qr-overlay" style="display:none;position:absolute;inset:0;background:rgba(0,44,80,.85);border-radius:var(--r-lg);display:none;align-items:center;justify-content:center;flex-direction:column;gap:.75rem">
<span style="color:#fff;font-weight:700;font-size:1rem">QR scaduto</span>
<button class="btn btn-s" style="background:var(--green-500);color:#fff" onclick="loadWaQr()">Genera nuovo QR</button>
</div>
</div>
<div id="wa-qr-timer" style="margin-top:.75rem;font-size:.875rem;color:var(--n400)"></div>
</div>
<div style="display:flex;gap:.75rem;align-items:center">
<label style="font-size:.875rem;font-weight:600;color:var(--n600)">Notifiche WA attive</label>
<input type="checkbox" id="cfg-wa_enabled" onchange="toggleSetting('wa_enabled',this.checked)">
<button class="btn btn-s btn-g" onclick="checkWaStatus()" style="margin-left:auto">↻ Aggiorna stato</button>
</div>
</div>
</div>
<!-- Email SMTP -->
<div class="card">
<div class="card__hd"><span class="card__ti">Email (SMTP)</span><span class="bs" id="smtp-badge"></span></div>
<div style="padding:1.5rem 1.75rem">
<div style="display:grid;grid-template-columns:2fr 1fr;gap:1rem">
<div class="fg"><label>Server SMTP</label><input class="inp" id="cfg-smtp_host" placeholder="smtp.gmail.com"></div>
<div class="fg"><label>Porta</label><input class="inp" id="cfg-smtp_port" placeholder="587"></div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem">
<div class="fg"><label>Utente</label><input class="inp" id="cfg-smtp_user" placeholder="email@farmaciaianni.it"></div>
<div class="fg"><label>Password</label><input class="inp" id="cfg-smtp_pass" type="password" placeholder="App password Gmail"></div>
</div>
<div class="fg"><label>Mittente (From)</label><input class="inp" id="cfg-smtp_from" placeholder="prenotazioni@farmaciaianni.it"></div>
<div style="display:flex;gap:.75rem;align-items:center;margin-bottom:1rem">
<label style="font-size:.875rem;font-weight:600;color:var(--n600)">Notifiche email attive</label>
<input type="checkbox" id="cfg-smtp_enabled" onchange="toggleSetting('smtp_enabled',this.checked)">
</div>
<div style="display:flex;gap:.75rem">
<button class="btn btn-p btn-s" onclick="saveSettings(['smtp_host','smtp_port','smtp_user','smtp_pass','smtp_from'])">Salva SMTP</button>
<button class="btn btn-s btn-g" onclick="testEmail()">Invia email di test</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div></div>
<!-- MODAL: Servizio -->
<div class="mo" id="m-svc"><div class="mo__box">
<h3 id="m-svc-t">Nuovo servizio</h3>
<div class="fg"><label>Nome</label><input class="inp" id="s-name" placeholder="es. Consulenza dermatologica"></div>
<div class="fg"><label>Slug</label><input class="inp" id="s-slug" placeholder="es. consulenza-dermo"></div>
<div class="fg"><label>Durata (minuti)</label><input class="inp" id="s-dur" type="number" value="30"></div>
<div class="fg"><label>Categoria</label><input class="inp" id="s-cat" placeholder="es. salute" value="generale"></div>
<div class="fg"><label>Descrizione</label><textarea class="inp" id="s-desc" rows="3"></textarea></div>
<div class="f-actions"><button class="btn btn-g" onclick="closeM('m-svc')">Annulla</button><button class="btn btn-p" onclick="saveSvc()">Salva</button></div>
</div></div>
<!-- MODAL: Operatore -->
<div class="mo" id="m-prov"><div class="mo__box">
<h3>Nuovo operatore</h3>
<div class="fg"><label>Nome e cognome</label><input class="inp" id="p-name" placeholder="es. Dott.ssa Maria Rossi"></div>
<div class="fg"><label>Email</label><input class="inp" id="p-email" type="email" placeholder="email@farmaciaianni.it"></div>
<div class="fg"><label>Telefono</label><input class="inp" id="p-phone" placeholder="+39 ..."></div>
<div class="f-actions"><button class="btn btn-g" onclick="closeM('m-prov')">Annulla</button><button class="btn btn-p" onclick="saveProv()">Salva</button></div>
</div></div>
<!-- MODAL: Dettaglio cliente -->
<div class="mo" id="m-cust"><div class="mo__box" style="max-width:640px;max-height:80vh;overflow-y:auto"><div id="m-cust-body"></div><div class="f-actions"><button class="btn btn-g" onclick="closeM('m-cust')">Chiudi</button></div></div></div>
<!-- MODAL: Assegna servizio -->
<div class="mo" id="m-assign"><div class="mo__box">
<h3 id="m-assign-t">Assegna servizio</h3>
<div class="fg"><label>Servizio</label><select class="inp" id="a-svc"></select></div>
<div class="fg"><label>Giorni e orari</label>
<div id="a-days" style="display:grid;grid-template-columns:auto 1fr 1fr;gap:.5rem;align-items:center"></div>
</div>
<div class="f-actions"><button class="btn btn-g" onclick="closeM('m-assign')">Annulla</button><button class="btn btn-p" onclick="saveAssign()">Assegna</button></div>
</div></div>
<div class="toast" id="toast"></div>
<script>
const API_KEY='ianni-booking-2026';
function getHeaders(){const h={'Content-Type':'application/json','x-api-key':API_KEY};const t=localStorage.getItem('booking_token');if(t)h['Authorization']='Bearer '+t;return h}
let user=null,editSvcId=null,provs=[];
const SL={confirmed:'Confermata',completed:'Completata',cancelled:'Cancellata',no_show:'No-show'};
const TT={dashboard:'Dashboard',bookings:'Prenotazioni',calendar:'Calendario',services:'Servizi',providers:'Operatori',customers:'Clienti',settings:'Impostazioni'};
const $=id=>document.getElementById(id);
async function api(url,opt){const r=await fetch(url,{headers:getHeaders(),...(opt||{})});if(!r.ok)throw new Error((await r.json()).detail||r.statusText);return r.json()}
/* Auth */
async function checkAuth(){
const params=new URLSearchParams(location.search);
if(params.get('token')){localStorage.setItem('booking_token',params.get('token'));history.replaceState(null,'','/admin')}
if(params.get('logout')){localStorage.removeItem('booking_token');history.replaceState(null,'','/admin')}
const token=localStorage.getItem('booking_token');
if(!token)return showLogin();
try{const r=await fetch('/auth/me',{headers:{'Authorization':'Bearer '+token}});
if(!r.ok){localStorage.removeItem('booking_token');return showLogin()}
user=await r.json();$('u-name').textContent=user.name||user.email;$('u-email').textContent=user.email;showApp()
}catch(e){showLogin()}}
function showLogin(){$('login-screen').style.display='flex';$('app').style.display='none'}
function showApp(){$('login-screen').style.display='none';$('app').style.display='block';loadAll()}
function logout(){localStorage.removeItem('booking_token');location.href='/auth/logout'}
/* Nav */
function go(t){document.querySelectorAll('.tab').forEach(e=>e.classList.remove('active'));document.querySelectorAll('.sb-item').forEach(e=>e.classList.remove('active'));$('t-'+t).classList.add('active');document.querySelector(`[data-tab="${t}"]`).classList.add('active');$('tb-title').textContent=TT[t]}
/* Dashboard */
async function loadStats(){try{
const d=await api('/api/admin/dashboard');
const k=d.kpi;
$('k-today').textContent=k.today;$('k-tomorrow').textContent=k.tomorrow||0;$('k-week').textContent=k.week;
$('k-noshow').textContent=k.no_shows;$('k-customers').textContent=k.customers;
$('tb-pill').textContent=k.today>0?k.today+' oggi':'';
// Week chart
const maxC=Math.max(...d.week_chart.map(x=>x.count),1);
$('d-weekchart').innerHTML=d.week_chart.map(x=>{
const pct=Math.round(x.count/maxC*100);
const bg=x.is_today?'var(--green-400)':'var(--navy-200)';
return '<div style="flex:1;display:flex;flex-direction:column;align-items:center;gap:.25rem">'
+'<span style="font-size:.8125rem;font-weight:700;color:var(--navy-500)">'+x.count+'</span>'
+'<div style="width:100%;height:'+Math.max(pct,4)+'%;background:'+bg+';border-radius:4px 4px 0 0;min-height:4px;transition:height .3s"></div>'
+'<span style="font-size:.6875rem;font-weight:600;color:'+(x.is_today?'var(--green-600)':'var(--n400)')+'">'+x.day+'</span></div>';
}).join('');
// Top services
const maxS=Math.max(...d.top_services.map(x=>x.count),1);
$('d-topsvcs').innerHTML=d.top_services.map(x=>{
const pct=Math.round(x.count/maxS*100);
return '<div style="margin-bottom:.75rem"><div style="display:flex;justify-content:space-between;font-size:.8125rem;margin-bottom:.25rem"><span style="font-weight:600">'+x.name+'</span><span style="color:var(--n400)">'+x.count+'</span></div>'
+'<div style="height:6px;background:var(--n100);border-radius:3px;overflow:hidden"><div style="width:'+pct+'%;height:100%;background:var(--green-400);border-radius:3px"></div></div></div>';
}).join('')||'<div class="empty" style="padding:1rem">Nessun dato</div>';
// Upcoming
if(d.upcoming.length){
let h='<table><tbody>';
d.upcoming.forEach(u=>{
const dt=new Date(u.start_at);const ts=dt.toLocaleTimeString('it-IT',{hour:'2-digit',minute:'2-digit'});
const ds=dt.toLocaleDateString('it-IT',{day:'2-digit',month:'short'});
h+='<tr><td><strong>'+ts+'</strong><br><span style="font-size:.75rem;color:var(--n400)">'+ds+'</span></td><td>'+u.customer_name+'</td><td style="color:var(--n500)">'+u.service+'</td><td style="font-size:.8125rem;color:var(--n400)">'+u.provider+'</td></tr>';
});
$('d-upcoming').innerHTML=h+'</tbody></table>';
}else{$('d-upcoming').innerHTML='<div class="empty">Nessun appuntamento imminente</div>'}
// Recent
if(d.recent.length){
let h='<table><tbody>';
d.recent.forEach(r=>{
const dt=new Date(r.created_at);const ds=dt.toLocaleDateString('it-IT',{day:'2-digit',month:'short',hour:'2-digit',minute:'2-digit'});
h+='<tr><td style="font-size:.8125rem;color:var(--n400)">'+ds+'</td><td>'+r.customer_name+'</td><td style="color:var(--n500)">'+(r.service||'')+'</td><td><span class="bs bs-'+r.status+'">'+(SL[r.status]||r.status)+'</span></td></tr>';
});
$('d-recent').innerHTML=h+'</tbody></table>';
}else{$('d-recent').innerHTML='<div class="empty">Nessuna attività</div>'}
}catch(e){console.error(e)}}
async function loadToday(){}
/* Bookings */
let bkPage=1;
async function loadBookings(pg){if(pg)bkPage=pg;try{let u='/api/admin/bookings?page='+bkPage+'&per_page=15&';const d=$('f-date').value,s=$('f-status').value,p=$('f-prov').value;if(d)u+='date='+d+'&';if(s)u+='status='+s+'&';if(p)u+='provider_id='+p+'&';const res=await api(u);if(!res.items.length){$('d-bookings').innerHTML='<div class="empty">Nessuna prenotazione trovata</div>';return}$('d-bookings').innerHTML=bTable(res.items,false)+pgHtml(res.page,res.pages,res.total,'loadBookings')}catch(e){console.error(e)}}
function bTable(data,short){
let h='<table><thead><tr>';if(!short)h+='<th>Data</th>';
h+='<th>Ora</th><th>Cliente</th><th>Servizio</th><th>Operatore</th><th>Stato</th>';if(!short)h+='<th>Note</th>';h+='<th></th></tr></thead><tbody>';
data.forEach(b=>{const dt=new Date(b.start_at);const ds=dt.toLocaleDateString('it-IT',{day:'2-digit',month:'short'});const ts=dt.toLocaleTimeString('it-IT',{hour:'2-digit',minute:'2-digit'});
h+='<tr>';if(!short)h+=`<td><strong>${ds}</strong></td>`;
h+=`<td><strong>${ts}</strong></td><td>${b.customer_name}<br><span style="font-size:.8125rem;color:var(--n400)">${b.customer_phone}</span></td><td>${b.service?.name||'—'}</td><td>${b.provider?.name||'—'}</td><td><span class="bs bs-${b.status}">${SL[b.status]||b.status}</span></td>`;
if(!short)h+=`<td style="font-size:.875rem;max-width:150px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${b.notes||''}</td>`;
h+='<td>';if(b.status==='confirmed')h+=`<button class="btn btn-s btn-g" onclick="updB(${b.id},'completed')" title="Completata">✓</button><button class="btn btn-s btn-g btn-d" onclick="updB(${b.id},'no_show')" title="No-show">✗</button>${short?'':`<button class="btn btn-s btn-g btn-d" onclick="updB(${b.id},'cancelled')" title="Cancella">✕</button>`}`;
h+='</td></tr>'});
return h+'</tbody></table>';
}
async function updB(id,status){if(!confirm('Cambiare stato a "'+SL[status]+'"?'))return;try{await api('/api/admin/bookings/'+id,{method:'PUT',body:JSON.stringify({status})});toast('Prenotazione aggiornata');loadAll()}catch(e){toast('Errore: '+e.message)}}
/* Services */
async function loadServices(){try{const data=await api('/api/admin/services');if(!data.length){$('d-services').innerHTML='<div class="empty">Nessun servizio configurato</div>';return}
let h='<table><thead><tr><th>Nome</th><th>Slug</th><th>Durata</th><th>Categoria</th><th>Descrizione</th><th></th></tr></thead><tbody>';
data.forEach(s=>{h+=`<tr><td><strong>${s.name}</strong></td><td style="color:var(--n400)">${s.slug}</td><td>${s.duration_min} min</td><td>${s.category}</td><td style="max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${s.description||'—'}</td><td><button class="btn btn-s btn-g" onclick="editSvc(${s.id},'${esc(s.name)}','${s.slug}',${s.duration_min},'${s.category}','${esc(s.description||'')}')">✎</button><button class="btn btn-s btn-g btn-d" onclick="delSvc(${s.id},'${esc(s.name)}')">🗑</button></td></tr>`});
$('d-services').innerHTML=h+'</tbody></table>'}catch(e){console.error(e)}}
function openSvcModal(){editSvcId=null;$('m-svc-t').textContent='Nuovo servizio';$('s-name').value='';$('s-slug').value='';$('s-dur').value=30;$('s-cat').value='generale';$('s-desc').value='';openM('m-svc')}
function editSvc(id,name,slug,dur,cat,desc){editSvcId=id;$('m-svc-t').textContent='Modifica servizio';$('s-name').value=name;$('s-slug').value=slug;$('s-dur').value=dur;$('s-cat').value=cat;$('s-desc').value=desc;openM('m-svc')}
async function saveSvc(){const body={name:$('s-name').value,slug:$('s-slug').value,duration_min:parseInt($('s-dur').value),category:$('s-cat').value,description:$('s-desc').value};if(!body.name||!body.slug){toast('Nome e slug obbligatori');return}try{const url=editSvcId?'/api/admin/services/'+editSvcId:'/api/admin/services';await api(url,{method:editSvcId?'PUT':'POST',body:JSON.stringify(body)});closeM('m-svc');toast(editSvcId?'Servizio aggiornato':'Servizio creato');loadServices()}catch(e){toast('Errore: '+e.message)}}
async function delSvc(id,name){if(!confirm('Eliminare "'+name+'"?'))return;try{await fetch('/api/admin/services/'+id,{method:'DELETE',headers:getHeaders()});toast('Servizio eliminato');loadServices()}catch(e){toast('Errore')}}
const DAYS=['Lun','Mar','Mer','Gio','Ven','Sab','Dom'];
let provDetail=[];
async function loadProviders(){
try{
provs=await api('/api/admin/providers');
// Populate filters
const sel=$('f-prov');const v=sel.value;
sel.innerHTML='<option value="">Tutti gli operatori</option>';
provs.forEach(p=>{sel.innerHTML+=`<option value="${p.id}">${p.name}</option>`});sel.value=v;
const csel=$('cal-prov-filter');if(csel){const cv=csel.value;csel.innerHTML='<option value="">Tutti gli operatori</option>';provs.forEach(p=>{csel.innerHTML+=`<option value="${p.id}">${p.name}</option>`});csel.value=cv}
// Load detail view
provDetail=await api('/api/admin/providers/detail');
renderProviders();
}catch(e){console.error(e)}
}
function renderProviders(){
const el=$('d-providers-detail');
if(!provDetail.length){el.innerHTML='<div class="empty">Nessun operatore configurato</div>';return}
let h='';
provDetail.forEach(p=>{
h+=`<div class="prov-card"><div class="prov-card__hd"><div><span class="prov-card__name">${p.name}</span><div class="prov-card__meta">${p.email||''} ${p.phone?'· '+p.phone:''}</div></div><button class="btn btn-s btn-g" onclick="openAssignModal(${p.id},'${esc(p.name)}')">+ Assegna servizio</button></div>`;
if(p.services.length){
p.services.forEach(s=>{
const days=s.availability_rules.map(r=>'<span class="svc-day">'+DAYS[r.weekday]+' '+r.start+'-'+r.end+'</span>').join('');
h+=`<div class="svc-row"><span class="svc-name">${s.service_name}</span><div class="svc-days">${days}</div><span class="svc-time">${s.duration_min} min</span><button class="btn btn-s btn-g btn-d" onclick="removeAssign(${p.id},${s.service_id},'${esc(s.service_name)}')">✕</button></div>`;
});
}else{h+='<div style="padding:1.25rem 1.75rem;color:var(--n400);font-size:.9375rem">Nessun servizio assegnato</div>'}
h+='</div>';
});
el.innerHTML=h;
}
async function removeAssign(pid,sid,name){if(!confirm('Rimuovere "'+name+'" da questo operatore?'))return;try{await fetch('/api/admin/providers/'+pid+'/services/'+sid,{method:'DELETE',headers:getHeaders()});toast('Assegnazione rimossa');loadProviders()}catch(e){toast('Errore')}}
/* Calendar */
let calStart=null;
function initCal(){const d=new Date();calStart=new Date(d);calStart.setDate(d.getDate()-d.getDay()+1);loadCalendar()}
function calNav(days){calStart.setDate(calStart.getDate()+days);loadCalendar()}
async function loadCalendar(){
const from=fmt(calStart);const toD=new Date(calStart);toD.setDate(toD.getDate()+6);const to=fmt(toD);
$('cal-range').textContent=calStart.toLocaleDateString('it-IT',{day:'numeric',month:'long'})+' — '+toD.toLocaleDateString('it-IT',{day:'numeric',month:'long',year:'numeric'});
const pf=$('cal-prov-filter').value;
let url='/api/admin/calendar?from_date='+from+'&to_date='+to;
if(pf)url+='&provider_id='+pf;
try{
const cal=await api(url);
renderCal(cal,calStart);
}catch(e){console.error(e)}
}
function renderCal(cal,weekStart){
const today=new Date().toISOString().split('T')[0];
const hours=[];for(let h=7;h<=20;h++)hours.push(h);
let g='<div class="cal-grid">';
// Header row
g+='<div class="cal-hdr"></div>';
for(let d=0;d<7;d++){
const day=new Date(weekStart);day.setDate(day.getDate()+d);
const ds=fmt(day);const isToday=ds===today;
g+=`<div class="cal-hdr${isToday?' today':''}">${DAYS[d]} ${day.getDate()}</div>`;
}
// Time rows
hours.forEach(h=>{
g+=`<div class="cal-time">${String(h).padStart(2,'0')}:00</div>`;
for(let d=0;d<7;d++){
const day=new Date(weekStart);day.setDate(day.getDate()+d);
const ds=fmt(day);
const evts=(cal[ds]||[]).filter(e=>{const eh=parseInt(e.start.split(':')[0]);return eh===h});
let cells='';
evts.forEach(e=>{
const startMin=parseInt(e.start.split(':')[1]);
const endH=parseInt(e.end.split(':')[0]);const endMin=parseInt(e.end.split(':')[1]);
const durMin=(endH*60+endMin)-(h*60+startMin);
const top=startMin/60*40;const height=Math.max(durMin/60*40,18);
const cat=e.category||'generale';
cells+=`<div class="cal-ev cat-${cat}" style="top:${top}px;height:${height}px" title="${e.start}-${e.end} ${e.service}\n${e.customer}\n${e.provider}">${e.start} ${e.customer.split(' ')[0]}</div>`;
});
g+=`<div class="cal-cell">${cells}</div>`;
}
});
g+='</div>';
$('d-calendar').innerHTML=g;
}
function fmt(d){return d.getFullYear()+'-'+String(d.getMonth()+1).padStart(2,'0')+'-'+String(d.getDate()).padStart(2,'0')}
function openProvModal(){openM('m-prov');$('p-name').value='';$('p-email').value='';$('p-phone').value=''}
async function saveProv(){const body={name:$('p-name').value,email:$('p-email').value||null,phone:$('p-phone').value||null};if(!body.name){toast('Nome obbligatorio');return}try{await api('/api/admin/providers',{method:'POST',body:JSON.stringify(body)});closeM('m-prov');toast('Operatore creato');loadProviders()}catch(e){toast('Errore: '+e.message)}}
/* Helpers */
function esc(s){return(s||'').replace(/'/g,"\\'")}
function openM(id){$(id).classList.add('open')}
function closeM(id){$(id).classList.remove('open')}
function toast(msg){const t=$('toast');t.textContent=msg;t.classList.add('show');setTimeout(()=>t.classList.remove('show'),2500)}
document.querySelectorAll('.mo').forEach(el=>{el.addEventListener('click',e=>{if(e.target===el)el.classList.remove('open')})});
/* Customers */
let custPage=1;let custQuery='';
async function loadCustomers(pg){if(pg)custPage=pg;try{const q=$('cust-search').value;custQuery=q;const res=await api('/api/admin/customers?page='+custPage+'&per_page=15'+(q?'&q='+encodeURIComponent(q):''));$('cust-count').textContent=res.total+' clienti';renderCustomers(res.items,res)}catch(e){console.error(e)}}
function searchCustomers(){custPage=1;loadCustomers()}
function renderCustomers(list,res){
if(!list.length){$('d-customers').innerHTML='<div class="empty">Nessun cliente trovato</div>';return}
let h='<table><thead><tr><th>Nome</th><th>Telefono</th><th>Email</th><th>Visite</th><th>Ultima visita</th><th>Note</th></tr></thead><tbody>';
list.forEach(c=>{
const lv=c.last_visit_at?new Date(c.last_visit_at).toLocaleDateString('it-IT',{day:'2-digit',month:'short',year:'numeric'}):'—';
h+=`<tr style="cursor:pointer" onclick="openCustomerDetail(${c.id})"><td><strong>${c.name}</strong></td><td style="font-size:.875rem">${c.phone}</td><td style="font-size:.875rem;color:var(--n400)">${c.email||'—'}</td><td style="text-align:center"><strong>${c.total_visits}</strong></td><td style="font-size:.875rem;color:var(--n400)">${lv}</td><td style="font-size:.8125rem;max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--n500)">${c.notes||''}</td></tr>`;
});
$('d-customers').innerHTML=h+'</tbody></table>'+(res?pgHtml(res.page,res.pages,res.total,'loadCustomers'):'');
}
async function openCustomerDetail(id){
try{
const c=await api('/api/admin/customers/'+id);
let h='<h3 style="margin-bottom:.5rem">'+c.name+'</h3>';
h+='<div style="font-size:.875rem;color:var(--n500);margin-bottom:1rem">'+c.phone+(c.email?' · '+c.email:'')+'</div>';
h+='<div class="fg"><label>Note</label><textarea class="inp" id="cust-notes" rows="2" style="width:100%">'+(c.notes||'')+'</textarea></div>';
h+='<button class="btn btn-p btn-s" onclick="saveCustNotes('+c.id+')">Salva note</button>';
if(c.top_services.length){h+='<div style="margin-top:1.25rem;font-size:.875rem;font-weight:600;color:var(--navy-500)">Servizi più frequenti</div>';c.top_services.forEach(s=>{h+='<span class="bs" style="background:var(--navy-50);color:var(--navy-500);margin:.25rem .25rem 0 0">'+s.name+' ('+s.count+')</span>'})}
if(c.bookings.length){h+='<div style="margin-top:1.25rem;font-size:.875rem;font-weight:600;color:var(--navy-500);margin-bottom:.5rem">Storico prenotazioni</div>';
h+='<table><thead><tr><th>Data</th><th>Servizio</th><th>Operatore</th><th>Stato</th></tr></thead><tbody>';
c.bookings.forEach(b=>{const dt=new Date(b.start_at);const ds=dt.toLocaleDateString('it-IT',{day:'2-digit',month:'short',year:'numeric'});const ts=dt.toLocaleTimeString('it-IT',{hour:'2-digit',minute:'2-digit'});
h+='<tr><td>'+ds+' '+ts+'</td><td>'+b.service+'</td><td>'+(b.provider||'—')+'</td><td><span class="bs bs-'+b.status+'">'+(SL[b.status]||b.status)+'</span></td></tr>'});
h+='</tbody></table>'}
$('m-cust-body').innerHTML=h;openM('m-cust');
}catch(e){toast('Errore: '+e.message)}
}
async function saveCustNotes(id){try{await api('/api/admin/customers/'+id,{method:'PUT',body:JSON.stringify({notes:$('cust-notes').value})});toast('Note salvate');loadCustomers()}catch(e){toast('Errore')}}
/* Settings */
let cfg={};
async function loadSettings(){try{cfg=await api('/api/admin/settings');const keys=['pharmacy_name','pharmacy_address','pharmacy_phone','pharmacy_wa','smtp_host','smtp_port','smtp_user','smtp_pass','smtp_from'];keys.forEach(k=>{const el=$('cfg-'+k);if(el)el.value=cfg[k]||''});$('cfg-wa_enabled').checked=cfg.wa_enabled==='true';$('cfg-smtp_enabled').checked=cfg.smtp_enabled==='true';$('smtp-badge').textContent=cfg.smtp_enabled==='true'?'Attivo':'Disattivo';$('smtp-badge').className='bs '+(cfg.smtp_enabled==='true'?'bs-confirmed':'bs-cancelled');checkWaStatus()}catch(e){console.error(e)}}
async function saveSettings(keys){const body={};keys.forEach(k=>{const el=$('cfg-'+k);if(el)body[k]=el.value});try{cfg=await api('/api/admin/settings',{method:'PUT',body:JSON.stringify(body)});toast('Impostazioni salvate')}catch(e){toast('Errore: '+e.message)}}
function toggleSetting(key,val){saveSettings([]);const body={};body[key]=val?'true':'false';api('/api/admin/settings',{method:'PUT',body:JSON.stringify(body)}).then(d=>{cfg=d;if(key==='smtp_enabled'){$('smtp-badge').textContent=val?'Attivo':'Disattivo';$('smtp-badge').className='bs '+(val?'bs-confirmed':'bs-cancelled')}if(key==='wa_enabled'){$('wa-badge').textContent=val?'Attivo':'Disattivo';$('wa-badge').className='bs '+(val?'bs-confirmed':'bs-cancelled')}toast(val?'Attivato':'Disattivato')}).catch(e=>toast('Errore'))}
let waTimer=null;let waPollInterval=null;
async function checkWaStatus(){
stopWaPoll();
try{
const d=await api('/api/admin/settings/wa-status');
if(d.connected){
$('wa-status-box').innerHTML='<div style="display:flex;align-items:center;gap:.75rem"><span class="bs bs-confirmed">Connesso</span><span style="font-size:.875rem;color:var(--n500)">'+(d.phone||'Numero collegato')+'</span></div>';
$('wa-badge').textContent='Connesso';$('wa-badge').className='bs bs-confirmed';
$('wa-qr-box').style.display='none';
}else{
$('wa-status-box').innerHTML='<div style="display:flex;align-items:center;gap:.75rem"><span class="bs bs-cancelled">Disconnesso</span><span style="font-size:.875rem;color:var(--n400)">'+(d.message||'Servizio non raggiungibile')+'</span></div>';
$('wa-badge').textContent='Disconnesso';$('wa-badge').className='bs bs-cancelled';
loadWaQr();
startWaPoll();
}
}catch(e){
$('wa-status-box').innerHTML='<p style="color:var(--n400);font-size:.9375rem">Servizio WhatsApp non disponibile. Verrà attivato con Baileys.</p>';
$('wa-badge').textContent='Non configurato';$('wa-badge').className='bs bs-no_show';
}
}
async function loadWaQr(){
try{
const d=await api('/api/admin/settings/wa-qr');
const box=$('wa-qr-box'),img=$('wa-qr-img'),timer=$('wa-qr-timer'),overlay=$('wa-qr-overlay');
if(d.qr){
box.style.display='block';img.src=d.qr;overlay.style.display='none';
startQrCountdown(d.expires_in||20,d.ttl||20);
}else if(d.expired){
box.style.display='block';overlay.style.display='flex';
timer.textContent='QR scaduto — clicca per rigenerare';
}else{
timer.textContent=d.message||'QR in arrivo...';
box.style.display='block';img.style.opacity='.3';overlay.style.display='none';
setTimeout(loadWaQr,3000);
}
}catch(e){$('wa-qr-box').style.display='none'}
}
function startQrCountdown(sec,ttl){
if(waTimer)clearInterval(waTimer);
let left=sec;
const timer=$('wa-qr-timer');
const img=$('wa-qr-img');
const overlay=$('wa-qr-overlay');
img.style.opacity='1';
timer.innerHTML=timerHtml(left,ttl);
waTimer=setInterval(()=>{
left--;
if(left<=0){
clearInterval(waTimer);waTimer=null;
overlay.style.display='flex';
timer.textContent='QR scaduto';
setTimeout(loadWaQr,1000);
}else{
timer.innerHTML=timerHtml(left,ttl);
if(left<=5)img.style.opacity='.4';
}
},1000);
}
function timerHtml(left,ttl){
const pct=Math.round(left/ttl*100);
const color=left>10?'var(--green-500)':left>5?'var(--warn)':'var(--err)';
return '<div style="display:flex;align-items:center;gap:.75rem;justify-content:center">'
+'<div style="flex:1;max-width:180px;height:4px;background:var(--n200);border-radius:2px;overflow:hidden">'
+'<div style="width:'+pct+'%;height:100%;background:'+color+';border-radius:2px;transition:width 1s linear"></div></div>'
+'<span style="font-size:.8125rem;font-weight:600;color:'+color+'">'+left+'s</span></div>';
}
function startWaPoll(){stopWaPoll();waPollInterval=setInterval(async()=>{
try{const d=await api('/api/admin/settings/wa-status');if(d.connected){checkWaStatus()}}catch(e){}
},5000)}
function stopWaPoll(){if(waPollInterval){clearInterval(waPollInterval);waPollInterval=null}}
async function testEmail(){try{const d=await api('/api/admin/settings/test-email',{method:'POST'});toast(d.message||'Email inviata')}catch(e){toast('Errore: '+e.message)}}
function loadAll(){loadStats();loadBookings();loadServices();loadProviders();loadCustomers();loadSettings();initCal()}
let assignProviderId=null;
function openAssignModal(pid,pname){
assignProviderId=pid;
$('m-assign-t').textContent='Assegna servizio a '+pname;
// Populate service select
const sel=$('a-svc');sel.innerHTML='';
api('/api/admin/services').then(svcs=>{
svcs.forEach(s=>{sel.innerHTML+=`<option value="${s.id}">${s.name} (${s.duration_min} min)</option>`});
});
// Render day toggles
const daysEl=$('a-days');daysEl.innerHTML='';
DAYS.forEach((d,i)=>{
daysEl.innerHTML+=`<label style="display:flex;align-items:center;gap:.375rem"><input type="checkbox" class="a-day-chk" data-day="${i}"> ${d}</label><input class="inp inp-s a-day-start" data-day="${i}" type="time" value="09:00"><input class="inp inp-s a-day-end" data-day="${i}" type="time" value="13:00">`;
});
openM('m-assign');
}
async function saveAssign(){
const sid=parseInt($('a-svc').value);
const rules=[];
document.querySelectorAll('.a-day-chk:checked').forEach(chk=>{
const d=parseInt(chk.dataset.day);
const start=document.querySelector(`.a-day-start[data-day="${d}"]`).value;
const end=document.querySelector(`.a-day-end[data-day="${d}"]`).value;
if(start&&end)rules.push({weekday:d,start,end});
});
if(!rules.length){toast('Seleziona almeno un giorno');return}
try{
await api('/api/admin/providers/'+assignProviderId+'/services/'+sid,{method:'POST',body:JSON.stringify(rules)});
closeM('m-assign');toast('Servizio assegnato');loadProviders();
}catch(e){toast('Errore: '+e.message)}
}
function pgHtml(page,pages,total,fn){
if(pages<=1)return '<div class="pgn"><span>'+total+' risultati</span></div>';
let h='<div class="pgn">';
h+='<button '+(page<=1?'disabled':'')+' onclick="'+fn+'('+(page-1)+')">←</button>';
const start=Math.max(1,page-2),end=Math.min(pages,page+2);
if(start>1)h+='<button onclick="'+fn+'(1)">1</button>';
if(start>2)h+='<span>…</span>';
for(let i=start;i<=end;i++)h+='<button class="'+(i===page?'active':'')+'" onclick="'+fn+'('+i+')">'+i+'</button>';
if(end<pages-1)h+='<span>…</span>';
if(end<pages)h+='<button onclick="'+fn+'('+pages+')">'+pages+'</button>';
h+='<button '+(page>=pages?'disabled':'')+' onclick="'+fn+'('+(page+1)+')">→</button>';
h+='<span style="margin-left:.5rem">'+total+' risultati</span></div>';
return h;
}
checkAuth();
</script>
</body>
</html>

5
frontend/favicon.svg Normal file
View File

@@ -0,0 +1,5 @@
<svg xmlns='http://www.w3.org/2000/svg' width='32' height='32' viewBox='0 0 32 32'>
<rect width='32' height='32' rx='6' fill='#002c50'/>
<rect x='13' y='7' width='6' height='18' rx='1.5' fill='#80ba27'/>
<rect x='7' y='13' width='18' height='6' rx='1.5' fill='#80ba27'/>
</svg>

After

Width:  |  Height:  |  Size: 277 B

339
frontend/index.html Normal file
View File

@@ -0,0 +1,339 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<title>Prenota — Farmacia Ianni</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.3/css/all.min.css">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<script>
tailwind.config = {
theme: {
extend: {
fontFamily: {
inter: ['Inter', 'sans-serif'],
},
colors: {
teal: {
50: '#f0faf4',
100: '#d1f0df',
500: '#3a9d6a',
600: '#2d8a5e',
700: '#1f6d48',
}
}
}
}
}
</script>
<style>
body { font-family: 'Inter', sans-serif; }
.bg-blob-1 { background: radial-gradient(ellipse at 20% 50%, rgba(167, 225, 195, 0.4) 0%, transparent 60%); }
.bg-blob-2 { background: radial-gradient(ellipse at 80% 70%, rgba(120, 200, 170, 0.35) 0%, transparent 55%); }
.bg-blob-3 { background: radial-gradient(ellipse at 90% 30%, rgba(167, 225, 195, 0.3) 0%, transparent 50%); }
.time-slot { transition: all 0.15s ease; }
.time-slot:hover { border-color: #2d8a5e; background-color: #f0faf4; }
.time-slot.selected { background-color: #2d8a5e; color: white; border-color: #2d8a5e; }
.svc-card { transition: all 0.2s ease; cursor: pointer; }
.svc-card:hover { border-color: #2d8a5e; background: rgba(255,255,255,0.85); }
.fade { animation: fadeIn 0.3s ease; }
@keyframes fadeIn { from{opacity:0;transform:translateY(6px)} to{opacity:1;transform:translateY(0)} }
input:focus { outline:none; border-color:#2d8a5e; box-shadow:0 0 0 3px rgba(45,138,94,0.1); }
</style>
</head>
<body class="min-h-screen flex flex-col items-center justify-center p-6" style="background-color: #ffffff;">
<!-- Background blobs -->
<div class="fixed inset-0 bg-blob-1 pointer-events-none"></div>
<div class="fixed inset-0 bg-blob-2 pointer-events-none"></div>
<div class="fixed inset-0 bg-blob-3 pointer-events-none"></div>
<!-- Logo centrato sopra la card -->
<div class="mb-6">
<img src="/static/logo.jpg" alt="Farmacia Ianni" class="h-auto w-auto max-h-[80px]">
</div>
<!-- Main Card -->
<div class="relative w-full max-w-[1020px] bg-white/60 backdrop-blur-sm rounded-3xl shadow-lg overflow-hidden flex min-h-[580px]">
<!-- Left Sidebar -->
<div class="w-[280px] flex-shrink-0 px-10 py-10 flex flex-col justify-between border-r border-white/40" style="background: linear-gradient(135deg, rgba(255,255,255,0.5) 0%, rgba(200,230,215,0.25) 100%);">
<div>
<!-- Logo reale Farmacia Ianni -->
<!-- Stepper dinamico -->
<div class="flex flex-col" id="stepper"></div>
</div>
<!-- Help -->
<div class="flex items-center gap-2 text-gray-500 text-sm mt-6">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 015.83 1c0 2-3 3-3 3"/><circle cx="12" cy="17" r="0.5" fill="currentColor"/></svg>
<a href="https://wa.me/393930579002" target="_blank" style="color:inherit;text-decoration:none">Serve aiuto?</a>
</div>
</div>
<!-- Main Content -->
<div class="flex-1 px-10 py-10 flex flex-col relative overflow-hidden">
<!-- Decorative gradient blobs in content area -->
<div class="absolute top-0 right-0 w-64 h-64 rounded-full opacity-30 pointer-events-none" style="background: radial-gradient(circle, rgba(120,200,170,0.5) 0%, transparent 70%); transform: translate(30%, -20%);"></div>
<div class="absolute bottom-0 right-0 w-80 h-80 rounded-full opacity-25 pointer-events-none" style="background: radial-gradient(circle, rgba(100,190,160,0.6) 0%, transparent 65%); transform: translate(20%, 30%);"></div>
<div id="main" class="relative z-10 flex-1 flex flex-col"></div>
</div>
</div>
<script>
const API = location.origin;
const STEPS = ['Servizio', 'Data e ora', 'I tuoi dati', 'Conferma'];
const MO = ['Gennaio','Febbraio','Marzo','Aprile','Maggio','Giugno','Luglio','Agosto','Settembre','Ottobre','Novembre','Dicembre'];
const DL = ['Domenica','Lunedì','Martedì','Mercoledì','Giovedì','Venerdì','Sabato'];
const DS = ['Lun','Mar','Mer','Gio','Ven','Sab','Dom'];
let step=0, svcs=[], sel=null, weekStart, selDate=null, slots=[], selSlot=null, lastPhone='';
const fd = d => d.getFullYear()+'-'+String(d.getMonth()+1).padStart(2,'0')+'-'+String(d.getDate()).padStart(2,'0');
const fl = d => DL[d.getDay()]+' '+d.getDate()+' '+MO[d.getMonth()];
function dayLabel(d) {
const t=new Date(); t.setHours(0,0,0,0);
const tm=new Date(t); tm.setDate(tm.getDate()+1);
if(d.toDateString()===t.toDateString()) return 'Oggi';
if(d.toDateString()===tm.toDateString()) return 'Domani';
return DS[d.getDay()===0?6:d.getDay()-1];
}
const CHK = '<svg class="w-4 h-4 text-white" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>';
const CHKBIG = '<svg class="w-7 h-7 text-white" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>';
// ── Stepper ──
function renderStepper() {
document.getElementById('stepper').innerHTML = STEPS.map((s,i) => {
const done=i<step, cur=i===step, fut=i>step;
let dot;
if(done) dot = '<div class="w-8 h-8 rounded-full bg-teal-600 flex items-center justify-center flex-shrink-0">'+CHK+'</div>';
else if(cur) dot = '<div class="w-8 h-8 rounded-full bg-teal-600 flex items-center justify-center flex-shrink-0"><span class="text-white font-semibold text-sm">'+(i+1)+'</span></div>';
else dot = '<div class="w-8 h-8 rounded-full border-2 border-gray-300 flex items-center justify-center flex-shrink-0 bg-white/50"><span class="text-gray-400 font-medium text-sm">'+(i+1)+'</span></div>';
let label = '<span class="'+(cur?'text-gray-900 font-semibold':done?'text-gray-700 font-medium':'text-gray-400 font-medium')+' text-[15px]">'+s+'</span>';
let line = i<3 ? '<div class="ml-[15px] w-[2px] h-8 '+(done?'bg-teal-500':'bg-gray-300')+'"></div>' : '';
return '<div class="flex items-center gap-4">'+dot+label+'</div>'+line;
}).join('');
}
// ── Init ──
async function init() {
weekStart = new Date(); weekStart.setHours(0,0,0,0);
try { svcs = await (await fetch(API+'/api/services')).json(); } catch(e) { svcs = []; }
go(0);
}
function go(n) { step=n; renderStepper(); render(); }
function render() {
const m = document.getElementById('main');
if(step===0) renderServices(m);
else if(step===1) renderDatetime(m);
else if(step===2) renderForm(m);
else renderConfirm(m);
}
// ── STEP 0: Servizi ──
function renderServices(m) {
const g = {};
svcs.forEach(s => (g[s.category] = g[s.category]||[]).push(s));
let html = '<div class="fade">';
html += '<h1 class="text-2xl font-bold text-gray-900 mb-6">Scegli il servizio</h1>';
for (const [cat, list] of Object.entries(g)) {
html += '<div class="text-xs font-bold uppercase tracking-widest text-teal-600 mb-2 mt-5">'+cat+'</div>';
for (const s of list) {
html += '<div class="svc-card flex items-center justify-between p-4 mb-2 rounded-2xl bg-white/50 border-2 border-transparent" onclick="pickSvc('+s.id+')">';
html += '<div><div class="font-semibold text-[15px] text-gray-900">'+s.name+'</div>';
if(s.description) html += '<div class="text-[13px] text-gray-500 mt-0.5">'+s.description+'</div>';
html += '</div>';
html += '<span class="text-xs font-semibold text-teal-600 bg-teal-50 px-3 py-1.5 rounded-full ml-4 whitespace-nowrap">'+s.duration_min+' min</span>';
html += '</div>';
}
}
html += '</div>';
m.innerHTML = html;
}
function pickSvc(id) { sel=svcs.find(s=>s.id===id); selDate=null; selSlot=null; slots=[]; go(1); }
// ── STEP 1: Data e Ora (Data e Ora) ──
function renderDatetime(m) {
const wk = [];
for(let i=0; i<7; i++) { const d=new Date(weekStart); d.setDate(d.getDate()+i); wk.push(d); }
const today = new Date(); today.setHours(0,0,0,0);
const monthLabel = MO[wk[3].getMonth()]+', '+wk[3].getFullYear();
// Slot HTML
let slotsHtml = '';
if(selDate && slots.length > 0) {
// Righe da 7 7 per riga
const rows = [];
for(let i=0; i<slots.length; i+=7) rows.push(slots.slice(i, i+7));
slotsHtml = rows.map(row =>
'<div class="flex flex-wrap gap-2.5 mb-2.5">' +
row.map(s => '<button class="time-slot px-4 py-2 rounded-full border '+(selSlot&&selSlot.start===s.start ? 'border-teal-600 bg-teal-600 text-white selected' : 'border-gray-200 bg-white text-gray-700')+' text-sm font-medium" onclick="pickSlot(\''+s.start+'\',\''+s.end+'\','+s.provider_id+')">'+s.start+'</button>').join('') +
'</div>'
).join('');
} else if(selDate && slots.length === 0) {
slotsHtml = '<div class="text-center py-8 text-gray-500"><div class="text-3xl mb-2">😔</div><div class="font-medium text-gray-700">Nessun orario disponibile</div><div class="text-sm mt-1">Prova un altro giorno</div></div>';
}
// Selected box
let selectedBox = '';
if(selSlot) {
selectedBox = '<div class="bg-amber-50/80 border border-amber-200/60 rounded-xl px-5 py-4 mb-8">' +
'<p class="text-sm text-gray-600 mb-1">Hai scelto:</p>' +
'<div class="flex items-center gap-2">' +
'<svg class="w-4 h-4 text-gray-500" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg>' +
'<span class="text-sm font-medium text-gray-800">'+fl(selDate)+', '+selSlot.start+' - '+selSlot.end+'</span>' +
'</div></div>';
}
// Day strip
let dayStrip = wk.map(d => {
const dis = d < today || d.getDay() === 0;
const act = selDate && d.toDateString() === selDate.toDateString();
return '<div class="flex flex-col items-center px-3 py-2 '+(dis?'opacity-30 cursor-default':act?'cursor-pointer rounded-xl border-b-2 border-gray-800':'cursor-pointer rounded-xl hover:bg-white/60')+'" '+(dis?'':'onclick="pickDate(\''+fd(d)+'\')"')+'>' +
'<span class="text-xs '+(act?'font-semibold text-gray-800':'text-gray-500')+' mb-1">'+dayLabel(d)+'</span>' +
'<span class="text-lg '+(act?'font-bold text-gray-900':'font-medium text-gray-600')+'">'+d.getDate()+'</span>' +
'</div>';
}).join('');
m.innerHTML = '<div class="fade flex-1 flex flex-col">' +
// Header con nav mese
'<div class="flex items-center justify-between mb-8">' +
'<h1 class="text-2xl font-bold text-gray-900">Scegli data e ora</h1>' +
'<div class="flex items-center gap-3 bg-white rounded-full px-4 py-2 shadow-sm border border-gray-100">' +
'<button class="text-gray-500 hover:text-gray-700 text-sm" onclick="navWeek(-1)"><i class="fas fa-chevron-left"></i></button>' +
'<span class="text-sm font-medium text-gray-800">'+monthLabel+'</span>' +
'<button class="text-gray-500 hover:text-gray-700 text-sm" onclick="navWeek(1)"><i class="fas fa-chevron-right"></i></button>' +
'</div>' +
'</div>' +
// Date selector
'<div class="flex items-center gap-1 mb-8">' +
'<button class="text-gray-400 hover:text-gray-600 px-1" onclick="navWeek(-1)"><i class="fas fa-chevron-left text-xs"></i></button>' +
'<div class="flex-1 flex justify-between px-2">'+dayStrip+'</div>' +
'<button class="text-gray-400 hover:text-gray-600 px-1" onclick="navWeek(1)"><i class="fas fa-chevron-right text-xs"></i></button>' +
'</div>' +
// Time slots
'<div class="relative z-10 mb-3">'+slotsHtml+'</div>' +
// Currently selected
selectedBox +
// Navigation buttons
'<div class="flex justify-end gap-4 mt-auto">' +
'<button class="px-10 py-3 border border-gray-300 rounded-xl text-gray-700 font-medium bg-white hover:bg-gray-50 transition-colors" onclick="go(0)">Indietro</button>' +
'<button class="px-10 py-3 '+(selSlot?'bg-teal-600 text-white hover:bg-teal-700 cursor-pointer':'bg-gray-200 text-white cursor-default')+' rounded-xl font-medium transition-colors" onclick="'+(selSlot?'go(2)':'')+'">Avanti</button>' +
'</div>' +
'</div>';
}
function navWeek(dir) {
const d = new Date(weekStart);
d.setDate(d.getDate() + dir*7);
const t = new Date(); t.setHours(0,0,0,0);
if(d < t && dir < 0) return;
weekStart = d;
render();
}
async function pickDate(ds) {
selDate = new Date(ds+'T00:00:00');
selSlot = null;
slots = [];
render();
try {
const r = await fetch(API+'/api/services/'+sel.id+'/slots?date='+ds);
const d = await r.json();
slots = d.slots || [];
} catch(e) { slots = []; }
render();
}
function pickSlot(start, end, pid) {
selSlot = {start: start, end: end, provider_id: pid};
render();
}
// ── STEP 2: Form dati ──
function renderForm(m) {
m.innerHTML = '<div class="fade flex-1 flex flex-col">' +
'<h1 class="text-2xl font-bold text-gray-900 mb-1">I tuoi dati</h1>' +
'<p class="text-sm text-gray-500 mb-6">'+sel.name+' · '+fl(selDate)+' alle '+selSlot.start+'</p>' +
'<div class="space-y-4 mb-6">' +
'<div><label class="block text-xs font-semibold text-gray-700 mb-1.5 uppercase tracking-wide">Nome e cognome *</label>' +
'<input id="fn" type="text" placeholder="Mario Rossi" autocomplete="name" class="w-full px-4 py-3 rounded-xl border border-gray-200 bg-white/80 text-[15px]"></div>' +
'<div><label class="block text-xs font-semibold text-gray-700 mb-1.5 uppercase tracking-wide">Cellulare *</label>' +
'<input id="fp" type="tel" placeholder="333 1234567" autocomplete="tel" class="w-full px-4 py-3 rounded-xl border border-gray-200 bg-white/80 text-[15px]">' +
'<p class="text-xs text-gray-500 mt-1.5">📱 Riceverai conferma e promemoria su WhatsApp</p></div>' +
'<div><label class="block text-xs font-semibold text-gray-500 mb-1.5 uppercase tracking-wide">Email (opzionale)</label>' +
'<input id="fe" type="email" placeholder="mario@email.it" autocomplete="email" class="w-full px-4 py-3 rounded-xl border border-gray-200 bg-white/80 text-[15px]"></div>' +
'<div><label class="block text-xs font-semibold text-gray-500 mb-1.5 uppercase tracking-wide">Note (opzionale)</label>' +
'<input id="fno" type="text" placeholder="Richieste particolari..." class="w-full px-4 py-3 rounded-xl border border-gray-200 bg-white/80 text-[15px]"></div>' +
'</div>' +
'<div id="err"></div>' +
'<div class="flex justify-end gap-4 mt-auto">' +
'<button class="px-10 py-3 border border-gray-300 rounded-xl text-gray-700 font-medium bg-white hover:bg-gray-50 transition-colors" onclick="go(1)">Indietro</button>' +
'<button class="px-10 py-3 bg-teal-600 text-white rounded-xl font-medium hover:bg-teal-700 transition-colors" onclick="submitBooking()" id="bsub">Conferma</button>' +
'</div>' +
'</div>';
}
async function submitBooking() {
const n = document.getElementById('fn').value.trim();
const p = document.getElementById('fp').value.trim();
if(!n || !p) {
document.getElementById('err').innerHTML = '<div class="text-red-600 text-sm mb-4 p-3 bg-red-50 rounded-xl">Inserisci nome e cellulare</div>';
return;
}
lastPhone = p;
const btn = document.getElementById('bsub');
btn.textContent = 'Invio in corso...';
btn.disabled = true;
try {
const r = await fetch(API+'/api/bookings', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
service_id: sel.id,
provider_id: selSlot.provider_id,
start_at: fd(selDate)+'T'+selSlot.start+':00+02:00',
customer_name: n,
customer_phone: p,
customer_email: document.getElementById('fe').value.trim() || null,
notes: document.getElementById('fno').value.trim() || null
})
});
if(!r.ok) { const e = await r.json(); throw new Error(e.detail || 'Errore'); }
go(3);
} catch(e) {
document.getElementById('err').innerHTML = '<div class="text-red-600 text-sm mb-4 p-3 bg-red-50 rounded-xl">'+e.message+'</div>';
btn.textContent = 'Conferma';
btn.disabled = false;
}
}
// ── STEP 3: Conferma ──
function renderConfirm(m) {
m.innerHTML = '<div class="fade text-center pt-16">' +
'<div class="w-16 h-16 rounded-full bg-teal-600 flex items-center justify-center mx-auto mb-6" style="box-shadow:0 8px 24px rgba(45,138,94,0.3)">'+CHKBIG+'</div>' +
'<h1 class="text-2xl font-bold text-gray-900 mb-3">Prenotazione confermata!</h1>' +
'<p class="text-sm text-gray-500 leading-relaxed">' +
'<strong class="text-gray-900">'+sel.name+'</strong><br>' +
fl(selDate)+' alle <strong class="text-gray-900">'+selSlot.start+'</strong><br><br>' +
'Riceverai conferma su WhatsApp<br>al numero <strong class="text-gray-900">'+lastPhone+'</strong>' +
'</p>' +
'<a href="https://wa.me/393930579002" target="_blank" class="inline-flex items-center gap-2 mt-6 px-6 py-3 rounded-xl text-white font-semibold text-sm" style="background:#25d366;text-decoration:none">💬 Contattaci su WhatsApp</a>' +
'<div class="mt-5"><button onclick="location.reload()" class="text-teal-600 text-sm font-semibold hover:underline cursor-pointer" style="background:none;border:none">Prenota un altro servizio</button></div>' +
'</div>';
}
init();
</script>
</body>
</html>

150
frontend/logo-white.svg Normal file
View File

@@ -0,0 +1,150 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Livello_2" data-name="Livello 2" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 440.56 107.85">
<defs>
<style>
.cls-1 {
fill: url(#linear-gradient);
}
.cls-2, .cls-3 {
fill: #ffffff;
}
.cls-4 {
fill: #6a971f;
}
.cls-5 {
fill: url(#radial-gradient-3);
}
.cls-6 {
fill: url(#radial-gradient-2);
}
.cls-7 {
fill: url(#radial-gradient);
}
.cls-3 {
fill-rule: evenodd;
}
</style>
<radialGradient id="radial-gradient" cx="273.62" cy="61.21" fx="273.62" fy="61.21" r="61.75" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#f1f6ec"/>
<stop offset=".06" stop-color="#d5e2c2"/>
<stop offset=".12" stop-color="#bcd19c"/>
<stop offset=".18" stop-color="#a6c17a"/>
<stop offset=".25" stop-color="#93b45d"/>
<stop offset=".33" stop-color="#84a946"/>
<stop offset=".42" stop-color="#78a134"/>
<stop offset=".52" stop-color="#709b28"/>
<stop offset=".67" stop-color="#6b9721"/>
<stop offset="1" stop-color="#6a971f"/>
</radialGradient>
<linearGradient id="linear-gradient" x1="248.35" y1="21.74" x2="298.89" y2="21.74" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#f4f9f1"/>
<stop offset=".05" stop-color="#e2efd1"/>
<stop offset=".11" stop-color="#cbe2a9"/>
<stop offset=".19" stop-color="#b6d786"/>
<stop offset=".26" stop-color="#a5ce68"/>
<stop offset=".35" stop-color="#97c650"/>
<stop offset=".44" stop-color="#8dc13d"/>
<stop offset=".56" stop-color="#85bd30"/>
<stop offset=".7" stop-color="#81ba29"/>
<stop offset="1" stop-color="#80ba27"/>
</linearGradient>
<radialGradient id="radial-gradient-2" cx="243.27" cy="-1778.99" fx="243.27" fy="-1778.99" r="31.75" gradientTransform="translate(59.23 297.58) scale(.88 .13)" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#002c50"/>
<stop offset="0" stop-color="#012d50"/>
<stop offset=".09" stop-color="#365875"/>
<stop offset=".17" stop-color="#647f95"/>
<stop offset=".26" stop-color="#8ea1b1"/>
<stop offset=".36" stop-color="#b1bec9"/>
<stop offset=".46" stop-color="#cdd6dd"/>
<stop offset=".56" stop-color="#e3e8ec"/>
<stop offset=".68" stop-color="#f3f5f6"/>
<stop offset=".81" stop-color="#fcfcfd"/>
<stop offset="1" stop-color="#fff"/>
</radialGradient>
<radialGradient id="radial-gradient-3" cx="273.62" cy="30.14" fx="273.62" fy="30.14" r="15.94" gradientUnits="userSpaceOnUse">
<stop offset=".03" stop-color="#f4f9f1"/>
<stop offset=".36" stop-color="#f2f8ee"/>
<stop offset=".5" stop-color="#eef6e7"/>
<stop offset=".61" stop-color="#e7f2db"/>
<stop offset=".69" stop-color="#ddecc9"/>
<stop offset=".77" stop-color="#d0e5b2"/>
<stop offset=".84" stop-color="#bfdc96"/>
<stop offset=".9" stop-color="#acd274"/>
<stop offset=".95" stop-color="#96c64e"/>
<stop offset="1" stop-color="#80ba27"/>
</radialGradient>
</defs>
<g id="Livello_2-2" data-name="Livello 2">
<g>
<g>
<path class="cls-7" d="M304.23,30.6c0,16.9-13.7,30.6-30.6,30.6s-30.6-13.7-30.6-30.6S256.72,0,273.62,0s30.6,13.7,30.6,30.6Z"/>
<path class="cls-1" d="M298.89,21.74c0,11.53-11.31,20.87-25.27,20.87s-25.27-9.34-25.27-20.87S259.66,.87,273.62,.87s25.27,9.35,25.27,20.87Z"/>
<path class="cls-6" d="M301.52,67.55c0,2.27-12.52,4.11-27.97,4.11s-27.97-1.84-27.97-4.11,12.53-4.1,27.97-4.1,27.97,1.84,27.97,4.1Z"/>
<path class="cls-4" d="M286.32,24.45h-8.55V15.9c0-1.37-1.11-2.47-2.47-2.47h-4.89c-1.37,0-2.48,1.11-2.48,2.47v8.55h-8.55c-1.36,0-2.47,1.11-2.47,2.47v4.89c0,1.36,1.11,2.47,2.47,2.47h8.55v8.55c0,1.37,1.11,2.47,2.48,2.47h4.89c1.36,0,2.47-1.11,2.47-2.47v-8.55h8.55c1.37,0,2.47-1.11,2.47-2.47v-4.89c0-1.37-1.11-2.47-2.47-2.47Z"/>
<path class="cls-5" d="M287.09,25.22h-8.55v-8.55c0-1.37-1.11-2.47-2.47-2.47h-4.9c-1.36,0-2.47,1.11-2.47,2.47v8.55h-8.55c-1.36,0-2.47,1.11-2.47,2.47v4.89c0,1.37,1.11,2.47,2.47,2.47h8.55v8.55c0,1.37,1.11,2.47,2.47,2.47h4.9c1.36,0,2.47-1.11,2.47-2.47v-8.55h8.55c1.37,0,2.47-1.11,2.47-2.47v-4.89c0-1.37-1.11-2.47-2.47-2.47Z"/>
</g>
<g>
<path class="cls-3" d="M322.06,32.18h-8.38v-7.63h8.38v7.63Zm-8.38,3.77h8.38v31.6h-8.38v-31.6Z"/>
<path class="cls-3" d="M346.59,52.18c-.52,.33-1.05,.6-1.59,.81-.52,.19-1.25,.38-2.17,.55l-1.86,.35c-1.74,.31-2.99,.69-3.74,1.13-1.28,.75-1.92,1.92-1.92,3.51,0,1.41,.39,2.44,1.16,3.07,.79,.62,1.75,.93,2.87,.93,1.78,0,3.41-.52,4.9-1.57,1.51-1.04,2.29-2.95,2.35-5.71v-3.07Zm-5.02-3.86c1.53-.19,2.62-.43,3.28-.73,1.18-.5,1.77-1.28,1.77-2.35,0-1.29-.46-2.18-1.36-2.67-.89-.5-2.2-.75-3.94-.75-1.95,0-3.33,.48-4.15,1.45-.58,.72-.97,1.68-1.16,2.9h-7.97c.17-2.76,.95-5.03,2.32-6.81,2.18-2.78,5.93-4.17,11.25-4.17,3.46,0,6.53,.69,9.22,2.06s4.03,3.96,4.03,7.77v14.5c0,1.01,.02,2.22,.06,3.65,.06,1.08,.22,1.82,.49,2.2,.27,.39,.68,.71,1.22,.96v1.22h-8.99c-.25-.64-.43-1.24-.52-1.8-.1-.56-.17-1.2-.23-1.91-1.14,1.24-2.45,2.29-3.94,3.16-1.78,1.02-3.79,1.54-6.03,1.54-2.86,0-5.23-.81-7.1-2.44-1.86-1.64-2.78-3.96-2.78-6.96,0-3.89,1.5-6.7,4.5-8.44,1.64-.95,4.06-1.62,7.25-2.03l2.81-.35Z"/>
<path class="cls-3" d="M386.82,37.77c2.09,1.7,3.13,4.53,3.13,8.5v21.28h-8.47v-19.22c0-1.66-.22-2.94-.67-3.83-.81-1.62-2.36-2.44-4.64-2.44-2.8,0-4.73,1.19-5.77,3.57-.54,1.26-.81,2.86-.81,4.81v17.11h-8.23v-31.54h7.97v4.61c1.06-1.62,2.07-2.79,3.01-3.51,1.7-1.28,3.85-1.91,6.47-1.91,3.27,0,5.93,.86,8,2.58Z"/>
<path class="cls-3" d="M422.2,37.77c2.09,1.7,3.13,4.53,3.13,8.5v21.28h-8.47v-19.22c0-1.66-.22-2.94-.67-3.83-.81-1.62-2.36-2.44-4.64-2.44-2.8,0-4.73,1.19-5.77,3.57-.54,1.26-.81,2.86-.81,4.81v17.11h-8.23v-31.54h7.97v4.61c1.06-1.62,2.07-2.79,3.02-3.51,1.7-1.28,3.86-1.91,6.46-1.91,3.27,0,5.93,.86,8,2.58Z"/>
<path class="cls-3" d="M440.56,32.18h-8.38v-7.63h8.38v7.63Zm-8.38,3.77h8.38v31.6h-8.38v-31.6Z"/>
</g>
<g>
<path class="cls-3" d="M16.32,24.43c.43,.02,1.01,.06,1.74,.12v6.73c-.46-.06-1.25-.1-2.35-.12-1.08-.04-1.84,.2-2.26,.73-.41,.5-.61,1.06-.61,1.68v2.67h5.42v5.83h-5.42v25.49H4.61v-25.49H0v-5.83H4.52v-2.03c0-3.38,.57-5.71,1.71-6.99,1.2-1.89,4.09-2.84,8.67-2.84,.52,0,.99,.02,1.42,.06Z"/>
<path class="cls-3" d="M39.56,52.18c-.52,.33-1.05,.6-1.59,.81-.52,.19-1.25,.38-2.17,.55l-1.85,.35c-1.74,.31-2.99,.69-3.74,1.13-1.28,.75-1.91,1.92-1.91,3.51,0,1.41,.39,2.44,1.16,3.07,.79,.62,1.75,.93,2.87,.93,1.78,0,3.41-.52,4.9-1.57,1.51-1.04,2.29-2.95,2.35-5.71v-3.07Zm-5.02-3.86c1.53-.19,2.62-.43,3.28-.73,1.18-.5,1.77-1.28,1.77-2.35,0-1.29-.45-2.18-1.36-2.67-.89-.5-2.2-.75-3.94-.75-1.95,0-3.33,.48-4.15,1.45-.58,.72-.97,1.68-1.16,2.9h-7.97c.17-2.76,.95-5.03,2.32-6.81,2.18-2.78,5.93-4.17,11.25-4.17,3.46,0,6.53,.69,9.22,2.06,2.69,1.37,4.03,3.96,4.03,7.77v14.5c0,1.01,.02,2.22,.06,3.65,.06,1.08,.22,1.82,.49,2.2,.27,.39,.68,.71,1.22,.96v1.22h-8.99c-.25-.64-.43-1.24-.52-1.8-.1-.56-.17-1.2-.23-1.91-1.14,1.24-2.45,2.29-3.94,3.16-1.78,1.02-3.79,1.54-6.03,1.54-2.86,0-5.23-.81-7.1-2.44-1.86-1.64-2.78-3.96-2.78-6.96,0-3.89,1.5-6.7,4.49-8.44,1.64-.95,4.06-1.62,7.25-2.03l2.81-.35Z"/>
<path class="cls-3" d="M71.81,35.22c.12,0,.36,0,.72,.03v8.47c-.52-.06-.99-.1-1.39-.12-.41-.02-.73-.03-.99-.03-3.33,0-5.56,1.08-6.7,3.25-.64,1.22-.96,3.09-.96,5.62v15.11h-8.32v-31.6h7.89v5.51c1.28-2.11,2.39-3.55,3.33-4.32,1.55-1.29,3.56-1.94,6.03-1.94,.15,0,.28,0,.38,.03Z"/>
<path class="cls-3" d="M115.91,36.06c1.35,.54,2.58,1.49,3.68,2.84,.89,1.1,1.49,2.46,1.8,4.06,.19,1.06,.29,2.62,.29,4.67l-.06,19.92h-8.47v-20.12c0-1.2-.19-2.18-.58-2.96-.73-1.47-2.09-2.2-4.06-2.2-2.28,0-3.86,.95-4.73,2.84-.45,1.01-.67,2.21-.67,3.62v18.82h-8.32v-18.82c0-1.87-.19-3.24-.58-4.09-.7-1.53-2.06-2.29-4.09-2.29-2.36,0-3.94,.76-4.75,2.29-.45,.87-.67,2.17-.67,3.89v19.02h-8.38v-31.54h8.03v4.61c1.02-1.64,1.99-2.81,2.9-3.51,1.6-1.24,3.68-1.85,6.23-1.86,2.42,0,4.37,.53,5.86,1.59,1.2,.99,2.11,2.25,2.73,3.8,1.08-1.86,2.43-3.22,4.03-4.09,1.7-.87,3.59-1.3,5.68-1.3,1.39,0,2.76,.27,4.12,.81Z"/>
<path class="cls-3" d="M145.81,52.18c-.52,.33-1.05,.6-1.59,.81-.52,.19-1.25,.38-2.17,.55l-1.86,.35c-1.74,.31-2.99,.69-3.74,1.13-1.28,.75-1.91,1.92-1.91,3.51,0,1.41,.39,2.44,1.16,3.07,.79,.62,1.75,.93,2.87,.93,1.78,0,3.41-.52,4.9-1.57,1.51-1.04,2.29-2.95,2.35-5.71v-3.07Zm-5.02-3.86c1.53-.19,2.62-.43,3.28-.73,1.18-.5,1.77-1.28,1.77-2.35,0-1.29-.46-2.18-1.36-2.67-.89-.5-2.2-.75-3.94-.75-1.95,0-3.33,.48-4.15,1.45-.58,.72-.97,1.68-1.16,2.9h-7.97c.17-2.76,.95-5.03,2.32-6.81,2.18-2.78,5.93-4.17,11.25-4.17,3.46,0,6.53,.69,9.22,2.06,2.69,1.37,4.03,3.96,4.03,7.77v14.5c0,1.01,.02,2.22,.06,3.65,.06,1.08,.22,1.82,.49,2.2,.27,.39,.68,.71,1.22,.96v1.22h-8.99c-.25-.64-.43-1.24-.52-1.8-.1-.56-.17-1.2-.23-1.91-1.14,1.24-2.45,2.29-3.94,3.16-1.78,1.02-3.79,1.54-6.03,1.54-2.86,0-5.23-.81-7.1-2.44-1.86-1.64-2.78-3.96-2.78-6.96,0-3.89,1.5-6.7,4.49-8.44,1.64-.95,4.06-1.62,7.25-2.03l2.81-.35Z"/>
<path class="cls-3" d="M179.4,47.28c-.15-1.18-.55-2.24-1.19-3.19-.93-1.27-2.37-1.91-4.32-1.91-2.78,0-4.69,1.38-5.71,4.15-.54,1.47-.81,3.42-.81,5.86s.27,4.18,.81,5.6c.99,2.63,2.84,3.94,5.57,3.94,1.93,0,3.3-.52,4.12-1.56,.81-1.04,1.31-2.4,1.48-4.06h8.44c-.19,2.51-1.1,4.89-2.73,7.13-2.59,3.62-6.43,5.42-11.51,5.42-5.08,0-8.82-1.51-11.22-4.52-2.4-3.01-3.6-6.93-3.6-11.74,0-5.43,1.32-9.65,3.97-12.67,2.65-3.02,6.3-4.52,10.96-4.52,3.96,0,7.2,.89,9.71,2.67,2.53,1.78,4.03,4.92,4.5,9.42h-8.47Z"/>
<path class="cls-3" d="M201.15,32.18h-8.38v-7.63h8.38v7.63Zm-8.38,3.77h8.38v31.6h-8.38v-31.6Z"/>
<path class="cls-3" d="M225.69,52.18c-.52,.33-1.05,.6-1.59,.81-.52,.19-1.25,.38-2.18,.55l-1.86,.35c-1.74,.31-2.99,.69-3.74,1.13-1.28,.75-1.91,1.92-1.91,3.51,0,1.41,.39,2.44,1.16,3.07,.79,.62,1.75,.93,2.87,.93,1.78,0,3.41-.52,4.9-1.57,1.51-1.04,2.29-2.95,2.35-5.71v-3.07Zm-5.02-3.86c1.53-.19,2.62-.43,3.28-.73,1.18-.5,1.77-1.28,1.77-2.35,0-1.29-.45-2.18-1.36-2.67-.89-.5-2.2-.75-3.94-.75-1.95,0-3.34,.48-4.15,1.45-.58,.72-.97,1.68-1.16,2.9h-7.97c.17-2.76,.95-5.03,2.32-6.81,2.18-2.78,5.93-4.17,11.25-4.17,3.46,0,6.53,.69,9.22,2.06,2.69,1.37,4.03,3.96,4.03,7.77v14.5c0,1.01,.02,2.22,.06,3.65,.06,1.08,.22,1.82,.49,2.2,.27,.39,.68,.71,1.22,.96v1.22h-8.99c-.25-.64-.43-1.24-.52-1.8-.1-.56-.17-1.2-.23-1.91-1.14,1.24-2.46,2.29-3.94,3.16-1.78,1.02-3.79,1.54-6.03,1.54-2.86,0-5.23-.81-7.1-2.44-1.86-1.64-2.78-3.96-2.78-6.96,0-3.89,1.5-6.7,4.49-8.44,1.64-.95,4.06-1.62,7.25-2.03l2.81-.35Z"/>
</g>
<g>
<path class="cls-2" d="M28.16,87.82c0,.74-.51,1.32-1.37,1.32-.78,0-1.3-.59-1.3-1.32s.54-1.35,1.35-1.35,1.32,.59,1.32,1.35Zm-2.4,15.18v-11.85h2.16v11.85h-2.16Z"/>
<path class="cls-2" d="M31.24,85.61h2.16v17.39h-2.16v-17.39Z"/>
<path class="cls-2" d="M42.56,91.15l2.33,6.64c.39,1.1,.71,2.08,.96,3.06h.07c.27-.98,.61-1.96,1-3.06l2.3-6.64h2.25l-4.65,11.85h-2.06l-4.51-11.85h2.3Z"/>
<path class="cls-2" d="M63.74,96.98c0,4.38-3.06,6.29-5.9,6.29-3.18,0-5.68-2.35-5.68-6.1,0-3.94,2.62-6.27,5.88-6.27s5.71,2.47,5.71,6.07Zm-9.38,.12c0,2.6,1.47,4.56,3.58,4.56s3.6-1.94,3.6-4.6c0-2.01-1-4.53-3.55-4.53s-3.62,2.35-3.62,4.58Z"/>
<path class="cls-2" d="M65.93,100.82c.66,.39,1.79,.83,2.87,.83,1.54,0,2.28-.76,2.28-1.76s-.61-1.57-2.18-2.16c-2.15-.78-3.16-1.93-3.16-3.35,0-1.91,1.57-3.48,4.09-3.48,1.2,0,2.25,.32,2.89,.73l-.51,1.57c-.47-.29-1.32-.71-2.42-.71-1.27,0-1.96,.74-1.96,1.62,0,.98,.69,1.42,2.23,2.03,2.03,.76,3.11,1.79,3.11,3.55,0,2.11-1.64,3.58-4.41,3.58-1.3,0-2.5-.34-3.33-.83l.51-1.62Z"/>
<path class="cls-2" d="M78.27,88.31v2.84h3.09v1.64h-3.09v6.39c0,1.47,.42,2.3,1.62,2.3,.59,0,.93-.05,1.25-.15l.1,1.64c-.42,.15-1.08,.29-1.91,.29-1,0-1.81-.34-2.33-.91-.59-.66-.83-1.71-.83-3.11v-6.46h-1.84v-1.64h1.84v-2.2l2.11-.64Z"/>
<path class="cls-2" d="M83.54,94.84c0-1.4-.02-2.6-.1-3.7h1.89l.1,2.35h.07c.54-1.59,1.86-2.6,3.31-2.6,.22,0,.39,.03,.59,.05v2.03c-.22-.05-.44-.05-.73-.05-1.52,0-2.6,1.13-2.89,2.74-.05,.29-.07,.66-.07,1v6.32h-2.16v-8.16Z"/>
<path class="cls-2" d="M101.78,96.98c0,4.38-3.06,6.29-5.9,6.29-3.18,0-5.68-2.35-5.68-6.1,0-3.94,2.62-6.27,5.88-6.27s5.71,2.47,5.71,6.07Zm-9.38,.12c0,2.6,1.47,4.56,3.58,4.56s3.6-1.94,3.6-4.6c0-2.01-1-4.53-3.55-4.53s-3.62,2.35-3.62,4.58Z"/>
<path class="cls-2" d="M109.2,85.61h2.13v7.45h.05c.76-1.32,2.13-2.16,4.04-2.16,2.96,0,5.02,2.45,5.02,6.03,0,4.24-2.69,6.34-5.34,6.34-1.71,0-3.09-.66-3.99-2.2h-.05l-.12,1.93h-1.84c.05-.81,.1-2.01,.1-3.06v-14.33Zm2.13,12.64c0,.27,.02,.54,.1,.78,.39,1.49,1.66,2.52,3.23,2.52,2.28,0,3.6-1.84,3.6-4.56,0-2.38-1.22-4.41-3.55-4.41-1.45,0-2.82,1.03-3.26,2.64-.07,.27-.12,.56-.12,.91v2.11Z"/>
<path class="cls-2" d="M124.1,97.47c.05,2.91,1.89,4.11,4.07,4.11,1.54,0,2.5-.27,3.28-.61l.39,1.54c-.76,.34-2.08,.76-3.97,.76-3.65,0-5.83-2.42-5.83-6s2.11-6.37,5.56-6.37c3.89,0,4.9,3.38,4.9,5.56,0,.44-.02,.76-.07,1h-8.33Zm6.32-1.54c.02-1.35-.56-3.48-2.99-3.48-2.2,0-3.13,1.98-3.31,3.48h6.29Z"/>
<path class="cls-2" d="M134.92,94.35c0-1.25-.02-2.23-.1-3.21h1.91l.12,1.96h.05c.59-1.1,1.96-2.2,3.92-2.2,1.64,0,4.19,.98,4.19,5.04v7.05h-2.16v-6.83c0-1.91-.71-3.5-2.74-3.5-1.4,0-2.5,1-2.89,2.2-.1,.27-.15,.64-.15,1v7.13h-2.16v-8.65Z"/>
<path class="cls-2" d="M149.47,97.47c.05,2.91,1.89,4.11,4.07,4.11,1.54,0,2.5-.27,3.28-.61l.39,1.54c-.76,.34-2.08,.76-3.97,.76-3.65,0-5.83-2.42-5.83-6s2.11-6.37,5.56-6.37c3.89,0,4.9,3.38,4.9,5.56,0,.44-.02,.76-.07,1h-8.33Zm6.32-1.54c.02-1.35-.56-3.48-2.99-3.48-2.2,0-3.13,1.98-3.31,3.48h6.29Z"/>
<path class="cls-2" d="M160,100.82c.66,.39,1.79,.83,2.87,.83,1.54,0,2.28-.76,2.28-1.76s-.61-1.57-2.18-2.16c-2.15-.78-3.16-1.93-3.16-3.35,0-1.91,1.57-3.48,4.09-3.48,1.2,0,2.25,.32,2.89,.73l-.51,1.57c-.47-.29-1.32-.71-2.42-.71-1.27,0-1.96,.74-1.96,1.62,0,.98,.69,1.42,2.23,2.03,2.03,.76,3.11,1.79,3.11,3.55,0,2.11-1.64,3.58-4.41,3.58-1.3,0-2.5-.34-3.33-.83l.51-1.62Z"/>
<path class="cls-2" d="M169.46,100.82c.66,.39,1.79,.83,2.87,.83,1.54,0,2.28-.76,2.28-1.76s-.61-1.57-2.18-2.16c-2.15-.78-3.16-1.93-3.16-3.35,0-1.91,1.57-3.48,4.09-3.48,1.2,0,2.25,.32,2.89,.73l-.51,1.57c-.47-.29-1.32-.71-2.42-.71-1.27,0-1.96,.74-1.96,1.62,0,.98,.69,1.42,2.23,2.03,2.03,.76,3.11,1.79,3.11,3.55,0,2.11-1.64,3.58-4.41,3.58-1.3,0-2.5-.34-3.33-.83l.51-1.62Z"/>
<path class="cls-2" d="M180.4,97.47c.05,2.91,1.89,4.11,4.07,4.11,1.54,0,2.5-.27,3.28-.61l.39,1.54c-.76,.34-2.08,.76-3.97,.76-3.65,0-5.83-2.42-5.83-6s2.11-6.37,5.56-6.37c3.89,0,4.9,3.38,4.9,5.56,0,.44-.02,.76-.07,1h-8.33Zm6.32-1.54c.02-1.35-.56-3.48-2.99-3.48-2.2,0-3.13,1.98-3.31,3.48h6.29Z"/>
<path class="cls-2" d="M191.23,94.84c0-1.4-.02-2.6-.1-3.7h1.89l.1,2.35h.07c.54-1.59,1.86-2.6,3.31-2.6,.22,0,.39,.03,.59,.05v2.03c-.22-.05-.44-.05-.73-.05-1.52,0-2.6,1.13-2.89,2.74-.05,.29-.07,.66-.07,1v6.32h-2.16v-8.16Z"/>
<path class="cls-2" d="M199.95,97.47c.05,2.91,1.89,4.11,4.07,4.11,1.54,0,2.5-.27,3.28-.61l.39,1.54c-.76,.34-2.08,.76-3.97,.76-3.65,0-5.83-2.42-5.83-6s2.11-6.37,5.56-6.37c3.89,0,4.9,3.38,4.9,5.56,0,.44-.02,.76-.07,1h-8.33Zm6.32-1.54c.02-1.35-.56-3.48-2.99-3.48-2.2,0-3.13,1.98-3.31,3.48h6.29Z"/>
<path class="cls-2" d="M214.82,96.58c-.02-1.42,1.15-2.57,2.57-2.57s2.57,1.13,2.57,2.57-1.15,2.57-2.6,2.57-2.55-1.13-2.55-2.57Z"/>
<path class="cls-2" d="M227.33,85.61h2.16v17.39h-2.16v-17.39Z"/>
<path class="cls-2" d="M241.2,100.16c0,1.03,.05,2.03,.17,2.84h-1.93l-.17-1.49h-.07c-.66,.93-1.93,1.76-3.62,1.76-2.4,0-3.62-1.69-3.62-3.4,0-2.87,2.55-4.43,7.13-4.41v-.25c0-.96-.27-2.74-2.69-2.72-1.13,0-2.28,.32-3.11,.88l-.49-1.45c.98-.61,2.42-1.03,3.92-1.03,3.62,0,4.51,2.47,4.51,4.83v4.43Zm-2.08-3.21c-2.35-.05-5.02,.37-5.02,2.67,0,1.42,.93,2.06,2.01,2.06,1.57,0,2.57-.98,2.91-1.98,.07-.25,.1-.49,.1-.69v-2.06Z"/>
<path class="cls-2" d="M249.38,94.35c0-1.25-.02-2.23-.1-3.21h1.91l.12,1.96h.05c.59-1.1,1.96-2.2,3.92-2.2,1.64,0,4.19,.98,4.19,5.04v7.05h-2.16v-6.83c0-1.91-.71-3.5-2.74-3.5-1.4,0-2.5,1-2.89,2.2-.1,.27-.15,.64-.15,1v7.13h-2.16v-8.65Z"/>
<path class="cls-2" d="M273.45,96.98c0,4.38-3.06,6.29-5.9,6.29-3.18,0-5.68-2.35-5.68-6.1,0-3.94,2.62-6.27,5.88-6.27s5.71,2.47,5.71,6.07Zm-9.38,.12c0,2.6,1.47,4.56,3.58,4.56s3.6-1.94,3.6-4.6c0-2.01-1-4.53-3.55-4.53s-3.62,2.35-3.62,4.58Z"/>
<path class="cls-2" d="M275.63,100.82c.66,.39,1.79,.83,2.87,.83,1.54,0,2.28-.76,2.28-1.76s-.61-1.57-2.18-2.16c-2.16-.78-3.16-1.93-3.16-3.35,0-1.91,1.57-3.48,4.09-3.48,1.2,0,2.25,.32,2.89,.73l-.51,1.57c-.47-.29-1.32-.71-2.42-.71-1.27,0-1.96,.74-1.96,1.62,0,.98,.69,1.42,2.23,2.03,2.03,.76,3.11,1.79,3.11,3.55,0,2.11-1.64,3.58-4.41,3.58-1.3,0-2.5-.34-3.33-.83l.51-1.62Z"/>
<path class="cls-2" d="M287.98,88.31v2.84h3.09v1.64h-3.09v6.39c0,1.47,.42,2.3,1.62,2.3,.59,0,.93-.05,1.25-.15l.1,1.64c-.42,.15-1.08,.29-1.91,.29-1,0-1.81-.34-2.33-.91-.59-.66-.83-1.71-.83-3.11v-6.46h-1.84v-1.64h1.84v-2.2l2.11-.64Z"/>
<path class="cls-2" d="M293.24,94.84c0-1.4-.02-2.6-.1-3.7h1.89l.1,2.35h.07c.54-1.59,1.86-2.6,3.31-2.6,.22,0,.39,.03,.59,.05v2.03c-.22-.05-.44-.05-.73-.05-1.52,0-2.6,1.13-2.89,2.74-.05,.29-.07,.66-.07,1v6.32h-2.16v-8.16Z"/>
<path class="cls-2" d="M309.21,100.16c0,1.03,.05,2.03,.17,2.84h-1.93l-.17-1.49h-.07c-.66,.93-1.93,1.76-3.62,1.76-2.4,0-3.62-1.69-3.62-3.4,0-2.87,2.55-4.43,7.13-4.41v-.25c0-.96-.27-2.74-2.69-2.72-1.13,0-2.28,.32-3.11,.88l-.49-1.45c.98-.61,2.42-1.03,3.92-1.03,3.62,0,4.51,2.47,4.51,4.83v4.43Zm-2.08-3.21c-2.35-.05-5.02,.37-5.02,2.67,0,1.42,.93,2.06,2.01,2.06,1.57,0,2.57-.98,2.91-1.98,.07-.25,.1-.49,.1-.69v-2.06Z"/>
<path class="cls-2" d="M317.39,95.02c0-1.52-.05-2.74-.1-3.87h1.91l.12,2.03h.05c.86-1.44,2.28-2.28,4.21-2.28,2.89,0,5.05,2.42,5.05,6,0,4.26-2.62,6.37-5.41,6.37-1.57,0-2.94-.69-3.65-1.86h-.05v6.44h-2.13v-12.83Zm2.13,3.16c0,.32,.02,.61,.1,.88,.39,1.49,1.69,2.52,3.23,2.52,2.28,0,3.6-1.86,3.6-4.58,0-2.35-1.25-4.38-3.53-4.38-1.47,0-2.87,1.03-3.26,2.64-.07,.27-.15,.59-.15,.86v2.06Z"/>
<path class="cls-2" d="M331.09,94.84c0-1.4-.02-2.6-.1-3.7h1.89l.1,2.35h.07c.54-1.59,1.86-2.6,3.31-2.6,.22,0,.39,.03,.59,.05v2.03c-.22-.05-.44-.05-.73-.05-1.52,0-2.6,1.13-2.89,2.74-.05,.29-.07,.66-.07,1v6.32h-2.16v-8.16Z"/>
<path class="cls-2" d="M349.33,96.98c0,4.38-3.06,6.29-5.9,6.29-3.18,0-5.68-2.35-5.68-6.1,0-3.94,2.62-6.27,5.88-6.27s5.71,2.47,5.71,6.07Zm-9.38,.12c0,2.6,1.47,4.56,3.58,4.56s3.6-1.94,3.6-4.6c0-2.01-1-4.53-3.55-4.53s-3.62,2.35-3.62,4.58Z"/>
<path class="cls-2" d="M352.03,103v-10.21h-1.64v-1.64h1.64v-.56c0-1.66,.39-3.18,1.37-4.14,.81-.78,1.89-1.1,2.89-1.1,.78,0,1.42,.17,1.84,.34l-.29,1.66c-.32-.15-.73-.27-1.37-.27-1.84,0-2.3,1.59-2.3,3.43v.64h2.87v1.64h-2.87v10.21h-2.13Z"/>
<path class="cls-2" d="M359.59,97.47c.05,2.91,1.89,4.11,4.07,4.11,1.54,0,2.5-.27,3.28-.61l.39,1.54c-.76,.34-2.08,.76-3.97,.76-3.65,0-5.83-2.42-5.83-6s2.11-6.37,5.56-6.37c3.89,0,4.9,3.38,4.9,5.56,0,.44-.02,.76-.07,1h-8.33Zm6.32-1.54c.02-1.35-.56-3.48-2.99-3.48-2.2,0-3.13,1.98-3.31,3.48h6.29Z"/>
<path class="cls-2" d="M370.13,100.82c.66,.39,1.79,.83,2.87,.83,1.54,0,2.28-.76,2.28-1.76s-.61-1.57-2.18-2.16c-2.16-.78-3.16-1.93-3.16-3.35,0-1.91,1.57-3.48,4.09-3.48,1.2,0,2.25,.32,2.89,.73l-.51,1.57c-.47-.29-1.32-.71-2.42-.71-1.27,0-1.96,.74-1.96,1.62,0,.98,.69,1.42,2.23,2.03,2.03,.76,3.11,1.79,3.11,3.55,0,2.11-1.64,3.58-4.41,3.58-1.3,0-2.5-.34-3.33-.83l.51-1.62Z"/>
<path class="cls-2" d="M379.58,100.82c.66,.39,1.79,.83,2.87,.83,1.54,0,2.28-.76,2.28-1.76s-.61-1.57-2.18-2.16c-2.16-.78-3.16-1.93-3.16-3.35,0-1.91,1.57-3.48,4.09-3.48,1.2,0,2.25,.32,2.89,.73l-.51,1.57c-.47-.29-1.32-.71-2.42-.71-1.27,0-1.96,.74-1.96,1.62,0,.98,.69,1.42,2.23,2.03,2.03,.76,3.11,1.79,3.11,3.55,0,2.11-1.64,3.58-4.41,3.58-1.3,0-2.5-.34-3.33-.83l.51-1.62Z"/>
<path class="cls-2" d="M391.73,87.82c0,.74-.51,1.32-1.37,1.32-.78,0-1.3-.59-1.3-1.32s.54-1.35,1.35-1.35,1.32,.59,1.32,1.35Zm-2.4,15.18v-11.85h2.16v11.85h-2.16Z"/>
<path class="cls-2" d="M405.54,96.98c0,4.38-3.06,6.29-5.9,6.29-3.18,0-5.68-2.35-5.68-6.1,0-3.94,2.62-6.27,5.88-6.27s5.71,2.47,5.71,6.07Zm-9.38,.12c0,2.6,1.47,4.56,3.58,4.56s3.6-1.94,3.6-4.6c0-2.01-1-4.53-3.55-4.53s-3.62,2.35-3.62,4.58Z"/>
<path class="cls-2" d="M408.02,94.35c0-1.25-.02-2.23-.1-3.21h1.91l.12,1.96h.05c.59-1.1,1.96-2.2,3.92-2.2,1.64,0,4.19,.98,4.19,5.04v7.05h-2.16v-6.83c0-1.91-.71-3.5-2.74-3.5-1.4,0-2.5,1-2.89,2.2-.1,.27-.15,.64-.15,1v7.13h-2.16v-8.65Z"/>
<path class="cls-2" d="M422.56,97.47c.05,2.91,1.89,4.11,4.07,4.11,1.54,0,2.5-.27,3.28-.61l.39,1.54c-.76,.34-2.08,.76-3.97,.76-3.65,0-5.83-2.42-5.83-6s2.11-6.37,5.56-6.37c3.89,0,4.9,3.38,4.9,5.56,0,.44-.02,.76-.07,1h-8.33Zm6.32-1.54c.02-1.35-.56-3.48-2.99-3.48-2.2,0-3.13,1.98-3.31,3.48h6.29Z"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 20 KiB

BIN
frontend/logo.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

BIN
frontend/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

150
frontend/logo.svg Normal file
View File

@@ -0,0 +1,150 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Livello_2" data-name="Livello 2" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 440.56 107.85">
<defs>
<style>
.cls-1 {
fill: url(#linear-gradient);
}
.cls-2, .cls-3 {
fill: #002c50;
}
.cls-4 {
fill: #6a971f;
}
.cls-5 {
fill: url(#radial-gradient-3);
}
.cls-6 {
fill: url(#radial-gradient-2);
}
.cls-7 {
fill: url(#radial-gradient);
}
.cls-3 {
fill-rule: evenodd;
}
</style>
<radialGradient id="radial-gradient" cx="273.62" cy="61.21" fx="273.62" fy="61.21" r="61.75" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#f1f6ec"/>
<stop offset=".06" stop-color="#d5e2c2"/>
<stop offset=".12" stop-color="#bcd19c"/>
<stop offset=".18" stop-color="#a6c17a"/>
<stop offset=".25" stop-color="#93b45d"/>
<stop offset=".33" stop-color="#84a946"/>
<stop offset=".42" stop-color="#78a134"/>
<stop offset=".52" stop-color="#709b28"/>
<stop offset=".67" stop-color="#6b9721"/>
<stop offset="1" stop-color="#6a971f"/>
</radialGradient>
<linearGradient id="linear-gradient" x1="248.35" y1="21.74" x2="298.89" y2="21.74" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#f4f9f1"/>
<stop offset=".05" stop-color="#e2efd1"/>
<stop offset=".11" stop-color="#cbe2a9"/>
<stop offset=".19" stop-color="#b6d786"/>
<stop offset=".26" stop-color="#a5ce68"/>
<stop offset=".35" stop-color="#97c650"/>
<stop offset=".44" stop-color="#8dc13d"/>
<stop offset=".56" stop-color="#85bd30"/>
<stop offset=".7" stop-color="#81ba29"/>
<stop offset="1" stop-color="#80ba27"/>
</linearGradient>
<radialGradient id="radial-gradient-2" cx="243.27" cy="-1778.99" fx="243.27" fy="-1778.99" r="31.75" gradientTransform="translate(59.23 297.58) scale(.88 .13)" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#002c50"/>
<stop offset="0" stop-color="#012d50"/>
<stop offset=".09" stop-color="#365875"/>
<stop offset=".17" stop-color="#647f95"/>
<stop offset=".26" stop-color="#8ea1b1"/>
<stop offset=".36" stop-color="#b1bec9"/>
<stop offset=".46" stop-color="#cdd6dd"/>
<stop offset=".56" stop-color="#e3e8ec"/>
<stop offset=".68" stop-color="#f3f5f6"/>
<stop offset=".81" stop-color="#fcfcfd"/>
<stop offset="1" stop-color="#fff"/>
</radialGradient>
<radialGradient id="radial-gradient-3" cx="273.62" cy="30.14" fx="273.62" fy="30.14" r="15.94" gradientUnits="userSpaceOnUse">
<stop offset=".03" stop-color="#f4f9f1"/>
<stop offset=".36" stop-color="#f2f8ee"/>
<stop offset=".5" stop-color="#eef6e7"/>
<stop offset=".61" stop-color="#e7f2db"/>
<stop offset=".69" stop-color="#ddecc9"/>
<stop offset=".77" stop-color="#d0e5b2"/>
<stop offset=".84" stop-color="#bfdc96"/>
<stop offset=".9" stop-color="#acd274"/>
<stop offset=".95" stop-color="#96c64e"/>
<stop offset="1" stop-color="#80ba27"/>
</radialGradient>
</defs>
<g id="Livello_2-2" data-name="Livello 2">
<g>
<g>
<path class="cls-7" d="M304.23,30.6c0,16.9-13.7,30.6-30.6,30.6s-30.6-13.7-30.6-30.6S256.72,0,273.62,0s30.6,13.7,30.6,30.6Z"/>
<path class="cls-1" d="M298.89,21.74c0,11.53-11.31,20.87-25.27,20.87s-25.27-9.34-25.27-20.87S259.66,.87,273.62,.87s25.27,9.35,25.27,20.87Z"/>
<path class="cls-6" d="M301.52,67.55c0,2.27-12.52,4.11-27.97,4.11s-27.97-1.84-27.97-4.11,12.53-4.1,27.97-4.1,27.97,1.84,27.97,4.1Z"/>
<path class="cls-4" d="M286.32,24.45h-8.55V15.9c0-1.37-1.11-2.47-2.47-2.47h-4.89c-1.37,0-2.48,1.11-2.48,2.47v8.55h-8.55c-1.36,0-2.47,1.11-2.47,2.47v4.89c0,1.36,1.11,2.47,2.47,2.47h8.55v8.55c0,1.37,1.11,2.47,2.48,2.47h4.89c1.36,0,2.47-1.11,2.47-2.47v-8.55h8.55c1.37,0,2.47-1.11,2.47-2.47v-4.89c0-1.37-1.11-2.47-2.47-2.47Z"/>
<path class="cls-5" d="M287.09,25.22h-8.55v-8.55c0-1.37-1.11-2.47-2.47-2.47h-4.9c-1.36,0-2.47,1.11-2.47,2.47v8.55h-8.55c-1.36,0-2.47,1.11-2.47,2.47v4.89c0,1.37,1.11,2.47,2.47,2.47h8.55v8.55c0,1.37,1.11,2.47,2.47,2.47h4.9c1.36,0,2.47-1.11,2.47-2.47v-8.55h8.55c1.37,0,2.47-1.11,2.47-2.47v-4.89c0-1.37-1.11-2.47-2.47-2.47Z"/>
</g>
<g>
<path class="cls-3" d="M322.06,32.18h-8.38v-7.63h8.38v7.63Zm-8.38,3.77h8.38v31.6h-8.38v-31.6Z"/>
<path class="cls-3" d="M346.59,52.18c-.52,.33-1.05,.6-1.59,.81-.52,.19-1.25,.38-2.17,.55l-1.86,.35c-1.74,.31-2.99,.69-3.74,1.13-1.28,.75-1.92,1.92-1.92,3.51,0,1.41,.39,2.44,1.16,3.07,.79,.62,1.75,.93,2.87,.93,1.78,0,3.41-.52,4.9-1.57,1.51-1.04,2.29-2.95,2.35-5.71v-3.07Zm-5.02-3.86c1.53-.19,2.62-.43,3.28-.73,1.18-.5,1.77-1.28,1.77-2.35,0-1.29-.46-2.18-1.36-2.67-.89-.5-2.2-.75-3.94-.75-1.95,0-3.33,.48-4.15,1.45-.58,.72-.97,1.68-1.16,2.9h-7.97c.17-2.76,.95-5.03,2.32-6.81,2.18-2.78,5.93-4.17,11.25-4.17,3.46,0,6.53,.69,9.22,2.06s4.03,3.96,4.03,7.77v14.5c0,1.01,.02,2.22,.06,3.65,.06,1.08,.22,1.82,.49,2.2,.27,.39,.68,.71,1.22,.96v1.22h-8.99c-.25-.64-.43-1.24-.52-1.8-.1-.56-.17-1.2-.23-1.91-1.14,1.24-2.45,2.29-3.94,3.16-1.78,1.02-3.79,1.54-6.03,1.54-2.86,0-5.23-.81-7.1-2.44-1.86-1.64-2.78-3.96-2.78-6.96,0-3.89,1.5-6.7,4.5-8.44,1.64-.95,4.06-1.62,7.25-2.03l2.81-.35Z"/>
<path class="cls-3" d="M386.82,37.77c2.09,1.7,3.13,4.53,3.13,8.5v21.28h-8.47v-19.22c0-1.66-.22-2.94-.67-3.83-.81-1.62-2.36-2.44-4.64-2.44-2.8,0-4.73,1.19-5.77,3.57-.54,1.26-.81,2.86-.81,4.81v17.11h-8.23v-31.54h7.97v4.61c1.06-1.62,2.07-2.79,3.01-3.51,1.7-1.28,3.85-1.91,6.47-1.91,3.27,0,5.93,.86,8,2.58Z"/>
<path class="cls-3" d="M422.2,37.77c2.09,1.7,3.13,4.53,3.13,8.5v21.28h-8.47v-19.22c0-1.66-.22-2.94-.67-3.83-.81-1.62-2.36-2.44-4.64-2.44-2.8,0-4.73,1.19-5.77,3.57-.54,1.26-.81,2.86-.81,4.81v17.11h-8.23v-31.54h7.97v4.61c1.06-1.62,2.07-2.79,3.02-3.51,1.7-1.28,3.86-1.91,6.46-1.91,3.27,0,5.93,.86,8,2.58Z"/>
<path class="cls-3" d="M440.56,32.18h-8.38v-7.63h8.38v7.63Zm-8.38,3.77h8.38v31.6h-8.38v-31.6Z"/>
</g>
<g>
<path class="cls-3" d="M16.32,24.43c.43,.02,1.01,.06,1.74,.12v6.73c-.46-.06-1.25-.1-2.35-.12-1.08-.04-1.84,.2-2.26,.73-.41,.5-.61,1.06-.61,1.68v2.67h5.42v5.83h-5.42v25.49H4.61v-25.49H0v-5.83H4.52v-2.03c0-3.38,.57-5.71,1.71-6.99,1.2-1.89,4.09-2.84,8.67-2.84,.52,0,.99,.02,1.42,.06Z"/>
<path class="cls-3" d="M39.56,52.18c-.52,.33-1.05,.6-1.59,.81-.52,.19-1.25,.38-2.17,.55l-1.85,.35c-1.74,.31-2.99,.69-3.74,1.13-1.28,.75-1.91,1.92-1.91,3.51,0,1.41,.39,2.44,1.16,3.07,.79,.62,1.75,.93,2.87,.93,1.78,0,3.41-.52,4.9-1.57,1.51-1.04,2.29-2.95,2.35-5.71v-3.07Zm-5.02-3.86c1.53-.19,2.62-.43,3.28-.73,1.18-.5,1.77-1.28,1.77-2.35,0-1.29-.45-2.18-1.36-2.67-.89-.5-2.2-.75-3.94-.75-1.95,0-3.33,.48-4.15,1.45-.58,.72-.97,1.68-1.16,2.9h-7.97c.17-2.76,.95-5.03,2.32-6.81,2.18-2.78,5.93-4.17,11.25-4.17,3.46,0,6.53,.69,9.22,2.06,2.69,1.37,4.03,3.96,4.03,7.77v14.5c0,1.01,.02,2.22,.06,3.65,.06,1.08,.22,1.82,.49,2.2,.27,.39,.68,.71,1.22,.96v1.22h-8.99c-.25-.64-.43-1.24-.52-1.8-.1-.56-.17-1.2-.23-1.91-1.14,1.24-2.45,2.29-3.94,3.16-1.78,1.02-3.79,1.54-6.03,1.54-2.86,0-5.23-.81-7.1-2.44-1.86-1.64-2.78-3.96-2.78-6.96,0-3.89,1.5-6.7,4.49-8.44,1.64-.95,4.06-1.62,7.25-2.03l2.81-.35Z"/>
<path class="cls-3" d="M71.81,35.22c.12,0,.36,0,.72,.03v8.47c-.52-.06-.99-.1-1.39-.12-.41-.02-.73-.03-.99-.03-3.33,0-5.56,1.08-6.7,3.25-.64,1.22-.96,3.09-.96,5.62v15.11h-8.32v-31.6h7.89v5.51c1.28-2.11,2.39-3.55,3.33-4.32,1.55-1.29,3.56-1.94,6.03-1.94,.15,0,.28,0,.38,.03Z"/>
<path class="cls-3" d="M115.91,36.06c1.35,.54,2.58,1.49,3.68,2.84,.89,1.1,1.49,2.46,1.8,4.06,.19,1.06,.29,2.62,.29,4.67l-.06,19.92h-8.47v-20.12c0-1.2-.19-2.18-.58-2.96-.73-1.47-2.09-2.2-4.06-2.2-2.28,0-3.86,.95-4.73,2.84-.45,1.01-.67,2.21-.67,3.62v18.82h-8.32v-18.82c0-1.87-.19-3.24-.58-4.09-.7-1.53-2.06-2.29-4.09-2.29-2.36,0-3.94,.76-4.75,2.29-.45,.87-.67,2.17-.67,3.89v19.02h-8.38v-31.54h8.03v4.61c1.02-1.64,1.99-2.81,2.9-3.51,1.6-1.24,3.68-1.85,6.23-1.86,2.42,0,4.37,.53,5.86,1.59,1.2,.99,2.11,2.25,2.73,3.8,1.08-1.86,2.43-3.22,4.03-4.09,1.7-.87,3.59-1.3,5.68-1.3,1.39,0,2.76,.27,4.12,.81Z"/>
<path class="cls-3" d="M145.81,52.18c-.52,.33-1.05,.6-1.59,.81-.52,.19-1.25,.38-2.17,.55l-1.86,.35c-1.74,.31-2.99,.69-3.74,1.13-1.28,.75-1.91,1.92-1.91,3.51,0,1.41,.39,2.44,1.16,3.07,.79,.62,1.75,.93,2.87,.93,1.78,0,3.41-.52,4.9-1.57,1.51-1.04,2.29-2.95,2.35-5.71v-3.07Zm-5.02-3.86c1.53-.19,2.62-.43,3.28-.73,1.18-.5,1.77-1.28,1.77-2.35,0-1.29-.46-2.18-1.36-2.67-.89-.5-2.2-.75-3.94-.75-1.95,0-3.33,.48-4.15,1.45-.58,.72-.97,1.68-1.16,2.9h-7.97c.17-2.76,.95-5.03,2.32-6.81,2.18-2.78,5.93-4.17,11.25-4.17,3.46,0,6.53,.69,9.22,2.06,2.69,1.37,4.03,3.96,4.03,7.77v14.5c0,1.01,.02,2.22,.06,3.65,.06,1.08,.22,1.82,.49,2.2,.27,.39,.68,.71,1.22,.96v1.22h-8.99c-.25-.64-.43-1.24-.52-1.8-.1-.56-.17-1.2-.23-1.91-1.14,1.24-2.45,2.29-3.94,3.16-1.78,1.02-3.79,1.54-6.03,1.54-2.86,0-5.23-.81-7.1-2.44-1.86-1.64-2.78-3.96-2.78-6.96,0-3.89,1.5-6.7,4.49-8.44,1.64-.95,4.06-1.62,7.25-2.03l2.81-.35Z"/>
<path class="cls-3" d="M179.4,47.28c-.15-1.18-.55-2.24-1.19-3.19-.93-1.27-2.37-1.91-4.32-1.91-2.78,0-4.69,1.38-5.71,4.15-.54,1.47-.81,3.42-.81,5.86s.27,4.18,.81,5.6c.99,2.63,2.84,3.94,5.57,3.94,1.93,0,3.3-.52,4.12-1.56,.81-1.04,1.31-2.4,1.48-4.06h8.44c-.19,2.51-1.1,4.89-2.73,7.13-2.59,3.62-6.43,5.42-11.51,5.42-5.08,0-8.82-1.51-11.22-4.52-2.4-3.01-3.6-6.93-3.6-11.74,0-5.43,1.32-9.65,3.97-12.67,2.65-3.02,6.3-4.52,10.96-4.52,3.96,0,7.2,.89,9.71,2.67,2.53,1.78,4.03,4.92,4.5,9.42h-8.47Z"/>
<path class="cls-3" d="M201.15,32.18h-8.38v-7.63h8.38v7.63Zm-8.38,3.77h8.38v31.6h-8.38v-31.6Z"/>
<path class="cls-3" d="M225.69,52.18c-.52,.33-1.05,.6-1.59,.81-.52,.19-1.25,.38-2.18,.55l-1.86,.35c-1.74,.31-2.99,.69-3.74,1.13-1.28,.75-1.91,1.92-1.91,3.51,0,1.41,.39,2.44,1.16,3.07,.79,.62,1.75,.93,2.87,.93,1.78,0,3.41-.52,4.9-1.57,1.51-1.04,2.29-2.95,2.35-5.71v-3.07Zm-5.02-3.86c1.53-.19,2.62-.43,3.28-.73,1.18-.5,1.77-1.28,1.77-2.35,0-1.29-.45-2.18-1.36-2.67-.89-.5-2.2-.75-3.94-.75-1.95,0-3.34,.48-4.15,1.45-.58,.72-.97,1.68-1.16,2.9h-7.97c.17-2.76,.95-5.03,2.32-6.81,2.18-2.78,5.93-4.17,11.25-4.17,3.46,0,6.53,.69,9.22,2.06,2.69,1.37,4.03,3.96,4.03,7.77v14.5c0,1.01,.02,2.22,.06,3.65,.06,1.08,.22,1.82,.49,2.2,.27,.39,.68,.71,1.22,.96v1.22h-8.99c-.25-.64-.43-1.24-.52-1.8-.1-.56-.17-1.2-.23-1.91-1.14,1.24-2.46,2.29-3.94,3.16-1.78,1.02-3.79,1.54-6.03,1.54-2.86,0-5.23-.81-7.1-2.44-1.86-1.64-2.78-3.96-2.78-6.96,0-3.89,1.5-6.7,4.49-8.44,1.64-.95,4.06-1.62,7.25-2.03l2.81-.35Z"/>
</g>
<g>
<path class="cls-2" d="M28.16,87.82c0,.74-.51,1.32-1.37,1.32-.78,0-1.3-.59-1.3-1.32s.54-1.35,1.35-1.35,1.32,.59,1.32,1.35Zm-2.4,15.18v-11.85h2.16v11.85h-2.16Z"/>
<path class="cls-2" d="M31.24,85.61h2.16v17.39h-2.16v-17.39Z"/>
<path class="cls-2" d="M42.56,91.15l2.33,6.64c.39,1.1,.71,2.08,.96,3.06h.07c.27-.98,.61-1.96,1-3.06l2.3-6.64h2.25l-4.65,11.85h-2.06l-4.51-11.85h2.3Z"/>
<path class="cls-2" d="M63.74,96.98c0,4.38-3.06,6.29-5.9,6.29-3.18,0-5.68-2.35-5.68-6.1,0-3.94,2.62-6.27,5.88-6.27s5.71,2.47,5.71,6.07Zm-9.38,.12c0,2.6,1.47,4.56,3.58,4.56s3.6-1.94,3.6-4.6c0-2.01-1-4.53-3.55-4.53s-3.62,2.35-3.62,4.58Z"/>
<path class="cls-2" d="M65.93,100.82c.66,.39,1.79,.83,2.87,.83,1.54,0,2.28-.76,2.28-1.76s-.61-1.57-2.18-2.16c-2.15-.78-3.16-1.93-3.16-3.35,0-1.91,1.57-3.48,4.09-3.48,1.2,0,2.25,.32,2.89,.73l-.51,1.57c-.47-.29-1.32-.71-2.42-.71-1.27,0-1.96,.74-1.96,1.62,0,.98,.69,1.42,2.23,2.03,2.03,.76,3.11,1.79,3.11,3.55,0,2.11-1.64,3.58-4.41,3.58-1.3,0-2.5-.34-3.33-.83l.51-1.62Z"/>
<path class="cls-2" d="M78.27,88.31v2.84h3.09v1.64h-3.09v6.39c0,1.47,.42,2.3,1.62,2.3,.59,0,.93-.05,1.25-.15l.1,1.64c-.42,.15-1.08,.29-1.91,.29-1,0-1.81-.34-2.33-.91-.59-.66-.83-1.71-.83-3.11v-6.46h-1.84v-1.64h1.84v-2.2l2.11-.64Z"/>
<path class="cls-2" d="M83.54,94.84c0-1.4-.02-2.6-.1-3.7h1.89l.1,2.35h.07c.54-1.59,1.86-2.6,3.31-2.6,.22,0,.39,.03,.59,.05v2.03c-.22-.05-.44-.05-.73-.05-1.52,0-2.6,1.13-2.89,2.74-.05,.29-.07,.66-.07,1v6.32h-2.16v-8.16Z"/>
<path class="cls-2" d="M101.78,96.98c0,4.38-3.06,6.29-5.9,6.29-3.18,0-5.68-2.35-5.68-6.1,0-3.94,2.62-6.27,5.88-6.27s5.71,2.47,5.71,6.07Zm-9.38,.12c0,2.6,1.47,4.56,3.58,4.56s3.6-1.94,3.6-4.6c0-2.01-1-4.53-3.55-4.53s-3.62,2.35-3.62,4.58Z"/>
<path class="cls-2" d="M109.2,85.61h2.13v7.45h.05c.76-1.32,2.13-2.16,4.04-2.16,2.96,0,5.02,2.45,5.02,6.03,0,4.24-2.69,6.34-5.34,6.34-1.71,0-3.09-.66-3.99-2.2h-.05l-.12,1.93h-1.84c.05-.81,.1-2.01,.1-3.06v-14.33Zm2.13,12.64c0,.27,.02,.54,.1,.78,.39,1.49,1.66,2.52,3.23,2.52,2.28,0,3.6-1.84,3.6-4.56,0-2.38-1.22-4.41-3.55-4.41-1.45,0-2.82,1.03-3.26,2.64-.07,.27-.12,.56-.12,.91v2.11Z"/>
<path class="cls-2" d="M124.1,97.47c.05,2.91,1.89,4.11,4.07,4.11,1.54,0,2.5-.27,3.28-.61l.39,1.54c-.76,.34-2.08,.76-3.97,.76-3.65,0-5.83-2.42-5.83-6s2.11-6.37,5.56-6.37c3.89,0,4.9,3.38,4.9,5.56,0,.44-.02,.76-.07,1h-8.33Zm6.32-1.54c.02-1.35-.56-3.48-2.99-3.48-2.2,0-3.13,1.98-3.31,3.48h6.29Z"/>
<path class="cls-2" d="M134.92,94.35c0-1.25-.02-2.23-.1-3.21h1.91l.12,1.96h.05c.59-1.1,1.96-2.2,3.92-2.2,1.64,0,4.19,.98,4.19,5.04v7.05h-2.16v-6.83c0-1.91-.71-3.5-2.74-3.5-1.4,0-2.5,1-2.89,2.2-.1,.27-.15,.64-.15,1v7.13h-2.16v-8.65Z"/>
<path class="cls-2" d="M149.47,97.47c.05,2.91,1.89,4.11,4.07,4.11,1.54,0,2.5-.27,3.28-.61l.39,1.54c-.76,.34-2.08,.76-3.97,.76-3.65,0-5.83-2.42-5.83-6s2.11-6.37,5.56-6.37c3.89,0,4.9,3.38,4.9,5.56,0,.44-.02,.76-.07,1h-8.33Zm6.32-1.54c.02-1.35-.56-3.48-2.99-3.48-2.2,0-3.13,1.98-3.31,3.48h6.29Z"/>
<path class="cls-2" d="M160,100.82c.66,.39,1.79,.83,2.87,.83,1.54,0,2.28-.76,2.28-1.76s-.61-1.57-2.18-2.16c-2.15-.78-3.16-1.93-3.16-3.35,0-1.91,1.57-3.48,4.09-3.48,1.2,0,2.25,.32,2.89,.73l-.51,1.57c-.47-.29-1.32-.71-2.42-.71-1.27,0-1.96,.74-1.96,1.62,0,.98,.69,1.42,2.23,2.03,2.03,.76,3.11,1.79,3.11,3.55,0,2.11-1.64,3.58-4.41,3.58-1.3,0-2.5-.34-3.33-.83l.51-1.62Z"/>
<path class="cls-2" d="M169.46,100.82c.66,.39,1.79,.83,2.87,.83,1.54,0,2.28-.76,2.28-1.76s-.61-1.57-2.18-2.16c-2.15-.78-3.16-1.93-3.16-3.35,0-1.91,1.57-3.48,4.09-3.48,1.2,0,2.25,.32,2.89,.73l-.51,1.57c-.47-.29-1.32-.71-2.42-.71-1.27,0-1.96,.74-1.96,1.62,0,.98,.69,1.42,2.23,2.03,2.03,.76,3.11,1.79,3.11,3.55,0,2.11-1.64,3.58-4.41,3.58-1.3,0-2.5-.34-3.33-.83l.51-1.62Z"/>
<path class="cls-2" d="M180.4,97.47c.05,2.91,1.89,4.11,4.07,4.11,1.54,0,2.5-.27,3.28-.61l.39,1.54c-.76,.34-2.08,.76-3.97,.76-3.65,0-5.83-2.42-5.83-6s2.11-6.37,5.56-6.37c3.89,0,4.9,3.38,4.9,5.56,0,.44-.02,.76-.07,1h-8.33Zm6.32-1.54c.02-1.35-.56-3.48-2.99-3.48-2.2,0-3.13,1.98-3.31,3.48h6.29Z"/>
<path class="cls-2" d="M191.23,94.84c0-1.4-.02-2.6-.1-3.7h1.89l.1,2.35h.07c.54-1.59,1.86-2.6,3.31-2.6,.22,0,.39,.03,.59,.05v2.03c-.22-.05-.44-.05-.73-.05-1.52,0-2.6,1.13-2.89,2.74-.05,.29-.07,.66-.07,1v6.32h-2.16v-8.16Z"/>
<path class="cls-2" d="M199.95,97.47c.05,2.91,1.89,4.11,4.07,4.11,1.54,0,2.5-.27,3.28-.61l.39,1.54c-.76,.34-2.08,.76-3.97,.76-3.65,0-5.83-2.42-5.83-6s2.11-6.37,5.56-6.37c3.89,0,4.9,3.38,4.9,5.56,0,.44-.02,.76-.07,1h-8.33Zm6.32-1.54c.02-1.35-.56-3.48-2.99-3.48-2.2,0-3.13,1.98-3.31,3.48h6.29Z"/>
<path class="cls-2" d="M214.82,96.58c-.02-1.42,1.15-2.57,2.57-2.57s2.57,1.13,2.57,2.57-1.15,2.57-2.6,2.57-2.55-1.13-2.55-2.57Z"/>
<path class="cls-2" d="M227.33,85.61h2.16v17.39h-2.16v-17.39Z"/>
<path class="cls-2" d="M241.2,100.16c0,1.03,.05,2.03,.17,2.84h-1.93l-.17-1.49h-.07c-.66,.93-1.93,1.76-3.62,1.76-2.4,0-3.62-1.69-3.62-3.4,0-2.87,2.55-4.43,7.13-4.41v-.25c0-.96-.27-2.74-2.69-2.72-1.13,0-2.28,.32-3.11,.88l-.49-1.45c.98-.61,2.42-1.03,3.92-1.03,3.62,0,4.51,2.47,4.51,4.83v4.43Zm-2.08-3.21c-2.35-.05-5.02,.37-5.02,2.67,0,1.42,.93,2.06,2.01,2.06,1.57,0,2.57-.98,2.91-1.98,.07-.25,.1-.49,.1-.69v-2.06Z"/>
<path class="cls-2" d="M249.38,94.35c0-1.25-.02-2.23-.1-3.21h1.91l.12,1.96h.05c.59-1.1,1.96-2.2,3.92-2.2,1.64,0,4.19,.98,4.19,5.04v7.05h-2.16v-6.83c0-1.91-.71-3.5-2.74-3.5-1.4,0-2.5,1-2.89,2.2-.1,.27-.15,.64-.15,1v7.13h-2.16v-8.65Z"/>
<path class="cls-2" d="M273.45,96.98c0,4.38-3.06,6.29-5.9,6.29-3.18,0-5.68-2.35-5.68-6.1,0-3.94,2.62-6.27,5.88-6.27s5.71,2.47,5.71,6.07Zm-9.38,.12c0,2.6,1.47,4.56,3.58,4.56s3.6-1.94,3.6-4.6c0-2.01-1-4.53-3.55-4.53s-3.62,2.35-3.62,4.58Z"/>
<path class="cls-2" d="M275.63,100.82c.66,.39,1.79,.83,2.87,.83,1.54,0,2.28-.76,2.28-1.76s-.61-1.57-2.18-2.16c-2.16-.78-3.16-1.93-3.16-3.35,0-1.91,1.57-3.48,4.09-3.48,1.2,0,2.25,.32,2.89,.73l-.51,1.57c-.47-.29-1.32-.71-2.42-.71-1.27,0-1.96,.74-1.96,1.62,0,.98,.69,1.42,2.23,2.03,2.03,.76,3.11,1.79,3.11,3.55,0,2.11-1.64,3.58-4.41,3.58-1.3,0-2.5-.34-3.33-.83l.51-1.62Z"/>
<path class="cls-2" d="M287.98,88.31v2.84h3.09v1.64h-3.09v6.39c0,1.47,.42,2.3,1.62,2.3,.59,0,.93-.05,1.25-.15l.1,1.64c-.42,.15-1.08,.29-1.91,.29-1,0-1.81-.34-2.33-.91-.59-.66-.83-1.71-.83-3.11v-6.46h-1.84v-1.64h1.84v-2.2l2.11-.64Z"/>
<path class="cls-2" d="M293.24,94.84c0-1.4-.02-2.6-.1-3.7h1.89l.1,2.35h.07c.54-1.59,1.86-2.6,3.31-2.6,.22,0,.39,.03,.59,.05v2.03c-.22-.05-.44-.05-.73-.05-1.52,0-2.6,1.13-2.89,2.74-.05,.29-.07,.66-.07,1v6.32h-2.16v-8.16Z"/>
<path class="cls-2" d="M309.21,100.16c0,1.03,.05,2.03,.17,2.84h-1.93l-.17-1.49h-.07c-.66,.93-1.93,1.76-3.62,1.76-2.4,0-3.62-1.69-3.62-3.4,0-2.87,2.55-4.43,7.13-4.41v-.25c0-.96-.27-2.74-2.69-2.72-1.13,0-2.28,.32-3.11,.88l-.49-1.45c.98-.61,2.42-1.03,3.92-1.03,3.62,0,4.51,2.47,4.51,4.83v4.43Zm-2.08-3.21c-2.35-.05-5.02,.37-5.02,2.67,0,1.42,.93,2.06,2.01,2.06,1.57,0,2.57-.98,2.91-1.98,.07-.25,.1-.49,.1-.69v-2.06Z"/>
<path class="cls-2" d="M317.39,95.02c0-1.52-.05-2.74-.1-3.87h1.91l.12,2.03h.05c.86-1.44,2.28-2.28,4.21-2.28,2.89,0,5.05,2.42,5.05,6,0,4.26-2.62,6.37-5.41,6.37-1.57,0-2.94-.69-3.65-1.86h-.05v6.44h-2.13v-12.83Zm2.13,3.16c0,.32,.02,.61,.1,.88,.39,1.49,1.69,2.52,3.23,2.52,2.28,0,3.6-1.86,3.6-4.58,0-2.35-1.25-4.38-3.53-4.38-1.47,0-2.87,1.03-3.26,2.64-.07,.27-.15,.59-.15,.86v2.06Z"/>
<path class="cls-2" d="M331.09,94.84c0-1.4-.02-2.6-.1-3.7h1.89l.1,2.35h.07c.54-1.59,1.86-2.6,3.31-2.6,.22,0,.39,.03,.59,.05v2.03c-.22-.05-.44-.05-.73-.05-1.52,0-2.6,1.13-2.89,2.74-.05,.29-.07,.66-.07,1v6.32h-2.16v-8.16Z"/>
<path class="cls-2" d="M349.33,96.98c0,4.38-3.06,6.29-5.9,6.29-3.18,0-5.68-2.35-5.68-6.1,0-3.94,2.62-6.27,5.88-6.27s5.71,2.47,5.71,6.07Zm-9.38,.12c0,2.6,1.47,4.56,3.58,4.56s3.6-1.94,3.6-4.6c0-2.01-1-4.53-3.55-4.53s-3.62,2.35-3.62,4.58Z"/>
<path class="cls-2" d="M352.03,103v-10.21h-1.64v-1.64h1.64v-.56c0-1.66,.39-3.18,1.37-4.14,.81-.78,1.89-1.1,2.89-1.1,.78,0,1.42,.17,1.84,.34l-.29,1.66c-.32-.15-.73-.27-1.37-.27-1.84,0-2.3,1.59-2.3,3.43v.64h2.87v1.64h-2.87v10.21h-2.13Z"/>
<path class="cls-2" d="M359.59,97.47c.05,2.91,1.89,4.11,4.07,4.11,1.54,0,2.5-.27,3.28-.61l.39,1.54c-.76,.34-2.08,.76-3.97,.76-3.65,0-5.83-2.42-5.83-6s2.11-6.37,5.56-6.37c3.89,0,4.9,3.38,4.9,5.56,0,.44-.02,.76-.07,1h-8.33Zm6.32-1.54c.02-1.35-.56-3.48-2.99-3.48-2.2,0-3.13,1.98-3.31,3.48h6.29Z"/>
<path class="cls-2" d="M370.13,100.82c.66,.39,1.79,.83,2.87,.83,1.54,0,2.28-.76,2.28-1.76s-.61-1.57-2.18-2.16c-2.16-.78-3.16-1.93-3.16-3.35,0-1.91,1.57-3.48,4.09-3.48,1.2,0,2.25,.32,2.89,.73l-.51,1.57c-.47-.29-1.32-.71-2.42-.71-1.27,0-1.96,.74-1.96,1.62,0,.98,.69,1.42,2.23,2.03,2.03,.76,3.11,1.79,3.11,3.55,0,2.11-1.64,3.58-4.41,3.58-1.3,0-2.5-.34-3.33-.83l.51-1.62Z"/>
<path class="cls-2" d="M379.58,100.82c.66,.39,1.79,.83,2.87,.83,1.54,0,2.28-.76,2.28-1.76s-.61-1.57-2.18-2.16c-2.16-.78-3.16-1.93-3.16-3.35,0-1.91,1.57-3.48,4.09-3.48,1.2,0,2.25,.32,2.89,.73l-.51,1.57c-.47-.29-1.32-.71-2.42-.71-1.27,0-1.96,.74-1.96,1.62,0,.98,.69,1.42,2.23,2.03,2.03,.76,3.11,1.79,3.11,3.55,0,2.11-1.64,3.58-4.41,3.58-1.3,0-2.5-.34-3.33-.83l.51-1.62Z"/>
<path class="cls-2" d="M391.73,87.82c0,.74-.51,1.32-1.37,1.32-.78,0-1.3-.59-1.3-1.32s.54-1.35,1.35-1.35,1.32,.59,1.32,1.35Zm-2.4,15.18v-11.85h2.16v11.85h-2.16Z"/>
<path class="cls-2" d="M405.54,96.98c0,4.38-3.06,6.29-5.9,6.29-3.18,0-5.68-2.35-5.68-6.1,0-3.94,2.62-6.27,5.88-6.27s5.71,2.47,5.71,6.07Zm-9.38,.12c0,2.6,1.47,4.56,3.58,4.56s3.6-1.94,3.6-4.6c0-2.01-1-4.53-3.55-4.53s-3.62,2.35-3.62,4.58Z"/>
<path class="cls-2" d="M408.02,94.35c0-1.25-.02-2.23-.1-3.21h1.91l.12,1.96h.05c.59-1.1,1.96-2.2,3.92-2.2,1.64,0,4.19,.98,4.19,5.04v7.05h-2.16v-6.83c0-1.91-.71-3.5-2.74-3.5-1.4,0-2.5,1-2.89,2.2-.1,.27-.15,.64-.15,1v7.13h-2.16v-8.65Z"/>
<path class="cls-2" d="M422.56,97.47c.05,2.91,1.89,4.11,4.07,4.11,1.54,0,2.5-.27,3.28-.61l.39,1.54c-.76,.34-2.08,.76-3.97,.76-3.65,0-5.83-2.42-5.83-6s2.11-6.37,5.56-6.37c3.89,0,4.9,3.38,4.9,5.56,0,.44-.02,.76-.07,1h-8.33Zm6.32-1.54c.02-1.35-.56-3.48-2.99-3.48-2.2,0-3.13,1.98-3.31,3.48h6.29Z"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 20 KiB

69
init.sql Normal file
View File

@@ -0,0 +1,69 @@
-- Init script per il container PostgreSQL del booking-service
-- Gira automaticamente al primo avvio
CREATE TABLE IF NOT EXISTS services (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
duration_min INT NOT NULL DEFAULT 15,
description TEXT,
category TEXT DEFAULT 'generale',
active BOOLEAN DEFAULT true,
sort_order INT DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS providers (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
email TEXT,
phone TEXT,
google_calendar_id TEXT,
active BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS provider_services (
id SERIAL PRIMARY KEY,
provider_id INT REFERENCES providers(id) ON DELETE CASCADE,
service_id INT REFERENCES services(id) ON DELETE CASCADE,
availability_rules JSONB NOT NULL DEFAULT '[]',
UNIQUE(provider_id, service_id)
);
CREATE TABLE IF NOT EXISTS bookings (
id SERIAL PRIMARY KEY,
service_id INT REFERENCES services(id),
provider_id INT REFERENCES providers(id),
customer_name TEXT NOT NULL,
customer_phone TEXT NOT NULL,
customer_email TEXT,
start_at TIMESTAMPTZ NOT NULL,
end_at TIMESTAMPTZ NOT NULL,
status TEXT DEFAULT 'confirmed' CHECK (status IN ('confirmed','cancelled','completed','no_show')),
google_event_id TEXT,
notes TEXT,
reminder_sent BOOLEAN DEFAULT false,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_bookings_date ON bookings(start_at);
CREATE INDEX IF NOT EXISTS idx_bookings_phone ON bookings(customer_phone);
CREATE INDEX IF NOT EXISTS idx_bookings_status ON bookings(status);
CREATE INDEX IF NOT EXISTS idx_bookings_provider ON bookings(provider_id, start_at);
-- Dati di esempio (da sostituire con i servizi reali dopo la riunione)
INSERT INTO providers (name, email, phone) VALUES
('Farmacia Ianni', 'e-shop@farmaciaianni.it', '+39 393 0579002');
INSERT INTO services (name, slug, duration_min, description, category, sort_order) VALUES
('Misurazione pressione', 'pressione', 10, 'Misurazione della pressione arteriosa', 'diagnostica', 1),
('Test glicemia', 'glicemia', 10, 'Test rapido della glicemia', 'diagnostica', 2),
('Holter pressorio 24h', 'holter', 30, 'Installazione e ritiro holter pressorio', 'diagnostica', 3),
('Consulenza dermocosmesi', 'dermocosmesi', 20, 'Consulenza personalizzata prodotti dermocosmesi', 'consulenza', 4),
('Preparazione galenica', 'galenica', 15, 'Preparazione magistrale su richiesta medica', 'galenica', 5);
-- Assegna tutti i servizi al provider di default con orari standard farmacia
INSERT INTO provider_services (provider_id, service_id, availability_rules)
SELECT 1, id, '[{"weekday":0,"start":"09:00","end":"13:00"},{"weekday":0,"start":"15:30","end":"19:30"},{"weekday":1,"start":"09:00","end":"13:00"},{"weekday":1,"start":"15:30","end":"19:30"},{"weekday":2,"start":"09:00","end":"13:00"},{"weekday":2,"start":"15:30","end":"19:30"},{"weekday":3,"start":"09:00","end":"13:00"},{"weekday":3,"start":"15:30","end":"19:30"},{"weekday":4,"start":"09:00","end":"13:00"},{"weekday":4,"start":"15:30","end":"19:30"},{"weekday":5,"start":"09:00","end":"13:00"}]'::jsonb
FROM services;

29
nginx-booking.conf Normal file
View File

@@ -0,0 +1,29 @@
server {
listen 80;
server_name booking.scan360.app;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl;
server_name booking.scan360.app;
ssl_certificate /etc/letsencrypt/live/booking.scan360.app/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/booking.scan360.app/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
location / {
proxy_pass http://127.0.0.1:8020;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}

15
requirements.txt Normal file
View File

@@ -0,0 +1,15 @@
fastapi>=0.115.0
uvicorn>=0.31.1
sqlalchemy>=2.0.35
psycopg2-binary>=2.9.9
pydantic>=2.11.0
pydantic-settings>=2.10.1
google-api-python-client>=2.149.0
google-auth-oauthlib>=1.2.1
httpx>=0.28.1
jinja2>=3.1.4
python-multipart>=0.0.12
apscheduler>=3.10.4
authlib>=1.3.0
python-jose[cryptography]>=3.3.0
itsdangerous>=2.1.0

13
wa-service/Dockerfile Normal file
View File

@@ -0,0 +1,13 @@
FROM node:20-slim
RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY package.json .
RUN npm install --omit=dev
COPY server.js .
RUN mkdir -p /data/auth
EXPOSE 3100
CMD ["node", "server.js"]

12
wa-service/package.json Normal file
View File

@@ -0,0 +1,12 @@
{
"name": "booking-wa",
"version": "1.0.0",
"private": true,
"scripts": { "start": "node server.js" },
"dependencies": {
"@whiskeysockets/baileys": "^6.7.16",
"express": "^4.21.0",
"pino": "^9.6.0",
"qrcode": "^1.5.4"
}
}

194
wa-service/server.js Normal file
View File

@@ -0,0 +1,194 @@
/**
* BookingWA — Servizio WhatsApp Baileys per Farmacia Ianni
* Espone REST: GET /status, GET /qr, POST /send, POST /disconnect
* Sessione persistente in /data/auth per sopravvivere ai restart.
*/
const express = require('express');
const { default: makeWASocket, useMultiFileAuthState, DisconnectReason, fetchLatestBaileysVersion } = require('@whiskeysockets/baileys');
const qrcode = require('qrcode');
const pino = require('pino');
const app = express();
app.use(express.json());
const PORT = process.env.PORT || 3100;
const AUTH_DIR = '/data/auth';
const logger = pino({ level: 'warn' });
let sock = null;
let currentQR = null;
let qrGeneratedAt = null;
let qrAttempt = 0;
let connectionState = 'disconnected';
let phoneNumber = null;
let retryCount = 0;
const MAX_RETRIES = 5;
const QR_TTL_SEC = 20;
async function startSocket() {
const { state, saveCreds } = await useMultiFileAuthState(AUTH_DIR);
const { version } = await fetchLatestBaileysVersion();
sock = makeWASocket({
version,
auth: state,
logger,
printQRInTerminal: false,
browser: ['Farmacia Ianni', 'Chrome', '120.0'],
connectTimeoutMs: 30000,
defaultQueryTimeoutMs: 30000,
generateHighQualityLinkPreview: false,
syncFullHistory: false,
qrTimeout: 60000,
});
sock.ev.on('creds.update', saveCreds);
sock.ev.on('connection.update', async (update) => {
const { connection, lastDisconnect, qr } = update;
if (qr) {
currentQR = await qrcode.toDataURL(qr, { width: 280, margin: 2 });
qrGeneratedAt = Date.now();
qrAttempt++;
connectionState = 'connecting';
console.log(`[WA] QR #${qrAttempt} generato — scade tra ${QR_TTL_SEC}s`);
}
if (connection === 'close') {
currentQR = null;
qrGeneratedAt = null;
const statusCode = lastDisconnect?.error?.output?.statusCode;
const shouldReconnect = statusCode !== DisconnectReason.loggedOut;
if (statusCode === DisconnectReason.loggedOut) {
connectionState = 'disconnected';
phoneNumber = null;
retryCount = 0;
qrAttempt = 0;
console.log('[WA] Disconnesso (logout). Serve nuovo QR.');
const fs = require('fs');
fs.rmSync(AUTH_DIR, { recursive: true, force: true });
fs.mkdirSync(AUTH_DIR, { recursive: true });
} else if (shouldReconnect && retryCount < MAX_RETRIES) {
retryCount++;
const delay = Math.min(retryCount * 2000, 15000);
console.log(`[WA] Riconnessione ${retryCount}/${MAX_RETRIES} in ${delay}ms...`);
connectionState = 'connecting';
setTimeout(startSocket, delay);
} else {
connectionState = 'disconnected';
console.log('[WA] Connessione persa, max retry raggiunto.');
}
}
if (connection === 'open') {
connectionState = 'connected';
currentQR = null;
qrGeneratedAt = null;
retryCount = 0;
qrAttempt = 0;
phoneNumber = sock.user?.id?.split(':')[0] || sock.user?.id;
console.log(`[WA] Connesso come ${phoneNumber}`);
}
});
}
// ── API ──
app.get('/status', (req, res) => {
res.json({
connected: connectionState === 'connected',
state: connectionState,
phone: phoneNumber,
message: connectionState === 'connected'
? `Connesso come ${phoneNumber}`
: connectionState === 'connecting'
? 'In attesa di scansione QR...'
: 'Disconnesso'
});
});
app.get('/qr', (req, res) => {
if (connectionState === 'connected') {
return res.json({ qr: null, expired: false, message: 'Già connesso' });
}
if (currentQR && qrGeneratedAt) {
const elapsed = (Date.now() - qrGeneratedAt) / 1000;
const remaining = Math.max(0, Math.round(QR_TTL_SEC - elapsed));
if (remaining <= 0) {
return res.json({
qr: null,
expired: true,
attempt: qrAttempt,
message: 'QR scaduto — in attesa del prossimo...'
});
}
return res.json({
qr: currentQR,
expired: false,
expires_in: remaining,
ttl: QR_TTL_SEC,
attempt: qrAttempt,
});
}
// Se non c'è QR e non siamo connessi, avvia socket
if (connectionState === 'disconnected') {
startSocket().catch(console.error);
return res.json({ qr: null, expired: false, message: 'Avvio connessione... riprova tra 5 secondi' });
}
res.json({ qr: null, expired: false, message: 'QR in generazione...' });
});
app.post('/send', async (req, res) => {
const { to, text } = req.body;
if (!to || !text) return res.status(400).json({ error: 'Servono "to" e "text"' });
if (connectionState !== 'connected' || !sock) return res.status(503).json({ error: 'WhatsApp non connesso' });
try {
let jid = to.replace(/[^0-9]/g, '');
if (!jid.endsWith('@s.whatsapp.net')) jid = jid + '@s.whatsapp.net';
await sock.sendMessage(jid, { text });
console.log(`[WA] Messaggio inviato a ${to}`);
res.json({ ok: true, to, message: 'Inviato' });
} catch (e) {
console.error('[WA] Errore invio:', e.message);
res.status(500).json({ error: e.message });
}
});
app.post('/disconnect', async (req, res) => {
try {
if (sock) { await sock.logout(); sock = null; }
connectionState = 'disconnected';
phoneNumber = null;
currentQR = null;
qrGeneratedAt = null;
qrAttempt = 0;
res.json({ ok: true, message: 'Disconnesso' });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
app.get('/health', (req, res) => {
res.json({ status: 'ok', service: 'booking-wa', state: connectionState });
});
// ── START ──
app.listen(PORT, '0.0.0.0', () => {
console.log(`[WA] Servizio avviato su :${PORT}`);
const fs = require('fs');
if (fs.existsSync(AUTH_DIR) && fs.readdirSync(AUTH_DIR).length > 0) {
console.log('[WA] Sessione trovata, riconnessione...');
startSocket().catch(console.error);
} else {
console.log('[WA] Nessuna sessione. Chiama GET /qr per iniziare.');
fs.mkdirSync(AUTH_DIR, { recursive: true });
}
});