Compare commits
18 Commits
e7b03da729
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| fa4fc02e9f | |||
| 86a901d598 | |||
| 168b11c73b | |||
| cadf88683e | |||
| 99935ab6fe | |||
| e51828a8d0 | |||
| d331e609a5 | |||
| 787553af22 | |||
| 5796e94154 | |||
| 97bb3998dd | |||
| aa0c77289d | |||
| fa8ee9c93f | |||
| 4998795648 | |||
| 0b9726a778 | |||
| b15567c8e1 | |||
| 23f6a0a3e8 | |||
| f5d2c8664f | |||
| c423a8d9a7 |
@@ -56,3 +56,52 @@
|
|||||||
2. ⚠ Inquadramento sanzionatorio definitivo (Art. 27 vs Artt. 62-66 Cod. Cons.) e importi post-Omnibus.
|
2. ⚠ Inquadramento sanzionatorio definitivo (Art. 27 vs Artt. 62-66 Cod. Cons.) e importi post-Omnibus.
|
||||||
3. Testo integrale Art. 54-bis da Normattiva (non solo Brocardi) per etichette e dati esatti.
|
3. Testo integrale Art. 54-bis da Normattiva (non solo Brocardi) per etichette e dati esatti.
|
||||||
4. Verifica cosa Shopify copre nativamente (regole reso/cancellazione UE) vs gap reali.
|
4. Verifica cosa Shopify copre nativamente (regole reso/cancellazione UE) vs gap reali.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Recesso parziale — punto CHIARITO (2026-07-10)
|
||||||
|
|
||||||
|
**Domanda:** il recesso si applica all'intero ordine o si puo' spacchettare per singolo bene?
|
||||||
|
|
||||||
|
**Risposta su due piani distinti.**
|
||||||
|
|
||||||
|
### 5.1 Diritto sostanziale: il recesso parziale e' AMMESSO
|
||||||
|
Il recesso puo' avere ad oggetto tutti i beni (totale) o soltanto parte di essi (parziale).
|
||||||
|
Coerente col **modulo tipo** (Allegato I, parte B), che prevede di indicare "i seguenti beni".
|
||||||
|
Nota: sul parziale le **spese di consegna non si rimborsano** (importo fisso, indipendente dal
|
||||||
|
numero di pezzi).
|
||||||
|
|
||||||
|
### 5.2 La FUNZIONE di recesso (art. 54-bis) NON deve offrire la selezione dei beni
|
||||||
|
Fonte primaria — **Considerando 37, Direttiva (UE) 2023/2673**:
|
||||||
|
|
||||||
|
> "If the consumer has ordered multiple goods or services within the same distance contract,
|
||||||
|
> the trader **can** provide the consumer with the possibility to withdraw from a part rather
|
||||||
|
> than the whole of the contract."
|
||||||
|
|
||||||
|
**"can", non "must"**: offrire il recesso parziale nella funzione e' una **facolta' del
|
||||||
|
professionista**, non un obbligo. Conformita' minima = il consumatore deve poter identificare
|
||||||
|
**il contratto** da cui recede.
|
||||||
|
|
||||||
|
Sei fonti italiane indipendenti convergono sull'elenco dei dati richiesti dall'art. 54-bis:
|
||||||
|
nome + **elementi identificativi del contratto** + mezzo elettronico per la conferma. Nessuna
|
||||||
|
menziona la selezione dei beni. (Stefanelli, CMS, Studio Legale MP, LegalBlink, Alexander Greco,
|
||||||
|
Rescindly.)
|
||||||
|
|
||||||
|
⚠ Un commento anglosassone (NatLawReview) sostiene il contrario ("the interface must allow the
|
||||||
|
customer to select the particular product(s)"): e' una **sovra-lettura del considerando 37**.
|
||||||
|
|
||||||
|
### 5.3 Conseguenza per l'app
|
||||||
|
Una funzione di recesso **a livello di ordine e' conforme**. Il consumatore conserva comunque il
|
||||||
|
diritto al recesso parziale attraverso gli **altri canali** (modulo tipo / email), che per legge
|
||||||
|
restano validi e non vanno disabilitati.
|
||||||
|
Il recesso parziale nel pulsante e' quindi una scelta di **prodotto**, non un obbligo di legge.
|
||||||
|
|
||||||
|
### 5.4 Panorama concorrenti (2026-07-10)
|
||||||
|
- **Revize** (revize.app): implementa la selezione articoli ("the customer selects which items to withdraw"); pre-compila e blocca nome/contratto/contatto dall'ordine.
|
||||||
|
- **Rescindly** (rescindly.eu): form = numero ordine, nome, email, motivo. **Nessuna selezione articoli** -> livello ordine.
|
||||||
|
- **Guida IFG eCommerce**: raccomanda esplicitamente l'implementazione **a livello di ordine**.
|
||||||
|
- **LegalBlink**: NON e' un concorrente — e' un generatore di documenti legali (condizioni di vendita, privacy, condizioni di recesso), non un widget di recesso.
|
||||||
|
- **Shopify**: **nessun pulsante di recesso nativo** (a maggio 2026). Gli strumenti annulla/rimborsa sono lato merchant e non soddisfano i requisiti art. 11a (funzione rivolta al consumatore, 2 step, etichettata, con ricevuta).
|
||||||
|
|
||||||
|
### 5.5 Sanzione confermata
|
||||||
|
Mancata conformita' -> il termine di recesso si estende automaticamente di **12 mesi**.
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ in capo al merchant. Da consegnare col progetto.
|
|||||||
- **Pulsante/funzione di recesso sempre accessibile** (footer o dove scelto), **guest**, senza login.
|
- **Pulsante/funzione di recesso sempre accessibile** (footer o dove scelto), **guest**, senza login.
|
||||||
- Raccolta della **dichiarazione inequivocabile** (nome, n. ordine, email, testo) con **conferma dedicata a 2 step** (nessun dark pattern).
|
- Raccolta della **dichiarazione inequivocabile** (nome, n. ordine, email, testo) con **conferma dedicata a 2 step** (nessun dark pattern).
|
||||||
- Registrazione con **timestamp di TRASMISSIONE** (onere della prova, Art. 54-bis).
|
- Registrazione con **timestamp di TRASMISSIONE** (onere della prova, Art. 54-bis).
|
||||||
- **Ricevuta su supporto durevole** al consumatore: dichiarazione + timestamp + avviso di legge - **sempre inviata, contenuto legale non modificabile** dal merchant.
|
- **Ricevuta su supporto durevole** al consumatore: dichiarazione + timestamp + avviso di legge - **sempre inviata, contenuto legale non modificabile** dal merchant. *(L'app la invia sempre; la CONSEGNA dipende pero' dal provider SMTP e dalla reputazione del dominio mittente configurati dal merchant - vedi §3.)*
|
||||||
- **Audit log immutabile** delle richieste ed eventi.
|
- **Audit log immutabile** delle richieste ed eventi.
|
||||||
- Coesistenza col reso/rimborso **nativo Shopify** (crea il Reso per gli ordini evasi).
|
- Coesistenza col reso/rimborso **nativo Shopify** (crea il Reso per gli ordini evasi).
|
||||||
|
|
||||||
@@ -32,6 +32,7 @@ in capo al merchant. Da consegnare col progetto.
|
|||||||
- Gestire **resi parziali** e rimborsi proporzionali (pannello ordini Shopify).
|
- Gestire **resi parziali** e rimborsi proporzionali (pannello ordini Shopify).
|
||||||
- Definire la **politica di reso** (indirizzo, spese, integrita' prodotto) coerente con quanto mostra l'app.
|
- Definire la **politica di reso** (indirizzo, spese, integrita' prodotto) coerente con quanto mostra l'app.
|
||||||
- **Verifica manuale della data di consegna** se il corriere non trasmette l'evento a Shopify (in tal caso la finestra automatica non blocca).
|
- **Verifica manuale della data di consegna** se il corriere non trasmette l'evento a Shopify (in tal caso la finestra automatica non blocca).
|
||||||
|
- **Configurare un provider SMTP e autenticare il dominio mittente** (SPF, DKIM, DMARC) presso quel provider. Senza SMTP la ricevuta non parte affatto; senza autenticazione del dominio finisce facilmente in spam. In entrambi i casi il supporto durevole non raggiunge il consumatore. Consigliato: mittente dedicato (`no-reply@` / `recesso@`), meglio su un **sottodominio** dedicato alla posta transazionale, mai una casella umana. Verificare con il bottone **"Invia email di prova"** in Impostazioni → Email (SMTP).
|
||||||
- Privacy policy, condizioni di vendita, gestione dati (GDPR) del negozio.
|
- Privacy policy, condizioni di vendita, gestione dati (GDPR) del negozio.
|
||||||
- Configurare le **esclusioni Art. 59** solo per prodotti realmente esclusi (mala-config = negare il diritto a torto).
|
- Configurare le **esclusioni Art. 59** solo per prodotti realmente esclusi (mala-config = negare il diritto a torto).
|
||||||
|
|
||||||
|
|||||||
10
PLAN.md
10
PLAN.md
@@ -181,6 +181,15 @@ input / deliverable / criteri di uscita espliciti. **Nessun avvio automatico**
|
|||||||
3. **Istruzioni di reso configurabili**: `returnAddress` (già in schema), `returnAtCustomerExpense` (default ON — Art. 57: informa + rende il cliente responsabile delle spese), `returnInstructions` (testo opzionale).
|
3. **Istruzioni di reso configurabili**: `returnAddress` (già in schema), `returnAtCustomerExpense` (default ON — Art. 57: informa + rende il cliente responsabile delle spese), `returnInstructions` (testo opzionale).
|
||||||
**Resta MANUALE per scelta Pizeta** (nessuna automazione richiesta): resi parziali + rimborso proporzionale (pannello Shopify), emissione rimborso (Shopify manda la sua mail), verifica data consegna se il corriere non passa l'evento a Shopify.
|
**Resta MANUALE per scelta Pizeta** (nessuna automazione richiesta): resi parziali + rimborso proporzionale (pannello Shopify), emissione rimborso (Shopify manda la sua mail), verifica data consegna se il corriere non passa l'evento a Shopify.
|
||||||
**Uscita:** merchant attiva/disattiva ciascun comportamento; il flusso si adatta allo stato dell'ordine.
|
**Uscita:** merchant attiva/disattiva ciascun comportamento; il flusso si adatta allo stato dell'ordine.
|
||||||
|
- **A6-ter recesso parziale** *(OPZIONALE — non è un obbligo di legge)* — Base legale in `ANALISI-REQUISITI-LEGALI.md` §5: il **considerando 37 Dir. (UE) 2023/2673** dice che il professionista **"can"** offrire il recesso su parte del contratto, non che **deve**. Un pulsante a livello di ordine è conforme; il parziale è **differenziazione di prodotto** (lo fa Revize, non lo fa Rescindly) e copre il caso reale Pizeta (3 confezioni, ne rende 2).
|
||||||
|
- Toggle per-shop **`partialWithdrawalEnabled`** (default **OFF** = comportamento attuale, un clic = recesso totale).
|
||||||
|
- Passo 2: elenco articoli dell'ordine con quantità; **default tutti selezionati** (nessun attrito per chi vuole il totale).
|
||||||
|
- Dichiarazione **generata sugli articoli scelti** (oggi dice "intero ordine": non si può lasciare così se il cliente seleziona un sottoinsieme).
|
||||||
|
- `createShopifyReturn`: reso limitato alle **righe/quantità selezionate**.
|
||||||
|
- `autoCancelUnfulfilled`: si attiva **solo** se la selezione copre l'intero ordine. Parziale + non evaso → order edit / rimborso parziale, che resta manuale.
|
||||||
|
- **Idempotenza da rivedere**: oggi la chiave è `(shop, orderId)` (commit `fa8ee9c`), corretta solo nel mondo total-only. Col parziale deve diventare `(shop, orderId, articoli)`: il cliente può recedere per l'articolo A oggi e per il B domani, entrambi legittimi nella finestra.
|
||||||
|
- Dati da aggiungere: `lookupOrder` deve restituire titoli, quantità e ID riga; mappatura `lineItem ↔ fulfillmentLineItem` per il reso; nuova colonna (es. `items Json`) su `WithdrawalRequest`.
|
||||||
|
- **Uscita:** merchant può abilitare il parziale; con toggle OFF il comportamento resta identico a oggi.
|
||||||
|
|
||||||
### Fase 4 — Robustezza *(A7→A8 sequenziali, poi A9 audit)*
|
### Fase 4 — Robustezza *(A7→A8 sequenziali, poi A9 audit)*
|
||||||
- **A7 i18n** — localizzazione IT/EN/DE/FR/ES (R14).
|
- **A7 i18n** — localizzazione IT/EN/DE/FR/ES (R14).
|
||||||
@@ -218,6 +227,7 @@ integrati. Dettaglio stato/commit nella memoria di progetto + git.
|
|||||||
|
|
||||||
- **R1 — A6-bis operatività per stato ordine (Pizeta-confirmed)** — 3 toggle per-shop: `stateAwareEmail` (ricevuta differenziata non-evaso/spedito-consegnato), `autoCancelUnfulfilled` (annullo automatico ordini non evasi), `returnAtCustomerExpense`+`returnInstructions`+`returnAddress` (istruzioni reso, Art. 57). Dettaglio: sezione A6-bis.
|
- **R1 — A6-bis operatività per stato ordine (Pizeta-confirmed)** — 3 toggle per-shop: `stateAwareEmail` (ricevuta differenziata non-evaso/spedito-consegnato), `autoCancelUnfulfilled` (annullo automatico ordini non evasi), `returnAtCustomerExpense`+`returnInstructions`+`returnAddress` (istruzioni reso, Art. 57). Dettaglio: sezione A6-bis.
|
||||||
- **R2 — A6 residuo copy compliance** — G7: promemoria rimborso 14gg + facoltà di trattenuta (Art. 56) nella notifica al merchant. *(G6 spese-reso assorbito da R1.)*
|
- **R2 — A6 residuo copy compliance** — G7: promemoria rimborso 14gg + facoltà di trattenuta (Art. 56) nella notifica al merchant. *(G6 spese-reso assorbito da R1.)*
|
||||||
|
- **R2-bis — A6-ter recesso parziale** *(opzionale, toggle `partialWithdrawalEnabled`, default OFF)* — non è compliance ma prodotto: selezione articoli/quantità, dichiarazione sugli articoli scelti, reso limitato alle righe scelte, auto-annullo solo se selezione = intero ordine, idempotenza su `(ordine, articoli)`. Vedi A6-ter e `ANALISI-REQUISITI-LEGALI.md` §5.
|
||||||
- **R3 — Deploy custom (go-live)** — Fly.io + Postgres prod + URL stabile + tunnel Cloudflare nominato + link install custom sui 2-3 store live. *(Serve per usarlo davvero; occhio deadline transfer pizeta-pharma-2 ~metà luglio 2026.)*
|
- **R3 — Deploy custom (go-live)** — Fly.io + Postgres prod + URL stabile + tunnel Cloudflare nominato + link install custom sui 2-3 store live. *(Serve per usarlo davvero; occhio deadline transfer pizeta-pharma-2 ~metà luglio 2026.)*
|
||||||
- **R4 — A9 hardening / QA-security** — rate-limit robusto, retry ricevuta fallita, idempotenza webhook, review avversariale (enumeration lookup, HMAC, PII), Protected Customer Data (approvazione Shopify per leggere `order.email` in prod).
|
- **R4 — A9 hardening / QA-security** — rate-limit robusto, retry ricevuta fallita, idempotenza webhook, review avversariale (enumeration lookup, HMAC, PII), Protected Customer Data (approvazione Shopify per leggere `order.email` in prod).
|
||||||
- **R5 — A7 i18n** — IT/EN/DE/FR/ES.
|
- **R5 — A7 i18n** — IT/EN/DE/FR/ES.
|
||||||
|
|||||||
119
PROTECTED-CUSTOMER-DATA.md
Normal file
119
PROTECTED-CUSTOMER-DATA.md
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
# Protected Customer Data — verdetto e autovalutazione
|
||||||
|
|
||||||
|
Stato: **2026-07-10**. App: `Legal Return PCRT` (custom distribution) su Fly `recesso-custom`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Serve l'approvazione di Shopify?
|
||||||
|
|
||||||
|
**No, non per la distribuzione custom.** Dalla documentazione ufficiale
|
||||||
|
([shopify.dev/docs/apps/launch/protected-customer-data](https://shopify.dev/docs/apps/launch/protected-customer-data)):
|
||||||
|
|
||||||
|
| Livello | Public app | **Custom app** | Admin-created custom app |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Level 1 | Requires review | **Always available** | Always available |
|
||||||
|
| Level 2 (nome, indirizzo, email, telefono) | Requires review | **Always available** | Varies by plan |
|
||||||
|
|
||||||
|
Conseguenze:
|
||||||
|
- **Oggi (custom, store live): nessuna approvazione da attendere.** `order.email` e
|
||||||
|
`order.statusPageUrl` sono accessibili.
|
||||||
|
- **Domani (R7, app pubblica): la review sarà obbligatoria.** Questo documento e' la base
|
||||||
|
della futura richiesta.
|
||||||
|
- ⚠ Il fatto che oggi funzioni su `pcrt-reso-test` **non prova nulla**: sui development store
|
||||||
|
la review non e' richiesta comunque. La prova e' la tabella qui sopra, non il test.
|
||||||
|
|
||||||
|
**Da fare comunque nel Dev Dashboard:** dichiarare quali dati e campi protetti si usano
|
||||||
|
(App → API access requests → Protected customer data). Testi pronti al §3.
|
||||||
|
|
||||||
|
**⚠ Attenzione:** `order.statusPageUrl` richiede **Level 2** dal 15 marzo 2024
|
||||||
|
([changelog](https://shopify.dev/changelog/level-2-protected-customer-data-requirements-are-now-needed-to-access-the-order-statuspageurl-field)).
|
||||||
|
Lo usiamo solo per il bottone "Vedi il tuo ordine" nella ricevuta al cliente: e' una comodita',
|
||||||
|
non un dato necessario. Per la data minimization della futura app pubblica **valutare di
|
||||||
|
rimuoverlo**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Dati protetti effettivamente trattati
|
||||||
|
|
||||||
|
| Dato | Da dove | Perche' e' il minimo necessario |
|
||||||
|
|---|---|---|
|
||||||
|
| `order.email` | Admin API (lookup ordine) | Verifica d'identita' del consumatore **guest** (senza login, come impone l'art. 54-bis) e destinatario della **ricevuta su supporto durevole** (obbligo di legge) |
|
||||||
|
| Nome del consumatore | Inserito dal consumatore nel form | Elemento richiesto dall'art. 54-bis nella dichiarazione |
|
||||||
|
| Testo della dichiarazione | Inserito dal consumatore | E' l'atto giuridico stesso; va conservato come prova |
|
||||||
|
| `order.statusPageUrl` | Admin API | Solo comodita' (link "Vedi il tuo ordine"). **Non necessario** |
|
||||||
|
|
||||||
|
NON trattiamo: indirizzi, telefono, dati di pagamento, profilazione. Nessuna vendita o
|
||||||
|
condivisione a terzi. Unico sub-processor: il **provider SMTP scelto dal merchant**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Testi per la dichiarazione nel Dev Dashboard
|
||||||
|
|
||||||
|
**Protected customer data — motivazione:**
|
||||||
|
> L'app implementa la funzione di recesso obbligatoria ex art. 54-bis del Codice del Consumo
|
||||||
|
> italiano (D.Lgs 209/2025, Dir. UE 2023/2673). Per legge la funzione deve essere utilizzabile
|
||||||
|
> senza login: l'app deve quindi verificare l'identita' del consumatore confrontando il numero
|
||||||
|
> d'ordine con l'email associata all'ordine, e inviare a quell'indirizzo la ricevuta su supporto
|
||||||
|
> durevole con il timestamp di trasmissione. Nessun altro dato del cliente viene letto.
|
||||||
|
|
||||||
|
**Protected customer field — `email`:**
|
||||||
|
> Necessaria per due obblighi di legge: (1) verificare che chi esercita il recesso sia il
|
||||||
|
> titolare dell'ordine (accesso guest, senza autenticazione); (2) recapitare la ricevuta su
|
||||||
|
> supporto durevole, che l'art. 54-bis impone di inviare senza indebito ritardo.
|
||||||
|
|
||||||
|
**Protected customer field — `name`:** non richiesto via API (lo inserisce il consumatore).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Requisiti Level 1 / Level 2 — stato reale
|
||||||
|
|
||||||
|
| # | Requisito | Stato | Evidenza / Gap |
|
||||||
|
|---|---|---|---|
|
||||||
|
| L1 | Minimizzazione dei dati | 🟡 | Ok tranne `statusPageUrl` (non necessario) |
|
||||||
|
| L1 | Informare il merchant su dati e finalita' | 🟡 | `CHECKLIST-COMPLIANCE-MERCHANT.md` c'e'; manca una **privacy policy dell'app** |
|
||||||
|
| L1 | Uso limitato alle finalita' dichiarate | ✅ | Nessun uso secondario |
|
||||||
|
| L1 | Periodi di retention definiti | ❌ | **Non documentati.** `shop/redact` fa purge totale; `customers/redact` pseudonimizza. Manca la policy scritta |
|
||||||
|
| L1 | Cifratura in transito | ✅ | HTTPS ovunque; SMTP con STARTTLS/TLS |
|
||||||
|
| L1 | Cifratura a riposo | ✅ | Volume Fly `pg_data` **ENCRYPTED = true**. Password SMTP per-shop cifrate AES-256-GCM (`crypto.server.ts`) |
|
||||||
|
| L2 | Backup cifrati | ✅ | Snapshot automatici Fly (cifrati perche' il volume lo e') |
|
||||||
|
| L2 | Separazione test / produzione | ✅ | Dev = Postgres in Docker locale; prod = Fly Postgres. DB e app distinti |
|
||||||
|
| L2 | Accesso staff limitato | 🟡 | Un solo titolare. **Ma la password master Fly era in chiaro nel file `Cred Fly`: da cambiare** |
|
||||||
|
| L2 | Password robuste | ❌ | Vedi sopra: cambio password Fly **non confermato** |
|
||||||
|
| L2 | Access log | 🟡 | Log applicativi Fly (effimeri) + `AuditLog` append-only con payload hashati. Nessun log di accesso al DB |
|
||||||
|
| L2 | Incident response policy | ❌ | **Non esiste.** Va scritta |
|
||||||
|
| L2 | Data loss prevention | ❌ | Snapshot con **retention 5 giorni**, nodo singolo, nessuna copia off-site. Vedi §5 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Due rischi che non sono formalita'
|
||||||
|
|
||||||
|
### 5.1 Durabilita' dei record legali
|
||||||
|
Gli snapshot Fly hanno **retention 5 giorni** e vivono nella stessa infrastruttura. Sono
|
||||||
|
*disaster recovery*, non *archivio*. Ma le `WithdrawalRequest` e l'`AuditLog` sono la **prova**
|
||||||
|
del recesso: se il termine si estende a 12 mesi per mancata informativa, o se sorge una
|
||||||
|
controversia, quei record devono esistere ben oltre 5 giorni. Un solo nodo, nessuna copia
|
||||||
|
off-site: se l'app Postgres viene cancellata, la prova sparisce.
|
||||||
|
|
||||||
|
**Fix:** dump logico periodico verso storage esterno (o Fly Managed Postgres / Supabase, che
|
||||||
|
hanno backup gestiti con retention lunga). Non e' un requisito Shopify — e' il motivo per cui
|
||||||
|
l'app esiste.
|
||||||
|
|
||||||
|
### 5.2 Cold start sulla funzione di recesso
|
||||||
|
`min_machines_running = 0`: la macchina si spegne. Una richiesta a freddo ha impiegato
|
||||||
|
**38 secondi**. L'art. 54-bis pretende una funzione **"sempre accessibile"** e **"facilmente
|
||||||
|
utilizzabile"**: 38 secondi di attesa dopo il clic sono un ostacolo, e con ogni probabilita'
|
||||||
|
il consumatore abbandona.
|
||||||
|
|
||||||
|
**Fix:** `min_machines_running = 1` (una macchina sempre calda).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Azioni, in ordine
|
||||||
|
|
||||||
|
1. **Cambiare la password Fly** (era in chiaro). — *utente*
|
||||||
|
2. **`min_machines_running = 1`** nel `fly.toml`. — *codice, banale*
|
||||||
|
3. **Backup off-site** dei record legali (dump periodico). — *da progettare*
|
||||||
|
4. **Privacy policy dell'app** + **retention policy** scritte. — *documenti*
|
||||||
|
5. **Incident response policy** (chi, cosa, entro quanto). — *documento*
|
||||||
|
6. Dichiarare dati e campi nel **Dev Dashboard** (testi al §3). — *utente*
|
||||||
|
7. *(Per R7, app pubblica)* valutare la **rimozione di `statusPageUrl`** per minimizzazione.
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
* SMTP_SECURE("true"/"false"), MAIL_FROM. DEV: Mailpit localhost:1025.
|
* SMTP_SECURE("true"/"false"), MAIL_FROM. DEV: Mailpit localhost:1025.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
import nodemailer from "nodemailer";
|
import nodemailer from "nodemailer";
|
||||||
import {
|
import {
|
||||||
renderReceiptHtml,
|
renderReceiptHtml,
|
||||||
@@ -35,13 +36,20 @@ function buildTransport(smtp?: SmtpConfig | null) {
|
|||||||
const port = useShop
|
const port = useShop
|
||||||
? Number(smtp!.port ?? 587)
|
? Number(smtp!.port ?? 587)
|
||||||
: Number(process.env.SMTP_PORT ?? 587);
|
: Number(process.env.SMTP_PORT ?? 587);
|
||||||
const secure = useShop ? !!smtp!.secure : process.env.SMTP_SECURE === "true";
|
const rawSecure = useShop
|
||||||
|
? !!smtp!.secure
|
||||||
|
: process.env.SMTP_SECURE === "true";
|
||||||
|
// Le porte standard vincolano la modalita' TLS: 465 = TLS diretto, 587 =
|
||||||
|
// STARTTLS. Spuntare "sicura" sulla 587 (errore comune) rompeva l'handshake.
|
||||||
|
// Su porte non standard vale la scelta del merchant.
|
||||||
|
const secure = port === 465 ? true : port === 587 ? false : rawSecure;
|
||||||
const user = useShop ? smtp!.user : process.env.SMTP_USER;
|
const user = useShop ? smtp!.user : process.env.SMTP_USER;
|
||||||
const pass = useShop ? smtp!.pass : process.env.SMTP_PASS;
|
const pass = useShop ? smtp!.pass : process.env.SMTP_PASS;
|
||||||
return nodemailer.createTransport({
|
return nodemailer.createTransport({
|
||||||
host,
|
host,
|
||||||
port,
|
port,
|
||||||
secure,
|
secure,
|
||||||
|
requireTLS: port === 587, // forza STARTTLS dove e' obbligatorio
|
||||||
auth: user ? { user, pass: pass ?? "" } : undefined,
|
auth: user ? { user, pass: pass ?? "" } : undefined,
|
||||||
connectionTimeout: 10_000,
|
connectionTimeout: 10_000,
|
||||||
greetingTimeout: 10_000,
|
greetingTimeout: 10_000,
|
||||||
@@ -59,16 +67,28 @@ function mailFrom(smtp?: SmtpConfig | null): string {
|
|||||||
|
|
||||||
type Transport = NonNullable<ReturnType<typeof buildTransport>>;
|
type Transport = NonNullable<ReturnType<typeof buildTransport>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Message-ID allineato al dominio del mittente. Un Message-ID con dominio
|
||||||
|
* incoerente (di default: l'hostname del container) e' un segnale negativo
|
||||||
|
* per i filtri antispam.
|
||||||
|
*/
|
||||||
|
function makeMessageId(from: unknown): string | undefined {
|
||||||
|
if (typeof from !== "string") return undefined;
|
||||||
|
const m = from.match(/@([^>\s]+)/);
|
||||||
|
return m ? `<${randomUUID()}@${m[1]}>` : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
/** Invio con retry (backoff lineare). Riduce le ricevute perse per glitch SMTP. */
|
/** Invio con retry (backoff lineare). Riduce le ricevute perse per glitch SMTP. */
|
||||||
async function trySend(
|
async function trySend(
|
||||||
transport: Transport,
|
transport: Transport,
|
||||||
message: Parameters<Transport["sendMail"]>[0],
|
message: Parameters<Transport["sendMail"]>[0],
|
||||||
attempts = 3,
|
attempts = 3,
|
||||||
) {
|
) {
|
||||||
|
const msg = { messageId: makeMessageId(message.from), ...message };
|
||||||
let lastErr: unknown;
|
let lastErr: unknown;
|
||||||
for (let i = 0; i < attempts; i++) {
|
for (let i = 0; i < attempts; i++) {
|
||||||
try {
|
try {
|
||||||
return await transport.sendMail(message);
|
return await transport.sendMail(msg);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
lastErr = e;
|
lastErr = e;
|
||||||
if (i < attempts - 1) {
|
if (i < attempts - 1) {
|
||||||
@@ -145,6 +165,63 @@ export async function sendWithdrawalReceipt(params: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invio di prova dalle Impostazioni. Fa prima `verify()` (errori di connessione/
|
||||||
|
* autenticazione molto piu' chiari), poi un solo tentativo di invio.
|
||||||
|
* Ritorna l'errore SMTP GREZZO: serve a diagnosticare.
|
||||||
|
*/
|
||||||
|
export async function sendTestEmail(params: {
|
||||||
|
smtp?: SmtpConfig | null;
|
||||||
|
to: string;
|
||||||
|
}): Promise<ReceiptResult> {
|
||||||
|
const transport = buildTransport(params.smtp);
|
||||||
|
if (!transport) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error:
|
||||||
|
"SMTP non configurato: compila 'Host SMTP' (oppure imposta il provider di default dell'app).",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const from = mailFrom(params.smtp);
|
||||||
|
if (/no-reply@localhost/.test(from)) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error:
|
||||||
|
"Mittente non impostato: compila 'Mittente (From)'. La maggior parte dei provider (Brevo incluso) rifiuta un mittente non verificato.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await transport.verify();
|
||||||
|
} catch (e) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: `Connessione/autenticazione SMTP fallita: ${e instanceof Error ? e.message : String(e)}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const info = await trySend(
|
||||||
|
transport,
|
||||||
|
{
|
||||||
|
from,
|
||||||
|
to: params.to,
|
||||||
|
subject: "Email di prova - App Recesso",
|
||||||
|
text: "Se leggi questo messaggio, la configurazione SMTP funziona.",
|
||||||
|
html: "<p>Se leggi questo messaggio, la configurazione SMTP funziona.</p>",
|
||||||
|
},
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
return { ok: true, messageId: info.messageId };
|
||||||
|
} catch (e) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: e instanceof Error ? e.message : "invio di prova fallito",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function escM(s: string): string {
|
function escM(s: string): string {
|
||||||
return String(s)
|
return String(s)
|
||||||
.replace(/&/g, "&")
|
.replace(/&/g, "&")
|
||||||
@@ -162,7 +239,10 @@ export async function sendMerchantNotification(params: {
|
|||||||
orderName: string;
|
orderName: string;
|
||||||
customerName: string;
|
customerName: string;
|
||||||
customerEmail: string;
|
customerEmail: string;
|
||||||
orderUrl: string;
|
/** Testo della dichiarazione: il cliente puo' averlo modificato (es. recesso parziale). */
|
||||||
|
statementText: string;
|
||||||
|
/** URL dell'ordine nel pannello ADMIN (non la pagina cliente): qui si gestisce il reso. */
|
||||||
|
adminOrderUrl: string;
|
||||||
transmittedAt: string;
|
transmittedAt: string;
|
||||||
returnStatus: "created" | "no_returnable" | "exists" | "error";
|
returnStatus: "created" | "no_returnable" | "exists" | "error";
|
||||||
smtp?: SmtpConfig | null;
|
smtp?: SmtpConfig | null;
|
||||||
@@ -181,8 +261,12 @@ export async function sendMerchantNotification(params: {
|
|||||||
? "L'ordine non risulta evaso: valuta annullamento o rimborso."
|
? "L'ordine non risulta evaso: valuta annullamento o rimborso."
|
||||||
: "Reso non creato automaticamente: verifica manualmente l'ordine.";
|
: "Reso non creato automaticamente: verifica manualmente l'ordine.";
|
||||||
|
|
||||||
const orderBtn = /^https?:\/\//i.test(params.orderUrl)
|
const btnLabel =
|
||||||
? `<p style="margin:16px 0 0;"><a href="${escM(params.orderUrl)}" style="display:inline-block;padding:10px 18px;background:#1a1a1a;color:#fff;text-decoration:none;border-radius:8px;font-size:14px;font-weight:600;">Apri l'ordine</a></p>`
|
params.returnStatus === "created" || params.returnStatus === "exists"
|
||||||
|
? "Gestisci il reso"
|
||||||
|
: "Apri l'ordine";
|
||||||
|
const orderBtn = /^https?:\/\//i.test(params.adminOrderUrl)
|
||||||
|
? `<p style="margin:16px 0 0;"><a href="${escM(params.adminOrderUrl)}" style="display:inline-block;padding:10px 18px;background:#1a1a1a;color:#fff;text-decoration:none;border-radius:8px;font-size:14px;font-weight:600;">${btnLabel}</a></p>`
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
const subject = `Nuovo recesso - Ordine ${params.orderName}`;
|
const subject = `Nuovo recesso - Ordine ${params.orderName}`;
|
||||||
@@ -199,6 +283,11 @@ export async function sendMerchantNotification(params: {
|
|||||||
<div><span style="color:#777;">Email:</span> ${escM(params.customerEmail)}</div>
|
<div><span style="color:#777;">Email:</span> ${escM(params.customerEmail)}</div>
|
||||||
<div><span style="color:#777;">Trasmesso:</span> ${escM(params.transmittedAt)}</div>
|
<div><span style="color:#777;">Trasmesso:</span> ${escM(params.transmittedAt)}</div>
|
||||||
</td></tr></table>
|
</td></tr></table>
|
||||||
|
<div style="margin:16px 0 0;">
|
||||||
|
<div style="font-size:13px;font-weight:600;color:#555;margin-bottom:6px;">Dichiarazione del cliente</div>
|
||||||
|
<div style="border-left:3px solid #d9d9d9;padding:8px 14px;font-size:14px;line-height:1.6;color:#555;font-style:italic;">${escM(params.statementText)}</div>
|
||||||
|
<div style="margin-top:6px;font-size:12px;line-height:1.5;color:#8a8a8a;">Il cliente puo' aver modificato questo testo: leggilo prima di agire (es. potrebbe chiedere il reso di soli alcuni articoli).</div>
|
||||||
|
</div>
|
||||||
<p style="margin:16px 0 0;font-size:14px;line-height:1.6;color:#3a3a3a;">${actionLine}</p>
|
<p style="margin:16px 0 0;font-size:14px;line-height:1.6;color:#3a3a3a;">${actionLine}</p>
|
||||||
<p style="margin:10px 0 0;font-size:12.5px;line-height:1.6;color:#8a8a8a;">Promemoria: disponi il rimborso entro 14 giorni dalla richiesta (art. 56 Cod. Consumo). Puoi trattenerlo fino alla riconsegna della merce o alla prova di spedizione da parte del cliente.</p>
|
<p style="margin:10px 0 0;font-size:12.5px;line-height:1.6;color:#8a8a8a;">Promemoria: disponi il rimborso entro 14 giorni dalla richiesta (art. 56 Cod. Consumo). Puoi trattenerlo fino alla riconsegna della merce o alla prova di spedizione da parte del cliente.</p>
|
||||||
${orderBtn}
|
${orderBtn}
|
||||||
@@ -210,9 +299,10 @@ ${orderBtn}
|
|||||||
`Ordine: ${params.orderName}`,
|
`Ordine: ${params.orderName}`,
|
||||||
`Cliente: ${params.customerName} (${params.customerEmail})`,
|
`Cliente: ${params.customerName} (${params.customerEmail})`,
|
||||||
`Trasmesso: ${params.transmittedAt}`,
|
`Trasmesso: ${params.transmittedAt}`,
|
||||||
|
`Dichiarazione del cliente: "${params.statementText}"`,
|
||||||
actionLine,
|
actionLine,
|
||||||
"Promemoria: rimborso entro 14 giorni dalla richiesta (art. 56); puoi trattenere fino alla riconsegna della merce o alla prova di spedizione.",
|
"Promemoria: rimborso entro 14 giorni dalla richiesta (art. 56); puoi trattenere fino alla riconsegna della merce o alla prova di spedizione.",
|
||||||
/^https?:\/\//i.test(params.orderUrl) ? params.orderUrl : "",
|
/^https?:\/\//i.test(params.adminOrderUrl) ? params.adminOrderUrl : "",
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join("\n");
|
.join("\n");
|
||||||
|
|||||||
@@ -23,9 +23,20 @@ export const FIELD = {
|
|||||||
|
|
||||||
// Dichiarazione precompilata (editabile), deriva dall'Allegato I-B.
|
// Dichiarazione precompilata (editabile), deriva dall'Allegato I-B.
|
||||||
export function statementTemplate(orderName: string): string {
|
export function statementTemplate(orderName: string): string {
|
||||||
return `Con la presente comunico il recesso dal contratto di vendita relativo all'ordine ${orderName}.`;
|
return `Con la presente comunico il recesso dal contratto di vendita relativo all'intero ordine ${orderName}.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ambito del recesso esercitato tramite questa funzione. La funzione dell'art.
|
||||||
|
* 54-bis puo' legittimamente operare a livello di CONTRATTO (ordine): offrire
|
||||||
|
* il recesso parziale e' una facolta' del professionista (considerando 37 Dir.
|
||||||
|
* UE 2023/2673, "the trader CAN provide..."), non un obbligo. Il diritto al
|
||||||
|
* recesso parziale resta comunque esercitabile dagli altri canali (modulo tipo,
|
||||||
|
* email), che non vanno mai disabilitati: per questo li indichiamo.
|
||||||
|
*/
|
||||||
|
export const SCOPE_HINT =
|
||||||
|
"Questo recesso riguarda l'intero ordine. Per restituire solo alcuni articoli, contatta il negozio.";
|
||||||
|
|
||||||
// Informazioni sul diritto di recesso (Art. 49) + alternative (coesistenza).
|
// Informazioni sul diritto di recesso (Art. 49) + alternative (coesistenza).
|
||||||
// Mostrate su richiesta dal pulsante info "i", per non appesantire il flusso.
|
// Mostrate su richiesta dal pulsante info "i", per non appesantire il flusso.
|
||||||
export const INFO_TITLE = "Il tuo diritto di recesso";
|
export const INFO_TITLE = "Il tuo diritto di recesso";
|
||||||
@@ -69,6 +80,26 @@ export function successMessage(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schermata per un recesso GIA' esercitato su quest'ordine. Il diritto si
|
||||||
|
* esercita una volta sola: non registriamo un secondo atto, ma confermiamo il
|
||||||
|
* primo (con il suo timestamp legale) invece di far finta di nulla.
|
||||||
|
*/
|
||||||
|
export function duplicateMessage(
|
||||||
|
orderName: string,
|
||||||
|
transmittedAt: string,
|
||||||
|
email: string,
|
||||||
|
receiptResent: boolean,
|
||||||
|
): { line1: string; line2: string; line3: string } {
|
||||||
|
return {
|
||||||
|
line1: "Recesso già registrato",
|
||||||
|
line2: `Risulta trasmesso per l'ordine ${orderName} il ${transmittedAt}. Non serve inviarlo di nuovo.`,
|
||||||
|
line3: receiptResent
|
||||||
|
? `Ti abbiamo re-inviato la ricevuta a ${email}.`
|
||||||
|
: `La ricevuta è stata inviata a ${email}. Controlla anche la posta indesiderata.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Template email ricevuta su supporto durevole (usato da A4).
|
// Template email ricevuta su supporto durevole (usato da A4).
|
||||||
export function receiptEmailSubject(orderName: string): string {
|
export function receiptEmailSubject(orderName: string): string {
|
||||||
return `Ricevuta della tua richiesta di recesso - Ordine ${orderName}`;
|
return `Ricevuta della tua richiesta di recesso - Ordine ${orderName}`;
|
||||||
|
|||||||
@@ -6,8 +6,10 @@
|
|||||||
* inesistente ed email non combaciante);
|
* inesistente ed email non combaciante);
|
||||||
* - rate-limit base per shop+IP (hardening -> A9);
|
* - rate-limit base per shop+IP (hardening -> A9);
|
||||||
* - hashing payload per l'audit trail;
|
* - hashing payload per l'audit trail;
|
||||||
* - formattazione timestamp di TRASMISSIONE (Europe/Rome);
|
* - formattazione timestamp di TRASMISSIONE (Europe/Rome).
|
||||||
* - rendering HTML standalone (nessun Polaris, CSS inline minimale, accessibile).
|
*
|
||||||
|
* Il RENDERING vive in ./recesso.view.ts (modulo puro, condiviso con l'anteprima
|
||||||
|
* admin). Qui lo ri-esportiamo, cosi' i chiamanti non cambiano.
|
||||||
*
|
*
|
||||||
* NB: `import "server-only"` non è disponibile qui; il suffisso `.server.ts`
|
* NB: `import "server-only"` non è disponibile qui; il suffisso `.server.ts`
|
||||||
* garantisce che Remix non impacchetti questo modulo nel bundle client.
|
* garantisce che Remix non impacchetti questo modulo nel bundle client.
|
||||||
@@ -16,13 +18,13 @@
|
|||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import type { AdminApiContext } from "@shopify/shopify-app-remix/server";
|
import type { AdminApiContext } from "@shopify/shopify-app-remix/server";
|
||||||
import type { ExclusionRule } from "@prisma/client";
|
import type { ExclusionRule } from "@prisma/client";
|
||||||
import { FIELD, INFO_TITLE, INFO_BODY, CONFIRM_LABEL, PAGE_TITLE } from "./recesso.copy";
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
export {
|
||||||
// Costanti path storefront (prefix "apps" + subpath "recesso" da shopify.app.toml).
|
renderStep1,
|
||||||
// Le form fanno POST a questo path: Shopify appende la firma e forwarda a /proxy.
|
renderStep2,
|
||||||
// ---------------------------------------------------------------------------
|
renderStep3,
|
||||||
export const PROXY_STOREFRONT_PATH = "/apps/recesso";
|
renderStep4,
|
||||||
|
} from "./recesso.view";
|
||||||
|
|
||||||
// Locale MVP fisso (multi-lingua -> A7).
|
// Locale MVP fisso (multi-lingua -> A7).
|
||||||
export const MVP_LOCALE = "it";
|
export const MVP_LOCALE = "it";
|
||||||
@@ -31,20 +33,6 @@ export const MVP_LOCALE = "it";
|
|||||||
// Utility
|
// Utility
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/** Escape dei caratteri HTML per prevenire XSS su tutto l'input riflesso. */
|
|
||||||
export function escapeHtml(value: string): string {
|
|
||||||
return value
|
|
||||||
.replace(/&/g, "&")
|
|
||||||
.replace(/</g, "<")
|
|
||||||
.replace(/>/g, ">")
|
|
||||||
.replace(/"/g, """)
|
|
||||||
.replace(/'/g, "'");
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Attributo HTML sicuro (per value="..."): riusa escapeHtml. */
|
|
||||||
export function attr(value: string): string {
|
|
||||||
return escapeHtml(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
export function isValidEmail(value: string): boolean {
|
export function isValidEmail(value: string): boolean {
|
||||||
@@ -97,7 +85,6 @@ const RATE_WINDOW_MS = 15 * 60 * 1000; // 15 minuti
|
|||||||
const RATE_MAX_ATTEMPTS = 8; // tentativi di lookup per finestra, per shop+IP
|
const RATE_MAX_ATTEMPTS = 8; // tentativi di lookup per finestra, per shop+IP
|
||||||
const rateBucket = new Map<string, { count: number; resetAt: number }>();
|
const rateBucket = new Map<string, { count: number; resetAt: number }>();
|
||||||
|
|
||||||
/** Ritorna true se la richiesta è consentita, false se ha superato la soglia. */
|
|
||||||
let lastRatePruneAt = 0;
|
let lastRatePruneAt = 0;
|
||||||
/** Rimuove le voci scadute dal bucket (evita crescita illimitata della Map). */
|
/** Rimuove le voci scadute dal bucket (evita crescita illimitata della Map). */
|
||||||
function pruneRateBucket(now: number): void {
|
function pruneRateBucket(now: number): void {
|
||||||
@@ -108,6 +95,7 @@ function pruneRateBucket(now: number): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Ritorna true se la richiesta è consentita, false se ha superato la soglia. */
|
||||||
export function checkRateLimit(shop: string, ip: string): boolean {
|
export function checkRateLimit(shop: string, ip: string): boolean {
|
||||||
const key = `${shop}:${ip}`;
|
const key = `${shop}:${ip}`;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@@ -645,548 +633,23 @@ export async function lookupOrder(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Rendering HTML - documento standalone, servito sul dominio storefront.
|
|
||||||
// Niente Polaris, niente root layout admin: solo HTML+CSS inline accessibile.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const PAGE_CSS = `
|
|
||||||
:root {
|
|
||||||
color-scheme: light dark;
|
|
||||||
--bg: #f1f2f4;
|
|
||||||
--surface: #ffffff;
|
|
||||||
--text: #1a1a1a;
|
|
||||||
--text-muted: #5c5f62;
|
|
||||||
--border: #d7dadf;
|
|
||||||
--border-input: #8a8f96;
|
|
||||||
--border-input-hover: #6d7175;
|
|
||||||
--accent: #005bd3;
|
|
||||||
--focus-ring: rgba(0, 91, 211, 0.24);
|
|
||||||
--primary-bg: #1a1a1a;
|
|
||||||
--primary-bg-hover: #000000;
|
|
||||||
--primary-text: #ffffff;
|
|
||||||
--secondary-text: #1a1a1a;
|
|
||||||
--subtle-bg: #f6f7f8;
|
|
||||||
--tag-bg: #e4ecf9;
|
|
||||||
--tag-text: #17457f;
|
|
||||||
--info-bg: #eef4fb;
|
|
||||||
--info-border: #cbdcf2;
|
|
||||||
--info-text: #1f3a5f;
|
|
||||||
--coexist-bg: #f6f7f8;
|
|
||||||
--coexist-border: #c7cbd0;
|
|
||||||
--coexist-text: #4a4f54;
|
|
||||||
--error-bg: #fdece8;
|
|
||||||
--error-border: #e3a596;
|
|
||||||
--error-text: #8b1f0e;
|
|
||||||
--success: #0f6b3a;
|
|
||||||
--success-bg: #e4f3ea;
|
|
||||||
--shadow: 0 1px 2px rgba(0, 0, 0, 0.05), 0 10px 30px rgba(18, 24, 40, 0.08);
|
|
||||||
--radius: 14px;
|
|
||||||
--radius-sm: 9px;
|
|
||||||
}
|
|
||||||
* { box-sizing: border-box; }
|
|
||||||
html { -webkit-text-size-adjust: 100%; }
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Inter", sans-serif;
|
|
||||||
line-height: 1.55;
|
|
||||||
color: var(--text);
|
|
||||||
background: var(--bg);
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
text-rendering: optimizeLegibility;
|
|
||||||
}
|
|
||||||
.wrap { max-width: 520px; margin: 0 auto; padding: 32px 16px 72px; }
|
|
||||||
.card {
|
|
||||||
background: var(--surface);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
padding: 28px 26px;
|
|
||||||
box-shadow: var(--shadow);
|
|
||||||
}
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
.wrap { padding: 16px 12px 48px; }
|
|
||||||
.card { padding: 22px 18px; }
|
|
||||||
}
|
|
||||||
/* Struttura a 3 fasce: head / body scorrevole / footer CTA */
|
|
||||||
.rc-foot { margin-top: 22px; }
|
|
||||||
.rc-foot .btn { width: 100%; margin-top: 0; }
|
|
||||||
.rc-foot .actions { display: flex; gap: 12px; margin: 0; }
|
|
||||||
.rc-foot .actions .btn { flex: 1; width: auto; }
|
|
||||||
|
|
||||||
/* Modalità embed (modal): colonna flex a tutta altezza, solo il body scorre */
|
|
||||||
body.embed { background: var(--surface); }
|
|
||||||
body.embed .wrap { max-width: none; margin: 0; padding: 0; }
|
|
||||||
body.embed .card {
|
|
||||||
display: flex; flex-direction: column; height: 100vh;
|
|
||||||
background: transparent; border: 0; border-radius: 0; box-shadow: none; padding: 0;
|
|
||||||
}
|
|
||||||
body.embed .rc-head {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
padding: 20px 24px 15px;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
background: var(--surface);
|
|
||||||
}
|
|
||||||
body.embed .rc-body {
|
|
||||||
flex: 1 1 auto; min-height: 0; overflow-y: auto;
|
|
||||||
padding: 18px 24px 12px;
|
|
||||||
}
|
|
||||||
body.embed .rc-foot {
|
|
||||||
flex: 0 0 auto; margin-top: 0;
|
|
||||||
padding: 14px 24px calc(14px + env(safe-area-inset-bottom, 0px));
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
background: var(--surface);
|
|
||||||
}
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
body.embed .rc-head { padding: 16px 16px 12px; }
|
|
||||||
body.embed .rc-body { padding: 14px 16px 10px; }
|
|
||||||
body.embed .rc-foot { padding: 12px 16px calc(12px + env(safe-area-inset-bottom, 0px)); }
|
|
||||||
}
|
|
||||||
h1 { font-size: 1.5rem; line-height: 1.25; letter-spacing: -0.01em; margin: 0 0 6px; font-weight: 650; }
|
|
||||||
h2 { font-size: 1.05rem; margin: 24px 0 8px; font-weight: 600; }
|
|
||||||
p { margin: 0 0 12px; }
|
|
||||||
.muted { color: var(--text-muted); font-size: 0.95rem; }
|
|
||||||
.muted:last-of-type { margin-bottom: 0; }
|
|
||||||
|
|
||||||
/* Indicatore di step (discreto, non dark-pattern) */
|
|
||||||
.stepper { margin: 0 0 12px; font-size: 0.72rem; font-weight: 600; letter-spacing: 0.09em; text-transform: uppercase; color: var(--text-muted); }
|
|
||||||
.rc-titlerow { display: flex; align-items: center; gap: 8px; }
|
|
||||||
.rc-titlerow h1 { margin: 0; }
|
|
||||||
.rc-i {
|
|
||||||
flex: none; width: 22px; height: 22px; border-radius: 999px;
|
|
||||||
border: 1px solid var(--border-input); background: transparent; color: var(--text-muted);
|
|
||||||
font-size: 0.74rem; font-weight: 700; font-style: italic; font-family: Georgia, "Times New Roman", serif;
|
|
||||||
line-height: 1; cursor: pointer; display: inline-flex; align-items: center; justify-content: center;
|
|
||||||
}
|
|
||||||
.rc-i:hover { color: var(--text); border-color: var(--border-input-hover); }
|
|
||||||
.rc-i:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
||||||
.rc-pop {
|
|
||||||
max-width: 380px; width: calc(100% - 32px); margin: auto; padding: 0;
|
|
||||||
border: 1px solid var(--border); border-radius: 12px;
|
|
||||||
background: var(--surface); color: var(--text); box-shadow: var(--shadow);
|
|
||||||
}
|
|
||||||
.rc-pop::backdrop { background: rgba(0, 0, 0, 0.4); }
|
|
||||||
.rc-pop__bar { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 14px; border-bottom: 1px solid var(--border); font-weight: 600; }
|
|
||||||
.rc-pop__x { border: 0; background: transparent; color: var(--text-muted); font-size: 20px; line-height: 1; cursor: pointer; padding: 2px 4px; }
|
|
||||||
.rc-pop__x:hover { color: var(--text); }
|
|
||||||
.rc-pop__body { padding: 14px; font-size: 0.9rem; color: var(--text-muted); white-space: pre-line; line-height: 1.55; }
|
|
||||||
|
|
||||||
/* Campi */
|
|
||||||
label { display: block; font-weight: 600; font-size: 0.95rem; margin: 20px 0 7px; color: var(--text); }
|
|
||||||
input[type="text"], input[type="email"], textarea {
|
|
||||||
width: 100%;
|
|
||||||
min-height: 46px;
|
|
||||||
padding: 11px 13px;
|
|
||||||
font-size: 1rem;
|
|
||||||
font-family: inherit;
|
|
||||||
line-height: 1.5;
|
|
||||||
color: var(--text);
|
|
||||||
background: var(--surface);
|
|
||||||
border: 1px solid var(--border-input);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
|
||||||
}
|
|
||||||
input::placeholder, textarea::placeholder { color: var(--text-muted); opacity: 0.8; }
|
|
||||||
textarea { min-height: 128px; resize: vertical; }
|
|
||||||
input:hover, textarea:hover { border-color: var(--border-input-hover); }
|
|
||||||
input:focus, textarea:focus {
|
|
||||||
border-color: var(--accent);
|
|
||||||
box-shadow: 0 0 0 3px var(--focus-ring);
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
.hint { font-weight: 400; color: var(--text-muted); font-size: 0.85rem; margin: 6px 0 0; }
|
|
||||||
|
|
||||||
/* Email ricevuta: de-enfatizzata ma editabile */
|
|
||||||
.receipt-field {
|
|
||||||
margin-top: 20px;
|
|
||||||
padding: 14px 15px 15px;
|
|
||||||
background: var(--subtle-bg);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
}
|
|
||||||
.receipt-field label { margin-top: 0; font-size: 0.9rem; }
|
|
||||||
.receipt-field .hint { margin-top: 8px; }
|
|
||||||
.tag {
|
|
||||||
display: inline-block;
|
|
||||||
margin-left: 6px;
|
|
||||||
padding: 2px 8px;
|
|
||||||
font-size: 0.66rem;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
vertical-align: middle;
|
|
||||||
color: var(--tag-text);
|
|
||||||
background: var(--tag-bg);
|
|
||||||
border-radius: 999px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Bottoni */
|
|
||||||
.btn {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
min-height: 46px;
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 600;
|
|
||||||
font-family: inherit;
|
|
||||||
padding: 12px 22px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
border: 1px solid transparent;
|
|
||||||
cursor: pointer;
|
|
||||||
margin-top: 24px;
|
|
||||||
transition: background 0.15s ease, border-color 0.15s ease, transform 0.05s ease;
|
|
||||||
}
|
|
||||||
.btn:active { transform: translateY(1px); }
|
|
||||||
.btn-primary { background: var(--primary-bg); color: var(--primary-text); }
|
|
||||||
.btn-primary:hover { background: var(--primary-bg-hover); }
|
|
||||||
.btn-secondary { background: var(--surface); color: var(--secondary-text); border-color: var(--border-input); }
|
|
||||||
.btn-secondary:hover { background: var(--subtle-bg); border-color: var(--border-input-hover); }
|
|
||||||
.btn:focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; }
|
|
||||||
.btn:focus:not(:focus-visible) { outline: none; }
|
|
||||||
|
|
||||||
/* Riga azioni (step riepilogo) */
|
|
||||||
.actions { display: flex; flex-direction: column; gap: 12px; margin-top: 26px; }
|
|
||||||
.actions form { margin: 0; }
|
|
||||||
.actions .btn { margin-top: 0; width: 100%; }
|
|
||||||
@media (min-width: 460px) {
|
|
||||||
.actions { flex-direction: row; }
|
|
||||||
.actions form { flex: 1; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Info / coesistenza / errore */
|
|
||||||
.info {
|
|
||||||
background: var(--info-bg);
|
|
||||||
border: 1px solid var(--info-border);
|
|
||||||
color: var(--info-text);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
padding: 14px 16px;
|
|
||||||
margin: 18px 0 4px;
|
|
||||||
white-space: pre-line;
|
|
||||||
font-size: 0.92rem;
|
|
||||||
}
|
|
||||||
.coexist {
|
|
||||||
background: var(--coexist-bg);
|
|
||||||
border: 1px solid var(--coexist-border);
|
|
||||||
border-left: 3px solid var(--border-input);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
padding: 13px 16px;
|
|
||||||
margin: 26px 0 0;
|
|
||||||
white-space: pre-line;
|
|
||||||
font-size: 0.86rem;
|
|
||||||
color: var(--coexist-text);
|
|
||||||
}
|
|
||||||
.error {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 10px;
|
|
||||||
background: var(--error-bg);
|
|
||||||
border: 1px solid var(--error-border);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
padding: 12px 14px;
|
|
||||||
margin: 0 0 18px;
|
|
||||||
color: var(--error-text);
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 0.92rem;
|
|
||||||
}
|
|
||||||
.error__icon { flex: none; width: 20px; height: 20px; margin-top: 1px; fill: currentColor; }
|
|
||||||
.notice {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 10px;
|
|
||||||
background: rgba(240, 170, 40, 0.14);
|
|
||||||
border: 1px solid rgba(240, 170, 40, 0.55);
|
|
||||||
border-left: 3px solid rgba(240, 170, 40, 0.95);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
padding: 12px 14px;
|
|
||||||
margin: 0 0 18px;
|
|
||||||
color: var(--text);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
.notice__icon { flex: none; width: 20px; height: 20px; margin-top: 1px; fill: #e0a020; }
|
|
||||||
|
|
||||||
/* Riepilogo (step 3) */
|
|
||||||
.summary { margin: 18px 0 4px; }
|
|
||||||
.summary dt { font-weight: 600; font-size: 0.82rem; text-transform: uppercase; letter-spacing: 0.03em; color: var(--text-muted); margin-top: 16px; }
|
|
||||||
.summary dt:first-child { margin-top: 0; }
|
|
||||||
.summary dd { margin: 3px 0 0; white-space: pre-line; color: var(--text); }
|
|
||||||
|
|
||||||
/* Successo (step 4) */
|
|
||||||
.success { text-align: center; padding: 6px 0 2px; }
|
|
||||||
.success__icon {
|
|
||||||
width: 44px; height: 44px; margin: 0 auto 14px;
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: var(--success-bg);
|
|
||||||
}
|
|
||||||
.success__icon svg { width: 24px; height: 24px; fill: var(--success); }
|
|
||||||
.success h1 { color: var(--success); }
|
|
||||||
.success p { color: var(--text-muted); }
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
:root {
|
|
||||||
--bg: #0f1114;
|
|
||||||
--surface: #1b1d21;
|
|
||||||
--text: #e7e9ec;
|
|
||||||
--text-muted: #a1a6ad;
|
|
||||||
--border: #34373d;
|
|
||||||
--border-input: #4c5058;
|
|
||||||
--border-input-hover: #676c75;
|
|
||||||
--accent: #5aa2ff;
|
|
||||||
--focus-ring: rgba(90, 162, 255, 0.34);
|
|
||||||
--primary-bg: #e7e9ec;
|
|
||||||
--primary-bg-hover: #ffffff;
|
|
||||||
--primary-text: #16181c;
|
|
||||||
--secondary-text: #e7e9ec;
|
|
||||||
--subtle-bg: #212429;
|
|
||||||
--tag-bg: #23374f;
|
|
||||||
--tag-text: #bcd6f7;
|
|
||||||
--info-bg: #15243a;
|
|
||||||
--info-border: #2d4a6b;
|
|
||||||
--info-text: #cfe0f5;
|
|
||||||
--coexist-bg: #212429;
|
|
||||||
--coexist-border: #3a3e45;
|
|
||||||
--coexist-text: #a1a6ad;
|
|
||||||
--error-bg: #3a1512;
|
|
||||||
--error-border: #7a2a1c;
|
|
||||||
--error-text: #ffb4a2;
|
|
||||||
--success: #5fd08a;
|
|
||||||
--success-bg: #163021;
|
|
||||||
--shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 12px 34px rgba(0, 0, 0, 0.45);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
* { transition: none !important; }
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
/** Wrapper documento HTML standalone. `inner` è già HTML sicuro. */
|
|
||||||
export function renderShell(inner: string): string {
|
|
||||||
return `<!doctype html>
|
|
||||||
<html lang="it">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<meta name="color-scheme" content="light dark">
|
|
||||||
<meta name="robots" content="noindex">
|
|
||||||
<title>${escapeHtml(PAGE_TITLE)}</title>
|
|
||||||
<style>${PAGE_CSS}</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<script>(function(){if(window.self!==window.top){try{document.body.className="embed";}catch(e){}}})();</script>
|
|
||||||
<main class="wrap">
|
|
||||||
<div class="card">
|
|
||||||
${inner}
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</body>
|
|
||||||
</html>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function errorBanner(message?: string): string {
|
|
||||||
if (!message) return "";
|
|
||||||
return `<div class="error" role="alert">
|
|
||||||
<svg class="error__icon" viewBox="0 0 20 20" aria-hidden="true" focusable="false"><path d="M10 1.5a8.5 8.5 0 1 0 0 17 8.5 8.5 0 0 0 0-17ZM9 6h2v6H9V6Zm0 7h2v2H9v-2Z"/></svg>
|
|
||||||
<span>${escapeHtml(message)}</span>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function noticeBanner(message?: string): string {
|
|
||||||
if (!message) return "";
|
|
||||||
return `<div class="notice" role="status">
|
|
||||||
<svg class="notice__icon" viewBox="0 0 20 20" aria-hidden="true" focusable="false"><path d="M10 1.5a8.5 8.5 0 1 0 0 17 8.5 8.5 0 0 0 0-17ZM9 5h2v2H9V5Zm0 4h2v6H9V9Z"/></svg>
|
|
||||||
<span>${escapeHtml(message)}</span>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pulsante info "i" + popover nativo (mini-modal, zero JS via Popover API).
|
* Response HTML standalone (status 200 di default per non leakare via status).
|
||||||
* Il popover vive nel top-layer; chiusura con Esc, click fuori o pulsante.
|
*
|
||||||
|
* `no-store`: la pagina contiene numero d'ordine, email e dichiarazione del
|
||||||
|
* consumatore. Non deve finire nella cache del browser (ne' in quella di un
|
||||||
|
* proxy intermedio), sia per privacy sia perche' altrimenti il cliente rivede
|
||||||
|
* una versione vecchia del form dopo un cambio di configurazione.
|
||||||
*/
|
*/
|
||||||
function infoWidget(id: string, title: string, body: string): string {
|
|
||||||
return `<button type="button" class="rc-i" popovertarget="${id}" aria-label="${attr(title)}">i</button>
|
|
||||||
<div id="${id}" popover class="rc-pop" role="dialog" aria-label="${attr(title)}">
|
|
||||||
<div class="rc-pop__bar"><span>${escapeHtml(title)}</span><button type="button" class="rc-pop__x" popovertarget="${id}" popovertargetaction="hide" aria-label="Chiudi">×</button></div>
|
|
||||||
<div class="rc-pop__body">${escapeHtml(body)}</div>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Header comune: step + titolo con pulsante info. */
|
|
||||||
function stepHead(step: number, subtitle?: string): string {
|
|
||||||
return `${stepIndicator(step)}
|
|
||||||
<div class="rc-titlerow"><h1>${escapeHtml(PAGE_TITLE)}</h1>${infoWidget("rcinfo", INFO_TITLE, INFO_BODY)}</div>${
|
|
||||||
subtitle ? `\n<p class="muted">${escapeHtml(subtitle)}</p>` : ""
|
|
||||||
}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Indicatore di step discreto in cima alla card (accessibilità: la traccia è
|
|
||||||
* decorativa/aria-hidden, l'etichetta testuale resta leggibile). NON è un
|
|
||||||
* dark-pattern: comunica solo a che punto è l'utente.
|
|
||||||
*/
|
|
||||||
function stepIndicator(current: number): string {
|
|
||||||
const labels: Record<number, string> = {
|
|
||||||
1: "Passo 1 di 2",
|
|
||||||
2: "Passo 2 di 2",
|
|
||||||
3: "Conferma",
|
|
||||||
4: "Fatto",
|
|
||||||
};
|
|
||||||
const label = labels[current] ?? "";
|
|
||||||
return `<p class="stepper">${escapeHtml(label)}</p>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Layout a 3 fasce: header fisso (contesto + step), corpo scorrevole, footer con
|
|
||||||
* la CTA. In pagina piena è flusso normale; in modalità embed (modal) diventa una
|
|
||||||
* colonna flex a tutta altezza con header/footer ancorati e solo il corpo che scorre
|
|
||||||
* (pattern dei modal moderni). I bottoni stanno nel footer e referenziano la form
|
|
||||||
* via attributo `form=` (HTML5), così restano sempre visibili.
|
|
||||||
*/
|
|
||||||
function stepLayout(parts: { head: string; body: string; foot?: string }): string {
|
|
||||||
return `<div class="rc-head">
|
|
||||||
${parts.head}
|
|
||||||
</div>
|
|
||||||
<div class="rc-body">
|
|
||||||
${parts.body}
|
|
||||||
</div>${
|
|
||||||
parts.foot
|
|
||||||
? `
|
|
||||||
<div class="rc-foot">
|
|
||||||
${parts.foot}
|
|
||||||
</div>`
|
|
||||||
: ""
|
|
||||||
}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Step 1: lookup guest -------------------------------------------------
|
|
||||||
export function renderStep1(opts?: {
|
|
||||||
error?: string;
|
|
||||||
orderName?: string;
|
|
||||||
email?: string;
|
|
||||||
}): string {
|
|
||||||
const orderName = opts?.orderName ?? "";
|
|
||||||
const email = opts?.email ?? "";
|
|
||||||
return renderShell(
|
|
||||||
stepLayout({
|
|
||||||
head: stepHead(1),
|
|
||||||
body: `<p class="muted">Inserisci numero dell'ordine ed email dell'acquisto. Non serve un account.</p>
|
|
||||||
${errorBanner(opts?.error)}
|
|
||||||
<form id="rcform" method="post" action="${PROXY_STOREFRONT_PATH}" novalidate>
|
|
||||||
<input type="hidden" name="intent" value="lookup">
|
|
||||||
<label for="orderName">${escapeHtml(FIELD.orderName.label)}</label>
|
|
||||||
<input type="text" id="orderName" name="orderName" value="${attr(orderName)}" placeholder="${attr(FIELD.orderName.placeholder)}" autocomplete="off" required>
|
|
||||||
<label for="email">${escapeHtml(FIELD.email.label)}</label>
|
|
||||||
<input type="email" id="email" name="email" value="${attr(email)}" placeholder="${attr(FIELD.email.placeholder)}" autocomplete="email" required>
|
|
||||||
</form>`,
|
|
||||||
foot: `<button type="submit" form="rcform" class="btn btn-primary">Continua</button>`,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Step 2: form dati + dichiarazione ------------------------------------
|
|
||||||
export function renderStep2(data: {
|
|
||||||
orderId: string;
|
|
||||||
orderName: string;
|
|
||||||
email: string;
|
|
||||||
customerName?: string;
|
|
||||||
statementText: string;
|
|
||||||
error?: string;
|
|
||||||
notice?: string;
|
|
||||||
}): string {
|
|
||||||
const customerName = data.customerName ?? "";
|
|
||||||
return renderShell(
|
|
||||||
stepLayout({
|
|
||||||
head: stepHead(2, `Ordine ${data.orderName}`),
|
|
||||||
body: `${errorBanner(data.error)}${noticeBanner(data.notice)}
|
|
||||||
<form id="rcform" method="post" action="${PROXY_STOREFRONT_PATH}" novalidate>
|
|
||||||
<input type="hidden" name="intent" value="details">
|
|
||||||
<input type="hidden" name="orderId" value="${attr(data.orderId)}">
|
|
||||||
<input type="hidden" name="orderName" value="${attr(data.orderName)}">
|
|
||||||
|
|
||||||
<label for="customerName">${escapeHtml(FIELD.name.label)}</label>
|
|
||||||
<input type="text" id="customerName" name="customerName" value="${attr(customerName)}" placeholder="${attr(FIELD.name.placeholder)}" autocomplete="name" required>
|
|
||||||
|
|
||||||
<label for="statementText">${escapeHtml(FIELD.statement.label)}</label>
|
|
||||||
<textarea id="statementText" name="statementText" required>${escapeHtml(data.statementText)}</textarea>
|
|
||||||
|
|
||||||
<label for="email">${escapeHtml(FIELD.email.label)}</label>
|
|
||||||
<input type="email" id="email" name="email" value="${attr(data.email)}" autocomplete="email" required>
|
|
||||||
<p class="hint">Ti invieremo qui la ricevuta.</p>
|
|
||||||
</form>`,
|
|
||||||
foot: `<button type="submit" form="rcform" class="btn btn-primary">Continua</button>`,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Step 3: riepilogo + conferma dedicata --------------------------------
|
|
||||||
export function renderStep3(data: {
|
|
||||||
orderId: string;
|
|
||||||
orderName: string;
|
|
||||||
email: string;
|
|
||||||
customerName: string;
|
|
||||||
statementText: string;
|
|
||||||
error?: string;
|
|
||||||
}): string {
|
|
||||||
return renderShell(
|
|
||||||
stepLayout({
|
|
||||||
head: stepHead(3, "Controlla i dati prima di confermare."),
|
|
||||||
body: `${errorBanner(data.error)}
|
|
||||||
<dl class="summary">
|
|
||||||
<dt>${escapeHtml(FIELD.orderName.label)}</dt>
|
|
||||||
<dd>${escapeHtml(data.orderName)}</dd>
|
|
||||||
<dt>${escapeHtml(FIELD.name.label)}</dt>
|
|
||||||
<dd>${escapeHtml(data.customerName)}</dd>
|
|
||||||
<dt>${escapeHtml(FIELD.email.label)}</dt>
|
|
||||||
<dd>${escapeHtml(data.email)}</dd>
|
|
||||||
<dt>${escapeHtml(FIELD.statement.label)}</dt>
|
|
||||||
<dd>${escapeHtml(data.statementText)}</dd>
|
|
||||||
</dl>
|
|
||||||
<form id="rcedit" method="post" action="${PROXY_STOREFRONT_PATH}">
|
|
||||||
<input type="hidden" name="intent" value="edit">
|
|
||||||
<input type="hidden" name="orderId" value="${attr(data.orderId)}">
|
|
||||||
<input type="hidden" name="orderName" value="${attr(data.orderName)}">
|
|
||||||
<input type="hidden" name="email" value="${attr(data.email)}">
|
|
||||||
<input type="hidden" name="customerName" value="${attr(data.customerName)}">
|
|
||||||
<input type="hidden" name="statementText" value="${attr(data.statementText)}">
|
|
||||||
</form>
|
|
||||||
<form id="rcconfirm" method="post" action="${PROXY_STOREFRONT_PATH}">
|
|
||||||
<input type="hidden" name="intent" value="confirm">
|
|
||||||
<input type="hidden" name="orderId" value="${attr(data.orderId)}">
|
|
||||||
<input type="hidden" name="orderName" value="${attr(data.orderName)}">
|
|
||||||
<input type="hidden" name="email" value="${attr(data.email)}">
|
|
||||||
<input type="hidden" name="customerName" value="${attr(data.customerName)}">
|
|
||||||
<input type="hidden" name="statementText" value="${attr(data.statementText)}">
|
|
||||||
</form>`,
|
|
||||||
foot: `<div class="actions">
|
|
||||||
<button type="submit" form="rcedit" class="btn btn-secondary">Torna indietro</button>
|
|
||||||
<button type="submit" form="rcconfirm" class="btn btn-primary">${escapeHtml(CONFIRM_LABEL)}</button>
|
|
||||||
</div>`,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Step 4: successo -----------------------------------------------------
|
|
||||||
export function renderStep4(data: {
|
|
||||||
line1: string;
|
|
||||||
line2: string;
|
|
||||||
line3: string;
|
|
||||||
}): string {
|
|
||||||
return renderShell(
|
|
||||||
stepLayout({
|
|
||||||
head: `${stepIndicator(4)}`,
|
|
||||||
body: `<div class="success">
|
|
||||||
<div class="success__icon" aria-hidden="true"><svg viewBox="0 0 24 24" focusable="false"><path d="M9.55 17.05 4.5 12l1.4-1.4 3.65 3.6 8.15-8.15L19.1 7.5z"/></svg></div>
|
|
||||||
<h1>${escapeHtml(data.line1)}</h1>
|
|
||||||
<p>${escapeHtml(data.line2)}</p>
|
|
||||||
<p>${escapeHtml(data.line3)}</p>
|
|
||||||
</div>`,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Helper: Response HTML standalone (status 200 di default per non leakare via status). */
|
|
||||||
export function htmlResponse(html: string, status = 200): Response {
|
export function htmlResponse(html: string, status = 200): Response {
|
||||||
return new Response(html, {
|
return new Response(html, {
|
||||||
status,
|
status,
|
||||||
headers: { "Content-Type": "text/html; charset=utf-8" },
|
headers: {
|
||||||
|
"Content-Type": "text/html; charset=utf-8",
|
||||||
|
"Cache-Control": "no-store, no-cache, must-revalidate",
|
||||||
|
Pragma: "no-cache",
|
||||||
|
"Referrer-Policy": "no-referrer",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
622
app/app/lib/recesso.view.ts
Normal file
622
app/app/lib/recesso.view.ts
Normal file
@@ -0,0 +1,622 @@
|
|||||||
|
/**
|
||||||
|
* Vista del form di recesso — modulo PURO (niente node/server).
|
||||||
|
*
|
||||||
|
* Estratto da recesso.server.ts perche' l'anteprima nell'admin deve rendere
|
||||||
|
* ESATTAMENTE lo stesso markup e lo stesso CSS dello storefront. Un iframe che
|
||||||
|
* puntasse a una route admin non funzionerebbe: l'admin embedded si autentica
|
||||||
|
* con session token via App Bridge, che una navigazione iframe non porta.
|
||||||
|
*
|
||||||
|
* recesso.server.ts ri-esporta renderStep1..4, quindi nessun chiamante cambia.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
DEFAULT_SCHEME,
|
||||||
|
schemeOrNull,
|
||||||
|
themeStyle,
|
||||||
|
type ColorScheme,
|
||||||
|
type ThemeTokens,
|
||||||
|
} from "./theme";
|
||||||
|
import {
|
||||||
|
FIELD,
|
||||||
|
INFO_TITLE,
|
||||||
|
INFO_BODY,
|
||||||
|
CONFIRM_LABEL,
|
||||||
|
PAGE_TITLE,
|
||||||
|
SCOPE_HINT,
|
||||||
|
} from "./recesso.copy";
|
||||||
|
|
||||||
|
// Costanti path storefront (prefix "apps" + subpath "recesso" da shopify.app.toml).
|
||||||
|
// Le form fanno POST a questo path: Shopify appende la firma e forwarda a /proxy.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export const PROXY_STOREFRONT_PATH = "/apps/recesso";
|
||||||
|
|
||||||
|
/** Escape dei caratteri HTML per prevenire XSS su tutto l'input riflesso. */
|
||||||
|
export function escapeHtml(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Attributo HTML sicuro (per value="..."): riusa escapeHtml. */
|
||||||
|
export function attr(value: string): string {
|
||||||
|
return escapeHtml(value);
|
||||||
|
}
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Rendering HTML - documento standalone, servito sul dominio storefront.
|
||||||
|
// Niente Polaris, niente root layout admin: solo HTML+CSS inline accessibile.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const PAGE_CSS = `
|
||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
--bg: #f1f2f4;
|
||||||
|
--surface: #ffffff;
|
||||||
|
--text: #1a1a1a;
|
||||||
|
--text-muted: #5c5f62;
|
||||||
|
--border: #d7dadf;
|
||||||
|
--border-input: #8a8f96;
|
||||||
|
--border-input-hover: #6d7175;
|
||||||
|
--accent: #005bd3;
|
||||||
|
--focus-ring: rgba(0, 91, 211, 0.24);
|
||||||
|
--primary-bg: #1a1a1a;
|
||||||
|
--primary-bg-hover: #000000;
|
||||||
|
--primary-text: #ffffff;
|
||||||
|
--secondary-text: #1a1a1a;
|
||||||
|
--subtle-bg: #f6f7f8;
|
||||||
|
--tag-bg: #e4ecf9;
|
||||||
|
--tag-text: #17457f;
|
||||||
|
--info-bg: #eef4fb;
|
||||||
|
--info-border: #cbdcf2;
|
||||||
|
--info-text: #1f3a5f;
|
||||||
|
--coexist-bg: #f6f7f8;
|
||||||
|
--coexist-border: #c7cbd0;
|
||||||
|
--coexist-text: #4a4f54;
|
||||||
|
--error-bg: #fdece8;
|
||||||
|
--error-border: #e3a596;
|
||||||
|
--error-text: #8b1f0e;
|
||||||
|
--success: #0f6b3a;
|
||||||
|
--success-bg: #e4f3ea;
|
||||||
|
--shadow: 0 1px 2px rgba(0, 0, 0, 0.05), 0 10px 30px rgba(18, 24, 40, 0.08);
|
||||||
|
--radius: 14px;
|
||||||
|
--radius-sm: 9px;
|
||||||
|
--font: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Inter", sans-serif;
|
||||||
|
--card-max: 520px;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html { -webkit-text-size-adjust: 100%; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font);
|
||||||
|
line-height: 1.55;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--bg);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
}
|
||||||
|
.wrap { max-width: var(--card-max); margin: 0 auto; padding: 32px 16px 72px; }
|
||||||
|
.card {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 28px 26px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.wrap { padding: 16px 12px 48px; }
|
||||||
|
.card { padding: 22px 18px; }
|
||||||
|
}
|
||||||
|
/* Struttura a 3 fasce: head / body scorrevole / footer CTA */
|
||||||
|
.rc-foot { margin-top: 22px; }
|
||||||
|
.rc-foot .btn { width: 100%; margin-top: 0; }
|
||||||
|
.rc-foot .actions { display: flex; gap: 12px; margin: 0; }
|
||||||
|
.rc-foot .actions .btn { flex: 1; width: auto; }
|
||||||
|
|
||||||
|
/* Modalità embed (modal): colonna flex a tutta altezza, solo il body scorre */
|
||||||
|
body.embed { background: var(--surface); }
|
||||||
|
body.embed .wrap { max-width: none; margin: 0; padding: 0; }
|
||||||
|
body.embed .card {
|
||||||
|
display: flex; flex-direction: column; height: 100vh;
|
||||||
|
background: transparent; border: 0; border-radius: 0; box-shadow: none; padding: 0;
|
||||||
|
}
|
||||||
|
body.embed .rc-head {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 20px 24px 15px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
body.embed .rc-body {
|
||||||
|
flex: 1 1 auto; min-height: 0; overflow-y: auto;
|
||||||
|
padding: 18px 24px 12px;
|
||||||
|
}
|
||||||
|
body.embed .rc-foot {
|
||||||
|
flex: 0 0 auto; margin-top: 0;
|
||||||
|
padding: 14px 24px calc(14px + env(safe-area-inset-bottom, 0px));
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
body.embed .rc-head { padding: 16px 16px 12px; }
|
||||||
|
body.embed .rc-body { padding: 14px 16px 10px; }
|
||||||
|
body.embed .rc-foot { padding: 12px 16px calc(12px + env(safe-area-inset-bottom, 0px)); }
|
||||||
|
}
|
||||||
|
h1 { font-size: 1.5rem; line-height: 1.25; letter-spacing: -0.01em; margin: 0 0 6px; font-weight: 650; }
|
||||||
|
h2 { font-size: 1.05rem; margin: 24px 0 8px; font-weight: 600; }
|
||||||
|
p { margin: 0 0 12px; }
|
||||||
|
.muted { color: var(--text-muted); font-size: 0.95rem; }
|
||||||
|
.muted:last-of-type { margin-bottom: 0; }
|
||||||
|
|
||||||
|
/* Indicatore di step (discreto, non dark-pattern) */
|
||||||
|
.stepper { margin: 0 0 12px; font-size: 0.72rem; font-weight: 600; letter-spacing: 0.09em; text-transform: uppercase; color: var(--text-muted); }
|
||||||
|
.rc-titlerow { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.rc-titlerow h1 { margin: 0; }
|
||||||
|
.rc-i {
|
||||||
|
flex: none; width: 22px; height: 22px; border-radius: 999px;
|
||||||
|
border: 1px solid var(--border-input); background: transparent; color: var(--text-muted);
|
||||||
|
font-size: 0.74rem; font-weight: 700; font-style: italic; font-family: Georgia, "Times New Roman", serif;
|
||||||
|
line-height: 1; cursor: pointer; display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.rc-i:hover { color: var(--text); border-color: var(--border-input-hover); }
|
||||||
|
.rc-i:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||||
|
.rc-pop {
|
||||||
|
max-width: 380px; width: calc(100% - 32px); margin: auto; padding: 0;
|
||||||
|
border: 1px solid var(--border); border-radius: 12px;
|
||||||
|
background: var(--surface); color: var(--text); box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
.rc-pop::backdrop { background: rgba(0, 0, 0, 0.4); }
|
||||||
|
.rc-pop__bar { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 14px; border-bottom: 1px solid var(--border); font-weight: 600; }
|
||||||
|
.rc-pop__x { border: 0; background: transparent; color: var(--text-muted); font-size: 20px; line-height: 1; cursor: pointer; padding: 2px 4px; }
|
||||||
|
.rc-pop__x:hover { color: var(--text); }
|
||||||
|
.rc-pop__body { padding: 14px; font-size: 0.9rem; color: var(--text-muted); white-space: pre-line; line-height: 1.55; }
|
||||||
|
|
||||||
|
/* Campi */
|
||||||
|
label { display: block; font-weight: 600; font-size: 0.95rem; margin: 20px 0 7px; color: var(--text); }
|
||||||
|
input[type="text"], input[type="email"], textarea {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 46px;
|
||||||
|
padding: 11px 13px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-family: inherit;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border-input);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
}
|
||||||
|
input::placeholder, textarea::placeholder { color: var(--text-muted); opacity: 0.8; }
|
||||||
|
textarea { min-height: 128px; resize: vertical; }
|
||||||
|
input:hover, textarea:hover { border-color: var(--border-input-hover); }
|
||||||
|
input:focus, textarea:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px var(--focus-ring);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.hint { font-weight: 400; color: var(--text-muted); font-size: 0.85rem; margin: 6px 0 0; }
|
||||||
|
|
||||||
|
/* Email ricevuta: de-enfatizzata ma editabile */
|
||||||
|
.receipt-field {
|
||||||
|
margin-top: 20px;
|
||||||
|
padding: 14px 15px 15px;
|
||||||
|
background: var(--subtle-bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
.receipt-field label { margin-top: 0; font-size: 0.9rem; }
|
||||||
|
.receipt-field .hint { margin-top: 8px; }
|
||||||
|
.tag {
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: 6px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
font-size: 0.66rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
vertical-align: middle;
|
||||||
|
color: var(--tag-text);
|
||||||
|
background: var(--tag-bg);
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bottoni */
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 46px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: inherit;
|
||||||
|
padding: 12px 22px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-top: 24px;
|
||||||
|
transition: background 0.15s ease, border-color 0.15s ease, transform 0.05s ease;
|
||||||
|
}
|
||||||
|
.btn:active { transform: translateY(1px); }
|
||||||
|
.btn-primary { background: var(--primary-bg); color: var(--primary-text); }
|
||||||
|
.btn-primary:hover { background: var(--primary-bg-hover); }
|
||||||
|
.btn-secondary { background: var(--surface); color: var(--secondary-text); border-color: var(--border-input); }
|
||||||
|
.btn-secondary:hover { background: var(--subtle-bg); border-color: var(--border-input-hover); }
|
||||||
|
.btn:focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; }
|
||||||
|
.btn:focus:not(:focus-visible) { outline: none; }
|
||||||
|
|
||||||
|
/* Riga azioni (step riepilogo) */
|
||||||
|
.actions { display: flex; flex-direction: column; gap: 12px; margin-top: 26px; }
|
||||||
|
.actions form { margin: 0; }
|
||||||
|
.actions .btn { margin-top: 0; width: 100%; }
|
||||||
|
@media (min-width: 460px) {
|
||||||
|
.actions { flex-direction: row; }
|
||||||
|
.actions form { flex: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Info / coesistenza / errore */
|
||||||
|
.info {
|
||||||
|
background: var(--info-bg);
|
||||||
|
border: 1px solid var(--info-border);
|
||||||
|
color: var(--info-text);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 14px 16px;
|
||||||
|
margin: 18px 0 4px;
|
||||||
|
white-space: pre-line;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
}
|
||||||
|
.coexist {
|
||||||
|
background: var(--coexist-bg);
|
||||||
|
border: 1px solid var(--coexist-border);
|
||||||
|
border-left: 3px solid var(--border-input);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 13px 16px;
|
||||||
|
margin: 26px 0 0;
|
||||||
|
white-space: pre-line;
|
||||||
|
font-size: 0.86rem;
|
||||||
|
color: var(--coexist-text);
|
||||||
|
}
|
||||||
|
.error {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
background: var(--error-bg);
|
||||||
|
border: 1px solid var(--error-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 12px 14px;
|
||||||
|
margin: 0 0 18px;
|
||||||
|
color: var(--error-text);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
}
|
||||||
|
.error__icon { flex: none; width: 20px; height: 20px; margin-top: 1px; fill: currentColor; }
|
||||||
|
.notice {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
background: rgba(240, 170, 40, 0.14);
|
||||||
|
border: 1px solid rgba(240, 170, 40, 0.55);
|
||||||
|
border-left: 3px solid rgba(240, 170, 40, 0.95);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 12px 14px;
|
||||||
|
margin: 0 0 18px;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.notice__icon { flex: none; width: 20px; height: 20px; margin-top: 1px; fill: #e0a020; }
|
||||||
|
|
||||||
|
/* Riepilogo (step 3) */
|
||||||
|
.summary { margin: 18px 0 4px; }
|
||||||
|
.summary dt { font-weight: 600; font-size: 0.82rem; text-transform: uppercase; letter-spacing: 0.03em; color: var(--text-muted); margin-top: 16px; }
|
||||||
|
.summary dt:first-child { margin-top: 0; }
|
||||||
|
.summary dd { margin: 3px 0 0; white-space: pre-line; color: var(--text); }
|
||||||
|
|
||||||
|
/* Successo (step 4) */
|
||||||
|
.success { text-align: center; padding: 6px 0 2px; }
|
||||||
|
.success__icon {
|
||||||
|
width: 44px; height: 44px; margin: 0 auto 14px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--success-bg);
|
||||||
|
}
|
||||||
|
.success__icon svg { width: 24px; height: 24px; fill: var(--success); }
|
||||||
|
.success h1 { color: var(--success); }
|
||||||
|
.success p { color: var(--text-muted); }
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
* { transition: none !important; }
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Palette scura: solo le dichiarazioni, senza selettore.
|
||||||
|
*
|
||||||
|
* Sta fuori da PAGE_CSS perche' il merchant decide *se* e *quando* applicarla
|
||||||
|
* (impostazione "Schema colore"). Prima era una `@media (prefers-color-scheme:
|
||||||
|
* dark)` incondizionata dentro PAGE_CSS: seguiva l'OS del visitatore e nessun
|
||||||
|
* override del merchant poteva spegnerla.
|
||||||
|
*/
|
||||||
|
const DARK_VARS = `
|
||||||
|
color-scheme: dark;
|
||||||
|
--bg: #0f1114;
|
||||||
|
--surface: #1b1d21;
|
||||||
|
--text: #e7e9ec;
|
||||||
|
--text-muted: #a1a6ad;
|
||||||
|
--border: #34373d;
|
||||||
|
--border-input: #4c5058;
|
||||||
|
--border-input-hover: #676c75;
|
||||||
|
--accent: #5aa2ff;
|
||||||
|
--focus-ring: rgba(90, 162, 255, 0.34);
|
||||||
|
--primary-bg: #e7e9ec;
|
||||||
|
--primary-bg-hover: #ffffff;
|
||||||
|
--primary-text: #16181c;
|
||||||
|
--secondary-text: #e7e9ec;
|
||||||
|
--subtle-bg: #212429;
|
||||||
|
--tag-bg: #23374f;
|
||||||
|
--tag-text: #bcd6f7;
|
||||||
|
--info-bg: #15243a;
|
||||||
|
--info-border: #2d4a6b;
|
||||||
|
--info-text: #cfe0f5;
|
||||||
|
--coexist-bg: #212429;
|
||||||
|
--coexist-border: #3a3e45;
|
||||||
|
--coexist-text: #a1a6ad;
|
||||||
|
--error-bg: #3a1512;
|
||||||
|
--error-border: #7a2a1c;
|
||||||
|
--error-text: #ffb4a2;
|
||||||
|
--success: #5fd08a;
|
||||||
|
--success-bg: #163021;
|
||||||
|
--shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 12px 34px rgba(0, 0, 0, 0.45);
|
||||||
|
`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CSS dello schema colore. Va iniettato DOPO PAGE_CSS (che porta la palette
|
||||||
|
* chiara) e PRIMA dell'override del merchant, cosi' accento e bottone
|
||||||
|
* personalizzati vincono in entrambi gli schemi.
|
||||||
|
*/
|
||||||
|
function schemeCss(scheme: ColorScheme): string {
|
||||||
|
if (scheme === "dark") return `:root{${DARK_VARS}}`;
|
||||||
|
if (scheme === "auto")
|
||||||
|
return `@media (prefers-color-scheme: dark){:root{${DARK_VARS}}}`;
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `color-scheme` per i controlli nativi (scrollbar, date picker, select). */
|
||||||
|
function schemeMeta(scheme: ColorScheme): string {
|
||||||
|
return scheme === "auto" ? "light dark" : scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wrapper documento HTML standalone. `inner` è già HTML sicuro. */
|
||||||
|
export function renderShell(inner: string, theme?: ThemeTokens | null): string {
|
||||||
|
// Cascata, in quest'ordine: palette chiara di base -> schema colore scelto dal
|
||||||
|
// merchant -> suoi override. L'override arriva per ultimo, quindi accento e
|
||||||
|
// bottone personalizzati valgono anche in tema scuro; e se sbaglia una
|
||||||
|
// configurazione, sotto resta comunque un form leggibile.
|
||||||
|
const scheme = schemeOrNull(theme?.scheme) ?? DEFAULT_SCHEME;
|
||||||
|
const schemeBlock = schemeCss(scheme);
|
||||||
|
const override = themeStyle(theme);
|
||||||
|
return `<!doctype html>
|
||||||
|
<html lang="it">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="color-scheme" content="${schemeMeta(scheme)}">
|
||||||
|
<meta name="robots" content="noindex">
|
||||||
|
<title>${escapeHtml(PAGE_TITLE)}</title>
|
||||||
|
<style>${PAGE_CSS}</style>${schemeBlock ? `\n<style>${schemeBlock}</style>` : ""}${override ? `\n<style>${override}</style>` : ""}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<script>(function(){if(window.self!==window.top){try{document.body.className="embed";}catch(e){}}})();</script>
|
||||||
|
<main class="wrap">
|
||||||
|
<div class="card">
|
||||||
|
${inner}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorBanner(message?: string): string {
|
||||||
|
if (!message) return "";
|
||||||
|
return `<div class="error" role="alert">
|
||||||
|
<svg class="error__icon" viewBox="0 0 20 20" aria-hidden="true" focusable="false"><path d="M10 1.5a8.5 8.5 0 1 0 0 17 8.5 8.5 0 0 0 0-17ZM9 6h2v6H9V6Zm0 7h2v2H9v-2Z"/></svg>
|
||||||
|
<span>${escapeHtml(message)}</span>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function noticeBanner(message?: string): string {
|
||||||
|
if (!message) return "";
|
||||||
|
return `<div class="notice" role="status">
|
||||||
|
<svg class="notice__icon" viewBox="0 0 20 20" aria-hidden="true" focusable="false"><path d="M10 1.5a8.5 8.5 0 1 0 0 17 8.5 8.5 0 0 0 0-17ZM9 5h2v2H9V5Zm0 4h2v6H9V9Z"/></svg>
|
||||||
|
<span>${escapeHtml(message)}</span>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pulsante info "i" + popover nativo (mini-modal, zero JS via Popover API).
|
||||||
|
* Il popover vive nel top-layer; chiusura con Esc, click fuori o pulsante.
|
||||||
|
*/
|
||||||
|
function infoWidget(id: string, title: string, body: string): string {
|
||||||
|
return `<button type="button" class="rc-i" popovertarget="${id}" aria-label="${attr(title)}">i</button>
|
||||||
|
<div id="${id}" popover class="rc-pop" role="dialog" aria-label="${attr(title)}">
|
||||||
|
<div class="rc-pop__bar"><span>${escapeHtml(title)}</span><button type="button" class="rc-pop__x" popovertarget="${id}" popovertargetaction="hide" aria-label="Chiudi">×</button></div>
|
||||||
|
<div class="rc-pop__body">${escapeHtml(body)}</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Header comune: step + titolo con pulsante info. */
|
||||||
|
function stepHead(step: number, subtitle?: string): string {
|
||||||
|
return `${stepIndicator(step)}
|
||||||
|
<div class="rc-titlerow"><h1>${escapeHtml(PAGE_TITLE)}</h1>${infoWidget("rcinfo", INFO_TITLE, INFO_BODY)}</div>${
|
||||||
|
subtitle ? `\n<p class="muted">${escapeHtml(subtitle)}</p>` : ""
|
||||||
|
}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indicatore di step discreto in cima alla card (accessibilità: la traccia è
|
||||||
|
* decorativa/aria-hidden, l'etichetta testuale resta leggibile). NON è un
|
||||||
|
* dark-pattern: comunica solo a che punto è l'utente.
|
||||||
|
*/
|
||||||
|
function stepIndicator(current: number): string {
|
||||||
|
const labels: Record<number, string> = {
|
||||||
|
1: "Passo 1 di 2",
|
||||||
|
2: "Passo 2 di 2",
|
||||||
|
3: "Conferma",
|
||||||
|
4: "Fatto",
|
||||||
|
};
|
||||||
|
const label = labels[current] ?? "";
|
||||||
|
return `<p class="stepper">${escapeHtml(label)}</p>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Layout a 3 fasce: header fisso (contesto + step), corpo scorrevole, footer con
|
||||||
|
* la CTA. In pagina piena è flusso normale; in modalità embed (modal) diventa una
|
||||||
|
* colonna flex a tutta altezza con header/footer ancorati e solo il corpo che scorre
|
||||||
|
* (pattern dei modal moderni). I bottoni stanno nel footer e referenziano la form
|
||||||
|
* via attributo `form=` (HTML5), così restano sempre visibili.
|
||||||
|
*/
|
||||||
|
function stepLayout(parts: { head: string; body: string; foot?: string }): string {
|
||||||
|
return `<div class="rc-head">
|
||||||
|
${parts.head}
|
||||||
|
</div>
|
||||||
|
<div class="rc-body">
|
||||||
|
${parts.body}
|
||||||
|
</div>${
|
||||||
|
parts.foot
|
||||||
|
? `
|
||||||
|
<div class="rc-foot">
|
||||||
|
${parts.foot}
|
||||||
|
</div>`
|
||||||
|
: ""
|
||||||
|
}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Step 1: lookup guest -------------------------------------------------
|
||||||
|
export function renderStep1(opts?: {
|
||||||
|
error?: string;
|
||||||
|
orderName?: string;
|
||||||
|
email?: string;
|
||||||
|
}, theme?: ThemeTokens | null): string {
|
||||||
|
const orderName = opts?.orderName ?? "";
|
||||||
|
const email = opts?.email ?? "";
|
||||||
|
return renderShell(
|
||||||
|
stepLayout({
|
||||||
|
head: stepHead(1),
|
||||||
|
body: `<p class="muted">Inserisci numero dell'ordine ed email dell'acquisto. Non serve un account.</p>
|
||||||
|
${errorBanner(opts?.error)}
|
||||||
|
<form id="rcform" method="post" action="${PROXY_STOREFRONT_PATH}" novalidate>
|
||||||
|
<input type="hidden" name="intent" value="lookup">
|
||||||
|
<label for="orderName">${escapeHtml(FIELD.orderName.label)}</label>
|
||||||
|
<input type="text" id="orderName" name="orderName" value="${attr(orderName)}" placeholder="${attr(FIELD.orderName.placeholder)}" autocomplete="off" required>
|
||||||
|
<label for="email">${escapeHtml(FIELD.email.label)}</label>
|
||||||
|
<input type="email" id="email" name="email" value="${attr(email)}" placeholder="${attr(FIELD.email.placeholder)}" autocomplete="email" required>
|
||||||
|
</form>`,
|
||||||
|
foot: `<button type="submit" form="rcform" class="btn btn-primary">Continua</button>`,
|
||||||
|
}),
|
||||||
|
theme,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Step 2: form dati + dichiarazione ------------------------------------
|
||||||
|
export function renderStep2(data: {
|
||||||
|
orderId: string;
|
||||||
|
orderName: string;
|
||||||
|
email: string;
|
||||||
|
customerName?: string;
|
||||||
|
statementText: string;
|
||||||
|
error?: string;
|
||||||
|
notice?: string;
|
||||||
|
}, theme?: ThemeTokens | null): string {
|
||||||
|
const customerName = data.customerName ?? "";
|
||||||
|
return renderShell(
|
||||||
|
stepLayout({
|
||||||
|
head: stepHead(2, `Ordine ${data.orderName}`),
|
||||||
|
body: `${errorBanner(data.error)}${noticeBanner(data.notice)}
|
||||||
|
<form id="rcform" method="post" action="${PROXY_STOREFRONT_PATH}" novalidate>
|
||||||
|
<input type="hidden" name="intent" value="details">
|
||||||
|
<input type="hidden" name="orderId" value="${attr(data.orderId)}">
|
||||||
|
<input type="hidden" name="orderName" value="${attr(data.orderName)}">
|
||||||
|
|
||||||
|
<label for="customerName">${escapeHtml(FIELD.name.label)}</label>
|
||||||
|
<input type="text" id="customerName" name="customerName" value="${attr(customerName)}" placeholder="${attr(FIELD.name.placeholder)}" autocomplete="name" required>
|
||||||
|
|
||||||
|
<label for="statementText">${escapeHtml(FIELD.statement.label)}</label>
|
||||||
|
<textarea id="statementText" name="statementText" required>${escapeHtml(data.statementText)}</textarea>
|
||||||
|
<p class="hint">${escapeHtml(SCOPE_HINT)}</p>
|
||||||
|
|
||||||
|
<label for="email">${escapeHtml(FIELD.email.label)}</label>
|
||||||
|
<input type="email" id="email" name="email" value="${attr(data.email)}" autocomplete="email" required>
|
||||||
|
<p class="hint">Ti invieremo qui la ricevuta.</p>
|
||||||
|
</form>`,
|
||||||
|
foot: `<button type="submit" form="rcform" class="btn btn-primary">Continua</button>`,
|
||||||
|
}),
|
||||||
|
theme,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Step 3: riepilogo + conferma dedicata --------------------------------
|
||||||
|
export function renderStep3(data: {
|
||||||
|
orderId: string;
|
||||||
|
orderName: string;
|
||||||
|
email: string;
|
||||||
|
customerName: string;
|
||||||
|
statementText: string;
|
||||||
|
error?: string;
|
||||||
|
}, theme?: ThemeTokens | null): string {
|
||||||
|
return renderShell(
|
||||||
|
stepLayout({
|
||||||
|
head: stepHead(3, "Controlla i dati prima di confermare."),
|
||||||
|
body: `${errorBanner(data.error)}
|
||||||
|
<dl class="summary">
|
||||||
|
<dt>${escapeHtml(FIELD.orderName.label)}</dt>
|
||||||
|
<dd>${escapeHtml(data.orderName)}</dd>
|
||||||
|
<dt>${escapeHtml(FIELD.name.label)}</dt>
|
||||||
|
<dd>${escapeHtml(data.customerName)}</dd>
|
||||||
|
<dt>${escapeHtml(FIELD.email.label)}</dt>
|
||||||
|
<dd>${escapeHtml(data.email)}</dd>
|
||||||
|
<dt>${escapeHtml(FIELD.statement.label)}</dt>
|
||||||
|
<dd>${escapeHtml(data.statementText)}</dd>
|
||||||
|
</dl>
|
||||||
|
<form id="rcedit" method="post" action="${PROXY_STOREFRONT_PATH}">
|
||||||
|
<input type="hidden" name="intent" value="edit">
|
||||||
|
<input type="hidden" name="orderId" value="${attr(data.orderId)}">
|
||||||
|
<input type="hidden" name="orderName" value="${attr(data.orderName)}">
|
||||||
|
<input type="hidden" name="email" value="${attr(data.email)}">
|
||||||
|
<input type="hidden" name="customerName" value="${attr(data.customerName)}">
|
||||||
|
<input type="hidden" name="statementText" value="${attr(data.statementText)}">
|
||||||
|
</form>
|
||||||
|
<form id="rcconfirm" method="post" action="${PROXY_STOREFRONT_PATH}">
|
||||||
|
<input type="hidden" name="intent" value="confirm">
|
||||||
|
<input type="hidden" name="orderId" value="${attr(data.orderId)}">
|
||||||
|
<input type="hidden" name="orderName" value="${attr(data.orderName)}">
|
||||||
|
<input type="hidden" name="email" value="${attr(data.email)}">
|
||||||
|
<input type="hidden" name="customerName" value="${attr(data.customerName)}">
|
||||||
|
<input type="hidden" name="statementText" value="${attr(data.statementText)}">
|
||||||
|
</form>`,
|
||||||
|
foot: `<div class="actions">
|
||||||
|
<button type="submit" form="rcedit" class="btn btn-secondary">Torna indietro</button>
|
||||||
|
<button type="submit" form="rcconfirm" class="btn btn-primary">${escapeHtml(CONFIRM_LABEL)}</button>
|
||||||
|
</div>`,
|
||||||
|
}),
|
||||||
|
theme,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Step 4: successo -----------------------------------------------------
|
||||||
|
export function renderStep4(data: {
|
||||||
|
line1: string;
|
||||||
|
line2: string;
|
||||||
|
line3: string;
|
||||||
|
}, theme?: ThemeTokens | null): string {
|
||||||
|
return renderShell(
|
||||||
|
stepLayout({
|
||||||
|
head: `${stepIndicator(4)}`,
|
||||||
|
body: `<div class="success">
|
||||||
|
<div class="success__icon" aria-hidden="true"><svg viewBox="0 0 24 24" focusable="false"><path d="M9.55 17.05 4.5 12l1.4-1.4 3.65 3.6 8.15-8.15L19.1 7.5z"/></svg></div>
|
||||||
|
<h1>${escapeHtml(data.line1)}</h1>
|
||||||
|
<p>${escapeHtml(data.line2)}</p>
|
||||||
|
<p>${escapeHtml(data.line3)}</p>
|
||||||
|
</div>`,
|
||||||
|
}),
|
||||||
|
theme,
|
||||||
|
);
|
||||||
|
}
|
||||||
171
app/app/lib/theme.ts
Normal file
171
app/app/lib/theme.ts
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
/**
|
||||||
|
* Motore di stile del form di recesso — Livello 1 (token) e Livello 3 (CSS custom).
|
||||||
|
*
|
||||||
|
* Il form e' gia' interamente tokenizzato (`:root` in recesso.server). Qui NON
|
||||||
|
* riscriviamo il CSS: generiamo un blocco di override che viene iniettato DOPO
|
||||||
|
* quello di base. Cosi' il default resta sempre valido anche se il merchant
|
||||||
|
* sbaglia una configurazione.
|
||||||
|
*
|
||||||
|
* Modulo PURO (niente node/server): lo usa sia il render dello storefront sia
|
||||||
|
* l'anteprima nell'admin.
|
||||||
|
*
|
||||||
|
* ⚠ Tutto cio' che arriva dal merchant e' sanificato qui, non a valle.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chiaro / scuro / segue il sistema del visitatore.
|
||||||
|
*
|
||||||
|
* Il default e' `light`, non `auto`: il form vive dentro un modal sovrapposto al
|
||||||
|
* tema del negozio, che l'app non puo' leggere ed e' quasi sempre chiaro. Con
|
||||||
|
* `auto` un visitatore con OS in tema scuro vedrebbe un riquadro scuro dentro
|
||||||
|
* una pagina chiara.
|
||||||
|
*/
|
||||||
|
export type ColorScheme = "light" | "dark" | "auto";
|
||||||
|
|
||||||
|
export const SCHEME_OPTIONS = [
|
||||||
|
{ label: "Chiaro", value: "light" },
|
||||||
|
{ label: "Scuro", value: "dark" },
|
||||||
|
{ label: "Segue il sistema del visitatore", value: "auto" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const DEFAULT_SCHEME: ColorScheme = "light";
|
||||||
|
|
||||||
|
/** Valore ammesso? Tutto il resto ricade sul default. */
|
||||||
|
export function schemeOrNull(v: unknown): ColorScheme | null {
|
||||||
|
const s = String(v ?? "").trim();
|
||||||
|
return s === "light" || s === "dark" || s === "auto" ? s : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThemeTokens {
|
||||||
|
accent?: string | null; // link, focus
|
||||||
|
buttonBg?: string | null; // bottone primario
|
||||||
|
buttonText?: string | null;
|
||||||
|
radius?: number | null; // px
|
||||||
|
font?: string | null; // chiave di FONT_PRESETS
|
||||||
|
width?: number | null; // px, larghezza max della card
|
||||||
|
scheme?: string | null; // ColorScheme; default DEFAULT_SCHEME
|
||||||
|
customCss?: string | null; // livello 3
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FONT_PRESETS: Record<string, string> = {
|
||||||
|
system:
|
||||||
|
'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
|
||||||
|
sans: 'Helvetica, Arial, "Helvetica Neue", sans-serif',
|
||||||
|
serif: 'Georgia, "Times New Roman", Times, serif',
|
||||||
|
mono: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FONT_OPTIONS = [
|
||||||
|
{ label: "Di sistema (consigliato)", value: "system" },
|
||||||
|
{ label: "Sans serif", value: "sans" },
|
||||||
|
{ label: "Serif", value: "serif" },
|
||||||
|
{ label: "Monospazio", value: "mono" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const RADIUS_MIN = 0;
|
||||||
|
export const RADIUS_MAX = 32;
|
||||||
|
export const WIDTH_MIN = 360;
|
||||||
|
export const WIDTH_MAX = 900;
|
||||||
|
export const CUSTOM_CSS_MAX = 4000;
|
||||||
|
|
||||||
|
const HEX = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
|
||||||
|
|
||||||
|
/** Colore valido? Accettiamo solo esadecimali: niente url(), niente espressioni. */
|
||||||
|
export function isHexColor(v: unknown): v is string {
|
||||||
|
return typeof v === "string" && HEX.test(v.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function expand(hex: string): [number, number, number] {
|
||||||
|
let h = hex.trim().slice(1);
|
||||||
|
if (h.length === 3) h = h[0]! + h[0]! + h[1]! + h[1]! + h[2]! + h[2]!;
|
||||||
|
return [
|
||||||
|
parseInt(h.slice(0, 2), 16),
|
||||||
|
parseInt(h.slice(2, 4), 16),
|
||||||
|
parseInt(h.slice(4, 6), 16),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** rgba() dal colore, per l'anello di focus. */
|
||||||
|
export function hexToRgba(hex: string, alpha: number): string {
|
||||||
|
const [r, g, b] = expand(hex);
|
||||||
|
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Scurisce (amount<0) o schiarisce (amount>0) verso nero/bianco. */
|
||||||
|
export function shade(hex: string, amount: number): string {
|
||||||
|
const [r, g, b] = expand(hex);
|
||||||
|
const t = amount < 0 ? 0 : 255;
|
||||||
|
const p = Math.abs(amount);
|
||||||
|
const mix = (c: number) => Math.round((t - c) * p + c);
|
||||||
|
const to2 = (c: number) => mix(c).toString(16).padStart(2, "0");
|
||||||
|
return `#${to2(r)}${to2(g)}${to2(b)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(n: number, min: number, max: number): number {
|
||||||
|
return Math.min(max, Math.max(min, n));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CSS custom: non possiamo permettere che il merchant esca dal blocco <style>
|
||||||
|
* o carichi risorse esterne. Niente JS possibile via CSS sui browser moderni,
|
||||||
|
* ma `</style` e `@import` vanno tolti comunque.
|
||||||
|
*/
|
||||||
|
export function sanitizeCustomCss(css: string): string {
|
||||||
|
return css
|
||||||
|
.slice(0, CUSTOM_CSS_MAX)
|
||||||
|
.replace(/<\/\s*style/gi, "")
|
||||||
|
.replace(/@import[^;]*;?/gi, "")
|
||||||
|
.replace(/expression\s*\(/gi, "")
|
||||||
|
.replace(/javascript\s*:/gi, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Blocco di override. Stringa vuota se il merchant non ha configurato nulla:
|
||||||
|
* in quel caso valgono i default del form.
|
||||||
|
*/
|
||||||
|
export function themeStyle(t?: ThemeTokens | null): string {
|
||||||
|
if (!t) return "";
|
||||||
|
const vars: string[] = [];
|
||||||
|
|
||||||
|
if (isHexColor(t.accent)) {
|
||||||
|
const a = t.accent.trim();
|
||||||
|
vars.push(`--accent: ${a};`);
|
||||||
|
vars.push(`--focus-ring: ${hexToRgba(a, 0.24)};`);
|
||||||
|
}
|
||||||
|
if (isHexColor(t.buttonBg)) {
|
||||||
|
const b = t.buttonBg.trim();
|
||||||
|
vars.push(`--primary-bg: ${b};`);
|
||||||
|
vars.push(`--primary-bg-hover: ${shade(b, -0.18)};`);
|
||||||
|
}
|
||||||
|
if (isHexColor(t.buttonText)) {
|
||||||
|
vars.push(`--primary-text: ${t.buttonText.trim()};`);
|
||||||
|
}
|
||||||
|
if (typeof t.radius === "number" && Number.isFinite(t.radius)) {
|
||||||
|
const r = clamp(Math.round(t.radius), RADIUS_MIN, RADIUS_MAX);
|
||||||
|
vars.push(`--radius: ${r}px;`);
|
||||||
|
vars.push(`--radius-sm: ${Math.max(0, r - 5)}px;`);
|
||||||
|
}
|
||||||
|
if (typeof t.width === "number" && Number.isFinite(t.width)) {
|
||||||
|
vars.push(
|
||||||
|
`--card-max: ${clamp(Math.round(t.width), WIDTH_MIN, WIDTH_MAX)}px;`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const font = t.font && FONT_PRESETS[t.font] ? FONT_PRESETS[t.font] : null;
|
||||||
|
if (font) vars.push(`--font: ${font};`);
|
||||||
|
|
||||||
|
const root = vars.length ? `:root{${vars.join("")}}` : "";
|
||||||
|
const custom = t.customCss?.trim()
|
||||||
|
? sanitizeCustomCss(t.customCss.trim())
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return `${root}${custom}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Anteprima admin: token di esempio quando il merchant non ha ancora salvato. */
|
||||||
|
export const THEME_DEFAULTS: Required<
|
||||||
|
Pick<ThemeTokens, "radius" | "width" | "font">
|
||||||
|
> = {
|
||||||
|
radius: 14,
|
||||||
|
width: 520,
|
||||||
|
font: "system",
|
||||||
|
};
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
Card,
|
Card,
|
||||||
Tabs,
|
Tabs,
|
||||||
TextField,
|
TextField,
|
||||||
|
Select,
|
||||||
Button,
|
Button,
|
||||||
ButtonGroup,
|
ButtonGroup,
|
||||||
Banner,
|
Banner,
|
||||||
@@ -25,7 +26,16 @@ import { TitleBar } from "@shopify/app-bridge-react";
|
|||||||
|
|
||||||
import { authenticate } from "../shopify.server";
|
import { authenticate } from "../shopify.server";
|
||||||
import db from "../db.server";
|
import db from "../db.server";
|
||||||
import { encryptSecret } from "../lib/crypto.server";
|
import { decryptSecret, encryptSecret } from "../lib/crypto.server";
|
||||||
|
import { sendTestEmail } from "../lib/mailer.server";
|
||||||
|
import { renderStep2 } from "../lib/recesso.view";
|
||||||
|
import { statementTemplate } from "../lib/recesso.copy";
|
||||||
|
import {
|
||||||
|
DEFAULT_SCHEME,
|
||||||
|
SCHEME_OPTIONS,
|
||||||
|
schemeOrNull,
|
||||||
|
type ThemeTokens,
|
||||||
|
} from "../lib/theme";
|
||||||
import {
|
import {
|
||||||
DEFAULT_INTRO,
|
DEFAULT_INTRO,
|
||||||
DEFAULT_NOTE,
|
DEFAULT_NOTE,
|
||||||
@@ -65,12 +75,53 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
|
|||||||
smtpSecure: s?.smtpSecure ?? false,
|
smtpSecure: s?.smtpSecure ?? false,
|
||||||
smtpFrom: s?.smtpFrom ?? "",
|
smtpFrom: s?.smtpFrom ?? "",
|
||||||
smtpPassSet: !!s?.smtpPass,
|
smtpPassSet: !!s?.smtpPass,
|
||||||
|
// Esiste un SMTP di default a livello app (env/fly secret)? In prod oggi NO:
|
||||||
|
// senza SMTP per-shop non parte nessuna ricevuta.
|
||||||
|
appDefaultSmtp: !!process.env.SMTP_HOST,
|
||||||
|
themeScheme: s?.themeScheme ?? DEFAULT_SCHEME,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const action = async ({ request }: ActionFunctionArgs) => {
|
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||||
const { session } = await authenticate.admin(request);
|
const { session } = await authenticate.admin(request);
|
||||||
const f = await request.formData();
|
const f = await request.formData();
|
||||||
|
|
||||||
|
// Invio di prova: NON salva, usa i valori correnti del form (password digitata
|
||||||
|
// oppure quella gia' salvata, decifrata). Ritorna l'errore SMTP grezzo.
|
||||||
|
if (String(f.get("intent") ?? "save") === "test") {
|
||||||
|
const to = String(f.get("testTo") ?? "").trim();
|
||||||
|
if (!to) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
tested: true,
|
||||||
|
error: "Inserisci un destinatario per la prova.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const host = String(f.get("smtpHost") ?? "").trim();
|
||||||
|
let pass: string | null = String(f.get("smtpPass") ?? "").trim() || null;
|
||||||
|
if (!pass) {
|
||||||
|
const saved = await db.settings.findUnique({
|
||||||
|
where: { shop: session.shop },
|
||||||
|
});
|
||||||
|
pass = saved?.smtpPass ? decryptSecret(saved.smtpPass) : null;
|
||||||
|
}
|
||||||
|
const smtp = host
|
||||||
|
? {
|
||||||
|
host,
|
||||||
|
port:
|
||||||
|
Number(f.get("smtpPort")) > 0
|
||||||
|
? Math.trunc(Number(f.get("smtpPort")))
|
||||||
|
: null,
|
||||||
|
user: String(f.get("smtpUser") ?? "").trim() || null,
|
||||||
|
pass,
|
||||||
|
secure: f.get("smtpSecure") === "true",
|
||||||
|
from: String(f.get("smtpFrom") ?? "").trim() || null,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
const r = await sendTestEmail({ smtp, to });
|
||||||
|
return { ok: r.ok, tested: true, error: r.ok ? null : r.error };
|
||||||
|
}
|
||||||
|
|
||||||
const subject = String(f.get("subject") ?? "").trim();
|
const subject = String(f.get("subject") ?? "").trim();
|
||||||
const intro = String(f.get("intro") ?? "").trim();
|
const intro = String(f.get("intro") ?? "").trim();
|
||||||
const note = String(f.get("note") ?? "").trim();
|
const note = String(f.get("note") ?? "").trim();
|
||||||
@@ -103,6 +154,9 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||||||
smtpUser: String(f.get("smtpUser") ?? "").trim() || null,
|
smtpUser: String(f.get("smtpUser") ?? "").trim() || null,
|
||||||
smtpSecure: f.get("smtpSecure") === "true",
|
smtpSecure: f.get("smtpSecure") === "true",
|
||||||
smtpFrom: String(f.get("smtpFrom") ?? "").trim() || null,
|
smtpFrom: String(f.get("smtpFrom") ?? "").trim() || null,
|
||||||
|
// Aspetto: solo lo schema colore. Le colonne dei token restano nel DB,
|
||||||
|
// inerti, per poter riesporre la personalizzazione senza migrazioni.
|
||||||
|
themeScheme: schemeOrNull(f.get("themeScheme")),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Password SMTP: cifrata solo se fornita; vuota = invariata.
|
// Password SMTP: cifrata solo se fornita; vuota = invariata.
|
||||||
@@ -116,9 +170,10 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||||||
create: { shop: session.shop, ...finalData },
|
create: { shop: session.shop, ...finalData },
|
||||||
update: finalData,
|
update: finalData,
|
||||||
});
|
});
|
||||||
return { ok: true };
|
return { ok: true, tested: false, error: null };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
function opFrame(html: string, height = 130) {
|
function opFrame(html: string, height = 130) {
|
||||||
const doc = `<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;">${html}</table>`;
|
const doc = `<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;">${html}</table>`;
|
||||||
return (
|
return (
|
||||||
@@ -168,12 +223,14 @@ export default function SettingsPage() {
|
|||||||
const [smtpPass, setSmtpPass] = useState("");
|
const [smtpPass, setSmtpPass] = useState("");
|
||||||
const [smtpSecure, setSmtpSecure] = useState(d.smtpSecure);
|
const [smtpSecure, setSmtpSecure] = useState(d.smtpSecure);
|
||||||
const [smtpFrom, setSmtpFrom] = useState(d.smtpFrom);
|
const [smtpFrom, setSmtpFrom] = useState(d.smtpFrom);
|
||||||
|
const [testTo, setTestTo] = useState(d.notifyEmail);
|
||||||
|
const [themeScheme, setThemeScheme] = useState(d.themeScheme);
|
||||||
const [showSaved, setShowSaved] = useState(false);
|
const [showSaved, setShowSaved] = useState(false);
|
||||||
|
|
||||||
const saving = nav.state === "submitting";
|
const saving = nav.state === "submitting";
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (actionData?.ok) setShowSaved(true);
|
if (actionData?.ok && !actionData.tested) setShowSaved(true);
|
||||||
}, [actionData]);
|
}, [actionData]);
|
||||||
|
|
||||||
const previewSubject = useMemo(
|
const previewSubject = useMemo(
|
||||||
@@ -213,9 +270,39 @@ export default function SettingsPage() {
|
|||||||
[opTextShipped, opTextUnfulfilled, returnAddress, returnAtCustomerExpense],
|
[opTextShipped, opTextUnfulfilled, returnAddress, returnAtCustomerExpense],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Anteprima FEDELE: stesso renderer e stesso CSS dello storefront (recesso.view).
|
||||||
|
const themePreview = useMemo(() => {
|
||||||
|
const tokens: ThemeTokens = { scheme: themeScheme };
|
||||||
|
return renderStep2(
|
||||||
|
{
|
||||||
|
orderId: "gid://shopify/Order/0",
|
||||||
|
orderName: "#1001",
|
||||||
|
email: "mario.rossi@example.com",
|
||||||
|
customerName: "Mario Rossi",
|
||||||
|
statementText: statementTemplate("#1001"),
|
||||||
|
},
|
||||||
|
tokens,
|
||||||
|
);
|
||||||
|
}, [themeScheme]);
|
||||||
|
|
||||||
|
const handleTest = () => {
|
||||||
|
setShowSaved(false);
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.set("intent", "test");
|
||||||
|
fd.set("testTo", testTo);
|
||||||
|
fd.set("smtpHost", smtpHost);
|
||||||
|
fd.set("smtpPort", smtpPort);
|
||||||
|
fd.set("smtpUser", smtpUser);
|
||||||
|
fd.set("smtpPass", smtpPass);
|
||||||
|
fd.set("smtpSecure", String(smtpSecure));
|
||||||
|
fd.set("smtpFrom", smtpFrom);
|
||||||
|
submit(fd, { method: "post" });
|
||||||
|
};
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
setShowSaved(false);
|
setShowSaved(false);
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
|
fd.set("intent", "save");
|
||||||
fd.set("subject", subject);
|
fd.set("subject", subject);
|
||||||
fd.set("intro", intro);
|
fd.set("intro", intro);
|
||||||
fd.set("note", note);
|
fd.set("note", note);
|
||||||
@@ -237,9 +324,15 @@ export default function SettingsPage() {
|
|||||||
fd.set("smtpPass", smtpPass);
|
fd.set("smtpPass", smtpPass);
|
||||||
fd.set("smtpSecure", String(smtpSecure));
|
fd.set("smtpSecure", String(smtpSecure));
|
||||||
fd.set("smtpFrom", smtpFrom);
|
fd.set("smtpFrom", smtpFrom);
|
||||||
|
fd.set("themeScheme", themeScheme);
|
||||||
submit(fd, { method: "post" });
|
submit(fd, { method: "post" });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const resetTheme = () => {
|
||||||
|
setThemeScheme(DEFAULT_SCHEME);
|
||||||
|
setShowSaved(false);
|
||||||
|
};
|
||||||
|
|
||||||
const resetEmail = () => {
|
const resetEmail = () => {
|
||||||
setSubject(DEFAULT_SUBJECT);
|
setSubject(DEFAULT_SUBJECT);
|
||||||
setIntro(DEFAULT_INTRO);
|
setIntro(DEFAULT_INTRO);
|
||||||
@@ -261,6 +354,7 @@ export default function SettingsPage() {
|
|||||||
{ id: "regole", content: "Regole recesso" },
|
{ id: "regole", content: "Regole recesso" },
|
||||||
{ id: "reso", content: "Reso e stato ordine" },
|
{ id: "reso", content: "Reso e stato ordine" },
|
||||||
{ id: "smtp", content: "Email (SMTP)" },
|
{ id: "smtp", content: "Email (SMTP)" },
|
||||||
|
{ id: "aspetto", content: "Aspetto" },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -275,6 +369,18 @@ export default function SettingsPage() {
|
|||||||
</Banner>
|
</Banner>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{actionData?.tested ? (
|
||||||
|
actionData.ok ? (
|
||||||
|
<Banner tone="success">
|
||||||
|
Email di prova inviata a {testTo}. Controlla anche lo spam.
|
||||||
|
</Banner>
|
||||||
|
) : (
|
||||||
|
<Banner tone="critical" title="Invio di prova fallito">
|
||||||
|
<Text as="p">{actionData.error}</Text>
|
||||||
|
</Banner>
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
|
||||||
<Tabs tabs={tabs} selected={tab} onSelect={setTab} />
|
<Tabs tabs={tabs} selected={tab} onSelect={setTab} />
|
||||||
|
|
||||||
{tab === 0 ? (
|
{tab === 0 ? (
|
||||||
@@ -373,6 +479,23 @@ export default function SettingsPage() {
|
|||||||
helpText="Dove ricevere le notifiche. Senza indirizzo l'email non parte."
|
helpText="Dove ricevere le notifiche. Senza indirizzo l'email non parte."
|
||||||
placeholder="ordini@tuonegozio.it"
|
placeholder="ordini@tuonegozio.it"
|
||||||
/>
|
/>
|
||||||
|
{notifyEnabled && !notifyEmail.trim() ? (
|
||||||
|
<Banner tone="warning">
|
||||||
|
Notifiche attive ma nessun indirizzo: al momento non ricevi
|
||||||
|
nulla. Inserisci un'email.
|
||||||
|
</Banner>
|
||||||
|
) : null}
|
||||||
|
{notifyEnabled &&
|
||||||
|
notifyEmail.trim() &&
|
||||||
|
smtpFrom.trim() &&
|
||||||
|
smtpFrom.toLowerCase().includes(notifyEmail.trim().toLowerCase()) ? (
|
||||||
|
<Banner tone="warning">
|
||||||
|
L'indirizzo di notifica coincide con il mittente SMTP.
|
||||||
|
Spedire da un indirizzo a sé stesso passando da un relay
|
||||||
|
esterno viene spesso bloccato o scartato dai provider
|
||||||
|
(Gmail in primis). Usa un destinatario diverso.
|
||||||
|
</Banner>
|
||||||
|
) : null}
|
||||||
<Checkbox
|
<Checkbox
|
||||||
label="Aggiungi il tag 'Recesso' all'ordine"
|
label="Aggiungi il tag 'Recesso' all'ordine"
|
||||||
checked={tagEnabled}
|
checked={tagEnabled}
|
||||||
@@ -453,6 +576,17 @@ export default function SettingsPage() {
|
|||||||
onChange={setAutoCancelUnfulfilled}
|
onChange={setAutoCancelUnfulfilled}
|
||||||
helpText="Al recesso, se l'ordine non è evaso: annullo + rimborso automatici. Irreversibile."
|
helpText="Al recesso, se l'ordine non è evaso: annullo + rimborso automatici. Irreversibile."
|
||||||
/>
|
/>
|
||||||
|
{autoCancelUnfulfilled ? (
|
||||||
|
<Banner tone="warning" title="Annulla l'INTERO ordine">
|
||||||
|
<Text as="p">
|
||||||
|
Il recesso da questa funzione riguarda tutto l'ordine.
|
||||||
|
Se il cliente voleva restituire solo alcuni articoli,
|
||||||
|
l'annullo automatico gli cancella e rimborsa l'intero
|
||||||
|
ordine, e non è reversibile. Con ordini multi-articolo
|
||||||
|
valuta di tenerlo spento e decidere caso per caso.
|
||||||
|
</Text>
|
||||||
|
</Banner>
|
||||||
|
) : null}
|
||||||
<Checkbox
|
<Checkbox
|
||||||
label="Spese di restituzione a carico del cliente (Art. 57)"
|
label="Spese di restituzione a carico del cliente (Art. 57)"
|
||||||
checked={returnAtCustomerExpense}
|
checked={returnAtCustomerExpense}
|
||||||
@@ -511,11 +645,23 @@ export default function SettingsPage() {
|
|||||||
Email (SMTP)
|
Email (SMTP)
|
||||||
</Text>
|
</Text>
|
||||||
<Text as="p" tone="subdued">
|
<Text as="p" tone="subdued">
|
||||||
Vuoto = provider di default dell'app. Compila per inviare dal
|
{d.appDefaultSmtp
|
||||||
tuo SMTP (email dal tuo dominio). La password è cifrata a
|
? "Vuoto = provider di default dell'app. Compila per inviare dal tuo SMTP (email dal tuo dominio). La password è cifrata a riposo."
|
||||||
riposo.
|
: "Questa installazione non ha un provider di default: la ricevuta parte solo dal tuo SMTP. La password è cifrata a riposo."}
|
||||||
</Text>
|
</Text>
|
||||||
</BlockStack>
|
</BlockStack>
|
||||||
|
|
||||||
|
{!smtpHost.trim() && !d.appDefaultSmtp ? (
|
||||||
|
<Banner tone="critical" title="Nessun SMTP configurato">
|
||||||
|
<Text as="p">
|
||||||
|
Le ricevute di recesso <strong>non vengono inviate</strong>.
|
||||||
|
La ricevuta su supporto durevole è un obbligo di legge
|
||||||
|
(art. 54-bis): finché non configuri l'SMTP, la funzione
|
||||||
|
non è conforme. Compila i campi qui sotto e usa "Invia
|
||||||
|
email di prova" per verificare.
|
||||||
|
</Text>
|
||||||
|
</Banner>
|
||||||
|
) : null}
|
||||||
<TextField
|
<TextField
|
||||||
label="Host SMTP"
|
label="Host SMTP"
|
||||||
value={smtpHost}
|
value={smtpHost}
|
||||||
@@ -550,9 +696,10 @@ export default function SettingsPage() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
label="Connessione sicura diretta (SSL/TLS, porta 465)"
|
label="Connessione sicura diretta (SSL/TLS)"
|
||||||
checked={smtpSecure}
|
checked={smtpSecure}
|
||||||
onChange={setSmtpSecure}
|
onChange={setSmtpSecure}
|
||||||
|
helpText="Ignorato sulle porte standard: 465 usa sempre TLS diretto, 587 usa sempre STARTTLS. Vale solo su porte non standard."
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
label="Mittente (From)"
|
label="Mittente (From)"
|
||||||
@@ -561,10 +708,128 @@ export default function SettingsPage() {
|
|||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
placeholder="Il tuo negozio <no-reply@tuodominio.it>"
|
placeholder="Il tuo negozio <no-reply@tuodominio.it>"
|
||||||
/>
|
/>
|
||||||
{saveBtn}
|
|
||||||
|
{smtpHost && !smtpFrom ? (
|
||||||
|
<Banner tone="warning">
|
||||||
|
Host SMTP impostato ma mittente vuoto. Brevo (come quasi
|
||||||
|
tutti i provider) rifiuta un mittente non verificato:
|
||||||
|
compila "Mittente (From)" con un indirizzo verificato nel
|
||||||
|
tuo account.
|
||||||
|
</Banner>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="Destinatario dell'email di prova"
|
||||||
|
type="email"
|
||||||
|
value={testTo}
|
||||||
|
onChange={setTestTo}
|
||||||
|
autoComplete="off"
|
||||||
|
placeholder="tu@tuodominio.it"
|
||||||
|
helpText="La prova usa i valori qui sopra, anche se non ancora salvati."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ButtonGroup>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
loading={saving}
|
||||||
|
onClick={handleSave}
|
||||||
|
>
|
||||||
|
Salva
|
||||||
|
</Button>
|
||||||
|
<Button loading={saving} onClick={handleTest}>
|
||||||
|
Invia email di prova
|
||||||
|
</Button>
|
||||||
|
</ButtonGroup>
|
||||||
</BlockStack>
|
</BlockStack>
|
||||||
</Card>
|
</Card>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{tab === 5 ? (
|
||||||
|
<BlockStack gap="400">
|
||||||
|
<Card>
|
||||||
|
<BlockStack gap="400">
|
||||||
|
<BlockStack gap="100">
|
||||||
|
<Text as="h2" variant="headingMd">
|
||||||
|
Aspetto del form
|
||||||
|
</Text>
|
||||||
|
<Text as="p" tone="subdued">
|
||||||
|
Il form usa una grafica gia' pronta, leggibile e
|
||||||
|
accessibile. Scegli solo se mostrarla chiara o scura.
|
||||||
|
</Text>
|
||||||
|
</BlockStack>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
label="Schema colore"
|
||||||
|
options={SCHEME_OPTIONS}
|
||||||
|
value={themeScheme}
|
||||||
|
onChange={setThemeScheme}
|
||||||
|
helpText="Il form si apre in un riquadro sopra il tuo tema, che l'app non puo' leggere. Scegli 'Segue il sistema' solo se il tuo tema ha una versione scura."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ButtonGroup>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
loading={saving}
|
||||||
|
onClick={handleSave}
|
||||||
|
>
|
||||||
|
Salva
|
||||||
|
</Button>
|
||||||
|
<Button onClick={resetTheme}>Ripristina default</Button>
|
||||||
|
</ButtonGroup>
|
||||||
|
</BlockStack>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<BlockStack gap="200">
|
||||||
|
<Text as="h2" variant="headingMd">
|
||||||
|
Anteprima
|
||||||
|
</Text>
|
||||||
|
<Text as="p" tone="subdued">
|
||||||
|
Stesso markup e stesso CSS che vedra' il cliente. Non e'
|
||||||
|
interattiva: i campi e i bottoni non rispondono.
|
||||||
|
</Text>
|
||||||
|
{/*
|
||||||
|
sandbox senza allow-forms / allow-same-origin /
|
||||||
|
allow-top-navigation: l'anteprima non puo' inviare il
|
||||||
|
form ne' navigare. `allow-scripts` serve solo allo script
|
||||||
|
inline che riconosce l'iframe e applica il layout
|
||||||
|
compatto, lo stesso che il cliente vede nel modal.
|
||||||
|
pointer-events + inert tolgono anche mouse e tastiera,
|
||||||
|
cosi' non sembra cliccabile.
|
||||||
|
*/}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
maxHeight: "720px",
|
||||||
|
overflowY: "auto",
|
||||||
|
border: "1px solid #e1e1e1",
|
||||||
|
borderRadius: "8px",
|
||||||
|
background: "#fff",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<iframe
|
||||||
|
title="Anteprima form di recesso (non interattiva)"
|
||||||
|
srcDoc={themePreview}
|
||||||
|
sandbox="allow-scripts"
|
||||||
|
// @ts-expect-error inert e' valido in HTML, non ancora nei tipi React 18
|
||||||
|
inert=""
|
||||||
|
tabIndex={-1}
|
||||||
|
scrolling="no"
|
||||||
|
style={{
|
||||||
|
display: "block",
|
||||||
|
width: "100%",
|
||||||
|
// Alto abbastanza da contenere il form: l'iframe non
|
||||||
|
// puo' scrollare (pointer-events: none), scrolla il
|
||||||
|
// contenitore.
|
||||||
|
height: "1100px",
|
||||||
|
border: 0,
|
||||||
|
pointerEvents: "none",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</BlockStack>
|
||||||
|
</Card>
|
||||||
|
</BlockStack>
|
||||||
|
) : null}
|
||||||
</BlockStack>
|
</BlockStack>
|
||||||
</Layout.Section>
|
</Layout.Section>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import db from "../db.server";
|
|||||||
import {
|
import {
|
||||||
sendMerchantNotification,
|
sendMerchantNotification,
|
||||||
sendWithdrawalReceipt,
|
sendWithdrawalReceipt,
|
||||||
|
type ReceiptResult,
|
||||||
type SmtpConfig,
|
type SmtpConfig,
|
||||||
} from "../lib/mailer.server";
|
} from "../lib/mailer.server";
|
||||||
import { decryptSecret } from "../lib/crypto.server";
|
import { decryptSecret } from "../lib/crypto.server";
|
||||||
@@ -27,6 +28,7 @@ import {
|
|||||||
ERROR,
|
ERROR,
|
||||||
EXCLUSION_REASON,
|
EXCLUSION_REASON,
|
||||||
NOTICE,
|
NOTICE,
|
||||||
|
duplicateMessage,
|
||||||
exclusionMessage,
|
exclusionMessage,
|
||||||
statementTemplate,
|
statementTemplate,
|
||||||
successMessage,
|
successMessage,
|
||||||
@@ -53,6 +55,24 @@ import {
|
|||||||
sha256,
|
sha256,
|
||||||
} from "../lib/recesso.server";
|
} from "../lib/recesso.server";
|
||||||
import type { MatchedOrder } from "../lib/recesso.server";
|
import type { MatchedOrder } from "../lib/recesso.server";
|
||||||
|
import type { ThemeTokens } from "../lib/theme";
|
||||||
|
|
||||||
|
/** Token di stile del merchant (livello 1 + 3). Null = default del form. */
|
||||||
|
async function loadTheme(shop: string): Promise<ThemeTokens | null> {
|
||||||
|
const s = await db.settings
|
||||||
|
.findUnique({ where: { shop } })
|
||||||
|
.catch(() => null);
|
||||||
|
if (!s) return null;
|
||||||
|
// Solo lo schema colore e' configurabile. Le colonne dei token (accento,
|
||||||
|
// bottone, raggio, carattere, larghezza, CSS custom) restano nel DB ma non
|
||||||
|
// vengono piu' lette: la grafica del form e' fissa e accessibile.
|
||||||
|
return { scheme: s.themeScheme };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Errore diagnosticabile ma senza PII: maschera gli indirizzi email. */
|
||||||
|
function redactErr(msg: string): string {
|
||||||
|
return msg.replace(/[\w.+-]+@[\w.-]+\.\w+/g, "[email]").slice(0, 180);
|
||||||
|
}
|
||||||
|
|
||||||
// A6: verifica finestra + esclusioni (rispetta i toggle nei Settings). Ritorna
|
// A6: verifica finestra + esclusioni (rispetta i toggle nei Settings). Ritorna
|
||||||
// il messaggio d'errore se il recesso va bloccato, altrimenti null.
|
// il messaggio d'errore se il recesso va bloccato, altrimenti null.
|
||||||
@@ -102,8 +122,9 @@ async function checkCompliance(
|
|||||||
|
|
||||||
// GET /apps/recesso -> Step 1 (form di lookup).
|
// GET /apps/recesso -> Step 1 (form di lookup).
|
||||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||||
await authenticate.public.appProxy(request);
|
const { session } = await authenticate.public.appProxy(request);
|
||||||
return htmlResponse(renderStep1());
|
const theme = session ? await loadTheme(session.shop) : null;
|
||||||
|
return htmlResponse(renderStep1(undefined, theme));
|
||||||
};
|
};
|
||||||
|
|
||||||
export const action = async ({ request }: ActionFunctionArgs) => {
|
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||||
@@ -112,9 +133,11 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||||||
// Narrowing: senza sessione offline non abbiamo Admin API per lo shop.
|
// Narrowing: senza sessione offline non abbiamo Admin API per lo shop.
|
||||||
// Multi-tenant: lo shop lo prendiamo SOLO da session.shop, mai dal client.
|
// Multi-tenant: lo shop lo prendiamo SOLO da session.shop, mai dal client.
|
||||||
if (!session || !admin) {
|
if (!session || !admin) {
|
||||||
return htmlResponse(renderStep1({ error: ERROR.generic }));
|
// Senza sessione non conosciamo lo shop: nessun tema da applicare.
|
||||||
|
return htmlResponse(renderStep1({ error: ERROR.generic }, null));
|
||||||
}
|
}
|
||||||
const shop = session.shop;
|
const shop = session.shop;
|
||||||
|
const theme = await loadTheme(shop);
|
||||||
|
|
||||||
const form = await request.formData();
|
const form = await request.formData();
|
||||||
const intent = String(form.get("intent") ?? "");
|
const intent = String(form.get("intent") ?? "");
|
||||||
@@ -134,7 +157,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||||||
error: ERROR.missingField,
|
error: ERROR.missingField,
|
||||||
orderName: orderNameInput,
|
orderName: orderNameInput,
|
||||||
email: emailInput,
|
email: emailInput,
|
||||||
}),
|
}, theme),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!isValidEmail(emailInput)) {
|
if (!isValidEmail(emailInput)) {
|
||||||
@@ -143,7 +166,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||||||
error: ERROR.invalidEmail,
|
error: ERROR.invalidEmail,
|
||||||
orderName: orderNameInput,
|
orderName: orderNameInput,
|
||||||
email: emailInput,
|
email: emailInput,
|
||||||
}),
|
}, theme),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,7 +185,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||||||
error: ERROR.generic,
|
error: ERROR.generic,
|
||||||
orderName: orderNameInput,
|
orderName: orderNameInput,
|
||||||
email: emailInput,
|
email: emailInput,
|
||||||
}),
|
}, theme),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,7 +207,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||||||
error: ERROR.lookupNoMatch,
|
error: ERROR.lookupNoMatch,
|
||||||
orderName: orderNameInput,
|
orderName: orderNameInput,
|
||||||
email: emailInput,
|
email: emailInput,
|
||||||
}),
|
}, theme),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,7 +219,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||||||
error: block,
|
error: block,
|
||||||
orderName: orderNameInput,
|
orderName: orderNameInput,
|
||||||
email: emailInput,
|
email: emailInput,
|
||||||
}),
|
}, theme),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,7 +234,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||||||
email: match.email,
|
email: match.email,
|
||||||
statementText: statementTemplate(match.orderName),
|
statementText: statementTemplate(match.orderName),
|
||||||
notice: orderClosed ? NOTICE.orderClosed : undefined,
|
notice: orderClosed ? NOTICE.orderClosed : undefined,
|
||||||
}),
|
}, theme),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,7 +251,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||||||
// Sicurezza: se mancano i riferimenti d'ordine (tamper/link diretto),
|
// Sicurezza: se mancano i riferimenti d'ordine (tamper/link diretto),
|
||||||
// riparti dallo Step 1 senza rivelare nulla.
|
// riparti dallo Step 1 senza rivelare nulla.
|
||||||
if (!orderId || !orderName) {
|
if (!orderId || !orderName) {
|
||||||
return htmlResponse(renderStep1({ error: ERROR.generic }));
|
return htmlResponse(renderStep1({ error: ERROR.generic }, theme));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!customerName || !email || !statementText) {
|
if (!customerName || !email || !statementText) {
|
||||||
@@ -240,7 +263,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||||||
customerName,
|
customerName,
|
||||||
statementText: statementText || statementTemplate(orderName),
|
statementText: statementText || statementTemplate(orderName),
|
||||||
error: ERROR.missingField,
|
error: ERROR.missingField,
|
||||||
}),
|
}, theme),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!isValidEmail(email)) {
|
if (!isValidEmail(email)) {
|
||||||
@@ -252,12 +275,12 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||||||
customerName,
|
customerName,
|
||||||
statementText,
|
statementText,
|
||||||
error: ERROR.invalidEmail,
|
error: ERROR.invalidEmail,
|
||||||
}),
|
}, theme),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return htmlResponse(
|
return htmlResponse(
|
||||||
renderStep3({ orderId, orderName, email, customerName, statementText }),
|
renderStep3({ orderId, orderName, email, customerName, statementText }, theme),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -271,7 +294,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||||||
const email = String(form.get("email") ?? "").trim();
|
const email = String(form.get("email") ?? "").trim();
|
||||||
const statementText = String(form.get("statementText") ?? "").trim();
|
const statementText = String(form.get("statementText") ?? "").trim();
|
||||||
if (!orderId || !orderName) {
|
if (!orderId || !orderName) {
|
||||||
return htmlResponse(renderStep1({ error: ERROR.generic }));
|
return htmlResponse(renderStep1({ error: ERROR.generic }, theme));
|
||||||
}
|
}
|
||||||
return htmlResponse(
|
return htmlResponse(
|
||||||
renderStep2({
|
renderStep2({
|
||||||
@@ -280,7 +303,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||||||
email,
|
email,
|
||||||
customerName,
|
customerName,
|
||||||
statementText: statementText || statementTemplate(orderName),
|
statementText: statementText || statementTemplate(orderName),
|
||||||
}),
|
}, theme),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -303,65 +326,101 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||||||
!statementText ||
|
!statementText ||
|
||||||
!isValidEmail(email)
|
!isValidEmail(email)
|
||||||
) {
|
) {
|
||||||
return htmlResponse(renderStep1({ error: ERROR.generic }));
|
return htmlResponse(renderStep1({ error: ERROR.generic }, theme));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate-limit ANCHE sulla conferma. Senza, l'endpoint e' un amplificatore:
|
||||||
|
// ogni POST manda una ricevuta al cliente + una notifica al merchant e
|
||||||
|
// brucia quota Admin API.
|
||||||
|
if (!checkRateLimit(shop, clientIp(request))) {
|
||||||
|
await db.auditLog.create({
|
||||||
|
data: {
|
||||||
|
shop,
|
||||||
|
event: "withdrawal_confirm_rate_limited",
|
||||||
|
detail: orderName,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return htmlResponse(renderStep1({ error: ERROR.generic }, theme));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-verifica server-side (integrità hidden fields / anti-tamper):
|
// Re-verifica server-side (integrità hidden fields / anti-tamper):
|
||||||
// l'ordine deve ancora esistere e l'email combaciare per questo shop.
|
// l'ordine deve ancora esistere e l'email combaciare per questo shop.
|
||||||
const match = await lookupOrder(admin, orderName, email);
|
const match = await lookupOrder(admin, orderName, email);
|
||||||
if (!match || match.orderId !== orderId) {
|
if (!match || match.orderId !== orderId) {
|
||||||
return htmlResponse(renderStep1({ error: ERROR.lookupNoMatch }));
|
return htmlResponse(renderStep1({ error: ERROR.lookupNoMatch }, theme));
|
||||||
}
|
}
|
||||||
|
|
||||||
// A6: re-check finestra + esclusioni (anti-tamper) prima di registrare.
|
// A6: re-check finestra + esclusioni (anti-tamper) prima di registrare.
|
||||||
const block = await checkCompliance(shop, match);
|
const block = await checkCompliance(shop, match);
|
||||||
if (block) {
|
if (block) {
|
||||||
return htmlResponse(renderStep1({ error: block }));
|
return htmlResponse(renderStep1({ error: block }, theme));
|
||||||
}
|
}
|
||||||
|
|
||||||
// transmittedAt = ISTANTE DI TRASMISSIONE (click "Conferma recesso"),
|
// IDEMPOTENZA: il diritto di recesso si esercita UNA volta per contratto.
|
||||||
// NON di ricezione. Salvato in UTC (Prisma DateTime).
|
// Se esiste gia' una richiesta per quest'ordine non ne creiamo una seconda:
|
||||||
const transmittedAt = new Date();
|
// il timestamp legale di trasmissione (onere della prova) deve restare uno.
|
||||||
|
// Non blocchiamo il flusso (niente dark pattern): confermiamo il primo atto.
|
||||||
|
const existing = await db.withdrawalRequest
|
||||||
|
.findFirst({
|
||||||
|
where: { shop, orderId: match.orderId, status: { not: "REJECTED" } },
|
||||||
|
orderBy: { transmittedAt: "asc" },
|
||||||
|
})
|
||||||
|
.catch(() => null);
|
||||||
|
const isDuplicate = !!existing;
|
||||||
|
|
||||||
let created;
|
let record = existing;
|
||||||
try {
|
if (!record) {
|
||||||
created = await db.withdrawalRequest.create({
|
// transmittedAt = ISTANTE DI TRASMISSIONE (click "Conferma recesso"),
|
||||||
data: {
|
// NON di ricezione. Salvato in UTC (Prisma DateTime).
|
||||||
shop, // sempre da session.shop
|
const transmittedAt = new Date();
|
||||||
orderId: match.orderId, // GID risolto dal lookup
|
try {
|
||||||
orderName: match.orderName,
|
record = await db.withdrawalRequest.create({
|
||||||
customerName,
|
data: {
|
||||||
email,
|
shop, // sempre da session.shop
|
||||||
statementText,
|
orderId: match.orderId, // GID risolto dal lookup
|
||||||
transmittedAt,
|
|
||||||
channel: "GUEST",
|
|
||||||
locale: MVP_LOCALE,
|
|
||||||
status: "RECEIVED",
|
|
||||||
receiptSentAt: null, // valorizzato da A4 dopo invio ricevuta
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Audit trail append-only (SPEC R6): evento + hash del payload.
|
|
||||||
await db.auditLog.create({
|
|
||||||
data: {
|
|
||||||
shop,
|
|
||||||
event: "withdrawal_received",
|
|
||||||
payloadHash: sha256({
|
|
||||||
orderId: match.orderId,
|
|
||||||
orderName: match.orderName,
|
orderName: match.orderName,
|
||||||
customerName,
|
customerName,
|
||||||
email,
|
email,
|
||||||
statementText,
|
statementText,
|
||||||
transmittedAt: transmittedAt.toISOString(),
|
transmittedAt,
|
||||||
}),
|
channel: "GUEST",
|
||||||
|
locale: MVP_LOCALE,
|
||||||
|
status: "RECEIVED",
|
||||||
|
receiptSentAt: null, // valorizzato da A4 dopo invio ricevuta
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Audit trail append-only (SPEC R6): evento + hash del payload.
|
||||||
|
await db.auditLog.create({
|
||||||
|
data: {
|
||||||
|
shop,
|
||||||
|
event: "withdrawal_received",
|
||||||
|
payloadHash: sha256({
|
||||||
|
orderId: match.orderId,
|
||||||
|
orderName: match.orderName,
|
||||||
|
customerName,
|
||||||
|
email,
|
||||||
|
statementText,
|
||||||
|
transmittedAt: transmittedAt.toISOString(),
|
||||||
|
}),
|
||||||
|
detail: match.orderName,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return htmlResponse(renderStep1({ error: ERROR.generic }, theme));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await db.auditLog.create({
|
||||||
|
data: {
|
||||||
|
shop,
|
||||||
|
event: "withdrawal_duplicate",
|
||||||
detail: match.orderName,
|
detail: match.orderName,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch {
|
|
||||||
return htmlResponse(renderStep1({ error: ERROR.generic }));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const transmittedLabel = formatTransmittedAt(transmittedAt);
|
// Sul duplicato mostriamo il timestamp ORIGINALE, non quello del click.
|
||||||
|
const transmittedLabel = formatTransmittedAt(record.transmittedAt);
|
||||||
|
|
||||||
// A4 — ricevuta su supporto durevole, senza ritardo. Il recesso è GIÀ
|
// A4 — ricevuta su supporto durevole, senza ritardo. Il recesso è GIÀ
|
||||||
// persistito e valido: un invio email fallito NON deve invalidarlo.
|
// persistito e valido: un invio email fallito NON deve invalidarlo.
|
||||||
@@ -399,49 +458,89 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||||||
console.error("[recesso] SMTP shop non decifrabile: uso default app");
|
console.error("[recesso] SMTP shop non decifrabile: uso default app");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const receipt = await sendWithdrawalReceipt({
|
// Ricevuta. Sul duplicato non ri-registriamo nulla: al massimo RE-inviamo
|
||||||
to: email,
|
// la ricevuta, non piu' di una volta all'ora (aiuta chi non l'ha ricevuta,
|
||||||
vars: {
|
// senza trasformare l'endpoint in un amplificatore di email).
|
||||||
shopName,
|
let shouldSend = true;
|
||||||
shopUrl: shopInfo.url,
|
if (isDuplicate) {
|
||||||
orderName: match.orderName,
|
const since = new Date(Date.now() - 60 * 60 * 1000);
|
||||||
orderUrl: match.orderUrl,
|
const recent = await db.auditLog
|
||||||
customerName,
|
.count({
|
||||||
transmittedAt: transmittedLabel,
|
where: {
|
||||||
statementText,
|
shop,
|
||||||
},
|
event: "receipt_resent",
|
||||||
subject: settings?.emailSubject,
|
detail: match.orderName,
|
||||||
intro: settings?.emailIntro,
|
createdAt: { gt: since },
|
||||||
note: settings?.emailNote,
|
},
|
||||||
operational,
|
})
|
||||||
smtp,
|
.catch(() => 1);
|
||||||
});
|
shouldSend = recent === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let receipt: ReceiptResult | null = null;
|
||||||
|
if (shouldSend) {
|
||||||
|
receipt = await sendWithdrawalReceipt({
|
||||||
|
to: email,
|
||||||
|
vars: {
|
||||||
|
shopName,
|
||||||
|
shopUrl: shopInfo.url,
|
||||||
|
orderName: match.orderName,
|
||||||
|
orderUrl: match.orderUrl,
|
||||||
|
customerName,
|
||||||
|
transmittedAt: transmittedLabel,
|
||||||
|
statementText,
|
||||||
|
},
|
||||||
|
subject: settings?.emailSubject,
|
||||||
|
intro: settings?.emailIntro,
|
||||||
|
note: settings?.emailNote,
|
||||||
|
operational,
|
||||||
|
smtp,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let resent = false;
|
||||||
try {
|
try {
|
||||||
if (receipt.ok) {
|
if (receipt?.ok) {
|
||||||
await db.withdrawalRequest.update({
|
if (isDuplicate) {
|
||||||
where: { id: created.id },
|
resent = true;
|
||||||
data: { receiptSentAt: new Date() },
|
await db.auditLog.create({
|
||||||
});
|
data: { shop, event: "receipt_resent", detail: match.orderName },
|
||||||
await db.auditLog.create({
|
});
|
||||||
data: { shop, event: "receipt_sent", detail: match.orderName },
|
} else {
|
||||||
});
|
await db.withdrawalRequest.update({
|
||||||
} else {
|
where: { id: record.id },
|
||||||
|
data: { receiptSentAt: new Date() },
|
||||||
|
});
|
||||||
|
await db.auditLog.create({
|
||||||
|
data: { shop, event: "receipt_sent", detail: match.orderName },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (receipt) {
|
||||||
console.error("[recesso] invio ricevuta fallito:", receipt.error);
|
console.error("[recesso] invio ricevuta fallito:", receipt.error);
|
||||||
await db.auditLog.create({
|
await db.auditLog.create({
|
||||||
data: {
|
data: {
|
||||||
shop,
|
shop,
|
||||||
event: "receipt_failed",
|
event: "receipt_failed",
|
||||||
// no PII in audit: l'errore SMTP puo' contenere l'email.
|
// errore SMTP con email mascherate (diagnosticabile, senza PII).
|
||||||
detail: "invio ricevuta fallito",
|
detail: redactErr(receipt.error),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
// Retry in-request nel mailer (trySend) + messaggio di successo onesto
|
|
||||||
// (successMessage riceve receipt.ok). Coda persistente = eventuale futuro.
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[recesso] aggiornamento stato ricevuta fallito:", e);
|
console.error("[recesso] aggiornamento stato ricevuta fallito:", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Duplicato: niente secondo reso, tag, notifica o annullo automatico.
|
||||||
|
// Confermiamo il primo atto (col suo timestamp) e usciamo.
|
||||||
|
if (isDuplicate) {
|
||||||
|
return htmlResponse(
|
||||||
|
renderStep4(
|
||||||
|
duplicateMessage(match.orderName, transmittedLabel, email, resent),
|
||||||
|
theme,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Integrazione Resi Shopify: crea un reso nativo per gli ordini evasi
|
// Integrazione Resi Shopify: crea un reso nativo per gli ordini evasi
|
||||||
// (best-effort; il recesso legale e' gia' registrato). Ordini annullati/
|
// (best-effort; il recesso legale e' gia' registrato). Ordini annullati/
|
||||||
// rimborsati -> skip (G5: evita doppio reso/rimborso). Non evaso -> il
|
// rimborsati -> skip (G5: evita doppio reso/rimborso). Non evaso -> il
|
||||||
@@ -467,7 +566,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||||||
returnStatus = ret.status;
|
returnStatus = ret.status;
|
||||||
if (ret.status === "created") {
|
if (ret.status === "created") {
|
||||||
await db.withdrawalRequest.update({
|
await db.withdrawalRequest.update({
|
||||||
where: { id: created.id },
|
where: { id: record.id },
|
||||||
data: { shopifyReturnId: ret.returnId },
|
data: { shopifyReturnId: ret.returnId },
|
||||||
});
|
});
|
||||||
await db.auditLog.create({
|
await db.auditLog.create({
|
||||||
@@ -544,12 +643,20 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||||||
const notifyTo = settings?.notifyEmail?.trim();
|
const notifyTo = settings?.notifyEmail?.trim();
|
||||||
if (settings?.notifyEnabled && notifyTo) {
|
if (settings?.notifyEnabled && notifyTo) {
|
||||||
try {
|
try {
|
||||||
|
// Il merchant gestisce il reso dall'ADMIN, non dalla pagina cliente.
|
||||||
|
// gid://shopify/Order/123 -> admin.shopify.com/store/<handle>/orders/123
|
||||||
|
const storeHandle = shop.replace(/\.myshopify\.com$/, "");
|
||||||
|
const orderNumericId = match.orderId.split("/").pop() ?? "";
|
||||||
|
const adminOrderUrl = orderNumericId
|
||||||
|
? `https://admin.shopify.com/store/${storeHandle}/orders/${orderNumericId}`
|
||||||
|
: "";
|
||||||
const notif = await sendMerchantNotification({
|
const notif = await sendMerchantNotification({
|
||||||
to: notifyTo,
|
to: notifyTo,
|
||||||
orderName: match.orderName,
|
orderName: match.orderName,
|
||||||
customerName,
|
customerName,
|
||||||
customerEmail: email,
|
customerEmail: email,
|
||||||
orderUrl: match.orderUrl,
|
statementText,
|
||||||
|
adminOrderUrl,
|
||||||
transmittedAt: transmittedLabel,
|
transmittedAt: transmittedLabel,
|
||||||
returnStatus,
|
returnStatus,
|
||||||
smtp,
|
smtp,
|
||||||
@@ -561,8 +668,8 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||||||
data: {
|
data: {
|
||||||
shop,
|
shop,
|
||||||
event: notif.ok ? "merchant_notified" : "merchant_notify_failed",
|
event: notif.ok ? "merchant_notified" : "merchant_notify_failed",
|
||||||
// no PII in audit: l'errore SMTP puo' contenere l'email.
|
// errore SMTP con email mascherate (diagnosticabile, senza PII).
|
||||||
detail: notif.ok ? match.orderName : "notifica merchant fallita",
|
detail: notif.ok ? match.orderName : redactErr(notif.error),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -574,13 +681,13 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||||||
match.orderName,
|
match.orderName,
|
||||||
transmittedLabel,
|
transmittedLabel,
|
||||||
email,
|
email,
|
||||||
receipt.ok,
|
!!receipt?.ok,
|
||||||
);
|
);
|
||||||
return htmlResponse(renderStep4(msg));
|
return htmlResponse(renderStep4(msg, theme));
|
||||||
}
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
// Intent sconosciuto: torna allo Step 1 senza rivelare dettagli.
|
// Intent sconosciuto: torna allo Step 1 senza rivelare dettagli.
|
||||||
return htmlResponse(renderStep1());
|
return htmlResponse(renderStep1(undefined, theme));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
{%- endcomment -%}
|
{%- endcomment -%}
|
||||||
|
|
||||||
{{ 'recesso-storefront.css' | asset_url | stylesheet_tag }}
|
{{ 'recesso-storefront.css' | asset_url | stylesheet_tag }}
|
||||||
{{ 'recesso-storefront.js' | asset_url | script_tag }}
|
<script src="{{ 'recesso-storefront.js' | asset_url }}" defer></script>
|
||||||
|
|
||||||
{% if block.settings.show_link %}
|
{% if block.settings.show_link %}
|
||||||
<div class="recesso-embed recesso-embed-{{ block.settings.alignment }}">
|
<div class="recesso-embed recesso-embed-{{ block.settings.alignment }}">
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
{%- assign rc_label = block.settings.label | default: 'Recedere dal contratto qui' -%}
|
{%- assign rc_label = block.settings.label | default: 'Recedere dal contratto qui' -%}
|
||||||
|
|
||||||
{{ 'recesso-storefront.css' | asset_url | stylesheet_tag }}
|
{{ 'recesso-storefront.css' | asset_url | stylesheet_tag }}
|
||||||
{% if block.settings.use_modal %}{{ 'recesso-storefront.js' | asset_url | script_tag }}{% endif %}
|
{% if block.settings.use_modal %}<script src="{{ 'recesso-storefront.js' | asset_url }}" defer></script>{% endif %}
|
||||||
|
|
||||||
<div class="recesso-block recesso-align-{{ block.settings.alignment }}" {{ block.shopify_attributes }}>
|
<div class="recesso-block recesso-align-{{ block.settings.alignment }}" {{ block.shopify_attributes }}>
|
||||||
<a
|
<a
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ primary_region = "fra" # Frankfurt (EU data residency)
|
|||||||
force_https = true
|
force_https = true
|
||||||
auto_stop_machines = true
|
auto_stop_machines = true
|
||||||
auto_start_machines = true
|
auto_start_machines = true
|
||||||
|
# TODO(go-live): portare a 1. Con 0 un cold start misurato ha richiesto ~38s, e
|
||||||
|
# l'art. 54-bis pretende una funzione "sempre accessibile" e "facilmente
|
||||||
|
# utilizzabile". Rimandato per scelta (costo: 1 macchina sempre accesa).
|
||||||
|
# Vedi PROTECTED-CUSTOMER-DATA.md §5.2.
|
||||||
min_machines_running = 0
|
min_machines_running = 0
|
||||||
|
|
||||||
[[vm]]
|
[[vm]]
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Settings" ADD COLUMN "themeAccent" TEXT,
|
||||||
|
ADD COLUMN "themeButtonBg" TEXT,
|
||||||
|
ADD COLUMN "themeButtonText" TEXT,
|
||||||
|
ADD COLUMN "themeCustomCss" TEXT,
|
||||||
|
ADD COLUMN "themeFont" TEXT,
|
||||||
|
ADD COLUMN "themeRadius" INTEGER,
|
||||||
|
ADD COLUMN "themeWidth" INTEGER;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- Schema colore del form: "light" | "dark" | "auto".
|
||||||
|
-- NULL sulle righe esistenti: l'app applica il default (light).
|
||||||
|
ALTER TABLE "Settings" ADD COLUMN "themeScheme" TEXT;
|
||||||
@@ -69,6 +69,16 @@ model Settings {
|
|||||||
smtpPass String? // cifrato AES-256-GCM (mai in chiaro)
|
smtpPass String? // cifrato AES-256-GCM (mai in chiaro)
|
||||||
smtpSecure Boolean @default(false)
|
smtpSecure Boolean @default(false)
|
||||||
smtpFrom String?
|
smtpFrom String?
|
||||||
|
// Motore di stile del form (livello 1 = token, livello 3 = CSS custom).
|
||||||
|
themeAccent String?
|
||||||
|
themeButtonBg String?
|
||||||
|
themeButtonText String?
|
||||||
|
themeRadius Int?
|
||||||
|
themeFont String?
|
||||||
|
themeWidth Int?
|
||||||
|
// "light" | "dark" | "auto". NULL = default applicativo (light).
|
||||||
|
themeScheme String?
|
||||||
|
themeCustomCss String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user