Compare commits

...

31 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
e7b03da729 Extension: aggiungi locales/en.default.json (fix ENOENT locales su shopify app deploy) 2026-07-07 18:34:31 +02:00
e0190c6a45 Fix app proxy 400 in prod: Docker base node:18-alpine -> node:22-alpine
Node 18-alpine non espone 'crypto' come global dichiarato: la libreria
@shopify/shopify-api getCryptoLib() fa 'crypto?.webcrypto' -> ReferenceError
'crypto is not defined' -> validateAppProxyHmac fallisce -> 400 su ogni richiesta
app proxy. Node 22 (come in dev) espone crypto global -> HMAC ok -> 200.

Diagnosi: log Fly mostravano 'crypto is not defined' prima di ogni 400; verificato
con firma app-proxy calcolata a mano (ora 200 + form recesso).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mv83a29B4eFv5ixoj6PoE1
2026-07-07 18:23:41 +02:00
f3b3b1abc3 Deploy Fly: application_url/app_proxy/redirect -> recesso-custom.fly.dev + escludi .env dall'immagine
- shopify.app.toml: URL Shopify puntano all'app Fly (custom distribution) invece del tunnel dev.
- .dockerignore: esclude .env, *cred*, .shopify (niente segreti dev nell'immagine prod).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mv83a29B4eFv5ixoj6PoE1
2026-07-07 17:50:11 +02:00
3bb7a30c1d SMTP per-shop configurabile dalle Impostazioni (password cifrata AES-256-GCM)
- Settings: smtpHost/Port/User/Pass(cifrata)/Secure/From. Vuoto -> provider
  default dell'app (env); compilato -> invio dal SMTP del merchant.
- crypto.server: encrypt/decrypt AES-256-GCM (chiave APP_ENCRYPTION_KEY).
- mailer: SmtpConfig + buildTransport(smtp) per-shop o env; mailFrom override.
  Sia ricevuta cliente sia notifica merchant usano lo SMTP del merchant.
- proxy: costruisce SmtpConfig (decifra pass), passa ai due invii.
- admin: nuovo tab 'Email (SMTP)'; password mai ri-mostrata (vuoto = invariata).
- Migrazione smtp_settings. APP_ENCRYPTION_KEY: dev in .env, prod = fly secret.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mv83a29B4eFv5ixoj6PoE1
2026-07-07 17:29:33 +02:00
ad9f76b6be R4 hardening (A9): retry ricevuta + webhook GDPR + rate-limit prune + fix PII/UX
Da review avversariale indipendente:
- Retry invio email (trySend, 3 tentativi backoff) per ricevuta cliente e notifica merchant.
- Webhook GDPR implementati (erano stub) con idempotenza (skip se gia' processed):
  * customers/redact: pseudonimizza PII (nome/email/dichiarazione) nelle
    WithdrawalRequest del cliente, mantiene il record legale (ordine+timestamp)
    come prova art. 54-bis (base art. 17(3) GDPR).
  * shop/redact: purge completa dei dati shop (Settings/Exclusion/Withdrawal/
    Session/AuditLog + WebhookEvent stale).
  * customers/data_request: registra la richiesta + n. record (merchant li recupera
    dalla dashboard Recessi).
- Rate-limit: pruning periodico del bucket in-memory (fix memory leak).
- Ricevuta fallita: messaggio di successo onesto (successMessage riceve receipt.ok)
  invece del falso 'ti abbiamo inviato la ricevuta'.
- PII: rimossa dai detail audit persistiti degli errori SMTP (ricevuta/notifica).

Non modificati (verificati): off-by-1 finestra = permissivo, non blocca a torto;
'doppio escape' = falso allarme (template e valori escapati una volta ciascuno).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mv83a29B4eFv5ixoj6PoE1
2026-07-07 17:07:06 +02:00
ce36f1a657 Fix: reso gia' esistente -> stato 'exists' + messaggio corretto al merchant
Se l'ordine ha gia' un reso (order.returns), createShopifyReturn ritorna 'exists'
invece di fallire: la notifica dice 'esiste gia' un reso, gestiscilo dall'ordine'
al posto del fuorviante 'reso non creato, fallo manualmente'. Audit shopify_return_exists.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mv83a29B4eFv5ixoj6PoE1
2026-07-07 16:54:43 +02:00
5fb9bf494c Dashboard Recessi: promemoria rimborso 14gg (Art. 56) + colonna 'Rimborsa entro' (trasmissione + 14gg)
Il promemoria non e' piu' solo nella notifica email: visibile anche dove il
merchant gestisce le richieste (funziona pure con notifiche off).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mv83a29B4eFv5ixoj6PoE1
2026-07-07 16:47:17 +02:00
04a43182cb R2 (G7): promemoria rimborso 14gg + facolta' di trattenuta (Art. 56) nella notifica al merchant
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mv83a29B4eFv5ixoj6PoE1
2026-07-07 16:40:05 +02:00
a550d8ff2e A6-bis: testi riquadro operativo editabili (segnaposto) + Impostazioni in tab
- opTextUnfulfilled / opTextShipped editabili dal merchant, con segnaposto
  {{returnAddress}} {{returnCost}} {{orderState}} {{customerName}} {{orderName}}
  {{shopName}}. Default = testi attuali. returnCost deriva da returnAtCustomerExpense
  (a tuo/nostro carico, Art. 57). returnInstructions deprecato (assorbito da opTextShipped).
- Pagina Impostazioni riorganizzata in 4 tab (Email / Notifiche / Regole recesso /
  Reso e stato ordine) + anteprima live del riquadro per stato (non evaso e spedito).
- emailTemplate: renderOperationalBlock ora usa i template + sostituzione segnaposto;
  renderOperationalPreview esportato per l'admin. Migrazione op_texts (additiva).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mv83a29B4eFv5ixoj6PoE1
2026-07-07 16:35:12 +02:00
a6a1900287 R1 A6-bis: operativita' per stato ordine (toggle) + checklist compliance
Comportamenti Pizeta-confirmed, tutti toggle per-shop:
- stateAwareEmail (default ON): la ricevuta durevole include un blocco operativo
  per stato - non evaso => annullo+rimborso; spedito/consegnato => istruzioni reso
  (indirizzo, spese a carico Art.57, prodotto integro, rimborso dopo rientro).
- autoCancelUnfulfilled (default OFF): recesso su ordine non evaso => orderCancel
  (refund+restock). Abilita anche lo stop del remarketing via orders/cancelled.
- returnAtCustomerExpense / returnInstructions / returnAddress: config istruzioni reso.

Impl: helper orderState + cancelOrder (recesso.server); renderOperationalBlock +
param operational in renderReceiptHtml (emailTemplate); mailer passa operational;
proxy calcola stato, passa alla ricevuta, auto-annulla; sezione admin 'Reso e stato
ordine'. Migrazione a6bis_state_ops.

+ CHECKLIST-COMPLIANCE-MERCHANT.md: cosa fa l'app vs obblighi del merchant.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mv83a29B4eFv5ixoj6PoE1
2026-07-07 16:16:22 +02:00
945f1a6230 PLAN: integra A6-bis (operativita' per stato ordine, Pizeta-confirmed) + piano consolidato stato/residuo
- A6-bis: toggle per-shop stateAwareEmail / autoCancelUnfulfilled /
  returnAtCustomerExpense / returnInstructions / returnAddress. Fonte: call
  Pizeta 2026-06-16 + AUDIT-STATI-ORDINE.md.
- Sezione 4-ter: vista unica FATTO vs RESIDUO (R1..R7) con ordine consigliato.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mv83a29B4eFv5ixoj6PoE1
2026-07-07 16:05:35 +02:00
883bd82708 Avviso storefront per ordini gia' annullati/rimborsati (info non bloccante)
Al passo 2 del recesso, se l'ordine risulta gia' annullato (cancelledAt) o
rimborsato/voided (financialStatus), mostra un banner informativo ambra (non
blocca): il recesso resta ammesso (diritto incondizionato + funzione sempre
accessibile Art. 54-bis), ma il consumatore e' informato dello stato.

- recesso.copy: NOTICE.orderClosed.
- recesso.server: noticeBanner + campo notice in renderStep2 + stile .notice ambra con icona.
- proxy: calcola orderClosed al lookup e passa la notice a renderStep2.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mv83a29B4eFv5ixoj6PoE1
2026-07-07 15:46:51 +02:00
3968d34011 Audit stati ordine + fix P1 (finestra su consegna, ordini chiusi)
Audit AUDIT-STATI-ORDINE.md: matrice stato ordine x normativa (Art. 52/56/57) x
comportamento attuale x gap, con fix prioritizzati.

Fix P1 (correttezza legale):
- G1+G4: la finestra 14gg decorre dalla CONSEGNA (evento fulfillment DELIVERED),
  non dalla spedizione (Art. 52 = possesso fisico). Se non consegnato, la finestra
  non e' iniziata -> computeDeadline null -> non blocca mai.
- G5: ordini annullati (cancelledAt) o rimborsati/voided (displayFinancialStatus)
  -> skip creazione reso (evita doppio reso/rimborso), audit shopify_return_skipped.
- lookupOrder esteso: deliveredAt (da eventi fulfillment), cancelledAt, financialStatus.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mv83a29B4eFv5ixoj6PoE1
2026-07-07 15:06:38 +02:00
32 changed files with 2841 additions and 896 deletions

6
.gitignore vendored
View File

@@ -27,3 +27,9 @@ app/prisma/*.sqlite*
# OS / editor cruft
.DS_Store
# Credenziali locali - MAI committare
Cred Fly
*[Cc]red*
*.secret
token.txt

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

118
AUDIT-STATI-ORDINE.md Normal file
View File

@@ -0,0 +1,118 @@
# Audit - Recesso per stato dell'ordine vs normativa
Verifica dello strumento (app + Shopify) rispetto al Codice del Consumo, per stato
dell'ordine (in elaborazione / spedito / consegnato / annullato). Obiettivo:
mappare, per ogni stato, il trattamento legale corretto, cosa fa oggi lo strumento,
e i gap con i fix prioritizzati.
Fonti normative (verificate 2026-07-07):
- Art. 52 Cod. Consumo - decorrenza termine 14 gg.
- Art. 56 - obblighi del professionista (rimborso).
- Art. 57 - obblighi del consumatore (restituzione, spese).
- Art. 54-bis - funzione/pulsante di recesso (D.Lgs 209/2025, in vigore 19/06/2026).
---
## 1. Principi legali (verificati)
1. **Il diritto nasce dalla conclusione del contratto.** Il consumatore puo' recedere
anche PRIMA della spedizione/consegna. Non serve aver ricevuto il bene per recedere.
2. **Decorrenza del termine di 14 giorni (Art. 52):**
- Beni: dal giorno in cui il consumatore (o terzo da lui designato, diverso dal
vettore) acquisisce il **possesso fisico** del bene = **CONSEGNA**. Non la spedizione.
- Beni multipli in un solo ordine, consegnati separatamente: dal possesso
dell'**ultimo** bene.
- Servizi: dalla **conclusione del contratto**.
- Se il professionista non fornisce l'informativa sul recesso: termine esteso fino
a 12 mesi + 14 gg. (L'app FORNISCE l'informativa -> vale il termine ordinario.)
3. **Rimborso (Art. 56):** entro **14 gg** da quando il professionista e' informato del
recesso. Comprende le spese di consegna standard. Il professionista puo' **trattenere**
il rimborso finche' non ha ricevuto i beni o finche' il consumatore non prova di
averli rispediti (salvo offerta di ritiro). Stesso mezzo di pagamento.
4. **Restituzione e spese (Art. 57):** il consumatore restituisce entro 14 gg dalla
comunicazione. Sostiene il **costo diretto della restituzione SOLO SE** il
professionista lo ha informato di tale onere; altrimenti lo sostiene il professionista.
Il consumatore risponde solo della diminuzione di valore da manipolazione oltre il
necessario; NON risponde se non e' stato informato del diritto di recesso.
---
## 2. Matrice: stato ordine x trattamento x comportamento attuale x gap
| Stato Shopify | Recesso ammesso? | Finestra 14gg | Gestione corretta | Cosa fa OGGI l'app | Gap |
|---|---|---|---|---|---|
| **Non evaso** (in elaborazione, pagato) | Si' | NON iniziata (nessun possesso) | Annullamento + rimborso pieno (incl. consegna). Nessun reso | Reso: skip (no_returnable). Tag + notifica. Finestra: calcola da data ordine | **G1** finestra puo' bloccare a torto; **G2** nessun annullo/rimborso |
| **Spedito / in transito** (evaso, non consegnato) | Si' | NON iniziata o in decorrenza solo alla consegna | Rifiuto consegna o reso dopo ricezione | Reso creato (perche' "fulfilled"). Tag + notifica. Finestra: da data spedizione | **G3** reso forse prematuro; **G4** finestra da spedizione non da consegna |
| **Consegnato** | Si' (entro finestra) | Decorre dalla **consegna** | Reso + rimborso | Reso creato. Tag + notifica. Finestra: da data spedizione | **G4** finestra ancorata a spedizione non a consegna |
| **Parz. evaso** | Si' | Dall'ultimo bene consegnato | Reso parziale | Reso solo per righe evase. Finestra: da ultima evasione | **G4** + nuance parziale/esclusioni |
| **Annullato / rimborsato** | Gia' chiuso | N/A | Nessuna azione (gia' risolto) | Processa comunque: crea record; reso puo' fallire | **G5** nessun rilevamento stato chiuso -> rischio doppio reso/rimborso |
---
## 3. Gap dettagliati e fix
### G1 - Finestra blocca a torto ordini non consegnati [P1]
`computeDeadline` usa `fulfilledAt || createdAt`. Su ordine NON evaso usa la data
ordine: se enforceWindow attivo e l'ordine ha piu' di 14 gg ma non e' mai stato
consegnato, il recesso viene bloccato -> **errato** (la finestra non e' nemmeno iniziata).
**Fix:** se non c'e' consegna, la finestra NON e' iniziata -> non bloccare mai.
### G4 - Finestra ancorata a spedizione, non a consegna [P1]
Art. 52 = possesso fisico (consegna). Oggi il riferimento e' `fulfillment.createdAt`
(~ spedizione), che precede la consegna -> scadenza calcolata troppo presto ->
rischio di bloccare recessi ancora validi.
**Fix:** usare la data dell'evento di **consegna** (`displayFulfillmentStatus = DELIVERED`
+ data evento di consegna dei fulfillment). Se spedito ma non consegnato -> finestra
non iniziata. Fallback conservativo se il dato consegna manca: non bloccare.
### G5 - Nessun rilevamento di ordini annullati/rimborsati [P1]
Su ordine gia' annullato/rimborsato l'app processa comunque (crea record, tenta reso).
**Fix:** leggere `cancelledAt` / `displayFinancialStatus` (REFUNDED/VOIDED) e
`returns` esistenti; se gia' chiuso -> registrare il recesso ma saltare reso e avvisare
il merchant (niente doppio rimborso).
### G2 - Pre-spedizione: nessun annullo/rimborso automatico [P2]
Legalmente pre-consegna e' un annullamento. Oggi: solo tag + notifica, merchant manuale.
**Fix (opzionale):** opzione Settings "annulla/rimborsa in automatico gli ordini non
evasi al recesso" (orderCancel/refundCreate). Richiede scope aggiuntivi -> valutare.
### G3 - Reso creato alla spedizione, non alla consegna [P2]
Creiamo il reso appena l'ordine e' "fulfilled" (spedito), anche se non consegnato.
Il flusso reso Shopify assume beni presso il consumatore.
**Fix:** valutare se creare il reso solo a consegna avvenuta (DELIVERED), altrimenti
solo tag + notifica finche' non consegnato.
### G6 - Informativa spese di restituzione (Art. 57) [P2]
Il consumatore paga il reso SOLO se informato. Verificare che storefront/ricevuta
lo dichiarino; altrimenti l'onere e' del merchant.
**Fix:** riga informativa "le spese di restituzione sono a tuo carico" (configurabile:
chi paga) nel modal e/o nella ricevuta. Coordinare con testo Art. 49 gia' presente.
### G7 - Rimborso: tempi e trattenuta (Art. 56) [P3]
L'app non gestisce rimborsi (giusto lasciarli a Shopify/merchant). Ma il merchant va
aiutato sui tempi (14 gg) e sulla facolta' di trattenere fino a riconsegna.
**Fix:** nella notifica al merchant, ricordare "rimborso entro 14 gg; puoi trattenere
fino a riconsegna dei beni o prova di spedizione". (Solo copy, no logica.)
---
## 4. Priorita' consigliata
- **P1 (correttezza legale) - FATTO (2026-07-07):** G1 + G4 (finestra su data di
CONSEGNA via evento `DELIVERED`; mai bloccare se non consegnato) + G5 (ordini
annullati/rimborsati/voided -> skip reso). `lookupOrder` esteso con data consegna,
`cancelledAt`, `displayFinancialStatus`.
- **P2:** G6 (informativa spese reso) + G3 (reso a consegna) + G2 (annullo/rimborso
pre-spedizione opzionale).
- **P3:** G7 (copy rimborso nella notifica merchant).
---
## 5. Note
- Tutto A6 (finestra/esclusioni) e' oggi dietro toggle default OFF: i gap G1/G4 non
sono attivi finche' il merchant non abilita l'enforcement. Comunque da correggere
prima di consigliarne l'attivazione.
- Servizi (non beni): decorrenza dalla conclusione. L'app oggi ragiona su beni/ordini
fisici; per merchant di soli servizi la finestra andrebbe ancorata a `createdAt`
(gia' fallback) - ok, ma da esplicitare se rilevante.

View File

@@ -0,0 +1,44 @@
# Recesso - Cosa fa l'app vs cosa deve fare il merchant
Ripartizione delle responsabilita' di conformita' (Art. 54-bis + Codice del
Consumo). L'app copre la FUNZIONE elettronica di recesso; alcuni obblighi restano
in capo al merchant. Da consegnare col progetto.
---
## 1. Cosa GARANTISCE l'app (automatico, non disattivabile)
- **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. *(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).
## 2. Cosa il merchant CONFIGURA nell'app (Impostazioni, opzionale)
- Testi email (oggetto/introduzione/nota) - le parti legali restano fisse.
- Notifica al merchant (on/off + indirizzo email).
- Tag "Recesso" sull'ordine (on/off).
- Finestra 14 gg: enforcement on/off + giorni (default 14, calcolata sulla **consegna**).
- Esclusioni Art. 59 (prodotti/tag non recedibili) + enforcement on/off.
- **[R1 A6-bis]** Ricevuta differenziata per stato ordine; annullo automatico ordini non evasi; indirizzo reso + spese a carico cliente + istruzioni di reso.
## 3. Cosa il merchant deve fare FUORI dall'app (obblighi propri)
- **Informativa precontrattuale** sul diritto di recesso (Art. 49) nelle pagine/checkout.
- Mettere a disposizione il **modulo tipo** di recesso (Allegato I, parte B) - coesiste col pulsante.
- **Emettere il rimborso entro 14 gg** dalla notifica (Art. 56) se non usa l'annullo/rimborso automatico dell'app. Puo' trattenere fino a riconsegna merce o prova di spedizione.
- 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).
## 4. Note
- L'app e' uno **strumento** di conformita', non sostituisce la consulenza legale.
- Prima del go-live (e soprattutto per la versione pubblica/App Store) consigliata una **review legale**, anche per chiudere i punti ⚠ dottrinali in `ANALISI-REQUISITI-LEGALI.md`.
- Ambito: **beni B2C online**. Servizi e beni digitali hanno decorrenza/esclusioni diverse (Art. 52/59) - da valutare per merchant fuori scope.
- Approfondimento stati ordine: `AUDIT-STATI-ORDINE.md`.

51
PLAN.md
View File

@@ -140,7 +140,7 @@ Corretta e integrata dopo `ANALISI-REQUISITI-LEGALI.md`.
## 3. Modello dati (bozza Prisma)
- **Shop** — dominio, accessToken(cifrato), piano, installedAt
- **Settings** — labelPulsante, brandColors, indirizzoReso, giorniFinestraDefault, overrideMercati, testoInfoRecesso
- **Settings** — labelPulsante, brandColors, indirizzoReso, giorniFinestraDefault, overrideMercati, testoInfoRecesso, email (emailSubject/emailIntro/emailNote), + toggle per-shop: notifyEnabled/notifyEmail, tagEnabled, enforceWindow, enforceExclusions, **stateAwareEmail, autoCancelUnfulfilled, returnAtCustomerExpense, returnInstructions**
- **ExclusionRule** — scope(prodotto|collezione|tag|tutto), targetId, motivo(sumisura|deperibile|igiene), attiva
- **WithdrawalRequest** — shopId, orderId, orderName, nomeCliente, email, testoDichiarazione, **trasmessoAt (ts)**, locale, canale(guest|account), tipoProdotto, stato, ricevutaInviataAt, scadenzaCalcolata
- **AuditLog** — shopId, evento, hashPayload, ts (append-only, immutabile)
@@ -173,6 +173,23 @@ input / deliverable / criteri di uscita espliciti. **Nessun avvio automatico**
3. **CSS custom** — escape hatch per personalizzazione totale.
Merchant sceglie il livello; default = card a token controllata. Può diventare un agente dedicato (theming) se troppo grande per A5.
- **A6 compliance-engine** — esclusioni Art. 59 + calcolo finestra/scadenza per tipo prodotto + warning misconfig + logica rimborso (R8,R9,R10,R13,R15). **Uscita:** merchant configura esclusioni/finestre; item escluso si comporta secondo R9.
- **A6-bis operatività per stato ordine** *(confermata call Pizeta 2026-06-16 + `AUDIT-STATI-ORDINE.md`)* — comportamenti aggiuntivi, **tutti toggle per-shop** (app generica: ognuno decide). Dati già raccolti dal lookup: `displayFulfillmentStatus`, `deliveredAt`, `cancelledAt`, `financialStatus`.
1. **Ricevuta differenziata per stato** (`stateAwareEmail`, default ON): la ricevuta durevole (sempre inviata) include un blocco operativo diverso —
- *non evaso* → "ordine annullato, procederemo al rimborso";
- *spedito/consegnato* → istruzioni di reso (indirizzo, spese a carico cliente, prodotto integro, rimborso dopo il rientro).
2. **Auto-annullamento ordini non evasi** (`autoCancelUnfulfilled`, default OFF — irreversibile, opt-in): al recesso su ordine non evaso → `orderCancel` (refund+restock). Bonus: Shopify emette `orders/cancelled` → si ferma il bot di remarketing del merchant.
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).
@@ -191,6 +208,38 @@ A0 → A1 → A2 → A3 → A4 → A5 → A6 → A7 → A8 → A9 → A10 → A1
---
## 4-ter. Piano consolidato — stato attuale + residuo *(agg. 2026-07-07)*
Vista unica: cosa è FATTO e cosa RESTA, con A6-bis (Pizeta) e i gap dell'audit
integrati. Dettaglio stato/commit nella memoria di progetto + git.
### ✅ Fatto (custom-grade, testato su pcrt-reso-test)
- **A0A1** fondamenta + spec compliance + copy deck.
- **A2** Theme App Extension (app block + app embed) + **modal** storefront (iframe, redesign de-AI, popover info).
- **A3** App Proxy: flusso guest 2-step + persistenza + **timestamp trasmissione** + audit (anti-enumeration).
- **A4** ricevuta durevole (nodemailer/Mailpit dev) + **dati per-shop dinamici** (nome/URL/link ordine da Shopify) + **template editabile vincolato** (oggetto/intro/nota + anteprima live + ripristino).
- **A5 (parziale)** admin Polaris: dashboard **Recessi**, **Impostazioni** (email/notifiche/regole), **Esclusioni** (CRUD). Theming: solo token base.
- **A6 (parziale)** engine finestra + esclusioni Art. 59 (dietro toggle, default OFF).
- **A8 (stub)** webhook GDPR + HMAC.
- **EXTRA (oltre il piano originale):** integrazione **Resi Shopify** (`returnCreate` su ordini evasi), **notifica merchant + tag** (toggle), **fix P1** (finestra sulla CONSEGNA, skip ordini annullati/rimborsati), **avviso ordini chiusi**, **audit stati ordine** (`AUDIT-STATI-ORDINE.md`).
### ⏳ Residuo (prioritizzato)
- **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.
- **R6 — A5 residuo theming** — motore 3 livelli completo (token no-code / eredita-tema Liquid / CSS custom).
- **R7 — Pubblica** — A10 billing + A11 submission App Store (registrazione Partner separata → non impatta le custom live).
### Ordine consigliato
`R1 → R2 → R3 (deploy) → R4 (hardening) → R5/R6 → R7`
R1/R2 chiudono valore-cliente + compliance copy; R3 mette live; R4 mette in sicurezza prima del traffico reale; R5/R6 rifiniscono; R7 quando si va public.
---
## 5. Milestone
- **M1 (Fase 01):** scaffold + contratto compliance. *Nulla di visibile, ma toglie rischio a tutto.*

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

@@ -1,3 +1,7 @@
.cache
build
node_modules
.env
.env.*
*[Cc]red*
.shopify

View File

@@ -1,4 +1,4 @@
FROM node:18-alpine
FROM node:22-alpine
RUN apk add --no-cache openssl
EXPOSE 3000

View File

@@ -0,0 +1,47 @@
/**
* Cifratura simmetrica per segreti a riposo (es. password SMTP per-shop).
* AES-256-GCM. Chiave da env APP_ENCRYPTION_KEY (qualsiasi lunghezza: derivata
* a 32 byte via SHA-256). In prod = `fly secrets set APP_ENCRYPTION_KEY=...`.
*/
import {
createCipheriv,
createDecipheriv,
createHash,
randomBytes,
} from "node:crypto";
const PREFIX = "enc:v1:";
function key(): Buffer {
const raw = process.env.APP_ENCRYPTION_KEY;
if (!raw || raw.length < 16) {
throw new Error("APP_ENCRYPTION_KEY mancante o troppo corta (>=16 char)");
}
return createHash("sha256").update(raw).digest();
}
/** Cifra -> "enc:v1:<iv>:<tag>:<data>" (base64). */
export function encryptSecret(plain: string): string {
const iv = randomBytes(12);
const cipher = createCipheriv("aes-256-gcm", key(), iv);
const enc = Buffer.concat([cipher.update(plain, "utf8"), cipher.final()]);
const tag = cipher.getAuthTag();
return `${PREFIX}${iv.toString("base64")}:${tag.toString("base64")}:${enc.toString("base64")}`;
}
/** Decifra; se non e' nel formato cifrato, ritorna il valore invariato. */
export function decryptSecret(value: string): string {
if (!value.startsWith(PREFIX)) return value;
const parts = value.split(":");
if (parts.length !== 5) return "";
const iv = Buffer.from(parts[2]!, "base64");
const tag = Buffer.from(parts[3]!, "base64");
const data = Buffer.from(parts[4]!, "base64");
const decipher = createDecipheriv("aes-256-gcm", key(), iv);
decipher.setAuthTag(tag);
return Buffer.concat([decipher.update(data), decipher.final()]).toString(
"utf8",
);
}
export const SECRET_PREFIX = PREFIX;

View File

@@ -61,11 +61,71 @@ export function renderSubject(subjectTpl: string | null | undefined, vars: Email
return substPlain(tpl, vars).trim() || substPlain(DEFAULT_SUBJECT, vars);
}
// Testi del riquadro operativo (A6-bis) - editabili dal merchant, con segnaposto.
export const OP_PLACEHOLDERS = [
"returnAddress",
"returnCost",
"orderState",
"customerName",
"orderName",
"shopName",
] as const;
export const DEFAULT_OP_UNFULFILLED =
"Il tuo ordine non risultava ancora spedito: procederemo all'annullamento e al rimborso. Non devi restituire nulla.";
export const DEFAULT_OP_SHIPPED =
"Il prodotto risulta {{orderState}}. Per ottenere il rimborso, restituisci la merce integra a: {{returnAddress}}. Le spese di restituzione sono {{returnCost}}. Il rimborso sarà disposto dopo il rientro della merce.";
export interface OperationalConfig {
state: "unfulfilled" | "shipped" | "delivered";
textUnfulfilled?: string | null;
textShipped?: string | null;
returnAddress?: string | null;
atCustomerExpense: boolean;
}
/** Blocco operativo per stato ordine (A6-bis): testo editabile + segnaposto. */
function renderOperationalBlock(op: OperationalConfig, vars: EmailVars): string {
const tpl =
op.state === "unfulfilled"
? (op.textUnfulfilled && op.textUnfulfilled.trim()) || DEFAULT_OP_UNFULFILLED
: (op.textShipped && op.textShipped.trim()) || DEFAULT_OP_SHIPPED;
const map: Record<string, string> = {
returnAddress:
op.returnAddress && op.returnAddress.trim()
? op.returnAddress.trim()
: "l'indirizzo che ti comunicheremo",
returnCost: op.atCustomerExpense ? "a tuo carico" : "a nostro carico",
orderState: op.state === "delivered" ? "consegnato" : "spedito",
customerName: vars.customerName,
orderName: vars.orderName,
shopName: vars.shopName,
};
const withVars = escHtml(tpl).replace(
/\{\{\s*(\w+)\s*\}\}/g,
(_m, k: string) => escHtml(map[k] ?? ""),
);
const inner = nl2br(withVars);
return `<tr><td style="padding:4px 32px 8px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tr><td style="padding:14px 16px;background:#fff7e6;border:1px solid #ffe2a8;border-radius:8px;font-size:14px;line-height:1.6;color:#6a5518;">${inner}</td></tr></table>
</td></tr>`;
}
/** Anteprima del riquadro operativo per un dato stato (per l'admin). */
export function renderOperationalPreview(
op: OperationalConfig,
vars: EmailVars,
): string {
return renderOperationalBlock(op, vars);
}
/** Corpo HTML fisso con intro/nota editabili inseriti. */
export function renderReceiptHtml(
vars: EmailVars,
introTpl: string | null | undefined,
noteTpl: string | null | undefined,
operational?: OperationalConfig | null,
): string {
const intro = richText((introTpl && introTpl.trim()) || DEFAULT_INTRO, vars);
const noteVal = noteTpl && noteTpl.trim() ? richText(noteTpl, vars) : "";
@@ -107,6 +167,7 @@ export function renderReceiptHtml(
<tr><td style="padding:12px 32px 4px;">
<p style="margin:0;font-size:12.5px;line-height:1.6;color:#8a8a8a;">Questa comunicazione costituisce la ricevuta su supporto durevole ai sensi dell'art. 54-bis del Codice del Consumo. La data e l'ora indicate attestano il momento della trasmissione.</p>
</td></tr>
${operational ? renderOperationalBlock(operational, vars) : ""}
${note}
<tr><td style="padding:18px 32px 24px;border-top:1px solid #ececec;">
<p style="margin:0 0 8px;font-size:12.5px;line-height:1.6;color:#999;">Ti invieremo separatamente le istruzioni per l'eventuale reso e i tempi di rimborso.</p>

View File

@@ -10,29 +10,95 @@
* SMTP_SECURE("true"/"false"), MAIL_FROM. DEV: Mailpit localhost:1025.
*/
import { randomUUID } from "node:crypto";
import nodemailer from "nodemailer";
import {
renderReceiptHtml,
renderSubject,
type EmailVars,
type OperationalConfig,
} from "./emailTemplate";
function buildTransport() {
const host = process.env.SMTP_HOST;
/** Config SMTP per-shop (password gia' DECIFRATA). Se host assente -> usa env app. */
export interface SmtpConfig {
host?: string | null;
port?: number | null;
user?: string | null;
pass?: string | null;
secure?: boolean;
from?: string | null;
}
function buildTransport(smtp?: SmtpConfig | null) {
const useShop = !!(smtp && smtp.host && smtp.host.trim());
const host = useShop ? smtp!.host!.trim() : process.env.SMTP_HOST;
if (!host) return null;
const port = Number(process.env.SMTP_PORT ?? 587);
const user = process.env.SMTP_USER;
const port = useShop
? Number(smtp!.port ?? 587)
: Number(process.env.SMTP_PORT ?? 587);
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: process.env.SMTP_SECURE === "true",
auth: user ? { user, pass: process.env.SMTP_PASS ?? "" } : undefined,
secure,
requireTLS: port === 587, // forza STARTTLS dove e' obbligatorio
auth: user ? { user, pass: pass ?? "" } : undefined,
connectionTimeout: 10_000,
greetingTimeout: 10_000,
socketTimeout: 10_000,
});
}
function mailFrom(smtp?: SmtpConfig | null): string {
return (
(smtp?.from && smtp.from.trim()) ||
process.env.MAIL_FROM ||
"no-reply@localhost"
);
}
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(msg);
} catch (e) {
lastErr = e;
if (i < attempts - 1) {
await new Promise((r) => setTimeout(r, 400 * (i + 1)));
}
}
}
throw lastErr;
}
/** Versione testo grezza dell'HTML (fallback per client senza HTML). */
function htmlToText(html: string): string {
return html
@@ -65,19 +131,26 @@ export async function sendWithdrawalReceipt(params: {
subject?: string | null;
intro?: string | null;
note?: string | null;
operational?: OperationalConfig | null;
smtp?: SmtpConfig | null;
}): Promise<ReceiptResult> {
const transport = buildTransport();
const transport = buildTransport(params.smtp);
if (!transport) {
return { ok: false, error: "SMTP non configurato (SMTP_HOST mancante)" };
}
const subject = renderSubject(params.subject, params.vars);
const html = renderReceiptHtml(params.vars, params.intro, params.note);
const html = renderReceiptHtml(
params.vars,
params.intro,
params.note,
params.operational,
);
const text = htmlToText(html);
try {
const info = await transport.sendMail({
from: process.env.MAIL_FROM ?? "no-reply@localhost",
const info = await trySend(transport, {
from: mailFrom(params.smtp),
to: params.to,
subject,
text,
@@ -92,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;")
@@ -109,11 +239,15 @@ 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" | "error";
returnStatus: "created" | "no_returnable" | "exists" | "error";
smtp?: SmtpConfig | null;
}): Promise<ReceiptResult> {
const transport = buildTransport();
const transport = buildTransport(params.smtp);
if (!transport) {
return { ok: false, error: "SMTP non configurato (SMTP_HOST mancante)" };
}
@@ -121,12 +255,18 @@ export async function sendMerchantNotification(params: {
const actionLine =
params.returnStatus === "created"
? "E' stato creato un reso nell'ordine: gestiscilo dalla pagina dell'ordine."
: params.returnStatus === "exists"
? "Esiste gia' un reso per questo ordine: gestiscilo dalla pagina dell'ordine."
: params.returnStatus === "no_returnable"
? "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}`;
@@ -143,7 +283,13 @@ 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}
</td></tr></table>
</td></tr></table>
@@ -153,15 +299,17 @@ ${orderBtn}
`Ordine: ${params.orderName}`,
`Cliente: ${params.customerName} (${params.customerEmail})`,
`Trasmesso: ${params.transmittedAt}`,
`Dichiarazione del cliente: "${params.statementText}"`,
actionLine,
/^https?:\/\//i.test(params.orderUrl) ? params.orderUrl : "",
"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.adminOrderUrl) ? params.adminOrderUrl : "",
]
.filter(Boolean)
.join("\n");
try {
const info = await transport.sendMail({
from: process.env.MAIL_FROM ?? "no-reply@localhost",
const info = await trySend(transport, {
from: mailFrom(params.smtp),
to: params.to,
subject,
text,

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";
@@ -47,16 +58,45 @@ export const ERROR = {
generic: "Si è verificato un problema. Riprova tra poco.",
} as const;
// Avvisi informativi (non bloccanti).
export const NOTICE = {
orderClosed:
"Questo ordine risulta già annullato o rimborsato. Puoi comunque registrare il recesso; il negozio ti contatterà per eventuali dettagli.",
} as const;
// Schermata finale (dopo "Conferma recesso").
export function successMessage(
orderName: string,
transmittedAt: string,
email: string,
receiptSent = true,
): { line1: string; line2: string; line3: string } {
return {
line1: "Recesso trasmesso",
line2: `Registrato per l'ordine ${orderName} il ${transmittedAt}.`,
line3: `Ti abbiamo inviato una ricevuta a ${email}.`,
line3: receiptSent
? `Ti abbiamo inviato una ricevuta a ${email}.`
: `La ricevuta verrà inviata a ${email}. Se non la ricevi a breve, contattaci.`,
};
}
/**
* 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.`,
};
}

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,10 +85,21 @@ 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 }>();
let lastRatePruneAt = 0;
/** Rimuove le voci scadute dal bucket (evita crescita illimitata della Map). */
function pruneRateBucket(now: number): void {
if (now - lastRatePruneAt < 60_000) return;
lastRatePruneAt = now;
for (const [k, v] of rateBucket) {
if (v.resetAt <= now) rateBucket.delete(k);
}
}
/** 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();
pruneRateBucket(now);
const entry = rateBucket.get(key);
if (!entry || entry.resetAt <= now) {
rateBucket.set(key, { count: 1, resetAt: now + RATE_WINDOW_MS });
@@ -123,22 +122,25 @@ export interface MatchedOrder {
email: string; // email dell'ordine (per precompilazione)
createdAt: string;
orderUrl: string; // URL pagina di stato dell'ordine (link per il cliente)
fulfilledAt: string | null; // data ultima evasione (riferimento finestra), null se non evaso
fulfilledAt: string | null; // data ultima evasione (spedizione), null se non evaso
deliveredAt: string | null; // data di consegna (possesso fisico, Art. 52), null se non consegnato
cancelledAt: string | null; // data annullamento ordine, null altrimenti
financialStatus: string | null; // displayFinancialStatus (REFUNDED/VOIDED/PAID/...)
lineItems: Array<{ productId: string | null; tags: string[] }>;
}
// --- A6: finestra di recesso (deadline engine) ---------------------------
// Riferimento = data di evasione (consegna ~ ricezione beni) se disponibile,
// altrimenti data ordine (fallback conservativo). Scadenza = riferimento + giorni.
// Riferimento = data di CONSEGNA (possesso fisico, Art. 52), NON la spedizione.
// Se non consegnato -> la finestra non e' iniziata -> nessuna scadenza (non si
// blocca mai): il recesso resta ammesso (nasce dalla conclusione del contratto).
// NB: l'estensione a 12 mesi + 14gg per mancata informativa (Art. 49) NON e'
// gestita qui: l'app FORNISCE l'informativa, quindi vale il termine ordinario.
export function computeDeadline(
match: MatchedOrder,
windowDays: number,
): Date | null {
const ref = match.fulfilledAt || match.createdAt;
if (!ref) return null;
const d = new Date(ref);
if (!match.deliveredAt) return null;
const d = new Date(match.deliveredAt);
if (Number.isNaN(d.getTime())) return null;
d.setUTCDate(d.getUTCDate() + windowDays);
return d;
@@ -200,6 +202,23 @@ export function checkExclusions(
};
}
// --- A6-bis: stato operativo ordine --------------------------------------
export type OrderState = "unfulfilled" | "shipped" | "delivered" | "closed";
/** Stato per il flusso recesso (email differenziata + auto-annullo). */
export function orderState(match: MatchedOrder): OrderState {
if (
match.cancelledAt ||
match.financialStatus === "REFUNDED" ||
match.financialStatus === "VOIDED"
) {
return "closed";
}
if (match.deliveredAt) return "delivered";
if (match.fulfilledAt) return "shipped";
return "unfulfilled";
}
interface OrderLookupGraphQL {
data?: {
orders?: {
@@ -210,8 +229,20 @@ interface OrderLookupGraphQL {
email?: string | null;
createdAt?: string | null;
statusPageUrl?: string | null;
cancelledAt?: string | null;
displayFulfillmentStatus?: string | null;
fulfillments?: Array<{ createdAt?: string | null } | null> | null;
displayFinancialStatus?: string | null;
fulfillments?: Array<{
createdAt?: string | null;
events?: {
edges?: Array<{
node?: {
status?: string | null;
happenedAt?: string | null;
} | null;
} | null> | null;
} | null;
} | null> | null;
lineItems?: {
edges?: Array<{
node?: {
@@ -236,9 +267,19 @@ const ORDER_LOOKUP_QUERY = `#graphql
email
createdAt
statusPageUrl
cancelledAt
displayFulfillmentStatus
displayFinancialStatus
fulfillments(first: 10) {
createdAt
events(first: 25) {
edges {
node {
status
happenedAt
}
}
}
}
lineItems(first: 50) {
edges {
@@ -314,6 +355,9 @@ export async function getShopInfo(admin: AdminApiContext): Promise<ShopInfo> {
const RETURNABLE_QUERY = `#graphql
query recessoOrderFulfillments($orderId: ID!) {
order(id: $orderId) {
returns(first: 1) {
edges { node { id } }
}
fulfillments(first: 10) {
fulfillmentLineItems(first: 50) {
edges {
@@ -338,6 +382,7 @@ const RETURN_CREATE_MUTATION = `#graphql
interface ReturnableGraphQL {
data?: {
order?: {
returns?: { edges?: Array<unknown> | null } | null;
fulfillments?: Array<{
fulfillmentLineItems?: {
edges?: Array<{
@@ -360,6 +405,7 @@ interface ReturnCreateGraphQL {
export type ReturnCreation =
| { status: "created"; returnId: string }
| { status: "no_returnable" }
| { status: "exists" } // esiste gia' un reso per l'ordine
| { status: "error"; error: string };
/**
@@ -376,6 +422,10 @@ export async function createShopifyReturn(
variables: { orderId: orderGid },
});
const qBody = (await qRes.json()) as ReturnableGraphQL;
// Se esiste gia' un reso per l'ordine, non crearne un altro (evita errore fuorviante).
if ((qBody.data?.order?.returns?.edges ?? []).length > 0) {
return { status: "exists" };
}
const returnLineItems: Array<{
fulfillmentLineItemId: string;
quantity: number;
@@ -468,6 +518,51 @@ export async function tagOrderRecesso(
}
}
const ORDER_CANCEL_MUTATION = `#graphql
mutation recessoOrderCancel($id: ID!) {
orderCancel(orderId: $id, reason: CUSTOMER, refund: true, restock: true, notifyCustomer: false, staffNote: "Recesso art. 54-bis") {
job { id }
orderCancelUserErrors { field message }
}
}`;
interface OrderCancelGraphQL {
data?: {
orderCancel?: {
orderCancelUserErrors?: Array<{ message?: string | null }> | null;
} | null;
} | null;
}
/** Annulla l'ordine (recesso su ordine non evaso): refund + restock. Best-effort. */
export async function cancelOrder(
admin: AdminApiContext,
orderGid: string,
): Promise<{ ok: boolean; error?: string }> {
try {
const res = await admin.graphql(ORDER_CANCEL_MUTATION, {
variables: { id: orderGid },
});
const body = (await res.json()) as OrderCancelGraphQL;
const errs = body.data?.orderCancel?.orderCancelUserErrors ?? [];
if (errs.length) {
return {
ok: false,
error: errs
.map((e) => e?.message ?? "")
.filter(Boolean)
.join("; "),
};
}
return { ok: true };
} catch (e) {
return {
ok: false,
error: e instanceof Error ? e.message : "orderCancel fallito",
};
}
}
export async function lookupOrder(
admin: AdminApiContext,
orderInput: string,
@@ -503,6 +598,13 @@ export async function lookupOrder(
.map((f) => f?.createdAt)
.filter((d): d is string => !!d)
.sort();
const deliveryDates = (node.fulfillments ?? [])
.flatMap((f) => f?.events?.edges ?? [])
.map((e) => e?.node)
.filter((n): n is NonNullable<typeof n> => !!n)
.filter((n) => n.status === "DELIVERED" && !!n.happenedAt)
.map((n) => n.happenedAt as string)
.sort();
const lineItems = (node.lineItems?.edges ?? [])
.map((e) => e?.node)
.filter((n): n is NonNullable<typeof n> => !!n)
@@ -519,6 +621,11 @@ export async function lookupOrder(
fulfilledAt: fulfillmentDates.length
? fulfillmentDates[fulfillmentDates.length - 1]
: null,
deliveredAt: deliveryDates.length
? deliveryDates[deliveryDates.length - 1]
: null,
cancelledAt: node.cancelledAt ?? null,
financialStatus: node.displayFinancialStatus ?? null,
lineItems,
};
}
@@ -526,524 +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; }
/* 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>`;
}
/**
* 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;
}): string {
const customerName = data.customerName ?? "";
return renderShell(
stepLayout({
head: stepHead(2, `Ordine ${data.orderName}`),
body: `${errorBanner(data.error)}
<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

@@ -10,7 +10,9 @@ import {
Page,
Layout,
Card,
Tabs,
TextField,
Select,
Button,
ButtonGroup,
Banner,
@@ -24,86 +26,211 @@ import { TitleBar } from "@shopify/app-bridge-react";
import { authenticate } from "../shopify.server";
import db from "../db.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,
DEFAULT_SUBJECT,
DEFAULT_OP_SHIPPED,
DEFAULT_OP_UNFULFILLED,
OP_PLACEHOLDERS,
SAMPLE_VARS,
TEXT_PLACEHOLDERS,
renderOperationalPreview,
renderReceiptHtml,
renderSubject,
} from "../lib/emailTemplate";
export const loader = async ({ request }: LoaderFunctionArgs) => {
const { session } = await authenticate.admin(request);
const settings = await db.settings.findUnique({ where: { shop: session.shop } });
const s = await db.settings.findUnique({ where: { shop: session.shop } });
return {
subject: settings?.emailSubject ?? DEFAULT_SUBJECT,
intro: settings?.emailIntro ?? DEFAULT_INTRO,
note: settings?.emailNote ?? DEFAULT_NOTE,
notifyEnabled: settings?.notifyEnabled ?? true,
notifyEmail: settings?.notifyEmail ?? "",
tagEnabled: settings?.tagEnabled ?? true,
enforceWindow: settings?.enforceWindow ?? false,
windowDays: settings?.defaultWindowDays ?? 14,
enforceExclusions: settings?.enforceExclusions ?? false,
subject: s?.emailSubject ?? DEFAULT_SUBJECT,
intro: s?.emailIntro ?? DEFAULT_INTRO,
note: s?.emailNote ?? DEFAULT_NOTE,
notifyEnabled: s?.notifyEnabled ?? true,
notifyEmail: s?.notifyEmail ?? "",
tagEnabled: s?.tagEnabled ?? true,
enforceWindow: s?.enforceWindow ?? false,
windowDays: s?.defaultWindowDays ?? 14,
enforceExclusions: s?.enforceExclusions ?? false,
stateAwareEmail: s?.stateAwareEmail ?? true,
autoCancelUnfulfilled: s?.autoCancelUnfulfilled ?? false,
returnAtCustomerExpense: s?.returnAtCustomerExpense ?? true,
returnAddress: s?.returnAddress ?? "",
opTextUnfulfilled: s?.opTextUnfulfilled ?? DEFAULT_OP_UNFULFILLED,
opTextShipped: s?.opTextShipped ?? DEFAULT_OP_SHIPPED,
smtpHost: s?.smtpHost ?? "",
smtpPort: s?.smtpPort != null ? String(s.smtpPort) : "",
smtpUser: s?.smtpUser ?? "",
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 form = await request.formData();
const subject = String(form.get("subject") ?? "").trim();
const intro = String(form.get("intro") ?? "").trim();
const note = String(form.get("note") ?? "").trim();
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();
const opUnf = String(f.get("opTextUnfulfilled") ?? "").trim();
const opShip = String(f.get("opTextShipped") ?? "").trim();
const data = {
emailSubject: subject && subject !== DEFAULT_SUBJECT ? subject : null,
emailIntro: intro && intro !== DEFAULT_INTRO ? intro : null,
emailNote: note || null,
notifyEnabled: form.get("notifyEnabled") === "true",
notifyEmail: String(form.get("notifyEmail") ?? "").trim() || null,
tagEnabled: form.get("tagEnabled") === "true",
enforceWindow: form.get("enforceWindow") === "true",
notifyEnabled: f.get("notifyEnabled") === "true",
notifyEmail: String(f.get("notifyEmail") ?? "").trim() || null,
tagEnabled: f.get("tagEnabled") === "true",
enforceWindow: f.get("enforceWindow") === "true",
defaultWindowDays: Math.min(
365,
Math.max(1, Number(form.get("windowDays")) || 14),
Math.max(1, Number(f.get("windowDays")) || 14),
),
enforceExclusions: form.get("enforceExclusions") === "true",
enforceExclusions: f.get("enforceExclusions") === "true",
stateAwareEmail: f.get("stateAwareEmail") === "true",
autoCancelUnfulfilled: f.get("autoCancelUnfulfilled") === "true",
returnAtCustomerExpense: f.get("returnAtCustomerExpense") === "true",
returnAddress: String(f.get("returnAddress") ?? "").trim() || null,
opTextUnfulfilled:
opUnf && opUnf !== DEFAULT_OP_UNFULFILLED ? opUnf : null,
opTextShipped: opShip && opShip !== DEFAULT_OP_SHIPPED ? opShip : null,
smtpHost: String(f.get("smtpHost") ?? "").trim() || null,
smtpPort:
Number(f.get("smtpPort")) > 0 ? Math.trunc(Number(f.get("smtpPort"))) : null,
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.
const newPass = String(f.get("smtpPass") ?? "").trim();
const finalData = newPass
? { ...data, smtpPass: encryptSecret(newPass) }
: data;
await db.settings.upsert({
where: { shop: session.shop },
create: { shop: session.shop, ...data },
update: data,
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 (
<iframe
title="Anteprima riquadro"
srcDoc={doc}
style={{
width: "100%",
height: `${height}px`,
border: "1px solid #e1e1e1",
borderRadius: "8px",
background: "#fff",
}}
/>
);
}
export default function SettingsPage() {
const data = useLoaderData<typeof loader>();
const d = useLoaderData<typeof loader>();
const actionData = useActionData<typeof action>();
const nav = useNavigation();
const submit = useSubmit();
const [subject, setSubject] = useState(data.subject);
const [intro, setIntro] = useState(data.intro);
const [note, setNote] = useState(data.note);
const [notifyEnabled, setNotifyEnabled] = useState(data.notifyEnabled);
const [notifyEmail, setNotifyEmail] = useState(data.notifyEmail);
const [tagEnabled, setTagEnabled] = useState(data.tagEnabled);
const [enforceWindow, setEnforceWindow] = useState(data.enforceWindow);
const [windowDays, setWindowDays] = useState(String(data.windowDays));
const [enforceExclusions, setEnforceExclusions] = useState(
data.enforceExclusions,
const [tab, setTab] = useState(0);
const [subject, setSubject] = useState(d.subject);
const [intro, setIntro] = useState(d.intro);
const [note, setNote] = useState(d.note);
const [notifyEnabled, setNotifyEnabled] = useState(d.notifyEnabled);
const [notifyEmail, setNotifyEmail] = useState(d.notifyEmail);
const [tagEnabled, setTagEnabled] = useState(d.tagEnabled);
const [enforceWindow, setEnforceWindow] = useState(d.enforceWindow);
const [windowDays, setWindowDays] = useState(String(d.windowDays));
const [enforceExclusions, setEnforceExclusions] = useState(d.enforceExclusions);
const [stateAwareEmail, setStateAwareEmail] = useState(d.stateAwareEmail);
const [autoCancelUnfulfilled, setAutoCancelUnfulfilled] = useState(
d.autoCancelUnfulfilled,
);
const [returnAtCustomerExpense, setReturnAtCustomerExpense] = useState(
d.returnAtCustomerExpense,
);
const [returnAddress, setReturnAddress] = useState(d.returnAddress);
const [opTextUnfulfilled, setOpTextUnfulfilled] = useState(d.opTextUnfulfilled);
const [opTextShipped, setOpTextShipped] = useState(d.opTextShipped);
const [smtpHost, setSmtpHost] = useState(d.smtpHost);
const [smtpPort, setSmtpPort] = useState(d.smtpPort);
const [smtpUser, setSmtpUser] = useState(d.smtpUser);
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(
@@ -114,10 +241,68 @@ export default function SettingsPage() {
() => renderReceiptHtml(SAMPLE_VARS, intro, note),
[intro, note],
);
const previewOpUnf = useMemo(
() =>
renderOperationalPreview(
{
state: "unfulfilled",
textUnfulfilled: opTextUnfulfilled,
textShipped: opTextShipped,
returnAddress,
atCustomerExpense: returnAtCustomerExpense,
},
SAMPLE_VARS,
),
[opTextUnfulfilled, opTextShipped, returnAddress, returnAtCustomerExpense],
);
const previewOpShip = useMemo(
() =>
renderOperationalPreview(
{
state: "shipped",
textUnfulfilled: opTextUnfulfilled,
textShipped: opTextShipped,
returnAddress,
atCustomerExpense: returnAtCustomerExpense,
},
SAMPLE_VARS,
),
[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);
@@ -127,19 +312,54 @@ export default function SettingsPage() {
fd.set("enforceWindow", String(enforceWindow));
fd.set("windowDays", windowDays);
fd.set("enforceExclusions", String(enforceExclusions));
fd.set("stateAwareEmail", String(stateAwareEmail));
fd.set("autoCancelUnfulfilled", String(autoCancelUnfulfilled));
fd.set("returnAtCustomerExpense", String(returnAtCustomerExpense));
fd.set("returnAddress", returnAddress);
fd.set("opTextUnfulfilled", opTextUnfulfilled);
fd.set("opTextShipped", opTextShipped);
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);
fd.set("themeScheme", themeScheme);
submit(fd, { method: "post" });
};
const handleReset = () => {
const resetTheme = () => {
setThemeScheme(DEFAULT_SCHEME);
setShowSaved(false);
};
const resetEmail = () => {
setSubject(DEFAULT_SUBJECT);
setIntro(DEFAULT_INTRO);
setNote(DEFAULT_NOTE);
setShowSaved(false);
};
const saveBtn = (
<div>
<Button variant="primary" loading={saving} onClick={handleSave}>
Salva
</Button>
</div>
);
const tabs = [
{ id: "email", content: "Email" },
{ id: "notifiche", content: "Notifiche" },
{ id: "regole", content: "Regole recesso" },
{ id: "reso", content: "Reso e stato ordine" },
{ id: "smtp", content: "Email (SMTP)" },
{ id: "aspetto", content: "Aspetto" },
];
return (
<Page>
<TitleBar title="Email di ricevuta" />
<TitleBar title="Impostazioni recesso" />
<Layout>
<Layout.Section>
<BlockStack gap="400">
@@ -149,6 +369,22 @@ 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 ? (
<BlockStack gap="400">
<Card>
<BlockStack gap="400">
<BlockStack gap="100">
@@ -156,9 +392,8 @@ export default function SettingsPage() {
Testi dell'email
</Text>
<Text as="p" tone="subdued">
Personalizza oggetto e testi. Il resto (dettagli ordine,
dichiarazione, data/ora, avviso di legge, layout) è fisso e
sempre conforme. Segnaposto disponibili:
Oggetto e testi. Dettagli ordine, dichiarazione, data/ora,
avviso di legge e layout sono fissi. Segnaposto:
</Text>
<InlineStack gap="200" wrap>
{TEXT_PLACEHOLDERS.map((p) => (
@@ -166,7 +401,6 @@ export default function SettingsPage() {
))}
</InlineStack>
</BlockStack>
<TextField
label="Oggetto"
value={subject}
@@ -179,7 +413,6 @@ export default function SettingsPage() {
onChange={setIntro}
autoComplete="off"
multiline={4}
helpText="Saluto e frase iniziale della email."
/>
<TextField
label="Nota aggiuntiva (opzionale)"
@@ -187,94 +420,20 @@ export default function SettingsPage() {
onChange={setNote}
autoComplete="off"
multiline={3}
placeholder="Es. Per il reso, spedisci il pacco a..."
helpText="Riquadro in fondo alla email. Lascia vuoto per non mostrarlo."
helpText="Riquadro in fondo alla email. Vuoto = nascosto."
/>
<ButtonGroup>
<Button variant="primary" loading={saving} onClick={handleSave}>
<Button
variant="primary"
loading={saving}
onClick={handleSave}
>
Salva
</Button>
<Button onClick={handleReset}>Ripristina default</Button>
<Button onClick={resetEmail}>Ripristina default</Button>
</ButtonGroup>
</BlockStack>
</Card>
<Card>
<BlockStack gap="400">
<Text as="h2" variant="headingMd">
Notifiche al merchant
</Text>
<Checkbox
label="Invia email di notifica a ogni recesso"
checked={notifyEnabled}
onChange={setNotifyEnabled}
/>
<TextField
label="Email notifiche"
type="email"
value={notifyEmail}
onChange={setNotifyEmail}
autoComplete="off"
disabled={!notifyEnabled}
helpText="Dove ricevere le notifiche. Senza indirizzo l'email non parte."
placeholder="ordini@tuonegozio.it"
/>
<Checkbox
label="Aggiungi il tag 'Recesso' all'ordine"
checked={tagEnabled}
onChange={setTagEnabled}
helpText="Rende l'ordine filtrabile nella lista ordini."
/>
<div>
<Button variant="primary" loading={saving} onClick={handleSave}>
Salva
</Button>
</div>
</BlockStack>
</Card>
<Card>
<BlockStack gap="400">
<BlockStack gap="100">
<Text as="h2" variant="headingMd">
Regole di recesso
</Text>
<Text as="p" tone="subdued">
Attiva questi controlli solo dopo aver verificato i dati. Da
spenti, il recesso è sempre accettato.
</Text>
</BlockStack>
<Checkbox
label="Blocca i recessi oltre il termine"
checked={enforceWindow}
onChange={setEnforceWindow}
helpText="Calcolato dalla data di evasione (o d'ordine) + i giorni sotto."
/>
<TextField
label="Giorni di recesso"
type="number"
value={windowDays}
onChange={setWindowDays}
autoComplete="off"
min={1}
max={365}
disabled={!enforceWindow}
/>
<Checkbox
label="Blocca i prodotti esclusi (Art. 59)"
checked={enforceExclusions}
onChange={setEnforceExclusions}
helpText="Usa le regole nella pagina Esclusioni. Blocca se l'intero ordine è escluso."
/>
<div>
<Button variant="primary" loading={saving} onClick={handleSave}>
Salva
</Button>
</div>
</BlockStack>
</Card>
<Card>
<BlockStack gap="200">
<Text as="h2" variant="headingMd">
@@ -297,6 +456,381 @@ export default function SettingsPage() {
</BlockStack>
</Card>
</BlockStack>
) : null}
{tab === 1 ? (
<Card>
<BlockStack gap="400">
<Text as="h2" variant="headingMd">
Notifiche al merchant
</Text>
<Checkbox
label="Invia email di notifica a ogni recesso"
checked={notifyEnabled}
onChange={setNotifyEnabled}
/>
<TextField
label="Email notifiche"
type="email"
value={notifyEmail}
onChange={setNotifyEmail}
autoComplete="off"
disabled={!notifyEnabled}
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}
onChange={setTagEnabled}
helpText="Rende l'ordine filtrabile nella lista ordini."
/>
{saveBtn}
</BlockStack>
</Card>
) : null}
{tab === 2 ? (
<Card>
<BlockStack gap="400">
<BlockStack gap="100">
<Text as="h2" variant="headingMd">
Regole di recesso
</Text>
<Text as="p" tone="subdued">
Attiva questi controlli solo dopo aver verificato i dati. Da
spenti, il recesso è sempre accettato. Le regole di esclusione
si gestiscono nella pagina Esclusioni.
</Text>
</BlockStack>
<Checkbox
label="Blocca i recessi oltre il termine"
checked={enforceWindow}
onChange={setEnforceWindow}
helpText="Calcolato dalla data di consegna + i giorni sotto."
/>
<TextField
label="Giorni di recesso"
type="number"
value={windowDays}
onChange={setWindowDays}
autoComplete="off"
min={1}
max={365}
disabled={!enforceWindow}
/>
<Checkbox
label="Blocca i prodotti esclusi (Art. 59)"
checked={enforceExclusions}
onChange={setEnforceExclusions}
helpText="Blocca se l'intero ordine è escluso. Regole nella pagina Esclusioni."
/>
{saveBtn}
</BlockStack>
</Card>
) : null}
{tab === 3 ? (
<BlockStack gap="400">
<Card>
<BlockStack gap="400">
<BlockStack gap="100">
<Text as="h2" variant="headingMd">
Reso e stato ordine
</Text>
<Text as="p" tone="subdued">
Il riquadro nella ricevuta cambia in base allo stato.
Segnaposto nei testi:
</Text>
<InlineStack gap="200" wrap>
{OP_PLACEHOLDERS.map((p) => (
<Badge key={p}>{`{{${p}}}`}</Badge>
))}
</InlineStack>
</BlockStack>
<Checkbox
label="Adatta la ricevuta allo stato dell'ordine"
checked={stateAwareEmail}
onChange={setStateAwareEmail}
/>
<Checkbox
label="Annulla automaticamente gli ordini non ancora spediti"
checked={autoCancelUnfulfilled}
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}
onChange={setReturnAtCustomerExpense}
helpText="Determina il valore di {{returnCost}}."
/>
<TextField
label="Indirizzo per il reso"
value={returnAddress}
onChange={setReturnAddress}
autoComplete="off"
multiline={2}
placeholder="Via ..., CAP Città (PR)"
helpText="Valore di {{returnAddress}}."
/>
<TextField
label="Testo - ordine non evaso"
value={opTextUnfulfilled}
onChange={setOpTextUnfulfilled}
autoComplete="off"
multiline={3}
/>
<TextField
label="Testo - ordine spedito/consegnato"
value={opTextShipped}
onChange={setOpTextShipped}
autoComplete="off"
multiline={4}
/>
{saveBtn}
</BlockStack>
</Card>
<Card>
<BlockStack gap="300">
<Text as="h2" variant="headingMd">
Anteprima riquadro
</Text>
<Text as="p" tone="subdued">
Ordine non evaso
</Text>
{opFrame(previewOpUnf)}
<Text as="p" tone="subdued">
Ordine spedito/consegnato
</Text>
{opFrame(previewOpShip)}
</BlockStack>
</Card>
</BlockStack>
) : null}
{tab === 4 ? (
<Card>
<BlockStack gap="400">
<BlockStack gap="100">
<Text as="h2" variant="headingMd">
Email (SMTP)
</Text>
<Text as="p" tone="subdued">
{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}
onChange={setSmtpHost}
autoComplete="off"
placeholder="smtp-relay.brevo.com"
/>
<TextField
label="Porta"
type="number"
value={smtpPort}
onChange={setSmtpPort}
autoComplete="off"
placeholder="587"
/>
<TextField
label="Utente"
value={smtpUser}
onChange={setSmtpUser}
autoComplete="off"
/>
<TextField
label="Password"
type="password"
value={smtpPass}
onChange={setSmtpPass}
autoComplete="off"
helpText={
d.smtpPassSet
? "Impostata. Lascia vuoto per non cambiarla."
: "Non impostata."
}
/>
<Checkbox
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)"
value={smtpFrom}
onChange={setSmtpFrom}
autoComplete="off"
placeholder="Il tuo negozio <no-reply@tuodominio.it>"
/>
{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>
</Page>

View File

@@ -7,6 +7,7 @@ import {
DataTable,
Text,
Badge,
Banner,
BlockStack,
} from "@shopify/polaris";
import { TitleBar } from "@shopify/app-bridge-react";
@@ -15,6 +16,18 @@ import { authenticate } from "../shopify.server";
import db from "../db.server";
import { formatTransmittedAt } from "../lib/recesso.server";
// Scadenza rimborso (Art. 56): trasmissione + 14 giorni, data Europe/Rome.
function refundBy(transmittedAt: Date): string {
const d = new Date(transmittedAt);
d.setDate(d.getDate() + 14);
return new Intl.DateTimeFormat("it-IT", {
timeZone: "Europe/Rome",
day: "2-digit",
month: "2-digit",
year: "numeric",
}).format(d);
}
export const loader = async ({ request }: LoaderFunctionArgs) => {
const { session } = await authenticate.admin(request);
const items = await db.withdrawalRequest.findMany({
@@ -29,6 +42,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
customerName: w.customerName,
email: w.email,
transmittedAt: formatTransmittedAt(w.transmittedAt),
refundBy: refundBy(w.transmittedAt),
receiptSent: !!w.receiptSentAt,
hasReturn: !!w.shopifyReturnId,
})),
@@ -43,6 +57,7 @@ export default function WithdrawalsPage() {
r.customerName,
r.email,
r.transmittedAt,
r.refundBy,
r.receiptSent ? "Inviata" : "-",
r.hasReturn ? "Sì" : "-",
]);
@@ -68,6 +83,11 @@ export default function WithdrawalsPage() {
<Text as="h2" variant="headingMd">
Registro recessi ({rows.length})
</Text>
<Banner tone="warning">
Ricorda: disponi il rimborso entro 14 giorni dalla richiesta
(art. 56). Puoi trattenere fino alla riconsegna della merce o
alla prova di spedizione.
</Banner>
<DataTable
columnContentTypes={[
"text",
@@ -76,12 +96,14 @@ export default function WithdrawalsPage() {
"text",
"text",
"text",
"text",
]}
headings={[
"Ordine",
"Cliente",
"Email",
"Trasmesso",
"Rimborsa entro",
"Ricevuta",
"Reso",
]}

View File

@@ -20,10 +20,15 @@ import db from "../db.server";
import {
sendMerchantNotification,
sendWithdrawalReceipt,
type ReceiptResult,
type SmtpConfig,
} from "../lib/mailer.server";
import { decryptSecret } from "../lib/crypto.server";
import {
ERROR,
EXCLUSION_REASON,
NOTICE,
duplicateMessage,
exclusionMessage,
statementTemplate,
successMessage,
@@ -32,6 +37,7 @@ import {
MVP_LOCALE,
checkExclusions,
checkRateLimit,
cancelOrder,
clientIp,
createShopifyReturn,
formatTransmittedAt,
@@ -41,6 +47,7 @@ import {
isValidEmail,
isWindowExpired,
lookupOrder,
orderState,
renderStep1,
renderStep2,
renderStep3,
@@ -48,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.
@@ -97,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) => {
@@ -107,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") ?? "");
@@ -129,7 +157,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
error: ERROR.missingField,
orderName: orderNameInput,
email: emailInput,
}),
}, theme),
);
}
if (!isValidEmail(emailInput)) {
@@ -138,7 +166,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
error: ERROR.invalidEmail,
orderName: orderNameInput,
email: emailInput,
}),
}, theme),
);
}
@@ -157,7 +185,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
error: ERROR.generic,
orderName: orderNameInput,
email: emailInput,
}),
}, theme),
);
}
@@ -179,7 +207,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
error: ERROR.lookupNoMatch,
orderName: orderNameInput,
email: emailInput,
}),
}, theme),
);
}
@@ -191,17 +219,22 @@ export const action = async ({ request }: ActionFunctionArgs) => {
error: block,
orderName: orderNameInput,
email: emailInput,
}),
}, theme),
);
}
const orderClosed =
!!match.cancelledAt ||
match.financialStatus === "REFUNDED" ||
match.financialStatus === "VOIDED";
return htmlResponse(
renderStep2({
orderId: match.orderId,
orderName: match.orderName,
email: match.email,
statementText: statementTemplate(match.orderName),
}),
notice: orderClosed ? NOTICE.orderClosed : undefined,
}, theme),
);
}
@@ -218,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) {
@@ -230,7 +263,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
customerName,
statementText: statementText || statementTemplate(orderName),
error: ERROR.missingField,
}),
}, theme),
);
}
if (!isValidEmail(email)) {
@@ -242,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),
);
}
@@ -261,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({
@@ -270,7 +303,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
email,
customerName,
statementText: statementText || statementTemplate(orderName),
}),
}, theme),
);
}
@@ -293,29 +326,55 @@ 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));
}
// 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 record = existing;
if (!record) {
// transmittedAt = ISTANTE DI TRASMISSIONE (click "Conferma recesso"),
// NON di ricezione. Salvato in UTC (Prisma DateTime).
const transmittedAt = new Date();
let created;
try {
created = await db.withdrawalRequest.create({
record = await db.withdrawalRequest.create({
data: {
shop, // sempre da session.shop
orderId: match.orderId, // GID risolto dal lookup
@@ -348,10 +407,20 @@ export const action = async ({ request }: ActionFunctionArgs) => {
},
});
} catch {
return htmlResponse(renderStep1({ error: ERROR.generic }));
return htmlResponse(renderStep1({ error: ERROR.generic }, theme));
}
} else {
await db.auditLog.create({
data: {
shop,
event: "withdrawal_duplicate",
detail: match.orderName,
},
});
}
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.
@@ -360,7 +429,57 @@ export const action = async ({ request }: ActionFunctionArgs) => {
getShopInfo(admin),
]);
const shopName = shopInfo.name || shop.replace(/\.myshopify\.com$/, "");
const receipt = await sendWithdrawalReceipt({
// A6-bis: blocco operativo per stato ordine (se stateAwareEmail attivo).
const state = orderState(match);
const operational =
settings?.stateAwareEmail !== false &&
(state === "unfulfilled" || state === "shipped" || state === "delivered")
? {
state,
textUnfulfilled: settings?.opTextUnfulfilled,
textShipped: settings?.opTextShipped,
returnAddress: settings?.returnAddress,
atCustomerExpense: settings?.returnAtCustomerExpense ?? true,
}
: null;
// SMTP per-shop (se configurato): password decifrata; altrimenti default app.
let smtp: SmtpConfig | null = null;
if (settings?.smtpHost) {
try {
smtp = {
host: settings.smtpHost,
port: settings.smtpPort,
user: settings.smtpUser,
pass: settings.smtpPass ? decryptSecret(settings.smtpPass) : null,
secure: settings.smtpSecure,
from: settings.smtpFrom,
};
} catch {
console.error("[recesso] SMTP shop non decifrabile: uso default app");
}
}
// 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,
@@ -374,42 +493,80 @@ export const action = async ({ request }: ActionFunctionArgs) => {
subject: settings?.emailSubject,
intro: settings?.emailIntro,
note: settings?.emailNote,
operational,
smtp,
});
}
let resent = false;
try {
if (receipt.ok) {
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: created.id },
where: { id: record.id },
data: { receiptSentAt: new Date() },
});
await db.auditLog.create({
data: { shop, event: "receipt_sent", detail: match.orderName },
});
} else {
}
} else if (receipt) {
console.error("[recesso] invio ricevuta fallito:", receipt.error);
await db.auditLog.create({
data: {
shop,
event: "receipt_failed",
detail: receipt.error.slice(0, 200),
// errore SMTP con email mascherate (diagnosticabile, senza PII).
detail: redactErr(receipt.error),
},
});
// TODO(A9): coda/retry per la ricevuta fallita ("senza ritardo") +
// messaggio di successo che rifletta l'esito reale dell'invio.
}
} 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). Non evaso -> il
// (best-effort; il recesso legale e' gia' registrato). Ordini annullati/
// rimborsati -> skip (G5: evita doppio reso/rimborso). Non evaso -> il
// merchant gestisce annullo/rimborso.
let returnStatus: "created" | "no_returnable" | "error" = "error";
let returnStatus: "created" | "no_returnable" | "exists" | "error" =
"error";
const orderClosed =
!!match.cancelledAt ||
match.financialStatus === "REFUNDED" ||
match.financialStatus === "VOIDED";
if (orderClosed) {
returnStatus = "no_returnable";
await db.auditLog.create({
data: {
shop,
event: "shopify_return_skipped",
detail: "ordine annullato o rimborsato",
},
});
} else {
try {
const ret = await createShopifyReturn(admin, match.orderId);
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({
@@ -427,6 +584,14 @@ export const action = async ({ request }: ActionFunctionArgs) => {
detail: "ordine non evaso o nulla da rendere",
},
});
} else if (ret.status === "exists") {
await db.auditLog.create({
data: {
shop,
event: "shopify_return_exists",
detail: match.orderName,
},
});
} else {
console.error("[recesso] returnCreate:", ret.error);
await db.auditLog.create({
@@ -440,6 +605,23 @@ export const action = async ({ request }: ActionFunctionArgs) => {
} catch (e) {
console.error("[recesso] integrazione reso fallita:", e);
}
}
// A6-bis: auto-annullo ordini non evasi (se abilitato). refund + restock.
if (settings?.autoCancelUnfulfilled && state === "unfulfilled") {
try {
const c = await cancelOrder(admin, match.orderId);
await db.auditLog.create({
data: {
shop,
event: c.ok ? "order_auto_cancelled" : "order_auto_cancel_failed",
detail: c.ok ? match.orderName : (c.error ?? "").slice(0, 200),
},
});
} catch (e) {
console.error("[recesso] auto-annullo fallito:", e);
}
}
// Tag "Recesso" sull'ordine (se abilitato nei Settings). Richiede write_orders.
if (settings?.tagEnabled) {
@@ -461,20 +643,33 @@ 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,
});
if (!notif.ok) {
console.error("[recesso] notifica merchant fallita:", notif.error);
}
await db.auditLog.create({
data: {
shop,
event: notif.ok ? "merchant_notified" : "merchant_notify_failed",
detail: notif.ok ? match.orderName : notif.error.slice(0, 200),
// errore SMTP con email mascherate (diagnosticabile, senza PII).
detail: notif.ok ? match.orderName : redactErr(notif.error),
},
});
} catch (e) {
@@ -482,12 +677,17 @@ export const action = async ({ request }: ActionFunctionArgs) => {
}
}
const msg = successMessage(match.orderName, transmittedLabel, email);
return htmlResponse(renderStep4(msg));
const msg = successMessage(
match.orderName,
transmittedLabel,
email,
!!receipt?.ok,
);
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

@@ -17,23 +17,39 @@ export const action = async ({ request }: ActionFunctionArgs) => {
.update(JSON.stringify(payload ?? {}))
.digest("hex");
// Idempotency: record the webhook once (webhookId is unique when present).
// Idempotency: se gia' lavorato, esci.
const dedupeKey = webhookId ?? `${topic}:${payloadHash}`;
const existing = await db.webhookEvent.findUnique({
where: { webhookId: dedupeKey },
});
if (existing?.processed) return new Response();
await db.webhookEvent.upsert({
where: { webhookId: dedupeKey },
create: { shop, topic, webhookId: dedupeKey, processed: false },
update: {},
});
// I dati del cliente (richieste di recesso) sono consultabili dal merchant
// (titolare) nella dashboard Recessi, che li relaziona al data subject.
// Registriamo la richiesta e quanti record esistono.
const email = ((payload as { customer?: { email?: unknown } } | null)?.customer
?.email ?? null) as string | null;
const count =
typeof email === "string" && email
? await db.withdrawalRequest.count({ where: { shop, email } })
: 0;
await db.auditLog.create({
data: { shop, event: `gdpr.${topic}`, payloadHash, detail: "customers/data_request received" },
data: {
shop,
event: `gdpr.${topic}`,
payloadHash,
detail: `customers/data_request: ${count} record disponibili nella dashboard Recessi`,
},
});
await db.webhookEvent.update({
where: { webhookId: dedupeKey },
data: { processed: true },
});
// TODO(A8): gather every stored personal data point for this customer
// (WithdrawalRequest rows matched by email / customer id: name, email,
// statement text, transmission timestamp, order refs) and hand it to the
// merchant (data controller), who relays it to the data subject. Then mark
// the WebhookEvent processed = true.
return new Response();
};

View File

@@ -18,22 +18,47 @@ export const action = async ({ request }: ActionFunctionArgs) => {
.update(JSON.stringify(payload ?? {}))
.digest("hex");
// Idempotency: record the webhook once (webhookId is unique when present).
// Idempotency: se gia' lavorato, esci.
const dedupeKey = webhookId ?? `${topic}:${payloadHash}`;
const existing = await db.webhookEvent.findUnique({
where: { webhookId: dedupeKey },
});
if (existing?.processed) return new Response();
await db.webhookEvent.upsert({
where: { webhookId: dedupeKey },
create: { shop, topic, webhookId: dedupeKey, processed: false },
update: {},
});
await db.auditLog.create({
data: { shop, event: `gdpr.${topic}`, payloadHash, detail: "customers/redact received" },
// Pseudonimizza la PII del cliente nelle richieste di recesso, mantenendo il
// record legale (ordine, timestamp) come prova ex art. 54-bis. Base di
// conservazione: obbligo legale / difesa in giudizio (art. 17(3) GDPR).
const email = ((payload as { customer?: { email?: unknown } } | null)?.customer
?.email ?? null) as string | null;
let redacted = 0;
if (typeof email === "string" && email) {
const res = await db.withdrawalRequest.updateMany({
where: { shop, email },
data: {
customerName: "[redatto]",
email: "[redatto]",
statementText: "[redatto]",
},
});
redacted = res.count;
}
// TODO(A8): redact/anonymize this customer's PII in WithdrawalRequest
// (customerName, email, statementText) for the given shop + customer/orders,
// WITHOUT destroying the legal audit trail (keep AuditLog + hashed refs).
// Then mark the WebhookEvent processed = true.
await db.auditLog.create({
data: {
shop,
event: `gdpr.${topic}`,
payloadHash,
detail: `customers/redact: ${redacted} record pseudonimizzati`,
},
});
await db.webhookEvent.update({
where: { webhookId: dedupeKey },
data: { processed: true },
});
return new Response();
};

View File

@@ -19,22 +19,33 @@ export const action = async ({ request }: ActionFunctionArgs) => {
.update(JSON.stringify(payload ?? {}))
.digest("hex");
// Idempotency: record the webhook once (webhookId is unique when present).
// Idempotency: se gia' lavorato, esci.
const dedupeKey = webhookId ?? `${topic}:${payloadHash}`;
const existing = await db.webhookEvent.findUnique({
where: { webhookId: dedupeKey },
});
if (existing?.processed) return new Response();
await db.webhookEvent.upsert({
where: { webhookId: dedupeKey },
create: { shop, topic, webhookId: dedupeKey, processed: false },
update: {},
});
await db.auditLog.create({
data: { shop, event: `gdpr.${topic}`, payloadHash, detail: "shop/redact received" },
// Purge completa dei dati dello shop (app disinstallata + ~48h). Il merchant,
// come titolare, deve aver esportato prima cio' che gli serve. Cancelliamo
// anche l'AuditLog: cessata la relazione, non c'e' piu' base per conservarlo.
await db.withdrawalRequest.deleteMany({ where: { shop } });
await db.exclusionRule.deleteMany({ where: { shop } });
await db.settings.deleteMany({ where: { shop } });
await db.session.deleteMany({ where: { shop } });
await db.auditLog.deleteMany({ where: { shop } });
await db.webhookEvent.deleteMany({
where: { shop, webhookId: { not: dedupeKey } },
});
// Manteniamo SOLO la WebhookEvent corrente (marcata processata) per idempotenza.
await db.webhookEvent.update({
where: { webhookId: dedupeKey },
data: { processed: true },
});
// TODO(A8): delete all data for this shop (Settings, ExclusionRule,
// WithdrawalRequest, Session, and stale WebhookEvent rows). Decide the legal
// retention policy for AuditLog before wiring the real deletion. Then mark
// the WebhookEvent processed = true.
return new Response();
};

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

@@ -0,0 +1 @@
{}

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,5 @@
-- AlterTable
ALTER TABLE "Settings" ADD COLUMN "autoCancelUnfulfilled" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "returnAtCustomerExpense" BOOLEAN NOT NULL DEFAULT true,
ADD COLUMN "returnInstructions" TEXT,
ADD COLUMN "stateAwareEmail" BOOLEAN NOT NULL DEFAULT true;

View File

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "Settings" ADD COLUMN "opTextShipped" TEXT,
ADD COLUMN "opTextUnfulfilled" TEXT;

View File

@@ -0,0 +1,7 @@
-- AlterTable
ALTER TABLE "Settings" ADD COLUMN "smtpFrom" TEXT,
ADD COLUMN "smtpHost" TEXT,
ADD COLUMN "smtpPass" TEXT,
ADD COLUMN "smtpPort" INTEGER,
ADD COLUMN "smtpSecure" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "smtpUser" TEXT;

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

@@ -57,6 +57,28 @@ model Settings {
tagEnabled Boolean @default(true)
enforceWindow Boolean @default(false)
enforceExclusions Boolean @default(false)
stateAwareEmail Boolean @default(true)
autoCancelUnfulfilled Boolean @default(false)
returnAtCustomerExpense Boolean @default(true)
returnInstructions String? // deprecato: sostituito da opTextShipped
opTextUnfulfilled String?
opTextShipped String?
smtpHost String?
smtpPort Int?
smtpUser String?
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

View File

@@ -3,14 +3,14 @@
client_id = "8d67c00a078a615037497ad056f7f99d"
name = "Legal Return PCRT "
# DEV: quick tunnel Cloudflare (no interstitial). Auto-update Dev Dashboard rotto → settato via deploy. Cambia se il tunnel riparte.
application_url = "https://miscellaneous-connections-harvest-chronicle.trycloudflare.com"
application_url = "https://recesso-custom.fly.dev"
embedded = true
# App Proxy: lo storefront /apps/recesso viene proxato a <application_url>/proxy.
# NB: se il tunnel Cloudflare (application_url) cambia, aggiornare anche `url` qui.
# Richiede `shopify app deploy` perché la configurazione abbia effetto.
[app_proxy]
url = "https://miscellaneous-connections-harvest-chronicle.trycloudflare.com/proxy"
url = "https://recesso-custom.fly.dev/proxy"
subpath = "recesso"
prefix = "apps"
@@ -49,7 +49,7 @@ use_legacy_install_flow = false
[auth]
redirect_urls = [
"https://miscellaneous-connections-harvest-chronicle.trycloudflare.com/auth/callback",
"https://miscellaneous-connections-harvest-chronicle.trycloudflare.com/auth/shopify/callback",
"https://miscellaneous-connections-harvest-chronicle.trycloudflare.com/api/auth/callback"
"https://recesso-custom.fly.dev/auth/callback",
"https://recesso-custom.fly.dev/auth/shopify/callback",
"https://recesso-custom.fly.dev/api/auth/callback"
]