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
|
||||
* elementi di legge (dichiarazione integrale, id ordine, data/ora di TRASMISSIONE,
|
||||
* nome consumatore). Il testo viene dal copy deck (recesso.copy §2.5).
|
||||
* Il corpo/oggetto sono TEMPLATE editabili dal merchant (HTML + segnaposto), con
|
||||
* default in ./emailTemplate. Qui sostituiamo i segnaposto coi valori reali
|
||||
* (dinamici da Shopify) e inviamo. La validazione compliance (segnaposto
|
||||
* obbligatori) avviene al salvataggio lato admin.
|
||||
*
|
||||
* Transport configurato via env (provider-agnostico):
|
||||
* SMTP_HOST, SMTP_PORT(=587), SMTP_USER?, SMTP_PASS?, SMTP_SECURE("true"/"false"), MAIL_FROM
|
||||
* DEV: Mailpit su localhost:1025 (nessuna auth) → email visibili su http://localhost:18025
|
||||
* PROD: provider reale (Resend/Postmark/SMTP) — vedi .env.example.
|
||||
* Transport via env: SMTP_HOST, SMTP_PORT(=587), SMTP_USER?, SMTP_PASS?,
|
||||
* SMTP_SECURE("true"/"false"), MAIL_FROM. DEV: Mailpit localhost:1025.
|
||||
*/
|
||||
|
||||
import nodemailer from "nodemailer";
|
||||
import { receiptEmailBody, receiptEmailSubject } from "./recesso.copy";
|
||||
import {
|
||||
renderReceiptHtml,
|
||||
renderSubject,
|
||||
type EmailVars,
|
||||
} from "./emailTemplate";
|
||||
|
||||
function buildTransport() {
|
||||
const host = process.env.SMTP_HOST;
|
||||
if (!host) return null; // email non configurata → invio saltato con errore gestito
|
||||
if (!host) return null;
|
||||
const port = Number(process.env.SMTP_PORT ?? 587);
|
||||
const user = process.env.SMTP_USER;
|
||||
return nodemailer.createTransport({
|
||||
@@ -30,12 +33,21 @@ function buildTransport() {
|
||||
});
|
||||
}
|
||||
|
||||
function textToHtml(text: string): string {
|
||||
const esc = text
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
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(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/·/g, "·")
|
||||
.replace(/ /g, " ")
|
||||
.replace(/[ \t]+\n/g, "\n")
|
||||
.replace(/\n{3,}/g, "\n\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export type ReceiptResult =
|
||||
@@ -43,30 +55,25 @@ export type ReceiptResult =
|
||||
| { ok: false; error: string };
|
||||
|
||||
/**
|
||||
* Invia la ricevuta. Non lancia mai: cattura internamente e ritorna esito.
|
||||
* `transmittedAt` deve essere già la stringa leggibile (Europe/Rome).
|
||||
* Invia la ricevuta. Non lancia mai: cattura e ritorna l'esito.
|
||||
* `subjectTemplate`/`bodyTemplate` dai Settings (null = default). `vars` sono i
|
||||
* valori reali (shop/ordine/cliente), gia' pronti dal chiamante.
|
||||
*/
|
||||
export async function sendWithdrawalReceipt(params: {
|
||||
to: string;
|
||||
orderName: string;
|
||||
customerName: string;
|
||||
statementText: string;
|
||||
transmittedAt: string;
|
||||
shopName: string;
|
||||
vars: EmailVars;
|
||||
subject?: string | null;
|
||||
intro?: string | null;
|
||||
note?: string | null;
|
||||
}): Promise<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) {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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">×</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). */
|
||||
|
||||
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">
|
||||
Home
|
||||
</Link>
|
||||
<Link to="/app/additional">Additional page</Link>
|
||||
<Link to="/app/settings">Impostazioni</Link>
|
||||
</NavMenu>
|
||||
<Outlet />
|
||||
</AppProvider>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
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?
|
||||
defaultWindowDays Int @default(14)
|
||||
withdrawalInfoText String?
|
||||
emailSubject String?
|
||||
emailIntro String?
|
||||
emailNote String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
|
||||
Reference in New Issue
Block a user