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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mv83a29B4eFv5ixoj6PoE1
This commit is contained in:
2026-07-07 12:17:07 +02:00
parent 8ca4de6c0b
commit d140d90629
18 changed files with 1002 additions and 178 deletions

View File

@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function nl2br(s: string): string {
return s.replace(/\n/g, "<br>");
}
/** 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<string, string>)[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<string, string>)[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
? `<tr><td style="padding:4px 32px 8px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tr><td style="padding:14px 16px;background:#f6f6f7;border-radius:8px;font-size:14px;line-height:1.6;color:#444;">${noteVal}</td></tr></table>
</td></tr>`
: "";
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"></head>
<body style="margin:0;padding:0;background:#f4f4f5;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f4f4f5;">
<tr><td align="center" style="padding:24px 12px;">
<table role="presentation" width="560" cellpadding="0" cellspacing="0" style="width:100%;max-width:560px;background:#ffffff;border:1px solid #e5e5e5;border-radius:12px;overflow:hidden;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;">
<tr><td style="height:4px;line-height:4px;font-size:0;background:#1a1a1a;">&nbsp;</td></tr>
<tr><td style="padding:22px 32px 18px;border-bottom:1px solid #ececec;">
<a href="${escHtml(vars.shopUrl)}" style="text-decoration:none;color:#1a1a1a;font-size:17px;font-weight:600;">${escHtml(vars.shopName)}</a>
</td></tr>
<tr><td style="padding:26px 32px 6px;">
<h1 style="margin:0 0 14px;font-size:20px;line-height:1.3;color:#1a1a1a;">Ricevuta della richiesta di recesso</h1>
<p style="margin:0;font-size:15px;line-height:1.6;color:#3a3a3a;">${intro}</p>
</td></tr>
<tr><td style="padding:16px 32px 6px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f6f6f7;border-radius:8px;">
<tr><td style="padding:14px 16px;font-size:14px;line-height:1.9;color:#444;">
<div><span style="color:#777;">Data e ora di trasmissione:</span> <strong>${escHtml(vars.transmittedAt)}</strong></div>
<div><span style="color:#777;">Ordine:</span> <strong>${escHtml(vars.orderName)}</strong></div>
<div><span style="color:#777;">Nome:</span> <strong>${escHtml(vars.customerName)}</strong></div>
</td></tr></table>
</td></tr>
<tr><td style="padding:14px 32px 6px;">
<div style="font-size:13px;font-weight:600;color:#555;margin-bottom:6px;">Dichiarazione trasmessa</div>
<div style="border-left:3px solid #d9d9d9;padding:8px 14px;font-size:14px;line-height:1.6;color:#555;font-style:italic;">${escHtml(vars.statementText)}</div>
</td></tr>
<tr><td style="padding:8px 32px 4px;">
<a href="${escHtml(vars.orderUrl)}" style="display:inline-block;padding:10px 18px;background:#1a1a1a;color:#ffffff;text-decoration:none;border-radius:8px;font-size:14px;font-weight:600;">Vedi il tuo ordine</a>
</td></tr>
<tr><td style="padding:12px 32px 4px;">
<p style="margin:0;font-size:12.5px;line-height:1.6;color:#8a8a8a;">Questa comunicazione costituisce la ricevuta su supporto durevole ai sensi dell'art. 54-bis del Codice del Consumo. La data e l'ora indicate attestano il momento della trasmissione.</p>
</td></tr>
${note}
<tr><td style="padding:18px 32px 24px;border-top:1px solid #ececec;">
<p style="margin:0 0 8px;font-size:12.5px;line-height:1.6;color:#999;">Ti invieremo separatamente le istruzioni per l'eventuale reso e i tempi di rimborso.</p>
<p style="margin:0 0 10px;font-size:12px;line-height:1.6;color:#aaa;">Questa funzione e' aggiuntiva: restano validi il modulo tipo (Allegato I, parte B) e la dichiarazione via email.</p>
<p style="margin:0;font-size:12px;line-height:1.6;color:#999;"><a href="${escHtml(vars.shopUrl)}" style="color:#999;text-decoration:underline;">${escHtml(vars.shopName)}</a></p>
</td></tr>
</table>
</td></tr></table>
</body></html>`;
}
/** 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.",
};

View File

@@ -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 * Il corpo/oggetto sono TEMPLATE editabili dal merchant (HTML + segnaposto), con
* elementi di legge (dichiarazione integrale, id ordine, data/ora di TRASMISSIONE, * default in ./emailTemplate. Qui sostituiamo i segnaposto coi valori reali
* nome consumatore). Il testo viene dal copy deck (recesso.copy §2.5). * (dinamici da Shopify) e inviamo. La validazione compliance (segnaposto
* obbligatori) avviene al salvataggio lato admin.
* *
* Transport configurato via env (provider-agnostico): * Transport via env: SMTP_HOST, SMTP_PORT(=587), SMTP_USER?, SMTP_PASS?,
* SMTP_HOST, SMTP_PORT(=587), SMTP_USER?, SMTP_PASS?, SMTP_SECURE("true"/"false"), MAIL_FROM * SMTP_SECURE("true"/"false"), MAIL_FROM. DEV: Mailpit localhost:1025.
* DEV: Mailpit su localhost:1025 (nessuna auth) → email visibili su http://localhost:18025
* PROD: provider reale (Resend/Postmark/SMTP) — vedi .env.example.
*/ */
import nodemailer from "nodemailer"; import nodemailer from "nodemailer";
import { receiptEmailBody, receiptEmailSubject } from "./recesso.copy"; import {
renderReceiptHtml,
renderSubject,
type EmailVars,
} from "./emailTemplate";
function buildTransport() { function buildTransport() {
const host = process.env.SMTP_HOST; 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 port = Number(process.env.SMTP_PORT ?? 587);
const user = process.env.SMTP_USER; const user = process.env.SMTP_USER;
return nodemailer.createTransport({ return nodemailer.createTransport({
@@ -30,12 +33,21 @@ function buildTransport() {
}); });
} }
function textToHtml(text: string): string { /** Versione testo grezza dell'HTML (fallback per client senza HTML). */
const esc = text function htmlToText(html: string): string {
.replace(/&/g, "&amp;") return html
.replace(/</g, "&lt;") .replace(/<style[\s\S]*?<\/style>/gi, "")
.replace(/>/g, "&gt;"); .replace(/<(?:\/p|\/div|\/tr|\/h1|\/h2|\/li|br\s*\/?)>/gi, "\n")
return `<div style="font-family:system-ui,-apple-system,Segoe UI,Arial,sans-serif;font-size:14px;line-height:1.6;color:#111;white-space:pre-wrap">${esc}</div>`; .replace(/<[^>]+>/g, "")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&middot;/g, "·")
.replace(/&nbsp;/g, " ")
.replace(/[ \t]+\n/g, "\n")
.replace(/\n{3,}/g, "\n\n")
.trim();
} }
export type ReceiptResult = export type ReceiptResult =
@@ -43,30 +55,25 @@ export type ReceiptResult =
| { ok: false; error: string }; | { ok: false; error: string };
/** /**
* Invia la ricevuta. Non lancia mai: cattura internamente e ritorna esito. * Invia la ricevuta. Non lancia mai: cattura e ritorna l'esito.
* `transmittedAt` deve essere già la stringa leggibile (Europe/Rome). * `subjectTemplate`/`bodyTemplate` dai Settings (null = default). `vars` sono i
* valori reali (shop/ordine/cliente), gia' pronti dal chiamante.
*/ */
export async function sendWithdrawalReceipt(params: { export async function sendWithdrawalReceipt(params: {
to: string; to: string;
orderName: string; vars: EmailVars;
customerName: string; subject?: string | null;
statementText: string; intro?: string | null;
transmittedAt: string; note?: string | null;
shopName: string;
}): Promise<ReceiptResult> { }): Promise<ReceiptResult> {
const transport = buildTransport(); const transport = buildTransport();
if (!transport) { if (!transport) {
return { ok: false, error: "SMTP non configurato (SMTP_HOST mancante)" }; return { ok: false, error: "SMTP non configurato (SMTP_HOST mancante)" };
} }
const subject = receiptEmailSubject(params.orderName); const subject = renderSubject(params.subject, params.vars);
const text = receiptEmailBody({ const html = renderReceiptHtml(params.vars, params.intro, params.note);
customerName: params.customerName, const text = htmlToText(html);
orderName: params.orderName,
transmittedAt: params.transmittedAt,
statementText: params.statementText,
shopName: params.shopName,
});
try { try {
const info = await transport.sendMail({ const info = await transport.sendMail({
@@ -74,7 +81,7 @@ export async function sendWithdrawalReceipt(params: {
to: params.to, to: params.to,
subject, subject,
text, text,
html: textToHtml(text), html,
}); });
return { ok: true, messageId: info.messageId }; return { ok: true, messageId: info.messageId };
} catch (e) { } catch (e) {

View File

@@ -1,75 +1,68 @@
/** /**
* Copy deck IT — stringhe ESATTE da SPEC-MVP-RECESSO.md §2. * Copy deck IT. Stringhe visibili del flusso di recesso + template email.
* * Testi legali vincolanti (non modificare senza verifica). Placeholder {{...}}.
* Non modificare i testi: sono vincolanti (contratto di build) e usati sia dal * i18n multi-lingua = fase A7.
* flusso storefront (route proxy) sia — per il template email — da A4.
* Placeholder con {{doppie graffe}} vengono sostituiti a runtime.
*
* i18n multi-lingua = fase A7. In MVP tutto IT hardcoded.
*/ */
// §2.1 — Etichetta pulsante di avvio (usata dalla Theme App Extension / A2). // Etichetta pulsante di avvio (Theme App Extension / A2). Dicitura di legge.
export const BUTTON_LABEL = "Recedere dal contratto qui"; 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"; 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 = { export const FIELD = {
name: { label: "Nome e cognome", placeholder: "Mario Rossi" }, name: { label: "Nome e cognome", placeholder: "Mario Rossi" },
orderName: { label: "Numero dell'ordine", placeholder: "es. #1234" }, orderName: { label: "Numero dell'ordine", placeholder: "es. 1234" },
email: { label: "Email", placeholder: "La tua email dell'ordine" }, email: { label: "Email", placeholder: "La tua email" },
statement: { label: "Dichiarazione di recesso" }, statement: { label: "Dichiarazione di recesso" },
} as const; } 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 { 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'ordine ${orderName}.`;
} }
// §2.4 — Testo informativo sul diritto di recesso (Art. 49), mostrato PRIMA dell'invio. // Informazioni sul diritto di recesso (Art. 49) + alternative (coesistenza).
export const ART49_INFO = // Mostrate su richiesta dal pulsante info "i", per non appesantire il flusso.
"Hai diritto di recedere da questo contratto entro 14 giorni senza dover fornire alcuna motivazione.\n" + export const INFO_TITLE = "Il tuo diritto di recesso";
"Il termine decorre dalla consegna del bene (o dalla conclusione del contratto per i servizi).\n" + export const INFO_BODY =
"Compilando e confermando questo modulo eserciti il recesso: riceverai via email una ricevuta con\n" + "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" +
"la data e l'ora di trasmissione della tua dichiarazione."; "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). // Messaggi di errore.
export const COEXISTENCE_NOTE =
"Questa funzione è un modo aggiuntivo per esercitare il recesso. Puoi comunque usare il modulo\n" +
"tipo (Allegato I, parte B) o inviare qualsiasi dichiarazione esplicita, anche via email.";
// §2.7 — Messaggi di errore.
export const ERROR = { export const ERROR = {
// Anti-enumeration: ordine inesistente ED email non combaciante DEVONO // Anti-enumeration: ordine inesistente ed email non combaciante mostrano lo
// mostrare lo stesso identico messaggio (stesso testo, stesso status 200). // stesso messaggio, stesso testo e stesso status 200.
lookupNoMatch: lookupNoMatch:
"Non abbiamo trovato un ordine con questi dati. Verifica il numero dell'ordine e l'email usata per l'acquisto.", "Non abbiamo trovato un ordine con questi dati. Controlla il numero dell'ordine e l'email dell'acquisto.",
missingField: "Compila tutti i campi obbligatori per continuare.", missingField: "Compila tutti i campi per continuare.",
invalidEmail: "Inserisci un indirizzo email valido.", invalidEmail: "Inserisci un indirizzo email valido.",
windowClosed: windowClosed:
"Il termine di 14 giorni per il recesso su questo ordine è terminato. Puoi comunque contattarci per altre richieste.", "Il termine di 14 giorni per il recesso su questo ordine è terminato. Contattaci per altre richieste.",
generic: generic: "Si è verificato un problema. Riprova tra poco.",
"Si è verificato un problema. Riprova tra qualche istante; se persiste, contattaci.",
} as const; } as const;
// §2.6 — Schermata finale (dopo "Conferma recesso"). // Schermata finale (dopo "Conferma recesso").
export function successMessage( export function successMessage(
orderName: string, orderName: string,
transmittedAt: string, transmittedAt: string,
email: string, email: string,
): { line1: string; line2: string; line3: string } { ): { line1: string; line2: string; line3: string } {
return { return {
line1: "Recesso trasmesso correttamente.", line1: "Recesso trasmesso",
line2: `Abbiamo registrato la tua dichiarazione di recesso per l'ordine ${orderName} in data ${transmittedAt}.`, line2: `Registrato per l'ordine ${orderName} il ${transmittedAt}.`,
line3: `Ti abbiamo inviato una ricevuta all'indirizzo ${email}.`, line3: `Ti abbiamo inviato una ricevuta a ${email}.`,
}; };
} }
// §2.5 — Template email ricevuta su supporto durevole. // Template email ricevuta su supporto durevole (usato da A4).
// Usato da A4 (invio reale). In MVP il flusso lascia receiptSentAt = null.
export function receiptEmailSubject(orderName: string): string { 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: { export function receiptEmailBody(params: {
@@ -83,8 +76,7 @@ export function receiptEmailBody(params: {
params; params;
return `Gentile ${customerName}, return `Gentile ${customerName},
confermiamo di aver ricevuto la Sua dichiarazione di recesso relativa all'ordine ${orderName}, confermiamo di aver ricevuto la Sua dichiarazione di recesso relativa all'ordine ${orderName}, trasmessa tramite la funzione di recesso presente sul nostro sito.
trasmessa tramite la funzione di recesso presente sul nostro sito.
Dettagli della richiesta: Dettagli della richiesta:
- Data e ora di trasmissione: ${transmittedAt} - Data e ora di trasmissione: ${transmittedAt}
@@ -94,20 +86,16 @@ Dettagli della richiesta:
Testo integrale della dichiarazione di recesso trasmessa: Testo integrale della dichiarazione di recesso trasmessa:
"${statementText}" "${statementText}"
Questa comunicazione costituisce la ricevuta su supporto durevole della Sua dichiarazione di 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.
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. 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 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.
all'Allegato I, parte B, o qualsiasi altra dichiarazione esplicita, anche via email): questa
funzione è aggiuntiva e non sostituisce tali strumenti.
${shopName}`; ${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 = { export const EXCLUSION_REASON = {
CUSTOM: "prodotto realizzato su misura o personalizzato", CUSTOM: "prodotto realizzato su misura o personalizzato",
PERISHABLE: "prodotto deperibile o a rapida scadenza", PERISHABLE: "prodotto deperibile o a rapida scadenza",
@@ -118,6 +106,3 @@ export const EXCLUSION_REASON = {
export function exclusionMessage(motivoEsclusione: string): string { 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.`; 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";

View File

@@ -15,7 +15,7 @@
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import type { AdminApiContext } from "@shopify/shopify-app-remix/server"; 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). // 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. // 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, // TODO(A9): sostituire con store condiviso (Redis/DB) per il deploy multi-istanza,
// aggiungere backoff/captcha oltre soglia e risposte a tempo costante // aggiungere backoff/captcha oltre soglia e risposte a tempo costante
@@ -121,6 +121,7 @@ export interface MatchedOrder {
orderName: string; // es. "#1001" orderName: string; // es. "#1001"
email: string; // email dell'ordine (per precompilazione) email: string; // email dell'ordine (per precompilazione)
createdAt: string; createdAt: string;
orderUrl: string; // URL pagina di stato dell'ordine (link per il cliente)
} }
interface OrderLookupGraphQL { interface OrderLookupGraphQL {
@@ -132,6 +133,7 @@ interface OrderLookupGraphQL {
name?: string | null; name?: string | null;
email?: string | null; email?: string | null;
createdAt?: string | null; createdAt?: string | null;
statusPageUrl?: string | null;
} | null; } | null;
} | null> | null; } | null> | null;
} | null; } | null;
@@ -147,6 +149,7 @@ const ORDER_LOOKUP_QUERY = `#graphql
name name
email email
createdAt createdAt
statusPageUrl
} }
} }
} }
@@ -166,6 +169,46 @@ const ORDER_LOOKUP_QUERY = `#graphql
* Multi-tenant: `admin` proviene dalla sessione App Proxy, quindi la query è * Multi-tenant: `admin` proviene dalla sessione App Proxy, quindi la query è
* già scoped allo shop corretto; non ci fidiamo mai dello shop lato client. * 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<ShopInfo> {
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( export async function lookupOrder(
admin: AdminApiContext, admin: AdminApiContext,
orderInput: string, orderInput: string,
@@ -202,6 +245,7 @@ export async function lookupOrder(
orderName: node.name, orderName: node.name,
email: node.email ?? emailInput.trim(), email: node.email ?? emailInput.trim(),
createdAt: node.createdAt ?? "", 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. // Niente Polaris, niente root layout admin: solo HTML+CSS inline accessibile.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -270,6 +314,40 @@ const PAGE_CSS = `
.wrap { padding: 16px 12px 48px; } .wrap { padding: 16px 12px 48px; }
.card { padding: 22px 18px; } .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; } 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; } h2 { font-size: 1.05rem; margin: 24px 0 8px; font-weight: 600; }
p { margin: 0 0 12px; } p { margin: 0 0 12px; }
@@ -277,11 +355,27 @@ const PAGE_CSS = `
.muted:last-of-type { margin-bottom: 0; } .muted:last-of-type { margin-bottom: 0; }
/* Indicatore di step (discreto, non dark-pattern) */ /* Indicatore di step (discreto, non dark-pattern) */
.stepper { display: flex; align-items: center; gap: 10px; margin: 0 0 22px; } .stepper { margin: 0 0 12px; font-size: 0.72rem; font-weight: 600; letter-spacing: 0.09em; text-transform: uppercase; color: var(--text-muted); }
.stepper__track { display: inline-flex; gap: 5px; } .rc-titlerow { display: flex; align-items: center; gap: 8px; }
.stepper__seg { width: 28px; height: 4px; border-radius: 999px; background: var(--border); transition: background 0.15s ease; } .rc-titlerow h1 { margin: 0; }
.stepper__seg.is-active, .stepper__seg.is-done { background: var(--accent); } .rc-i {
.stepper__label { font-size: 0.72rem; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; color: var(--text-muted); } 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 */ /* Campi */
label { display: block; font-weight: 600; font-size: 0.95rem; margin: 20px 0 7px; color: var(--text); } 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) */ /* Successo (step 4) */
.success { text-align: center; padding: 6px 0 2px; } .success { text-align: center; padding: 6px 0 2px; }
.success__icon { .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; display: flex; align-items: center; justify-content: center;
border-radius: 999px; border-radius: 999px;
background: var(--success-bg); 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 h1 { color: var(--success); }
.success p { color: var(--text-muted); } .success p { color: var(--text-muted); }
@@ -470,6 +564,7 @@ export function renderShell(inner: string): string {
<style>${PAGE_CSS}</style> <style>${PAGE_CSS}</style>
</head> </head>
<body> <body>
<script>(function(){if(window.self!==window.top){try{document.body.className="embed";}catch(e){}}})();</script>
<main class="wrap"> <main class="wrap">
<div class="card"> <div class="card">
${inner} ${inner}
@@ -487,8 +582,24 @@ function errorBanner(message?: string): string {
</div>`; </div>`;
} }
function coexistenceBlock(): string { /**
return `<div class="coexist">${escapeHtml(COEXISTENCE_NOTE)}</div>`; * Pulsante info "i" + popover nativo (mini-modal, zero JS via Popover API).
* Il popover vive nel top-layer; chiusura con Esc, click fuori o pulsante.
*/
function infoWidget(id: string, title: string, body: string): string {
return `<button type="button" class="rc-i" popovertarget="${id}" aria-label="${attr(title)}">i</button>
<div id="${id}" popover class="rc-pop" role="dialog" aria-label="${attr(title)}">
<div class="rc-pop__bar"><span>${escapeHtml(title)}</span><button type="button" class="rc-pop__x" popovertarget="${id}" popovertargetaction="hide" aria-label="Chiudi">&times;</button></div>
<div class="rc-pop__body">${escapeHtml(body)}</div>
</div>`;
}
/** Header comune: step + titolo con pulsante info. */
function stepHead(step: number, subtitle?: string): string {
return `${stepIndicator(step)}
<div class="rc-titlerow"><h1>${escapeHtml(PAGE_TITLE)}</h1>${infoWidget("rcinfo", INFO_TITLE, INFO_BODY)}</div>${
subtitle ? `\n<p class="muted">${escapeHtml(subtitle)}</p>` : ""
}`;
} }
/** /**
@@ -503,22 +614,31 @@ function stepIndicator(current: number): string {
3: "Conferma", 3: "Conferma",
4: "Fatto", 4: "Fatto",
}; };
const segs = [1, 2, 3]
.map((i) => {
const cls =
current >= 4 || current > i
? "stepper__seg is-done"
: current === i
? "stepper__seg is-active"
: "stepper__seg";
return `<span class="${cls}"></span>`;
})
.join("");
const label = labels[current] ?? ""; const label = labels[current] ?? "";
return `<div class="stepper"> return `<p class="stepper">${escapeHtml(label)}</p>`;
<span class="stepper__track" aria-hidden="true">${segs}</span> }
<span class="stepper__label">${escapeHtml(label)}</span>
</div>`; /**
* 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 ------------------------------------------------- // --- Step 1: lookup guest -------------------------------------------------
@@ -529,21 +649,21 @@ export function renderStep1(opts?: {
}): string { }): string {
const orderName = opts?.orderName ?? ""; const orderName = opts?.orderName ?? "";
const email = opts?.email ?? ""; const email = opts?.email ?? "";
return renderShell(` return renderShell(
${stepIndicator(1)} stepLayout({
<h1>${escapeHtml(PAGE_TITLE)}</h1> head: stepHead(1),
<p class="muted">Inserisci il numero dell'ordine e l'email usata per l'acquisto per iniziare. Non è necessario alcun account.</p> body: `<p class="muted">Inserisci numero dell'ordine ed email dell'acquisto. Non serve un account.</p>
${errorBanner(opts?.error)} ${errorBanner(opts?.error)}
<form method="post" action="${PROXY_STOREFRONT_PATH}" novalidate> <form id="rcform" method="post" action="${PROXY_STOREFRONT_PATH}" novalidate>
<input type="hidden" name="intent" value="lookup"> <input type="hidden" name="intent" value="lookup">
<label for="orderName">${escapeHtml(FIELD.orderName.label)}</label> <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> <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> <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> <input type="email" id="email" name="email" value="${attr(email)}" placeholder="${attr(FIELD.email.placeholder)}" autocomplete="email" required>
<button type="submit" class="btn btn-primary">Continua</button> </form>`,
</form> foot: `<button type="submit" form="rcform" class="btn btn-primary">Continua</button>`,
${coexistenceBlock()} }),
`); );
} }
// --- Step 2: form dati + dichiarazione ------------------------------------ // --- Step 2: form dati + dichiarazione ------------------------------------
@@ -556,13 +676,11 @@ export function renderStep2(data: {
error?: string; error?: string;
}): string { }): string {
const customerName = data.customerName ?? ""; const customerName = data.customerName ?? "";
return renderShell(` return renderShell(
${stepIndicator(2)} stepLayout({
<h1>${escapeHtml(PAGE_TITLE)}</h1> head: stepHead(2, `Ordine ${data.orderName}`),
<p class="muted">Ordine ${escapeHtml(data.orderName)}</p> body: `${errorBanner(data.error)}
<div class="info">${escapeHtml(ART49_INFO)}</div> <form id="rcform" method="post" action="${PROXY_STOREFRONT_PATH}" novalidate>
${errorBanner(data.error)}
<form method="post" action="${PROXY_STOREFRONT_PATH}" novalidate>
<input type="hidden" name="intent" value="details"> <input type="hidden" name="intent" value="details">
<input type="hidden" name="orderId" value="${attr(data.orderId)}"> <input type="hidden" name="orderId" value="${attr(data.orderId)}">
<input type="hidden" name="orderName" value="${attr(data.orderName)}"> <input type="hidden" name="orderName" value="${attr(data.orderName)}">
@@ -572,18 +690,14 @@ ${errorBanner(data.error)}
<label for="statementText">${escapeHtml(FIELD.statement.label)}</label> <label for="statementText">${escapeHtml(FIELD.statement.label)}</label>
<textarea id="statementText" name="statementText" required>${escapeHtml(data.statementText)}</textarea> <textarea id="statementText" name="statementText" required>${escapeHtml(data.statementText)}</textarea>
<p class="hint">Puoi modificare il testo della dichiarazione se lo desideri.</p>
<div class="receipt-field"> <label for="email">${escapeHtml(FIELD.email.label)}</label>
<label for="email">${escapeHtml(FIELD.email.label)} <span class="tag">Ricevuta</span></label>
<input type="email" id="email" name="email" value="${attr(data.email)}" autocomplete="email" required> <input type="email" id="email" name="email" value="${attr(data.email)}" autocomplete="email" required>
<p class="hint">Qui riceverai la ricevuta del recesso. Già preso dal tuo ordine — puoi modificarlo.</p> <p class="hint">Ti invieremo qui la ricevuta.</p>
</div> </form>`,
foot: `<button type="submit" form="rcform" class="btn btn-primary">Continua</button>`,
<button type="submit" class="btn btn-primary">Continua</button> }),
</form> );
${coexistenceBlock()}
`);
} }
// --- Step 3: riepilogo + conferma dedicata -------------------------------- // --- Step 3: riepilogo + conferma dedicata --------------------------------
@@ -595,11 +709,10 @@ export function renderStep3(data: {
statementText: string; statementText: string;
error?: string; error?: string;
}): string { }): string {
return renderShell(` return renderShell(
${stepIndicator(3)} stepLayout({
<h1>${escapeHtml(PAGE_TITLE)}</h1> head: stepHead(3, "Controlla i dati prima di confermare."),
<p class="muted">Controlla i dati. Il recesso sarà trasmesso solo quando premi «${escapeHtml(CONFIRM_LABEL)}».</p> body: `${errorBanner(data.error)}
${errorBanner(data.error)}
<dl class="summary"> <dl class="summary">
<dt>${escapeHtml(FIELD.orderName.label)}</dt> <dt>${escapeHtml(FIELD.orderName.label)}</dt>
<dd>${escapeHtml(data.orderName)}</dd> <dd>${escapeHtml(data.orderName)}</dd>
@@ -610,30 +723,28 @@ ${errorBanner(data.error)}
<dt>${escapeHtml(FIELD.statement.label)}</dt> <dt>${escapeHtml(FIELD.statement.label)}</dt>
<dd>${escapeHtml(data.statementText)}</dd> <dd>${escapeHtml(data.statementText)}</dd>
</dl> </dl>
<form id="rcedit" method="post" action="${PROXY_STOREFRONT_PATH}">
<div class="actions">
<form method="post" action="${PROXY_STOREFRONT_PATH}">
<input type="hidden" name="intent" value="edit"> <input type="hidden" name="intent" value="edit">
<input type="hidden" name="orderId" value="${attr(data.orderId)}"> <input type="hidden" name="orderId" value="${attr(data.orderId)}">
<input type="hidden" name="orderName" value="${attr(data.orderName)}"> <input type="hidden" name="orderName" value="${attr(data.orderName)}">
<input type="hidden" name="email" value="${attr(data.email)}"> <input type="hidden" name="email" value="${attr(data.email)}">
<input type="hidden" name="customerName" value="${attr(data.customerName)}"> <input type="hidden" name="customerName" value="${attr(data.customerName)}">
<input type="hidden" name="statementText" value="${attr(data.statementText)}"> <input type="hidden" name="statementText" value="${attr(data.statementText)}">
<button type="submit" class="btn btn-secondary">Torna indietro</button> </form>
</form> <form id="rcconfirm" method="post" action="${PROXY_STOREFRONT_PATH}">
<form method="post" action="${PROXY_STOREFRONT_PATH}">
<input type="hidden" name="intent" value="confirm"> <input type="hidden" name="intent" value="confirm">
<input type="hidden" name="orderId" value="${attr(data.orderId)}"> <input type="hidden" name="orderId" value="${attr(data.orderId)}">
<input type="hidden" name="orderName" value="${attr(data.orderName)}"> <input type="hidden" name="orderName" value="${attr(data.orderName)}">
<input type="hidden" name="email" value="${attr(data.email)}"> <input type="hidden" name="email" value="${attr(data.email)}">
<input type="hidden" name="customerName" value="${attr(data.customerName)}"> <input type="hidden" name="customerName" value="${attr(data.customerName)}">
<input type="hidden" name="statementText" value="${attr(data.statementText)}"> <input type="hidden" name="statementText" value="${attr(data.statementText)}">
<button type="submit" class="btn btn-primary">${escapeHtml(CONFIRM_LABEL)}</button> </form>`,
</form> foot: `<div class="actions">
</div> <button type="submit" form="rcedit" class="btn btn-secondary">Torna indietro</button>
${coexistenceBlock()} <button type="submit" form="rcconfirm" class="btn btn-primary">${escapeHtml(CONFIRM_LABEL)}</button>
`); </div>`,
}),
);
} }
// --- Step 4: successo ----------------------------------------------------- // --- Step 4: successo -----------------------------------------------------
@@ -642,16 +753,17 @@ export function renderStep4(data: {
line2: string; line2: string;
line3: string; line3: string;
}): string { }): string {
return renderShell(` return renderShell(
${stepIndicator(4)} stepLayout({
<div class="success"> 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> <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> <h1>${escapeHtml(data.line1)}</h1>
<p>${escapeHtml(data.line2)}</p> <p>${escapeHtml(data.line2)}</p>
<p>${escapeHtml(data.line3)}</p> <p>${escapeHtml(data.line3)}</p>
</div> </div>`,
${coexistenceBlock()} }),
`); );
} }
/** Helper: Response HTML standalone (status 200 di default per non leakare via status). */ /** Helper: Response HTML standalone (status 200 di default per non leakare via status). */

View File

@@ -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<typeof loader>();
const actionData = useActionData<typeof action>();
const nav = useNavigation();
const submit = useSubmit();
const [subject, setSubject] = useState(data.subject);
const [intro, setIntro] = useState(data.intro);
const [note, setNote] = useState(data.note);
const [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 (
<Page>
<TitleBar title="Email di ricevuta" />
<Layout>
<Layout.Section>
<BlockStack gap="400">
{showSaved ? (
<Banner tone="success" onDismiss={() => setShowSaved(false)}>
Salvato.
</Banner>
) : null}
<Card>
<BlockStack gap="400">
<BlockStack gap="100">
<Text as="h2" variant="headingMd">
Testi dell'email
</Text>
<Text as="p" tone="subdued">
Personalizza oggetto e testi. Il resto (dettagli ordine,
dichiarazione, data/ora, avviso di legge, layout) è fisso e
sempre conforme. Segnaposto disponibili:
</Text>
<InlineStack gap="200" wrap>
{TEXT_PLACEHOLDERS.map((p) => (
<Badge key={p}>{`{{${p}}}`}</Badge>
))}
</InlineStack>
</BlockStack>
<TextField
label="Oggetto"
value={subject}
onChange={setSubject}
autoComplete="off"
/>
<TextField
label="Introduzione"
value={intro}
onChange={setIntro}
autoComplete="off"
multiline={4}
helpText="Saluto e frase iniziale della email."
/>
<TextField
label="Nota aggiuntiva (opzionale)"
value={note}
onChange={setNote}
autoComplete="off"
multiline={3}
placeholder="Es. Per il reso, spedisci il pacco a..."
helpText="Riquadro in fondo alla email. Lascia vuoto per non mostrarlo."
/>
<ButtonGroup>
<Button variant="primary" loading={saving} onClick={handleSave}>
Salva
</Button>
<Button onClick={handleReset}>Ripristina default</Button>
</ButtonGroup>
</BlockStack>
</Card>
<Card>
<BlockStack gap="200">
<Text as="h2" variant="headingMd">
Anteprima
</Text>
<Text as="p" tone="subdued">
Oggetto: {previewSubject}
</Text>
<iframe
title="Anteprima email"
srcDoc={previewHtml}
style={{
width: "100%",
height: "640px",
border: "1px solid #e1e1e1",
borderRadius: "8px",
background: "#fff",
}}
/>
</BlockStack>
</Card>
</BlockStack>
</Layout.Section>
</Layout>
</Page>
);
}

View File

@@ -24,7 +24,7 @@ export default function App() {
<Link to="/app" rel="home"> <Link to="/app" rel="home">
Home Home
</Link> </Link>
<Link to="/app/additional">Additional page</Link> <Link to="/app/settings">Impostazioni</Link>
</NavMenu> </NavMenu>
<Outlet /> <Outlet />
</AppProvider> </AppProvider>

View File

@@ -24,6 +24,7 @@ import {
checkRateLimit, checkRateLimit,
clientIp, clientIp,
formatTransmittedAt, formatTransmittedAt,
getShopInfo,
htmlResponse, htmlResponse,
isValidEmail, isValidEmail,
lookupOrder, lookupOrder,
@@ -282,13 +283,25 @@ export const action = async ({ request }: ActionFunctionArgs) => {
// A4 — ricevuta su supporto durevole, senza ritardo. Il recesso è GIÀ // A4 — ricevuta su supporto durevole, senza ritardo. Il recesso è GIÀ
// persistito e valido: un invio email fallito NON deve invalidarlo. // persistito e valido: un invio email fallito NON deve invalidarlo.
const [settings, shopInfo] = await Promise.all([
db.settings.findUnique({ where: { shop } }).catch(() => null),
getShopInfo(admin),
]);
const shopName = shopInfo.name || shop.replace(/\.myshopify\.com$/, "");
const receipt = await sendWithdrawalReceipt({ const receipt = await sendWithdrawalReceipt({
to: email, to: email,
vars: {
shopName,
shopUrl: shopInfo.url,
orderName: match.orderName, orderName: match.orderName,
orderUrl: match.orderUrl,
customerName, customerName,
statementText,
transmittedAt: transmittedLabel, transmittedAt: transmittedLabel,
shopName: shop, statementText,
},
subject: settings?.emailSubject,
intro: settings?.emailIntro,
note: settings?.emailNote,
}); });
try { try {
if (receipt.ok) { if (receipt.ok) {

View File

@@ -0,0 +1,106 @@
/* Stile dei blocchi storefront di recesso. Scoped con prefisso .recesso- per non
interferire col tema del merchant. */
/* App block "Recesso - pulsante" */
.recesso-block { margin: 12px 0; }
.recesso-align-center { text-align: center; }
.recesso-align-right { text-align: right; }
.recesso-link {
color: inherit;
text-decoration: underline;
font-size: 15px;
line-height: 1.4;
}
.recesso-button {
display: inline-block;
padding: 12px 20px;
min-height: 44px;
box-sizing: border-box;
border-radius: 8px;
background: #1a1a1a;
color: #ffffff;
text-decoration: none;
font-size: 15px;
font-weight: 600;
line-height: 1.3;
transition: opacity .15s ease;
}
.recesso-button:hover { opacity: .9; }
.recesso-link:focus-visible,
.recesso-button:focus-visible {
outline: 2px solid currentColor;
outline-offset: 2px;
}
/* App embed "Recesso - link globale" */
.recesso-embed { padding: 12px 16px; text-align: center; }
.recesso-embed-left { text-align: left; }
.recesso-embed-right { text-align: right; }
.recesso-embed-link {
font-size: 14px;
color: inherit;
text-decoration: underline;
line-height: 1.4;
}
.recesso-embed-link:focus-visible {
outline: 2px solid currentColor;
outline-offset: 2px;
}
/* Modal (iframe sul flusso /apps/recesso) */
.recesso-modal-overlay {
position: fixed;
inset: 0;
z-index: 2147483000;
display: none;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, .5);
padding: 16px;
}
.recesso-modal-overlay.is-open { display: flex; }
.recesso-modal {
position: relative;
width: 100%;
max-width: 600px;
height: min(760px, 92vh); /* fissa: apertura immediata; header/footer ancorati, solo il corpo scorre */
background: #ffffff;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 24px 70px rgba(0, 0, 0, .35);
}
.recesso-modal-frame {
width: 100%;
height: 100%;
border: 0;
display: block;
}
.recesso-modal-close {
position: absolute;
top: 8px;
right: 8px;
z-index: 1;
width: 36px;
height: 36px;
padding: 0;
border: 0;
border-radius: 50%;
background: rgba(0, 0, 0, .06);
color: #111111;
font-size: 22px;
line-height: 1;
cursor: pointer;
}
.recesso-modal-close:hover { background: rgba(0, 0, 0, .12); }
.recesso-modal-close:focus-visible {
outline: 2px solid currentColor;
outline-offset: 2px;
}
@media (max-width: 480px) {
.recesso-modal { height: 92vh; max-height: none; border-radius: 12px; }
}
@media (prefers-color-scheme: dark) {
.recesso-modal { background: #1a1a1a; }
.recesso-modal-close { background: rgba(255, 255, 255, .12); color: #ffffff; }
}

View File

@@ -0,0 +1,81 @@
/* Modal del flusso di recesso.
Progressive enhancement: i link puntano a /apps/recesso (funzionano senza JS
come pagina piena). Se questo script è caricato, intercetta QUALSIASI link a
/apps/recesso - anche una voce di menu o un testo linkato dal merchant - e lo
apre in un modal con iframe sul flusso (stesso dominio → framing consentito).
Opt-out per singolo link: attributo data-recesso-no-modal. */
(function () {
if (window.__recessoModalInit) return; // guard: eseguito una volta sola
window.__recessoModalInit = true;
var PROXY_PATH = "/apps/recesso";
var overlay = null;
var lastFocus = null;
function build() {
var o = document.createElement("div");
o.className = "recesso-modal-overlay";
o.setAttribute("role", "dialog");
o.setAttribute("aria-modal", "true");
o.setAttribute("aria-label", "Recesso dal contratto");
o.innerHTML =
'<div class="recesso-modal">' +
'<button type="button" class="recesso-modal-close" aria-label="Chiudi">×</button>' +
'<iframe class="recesso-modal-frame" title="Recesso dal contratto"></iframe>' +
"</div>";
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 <a> 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);
});
})();

View File

@@ -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 %}
<div class="recesso-embed recesso-embed-{{ block.settings.alignment }}">
<a
class="recesso-embed-link"
href="/apps/recesso"
>{{ block.settings.label | default: 'Recedere dal contratto qui' | escape }}</a>
</div>
{% 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 %}

View File

@@ -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 %}
<div class="recesso-block recesso-align-{{ block.settings.alignment }}" {{ block.shopify_attributes }}>
<a
class="recesso-{{ block.settings.style }}"
href="/apps/recesso"
{% unless block.settings.use_modal %}data-recesso-no-modal{% endunless %}
{% if block.settings.open_new_tab and block.settings.use_modal == false %}target="_blank" rel="noopener"{% endif %}
{%- if block.settings.style == 'button' and block.settings.button_bg != blank %} style="background:{{ block.settings.button_bg }};color:{{ block.settings.button_text | default: '#ffffff' }};"{% endif -%}
>{{ rc_label | escape }}</a>
</div>
{% 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 %}

View File

@@ -0,0 +1,3 @@
name = "recesso-storefront"
type = "theme"
uid = "32fd4d4f-a884-a3be-0335-9696fbc24efd646eafe9"

View File

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

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Settings" ADD COLUMN "emailLogoUrl" TEXT;

View File

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

View File

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

View File

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

View File

@@ -49,6 +49,9 @@ model Settings {
returnAddress String? returnAddress String?
defaultWindowDays Int @default(14) defaultWindowDays Int @default(14)
withdrawalInfoText String? withdrawalInfoText String?
emailSubject String?
emailIntro String?
emailNote String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt