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