Compare commits

...

18 Commits

Author SHA1 Message Date
fa4fc02e9f chore(theme): drop "consigliato" hint from the light scheme label
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mv83a29B4eFv5ixoj6PoE1
2026-07-10 18:14:04 +02:00
86a901d598 feat(theme): reduce Aspetto to a color-scheme preset, drop the token editor
The per-token editor (accent, button background, button text, font,
radius, max width, custom CSS) let a merchant produce an unreadable or
inaccessible form: the accent token only drives the keyboard focus ring,
so a light accent hid it; button background/text had no contrast guard;
custom CSS was arbitrary CSS injected into the page. Per the merchant's
call, the appearance is now a fixed, accessible design with a single
choice: light (default), dark, or auto.

- Settings "Aspetto" tab: keep only the Schema colore select; remove the
  color fields, ColorField, the hex/int/font validators and their state.
- proxy loadTheme: read only themeScheme; stop applying the other tokens.
- Preview iframe made non-interactive (sandbox="allow-scripts", inert,
  tabIndex -1, pointer-events:none) with scrolling moved to the wrapper,
  since it demonstrates a fixed design and must not look clickable.

The token columns stay in the schema, unread, so the customization
surface can be re-exposed later without a migration. theme.ts (the
engine) is untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mv83a29B4eFv5ixoj6PoE1
2026-07-10 18:04:56 +02:00
168b11c73b feat(theme): let the merchant choose the form color scheme
PAGE_CSS carried an unconditional `@media (prefers-color-scheme: dark)`
block that redefined --bg, --surface, --text and --border. The merchant
theme override only sets accent, button and typography tokens, so a
visitor whose OS is in dark mode saw a dark card with the merchant's
button colour on it, and no setting could change that. The form is
rendered in a modal on top of the shop theme, which the app cannot read
and which is almost always light.

Move the dark palette out of PAGE_CSS into DARK_VARS and let renderShell
apply it according to a new "themeScheme" setting: light (default), dark,
or auto (follows prefers-color-scheme, the previous behaviour).

The cascade is now base light palette -> chosen scheme -> merchant
override, so custom accent and button colours win under either scheme.
`color-scheme` and its meta tag follow the same setting, which keeps the
native controls consistent.

Existing rows have themeScheme NULL and fall back to the light default.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mv83a29B4eFv5ixoj6PoE1
2026-07-10 14:56:39 +02:00
cadf88683e fix(proxy): prevent caching of the withdrawal form
The App Proxy HTML response carried no cache headers. Two consequences:

- Browsers could serve a stale copy of /apps/recesso from cache, so theme
  changes made by the merchant were not reflected for the customer.
- The page renders the order name, the customer email and the free-text
  withdrawal declaration. That content must not be stored by the browser
  or by any intermediate proxy.

Send no-store (plus Pragma for HTTP/1.0 caches) and Referrer-Policy on
every response produced by htmlResponse.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mv83a29B4eFv5ixoj6PoE1
2026-07-10 14:41:28 +02:00
99935ab6fe R6: tab Aspetto con anteprima fedele del form
Sesto tab delle Impostazioni. Il merchant configura accento, sfondo/testo del
bottone, carattere, raggio, larghezza e CSS custom; l'anteprima e' resa con lo
STESSO renderer e lo STESSO CSS dello storefront (recesso.view), non con una
ricostruzione approssimata.

- Validazione al salvataggio: un valore non valido NON viene scritto (colori solo
  esadecimali, raggio e larghezza clampati, font da whitelist). Cosi' il form
  ricade sul default invece di rompersi.
- ColorField: campo testo + selettore nativo, con errore se l'esadecimale e' malformato.
- Verificato con build di produzione che nel bundle client non finisca codice
  server-only (crypto.server, nodemailer, PrismaClient, APP_ENCRYPTION_KEY: zero
  occorrenze); la vista pura invece c'e', come deve.
2026-07-10 14:18:07 +02:00
e51828a8d0 Estrai recesso.view.ts: vista pura, condivisibile con l'admin
PAGE_CSS, renderShell, stepLayout, renderStep1..4, escapeHtml/attr e
PROXY_STOREFRONT_PATH spostati in un modulo PURO (nessun node/server).
recesso.server.ts li ri-esporta: nessun chiamante cambia (proxy invariato).

Motivo: l'anteprima nell'admin deve rendere lo stesso identico markup e CSS
dello storefront, e non puo' farlo via <iframe src=route-admin> perche' l'admin
embedded si autentica con session token via App Bridge, che una navigazione
iframe non trasporta. Serve anche per il livello 2 (eredita il tema).

Rimosso il parametro 'theme' spurio che il replace globale aveva aggiunto a
stepLayout (non lo usa: il tema lo applica renderShell).
2026-07-10 14:14:24 +02:00
d331e609a5 R6 L1+L3: motore di stile del form (token + CSS custom)
Il form era gia' tokenizzato (~30 var CSS in :root): non riscriviamo nulla,
iniettiamo un blocco di override DOPO il CSS di base, cosi' un errore di
configurazione del merchant non puo' rompere il default.

- theme.ts (modulo PURO, condiviso con la futura anteprima admin): ThemeTokens,
  FONT_PRESETS, themeStyle(). Sanificazione a monte: colori solo esadecimali,
  raggio e larghezza clampati, font da whitelist, CSS custom con rimozione di
  </style, @import, expression(), javascript: e cap a 4000 char.
  Derivati: --focus-ring da accent (rgba), --primary-bg-hover per shading.
- recesso.server: tokenizzati anche font (--font) e larghezza (--card-max), che
  erano hardcoded; renderShell(inner, theme) inietta l'override; renderStep1..4
  accettano e propagano il tema.
- Settings: themeAccent/ButtonBg/ButtonText/Radius/Font/Width/CustomCss + migrazione.
- proxy: loadTheme(shop) in loader e action, propagato ai 22 punti di render.
  Nell'early-return senza sessione il tema e' null (non conosciamo lo shop).

fly.toml: min_machines_running resta 0 per scelta (TODO go-live, vedi
PROTECTED-CUSTOMER-DATA.md §5.2).
2026-07-10 13:07:43 +02:00
787553af22 Protected Customer Data: autovalutazione + macchina sempre calda
PROTECTED-CUSTOMER-DATA.md - verdetto: la distribuzione CUSTOM ha accesso
'Always available' a Level 1 e Level 2 (email, statusPageUrl): nessuna approvazione
Shopify da attendere per gli store live. La review servira' per l'app pubblica (R7).
Il fatto che funzioni oggi su pcrt-reso-test non prova nulla: sui development store
la review non e' richiesta comunque.

I requisiti di sicurezza L1/L2 restano pero' obbligatori. Autovalutazione con
evidenze: cifratura a riposo OK (volume Fly ENCRYPTED=true), backup cifrati OK
(snapshot Fly), separazione test/prod OK. Gap: retention non documentata, privacy
policy assente, incident response assente, password Fly da cambiare, snapshot con
sola retention 5gg e nodo singolo (i record legali sono la PROVA del recesso).

fly.toml: min_machines_running 0 -> 1. Un cold start misurato ha richiesto ~38s;
l'art. 54-bis pretende una funzione sempre accessibile e facilmente utilizzabile.
2026-07-10 12:55:00 +02:00
5796e94154 Admin: avviso critico se non c'e' alcun SMTP configurato
Il tab diceva 'Vuoto = provider di default dell'app', ma questa installazione non
ha secret SMTP_*: vuoto significava 'nessuna ricevuta parte', in silenzio - mentre
CHECKLIST-COMPLIANCE-MERCHANT promette 'ricevuta sempre inviata'.

Il loader ora espone appDefaultSmtp (presenza di SMTP_HOST lato app). Se non c'e'
ne' quello ne' l'SMTP per-shop, banner critico: la ricevuta su supporto durevole
e' un obbligo (art. 54-bis), la funzione non e' conforme finche' non e' configurato.
2026-07-10 12:42:19 +02:00
97bb3998dd Notifica merchant: includi la dichiarazione del cliente
La dichiarazione e' un campo editabile: il cliente puo' scriverci un intento
parziale (es. 'recedo solo per 2 confezioni su 3'). Finiva nel DB e nella ricevuta
al cliente, ma NON nella notifica al merchant, che agiva senza averla mai letta -
mentre l'app apriva un reso totale. Ora la notifica riporta il testo integrale,
con l'avvertenza di leggerlo prima di agire.
2026-07-10 10:57:46 +02:00
aa0c77289d Ambito del recesso esplicito (intero ordine) + avviso auto-annullo + A6-ter in roadmap
Base legale (ANALISI-REQUISITI-LEGALI.md §5): il considerando 37 della 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 e' conforme. Ma il form
non lo diceva, mentre le automazioni agivano in modo totale.

- statementTemplate: 'relativo all'INTERO ordine #X' (la dichiarazione ora
  corrisponde a cio' che l'app fa davvero).
- SCOPE_HINT sotto la dichiarazione: il recesso riguarda l'intero ordine, per il
  parziale contattare il negozio (canale modulo tipo/email, sempre valido).
- Admin: banner quando autoCancelUnfulfilled e' attivo -> annulla e rimborsa
  l'INTERO ordine, irreversibile; con ordini multi-articolo meglio tenerlo spento.
- PLAN: A6-ter (recesso parziale) come attivita' OPZIONALE con toggle per-shop
  partialWithdrawalEnabled, default OFF. Include la nota che l'idempotenza
  (shop, orderId) di fa8ee9c andra' riportata a (shop, orderId, articoli).
- ANALISI-REQUISITI-LEGALI.md §5: punto chiarito + panorama concorrenti (Revize
  fa il parziale, Rescindly no, Shopify non ha pulsante nativo, LegalBlink non e'
  un concorrente ma un generatore di documenti).
2026-07-10 10:56:50 +02:00
fa8ee9c93f Idempotenza del recesso + rate-limit sulla conferma
Due difetti sullo stesso endpoint:

1) Nessun controllo duplicati: ogni POST 'confirm' creava una nuova
   WithdrawalRequest. Il diritto di recesso si esercita UNA volta per contratto:
   una seconda dichiarazione e' senza oggetto e, peggio, inquina l'onere della
   prova (N timestamp di trasmissione per un atto che deve averne uno).
   Ora: se esiste gia' una richiesta per (shop, orderId) non REJECTED, non se ne
   crea una seconda; niente secondo reso, tag, notifica o annullo automatico.
   Audit 'withdrawal_duplicate'. La schermata conferma il primo atto col suo
   timestamp originale (nessun blocco del flusso: non e' un dark pattern).
   Ricevuta re-inviabile al massimo 1 volta/ora (aiuta chi non l'ha ricevuta
   senza amplificare), tracciata da audit 'receipt_resent'; il timestamp legale
   e receiptSentAt originali non vengono toccati.

2) checkRateLimit era applicato SOLO al case 'lookup'. La conferma era libera:
   ogni POST inviava una ricevuta al cliente + una notifica al merchant e
   bruciava quota Admin API -> amplificatore di email raggiungibile da chiunque
   navighi lo store. Ora e' limitata, con audit 'withdrawal_confirm_rate_limited'.
2026-07-10 10:31:47 +02:00
4998795648 Avviso: email di notifica uguale al mittente SMTP
Spedire da un indirizzo a se stesso via relay esterno (Brevo) viene spesso
scartato dai provider: era il caso in prod (notifyEmail == smtpFrom), la
notifica risultava 'merchant_notified' ma non veniva consegnata.
2026-07-10 10:16:48 +02:00
0b9726a778 Notifica merchant: link all'ordine nell'ADMIN (non la pagina cliente)
Il bottone puntava a order.statusPageUrl, cioe' la pagina di stato lato cliente:
inutile per il merchant, che il reso lo gestisce dal pannello. Ora punta a
admin.shopify.com/store/<handle>/orders/<id>, ricavato dal GID dell'ordine.
Etichetta contestuale: 'Gestisci il reso' quando un reso esiste o e' stato creato,
altrimenti 'Apri l'ordine'.
2026-07-10 10:12:41 +02:00
b15567c8e1 Deliverability: Message-ID allineato al dominio mittente + checklist dominio
- trySend(): genera un Message-ID nel dominio del From (il default di nodemailer
  usa l'hostname del container -> dominio incoerente, segnale antispam).
- CHECKLIST-COMPLIANCE-MERCHANT: l'autenticazione del dominio mittente (SPF/DKIM/
  DMARC) e la configurazione SMTP diventano obblighi espliciti del merchant; senza,
  la ricevuta durevole non raggiunge il consumatore. Chiarito che l'app la invia
  sempre ma la consegna dipende dal provider/dominio del merchant.
2026-07-10 10:01:11 +02:00
23f6a0a3e8 Fix invio email: TLS derivato dalla porta (465 diretto / 587 STARTTLS)
Causa reale del mancato invio in prod: Settings aveva porta 587 con 'Connessione
sicura diretta' spuntata -> nodemailer apriva subito TLS su una porta che parla
in chiaro + STARTTLS -> handshake fallito -> receipt_failed.

- buildTransport: secure derivato dalla porta standard (465=true, 587=false),
  requireTLS su 587. Su porte non standard resta la scelta del merchant.
- Checkbox 'Connessione sicura' ora dichiara che e' ignorata sulle porte standard.
- Avviso se notifiche attive ma 'Email notifiche' vuota (era null in prod: per
  questo non arrivava neanche la notifica al merchant).
2026-07-10 09:53:13 +02:00
f5d2c8664f Impostazioni: invio email di prova + diagnostica SMTP
- sendTestEmail(): transport.verify() prima dell'invio (errori auth/connessione
  espliciti), 1 solo tentativo, ritorna l'errore SMTP grezzo.
- Tab 'Email (SMTP)': campo destinatario + bottone 'Invia email di prova' (usa i
  valori del form anche se non salvati; password digitata oppure quella salvata
  decifrata). Banner con l'errore esatto.
- Guardia: se Host SMTP e' impostato ma il Mittente (From) e' vuoto, l'invio
  fallisce con messaggio chiaro (i provider rifiutano mittenti non verificati)
  + banner di avviso nel tab.
- Audit: receipt_failed / merchant_notify_failed ora salvano l'errore SMTP con
  le email mascherate (diagnosticabile, senza PII) invece di una stringa generica.
2026-07-10 09:47:52 +02:00
c423a8d9a7 Extension: script non parser-blocking (script_tag -> <script defer>)
Sia app embed che app block caricavano il JS con il filtro script_tag (sincrono,
parser-blocking -> warning ParserBlockingScript). Passato a <script defer>.
Il JS ha gia' la guardia window.__recessoModalInit, quindi il doppio caricamento
(embed + block sulla stessa pagina) resta sicuro.
2026-07-10 09:01:05 +02:00
17 changed files with 1619 additions and 666 deletions

View File

@@ -56,3 +56,52 @@
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.
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**.

View File

@@ -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.
- 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).
- **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.
- 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).
- 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).
- **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.
- Configurare le **esclusioni Art. 59** solo per prodotti realmente esclusi (mala-config = negare il diritto a torto).

10
PLAN.md
View File

@@ -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).
**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.
- **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)*
- **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.
- **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.)*
- **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.

119
PROTECTED-CUSTOMER-DATA.md Normal file
View 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.

View File

@@ -10,6 +10,7 @@
* SMTP_SECURE("true"/"false"), MAIL_FROM. DEV: Mailpit localhost:1025.
*/
import { randomUUID } from "node:crypto";
import nodemailer from "nodemailer";
import {
renderReceiptHtml,
@@ -35,13 +36,20 @@ function buildTransport(smtp?: SmtpConfig | null) {
const port = useShop
? Number(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 pass = useShop ? smtp!.pass : process.env.SMTP_PASS;
return nodemailer.createTransport({
host,
port,
secure,
requireTLS: port === 587, // forza STARTTLS dove e' obbligatorio
auth: user ? { user, pass: pass ?? "" } : undefined,
connectionTimeout: 10_000,
greetingTimeout: 10_000,
@@ -59,16 +67,28 @@ function mailFrom(smtp?: SmtpConfig | null): string {
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. */
async function trySend(
transport: Transport,
message: Parameters<Transport["sendMail"]>[0],
attempts = 3,
) {
const msg = { messageId: makeMessageId(message.from), ...message };
let lastErr: unknown;
for (let i = 0; i < attempts; i++) {
try {
return await transport.sendMail(message);
return await transport.sendMail(msg);
} catch (e) {
lastErr = e;
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 {
return String(s)
.replace(/&/g, "&amp;")
@@ -162,7 +239,10 @@ export async function sendMerchantNotification(params: {
orderName: string;
customerName: 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;
returnStatus: "created" | "no_returnable" | "exists" | "error";
smtp?: SmtpConfig | null;
@@ -181,8 +261,12 @@ export async function sendMerchantNotification(params: {
? "L'ordine non risulta evaso: valuta annullamento o rimborso."
: "Reso non creato automaticamente: verifica manualmente l'ordine.";
const orderBtn = /^https?:\/\//i.test(params.orderUrl)
? `<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>`
const btnLabel =
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}`;
@@ -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;">Trasmesso:</span> ${escM(params.transmittedAt)}</div>
</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: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}
@@ -210,9 +299,10 @@ ${orderBtn}
`Ordine: ${params.orderName}`,
`Cliente: ${params.customerName} (${params.customerEmail})`,
`Trasmesso: ${params.transmittedAt}`,
`Dichiarazione del cliente: "${params.statementText}"`,
actionLine,
"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)
.join("\n");

View File

@@ -23,9 +23,20 @@ export const FIELD = {
// Dichiarazione precompilata (editabile), deriva dall'Allegato I-B.
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).
// Mostrate su richiesta dal pulsante info "i", per non appesantire il flusso.
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).
export function receiptEmailSubject(orderName: string): string {
return `Ricevuta della tua richiesta di recesso - Ordine ${orderName}`;

View File

@@ -6,8 +6,10 @@
* inesistente ed email non combaciante);
* - rate-limit base per shop+IP (hardening -> A9);
* - hashing payload per l'audit trail;
* - formattazione timestamp di TRASMISSIONE (Europe/Rome);
* - rendering HTML standalone (nessun Polaris, CSS inline minimale, accessibile).
* - formattazione timestamp di TRASMISSIONE (Europe/Rome).
*
* 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`
* garantisce che Remix non impacchetti questo modulo nel bundle client.
@@ -16,13 +18,13 @@
import { createHash } from "node:crypto";
import type { AdminApiContext } from "@shopify/shopify-app-remix/server";
import type { ExclusionRule } from "@prisma/client";
import { FIELD, INFO_TITLE, INFO_BODY, CONFIRM_LABEL, PAGE_TITLE } 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";
export {
renderStep1,
renderStep2,
renderStep3,
renderStep4,
} from "./recesso.view";
// Locale MVP fisso (multi-lingua -> A7).
export const MVP_LOCALE = "it";
@@ -31,20 +33,6 @@ export const MVP_LOCALE = "it";
// Utility
// ---------------------------------------------------------------------------
/** Escape dei caratteri HTML per prevenire XSS su tutto l'input riflesso. */
export function escapeHtml(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
/** Attributo HTML sicuro (per value="..."): riusa escapeHtml. */
export function attr(value: string): string {
return escapeHtml(value);
}
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
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 rateBucket = new Map<string, { count: number; resetAt: number }>();
/** Ritorna true se la richiesta è consentita, false se ha superato la soglia. */
let lastRatePruneAt = 0;
/** Rimuove le voci scadute dal bucket (evita crescita illimitata della Map). */
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 {
const key = `${shop}:${ip}`;
const now = Date.now();
@@ -645,548 +633,23 @@ export async function lookupOrder(
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).
* Il popover vive nel top-layer; chiusura con Esc, click fuori o pulsante.
* Response HTML standalone (status 200 di default per non leakare via status).
*
* `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">&times;</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 {
return new Response(html, {
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
View 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
/** 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">&times;</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
View 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",
};

View File

@@ -12,6 +12,7 @@ import {
Card,
Tabs,
TextField,
Select,
Button,
ButtonGroup,
Banner,
@@ -25,7 +26,16 @@ import { TitleBar } from "@shopify/app-bridge-react";
import { authenticate } from "../shopify.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 {
DEFAULT_INTRO,
DEFAULT_NOTE,
@@ -65,12 +75,53 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
smtpSecure: s?.smtpSecure ?? false,
smtpFrom: s?.smtpFrom ?? "",
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) => {
const { session } = await authenticate.admin(request);
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 intro = String(f.get("intro") ?? "").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,
smtpSecure: f.get("smtpSecure") === "true",
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.
@@ -116,9 +170,10 @@ export const action = async ({ request }: ActionFunctionArgs) => {
create: { shop: session.shop, ...finalData },
update: finalData,
});
return { ok: true };
return { ok: true, tested: false, error: null };
};
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>`;
return (
@@ -168,12 +223,14 @@ export default function SettingsPage() {
const [smtpPass, setSmtpPass] = useState("");
const [smtpSecure, setSmtpSecure] = useState(d.smtpSecure);
const [smtpFrom, setSmtpFrom] = useState(d.smtpFrom);
const [testTo, setTestTo] = useState(d.notifyEmail);
const [themeScheme, setThemeScheme] = useState(d.themeScheme);
const [showSaved, setShowSaved] = useState(false);
const saving = nav.state === "submitting";
useEffect(() => {
if (actionData?.ok) setShowSaved(true);
if (actionData?.ok && !actionData.tested) setShowSaved(true);
}, [actionData]);
const previewSubject = useMemo(
@@ -213,9 +270,39 @@ export default function SettingsPage() {
[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 = () => {
setShowSaved(false);
const fd = new FormData();
fd.set("intent", "save");
fd.set("subject", subject);
fd.set("intro", intro);
fd.set("note", note);
@@ -237,9 +324,15 @@ export default function SettingsPage() {
fd.set("smtpPass", smtpPass);
fd.set("smtpSecure", String(smtpSecure));
fd.set("smtpFrom", smtpFrom);
fd.set("themeScheme", themeScheme);
submit(fd, { method: "post" });
};
const resetTheme = () => {
setThemeScheme(DEFAULT_SCHEME);
setShowSaved(false);
};
const resetEmail = () => {
setSubject(DEFAULT_SUBJECT);
setIntro(DEFAULT_INTRO);
@@ -261,6 +354,7 @@ export default function SettingsPage() {
{ id: "regole", content: "Regole recesso" },
{ id: "reso", content: "Reso e stato ordine" },
{ id: "smtp", content: "Email (SMTP)" },
{ id: "aspetto", content: "Aspetto" },
];
return (
@@ -275,6 +369,18 @@ export default function SettingsPage() {
</Banner>
) : 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} />
{tab === 0 ? (
@@ -373,6 +479,23 @@ export default function SettingsPage() {
helpText="Dove ricevere le notifiche. Senza indirizzo l'email non parte."
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
label="Aggiungi il tag 'Recesso' all'ordine"
checked={tagEnabled}
@@ -453,6 +576,17 @@ export default function SettingsPage() {
onChange={setAutoCancelUnfulfilled}
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
label="Spese di restituzione a carico del cliente (Art. 57)"
checked={returnAtCustomerExpense}
@@ -511,11 +645,23 @@ export default function SettingsPage() {
Email (SMTP)
</Text>
<Text as="p" tone="subdued">
Vuoto = provider di default dell'app. Compila per inviare dal
tuo SMTP (email dal tuo dominio). La password è cifrata a
riposo.
{d.appDefaultSmtp
? "Vuoto = provider di default dell'app. Compila per inviare dal tuo SMTP (email dal tuo dominio). La password è cifrata a riposo."
: "Questa installazione non ha un provider di default: la ricevuta parte solo dal tuo SMTP. La password è cifrata a riposo."}
</Text>
</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
label="Host SMTP"
value={smtpHost}
@@ -550,9 +696,10 @@ export default function SettingsPage() {
}
/>
<Checkbox
label="Connessione sicura diretta (SSL/TLS, porta 465)"
label="Connessione sicura diretta (SSL/TLS)"
checked={smtpSecure}
onChange={setSmtpSecure}
helpText="Ignorato sulle porte standard: 465 usa sempre TLS diretto, 587 usa sempre STARTTLS. Vale solo su porte non standard."
/>
<TextField
label="Mittente (From)"
@@ -561,10 +708,128 @@ export default function SettingsPage() {
autoComplete="off"
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>
</Card>
) : 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>
</Layout.Section>
</Layout>

View File

@@ -20,6 +20,7 @@ import db from "../db.server";
import {
sendMerchantNotification,
sendWithdrawalReceipt,
type ReceiptResult,
type SmtpConfig,
} from "../lib/mailer.server";
import { decryptSecret } from "../lib/crypto.server";
@@ -27,6 +28,7 @@ import {
ERROR,
EXCLUSION_REASON,
NOTICE,
duplicateMessage,
exclusionMessage,
statementTemplate,
successMessage,
@@ -53,6 +55,24 @@ import {
sha256,
} 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
// 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).
export const loader = async ({ request }: LoaderFunctionArgs) => {
await authenticate.public.appProxy(request);
return htmlResponse(renderStep1());
const { session } = await authenticate.public.appProxy(request);
const theme = session ? await loadTheme(session.shop) : null;
return htmlResponse(renderStep1(undefined, theme));
};
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.
// Multi-tenant: lo shop lo prendiamo SOLO da session.shop, mai dal client.
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 theme = await loadTheme(shop);
const form = await request.formData();
const intent = String(form.get("intent") ?? "");
@@ -134,7 +157,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
error: ERROR.missingField,
orderName: orderNameInput,
email: emailInput,
}),
}, theme),
);
}
if (!isValidEmail(emailInput)) {
@@ -143,7 +166,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
error: ERROR.invalidEmail,
orderName: orderNameInput,
email: emailInput,
}),
}, theme),
);
}
@@ -162,7 +185,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
error: ERROR.generic,
orderName: orderNameInput,
email: emailInput,
}),
}, theme),
);
}
@@ -184,7 +207,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
error: ERROR.lookupNoMatch,
orderName: orderNameInput,
email: emailInput,
}),
}, theme),
);
}
@@ -196,7 +219,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
error: block,
orderName: orderNameInput,
email: emailInput,
}),
}, theme),
);
}
@@ -211,7 +234,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
email: match.email,
statementText: statementTemplate(match.orderName),
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),
// riparti dallo Step 1 senza rivelare nulla.
if (!orderId || !orderName) {
return htmlResponse(renderStep1({ error: ERROR.generic }));
return htmlResponse(renderStep1({ error: ERROR.generic }, theme));
}
if (!customerName || !email || !statementText) {
@@ -240,7 +263,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
customerName,
statementText: statementText || statementTemplate(orderName),
error: ERROR.missingField,
}),
}, theme),
);
}
if (!isValidEmail(email)) {
@@ -252,12 +275,12 @@ export const action = async ({ request }: ActionFunctionArgs) => {
customerName,
statementText,
error: ERROR.invalidEmail,
}),
}, theme),
);
}
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 statementText = String(form.get("statementText") ?? "").trim();
if (!orderId || !orderName) {
return htmlResponse(renderStep1({ error: ERROR.generic }));
return htmlResponse(renderStep1({ error: ERROR.generic }, theme));
}
return htmlResponse(
renderStep2({
@@ -280,7 +303,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
email,
customerName,
statementText: statementText || statementTemplate(orderName),
}),
}, theme),
);
}
@@ -303,65 +326,101 @@ export const action = async ({ request }: ActionFunctionArgs) => {
!statementText ||
!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):
// l'ordine deve ancora esistere e l'email combaciare per questo shop.
const match = await lookupOrder(admin, orderName, email);
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.
const block = await checkCompliance(shop, match);
if (block) {
return htmlResponse(renderStep1({ error: block }));
return htmlResponse(renderStep1({ error: block }, theme));
}
// transmittedAt = ISTANTE DI TRASMISSIONE (click "Conferma recesso"),
// NON di ricezione. Salvato in UTC (Prisma DateTime).
const transmittedAt = new Date();
// IDEMPOTENZA: il diritto di recesso si esercita UNA volta per contratto.
// Se esiste gia' una richiesta per quest'ordine non ne creiamo una seconda:
// 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;
try {
created = await db.withdrawalRequest.create({
data: {
shop, // sempre da session.shop
orderId: match.orderId, // GID risolto dal lookup
orderName: match.orderName,
customerName,
email,
statementText,
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,
let record = existing;
if (!record) {
// transmittedAt = ISTANTE DI TRASMISSIONE (click "Conferma recesso"),
// NON di ricezione. Salvato in UTC (Prisma DateTime).
const transmittedAt = new Date();
try {
record = await db.withdrawalRequest.create({
data: {
shop, // sempre da session.shop
orderId: match.orderId, // GID risolto dal lookup
orderName: match.orderName,
customerName,
email,
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,
},
});
} 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À
// 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");
}
}
const 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,
});
// Ricevuta. Sul duplicato non ri-registriamo nulla: al massimo RE-inviamo
// la ricevuta, non piu' di una volta all'ora (aiuta chi non l'ha ricevuta,
// senza trasformare l'endpoint in un amplificatore di email).
let shouldSend = true;
if (isDuplicate) {
const since = new Date(Date.now() - 60 * 60 * 1000);
const recent = await db.auditLog
.count({
where: {
shop,
event: "receipt_resent",
detail: match.orderName,
createdAt: { gt: since },
},
})
.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 {
if (receipt.ok) {
await db.withdrawalRequest.update({
where: { id: created.id },
data: { receiptSentAt: new Date() },
});
await db.auditLog.create({
data: { shop, event: "receipt_sent", detail: match.orderName },
});
} else {
if (receipt?.ok) {
if (isDuplicate) {
resent = true;
await db.auditLog.create({
data: { shop, event: "receipt_resent", detail: match.orderName },
});
} else {
await db.withdrawalRequest.update({
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);
await db.auditLog.create({
data: {
shop,
event: "receipt_failed",
// no PII in audit: l'errore SMTP puo' contenere l'email.
detail: "invio ricevuta fallito",
// errore SMTP con email mascherate (diagnosticabile, senza PII).
detail: redactErr(receipt.error),
},
});
// Retry in-request nel mailer (trySend) + messaggio di successo onesto
// (successMessage riceve receipt.ok). Coda persistente = eventuale futuro.
}
} catch (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
// (best-effort; il recesso legale e' gia' registrato). Ordini annullati/
// rimborsati -> skip (G5: evita doppio reso/rimborso). Non evaso -> il
@@ -467,7 +566,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
returnStatus = ret.status;
if (ret.status === "created") {
await db.withdrawalRequest.update({
where: { id: created.id },
where: { id: record.id },
data: { shopifyReturnId: ret.returnId },
});
await db.auditLog.create({
@@ -544,12 +643,20 @@ export const action = async ({ request }: ActionFunctionArgs) => {
const notifyTo = settings?.notifyEmail?.trim();
if (settings?.notifyEnabled && notifyTo) {
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({
to: notifyTo,
orderName: match.orderName,
customerName,
customerEmail: email,
orderUrl: match.orderUrl,
statementText,
adminOrderUrl,
transmittedAt: transmittedLabel,
returnStatus,
smtp,
@@ -561,8 +668,8 @@ export const action = async ({ request }: ActionFunctionArgs) => {
data: {
shop,
event: notif.ok ? "merchant_notified" : "merchant_notify_failed",
// no PII in audit: l'errore SMTP puo' contenere l'email.
detail: notif.ok ? match.orderName : "notifica merchant fallita",
// errore SMTP con email mascherate (diagnosticabile, senza PII).
detail: notif.ok ? match.orderName : redactErr(notif.error),
},
});
} catch (e) {
@@ -574,13 +681,13 @@ export const action = async ({ request }: ActionFunctionArgs) => {
match.orderName,
transmittedLabel,
email,
receipt.ok,
!!receipt?.ok,
);
return htmlResponse(renderStep4(msg));
return htmlResponse(renderStep4(msg, theme));
}
default:
// Intent sconosciuto: torna allo Step 1 senza rivelare dettagli.
return htmlResponse(renderStep1());
return htmlResponse(renderStep1(undefined, theme));
}
};

View File

@@ -7,7 +7,7 @@
{%- endcomment -%}
{{ '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 %}
<div class="recesso-embed recesso-embed-{{ block.settings.alignment }}">

View File

@@ -9,7 +9,7 @@
{%- assign rc_label = block.settings.label | default: 'Recedere dal contratto qui' -%}
{{ '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 }}>
<a

View File

@@ -17,6 +17,10 @@ primary_region = "fra" # Frankfurt (EU data residency)
force_https = true
auto_stop_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
[[vm]]

View File

@@ -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;

View File

@@ -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;

View File

@@ -69,6 +69,16 @@ model Settings {
smtpPass String? // cifrato AES-256-GCM (mai in chiaro)
smtpSecure Boolean @default(false)
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())
updatedAt DateTime @updatedAt