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
* 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
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>`;
/** Versione testo grezza dell'HTML (fallback per client senza HTML). */
function htmlToText(html: string): string {
return html
.replace(/<style[\s\S]*?<\/style>/gi, "")
.replace(/<(?:\/p|\/div|\/tr|\/h1|\/h2|\/li|br\s*\/?)>/gi, "\n")
.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 =
@@ -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<ReceiptResult> {
const transport = buildTransport();
if (!transport) {
return { ok: false, error: "SMTP non configurato (SMTP_HOST mancante)" };
}
const subject = receiptEmailSubject(params.orderName);
const text = receiptEmailBody({
customerName: params.customerName,
orderName: params.orderName,
transmittedAt: params.transmittedAt,
statementText: params.statementText,
shopName: params.shopName,
});
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) {

View File

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

View File

@@ -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<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(
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 {
<style>${PAGE_CSS}</style>
</head>
<body>
<script>(function(){if(window.self!==window.top){try{document.body.className="embed";}catch(e){}}})();</script>
<main class="wrap">
<div class="card">
${inner}
@@ -487,8 +582,24 @@ function errorBanner(message?: string): string {
</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",
4: "Fatto",
};
const segs = [1, 2, 3]
.map((i) => {
const cls =
current >= 4 || current > i
? "stepper__seg is-done"
: current === i
? "stepper__seg is-active"
: "stepper__seg";
return `<span class="${cls}"></span>`;
})
.join("");
const label = labels[current] ?? "";
return `<div class="stepper">
<span class="stepper__track" aria-hidden="true">${segs}</span>
<span class="stepper__label">${escapeHtml(label)}</span>
</div>`;
return `<p class="stepper">${escapeHtml(label)}</p>`;
}
/**
* Layout a 3 fasce: header fisso (contesto + step), corpo scorrevole, footer con
* la CTA. In pagina piena è flusso normale; in modalità embed (modal) diventa una
* colonna flex a tutta altezza con header/footer ancorati e solo il corpo che scorre
* (pattern dei modal moderni). I bottoni stanno nel footer e referenziano la form
* via attributo `form=` (HTML5), così restano sempre visibili.
*/
function stepLayout(parts: { head: string; body: string; foot?: string }): string {
return `<div class="rc-head">
${parts.head}
</div>
<div class="rc-body">
${parts.body}
</div>${
parts.foot
? `
<div class="rc-foot">
${parts.foot}
</div>`
: ""
}`;
}
// --- Step 1: lookup guest -------------------------------------------------
@@ -529,21 +649,21 @@ export function renderStep1(opts?: {
}): string {
const orderName = opts?.orderName ?? "";
const email = opts?.email ?? "";
return renderShell(`
${stepIndicator(1)}
<h1>${escapeHtml(PAGE_TITLE)}</h1>
<p class="muted">Inserisci il numero dell'ordine e l'email usata per l'acquisto per iniziare. Non è necessario alcun account.</p>
return renderShell(
stepLayout({
head: stepHead(1),
body: `<p class="muted">Inserisci numero dell'ordine ed email dell'acquisto. Non serve un account.</p>
${errorBanner(opts?.error)}
<form method="post" action="${PROXY_STOREFRONT_PATH}" novalidate>
<form id="rcform" method="post" action="${PROXY_STOREFRONT_PATH}" novalidate>
<input type="hidden" name="intent" value="lookup">
<label for="orderName">${escapeHtml(FIELD.orderName.label)}</label>
<input type="text" id="orderName" name="orderName" value="${attr(orderName)}" placeholder="${attr(FIELD.orderName.placeholder)}" autocomplete="off" required>
<label for="email">${escapeHtml(FIELD.email.label)}</label>
<input type="email" id="email" name="email" value="${attr(email)}" placeholder="${attr(FIELD.email.placeholder)}" autocomplete="email" required>
<button type="submit" class="btn btn-primary">Continua</button>
</form>
${coexistenceBlock()}
`);
</form>`,
foot: `<button type="submit" form="rcform" class="btn btn-primary">Continua</button>`,
}),
);
}
// --- Step 2: form dati + dichiarazione ------------------------------------
@@ -556,13 +676,11 @@ export function renderStep2(data: {
error?: string;
}): string {
const customerName = data.customerName ?? "";
return renderShell(`
${stepIndicator(2)}
<h1>${escapeHtml(PAGE_TITLE)}</h1>
<p class="muted">Ordine ${escapeHtml(data.orderName)}</p>
<div class="info">${escapeHtml(ART49_INFO)}</div>
${errorBanner(data.error)}
<form method="post" action="${PROXY_STOREFRONT_PATH}" novalidate>
return renderShell(
stepLayout({
head: stepHead(2, `Ordine ${data.orderName}`),
body: `${errorBanner(data.error)}
<form id="rcform" method="post" action="${PROXY_STOREFRONT_PATH}" novalidate>
<input type="hidden" name="intent" value="details">
<input type="hidden" name="orderId" value="${attr(data.orderId)}">
<input type="hidden" name="orderName" value="${attr(data.orderName)}">
@@ -572,18 +690,14 @@ ${errorBanner(data.error)}
<label for="statementText">${escapeHtml(FIELD.statement.label)}</label>
<textarea id="statementText" name="statementText" required>${escapeHtml(data.statementText)}</textarea>
<p class="hint">Puoi modificare il testo della dichiarazione se lo desideri.</p>
<div class="receipt-field">
<label for="email">${escapeHtml(FIELD.email.label)} <span class="tag">Ricevuta</span></label>
<input type="email" id="email" name="email" value="${attr(data.email)}" autocomplete="email" required>
<p class="hint">Qui riceverai la ricevuta del recesso. Già preso dal tuo ordine — puoi modificarlo.</p>
</div>
<button type="submit" class="btn btn-primary">Continua</button>
</form>
${coexistenceBlock()}
`);
<label for="email">${escapeHtml(FIELD.email.label)}</label>
<input type="email" id="email" name="email" value="${attr(data.email)}" autocomplete="email" required>
<p class="hint">Ti invieremo qui la ricevuta.</p>
</form>`,
foot: `<button type="submit" form="rcform" class="btn btn-primary">Continua</button>`,
}),
);
}
// --- Step 3: riepilogo + conferma dedicata --------------------------------
@@ -595,11 +709,10 @@ export function renderStep3(data: {
statementText: string;
error?: string;
}): string {
return renderShell(`
${stepIndicator(3)}
<h1>${escapeHtml(PAGE_TITLE)}</h1>
<p class="muted">Controlla i dati. Il recesso sarà trasmesso solo quando premi «${escapeHtml(CONFIRM_LABEL)}».</p>
${errorBanner(data.error)}
return renderShell(
stepLayout({
head: stepHead(3, "Controlla i dati prima di confermare."),
body: `${errorBanner(data.error)}
<dl class="summary">
<dt>${escapeHtml(FIELD.orderName.label)}</dt>
<dd>${escapeHtml(data.orderName)}</dd>
@@ -610,30 +723,28 @@ ${errorBanner(data.error)}
<dt>${escapeHtml(FIELD.statement.label)}</dt>
<dd>${escapeHtml(data.statementText)}</dd>
</dl>
<div class="actions">
<form method="post" action="${PROXY_STOREFRONT_PATH}">
<input type="hidden" name="intent" value="edit">
<input type="hidden" name="orderId" value="${attr(data.orderId)}">
<input type="hidden" name="orderName" value="${attr(data.orderName)}">
<input type="hidden" name="email" value="${attr(data.email)}">
<input type="hidden" name="customerName" value="${attr(data.customerName)}">
<input type="hidden" name="statementText" value="${attr(data.statementText)}">
<button type="submit" class="btn btn-secondary">Torna indietro</button>
</form>
<form method="post" action="${PROXY_STOREFRONT_PATH}">
<input type="hidden" name="intent" value="confirm">
<input type="hidden" name="orderId" value="${attr(data.orderId)}">
<input type="hidden" name="orderName" value="${attr(data.orderName)}">
<input type="hidden" name="email" value="${attr(data.email)}">
<input type="hidden" name="customerName" value="${attr(data.customerName)}">
<input type="hidden" name="statementText" value="${attr(data.statementText)}">
<button type="submit" class="btn btn-primary">${escapeHtml(CONFIRM_LABEL)}</button>
</form>
</div>
${coexistenceBlock()}
`);
<form id="rcedit" method="post" action="${PROXY_STOREFRONT_PATH}">
<input type="hidden" name="intent" value="edit">
<input type="hidden" name="orderId" value="${attr(data.orderId)}">
<input type="hidden" name="orderName" value="${attr(data.orderName)}">
<input type="hidden" name="email" value="${attr(data.email)}">
<input type="hidden" name="customerName" value="${attr(data.customerName)}">
<input type="hidden" name="statementText" value="${attr(data.statementText)}">
</form>
<form id="rcconfirm" method="post" action="${PROXY_STOREFRONT_PATH}">
<input type="hidden" name="intent" value="confirm">
<input type="hidden" name="orderId" value="${attr(data.orderId)}">
<input type="hidden" name="orderName" value="${attr(data.orderName)}">
<input type="hidden" name="email" value="${attr(data.email)}">
<input type="hidden" name="customerName" value="${attr(data.customerName)}">
<input type="hidden" name="statementText" value="${attr(data.statementText)}">
</form>`,
foot: `<div class="actions">
<button type="submit" form="rcedit" class="btn btn-secondary">Torna indietro</button>
<button type="submit" form="rcconfirm" class="btn btn-primary">${escapeHtml(CONFIRM_LABEL)}</button>
</div>`,
}),
);
}
// --- Step 4: successo -----------------------------------------------------
@@ -642,16 +753,17 @@ export function renderStep4(data: {
line2: string;
line3: string;
}): string {
return renderShell(`
${stepIndicator(4)}
<div class="success">
return renderShell(
stepLayout({
head: `${stepIndicator(4)}`,
body: `<div class="success">
<div class="success__icon" aria-hidden="true"><svg viewBox="0 0 24 24" focusable="false"><path d="M9.55 17.05 4.5 12l1.4-1.4 3.65 3.6 8.15-8.15L19.1 7.5z"/></svg></div>
<h1>${escapeHtml(data.line1)}</h1>
<p>${escapeHtml(data.line2)}</p>
<p>${escapeHtml(data.line3)}</p>
</div>
${coexistenceBlock()}
`);
</div>`,
}),
);
}
/** 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">
Home
</Link>
<Link to="/app/additional">Additional page</Link>
<Link to="/app/settings">Impostazioni</Link>
</NavMenu>
<Outlet />
</AppProvider>

View File

@@ -24,6 +24,7 @@ import {
checkRateLimit,
clientIp,
formatTransmittedAt,
getShopInfo,
htmlResponse,
isValidEmail,
lookupOrder,
@@ -282,13 +283,25 @@ export const action = async ({ request }: ActionFunctionArgs) => {
// A4 — ricevuta su supporto durevole, senza ritardo. Il recesso è GIÀ
// 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({
to: email,
orderName: match.orderName,
customerName,
statementText,
transmittedAt: transmittedLabel,
shopName: shop,
vars: {
shopName,
shopUrl: shopInfo.url,
orderName: match.orderName,
orderUrl: match.orderUrl,
customerName,
transmittedAt: transmittedLabel,
statementText,
},
subject: settings?.emailSubject,
intro: settings?.emailIntro,
note: settings?.emailNote,
});
try {
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?
defaultWindowDays Int @default(14)
withdrawalInfoText String?
emailSubject String?
emailIntro String?
emailNote String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt