v1.3.0 — pannello admin completo, auth localStorage, Baileys WA, customers, calendario, paginazione, dashboard 7gg
This commit is contained in:
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.env
|
||||
*.log
|
||||
wa-service/node_modules/
|
||||
13
Dockerfile
Normal file
13
Dockerfile
Normal 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
0
app/__init__.py
Normal file
52
app/config.py
Normal file
52
app/config.py
Normal 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
19
app/database.py
Normal 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
55
app/main.py
Normal 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
71
app/models.py
Normal 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
0
app/routers/__init__.py
Normal file
258
app/routers/admin.py
Normal file
258
app/routers/admin.py
Normal 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
135
app/routers/auth.py
Normal 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
161
app/routers/customers.py
Normal 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
113
app/routers/public.py
Normal 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
85
app/routers/settings.py
Normal 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
90
app/schemas.py
Normal 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
0
app/services/__init__.py
Normal file
100
app/services/availability.py
Normal file
100
app/services/availability.py
Normal 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
|
||||
114
app/services/notifications.py
Normal file
114
app/services/notifications.py
Normal 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
52
docker-compose.yml
Normal 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
773
frontend/admin.html
Normal 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
5
frontend/favicon.svg
Normal 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
339
frontend/index.html
Normal 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
150
frontend/logo-white.svg
Normal 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
BIN
frontend/logo.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
BIN
frontend/logo.png
Normal file
BIN
frontend/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.1 KiB |
150
frontend/logo.svg
Normal file
150
frontend/logo.svg
Normal 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
69
init.sql
Normal 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
29
nginx-booking.conf
Normal 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
15
requirements.txt
Normal 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
13
wa-service/Dockerfile
Normal 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
12
wa-service/package.json
Normal 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
194
wa-service/server.js
Normal 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 });
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user