MVP core recesso: App Proxy flow + ricevuta durevole + design storefront
- A1: SPEC-MVP-RECESSO.md (criteri + copy IT) - A3: App Proxy /apps/recesso — lookup guest ordine+email (anti-leak), form 2-step, funzione dedicata Conferma recesso, persist WithdrawalRequest + transmittedAt + AuditLog - A4: ricevuta durevole via nodemailer (mailer.server.ts) + receiptSentAt + AuditLog - Design: recesso.server.ts restyle token CSS light/dark, coerente Shopify - Fix: @shopify/shopify-api pinnato 13.1.0 (dedupe) -> tsc clean - PLAN: A5 esteso a motore di stile a 3 livelli Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Mv83a29B4eFv5ixoj6PoE1
This commit is contained in:
6
PLAN.md
6
PLAN.md
@@ -167,7 +167,11 @@ input / deliverable / criteri di uscita espliciti. **Nessun avvio automatico**
|
|||||||
- **Uscita:** guest su dev store immacolato completa il recesso → record + ricevuta. **Questo è già conforme.**
|
- **Uscita:** guest su dev store immacolato completa il recesso → record + ricevuta. **Questo è già conforme.**
|
||||||
|
|
||||||
### Fase 3 — Admin merchant *(sequenziale; A5→A6)*
|
### Fase 3 — Admin merchant *(sequenziale; A5→A6)*
|
||||||
- **A5 admin-UI** — Polaris: dashboard richieste, impostazioni, branding, testo info (R12).
|
- **A5 admin-UI + motore di stile estensibile** — Polaris: dashboard richieste, impostazioni, testo info (R12). **Theming a 3 livelli** (requisito utente: "super estendibile, personalizzabile da template"):
|
||||||
|
1. **Token da Settings** (no-code) — colore brand, font, raggio, logo, larghezza → override delle CSS variables del form (base già pronta: il form del design è tutto a token `:root`).
|
||||||
|
2. **Eredita tema** — opzione App Proxy in **Liquid** → il form è reso dentro il tema del negozio (look nativo, "template del merchant").
|
||||||
|
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 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.
|
||||||
|
|
||||||
### Fase 4 — Robustezza *(A7→A8 sequenziali, poi A9 audit)*
|
### Fase 4 — Robustezza *(A7→A8 sequenziali, poi A9 audit)*
|
||||||
|
|||||||
326
SPEC-MVP-RECESSO.md
Normal file
326
SPEC-MVP-RECESSO.md
Normal file
@@ -0,0 +1,326 @@
|
|||||||
|
# SPEC-MVP-RECESSO — Contratto operativo di build
|
||||||
|
|
||||||
|
> **Ruolo:** riferimento vincolante per gli agenti di build (A2/A3/A4) e di test (A9).
|
||||||
|
> Trasforma `ANALISI-REQUISITI-LEGALI.md` + `PLAN.md §2` in criteri testabili + copy IT esatto.
|
||||||
|
> **Base legale:** Art. 54-bis Cod. Consumo (D.Lgs 209/2025, recepimento Dir. UE 2023/2673), in
|
||||||
|
> vigore per contratti conclusi dal 19/6/2026.
|
||||||
|
>
|
||||||
|
> **Principi non negoziabili (da ANALISI):**
|
||||||
|
> 1. Timestamp = **TRASMISSIONE** (momento in cui il consumatore esercita), **non** ricezione.
|
||||||
|
> 2. Il pulsante è **AGGIUNTIVO**, non sostitutivo (restano modulo Allegato I-B + email/PEC).
|
||||||
|
> 3. La conferma è una **funzione dedicata a 2 step** ("conferma recesso"), **non** una checkbox.
|
||||||
|
> 4. **Vietati dark pattern**: recesso non più oneroso della conclusione del contratto.
|
||||||
|
>
|
||||||
|
> **Legenda scope:** 🟢 MVP (fasi 2) · 🟡 Fase successiva (A6/A7/A8) · ⚠ da confermare.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Criteri di accettazione testabili (Given/When/Then)
|
||||||
|
|
||||||
|
Verificabili da QA (A9). Focus MVP: R1–R7, R11, R12.
|
||||||
|
|
||||||
|
### R1 — 🟢 Funzione visibile, accessibile, continuativamente disponibile
|
||||||
|
- **Dato** un ordine B2C con finestra di recesso ancora aperta,
|
||||||
|
**Quando** l'acquirente visita una qualsiasi pagina dove è attivo l'app embed,
|
||||||
|
**Allora** vede il pulsante con etichetta esatta **"Recedere dal contratto qui"**.
|
||||||
|
- **Dato** un ordine consegnato oggi (giorno 0),
|
||||||
|
**Quando** l'acquirente apre la funzione in qualunque momento entro la finestra,
|
||||||
|
**Allora** la funzione è raggiungibile e operativa (nessuno stato "disabilitato").
|
||||||
|
- **Dato** un ordine con finestra **scaduta**,
|
||||||
|
**Quando** l'acquirente tenta il recesso,
|
||||||
|
**Allora** il sistema mostra un messaggio di finestra chiusa e NON consente l'invio.
|
||||||
|
- **Negativo:** l'etichetta non deve mai essere ambigua ("Gestisci ordine", "Resi") → fail.
|
||||||
|
|
||||||
|
### R2 — 🟢 Accesso guest (buona prassi, non login forzato)
|
||||||
|
- **Dato** un utente NON autenticato,
|
||||||
|
**Quando** inserisce numero ordine + email corretti,
|
||||||
|
**Allora** completa l'intero flusso senza creare account né effettuare login.
|
||||||
|
- **Negativo:** se in qualunque step compare un obbligo di login/registrazione → fail (viola "facilmente accessibile").
|
||||||
|
- **Dato** un utente autenticato, **Quando** apre la funzione, **Allora** i campi noti (nome, email, ordine) sono precompilati ma modificabili.
|
||||||
|
|
||||||
|
### R3 — 🟢 Raccolta dei 4 dati di legge
|
||||||
|
- **Dato** il form di recesso,
|
||||||
|
**Quando** l'acquirente lo compila,
|
||||||
|
**Allora** sono raccolti e persistiti: (a) nome e cognome, (b) identificativo ordine/contratto,
|
||||||
|
(c) email (mezzo elettronico per la conferma), (d) testo della dichiarazione di recesso.
|
||||||
|
- **Negativo:** invio con uno dei 4 campi obbligatori vuoto → bloccato con messaggio di campo mancante.
|
||||||
|
- **Dato** l'invio riuscito, **Quando** si ispeziona il DB, **Allora** esiste un record `WithdrawalRequest` con tutti e 4 i valori.
|
||||||
|
|
||||||
|
### R4 — 🟢 Conferma a 2 step, funzione dedicata, anti-dark-pattern
|
||||||
|
- **Dato** il form compilato,
|
||||||
|
**Quando** l'acquirente conferma la compilazione,
|
||||||
|
**Allora** viene mostrata una **schermata di riepilogo** con una funzione dedicata etichettata **"Conferma recesso"**.
|
||||||
|
- **Dato** la schermata di riepilogo,
|
||||||
|
**Quando** l'acquirente NON attiva "Conferma recesso",
|
||||||
|
**Allora** nessuna richiesta viene registrata (l'invio è impossibile senza la 2ª azione esplicita).
|
||||||
|
- **Negativo (dark pattern):** la conferma NON deve essere una checkbox pre-flaggata, un timer, un
|
||||||
|
pop-up dissuasivo o un retention nudge → qualsiasi di questi = fail.
|
||||||
|
- **Dato** i due step, **Quando** si contano le azioni, **Allora** sono esattamente 2 (compila → conferma), senza attriti aggiuntivi.
|
||||||
|
|
||||||
|
### R5 — 🟢 Ricevuta su supporto durevole con timestamp di TRASMISSIONE
|
||||||
|
- **Dato** una conferma andata a buon fine (attivazione di "Conferma recesso"),
|
||||||
|
**Quando** il backend registra la richiesta,
|
||||||
|
**Allora** `transmittedAt` = istante dell'attivazione della conferma (NON istante di ricezione/lettura email).
|
||||||
|
- **Dato** la richiesta registrata,
|
||||||
|
**Quando** trascorre meno di 1 minuto,
|
||||||
|
**Allora** viene inviata al consumatore una ricevuta (email, in MVP via provider stub/log) che contiene:
|
||||||
|
**(1)** testo integrale della dichiarazione, **(2)** identificativo ordine, **(3)** data e ora di **trasmissione**, **(4)** nome del consumatore.
|
||||||
|
- **Negativo:** ricevuta priva anche di uno solo di questi 4 elementi → fail.
|
||||||
|
- **Dato** MVP con provider stub, **Quando** l'email non parte davvero, **Allora** il contenuto della ricevuta è comunque generato e loggato in modo riproducibile (valore probatorio).
|
||||||
|
|
||||||
|
### R6 — 🟢 Onere probatorio / audit log immutabile
|
||||||
|
- **Dato** ogni evento (creazione richiesta, invio ricevuta),
|
||||||
|
**Quando** l'evento accade,
|
||||||
|
**Allora** è scritto un record `AuditLog` **append-only** con timestamp e hash del payload.
|
||||||
|
- **Negativo:** possibilità di UPDATE/DELETE su `AuditLog` dall'applicazione → fail.
|
||||||
|
- **Dato** una richiesta, **Quando** la si audita mesi dopo, **Allora** si può dimostrare data/ora e contenuto della dichiarazione dal server (non solo dall'email).
|
||||||
|
|
||||||
|
### R7 — 🟢 Non più onerosa della conclusione del contratto
|
||||||
|
- **Dato** il flusso di recesso completo,
|
||||||
|
**Quando** si contano i passaggi utente,
|
||||||
|
**Allora** sono **≤** ai passaggi del checkout dello stesso store.
|
||||||
|
- **Negativo:** presenza di pop-up di dissuasione, offerte di trattenimento, campi non necessari,
|
||||||
|
step aggiuntivi rispetto al checkout → fail.
|
||||||
|
|
||||||
|
### R11 — 🟢 Pulsante aggiuntivo, non sostitutivo
|
||||||
|
- **Dato** l'app installata,
|
||||||
|
**Quando** si ispezionano i canali di recesso esistenti (modulo tipo Allegato I-B, email, PEC),
|
||||||
|
**Allora** restano tutti attivi e raggiungibili; l'app non li disabilita né li nasconde.
|
||||||
|
- **Dato** il flusso app, **Quando** l'acquirente lo usa, **Allora** vede la **nota di coesistenza** (§2) che ricorda gli altri mezzi.
|
||||||
|
|
||||||
|
### R12 — 🟢 Obblighi informativi Art. 49 (ubicazione della funzione)
|
||||||
|
- **Dato** il flusso di recesso,
|
||||||
|
**Quando** l'acquirente lo apre,
|
||||||
|
**Allora** vede il **testo informativo sul diritto di recesso** (§2) PRIMA dell'invio.
|
||||||
|
- **Dato** le informazioni precontrattuali,
|
||||||
|
**Quando** il merchant configura l'app,
|
||||||
|
**Allora** è disponibile un testo che indica **dove si trova** la funzione nell'interfaccia (per pagine legali/checkout).
|
||||||
|
- **Nota:** l'omissione di questa informazione attiva la proroga Art. 53 (12 mesi + 14 gg) → vedi §5.
|
||||||
|
|
||||||
|
### R9 — 🟡 Item escluso Art. 59 (criterio anticipato, motore in A6)
|
||||||
|
- **Dato** un item marcato come escluso (su misura / deperibile / sigillato igiene),
|
||||||
|
**Quando** l'acquirente apre il recesso per quell'item,
|
||||||
|
**Allora** l'item è segnalato come non recedibile con il **motivo** (messaggio §2), senza recesso "cieco".
|
||||||
|
- **MVP:** in fase 2 non è richiesto il motore di esclusione; il copy è pronto per A6.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Copy deck IT — stringhe ESATTE (hardcodabili)
|
||||||
|
|
||||||
|
> Tutte le stringhe sono in IT (lingua del contratto per gli store target). i18n = 🟡 Fase A7.
|
||||||
|
> Placeholder con `{{doppie graffe}}`. Il testo tra virgolette è letterale.
|
||||||
|
|
||||||
|
### 2.1 Etichetta pulsante di avvio 🟢
|
||||||
|
```
|
||||||
|
Recedere dal contratto qui
|
||||||
|
```
|
||||||
|
Varianti ammesse (equivalenti statutari, se serve adattare la resa grafica — mantenere il verbo "recedere"):
|
||||||
|
- `Recedi dal contratto qui`
|
||||||
|
- `Recedere dal contratto qui` *(forma preferita, coerente col testo di legge)*
|
||||||
|
|
||||||
|
### 2.2 Etichetta funzione di conferma (step 2) 🟢
|
||||||
|
```
|
||||||
|
Conferma recesso
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 Label dei campi del form 🟢
|
||||||
|
| Campo | Label esatta | Placeholder / hint |
|
||||||
|
|---|---|---|
|
||||||
|
| Nome e cognome | `Nome e cognome` | `Mario Rossi` |
|
||||||
|
| Identificativo ordine/contratto | `Numero dell'ordine` | `es. #1234` |
|
||||||
|
| Email (mezzo elettronico per la conferma) | `Email` | `La tua email dell'ordine` |
|
||||||
|
| Testo della dichiarazione di recesso | `Dichiarazione di recesso` | vedi testo precompilato sotto |
|
||||||
|
|
||||||
|
**Testo precompilato (editabile) della dichiarazione** (buona prassi, deriva dall'Allegato I-B):
|
||||||
|
```
|
||||||
|
Con la presente comunico il recesso dal contratto di vendita relativo all'ordine {{orderName}}.
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.4 Testo informativo sul diritto di recesso (Art. 49) — mostrato prima dell'invio 🟢
|
||||||
|
```
|
||||||
|
Hai diritto di recedere da questo contratto entro 14 giorni senza dover fornire alcuna motivazione.
|
||||||
|
Il termine decorre dalla consegna del bene (o dalla conclusione del contratto per i servizi).
|
||||||
|
Compilando e confermando questo modulo eserciti il recesso: riceverai via email una ricevuta con
|
||||||
|
la data e l'ora di trasmissione della tua dichiarazione.
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.5 Template email — ricevuta su supporto durevole 🟢
|
||||||
|
**Oggetto:**
|
||||||
|
```
|
||||||
|
Ricevuta della tua richiesta di recesso — Ordine {{orderName}}
|
||||||
|
```
|
||||||
|
**Corpo:**
|
||||||
|
```
|
||||||
|
Gentile {{customerName}},
|
||||||
|
|
||||||
|
confermiamo di aver ricevuto la Sua dichiarazione di recesso relativa all'ordine {{orderName}},
|
||||||
|
trasmessa tramite la funzione di recesso presente sul nostro sito.
|
||||||
|
|
||||||
|
Dettagli della richiesta:
|
||||||
|
- Data e ora di trasmissione: {{transmittedAt}}
|
||||||
|
- Ordine: {{orderName}}
|
||||||
|
- Nome del consumatore: {{customerName}}
|
||||||
|
|
||||||
|
Testo integrale della dichiarazione di recesso trasmessa:
|
||||||
|
"{{statementText}}"
|
||||||
|
|
||||||
|
Questa comunicazione costituisce la ricevuta su supporto durevole della Sua dichiarazione di
|
||||||
|
recesso, ai sensi dell'art. 54-bis del Codice del Consumo. La data e l'ora sopra indicate
|
||||||
|
attestano il momento della trasmissione della dichiarazione.
|
||||||
|
|
||||||
|
Le invieremo separatamente le istruzioni per l'eventuale restituzione dei beni e i tempi di rimborso.
|
||||||
|
|
||||||
|
Restano comunque validi anche gli altri mezzi per esercitare il recesso (modulo tipo di cui
|
||||||
|
all'Allegato I, parte B, o qualsiasi altra dichiarazione esplicita, anche via email): questa
|
||||||
|
funzione è aggiuntiva e non sostituisce tali strumenti.
|
||||||
|
|
||||||
|
{{shopName}}
|
||||||
|
```
|
||||||
|
> **Vincolo build:** `{{transmittedAt}}` va reso in formato leggibile e conservabile con fuso
|
||||||
|
> orario esplicito, es. `06/07/2026, 14:32:07 (Europe/Rome, CEST)`. È lo stesso istante di `transmittedAt` del DB.
|
||||||
|
|
||||||
|
### 2.6 Messaggi di successo / conferma finale 🟢
|
||||||
|
**Schermata finale (dopo "Conferma recesso"):**
|
||||||
|
```
|
||||||
|
Recesso trasmesso correttamente.
|
||||||
|
Abbiamo registrato la tua dichiarazione di recesso per l'ordine {{orderName}} in data {{transmittedAt}}.
|
||||||
|
Ti abbiamo inviato una ricevuta all'indirizzo {{email}}.
|
||||||
|
```
|
||||||
|
**Sotto-testo (coesistenza):** vedi §2.9.
|
||||||
|
|
||||||
|
### 2.7 Messaggi di errore 🟢
|
||||||
|
| Caso | Messaggio (UI) |
|
||||||
|
|---|---|
|
||||||
|
| Ordine non trovato **o** email non corrispondente (stesso messaggio, no leak) | `Non abbiamo trovato un ordine con questi dati. Verifica il numero dell'ordine e l'email usata per l'acquisto.` |
|
||||||
|
| Campo obbligatorio mancante | `Compila tutti i campi obbligatori per continuare.` |
|
||||||
|
| Email in formato non valido | `Inserisci un indirizzo email valido.` |
|
||||||
|
| Finestra di recesso scaduta | `Il termine di 14 giorni per il recesso su questo ordine è terminato. Puoi comunque contattarci per altre richieste.` |
|
||||||
|
| Errore generico invio | `Si è verificato un problema. Riprova tra qualche istante; se persiste, contattaci.` |
|
||||||
|
|
||||||
|
> **Regola anti-enumeration (R2/§6):** "ordine inesistente" ed "email non combaciante" DEVONO
|
||||||
|
> mostrare lo **stesso identico messaggio**. Nessun dettaglio che riveli l'esistenza dell'ordine.
|
||||||
|
|
||||||
|
### 2.8 Messaggio prodotto escluso (Art. 59) 🟡 (copy MVP-ready, logica in A6)
|
||||||
|
```
|
||||||
|
Per questo prodotto il diritto di recesso non è previsto ({{motivoEsclusione}}, ai sensi
|
||||||
|
dell'art. 59 del Codice del Consumo). Per informazioni o altre richieste, contattaci.
|
||||||
|
```
|
||||||
|
Valori di `{{motivoEsclusione}}`:
|
||||||
|
- `prodotto realizzato su misura o personalizzato`
|
||||||
|
- `prodotto deperibile o a rapida scadenza`
|
||||||
|
- `prodotto sigillato, aperto dopo la consegna, non restituibile per motivi igienici o di salute`
|
||||||
|
|
||||||
|
### 2.9 Nota di coesistenza (pulsante aggiuntivo) 🟢
|
||||||
|
```
|
||||||
|
Questa funzione è un modo aggiuntivo per esercitare il recesso. Puoi comunque usare il modulo
|
||||||
|
tipo (Allegato I, parte B) o inviare qualsiasi dichiarazione esplicita, anche via email.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Campi raccolti (UI) → modello dati Prisma `WithdrawalRequest`
|
||||||
|
|
||||||
|
> Modello sorgente: `PLAN.md §3`. I nomi Prisma nel PLAN sono in italiano; qui affiancati agli
|
||||||
|
> alias EN citati nel brief. **⚠ Scelta da confermare:** standardizzare i nomi campo (IT vs EN).
|
||||||
|
|
||||||
|
| Campo UI | Campo Prisma (PLAN §3) | Alias EN (brief) | Obbligatorio | Note |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| Nome e cognome | `nomeCliente` | `customerName` | **Sì** | Art. 54-bis lett. (a) |
|
||||||
|
| Numero dell'ordine | `orderName` (+ `orderId` interno) | `orderName` / `orderId` | **Sì** | `orderName` = mostrato (#1234); `orderId` = GID Shopify risolto dal lookup |
|
||||||
|
| Email | `email` | `email` | **Sì** | Mezzo elettronico per la conferma, lett. (c) |
|
||||||
|
| Dichiarazione di recesso | `testoDichiarazione` | `statementText` | **Sì** | Testo integrale trasmesso, va nella ricevuta |
|
||||||
|
| — (generato al submit) | `trasmessoAt` | `transmittedAt` | **Sì (auto)** | Istante di TRASMISSIONE = click "Conferma recesso" |
|
||||||
|
| — (dal contesto) | `canale` | `channel` | **Sì** | `guest` \| `account` |
|
||||||
|
| — (dal contesto) | `locale` | `locale` | **Sì** | MVP fisso `it`; multi-lingua = 🟡 A7 |
|
||||||
|
| — (derivato) | `tipoProdotto` | — | 🟡 A6 | beni/servizi/digitale, per calcolo scadenza |
|
||||||
|
| — (sistema) | `stato` | — | Sì (default) | `trasmessa` all'invio |
|
||||||
|
| — (sistema) | `ricevutaInviataAt` | — | No | valorizzato dopo invio ricevuta |
|
||||||
|
| — (derivato) | `scadenzaCalcolata` | — | 🟡 A6 | dal motore compliance (§5) |
|
||||||
|
|
||||||
|
**Obbligatori MVP (blocco invio se mancanti):** `nomeCliente`, `orderName`, `email`, `testoDichiarazione`.
|
||||||
|
**Auto MVP:** `trasmessoAt`, `canale`, `locale`, `stato`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Flusso a 2 step (anti-dark-pattern) 🟢
|
||||||
|
|
||||||
|
```
|
||||||
|
STEP 0 — Avvio
|
||||||
|
Pulsante "Recedere dal contratto qui" (Theme App Extension) → apre /apps/recesso (App Proxy).
|
||||||
|
|
||||||
|
STEP 1 — Lookup guest
|
||||||
|
Input: Numero ordine + Email. Nessun login richiesto.
|
||||||
|
→ match OK: prosegue. → miss: messaggio unico anti-leak (§2.7), nessun dettaglio.
|
||||||
|
|
||||||
|
STEP 2 — Form dati + dichiarazione
|
||||||
|
Campi: Nome e cognome, Email (precompilata), Dichiarazione (precompilata, editabile).
|
||||||
|
Mostrato SOPRA il form: testo informativo Art. 49 (§2.4).
|
||||||
|
Azione: "Continua" → schermata di riepilogo.
|
||||||
|
|
||||||
|
STEP 3 — Riepilogo + conferma dedicata
|
||||||
|
Mostra: ordine, nome, email, testo dichiarazione.
|
||||||
|
Funzione dedicata: "Conferma recesso" (§2.2). ← unica azione che registra la richiesta.
|
||||||
|
Al click: transmittedAt = ora, persistenza record, AuditLog, invio ricevuta.
|
||||||
|
|
||||||
|
STEP 4 — Successo
|
||||||
|
Messaggio §2.6 + nota coesistenza §2.9.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Vincoli vincolanti (fail se violati):**
|
||||||
|
- Numero azioni utente ≤ passaggi del checkout dello store (R7).
|
||||||
|
- Nessun pop-up dissuasivo, offerta di trattenimento, timer, o retention nudge.
|
||||||
|
- Nessuna checkbox pre-flaggata; la conferma è **funzione/pulsante dedicato**, non un flag.
|
||||||
|
- Nessun login o registrazione forzati in nessuno step.
|
||||||
|
- Gli altri canali di recesso restano visibili/attivi (R11).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Regole di scadenza (compliance engine) 🟡 (spec per A6)
|
||||||
|
|
||||||
|
> Fonte: ANALISI §2 punto 6, §2 punto 8, §3. Tipi: **beni**, **servizi**, **contenuto digitale**.
|
||||||
|
> Timezone di riferimento: `Europe/Rome`. La finestra è di **14 giorni**.
|
||||||
|
|
||||||
|
**Formule (giorno 0 = data evento di decorrenza):**
|
||||||
|
- **Beni:** `scadenza = dataConsegna + 14 giorni`.
|
||||||
|
- Beni in **più lotti/pezzi** (un solo ordine): decorre dalla **consegna dell'ultimo** bene → `dataConsegnaUltimoLotto + 14 giorni`.
|
||||||
|
- **Servizi:** `scadenza = dataConclusioneContratto + 14 giorni`.
|
||||||
|
- **Contenuto digitale:** decorrenza propria (a parte); gestione dedicata, **non** assimilare ai beni.
|
||||||
|
- **Estensione per omessa informazione (Art. 53):** se il merchant NON ha fornito l'informazione
|
||||||
|
sul diritto/ubicazione della funzione (R12/Art. 49):
|
||||||
|
`scadenza = decorrenzaBase + 12 mesi + 14 giorni`.
|
||||||
|
Se l'informazione viene fornita entro i 12 mesi: la finestra dei 14 giorni riparte dalla data in
|
||||||
|
cui l'informazione è fornita.
|
||||||
|
|
||||||
|
**Note per A6:**
|
||||||
|
- Fonte di verità unica nel motore compliance; input consegna dai webhook fulfillment.
|
||||||
|
- Fulfillment parziale → tracciare per-item; usare la consegna più recente pertinente all'ordine.
|
||||||
|
- Alla scadenza, il flusso mostra il messaggio "finestra chiusa" (§2.7) e blocca l'invio.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Lookup guest & prevenzione abuso 🟢
|
||||||
|
|
||||||
|
**Regole di match:**
|
||||||
|
- Chiave di ricerca: **Numero ordine (`orderName`) + Email**, entrambi devono combaciare.
|
||||||
|
- **Nessun leak:** ordine inesistente ed email non corrispondente producono lo **stesso** messaggio
|
||||||
|
(§2.7). Nessuna differenza in testo, codice di stato o tempo di risposta osservabile.
|
||||||
|
|
||||||
|
**Anti-abuso (superficie di enumeration ordini) — nota per A9/A3:**
|
||||||
|
- **Rate-limit** sul lookup: per IP e/o per email (es. N tentativi / finestra temporale) → 🟢 predisporre in A3, verifica avversariale in A9.
|
||||||
|
- Considerare **backoff/captcha** dopo soglia di tentativi falliti.
|
||||||
|
- Log dei tentativi falliti in `AuditLog` (senza PII sensibile in chiaro non necessaria).
|
||||||
|
- Risposte a **tempo costante** per non rivelare l'esistenza dell'ordine via timing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Confini MVP (esplicito)
|
||||||
|
|
||||||
|
| In MVP 🟢 | Fase successiva 🟡 |
|
||||||
|
|---|---|
|
||||||
|
| Pulsante + flusso guest 2 step | Motore esclusioni Art. 59 (A6) |
|
||||||
|
| Raccolta 4 dati + persistenza | Calcolo scadenza per tipo prodotto (A6) |
|
||||||
|
| Conferma dedicata anti-dark-pattern | Logica rimborso / diminuzione valore (A6) |
|
||||||
|
| Ricevuta durevole con timestamp trasmissione (provider **stub**) | Provider email reale (pre-go-live) |
|
||||||
|
| AuditLog append-only | i18n multi-lingua EN/DE/FR/ES (A7) |
|
||||||
|
| Copy IT hardcoded | Webhook GDPR (A8), Billing (A10) |
|
||||||
|
| Rate-limit base + anti-leak | Rate-limit avanzato / captcha (A9) |
|
||||||
@@ -13,3 +13,13 @@ SHOPIFY_APP_URL=https://your-tunnel-or-fly-url.example
|
|||||||
|
|
||||||
# Postgres connection string (Fly Postgres or Supabase).
|
# Postgres connection string (Fly Postgres or Supabase).
|
||||||
DATABASE_URL=postgresql://user:password@host:5432/recesso?sslmode=require
|
DATABASE_URL=postgresql://user:password@host:5432/recesso?sslmode=require
|
||||||
|
|
||||||
|
# Email ricevuta recesso (supporto durevole).
|
||||||
|
# DEV: Mailpit → SMTP_HOST=localhost SMTP_PORT=1025 SMTP_SECURE=false (nessuna auth).
|
||||||
|
# PROD: provider reale (Resend/Postmark/SMTP) con SMTP_USER/SMTP_PASS.
|
||||||
|
SMTP_HOST=smtp.your-provider.example
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_SECURE=false
|
||||||
|
SMTP_USER=your_smtp_user
|
||||||
|
SMTP_PASS=your_smtp_password
|
||||||
|
MAIL_FROM=Recesso <no-reply@yourdomain.example>
|
||||||
|
|||||||
86
app/app/lib/mailer.server.ts
Normal file
86
app/app/lib/mailer.server.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
/**
|
||||||
|
* Invio ricevuta di recesso su supporto durevole (A4 — Art. 54-bis).
|
||||||
|
*
|
||||||
|
* L'email È il supporto durevole: deve essere autoconsistente e contenere i 4
|
||||||
|
* elementi di legge (dichiarazione integrale, id ordine, data/ora di TRASMISSIONE,
|
||||||
|
* nome consumatore). Il testo viene dal copy deck (recesso.copy §2.5).
|
||||||
|
*
|
||||||
|
* Transport configurato via env (provider-agnostico):
|
||||||
|
* SMTP_HOST, SMTP_PORT(=587), SMTP_USER?, SMTP_PASS?, SMTP_SECURE("true"/"false"), MAIL_FROM
|
||||||
|
* DEV: Mailpit su localhost:1025 (nessuna auth) → email visibili su http://localhost:18025
|
||||||
|
* PROD: provider reale (Resend/Postmark/SMTP) — vedi .env.example.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import nodemailer from "nodemailer";
|
||||||
|
import { receiptEmailBody, receiptEmailSubject } from "./recesso.copy";
|
||||||
|
|
||||||
|
function buildTransport() {
|
||||||
|
const host = process.env.SMTP_HOST;
|
||||||
|
if (!host) return null; // email non configurata → invio saltato con errore gestito
|
||||||
|
const port = Number(process.env.SMTP_PORT ?? 587);
|
||||||
|
const user = process.env.SMTP_USER;
|
||||||
|
return nodemailer.createTransport({
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
secure: process.env.SMTP_SECURE === "true",
|
||||||
|
auth: user ? { user, pass: process.env.SMTP_PASS ?? "" } : undefined,
|
||||||
|
connectionTimeout: 10_000,
|
||||||
|
greetingTimeout: 10_000,
|
||||||
|
socketTimeout: 10_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function textToHtml(text: string): string {
|
||||||
|
const esc = text
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">");
|
||||||
|
return `<div style="font-family:system-ui,-apple-system,Segoe UI,Arial,sans-serif;font-size:14px;line-height:1.6;color:#111;white-space:pre-wrap">${esc}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ReceiptResult =
|
||||||
|
| { ok: true; messageId: string }
|
||||||
|
| { ok: false; error: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invia la ricevuta. Non lancia mai: cattura internamente e ritorna esito.
|
||||||
|
* `transmittedAt` deve essere già la stringa leggibile (Europe/Rome).
|
||||||
|
*/
|
||||||
|
export async function sendWithdrawalReceipt(params: {
|
||||||
|
to: string;
|
||||||
|
orderName: string;
|
||||||
|
customerName: string;
|
||||||
|
statementText: string;
|
||||||
|
transmittedAt: string;
|
||||||
|
shopName: string;
|
||||||
|
}): Promise<ReceiptResult> {
|
||||||
|
const transport = buildTransport();
|
||||||
|
if (!transport) {
|
||||||
|
return { ok: false, error: "SMTP non configurato (SMTP_HOST mancante)" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const subject = receiptEmailSubject(params.orderName);
|
||||||
|
const text = receiptEmailBody({
|
||||||
|
customerName: params.customerName,
|
||||||
|
orderName: params.orderName,
|
||||||
|
transmittedAt: params.transmittedAt,
|
||||||
|
statementText: params.statementText,
|
||||||
|
shopName: params.shopName,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const info = await transport.sendMail({
|
||||||
|
from: process.env.MAIL_FROM ?? "no-reply@localhost",
|
||||||
|
to: params.to,
|
||||||
|
subject,
|
||||||
|
text,
|
||||||
|
html: textToHtml(text),
|
||||||
|
});
|
||||||
|
return { ok: true, messageId: info.messageId };
|
||||||
|
} catch (e) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: e instanceof Error ? e.message : "invio ricevuta fallito",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
123
app/app/lib/recesso.copy.ts
Normal file
123
app/app/lib/recesso.copy.ts
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
/**
|
||||||
|
* Copy deck IT — stringhe ESATTE da SPEC-MVP-RECESSO.md §2.
|
||||||
|
*
|
||||||
|
* Non modificare i testi: sono vincolanti (contratto di build) e usati sia dal
|
||||||
|
* flusso storefront (route proxy) sia — per il template email — da A4.
|
||||||
|
* Placeholder con {{doppie graffe}} vengono sostituiti a runtime.
|
||||||
|
*
|
||||||
|
* i18n multi-lingua = fase A7. In MVP tutto IT hardcoded.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// §2.1 — Etichetta pulsante di avvio (usata dalla Theme App Extension / A2).
|
||||||
|
export const BUTTON_LABEL = "Recedere dal contratto qui";
|
||||||
|
|
||||||
|
// §2.2 — Etichetta funzione di conferma (step 3), funzione dedicata anti-dark-pattern.
|
||||||
|
export const CONFIRM_LABEL = "Conferma recesso";
|
||||||
|
|
||||||
|
// §2.3 — Label dei campi del form + placeholder/hint.
|
||||||
|
export const FIELD = {
|
||||||
|
name: { label: "Nome e cognome", placeholder: "Mario Rossi" },
|
||||||
|
orderName: { label: "Numero dell'ordine", placeholder: "es. #1234" },
|
||||||
|
email: { label: "Email", placeholder: "La tua email dell'ordine" },
|
||||||
|
statement: { label: "Dichiarazione di recesso" },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// §2.3 — Testo precompilato (editabile) della dichiarazione (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}.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// §2.4 — Testo informativo sul diritto di recesso (Art. 49), mostrato PRIMA dell'invio.
|
||||||
|
export const ART49_INFO =
|
||||||
|
"Hai diritto di recedere da questo contratto entro 14 giorni senza dover fornire alcuna motivazione.\n" +
|
||||||
|
"Il termine decorre dalla consegna del bene (o dalla conclusione del contratto per i servizi).\n" +
|
||||||
|
"Compilando e confermando questo modulo eserciti il recesso: riceverai via email una ricevuta con\n" +
|
||||||
|
"la data e l'ora di trasmissione della tua dichiarazione.";
|
||||||
|
|
||||||
|
// §2.9 — Nota di coesistenza (il pulsante è aggiuntivo, non sostitutivo).
|
||||||
|
export const COEXISTENCE_NOTE =
|
||||||
|
"Questa funzione è un modo aggiuntivo per esercitare il recesso. Puoi comunque usare il modulo\n" +
|
||||||
|
"tipo (Allegato I, parte B) o inviare qualsiasi dichiarazione esplicita, anche via email.";
|
||||||
|
|
||||||
|
// §2.7 — Messaggi di errore.
|
||||||
|
export const ERROR = {
|
||||||
|
// Anti-enumeration: ordine inesistente ED email non combaciante DEVONO
|
||||||
|
// mostrare lo stesso identico messaggio (stesso testo, stesso status 200).
|
||||||
|
lookupNoMatch:
|
||||||
|
"Non abbiamo trovato un ordine con questi dati. Verifica il numero dell'ordine e l'email usata per l'acquisto.",
|
||||||
|
missingField: "Compila tutti i campi obbligatori per continuare.",
|
||||||
|
invalidEmail: "Inserisci un indirizzo email valido.",
|
||||||
|
windowClosed:
|
||||||
|
"Il termine di 14 giorni per il recesso su questo ordine è terminato. Puoi comunque contattarci per altre richieste.",
|
||||||
|
generic:
|
||||||
|
"Si è verificato un problema. Riprova tra qualche istante; se persiste, contattaci.",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// §2.6 — Schermata finale (dopo "Conferma recesso").
|
||||||
|
export function successMessage(
|
||||||
|
orderName: string,
|
||||||
|
transmittedAt: string,
|
||||||
|
email: string,
|
||||||
|
): { line1: string; line2: string; line3: string } {
|
||||||
|
return {
|
||||||
|
line1: "Recesso trasmesso correttamente.",
|
||||||
|
line2: `Abbiamo registrato la tua dichiarazione di recesso per l'ordine ${orderName} in data ${transmittedAt}.`,
|
||||||
|
line3: `Ti abbiamo inviato una ricevuta all'indirizzo ${email}.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// §2.5 — Template email ricevuta su supporto durevole.
|
||||||
|
// Usato da A4 (invio reale). In MVP il flusso lascia receiptSentAt = null.
|
||||||
|
export function receiptEmailSubject(orderName: string): string {
|
||||||
|
return `Ricevuta della tua richiesta di recesso — Ordine ${orderName}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function receiptEmailBody(params: {
|
||||||
|
customerName: string;
|
||||||
|
orderName: string;
|
||||||
|
transmittedAt: string; // formato leggibile con fuso orario esplicito (Europe/Rome)
|
||||||
|
statementText: string;
|
||||||
|
shopName: string;
|
||||||
|
}): string {
|
||||||
|
const { customerName, orderName, transmittedAt, statementText, shopName } =
|
||||||
|
params;
|
||||||
|
return `Gentile ${customerName},
|
||||||
|
|
||||||
|
confermiamo di aver ricevuto la Sua dichiarazione di recesso relativa all'ordine ${orderName},
|
||||||
|
trasmessa tramite la funzione di recesso presente sul nostro sito.
|
||||||
|
|
||||||
|
Dettagli della richiesta:
|
||||||
|
- Data e ora di trasmissione: ${transmittedAt}
|
||||||
|
- Ordine: ${orderName}
|
||||||
|
- Nome del consumatore: ${customerName}
|
||||||
|
|
||||||
|
Testo integrale della dichiarazione di recesso trasmessa:
|
||||||
|
"${statementText}"
|
||||||
|
|
||||||
|
Questa comunicazione costituisce la ricevuta su supporto durevole della Sua dichiarazione di
|
||||||
|
recesso, ai sensi dell'art. 54-bis del Codice del Consumo. La data e l'ora sopra indicate
|
||||||
|
attestano il momento della trasmissione della dichiarazione.
|
||||||
|
|
||||||
|
Le invieremo separatamente le istruzioni per l'eventuale restituzione dei beni e i tempi di rimborso.
|
||||||
|
|
||||||
|
Restano comunque validi anche gli altri mezzi per esercitare il recesso (modulo tipo di cui
|
||||||
|
all'Allegato I, parte B, o qualsiasi altra dichiarazione esplicita, anche via email): questa
|
||||||
|
funzione è aggiuntiva e non sostituisce tali strumenti.
|
||||||
|
|
||||||
|
${shopName}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// §2.8 — Messaggio prodotto escluso (Art. 59). Copy MVP-ready; la LOGICA è in A6.
|
||||||
|
export const EXCLUSION_REASON = {
|
||||||
|
CUSTOM: "prodotto realizzato su misura o personalizzato",
|
||||||
|
PERISHABLE: "prodotto deperibile o a rapida scadenza",
|
||||||
|
HYGIENE:
|
||||||
|
"prodotto sigillato, aperto dopo la consegna, non restituibile per motivi igienici o di salute",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export function exclusionMessage(motivoEsclusione: string): string {
|
||||||
|
return `Per questo prodotto il diritto di recesso non è previsto (${motivoEsclusione}, ai sensi dell'art. 59 del Codice del Consumo). Per informazioni o altre richieste, contattaci.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Titolo pagina / intestazione del flusso (non normato: label neutra, coerente col verbo di legge).
|
||||||
|
export const PAGE_TITLE = "Recesso dal contratto";
|
||||||
663
app/app/lib/recesso.server.ts
Normal file
663
app/app/lib/recesso.server.ts
Normal file
@@ -0,0 +1,663 @@
|
|||||||
|
/**
|
||||||
|
* Helper server-only per il flusso di recesso guest (App Proxy).
|
||||||
|
*
|
||||||
|
* Responsabilità:
|
||||||
|
* - lookup ordine via Admin GraphQL (anti-leak: nessuna differenza tra ordine
|
||||||
|
* 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).
|
||||||
|
*
|
||||||
|
* NB: `import "server-only"` non è disponibile qui; il suffisso `.server.ts`
|
||||||
|
* garantisce che Remix non impacchetti questo modulo nel bundle client.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import type { AdminApiContext } from "@shopify/shopify-app-remix/server";
|
||||||
|
import { FIELD, ART49_INFO, COEXISTENCE_NOTE, 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";
|
||||||
|
|
||||||
|
// Locale MVP fisso (multi-lingua -> A7).
|
||||||
|
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, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Attributo HTML sicuro (per value="..."): riusa escapeHtml. */
|
||||||
|
export function attr(value: string): string {
|
||||||
|
return escapeHtml(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
export function isValidEmail(value: string): boolean {
|
||||||
|
return EMAIL_RE.test(value.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rimuove il prefisso '#' e spazi dal numero ordine digitato dall'utente. */
|
||||||
|
export function normalizeOrderName(input: string): string {
|
||||||
|
return input.trim().replace(/^#+/, "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sha256(payload: unknown): string {
|
||||||
|
return createHash("sha256")
|
||||||
|
.update(typeof payload === "string" ? payload : JSON.stringify(payload))
|
||||||
|
.digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formatta l'istante di TRASMISSIONE in modo leggibile e conservabile, con fuso
|
||||||
|
* orario esplicito, es. "06/07/2026, 14:32:07 CEST". Il DB conserva UTC.
|
||||||
|
*/
|
||||||
|
export function formatTransmittedAt(date: Date): string {
|
||||||
|
return new Intl.DateTimeFormat("it-IT", {
|
||||||
|
timeZone: "Europe/Rome",
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
year: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit",
|
||||||
|
timeZoneName: "short",
|
||||||
|
}).format(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Estrae l'IP client dell'utente storefront (App Proxy forwarda X-Forwarded-For). */
|
||||||
|
export function clientIp(request: Request): string {
|
||||||
|
const xff = request.headers.get("X-Forwarded-For");
|
||||||
|
if (xff) return xff.split(",")[0]!.trim();
|
||||||
|
return request.headers.get("X-Real-IP")?.trim() || "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Rate-limit base (in-memory, single-instance) — hardening -> A9.
|
||||||
|
// Finestra fissa per chiave shop+IP. Predisposto per la verifica avversariale A9.
|
||||||
|
// TODO(A9): sostituire con store condiviso (Redis/DB) per il deploy multi-istanza,
|
||||||
|
// aggiungere backoff/captcha oltre soglia e risposte a tempo costante
|
||||||
|
// (attualmente evitiamo solo leak di testo/status, non di timing).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const RATE_WINDOW_MS = 15 * 60 * 1000; // 15 minuti
|
||||||
|
const RATE_MAX_ATTEMPTS = 8; // tentativi di lookup per finestra, per shop+IP
|
||||||
|
const rateBucket = new Map<string, { count: number; resetAt: number }>();
|
||||||
|
|
||||||
|
/** Ritorna true se la richiesta è consentita, false se ha superato la soglia. */
|
||||||
|
export function checkRateLimit(shop: string, ip: string): boolean {
|
||||||
|
const key = `${shop}:${ip}`;
|
||||||
|
const now = Date.now();
|
||||||
|
const entry = rateBucket.get(key);
|
||||||
|
if (!entry || entry.resetAt <= now) {
|
||||||
|
rateBucket.set(key, { count: 1, resetAt: now + RATE_WINDOW_MS });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (entry.count >= RATE_MAX_ATTEMPTS) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
entry.count += 1;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Lookup ordine via Admin GraphQL.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface MatchedOrder {
|
||||||
|
orderId: string; // GID Shopify (gid://shopify/Order/...)
|
||||||
|
orderName: string; // es. "#1001"
|
||||||
|
email: string; // email dell'ordine (per precompilazione)
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OrderLookupGraphQL {
|
||||||
|
data?: {
|
||||||
|
orders?: {
|
||||||
|
edges?: Array<{
|
||||||
|
node?: {
|
||||||
|
id?: string | null;
|
||||||
|
name?: string | null;
|
||||||
|
email?: string | null;
|
||||||
|
createdAt?: string | null;
|
||||||
|
} | null;
|
||||||
|
} | null> | null;
|
||||||
|
} | null;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ORDER_LOOKUP_QUERY = `#graphql
|
||||||
|
query recessoOrderLookup($query: String!) {
|
||||||
|
orders(first: 5, query: $query) {
|
||||||
|
edges {
|
||||||
|
node {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
email
|
||||||
|
createdAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cerca l'ordine per numero e verifica che l'email combaci.
|
||||||
|
*
|
||||||
|
* ANTI-LEAK (SPEC §6 / R2): sia se l'ordine non esiste sia se l'email non
|
||||||
|
* corrisponde, la funzione ritorna `null` in modo indistinguibile. Il chiamante
|
||||||
|
* mostra SEMPRE lo stesso messaggio (ERROR.lookupNoMatch) con lo stesso status.
|
||||||
|
*
|
||||||
|
* NB "Protected Customer Data": in PRODUZIONE la lettura di `order.email` via
|
||||||
|
* Admin API richiede l'approvazione Shopify "Protected customer data access".
|
||||||
|
* Su dev/custom store funziona senza. // TODO: richiedere l'accesso pre-go-live.
|
||||||
|
*
|
||||||
|
* Multi-tenant: `admin` proviene dalla sessione App Proxy, quindi la query è
|
||||||
|
* già scoped allo shop corretto; non ci fidiamo mai dello shop lato client.
|
||||||
|
*/
|
||||||
|
export async function lookupOrder(
|
||||||
|
admin: AdminApiContext,
|
||||||
|
orderInput: string,
|
||||||
|
emailInput: string,
|
||||||
|
): Promise<MatchedOrder | null> {
|
||||||
|
const normalized = normalizeOrderName(orderInput);
|
||||||
|
const email = emailInput.trim().toLowerCase();
|
||||||
|
if (!normalized || !email) return null;
|
||||||
|
|
||||||
|
let body: OrderLookupGraphQL;
|
||||||
|
try {
|
||||||
|
const res = await admin.graphql(ORDER_LOOKUP_QUERY, {
|
||||||
|
variables: { query: `name:#${normalized}` },
|
||||||
|
});
|
||||||
|
body = (await res.json()) as OrderLookupGraphQL;
|
||||||
|
} catch {
|
||||||
|
// Errore API: trattiamo come "nessun match" per non rivelare nulla.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const edges = body.data?.orders?.edges ?? [];
|
||||||
|
const wantedName = `#${normalized}`.toLowerCase();
|
||||||
|
|
||||||
|
for (const edge of edges) {
|
||||||
|
const node = edge?.node;
|
||||||
|
if (!node?.id || !node.name) continue;
|
||||||
|
const nameMatch =
|
||||||
|
node.name.toLowerCase() === wantedName ||
|
||||||
|
node.name.toLowerCase() === normalized.toLowerCase();
|
||||||
|
const emailMatch = (node.email ?? "").trim().toLowerCase() === email;
|
||||||
|
if (nameMatch && emailMatch) {
|
||||||
|
return {
|
||||||
|
orderId: node.id,
|
||||||
|
orderName: node.name,
|
||||||
|
email: node.email ?? emailInput.trim(),
|
||||||
|
createdAt: node.createdAt ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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; }
|
||||||
|
}
|
||||||
|
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 { display: flex; align-items: center; gap: 10px; margin: 0 0 22px; }
|
||||||
|
.stepper__track { display: inline-flex; gap: 5px; }
|
||||||
|
.stepper__seg { width: 28px; height: 4px; border-radius: 999px; background: var(--border); transition: background 0.15s ease; }
|
||||||
|
.stepper__seg.is-active, .stepper__seg.is-done { background: var(--accent); }
|
||||||
|
.stepper__label { font-size: 0.72rem; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; color: var(--text-muted); }
|
||||||
|
|
||||||
|
/* 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: 60px; height: 60px; margin: 0 auto 18px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--success-bg);
|
||||||
|
}
|
||||||
|
.success__icon svg { width: 32px; height: 32px; 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>
|
||||||
|
<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 coexistenceBlock(): string {
|
||||||
|
return `<div class="coexist">${escapeHtml(COEXISTENCE_NOTE)}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 segs = [1, 2, 3]
|
||||||
|
.map((i) => {
|
||||||
|
const cls =
|
||||||
|
current >= 4 || current > i
|
||||||
|
? "stepper__seg is-done"
|
||||||
|
: current === i
|
||||||
|
? "stepper__seg is-active"
|
||||||
|
: "stepper__seg";
|
||||||
|
return `<span class="${cls}"></span>`;
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
const label = labels[current] ?? "";
|
||||||
|
return `<div class="stepper">
|
||||||
|
<span class="stepper__track" aria-hidden="true">${segs}</span>
|
||||||
|
<span class="stepper__label">${escapeHtml(label)}</span>
|
||||||
|
</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(`
|
||||||
|
${stepIndicator(1)}
|
||||||
|
<h1>${escapeHtml(PAGE_TITLE)}</h1>
|
||||||
|
<p class="muted">Inserisci il numero dell'ordine e l'email usata per l'acquisto per iniziare. Non è necessario alcun account.</p>
|
||||||
|
${errorBanner(opts?.error)}
|
||||||
|
<form 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>
|
||||||
|
<button type="submit" class="btn btn-primary">Continua</button>
|
||||||
|
</form>
|
||||||
|
${coexistenceBlock()}
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 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(`
|
||||||
|
${stepIndicator(2)}
|
||||||
|
<h1>${escapeHtml(PAGE_TITLE)}</h1>
|
||||||
|
<p class="muted">Ordine ${escapeHtml(data.orderName)}</p>
|
||||||
|
<div class="info">${escapeHtml(ART49_INFO)}</div>
|
||||||
|
${errorBanner(data.error)}
|
||||||
|
<form 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">Puoi modificare il testo della dichiarazione se lo desideri.</p>
|
||||||
|
|
||||||
|
<div class="receipt-field">
|
||||||
|
<label for="email">${escapeHtml(FIELD.email.label)} <span class="tag">Ricevuta</span></label>
|
||||||
|
<input type="email" id="email" name="email" value="${attr(data.email)}" autocomplete="email" required>
|
||||||
|
<p class="hint">Qui riceverai la ricevuta del recesso. Già preso dal tuo ordine — puoi modificarlo.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary">Continua</button>
|
||||||
|
</form>
|
||||||
|
${coexistenceBlock()}
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Step 3: riepilogo + conferma dedicata --------------------------------
|
||||||
|
export function renderStep3(data: {
|
||||||
|
orderId: string;
|
||||||
|
orderName: string;
|
||||||
|
email: string;
|
||||||
|
customerName: string;
|
||||||
|
statementText: string;
|
||||||
|
error?: string;
|
||||||
|
}): string {
|
||||||
|
return renderShell(`
|
||||||
|
${stepIndicator(3)}
|
||||||
|
<h1>${escapeHtml(PAGE_TITLE)}</h1>
|
||||||
|
<p class="muted">Controlla i dati. Il recesso sarà trasmesso solo quando premi «${escapeHtml(CONFIRM_LABEL)}».</p>
|
||||||
|
${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>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<form 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)}">
|
||||||
|
<button type="submit" class="btn btn-secondary">Torna indietro</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form 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)}">
|
||||||
|
<button type="submit" class="btn btn-primary">${escapeHtml(CONFIRM_LABEL)}</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
${coexistenceBlock()}
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Step 4: successo -----------------------------------------------------
|
||||||
|
export function renderStep4(data: {
|
||||||
|
line1: string;
|
||||||
|
line2: string;
|
||||||
|
line3: string;
|
||||||
|
}): string {
|
||||||
|
return renderShell(`
|
||||||
|
${stepIndicator(4)}
|
||||||
|
<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>
|
||||||
|
${coexistenceBlock()}
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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" },
|
||||||
|
});
|
||||||
|
}
|
||||||
326
app/app/routes/proxy.tsx
Normal file
326
app/app/routes/proxy.tsx
Normal file
@@ -0,0 +1,326 @@
|
|||||||
|
/**
|
||||||
|
* App Proxy — flusso di recesso guest (SPEC-MVP-RECESSO §4).
|
||||||
|
*
|
||||||
|
* Storefront `/apps/recesso` -> (App Proxy) -> questa route `/proxy`.
|
||||||
|
* Config in shopify.app.toml: [app_proxy] url=<app>/proxy, subpath=recesso, prefix=apps.
|
||||||
|
*
|
||||||
|
* La route espone SOLO loader + action e ritorna sempre una `Response` HTML
|
||||||
|
* standalone (nessun default component, nessun Polaris, nessun root layout admin):
|
||||||
|
* è il pattern delle route App Proxy (come l'helper `liquid()`).
|
||||||
|
*
|
||||||
|
* Macchina a stati a 2 step (guest, zero JS client). Lo stato viaggia tra gli
|
||||||
|
* step in campi hidden delle form; nessuno stato server è persistito prima della
|
||||||
|
* conferma finale. Ogni richiesta è autenticata da authenticate.public.appProxy,
|
||||||
|
* che verifica la firma HMAC di Shopify e fornisce session/admin scoped allo shop.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node";
|
||||||
|
import { authenticate } from "../shopify.server";
|
||||||
|
import db from "../db.server";
|
||||||
|
import { sendWithdrawalReceipt } from "../lib/mailer.server";
|
||||||
|
import { ERROR, statementTemplate, successMessage } from "../lib/recesso.copy";
|
||||||
|
import {
|
||||||
|
MVP_LOCALE,
|
||||||
|
checkRateLimit,
|
||||||
|
clientIp,
|
||||||
|
formatTransmittedAt,
|
||||||
|
htmlResponse,
|
||||||
|
isValidEmail,
|
||||||
|
lookupOrder,
|
||||||
|
renderStep1,
|
||||||
|
renderStep2,
|
||||||
|
renderStep3,
|
||||||
|
renderStep4,
|
||||||
|
sha256,
|
||||||
|
} from "../lib/recesso.server";
|
||||||
|
|
||||||
|
// GET /apps/recesso -> Step 1 (form di lookup).
|
||||||
|
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||||
|
await authenticate.public.appProxy(request);
|
||||||
|
return htmlResponse(renderStep1());
|
||||||
|
};
|
||||||
|
|
||||||
|
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||||
|
const { session, admin } = await authenticate.public.appProxy(request);
|
||||||
|
|
||||||
|
// 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 }));
|
||||||
|
}
|
||||||
|
const shop = session.shop;
|
||||||
|
|
||||||
|
const form = await request.formData();
|
||||||
|
const intent = String(form.get("intent") ?? "");
|
||||||
|
|
||||||
|
switch (intent) {
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// STEP 1 -> lookup ordine (anti-leak) -> STEP 2
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
case "lookup": {
|
||||||
|
const orderNameInput = String(form.get("orderName") ?? "").trim();
|
||||||
|
const emailInput = String(form.get("email") ?? "").trim();
|
||||||
|
|
||||||
|
// Validazione base (stesso status 200 di ogni risposta in-flow).
|
||||||
|
if (!orderNameInput || !emailInput) {
|
||||||
|
return htmlResponse(
|
||||||
|
renderStep1({
|
||||||
|
error: ERROR.missingField,
|
||||||
|
orderName: orderNameInput,
|
||||||
|
email: emailInput,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!isValidEmail(emailInput)) {
|
||||||
|
return htmlResponse(
|
||||||
|
renderStep1({
|
||||||
|
error: ERROR.invalidEmail,
|
||||||
|
orderName: orderNameInput,
|
||||||
|
email: emailInput,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate-limit base per shop+IP (hardening -> A9).
|
||||||
|
if (!checkRateLimit(shop, clientIp(request))) {
|
||||||
|
await db.auditLog.create({
|
||||||
|
data: {
|
||||||
|
shop,
|
||||||
|
event: "withdrawal_lookup_failed",
|
||||||
|
payloadHash: sha256({ orderNameInput, emailInput }),
|
||||||
|
detail: "rate_limited",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return htmlResponse(
|
||||||
|
renderStep1({
|
||||||
|
error: ERROR.generic,
|
||||||
|
orderName: orderNameInput,
|
||||||
|
email: emailInput,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = await lookupOrder(admin, orderNameInput, emailInput);
|
||||||
|
|
||||||
|
// ANTI-LEAK (SPEC §6 / R2): ordine inesistente ED email non combaciante
|
||||||
|
// producono lo STESSO messaggio, stesso testo e stesso status 200.
|
||||||
|
if (!match) {
|
||||||
|
await db.auditLog.create({
|
||||||
|
data: {
|
||||||
|
shop,
|
||||||
|
event: "withdrawal_lookup_failed",
|
||||||
|
payloadHash: sha256({ orderNameInput, emailInput }), // no PII in chiaro
|
||||||
|
detail: "no_match",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return htmlResponse(
|
||||||
|
renderStep1({
|
||||||
|
error: ERROR.lookupNoMatch,
|
||||||
|
orderName: orderNameInput,
|
||||||
|
email: emailInput,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO(A6): qui andrà il check finestra 14 gg (deadline engine). Se la
|
||||||
|
// finestra è scaduta -> mostrare ERROR.windowClosed e bloccare.
|
||||||
|
// TODO(A6): qui andrà il check esclusioni Art. 59 per gli item dell'ordine.
|
||||||
|
|
||||||
|
return htmlResponse(
|
||||||
|
renderStep2({
|
||||||
|
orderId: match.orderId,
|
||||||
|
orderName: match.orderName,
|
||||||
|
email: match.email,
|
||||||
|
statementText: statementTemplate(match.orderName),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// STEP 2 -> validazione 4 dati -> STEP 3 (riepilogo)
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
case "details": {
|
||||||
|
const orderId = String(form.get("orderId") ?? "").trim();
|
||||||
|
const orderName = String(form.get("orderName") ?? "").trim();
|
||||||
|
const customerName = String(form.get("customerName") ?? "").trim();
|
||||||
|
const email = String(form.get("email") ?? "").trim();
|
||||||
|
const statementText = String(form.get("statementText") ?? "").trim();
|
||||||
|
|
||||||
|
// 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 }));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!customerName || !email || !statementText) {
|
||||||
|
return htmlResponse(
|
||||||
|
renderStep2({
|
||||||
|
orderId,
|
||||||
|
orderName,
|
||||||
|
email,
|
||||||
|
customerName,
|
||||||
|
statementText: statementText || statementTemplate(orderName),
|
||||||
|
error: ERROR.missingField,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!isValidEmail(email)) {
|
||||||
|
return htmlResponse(
|
||||||
|
renderStep2({
|
||||||
|
orderId,
|
||||||
|
orderName,
|
||||||
|
email,
|
||||||
|
customerName,
|
||||||
|
statementText,
|
||||||
|
error: ERROR.invalidEmail,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return htmlResponse(
|
||||||
|
renderStep3({ orderId, orderName, email, customerName, statementText }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// STEP 3 «Torna indietro» -> ripopola STEP 2 (editing consentito, non dark pattern)
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
case "edit": {
|
||||||
|
const orderId = String(form.get("orderId") ?? "").trim();
|
||||||
|
const orderName = String(form.get("orderName") ?? "").trim();
|
||||||
|
const customerName = String(form.get("customerName") ?? "").trim();
|
||||||
|
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(
|
||||||
|
renderStep2({
|
||||||
|
orderId,
|
||||||
|
orderName,
|
||||||
|
email,
|
||||||
|
customerName,
|
||||||
|
statementText: statementText || statementTemplate(orderName),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// STEP 3 «Conferma recesso» -> TRASMISSIONE: persistenza + audit -> STEP 4
|
||||||
|
// Unica azione che registra la richiesta (funzione dedicata, no checkbox).
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
case "confirm": {
|
||||||
|
const orderId = String(form.get("orderId") ?? "").trim();
|
||||||
|
const orderName = String(form.get("orderName") ?? "").trim();
|
||||||
|
const customerName = String(form.get("customerName") ?? "").trim();
|
||||||
|
const email = String(form.get("email") ?? "").trim();
|
||||||
|
const statementText = String(form.get("statementText") ?? "").trim();
|
||||||
|
|
||||||
|
if (
|
||||||
|
!orderId ||
|
||||||
|
!orderName ||
|
||||||
|
!customerName ||
|
||||||
|
!email ||
|
||||||
|
!statementText ||
|
||||||
|
!isValidEmail(email)
|
||||||
|
) {
|
||||||
|
return htmlResponse(renderStep1({ error: ERROR.generic }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO(A6): re-check finestra 14 gg + esclusioni Art. 59 prima di registrare.
|
||||||
|
|
||||||
|
// 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({
|
||||||
|
data: {
|
||||||
|
shop, // sempre da session.shop
|
||||||
|
orderId: match.orderId, // GID risolto dal lookup
|
||||||
|
orderName: match.orderName,
|
||||||
|
customerName,
|
||||||
|
email,
|
||||||
|
statementText,
|
||||||
|
transmittedAt,
|
||||||
|
channel: "GUEST",
|
||||||
|
locale: MVP_LOCALE,
|
||||||
|
status: "RECEIVED",
|
||||||
|
receiptSentAt: null, // valorizzato da A4 dopo invio ricevuta
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Audit trail append-only (SPEC R6): evento + hash del payload.
|
||||||
|
await db.auditLog.create({
|
||||||
|
data: {
|
||||||
|
shop,
|
||||||
|
event: "withdrawal_received",
|
||||||
|
payloadHash: sha256({
|
||||||
|
orderId: match.orderId,
|
||||||
|
orderName: match.orderName,
|
||||||
|
customerName,
|
||||||
|
email,
|
||||||
|
statementText,
|
||||||
|
transmittedAt: transmittedAt.toISOString(),
|
||||||
|
}),
|
||||||
|
detail: match.orderName,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return htmlResponse(renderStep1({ error: ERROR.generic }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const transmittedLabel = formatTransmittedAt(transmittedAt);
|
||||||
|
|
||||||
|
// A4 — ricevuta su supporto durevole, senza ritardo. Il recesso è GIÀ
|
||||||
|
// persistito e valido: un invio email fallito NON deve invalidarlo.
|
||||||
|
const receipt = await sendWithdrawalReceipt({
|
||||||
|
to: email,
|
||||||
|
orderName: match.orderName,
|
||||||
|
customerName,
|
||||||
|
statementText,
|
||||||
|
transmittedAt: transmittedLabel,
|
||||||
|
shopName: shop,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
if (receipt.ok) {
|
||||||
|
await db.withdrawalRequest.update({
|
||||||
|
where: { id: created.id },
|
||||||
|
data: { receiptSentAt: new Date() },
|
||||||
|
});
|
||||||
|
await db.auditLog.create({
|
||||||
|
data: { shop, event: "receipt_sent", detail: match.orderName },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.error("[recesso] invio ricevuta fallito:", receipt.error);
|
||||||
|
await db.auditLog.create({
|
||||||
|
data: {
|
||||||
|
shop,
|
||||||
|
event: "receipt_failed",
|
||||||
|
detail: receipt.error.slice(0, 200),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
const msg = successMessage(match.orderName, transmittedLabel, email);
|
||||||
|
return htmlResponse(renderStep4(msg));
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
// Intent sconosciuto: torna allo Step 1 senza rivelare dettagli.
|
||||||
|
return htmlResponse(renderStep1());
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -34,6 +34,7 @@
|
|||||||
"@shopify/shopify-app-remix": "^4.1.0",
|
"@shopify/shopify-app-remix": "^4.1.0",
|
||||||
"@shopify/shopify-app-session-storage-prisma": "^8.0.0",
|
"@shopify/shopify-app-session-storage-prisma": "^8.0.0",
|
||||||
"isbot": "^5.1.0",
|
"isbot": "^5.1.0",
|
||||||
|
"nodemailer": "^9.0.3",
|
||||||
"prisma": "^6.2.1",
|
"prisma": "^6.2.1",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
@@ -45,6 +46,7 @@
|
|||||||
"@shopify/api-codegen-preset": "^1.1.1",
|
"@shopify/api-codegen-preset": "^1.1.1",
|
||||||
"@types/eslint": "^9.6.1",
|
"@types/eslint": "^9.6.1",
|
||||||
"@types/node": "^22.2.0",
|
"@types/node": "^22.2.0",
|
||||||
|
"@types/nodemailer": "^8.0.1",
|
||||||
"@types/react": "^18.2.31",
|
"@types/react": "^18.2.31",
|
||||||
"@types/react-dom": "^18.2.14",
|
"@types/react-dom": "^18.2.14",
|
||||||
"eslint": "^8.42.0",
|
"eslint": "^8.42.0",
|
||||||
@@ -62,6 +64,7 @@
|
|||||||
"@shopify/plugin-cloudflare"
|
"@shopify/plugin-cloudflare"
|
||||||
],
|
],
|
||||||
"resolutions": {
|
"resolutions": {
|
||||||
|
"@shopify/shopify-api": "13.1.0",
|
||||||
"@graphql-tools/url-loader": "8.0.16",
|
"@graphql-tools/url-loader": "8.0.16",
|
||||||
"@graphql-codegen/client-preset": "4.7.0",
|
"@graphql-codegen/client-preset": "4.7.0",
|
||||||
"@graphql-codegen/typescript-operations": "4.5.0",
|
"@graphql-codegen/typescript-operations": "4.5.0",
|
||||||
@@ -69,6 +72,7 @@
|
|||||||
"vite": "^6.2.2"
|
"vite": "^6.2.2"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
|
"@shopify/shopify-api": "13.1.0",
|
||||||
"@graphql-tools/url-loader": "8.0.16",
|
"@graphql-tools/url-loader": "8.0.16",
|
||||||
"@graphql-codegen/client-preset": "4.7.0",
|
"@graphql-codegen/client-preset": "4.7.0",
|
||||||
"@graphql-codegen/typescript-operations": "4.5.0",
|
"@graphql-codegen/typescript-operations": "4.5.0",
|
||||||
|
|||||||
@@ -6,6 +6,14 @@ name = "Legal Return PCRT "
|
|||||||
application_url = "https://miscellaneous-connections-harvest-chronicle.trycloudflare.com"
|
application_url = "https://miscellaneous-connections-harvest-chronicle.trycloudflare.com"
|
||||||
embedded = true
|
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"
|
||||||
|
subpath = "recesso"
|
||||||
|
prefix = "apps"
|
||||||
|
|
||||||
[build]
|
[build]
|
||||||
automatically_update_urls_on_dev = false
|
automatically_update_urls_on_dev = false
|
||||||
dev_store_url = "pcrt-reso-test.myshopify.com"
|
dev_store_url = "pcrt-reso-test.myshopify.com"
|
||||||
|
|||||||
Reference in New Issue
Block a user