From d140d90629ef6178201102331dfe06c13c321fe9 Mon Sep 17 00:00:00 2001 From: tommaso Date: Tue, 7 Jul 2026 12:17:07 +0200 Subject: [PATCH] Storefront modal + email templating: theme extension, redesign, editable receipt Storefront (theme app extension recesso-storefront): - App block + app embed che linkano a /apps/recesso; modal via iframe con fallback progressive-enhancement; apertura immediata, dimensione fissa - Redesign form: pattern modal moderno (header/footer fissi, corpo scorrevole), modalita' embed, popover info "i", testo ridotto, de-AI, trattini al posto degli em-dash Ricevuta email: - Dati per-shop dinamici da Shopify (nome, url, link stato ordine) via getShopInfo + statusPageUrl - Template editabili VINCOLATI (oggetto/introduzione/nota) con segnaposto; layout fisso e conforme (dichiarazione, timestamp, avviso art. 54-bis) - Pagina admin (Polaris) con anteprima live + ripristina default - Campi email in Settings (Prisma) + migrazioni Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Mv83a29B4eFv5ixoj6PoE1 --- app/app/lib/emailTemplate.ts | 131 ++++++++ app/app/lib/mailer.server.ts | 71 +++-- app/app/lib/recesso.copy.ts | 87 +++--- app/app/lib/recesso.server.ts | 290 ++++++++++++------ app/app/routes/app.settings.tsx | 202 ++++++++++++ app/app/routes/app.tsx | 2 +- app/app/routes/proxy.tsx | 23 +- .../assets/recesso-storefront.css | 106 +++++++ .../assets/recesso-storefront.js | 81 +++++ .../blocks/withdrawal_embed.liquid | 56 ++++ .../blocks/withdrawal_link.liquid | 87 ++++++ .../recesso-storefront/shopify.extension.toml | 3 + .../migration.sql | 3 + .../20260707093246_email_logo/migration.sql | 2 + .../migration.sql | 8 + .../migration.sql | 12 + .../migration.sql | 13 + app/prisma/schema.prisma | 3 + 18 files changed, 1002 insertions(+), 178 deletions(-) create mode 100644 app/app/lib/emailTemplate.ts create mode 100644 app/app/routes/app.settings.tsx create mode 100644 app/extensions/recesso-storefront/assets/recesso-storefront.css create mode 100644 app/extensions/recesso-storefront/assets/recesso-storefront.js create mode 100644 app/extensions/recesso-storefront/blocks/withdrawal_embed.liquid create mode 100644 app/extensions/recesso-storefront/blocks/withdrawal_link.liquid create mode 100644 app/extensions/recesso-storefront/shopify.extension.toml create mode 100644 app/prisma/migrations/20260707092205_email_settings/migration.sql create mode 100644 app/prisma/migrations/20260707093246_email_logo/migration.sql create mode 100644 app/prisma/migrations/20260707094749_remove_email_logo/migration.sql create mode 100644 app/prisma/migrations/20260707095341_email_templates/migration.sql create mode 100644 app/prisma/migrations/20260707100338_email_text_fields/migration.sql diff --git a/app/app/lib/emailTemplate.ts b/app/app/lib/emailTemplate.ts new file mode 100644 index 0000000..9e233a5 --- /dev/null +++ b/app/app/lib/emailTemplate.ts @@ -0,0 +1,131 @@ +/** + * Email ricevuta recesso - layout FISSO (stile Shopify), con solo alcuni TESTI + * editabili dal merchant: oggetto, introduzione, nota opzionale. Le parti legali + * (dichiarazione, timestamp, avviso supporto durevole art. 54-bis), la struttura + * e il footer sono fissi -> sempre conforme e non rompibile. + * + * Modulo PURO (niente node/server): usato dal mailer e dall'anteprima admin. + * Segnaposto ammessi nei testi: {{customerName}} {{orderName}} {{shopName}}. + */ + +export interface EmailVars { + shopName: string; + shopUrl: string; + orderName: string; + orderUrl: string; + customerName: string; + transmittedAt: string; + statementText: string; +} + +// Segnaposto usabili nei campi di testo editabili. +export const TEXT_PLACEHOLDERS = ["customerName", "orderName", "shopName"] as const; + +export const DEFAULT_SUBJECT = + "{{shopName}} - Ricevuta della tua richiesta di recesso - Ordine {{orderName}}"; + +export const DEFAULT_INTRO = + "Gentile {{customerName}},\n\nconfermiamo di aver ricevuto la tua dichiarazione di recesso relativa all'ordine {{orderName}}."; + +export const DEFAULT_NOTE = ""; + +function escHtml(s: string): string { + return String(s) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} +function nl2br(s: string): string { + return s.replace(/\n/g, "
"); +} + +/** Sostituzione segnaposto in testo semplice (valori grezzi). Per l'oggetto. */ +function substPlain(tpl: string, vars: EmailVars): string { + return tpl.replace(/\{\{\s*(\w+)\s*\}\}/g, (_m, k: string) => + String((vars as unknown as Record)[k] ?? ""), + ); +} + +/** Testo del merchant -> HTML sicuro: escape del letterale + segnaposto (valori escaped) + a-capo. */ +function richText(tpl: string, vars: EmailVars): string { + const escaped = escHtml(tpl); + const withVars = escaped.replace(/\{\{\s*(\w+)\s*\}\}/g, (_m, k: string) => + escHtml(String((vars as unknown as Record)[k] ?? "")), + ); + return nl2br(withVars); +} + +export function renderSubject(subjectTpl: string | null | undefined, vars: EmailVars): string { + const tpl = (subjectTpl && subjectTpl.trim()) || DEFAULT_SUBJECT; + return substPlain(tpl, vars).trim() || substPlain(DEFAULT_SUBJECT, vars); +} + +/** Corpo HTML fisso con intro/nota editabili inseriti. */ +export function renderReceiptHtml( + vars: EmailVars, + introTpl: string | null | undefined, + noteTpl: string | null | undefined, +): string { + const intro = richText((introTpl && introTpl.trim()) || DEFAULT_INTRO, vars); + const noteVal = noteTpl && noteTpl.trim() ? richText(noteTpl, vars) : ""; + const note = noteVal + ? ` +
${noteVal}
+` + : ""; + + return ` + + + +
+ + + + + + + + +${note} + +
 
+${escHtml(vars.shopName)} +
+

Ricevuta della richiesta di recesso

+

${intro}

+
+ +
+
Data e ora di trasmissione: ${escHtml(vars.transmittedAt)}
+
Ordine: ${escHtml(vars.orderName)}
+
Nome: ${escHtml(vars.customerName)}
+
+
+
Dichiarazione trasmessa
+
${escHtml(vars.statementText)}
+
+Vedi il tuo ordine +
+

Questa comunicazione costituisce la ricevuta su supporto durevole ai sensi dell'art. 54-bis del Codice del Consumo. La data e l'ora indicate attestano il momento della trasmissione.

+
+

Ti invieremo separatamente le istruzioni per l'eventuale reso e i tempi di rimborso.

+

Questa funzione e' aggiuntiva: restano validi il modulo tipo (Allegato I, parte B) e la dichiarazione via email.

+

${escHtml(vars.shopName)}

+
+
+`; +} + +/** Dati fittizi per l'anteprima admin. */ +export const SAMPLE_VARS: EmailVars = { + shopName: "Il tuo negozio", + shopUrl: "https://esempio.myshopify.com", + orderName: "#1001", + orderUrl: "https://esempio.myshopify.com/orders/xyz", + customerName: "Mario Rossi", + transmittedAt: "07/07/2026, 11:32:07 CEST", + statementText: + "Con la presente comunico il recesso dal contratto di vendita relativo all'ordine #1001.", +}; diff --git a/app/app/lib/mailer.server.ts b/app/app/lib/mailer.server.ts index d69a2f6..4b07237 100644 --- a/app/app/lib/mailer.server.ts +++ b/app/app/lib/mailer.server.ts @@ -1,22 +1,25 @@ /** - * Invio ricevuta di recesso su supporto durevole (A4 — Art. 54-bis). + * Invio ricevuta di recesso su supporto durevole (A4 - Art. 54-bis). * - * L'email È il supporto durevole: deve essere autoconsistente e contenere i 4 - * elementi di legge (dichiarazione integrale, id ordine, data/ora di TRASMISSIONE, - * nome consumatore). Il testo viene dal copy deck (recesso.copy §2.5). + * Il corpo/oggetto sono TEMPLATE editabili dal merchant (HTML + segnaposto), con + * default in ./emailTemplate. Qui sostituiamo i segnaposto coi valori reali + * (dinamici da Shopify) e inviamo. La validazione compliance (segnaposto + * obbligatori) avviene al salvataggio lato admin. * - * Transport configurato via env (provider-agnostico): - * SMTP_HOST, SMTP_PORT(=587), SMTP_USER?, SMTP_PASS?, SMTP_SECURE("true"/"false"), MAIL_FROM - * DEV: Mailpit su localhost:1025 (nessuna auth) → email visibili su http://localhost:18025 - * PROD: provider reale (Resend/Postmark/SMTP) — vedi .env.example. + * Transport via env: SMTP_HOST, SMTP_PORT(=587), SMTP_USER?, SMTP_PASS?, + * SMTP_SECURE("true"/"false"), MAIL_FROM. DEV: Mailpit localhost:1025. */ import nodemailer from "nodemailer"; -import { receiptEmailBody, receiptEmailSubject } from "./recesso.copy"; +import { + renderReceiptHtml, + renderSubject, + type EmailVars, +} from "./emailTemplate"; function buildTransport() { const host = process.env.SMTP_HOST; - if (!host) return null; // email non configurata → invio saltato con errore gestito + if (!host) return null; const port = Number(process.env.SMTP_PORT ?? 587); const user = process.env.SMTP_USER; return nodemailer.createTransport({ @@ -30,12 +33,21 @@ function buildTransport() { }); } -function textToHtml(text: string): string { - const esc = text - .replace(/&/g, "&") - .replace(//g, ">"); - return `
${esc}
`; +/** Versione testo grezza dell'HTML (fallback per client senza HTML). */ +function htmlToText(html: string): string { + return html + .replace(//gi, "") + .replace(/<(?:\/p|\/div|\/tr|\/h1|\/h2|\/li|br\s*\/?)>/gi, "\n") + .replace(/<[^>]+>/g, "") + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/·/g, "·") + .replace(/ /g, " ") + .replace(/[ \t]+\n/g, "\n") + .replace(/\n{3,}/g, "\n\n") + .trim(); } export type ReceiptResult = @@ -43,30 +55,25 @@ export type ReceiptResult = | { ok: false; error: string }; /** - * Invia la ricevuta. Non lancia mai: cattura internamente e ritorna esito. - * `transmittedAt` deve essere già la stringa leggibile (Europe/Rome). + * Invia la ricevuta. Non lancia mai: cattura e ritorna l'esito. + * `subjectTemplate`/`bodyTemplate` dai Settings (null = default). `vars` sono i + * valori reali (shop/ordine/cliente), gia' pronti dal chiamante. */ export async function sendWithdrawalReceipt(params: { to: string; - orderName: string; - customerName: string; - statementText: string; - transmittedAt: string; - shopName: string; + vars: EmailVars; + subject?: string | null; + intro?: string | null; + note?: string | null; }): Promise { const transport = buildTransport(); if (!transport) { return { ok: false, error: "SMTP non configurato (SMTP_HOST mancante)" }; } - const subject = receiptEmailSubject(params.orderName); - const text = receiptEmailBody({ - customerName: params.customerName, - orderName: params.orderName, - transmittedAt: params.transmittedAt, - statementText: params.statementText, - shopName: params.shopName, - }); + const subject = renderSubject(params.subject, params.vars); + const html = renderReceiptHtml(params.vars, params.intro, params.note); + const text = htmlToText(html); try { const info = await transport.sendMail({ @@ -74,7 +81,7 @@ export async function sendWithdrawalReceipt(params: { to: params.to, subject, text, - html: textToHtml(text), + html, }); return { ok: true, messageId: info.messageId }; } catch (e) { diff --git a/app/app/lib/recesso.copy.ts b/app/app/lib/recesso.copy.ts index 1619dba..4e7cdf9 100644 --- a/app/app/lib/recesso.copy.ts +++ b/app/app/lib/recesso.copy.ts @@ -1,75 +1,68 @@ /** - * Copy deck IT — stringhe ESATTE da SPEC-MVP-RECESSO.md §2. - * - * Non modificare i testi: sono vincolanti (contratto di build) e usati sia dal - * flusso storefront (route proxy) sia — per il template email — da A4. - * Placeholder con {{doppie graffe}} vengono sostituiti a runtime. - * - * i18n multi-lingua = fase A7. In MVP tutto IT hardcoded. + * Copy deck IT. Stringhe visibili del flusso di recesso + template email. + * Testi legali vincolanti (non modificare senza verifica). Placeholder {{...}}. + * i18n multi-lingua = fase A7. */ -// §2.1 — Etichetta pulsante di avvio (usata dalla Theme App Extension / A2). +// Etichetta pulsante di avvio (Theme App Extension / A2). Dicitura di legge. export const BUTTON_LABEL = "Recedere dal contratto qui"; -// §2.2 — Etichetta funzione di conferma (step 3), funzione dedicata anti-dark-pattern. +// Etichetta funzione di conferma (step 3). Funzione dedicata, non checkbox. export const CONFIRM_LABEL = "Conferma recesso"; -// §2.3 — Label dei campi del form + placeholder/hint. +// Titolo del flusso. +export const PAGE_TITLE = "Recesso dal contratto"; + +// Label dei campi + placeholder. export const FIELD = { name: { label: "Nome e cognome", placeholder: "Mario Rossi" }, - orderName: { label: "Numero dell'ordine", placeholder: "es. #1234" }, - email: { label: "Email", placeholder: "La tua email dell'ordine" }, + orderName: { label: "Numero dell'ordine", placeholder: "es. 1234" }, + email: { label: "Email", placeholder: "La tua email" }, statement: { label: "Dichiarazione di recesso" }, } as const; -// §2.3 — Testo precompilato (editabile) della dichiarazione (deriva dall'Allegato I-B). +// 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}.`; } -// §2.4 — Testo informativo sul diritto di recesso (Art. 49), mostrato PRIMA dell'invio. -export const ART49_INFO = - "Hai diritto di recedere da questo contratto entro 14 giorni senza dover fornire alcuna motivazione.\n" + - "Il termine decorre dalla consegna del bene (o dalla conclusione del contratto per i servizi).\n" + - "Compilando e confermando questo modulo eserciti il recesso: riceverai via email una ricevuta con\n" + - "la data e l'ora di trasmissione della tua dichiarazione."; +// 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"; +export const INFO_BODY = + "Hai 14 giorni per recedere da questo contratto senza dover fornire una motivazione. Il termine decorre dalla consegna del bene (o dalla conclusione del contratto per i servizi).\n\n" + + "Confermando il modulo eserciti il recesso: riceverai via email una ricevuta con la data e l'ora di trasmissione.\n\n" + + "Questa funzione è aggiuntiva: puoi comunque usare il modulo tipo (Allegato I, parte B) o inviare una dichiarazione via email."; -// §2.9 — Nota di coesistenza (il pulsante è aggiuntivo, non sostitutivo). -export const COEXISTENCE_NOTE = - "Questa funzione è un modo aggiuntivo per esercitare il recesso. Puoi comunque usare il modulo\n" + - "tipo (Allegato I, parte B) o inviare qualsiasi dichiarazione esplicita, anche via email."; - -// §2.7 — Messaggi di errore. +// Messaggi di errore. export const ERROR = { - // Anti-enumeration: ordine inesistente ED email non combaciante DEVONO - // mostrare lo stesso identico messaggio (stesso testo, stesso status 200). + // Anti-enumeration: ordine inesistente ed email non combaciante mostrano lo + // stesso messaggio, stesso testo e stesso status 200. lookupNoMatch: - "Non abbiamo trovato un ordine con questi dati. Verifica il numero dell'ordine e l'email usata per l'acquisto.", - missingField: "Compila tutti i campi obbligatori per continuare.", + "Non abbiamo trovato un ordine con questi dati. Controlla il numero dell'ordine e l'email dell'acquisto.", + missingField: "Compila tutti i campi per continuare.", invalidEmail: "Inserisci un indirizzo email valido.", windowClosed: - "Il termine di 14 giorni per il recesso su questo ordine è terminato. Puoi comunque contattarci per altre richieste.", - generic: - "Si è verificato un problema. Riprova tra qualche istante; se persiste, contattaci.", + "Il termine di 14 giorni per il recesso su questo ordine è terminato. Contattaci per altre richieste.", + generic: "Si è verificato un problema. Riprova tra poco.", } as const; -// §2.6 — Schermata finale (dopo "Conferma recesso"). +// Schermata finale (dopo "Conferma recesso"). export function successMessage( orderName: string, transmittedAt: string, email: string, ): { line1: string; line2: string; line3: string } { return { - line1: "Recesso trasmesso correttamente.", - line2: `Abbiamo registrato la tua dichiarazione di recesso per l'ordine ${orderName} in data ${transmittedAt}.`, - line3: `Ti abbiamo inviato una ricevuta all'indirizzo ${email}.`, + line1: "Recesso trasmesso", + line2: `Registrato per l'ordine ${orderName} il ${transmittedAt}.`, + line3: `Ti abbiamo inviato una ricevuta a ${email}.`, }; } -// §2.5 — Template email ricevuta su supporto durevole. -// Usato da A4 (invio reale). In MVP il flusso lascia receiptSentAt = null. +// Template email ricevuta su supporto durevole (usato da A4). export function receiptEmailSubject(orderName: string): string { - return `Ricevuta della tua richiesta di recesso — Ordine ${orderName}`; + return `Ricevuta della tua richiesta di recesso - Ordine ${orderName}`; } export function receiptEmailBody(params: { @@ -83,8 +76,7 @@ export function receiptEmailBody(params: { params; return `Gentile ${customerName}, -confermiamo di aver ricevuto la Sua dichiarazione di recesso relativa all'ordine ${orderName}, -trasmessa tramite la funzione di recesso presente sul nostro sito. +confermiamo di aver ricevuto la Sua dichiarazione di recesso relativa all'ordine ${orderName}, trasmessa tramite la funzione di recesso presente sul nostro sito. Dettagli della richiesta: - Data e ora di trasmissione: ${transmittedAt} @@ -94,20 +86,16 @@ Dettagli della richiesta: Testo integrale della dichiarazione di recesso trasmessa: "${statementText}" -Questa comunicazione costituisce la ricevuta su supporto durevole della Sua dichiarazione di -recesso, ai sensi dell'art. 54-bis del Codice del Consumo. La data e l'ora sopra indicate -attestano il momento della trasmissione della dichiarazione. +Questa comunicazione costituisce la ricevuta su supporto durevole della Sua dichiarazione di recesso, ai sensi dell'art. 54-bis del Codice del Consumo. La data e l'ora sopra indicate attestano il momento della trasmissione della dichiarazione. Le invieremo separatamente le istruzioni per l'eventuale restituzione dei beni e i tempi di rimborso. -Restano comunque validi anche gli altri mezzi per esercitare il recesso (modulo tipo di cui -all'Allegato I, parte B, o qualsiasi altra dichiarazione esplicita, anche via email): questa -funzione è aggiuntiva e non sostituisce tali strumenti. +Restano validi anche gli altri mezzi per esercitare il recesso (modulo tipo di cui all'Allegato I, parte B, o qualsiasi altra dichiarazione esplicita, anche via email): questa funzione è aggiuntiva e non li sostituisce. ${shopName}`; } -// §2.8 — Messaggio prodotto escluso (Art. 59). Copy MVP-ready; la LOGICA è in A6. +// Messaggio prodotto escluso (Art. 59). Copy pronto; la LOGICA è in A6. export const EXCLUSION_REASON = { CUSTOM: "prodotto realizzato su misura o personalizzato", PERISHABLE: "prodotto deperibile o a rapida scadenza", @@ -118,6 +106,3 @@ export const EXCLUSION_REASON = { export function exclusionMessage(motivoEsclusione: string): string { return `Per questo prodotto il diritto di recesso non è previsto (${motivoEsclusione}, ai sensi dell'art. 59 del Codice del Consumo). Per informazioni o altre richieste, contattaci.`; } - -// Titolo pagina / intestazione del flusso (non normato: label neutra, coerente col verbo di legge). -export const PAGE_TITLE = "Recesso dal contratto"; diff --git a/app/app/lib/recesso.server.ts b/app/app/lib/recesso.server.ts index 286020a..8acd703 100644 --- a/app/app/lib/recesso.server.ts +++ b/app/app/lib/recesso.server.ts @@ -15,7 +15,7 @@ import { createHash } from "node:crypto"; import type { AdminApiContext } from "@shopify/shopify-app-remix/server"; -import { FIELD, ART49_INFO, COEXISTENCE_NOTE, CONFIRM_LABEL, PAGE_TITLE } from "./recesso.copy"; +import { FIELD, INFO_TITLE, INFO_BODY, CONFIRM_LABEL, PAGE_TITLE } from "./recesso.copy"; // --------------------------------------------------------------------------- // Costanti path storefront (prefix "apps" + subpath "recesso" da shopify.app.toml). @@ -86,7 +86,7 @@ export function clientIp(request: Request): string { } // --------------------------------------------------------------------------- -// Rate-limit base (in-memory, single-instance) — hardening -> A9. +// Rate-limit base (in-memory, single-instance) - hardening -> A9. // Finestra fissa per chiave shop+IP. Predisposto per la verifica avversariale A9. // TODO(A9): sostituire con store condiviso (Redis/DB) per il deploy multi-istanza, // aggiungere backoff/captcha oltre soglia e risposte a tempo costante @@ -121,6 +121,7 @@ export interface MatchedOrder { orderName: string; // es. "#1001" email: string; // email dell'ordine (per precompilazione) createdAt: string; + orderUrl: string; // URL pagina di stato dell'ordine (link per il cliente) } interface OrderLookupGraphQL { @@ -132,6 +133,7 @@ interface OrderLookupGraphQL { name?: string | null; email?: string | null; createdAt?: string | null; + statusPageUrl?: string | null; } | null; } | null> | null; } | null; @@ -147,6 +149,7 @@ const ORDER_LOOKUP_QUERY = `#graphql name email createdAt + statusPageUrl } } } @@ -166,6 +169,46 @@ const ORDER_LOOKUP_QUERY = `#graphql * Multi-tenant: `admin` proviene dalla sessione App Proxy, quindi la query è * già scoped allo shop corretto; non ci fidiamo mai dello shop lato client. */ +export interface ShopInfo { + name: string; + contactEmail: string; + url: string; +} +interface ShopInfoGraphQL { + data?: { + shop?: { + name?: string | null; + contactEmail?: string | null; + primaryDomain?: { url?: string | null } | null; + } | null; + } | null; +} +const SHOP_INFO_QUERY = `#graphql + query recessoShopInfo { + shop { + name + primaryDomain { + url + } + } + }`; + +/** Dati del negozio per l'email (nome, URL, email contatto). Fallback vuoto su errore. */ +export async function getShopInfo(admin: AdminApiContext): Promise { + try { + const res = await admin.graphql(SHOP_INFO_QUERY); + const body = (await res.json()) as ShopInfoGraphQL; + const s = body.data?.shop; + return { + name: s?.name?.trim() ?? "", + contactEmail: "", + url: s?.primaryDomain?.url?.trim() ?? "", + }; + } catch { + return { name: "", contactEmail: "", url: "" }; + } +} + export async function lookupOrder( admin: AdminApiContext, orderInput: string, @@ -202,6 +245,7 @@ export async function lookupOrder( orderName: node.name, email: node.email ?? emailInput.trim(), createdAt: node.createdAt ?? "", + orderUrl: node.statusPageUrl ?? "", }; } } @@ -209,7 +253,7 @@ export async function lookupOrder( } // --------------------------------------------------------------------------- -// Rendering HTML — documento standalone, servito sul dominio storefront. +// Rendering HTML - documento standalone, servito sul dominio storefront. // Niente Polaris, niente root layout admin: solo HTML+CSS inline accessibile. // --------------------------------------------------------------------------- @@ -270,6 +314,40 @@ const PAGE_CSS = ` .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; } @@ -277,11 +355,27 @@ const PAGE_CSS = ` .muted:last-of-type { margin-bottom: 0; } /* Indicatore di step (discreto, non dark-pattern) */ - .stepper { display: flex; align-items: center; gap: 10px; margin: 0 0 22px; } - .stepper__track { display: inline-flex; gap: 5px; } - .stepper__seg { width: 28px; height: 4px; border-radius: 999px; background: var(--border); transition: background 0.15s ease; } - .stepper__seg.is-active, .stepper__seg.is-done { background: var(--accent); } - .stepper__label { font-size: 0.72rem; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; color: var(--text-muted); } + .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); } @@ -411,12 +505,12 @@ const PAGE_CSS = ` /* Successo (step 4) */ .success { text-align: center; padding: 6px 0 2px; } .success__icon { - width: 60px; height: 60px; margin: 0 auto 18px; + 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: 32px; height: 32px; fill: var(--success); } + .success__icon svg { width: 24px; height: 24px; fill: var(--success); } .success h1 { color: var(--success); } .success p { color: var(--text-muted); } @@ -470,6 +564,7 @@ export function renderShell(inner: string): string { +
${inner} @@ -487,8 +582,24 @@ function errorBanner(message?: string): string {
`; } -function coexistenceBlock(): string { - return `
${escapeHtml(COEXISTENCE_NOTE)}
`; +/** + * 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 ` +`; +} + +/** Header comune: step + titolo con pulsante info. */ +function stepHead(step: number, subtitle?: string): string { + return `${stepIndicator(step)} +

${escapeHtml(PAGE_TITLE)}

${infoWidget("rcinfo", INFO_TITLE, INFO_BODY)}
${ + subtitle ? `\n

${escapeHtml(subtitle)}

` : "" + }`; } /** @@ -503,22 +614,31 @@ function stepIndicator(current: number): string { 3: "Conferma", 4: "Fatto", }; - const segs = [1, 2, 3] - .map((i) => { - const cls = - current >= 4 || current > i - ? "stepper__seg is-done" - : current === i - ? "stepper__seg is-active" - : "stepper__seg"; - return ``; - }) - .join(""); const label = labels[current] ?? ""; - return `
- - ${escapeHtml(label)} -
`; + return `

${escapeHtml(label)}

`; +} + +/** + * 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 `
+${parts.head} +
+
+${parts.body} +
${ + parts.foot + ? ` +
+${parts.foot} +
` + : "" + }`; } // --- Step 1: lookup guest ------------------------------------------------- @@ -529,21 +649,21 @@ export function renderStep1(opts?: { }): string { const orderName = opts?.orderName ?? ""; const email = opts?.email ?? ""; - return renderShell(` -${stepIndicator(1)} -

${escapeHtml(PAGE_TITLE)}

-

Inserisci il numero dell'ordine e l'email usata per l'acquisto per iniziare. Non è necessario alcun account.

+ return renderShell( + stepLayout({ + head: stepHead(1), + body: `

Inserisci numero dell'ordine ed email dell'acquisto. Non serve un account.

${errorBanner(opts?.error)} -
+ - -
-${coexistenceBlock()} -`); +`, + foot: ``, + }), + ); } // --- Step 2: form dati + dichiarazione ------------------------------------ @@ -556,13 +676,11 @@ export function renderStep2(data: { error?: string; }): string { const customerName = data.customerName ?? ""; - return renderShell(` -${stepIndicator(2)} -

${escapeHtml(PAGE_TITLE)}

-

Ordine ${escapeHtml(data.orderName)}

-
${escapeHtml(ART49_INFO)}
-${errorBanner(data.error)} -
+ return renderShell( + stepLayout({ + head: stepHead(2, `Ordine ${data.orderName}`), + body: `${errorBanner(data.error)} + @@ -572,18 +690,14 @@ ${errorBanner(data.error)} -

Puoi modificare il testo della dichiarazione se lo desideri.

-
- - -

Qui riceverai la ricevuta del recesso. Già preso dal tuo ordine — puoi modificarlo.

-
- - -
-${coexistenceBlock()} -`); + + +

Ti invieremo qui la ricevuta.

+`, + foot: ``, + }), + ); } // --- Step 3: riepilogo + conferma dedicata -------------------------------- @@ -595,11 +709,10 @@ export function renderStep3(data: { statementText: string; error?: string; }): string { - return renderShell(` -${stepIndicator(3)} -

${escapeHtml(PAGE_TITLE)}

-

Controlla i dati. Il recesso sarà trasmesso solo quando premi «${escapeHtml(CONFIRM_LABEL)}».

-${errorBanner(data.error)} + return renderShell( + stepLayout({ + head: stepHead(3, "Controlla i dati prima di confermare."), + body: `${errorBanner(data.error)}
${escapeHtml(FIELD.orderName.label)}
${escapeHtml(data.orderName)}
@@ -610,30 +723,28 @@ ${errorBanner(data.error)}
${escapeHtml(FIELD.statement.label)}
${escapeHtml(data.statementText)}
- -
-
- - - - - - - -
- -
- - - - - - - -
-
-${coexistenceBlock()} -`); +
+ + + + + + +
+
+ + + + + + +
`, + foot: `
+ + +
`, + }), + ); } // --- Step 4: successo ----------------------------------------------------- @@ -642,16 +753,17 @@ export function renderStep4(data: { line2: string; line3: string; }): string { - return renderShell(` -${stepIndicator(4)} -
+ return renderShell( + stepLayout({ + head: `${stepIndicator(4)}`, + body: `

${escapeHtml(data.line1)}

${escapeHtml(data.line2)}

${escapeHtml(data.line3)}

-
-${coexistenceBlock()} -`); +
`, + }), + ); } /** Helper: Response HTML standalone (status 200 di default per non leakare via status). */ diff --git a/app/app/routes/app.settings.tsx b/app/app/routes/app.settings.tsx new file mode 100644 index 0000000..16aa286 --- /dev/null +++ b/app/app/routes/app.settings.tsx @@ -0,0 +1,202 @@ +import { useEffect, useMemo, useState } from "react"; +import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node"; +import { + useActionData, + useLoaderData, + useNavigation, + useSubmit, +} from "@remix-run/react"; +import { + Page, + Layout, + Card, + TextField, + Button, + ButtonGroup, + Banner, + Text, + BlockStack, + InlineStack, + Badge, +} from "@shopify/polaris"; +import { TitleBar } from "@shopify/app-bridge-react"; + +import { authenticate } from "../shopify.server"; +import db from "../db.server"; +import { + DEFAULT_INTRO, + DEFAULT_NOTE, + DEFAULT_SUBJECT, + SAMPLE_VARS, + TEXT_PLACEHOLDERS, + renderReceiptHtml, + renderSubject, +} from "../lib/emailTemplate"; + +export const loader = async ({ request }: LoaderFunctionArgs) => { + const { session } = await authenticate.admin(request); + const settings = await db.settings.findUnique({ where: { shop: session.shop } }); + return { + subject: settings?.emailSubject ?? DEFAULT_SUBJECT, + intro: settings?.emailIntro ?? DEFAULT_INTRO, + note: settings?.emailNote ?? DEFAULT_NOTE, + }; +}; + +export const action = async ({ request }: ActionFunctionArgs) => { + const { session } = await authenticate.admin(request); + const form = await request.formData(); + const subject = String(form.get("subject") ?? "").trim(); + const intro = String(form.get("intro") ?? "").trim(); + const note = String(form.get("note") ?? "").trim(); + + await db.settings.upsert({ + where: { shop: session.shop }, + create: { + shop: session.shop, + emailSubject: subject && subject !== DEFAULT_SUBJECT ? subject : null, + emailIntro: intro && intro !== DEFAULT_INTRO ? intro : null, + emailNote: note || null, + }, + update: { + emailSubject: subject && subject !== DEFAULT_SUBJECT ? subject : null, + emailIntro: intro && intro !== DEFAULT_INTRO ? intro : null, + emailNote: note || null, + }, + }); + + return { ok: true }; +}; + +export default function SettingsPage() { + const data = useLoaderData(); + const actionData = useActionData(); + const nav = useNavigation(); + const submit = useSubmit(); + + const [subject, setSubject] = useState(data.subject); + const [intro, setIntro] = useState(data.intro); + const [note, setNote] = useState(data.note); + const [showSaved, setShowSaved] = useState(false); + + const saving = nav.state === "submitting"; + + useEffect(() => { + if (actionData?.ok) setShowSaved(true); + }, [actionData]); + + const previewSubject = useMemo( + () => renderSubject(subject, SAMPLE_VARS), + [subject], + ); + const previewHtml = useMemo( + () => renderReceiptHtml(SAMPLE_VARS, intro, note), + [intro, note], + ); + + const handleSave = () => { + setShowSaved(false); + const fd = new FormData(); + fd.set("subject", subject); + fd.set("intro", intro); + fd.set("note", note); + submit(fd, { method: "post" }); + }; + + const handleReset = () => { + setSubject(DEFAULT_SUBJECT); + setIntro(DEFAULT_INTRO); + setNote(DEFAULT_NOTE); + setShowSaved(false); + }; + + return ( + + + + + + {showSaved ? ( + setShowSaved(false)}> + Salvato. + + ) : null} + + + + + + Testi dell'email + + + Personalizza oggetto e testi. Il resto (dettagli ordine, + dichiarazione, data/ora, avviso di legge, layout) è fisso e + sempre conforme. Segnaposto disponibili: + + + {TEXT_PLACEHOLDERS.map((p) => ( + {`{{${p}}}`} + ))} + + + + + + + + + + + + + + + + + + Anteprima + + + Oggetto: {previewSubject} + + ' + + ""; + o.addEventListener("click", function (e) { + if (e.target === o) close(); + }); + o.querySelector(".recesso-modal-close").addEventListener("click", close); + return o; + } + + function onKey(e) { + if (e.key === "Escape" || e.key === "Esc") close(); + } + + function open(url) { + lastFocus = document.activeElement; + if (!overlay) { + overlay = build(); + document.body.appendChild(overlay); + } + overlay.querySelector(".recesso-modal-frame").src = url; + overlay.classList.add("is-open"); + document.documentElement.style.overflow = "hidden"; + document.addEventListener("keydown", onKey); + var btn = overlay.querySelector(".recesso-modal-close"); + if (btn && btn.focus) btn.focus(); + } + + function close() { + if (!overlay) return; + overlay.classList.remove("is-open"); + overlay.querySelector(".recesso-modal-frame").src = "about:blank"; + document.documentElement.style.overflow = ""; + document.removeEventListener("keydown", onKey); + if (lastFocus && lastFocus.focus) lastFocus.focus(); + } + + function normalizePath(p) { + return (p || "").replace(/\/+$/, ""); + } + + // Click delegato: intercetta ogni che punta a /apps/recesso (path match), + // salvo opt-out esplicito. Funziona per blocchi app, app embed e link/menu + // creati dal merchant. Solo click primario, senza modificatori/target. + document.addEventListener("click", function (e) { + if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return; + var a = e.target.closest ? e.target.closest("a[href]") : null; + if (!a || a.hasAttribute("data-recesso-no-modal")) return; + var path; + try { + path = normalizePath(new URL(a.href, window.location.origin).pathname); + } catch (_) { + return; + } + if (path !== PROXY_PATH) return; + e.preventDefault(); + open(a.getAttribute("href") || PROXY_PATH); + }); +})(); diff --git a/app/extensions/recesso-storefront/blocks/withdrawal_embed.liquid b/app/extensions/recesso-storefront/blocks/withdrawal_embed.liquid new file mode 100644 index 0000000..98bb4e7 --- /dev/null +++ b/app/extensions/recesso-storefront/blocks/withdrawal_embed.liquid @@ -0,0 +1,56 @@ +{%- comment -%} + App embed "Recesso - link globale". Attivabile site-wide dal theme editor + (Impostazioni tema → App embed). Carica CSS+JS su TUTTE le pagine: questo abilita + il modal su QUALSIASI link a /apps/recesso (anche voci di menu o testi linkati + dal merchant). In più, opzionalmente, mostra un proprio link discreto a fondo pagina. + CSS/JS in assets/. +{%- endcomment -%} + +{{ 'recesso-storefront.css' | asset_url | stylesheet_tag }} +{{ 'recesso-storefront.js' | asset_url | script_tag }} + +{% if block.settings.show_link %} + +{% endif %} + +{% schema %} +{ + "name": "Recesso - link globale", + "target": "body", + "settings": [ + { + "type": "paragraph", + "content": "Attivando questo embed, qualsiasi link a /apps/recesso nel tuo tema (voci di menu, testi linkati, ecc.) si apre in un popup. Puoi anche mostrare un link discreto a fondo pagina." + }, + { + "type": "checkbox", + "id": "show_link", + "label": "Mostra anche un link a fondo pagina", + "default": true + }, + { + "type": "text", + "id": "label", + "label": "Testo del link", + "info": "Per legge deve essere inequivocabile.", + "default": "Recedere dal contratto qui" + }, + { + "type": "select", + "id": "alignment", + "label": "Allineamento", + "options": [ + { "value": "left", "label": "Sinistra" }, + { "value": "center", "label": "Centro" }, + { "value": "right", "label": "Destra" } + ], + "default": "center" + } + ] +} +{% endschema %} diff --git a/app/extensions/recesso-storefront/blocks/withdrawal_link.liquid b/app/extensions/recesso-storefront/blocks/withdrawal_link.liquid new file mode 100644 index 0000000..f592b18 --- /dev/null +++ b/app/extensions/recesso-storefront/blocks/withdrawal_link.liquid @@ -0,0 +1,87 @@ +{%- comment -%} + App block "Recesso - pulsante". Il merchant lo aggiunge in QUALSIASI sezione + dal theme editor e lo personalizza. Linka al flusso via App Proxy /apps/recesso. + Se "Apri in un popup" è attivo, il JS apre un modal con iframe sul flusso + (fallback: pagina piena se JS off). CSS/JS in assets/ (le app block non + ammettono i tag stylesheet/javascript inline). +{%- endcomment -%} + +{%- 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 %} + + + +{% schema %} +{ + "name": "Recesso - pulsante", + "target": "section", + "settings": [ + { + "type": "text", + "id": "label", + "label": "Testo del link", + "info": "Per legge deve essere inequivocabile (es. \"Recedere dal contratto qui\").", + "default": "Recedere dal contratto qui" + }, + { + "type": "select", + "id": "style", + "label": "Stile", + "options": [ + { "value": "link", "label": "Link" }, + { "value": "button", "label": "Bottone" } + ], + "default": "button" + }, + { + "type": "select", + "id": "alignment", + "label": "Allineamento", + "options": [ + { "value": "left", "label": "Sinistra" }, + { "value": "center", "label": "Centro" }, + { "value": "right", "label": "Destra" } + ], + "default": "left" + }, + { + "type": "checkbox", + "id": "use_modal", + "label": "Apri in un popup (modal)", + "info": "Se disattivo, apre la pagina intera del flusso.", + "default": true + }, + { + "type": "checkbox", + "id": "open_new_tab", + "label": "Apri in una nuova scheda", + "info": "Solo se il popup è disattivo.", + "default": false + }, + { + "type": "header", + "content": "Colori (solo stile Bottone)" + }, + { + "type": "color", + "id": "button_bg", + "label": "Sfondo bottone" + }, + { + "type": "color", + "id": "button_text", + "label": "Testo bottone" + } + ] +} +{% endschema %} diff --git a/app/extensions/recesso-storefront/shopify.extension.toml b/app/extensions/recesso-storefront/shopify.extension.toml new file mode 100644 index 0000000..5bcbb6c --- /dev/null +++ b/app/extensions/recesso-storefront/shopify.extension.toml @@ -0,0 +1,3 @@ +name = "recesso-storefront" +type = "theme" +uid = "32fd4d4f-a884-a3be-0335-9696fbc24efd646eafe9" diff --git a/app/prisma/migrations/20260707092205_email_settings/migration.sql b/app/prisma/migrations/20260707092205_email_settings/migration.sql new file mode 100644 index 0000000..92588f2 --- /dev/null +++ b/app/prisma/migrations/20260707092205_email_settings/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "Settings" ADD COLUMN "emailCustomMessage" TEXT, +ADD COLUMN "emailSenderName" TEXT; diff --git a/app/prisma/migrations/20260707093246_email_logo/migration.sql b/app/prisma/migrations/20260707093246_email_logo/migration.sql new file mode 100644 index 0000000..6c710b6 --- /dev/null +++ b/app/prisma/migrations/20260707093246_email_logo/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Settings" ADD COLUMN "emailLogoUrl" TEXT; diff --git a/app/prisma/migrations/20260707094749_remove_email_logo/migration.sql b/app/prisma/migrations/20260707094749_remove_email_logo/migration.sql new file mode 100644 index 0000000..c34cf93 --- /dev/null +++ b/app/prisma/migrations/20260707094749_remove_email_logo/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - You are about to drop the column `emailLogoUrl` on the `Settings` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "Settings" DROP COLUMN "emailLogoUrl"; diff --git a/app/prisma/migrations/20260707095341_email_templates/migration.sql b/app/prisma/migrations/20260707095341_email_templates/migration.sql new file mode 100644 index 0000000..0e333aa --- /dev/null +++ b/app/prisma/migrations/20260707095341_email_templates/migration.sql @@ -0,0 +1,12 @@ +/* + Warnings: + + - You are about to drop the column `emailCustomMessage` on the `Settings` table. All the data in the column will be lost. + - You are about to drop the column `emailSenderName` on the `Settings` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "Settings" DROP COLUMN "emailCustomMessage", +DROP COLUMN "emailSenderName", +ADD COLUMN "emailBodyTemplate" TEXT, +ADD COLUMN "emailSubjectTemplate" TEXT; diff --git a/app/prisma/migrations/20260707100338_email_text_fields/migration.sql b/app/prisma/migrations/20260707100338_email_text_fields/migration.sql new file mode 100644 index 0000000..9ccb47a --- /dev/null +++ b/app/prisma/migrations/20260707100338_email_text_fields/migration.sql @@ -0,0 +1,13 @@ +/* + Warnings: + + - You are about to drop the column `emailBodyTemplate` on the `Settings` table. All the data in the column will be lost. + - You are about to drop the column `emailSubjectTemplate` on the `Settings` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "Settings" DROP COLUMN "emailBodyTemplate", +DROP COLUMN "emailSubjectTemplate", +ADD COLUMN "emailIntro" TEXT, +ADD COLUMN "emailNote" TEXT, +ADD COLUMN "emailSubject" TEXT; diff --git a/app/prisma/schema.prisma b/app/prisma/schema.prisma index db9eb74..a2b8f20 100644 --- a/app/prisma/schema.prisma +++ b/app/prisma/schema.prisma @@ -49,6 +49,9 @@ model Settings { returnAddress String? defaultWindowDays Int @default(14) withdrawalInfoText String? + emailSubject String? + emailIntro String? + emailNote String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt