Files
pcrt-legal-return/app/app/lib/emailTemplate.ts
tommaso a550d8ff2e A6-bis: testi riquadro operativo editabili (segnaposto) + Impostazioni in tab
- opTextUnfulfilled / opTextShipped editabili dal merchant, con segnaposto
  {{returnAddress}} {{returnCost}} {{orderState}} {{customerName}} {{orderName}}
  {{shopName}}. Default = testi attuali. returnCost deriva da returnAtCustomerExpense
  (a tuo/nostro carico, Art. 57). returnInstructions deprecato (assorbito da opTextShipped).
- Pagina Impostazioni riorganizzata in 4 tab (Email / Notifiche / Regole recesso /
  Reso e stato ordine) + anteprima live del riquadro per stato (non evaso e spedito).
- emailTemplate: renderOperationalBlock ora usa i template + sostituzione segnaposto;
  renderOperationalPreview esportato per l'admin. Migrazione op_texts (additiva).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mv83a29B4eFv5ixoj6PoE1
2026-07-07 16:35:12 +02:00

193 lines
8.9 KiB
TypeScript

/**
* Email ricevuta recesso - layout FISSO (stile Shopify), con solo alcuni TESTI
* editabili dal merchant: oggetto, introduzione, nota opzionale. Le parti legali
* (dichiarazione, timestamp, avviso supporto durevole art. 54-bis), la struttura
* e il footer sono fissi -> sempre conforme e non rompibile.
*
* Modulo PURO (niente node/server): usato dal mailer e dall'anteprima admin.
* Segnaposto ammessi nei testi: {{customerName}} {{orderName}} {{shopName}}.
*/
export interface EmailVars {
shopName: string;
shopUrl: string;
orderName: string;
orderUrl: string;
customerName: string;
transmittedAt: string;
statementText: string;
}
// Segnaposto usabili nei campi di testo editabili.
export const TEXT_PLACEHOLDERS = ["customerName", "orderName", "shopName"] as const;
export const DEFAULT_SUBJECT =
"{{shopName}} - Ricevuta della tua richiesta di recesso - Ordine {{orderName}}";
export const DEFAULT_INTRO =
"Gentile {{customerName}},\n\nconfermiamo di aver ricevuto la tua dichiarazione di recesso relativa all'ordine {{orderName}}.";
export const DEFAULT_NOTE = "";
function escHtml(s: string): string {
return String(s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function nl2br(s: string): string {
return s.replace(/\n/g, "<br>");
}
/** Sostituzione segnaposto in testo semplice (valori grezzi). Per l'oggetto. */
function substPlain(tpl: string, vars: EmailVars): string {
return tpl.replace(/\{\{\s*(\w+)\s*\}\}/g, (_m, k: string) =>
String((vars as unknown as Record<string, string>)[k] ?? ""),
);
}
/** Testo del merchant -> HTML sicuro: escape del letterale + segnaposto (valori escaped) + a-capo. */
function richText(tpl: string, vars: EmailVars): string {
const escaped = escHtml(tpl);
const withVars = escaped.replace(/\{\{\s*(\w+)\s*\}\}/g, (_m, k: string) =>
escHtml(String((vars as unknown as Record<string, string>)[k] ?? "")),
);
return nl2br(withVars);
}
export function renderSubject(subjectTpl: string | null | undefined, vars: EmailVars): string {
const tpl = (subjectTpl && subjectTpl.trim()) || DEFAULT_SUBJECT;
return substPlain(tpl, vars).trim() || substPlain(DEFAULT_SUBJECT, vars);
}
// Testi del riquadro operativo (A6-bis) - editabili dal merchant, con segnaposto.
export const OP_PLACEHOLDERS = [
"returnAddress",
"returnCost",
"orderState",
"customerName",
"orderName",
"shopName",
] as const;
export const DEFAULT_OP_UNFULFILLED =
"Il tuo ordine non risultava ancora spedito: procederemo all'annullamento e al rimborso. Non devi restituire nulla.";
export const DEFAULT_OP_SHIPPED =
"Il prodotto risulta {{orderState}}. Per ottenere il rimborso, restituisci la merce integra a: {{returnAddress}}. Le spese di restituzione sono {{returnCost}}. Il rimborso sarà disposto dopo il rientro della merce.";
export interface OperationalConfig {
state: "unfulfilled" | "shipped" | "delivered";
textUnfulfilled?: string | null;
textShipped?: string | null;
returnAddress?: string | null;
atCustomerExpense: boolean;
}
/** Blocco operativo per stato ordine (A6-bis): testo editabile + segnaposto. */
function renderOperationalBlock(op: OperationalConfig, vars: EmailVars): string {
const tpl =
op.state === "unfulfilled"
? (op.textUnfulfilled && op.textUnfulfilled.trim()) || DEFAULT_OP_UNFULFILLED
: (op.textShipped && op.textShipped.trim()) || DEFAULT_OP_SHIPPED;
const map: Record<string, string> = {
returnAddress:
op.returnAddress && op.returnAddress.trim()
? op.returnAddress.trim()
: "l'indirizzo che ti comunicheremo",
returnCost: op.atCustomerExpense ? "a tuo carico" : "a nostro carico",
orderState: op.state === "delivered" ? "consegnato" : "spedito",
customerName: vars.customerName,
orderName: vars.orderName,
shopName: vars.shopName,
};
const withVars = escHtml(tpl).replace(
/\{\{\s*(\w+)\s*\}\}/g,
(_m, k: string) => escHtml(map[k] ?? ""),
);
const inner = nl2br(withVars);
return `<tr><td style="padding:4px 32px 8px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tr><td style="padding:14px 16px;background:#fff7e6;border:1px solid #ffe2a8;border-radius:8px;font-size:14px;line-height:1.6;color:#6a5518;">${inner}</td></tr></table>
</td></tr>`;
}
/** Anteprima del riquadro operativo per un dato stato (per l'admin). */
export function renderOperationalPreview(
op: OperationalConfig,
vars: EmailVars,
): string {
return renderOperationalBlock(op, vars);
}
/** Corpo HTML fisso con intro/nota editabili inseriti. */
export function renderReceiptHtml(
vars: EmailVars,
introTpl: string | null | undefined,
noteTpl: string | null | undefined,
operational?: OperationalConfig | null,
): string {
const intro = richText((introTpl && introTpl.trim()) || DEFAULT_INTRO, vars);
const noteVal = noteTpl && noteTpl.trim() ? richText(noteTpl, vars) : "";
const note = noteVal
? `<tr><td style="padding:4px 32px 8px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tr><td style="padding:14px 16px;background:#f6f6f7;border-radius:8px;font-size:14px;line-height:1.6;color:#444;">${noteVal}</td></tr></table>
</td></tr>`
: "";
return `<!doctype html>
<html lang="it"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="color-scheme" content="light"></head>
<body style="margin:0;padding:0;background:#f4f4f5;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f4f4f5;">
<tr><td align="center" style="padding:24px 12px;">
<table role="presentation" width="560" cellpadding="0" cellspacing="0" style="width:100%;max-width:560px;background:#ffffff;border:1px solid #e5e5e5;border-radius:12px;overflow:hidden;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;">
<tr><td style="height:4px;line-height:4px;font-size:0;background:#1a1a1a;">&nbsp;</td></tr>
<tr><td style="padding:22px 32px 18px;border-bottom:1px solid #ececec;">
<a href="${escHtml(vars.shopUrl)}" style="text-decoration:none;color:#1a1a1a;font-size:17px;font-weight:600;">${escHtml(vars.shopName)}</a>
</td></tr>
<tr><td style="padding:26px 32px 6px;">
<h1 style="margin:0 0 14px;font-size:20px;line-height:1.3;color:#1a1a1a;">Ricevuta della richiesta di recesso</h1>
<p style="margin:0;font-size:15px;line-height:1.6;color:#3a3a3a;">${intro}</p>
</td></tr>
<tr><td style="padding:16px 32px 6px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f6f6f7;border-radius:8px;">
<tr><td style="padding:14px 16px;font-size:14px;line-height:1.9;color:#444;">
<div><span style="color:#777;">Data e ora di trasmissione:</span> <strong>${escHtml(vars.transmittedAt)}</strong></div>
<div><span style="color:#777;">Ordine:</span> <strong>${escHtml(vars.orderName)}</strong></div>
<div><span style="color:#777;">Nome:</span> <strong>${escHtml(vars.customerName)}</strong></div>
</td></tr></table>
</td></tr>
<tr><td style="padding:14px 32px 6px;">
<div style="font-size:13px;font-weight:600;color:#555;margin-bottom:6px;">Dichiarazione trasmessa</div>
<div style="border-left:3px solid #d9d9d9;padding:8px 14px;font-size:14px;line-height:1.6;color:#555;font-style:italic;">${escHtml(vars.statementText)}</div>
</td></tr>
<tr><td style="padding:8px 32px 4px;">
<a href="${escHtml(vars.orderUrl)}" style="display:inline-block;padding:10px 18px;background:#1a1a1a;color:#ffffff;text-decoration:none;border-radius:8px;font-size:14px;font-weight:600;">Vedi il tuo ordine</a>
</td></tr>
<tr><td style="padding:12px 32px 4px;">
<p style="margin:0;font-size:12.5px;line-height:1.6;color:#8a8a8a;">Questa comunicazione costituisce la ricevuta su supporto durevole ai sensi dell'art. 54-bis del Codice del Consumo. La data e l'ora indicate attestano il momento della trasmissione.</p>
</td></tr>
${operational ? renderOperationalBlock(operational, vars) : ""}
${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.",
};