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:
131
app/app/lib/emailTemplate.ts
Normal file
131
app/app/lib/emailTemplate.ts
Normal 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, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
|
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;"> </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.",
|
||||||
|
};
|
||||||
@@ -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, "&")
|
return html
|
||||||
.replace(/</g, "<")
|
.replace(/<style[\s\S]*?<\/style>/gi, "")
|
||||||
.replace(/>/g, ">");
|
.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(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/·/g, "·")
|
||||||
|
.replace(/ /g, " ")
|
||||||
|
.replace(/[ \t]+\n/g, "\n")
|
||||||
|
.replace(/\n{3,}/g, "\n\n")
|
||||||
|
.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ReceiptResult =
|
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) {
|
||||||
|
|||||||
@@ -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";
|
|
||||||
|
|||||||
@@ -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">×</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">Ti invieremo qui la ricevuta.</p>
|
||||||
<p class="hint">Qui riceverai la ricevuta del recesso. Già preso dal tuo ordine — puoi modificarlo.</p>
|
</form>`,
|
||||||
</div>
|
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">
|
<input type="hidden" name="intent" value="edit">
|
||||||
<form method="post" action="${PROXY_STOREFRONT_PATH}">
|
<input type="hidden" name="orderId" value="${attr(data.orderId)}">
|
||||||
<input type="hidden" name="intent" value="edit">
|
<input type="hidden" name="orderName" value="${attr(data.orderName)}">
|
||||||
<input type="hidden" name="orderId" value="${attr(data.orderId)}">
|
<input type="hidden" name="email" value="${attr(data.email)}">
|
||||||
<input type="hidden" name="orderName" value="${attr(data.orderName)}">
|
<input type="hidden" name="customerName" value="${attr(data.customerName)}">
|
||||||
<input type="hidden" name="email" value="${attr(data.email)}">
|
<input type="hidden" name="statementText" value="${attr(data.statementText)}">
|
||||||
<input type="hidden" name="customerName" value="${attr(data.customerName)}">
|
</form>
|
||||||
<input type="hidden" name="statementText" value="${attr(data.statementText)}">
|
<form id="rcconfirm" method="post" action="${PROXY_STOREFRONT_PATH}">
|
||||||
<button type="submit" class="btn btn-secondary">Torna indietro</button>
|
<input type="hidden" name="intent" value="confirm">
|
||||||
</form>
|
<input type="hidden" name="orderId" value="${attr(data.orderId)}">
|
||||||
|
<input type="hidden" name="orderName" value="${attr(data.orderName)}">
|
||||||
<form method="post" action="${PROXY_STOREFRONT_PATH}">
|
<input type="hidden" name="email" value="${attr(data.email)}">
|
||||||
<input type="hidden" name="intent" value="confirm">
|
<input type="hidden" name="customerName" value="${attr(data.customerName)}">
|
||||||
<input type="hidden" name="orderId" value="${attr(data.orderId)}">
|
<input type="hidden" name="statementText" value="${attr(data.statementText)}">
|
||||||
<input type="hidden" name="orderName" value="${attr(data.orderName)}">
|
</form>`,
|
||||||
<input type="hidden" name="email" value="${attr(data.email)}">
|
foot: `<div class="actions">
|
||||||
<input type="hidden" name="customerName" value="${attr(data.customerName)}">
|
<button type="submit" form="rcedit" class="btn btn-secondary">Torna indietro</button>
|
||||||
<input type="hidden" name="statementText" value="${attr(data.statementText)}">
|
<button type="submit" form="rcconfirm" class="btn btn-primary">${escapeHtml(CONFIRM_LABEL)}</button>
|
||||||
<button type="submit" class="btn btn-primary">${escapeHtml(CONFIRM_LABEL)}</button>
|
</div>`,
|
||||||
</form>
|
}),
|
||||||
</div>
|
);
|
||||||
${coexistenceBlock()}
|
|
||||||
`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 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). */
|
||||||
|
|||||||
202
app/app/routes/app.settings.tsx
Normal file
202
app/app/routes/app.settings.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
orderName: match.orderName,
|
vars: {
|
||||||
customerName,
|
shopName,
|
||||||
statementText,
|
shopUrl: shopInfo.url,
|
||||||
transmittedAt: transmittedLabel,
|
orderName: match.orderName,
|
||||||
shopName: shop,
|
orderUrl: match.orderUrl,
|
||||||
|
customerName,
|
||||||
|
transmittedAt: transmittedLabel,
|
||||||
|
statementText,
|
||||||
|
},
|
||||||
|
subject: settings?.emailSubject,
|
||||||
|
intro: settings?.emailIntro,
|
||||||
|
note: settings?.emailNote,
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
if (receipt.ok) {
|
if (receipt.ok) {
|
||||||
|
|||||||
106
app/extensions/recesso-storefront/assets/recesso-storefront.css
Normal file
106
app/extensions/recesso-storefront/assets/recesso-storefront.css
Normal 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; }
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
3
app/extensions/recesso-storefront/shopify.extension.toml
Normal file
3
app/extensions/recesso-storefront/shopify.extension.toml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
name = "recesso-storefront"
|
||||||
|
type = "theme"
|
||||||
|
uid = "32fd4d4f-a884-a3be-0335-9696fbc24efd646eafe9"
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Settings" ADD COLUMN "emailCustomMessage" TEXT,
|
||||||
|
ADD COLUMN "emailSenderName" TEXT;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Settings" ADD COLUMN "emailLogoUrl" TEXT;
|
||||||
@@ -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";
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user