Panoramica progetto
Descrizione
PrenoTaglio Γ¨ una Progressive Web App multi-tenant per la gestione delle prenotazioni di saloni parrucchieri. Ogni salone (tenant) ha un proprio slug, una propria pagina di prenotazione pubblica per i clienti e un pannello di amministrazione dedicato. L'intera applicazione gira su hosting condiviso Hostinger senza necessitΓ di Node.js o WebSocket.
Architettura multi-tenant
Il sistema supporta piΓΉ saloni sulla stessa installazione. Ogni tenant Γ¨ identificato dal proprio slug univoco nella URL. La risoluzione del tenant avviene lato PHP tramite il file tenant.php prima di ogni request.
Struttura file sul server
I file sono organizzati in due posizioni distinte: gli include (file PHP sensibili fuori dalla webroot) e il public_html (file accessibili via web).
/domains/mydigitaltools.it/ βββ include/ β βββ prenotataglio/ β βββ config.php β credenziali DB, JWT secret, costanti app β βββ db.php β PDO singleton connection β βββ jwt.php β implementazione JWT HS256 β βββ auth.php β middleware autenticazione β βββ functions.php β helper (uuid, jsonResponse, generateSlots, ecc.) β βββ tenant.php β risoluzione tenant da slug βββ public_html/ βββ prenotataglio/ β document root sottodominio βββ .htaccess β URL rewriting + sicurezza βββ bootstrap.php β carica tutti gli include βββ index.php β entry point cliente SPA βββ manifest.php β PWA manifest dinamico (cliente) βββ sw.js β Service Worker βββ offline.html β fallback offline βββ install.sql β schema DB (DA ELIMINARE dopo import) βββ api/ β βββ .htaccess β βββ auth/ β β βββ register.php β β βββ login.php β β βββ logout.php β β βββ refresh.php β β βββ admin-login.php β β βββ admin-logout.php β β βββ admin-refresh.php β βββ servizi.php β βββ disponibilita.php β βββ prenotazioni.php β βββ admin/ β βββ dashboard.php β βββ servizi.php β βββ orari.php β βββ chiusure.php β βββ prenotazioni.php β βββ operatori.php β βββ clienti.php β βββ tenants.php βββ admin/ β βββ index.php β admin SPA β βββ manifest.php β PWA manifest dinamico (admin) βββ setup/ β βββ index.php β wizard primo setup (DA ELIMINARE dopo uso) βββ assets/ βββ css/ β βββ app.css β βββ admin.css βββ js/ β βββ app.js β βββ booking.js β βββ admin.js βββ img/ β βββ generate-icons.php β DA ELIMINARE dopo uso β βββ icon-192.png β βββ icon-512.png βββ uploads/ βββ servizi/ β foto servizi caricati
setup/, install.sql, generate-icons.php) devono essere eliminati dal server dopo l'uso per ragioni di sicurezza.
Schema Database
Database: u568594947_prenotataglio β Utente MySQL: u568594947_prenotataglio
Tabella: tenants
| Colonna | Tipo | Note |
|---|---|---|
| id | CHAR(36) | UUID, PRIMARY KEY |
| slug | VARCHAR(50) | UNIQUE β usato nelle URL (es. elite-parrucchieri) |
| nome | VARCHAR(150) | Nome visualizzato del salone |
| VARCHAR(150) | Email di contatto | |
| telefono | VARCHAR(20) | Numero di telefono |
| indirizzo | TEXT | Indirizzo fisico |
| colore_primario | VARCHAR(7) | Hex colore tema (es. #1a1a2e) |
| colore_secondario | VARCHAR(7) | Hex colore secondario |
| logo_url | VARCHAR(255) | Percorso logo caricato |
| piano | ENUM | 'base', 'pro', 'enterprise' |
| attivo | TINYINT(1) | 1=attivo, 0=disabilitato |
| created_at | DATETIME | Data creazione |
Tabella: admin_users
| Colonna | Tipo | Note |
|---|---|---|
| id | CHAR(36) | UUID, PRIMARY KEY |
| tenant_id | CHAR(36) | FK β tenants.id |
| nome | VARCHAR(100) | |
| cognome | VARCHAR(100) | |
| VARCHAR(150) | UNIQUE per tenant | |
| password_hash | VARCHAR(255) | Argon2ID hash |
| role | ENUM | 'superadmin', 'admin', 'operatore' |
| attivo | TINYINT(1) | |
| created_at | DATETIME |
Tabella: clienti
| Colonna | Tipo | Note |
|---|---|---|
| id | CHAR(36) | UUID, PRIMARY KEY |
| tenant_id | CHAR(36) | FK β tenants.id |
| nome | VARCHAR(100) | |
| cognome | VARCHAR(100) | |
| VARCHAR(150) | UNIQUE per tenant | |
| telefono | VARCHAR(20) | |
| password_hash | VARCHAR(255) | Argon2ID hash |
| attivo | TINYINT(1) | |
| created_at | DATETIME |
Tabella: operatori
| Colonna | Tipo | Note |
|---|---|---|
| id | CHAR(36) | UUID, PRIMARY KEY |
| tenant_id | CHAR(36) | FK β tenants.id |
| nome | VARCHAR(100) | |
| cognome | VARCHAR(100) | |
| colore | VARCHAR(7) | Colore hex per calendario |
| attivo | TINYINT(1) | |
| created_at | DATETIME |
Tabella: servizi
| Colonna | Tipo | Note |
|---|---|---|
| id | CHAR(36) | UUID, PRIMARY KEY |
| tenant_id | CHAR(36) | FK β tenants.id |
| nome | VARCHAR(150) | Nome del servizio |
| descrizione | TEXT | |
| durata_minuti | INT | Durata in minuti (es. 30, 45, 60) |
| prezzo | DECIMAL(8,2) | Prezzo in euro |
| foto_url | VARCHAR(255) | Percorso foto in uploads/servizi/ |
| attivo | TINYINT(1) | |
| ordinamento | INT | Ordine visualizzazione |
| created_at | DATETIME |
Tabella: config_orari
Orari di apertura per giorno della settimana. 0 = Domenica, 1 = Lunedì, ..., 6 = Sabato (convenzione PHP/MySQL DAYOFWEEK).
| Colonna | Tipo | Note |
|---|---|---|
| id | CHAR(36) | UUID, PRIMARY KEY |
| tenant_id | CHAR(36) | FK β tenants.id |
| giorno_settimana | TINYINT | 0=Dom, 1=Lun, 2=Mar, 3=Mer, 4=Gio, 5=Ven, 6=Sab |
| aperto | TINYINT(1) | 1=aperto, 0=chiuso |
| ora_apertura | TIME | Es. 09:00:00 |
| ora_chiusura | TIME | Es. 18:00:00 |
| durata_slot | INT | Minuti per slot (es. 20, 30) |
| created_at | DATETIME |
Tabella: chiusure
| Colonna | Tipo | Note |
|---|---|---|
| id | CHAR(36) | UUID, PRIMARY KEY |
| tenant_id | CHAR(36) | FK β tenants.id |
| data_inizio | DATE | Inizio periodo chiusura |
| data_fine | DATE | Fine periodo chiusura |
| motivo | VARCHAR(255) | Descrizione (es. "Ferie agosto") |
| created_at | DATETIME |
Tabella: prenotazioni
| Colonna | Tipo | Note |
|---|---|---|
| id | CHAR(36) | UUID, PRIMARY KEY |
| tenant_id | CHAR(36) | FK β tenants.id |
| cliente_id | CHAR(36) | FK β clienti.id |
| servizio_id | CHAR(36) | FK β servizi.id |
| operatore_id | CHAR(36) | FK β operatori.id (nullable) |
| data_ora | DATETIME | Data e ora appuntamento |
| durata_minuti | INT | Snapshot durata al momento prenotazione |
| prezzo | DECIMAL(8,2) | Snapshot prezzo al momento prenotazione |
| stato | ENUM | 'in_attesa', 'confermata', 'annullata', 'completata' |
| note | TEXT | Note del cliente |
| created_at | DATETIME |
Tabella: sessioni e sessioni_admin
Refresh token per clienti e admin rispettivamente. Stessa struttura:
| Colonna | Tipo | Note |
|---|---|---|
| id | CHAR(36) | UUID, PRIMARY KEY |
| utente_id | CHAR(36) | FK β clienti.id oppure admin_users.id |
| tenant_id | CHAR(36) | FK β tenants.id |
| refresh_token | VARCHAR(255) | Token SHA256 opaco |
| scadenza | DATETIME | Scadenza refresh token (default 30 giorni) |
| ip | VARCHAR(45) | IP client al momento del login |
| user_agent | TEXT | Browser/device |
| created_at | DATETIME |
Setup iniziale passo per passo
Creare il sottodominio in hPanel
Accedi a hPanel β Domains β Subdomains e crea:
| Campo | Valore |
|---|---|
| Sottodominio | prenotataglio |
| Dominio base | mydigitaltools.it |
| Document Root | domains/mydigitaltools.it/public_html/prenotataglio |
Configurare config.php
Modifica il file /domains/mydigitaltools.it/include/prenotataglio/config.php:
<?php // Database define('DB_HOST', 'localhost'); define('DB_NAME', 'u568594947_prenotataglio'); define('DB_USER', 'u568594947_prenotataglio'); define('DB_PASS', 'LA_TUA_PASSWORD'); // Sicurezza define('JWT_SECRET', 'stringa-random-64-chars-cambia-con-valore-sicuro'); // App define('APP_URL', 'https://prenotataglio.mydigitaltools.it'); define('APP_ENV', 'production'); // JWT durata define('JWT_EXPIRE', 900); // 15 minuti access token define('REFRESH_EXPIRE', 2592000); // 30 giorni refresh token
Caricare i file sul server
Usa il File Manager di hPanel o un client FTP (FileZilla, WinSCP):
| Cartella locale | Destinazione server |
|---|---|
include/prenotataglio/ | /domains/mydigitaltools.it/include/prenotataglio/ |
public_html/prenotataglio/ | /domains/mydigitaltools.it/public_html/prenotataglio/ |
assets/uploads/servizi/ abbia permessi 755 per consentire l'upload delle foto.Importare il database
- Vai su hPanel β Databases β phpMyAdmin
- Seleziona il database
u568594947_prenotataglio - Clicca su Importa nella barra superiore
- Scegli il file
install.sql - Clicca Esegui
install.sql. L'.htaccess lo blocca giΓ , ma Γ¨ preferibile rimuoverlo fisicamente.Generare le icone PWA
Visita questo URL nel browser per generare automaticamente le icone icon-192.png e icon-512.png:
https://prenotataglio.mydigitaltools.it/assets/img/generate-icons.php
assets/img/generate-icons.php dal server non appena le icone sono state generate.Setup primo tenant (wizard)
Visita l'URL del wizard di setup per creare il primo salone:
https://prenotataglio.mydigitaltools.it/setup/
Il wizard creerΓ automaticamente il tenant, l'admin e gli orari di default.
Verificare il file .htaccess
Il file .htaccess nella root del sottodominio deve contenere:
Options -Indexes RewriteEngine On RewriteBase / # Blocca accesso a file sensibili RewriteRule ^bootstrap\.php$ - [F,L] RewriteRule ^install\.sql$ - [F,L] # Servi file e directory esistenti direttamente RewriteCond %{REQUEST_FILENAME} -f [OR] RewriteCond %{REQUEST_FILENAME} -d RewriteRule ^ - [L] # Cartelle di sistema β non trattare come slug tenant RewriteRule ^(api|assets|setup|offline\.html|manifest\.json|manifest\.php|sw\.js)(.*)?$ - [L] # Admin tenant: /{slug}/admin RewriteCond %{REQUEST_FILENAME} !-f RewriteCond %{REQUEST_FILENAME} !-d RewriteRule ^([a-z0-9_-]+)/admin(/.*)?$ admin/index.php?tenant=$1 [QSA,L] # Homepage tenant: /{slug}/ RewriteCond %{REQUEST_FILENAME} !-f RewriteCond %{REQUEST_FILENAME} !-d RewriteRule ^([a-z0-9_-]+)/?$ index.php?tenant=$1 [QSA,L] # Sottopagine tenant: /{slug}/{path} RewriteCond %{REQUEST_FILENAME} !-f RewriteCond %{REQUEST_FILENAME} !-d RewriteRule ^([a-z0-9_-]+)/(.+)$ index.php?tenant=$1&path=$2 [QSA,L]
URL e Routing
| URL | Destinazione PHP | Descrizione |
|---|---|---|
prenotataglio.mydigitaltools.it/ | index.php (no tenant) | Landing page β lista saloni attivi |
prenotataglio.mydigitaltools.it/{slug}/ | index.php?tenant={slug} | SPA prenotazione cliente |
prenotataglio.mydigitaltools.it/{slug}/admin | admin/index.php?tenant={slug} | Pannello admin del salone |
prenotataglio.mydigitaltools.it/api/... | api/... (diretto) | REST API β non rewrittata |
prenotataglio.mydigitaltools.it/sw.js | sw.js (diretto) | Service Worker |
prenotataglio.mydigitaltools.it/{slug}/manifest.json | manifest.php?tenant={slug} | PWA Manifest cliente dinamico |
prenotataglio.mydigitaltools.it/{slug}/admin/manifest.json | admin/manifest.php?tenant={slug} | PWA Manifest admin dinamico |
prenotataglio.mydigitaltools.it/elite-parrucchieri/ β Admin: prenotataglio.mydigitaltools.it/elite-parrucchieri/admin
API Endpoints
Auth Cliente
Auth Admin
Endpoint Pubblici Cliente
Prenotazioni Cliente (autenticato)
Admin β Dashboard
Admin β Servizi
Admin β Orari
Admin β Chiusure
Admin β Prenotazioni
Admin β Operatori
Admin β Clienti
PWA β Dettagli implementazione
Due PWA installabili separatamente
La stessa origine serve due app installabili: una per il cliente e una per l'admin di ogni salone. Questo Γ¨ possibile grazie ai campi id e scope nel manifest.
| PWA | id | scope | start_url |
|---|---|---|---|
| Cliente | /pwa/client/{slug} | /{slug}/ | /{slug}/ |
| Admin | /pwa/admin/{slug} | /{slug}/admin | /{slug}/admin |
id del manifest per identificare univocamente una PWA. Senza ID diversi, lo stesso browser non permetterebbe di installare sia l'app cliente che quella admin dallo stesso dominio. Con scope diversi e ID univoci, entrambe possono essere installate e apparse come app separate nel launcher del dispositivo.
Manifest dinamico (manifest.php)
I manifest sono generati dinamicamente da PHP perchΓ© devono includere il nome del salone, i colori del tema e gli ID specifici per ogni tenant. Vengono serviti con header Content-Type: application/manifest+json.
Service Worker (sw.js) β Strategie di cache
| Risorsa | Strategia | Note |
|---|---|---|
| HTML/CSS/JS app shell | Cache First | Aggiornato a ogni nuovo deploy |
| Immagini | Cache First | TTL 30 giorni |
Chiamate API (/api/...) | Network Only | Dati sempre freschi |
| Offline fallback | β | Serve offline.html se rete non disponibile |
Real-time polling admin
Implementazione
Il pannello admin usa setInterval da 20 secondi per aggiornare i dati silenziosamente senza ricaricare la pagina.
Componenti con polling attivo
| Componente Vue | Azione al poll |
|---|---|
| AdminDashboard | Rileva nuove prenotazioni, mostra toast notifica |
| AdminCalendario | Chiama calendar.refetchEvents() |
| AdminPrenotazioni | Aggiorna lista silenziosamente (senza loader) |
Change detection via signature
// Genera una firma stringa dalla lista prenotazioni // Se la firma cambia β ci sono nuove prenotazioni o cambi di stato _signature(list) { return list.map(p => p.id + '|' + p.stato).join(','); }
Toast notification system
Il sistema di notifiche usa un array adminStore.toasts[] reattivo in Pinia/Vue.
// Mostra un toast β tipo: 'success' | 'error' | 'info' | 'warning' adminStore.showToast(message, type, duration) // Esempio β nuova prenotazione rilevata dal polling if (currentSig !== this.lastSignature) { adminStore.showToast('Nuova prenotazione ricevuta!', 'success', 4000); this.lastSignature = currentSig; }
Live indicator
Nel topbar del pannello admin Γ¨ presente un pallino verde pulsante con la scritta LIVE che indica che il polling Γ¨ attivo. Si colora di rosso e mostra OFFLINE in caso di errore di rete.
Bug risolti durante lo sviluppo
Bug 1: .htaccess catturava assets/ come slug tenant
Sintomo: Le richieste a /assets/css/app.css venivano reindirizzate a index.php?tenant=assets invece di servire il file CSS.
Causa: In Apache, le direttive RewriteCond si applicano solo alla RewriteRule immediatamente successiva. Le condizioni !-f e !-d scritte una volta prima di piΓΉ RewriteRule non proteggevano tutte le regole seguenti.
Soluzione: Aggiunta una regola esplicita per tutte le cartelle di sistema prima delle regole tenant, e le condizioni RewriteCond sono state ripetute prima di ogni RewriteRule che ne ha bisogno.
# Questa regola esplicita protegge le cartelle di sistema RewriteRule ^(api|assets|setup|offline\.html|manifest\.json|manifest\.php|sw\.js)(.*)?$ - [L] # Le RewriteCond devono essere ripetute per OGNI RewriteRule che ne ha bisogno RewriteCond %{REQUEST_FILENAME} !-f RewriteCond %{REQUEST_FILENAME} !-d RewriteRule ^([a-z0-9_-]+)/admin(/.*)?$ admin/index.php?tenant=$1 [QSA,L]
Bug 2: Calendario tutto occupato (nessuno slot disponibile)
Sintomo: L'API /api/disponibilita.php restituiva sempre zero slot liberi, anche per giorni senza prenotazioni.
Causa: SQLSTATE[HY093] Invalid parameter number β con PDO e ATTR_EMULATE_PREPARES = false, lo stesso named parameter (es. :data) non puΓ² essere usato piΓΉ di una volta nella stessa query preparata.
-- ERRATO: :data usato due volte WHERE data_inizio <= :data AND data_fine >= :data
-- CORRETTO: parametri con nomi distinti WHERE data_inizio <= :data1 AND data_fine >= :data2 // Binding PHP $stmt->execute([':data1' => $data, ':data2' => $data]);
Bug 3: PWA admin e cliente in conflitto sullo stesso device
Sintomo: Impossibile installare sia la PWA cliente che quella admin sullo stesso dispositivo. Il browser mostrava un solo prompt di installazione, o sovrascriveva quella giΓ installata.
Causa: Entrambi i manifest usavano "scope": "/" e non avevano il campo id esplicito. Il browser le considerava la stessa app.
Soluzione: Scope diversi per le due PWA e campo id univoco per ogni combinazione tenant/tipo:
"id": "/pwa/client/= $tenant['slug'] ?>", "scope": "/= $tenant['slug'] ?>/", "start_url": "/= $tenant['slug'] ?>/"
"id": "/pwa/admin/= $tenant['slug'] ?>", "scope": "/= $tenant['slug'] ?>/admin", "start_url": "/= $tenant['slug'] ?>/admin"
Aggiungere un nuovo tenant
Per aggiungere un nuovo salone al sistema, esegui queste query in phpMyAdmin sul database u568594947_prenotataglio.
-- 1. Inserisci il tenant INSERT INTO tenants (id, slug, nome, email, colore_primario, colore_secondario, piano, attivo, created_at) VALUES (UUID(), 'nuovo-salone', 'Nome Salone', 'email@salone.it', '#1a1a2e', '#16213e', 'base', 1, NOW()); -- 2. Recupera l'ID appena creato SELECT id FROM tenants WHERE slug = 'nuovo-salone'; -- 3. Crea l'admin del tenant (sostituisci ID-DEL-TENANT con il valore del passo 2) INSERT INTO admin_users (id, tenant_id, nome, cognome, email, password_hash, role, attivo, created_at) VALUES (UUID(), 'ID-DEL-TENANT', 'Nome', 'Cognome', 'admin@salone.it', '$2y$10$...', 'admin', 1, NOW()); -- 4. Inserisci orari default (0=Dom, 1=Lun, 2=Mar, 3=Mer, 4=Gio, 5=Ven, 6=Sab) INSERT INTO config_orari (id, tenant_id, giorno_settimana, aperto, ora_apertura, ora_chiusura, durata_slot, created_at) VALUES (UUID(), 'ID-DEL-TENANT', 0, 0, '09:00:00', '18:00:00', 20, NOW()), -- Dom: chiuso (UUID(), 'ID-DEL-TENANT', 1, 1, '09:00:00', '18:00:00', 20, NOW()), -- Lun: aperto (UUID(), 'ID-DEL-TENANT', 2, 1, '09:00:00', '18:00:00', 20, NOW()), -- Mar: aperto (UUID(), 'ID-DEL-TENANT', 3, 1, '09:00:00', '18:00:00', 20, NOW()), -- Mer: aperto (UUID(), 'ID-DEL-TENANT', 4, 1, '09:00:00', '18:00:00', 20, NOW()), -- Gio: aperto (UUID(), 'ID-DEL-TENANT', 5, 1, '09:00:00', '18:00:00', 20, NOW()), -- Ven: aperto (UUID(), 'ID-DEL-TENANT', 6, 0, '09:00:00', '13:00:00', 20, NOW()); -- Sab: chiuso
Generare una password hash PHP
Per creare la password_hash da inserire nel database, crea un file PHP temporaneo sul server:
<?php // 1. Carica questo file sul server come hashgen.php // 2. Visitalo nel browser // 3. Copia l'hash generato // 4. ELIMINA subito questo file! echo password_hash('LA_PASSWORD_DESIDERATA', PASSWORD_ARGON2ID);
Manutenzione
Pulizia sessioni scadute
Da eseguire periodicamente in phpMyAdmin per evitare che la tabella sessioni cresca indefinitamente:
DELETE FROM sessioni WHERE scadenza < NOW(); DELETE FROM sessioni_admin WHERE scadenza < NOW();
Backup database
- Vai su hPanel β Databases β phpMyAdmin
- Seleziona il database
u568594947_prenotataglio - Clicca Esporta nella barra superiore
- Formato: SQL, Metodo: Veloce
- Clicca Esegui β scarica il file .sql
Cambio password admin
Prima genera il nuovo hash (sezione 11), poi esegui:
UPDATE admin_users SET password_hash = '$2y$10$...' -- incolla qui il nuovo hash WHERE email = 'admin@salone.it' AND tenant_id = 'ID-TENANT'; -- sicurezza: limita al tenant corretto
Verificare le prenotazioni del giorno
SELECT p.data_ora, p.stato, p.prezzo, CONCAT(c.nome, ' ', c.cognome) AS cliente, c.telefono, s.nome AS servizio, s.durata_minuti, CONCAT(o.nome, ' ', o.cognome) AS operatore FROM prenotazioni p JOIN clienti c ON p.cliente_id = c.id JOIN servizi s ON p.servizio_id = s.id LEFT JOIN operatori o ON p.operatore_id = o.id WHERE DATE(p.data_ora) = CURDATE() AND p.tenant_id = 'ID-TENANT' AND p.stato = 'confermata' ORDER BY p.data_ora ASC;
Checklist sicurezza post-deploy
Dopo aver completato il deploy, verifica tutti i punti seguenti. Clicca sulle caselle per segnarle come completate.
- Eliminata la cartella
setup/dal server - Eliminato il file
assets/img/generate-icons.php - Eliminato o reso inaccessibile
install.sql(l'.htaccess lo blocca ma Γ¨ meglio rimuoverlo) APP_ENV = 'production'impostato in config.phpJWT_SECRETcambiato con una stringa random sicura di 64+ caratteri- Password database robusta (min. 16 caratteri, misto maiuscole/minuscole/numeri/simboli)
- .htaccess nega esplicitamente accesso a
bootstrap.phpeinstall.sql - HTTPS attivo e certificato SSL valido (Let's Encrypt su Hostinger Γ¨ gratuito)
- La cartella
include/prenotataglio/Γ¨ fuori dalla webroot - Testato il login admin e il flusso completo di prenotazione cliente
- Verificato che le notifiche toast del polling admin funzionino
- PWA installata su almeno un dispositivo mobile per test
Link utili
| Risorsa | URL |
|---|---|
| App cliente (Elite Parrucchieri) | https://prenotataglio.mydigitaltools.it/elite-parrucchieri/ |
| Pannello admin (Elite Parrucchieri) | https://prenotataglio.mydigitaltools.it/elite-parrucchieri/admin |
| hPanel Hostinger | https://hpanel.hostinger.com |
| phpMyAdmin | Accessibile da hPanel β Databases β phpMyAdmin |
| File Manager | Accessibile da hPanel β Files β File Manager |