Estrai recesso.view.ts: vista pura, condivisibile con l'admin

PAGE_CSS, renderShell, stepLayout, renderStep1..4, escapeHtml/attr e
PROXY_STOREFRONT_PATH spostati in un modulo PURO (nessun node/server).
recesso.server.ts li ri-esporta: nessun chiamante cambia (proxy invariato).

Motivo: l'anteprima nell'admin deve rendere lo stesso identico markup e CSS
dello storefront, e non puo' farlo via <iframe src=route-admin> perche' l'admin
embedded si autentica con session token via App Bridge, che una navigazione
iframe non trasporta. Serve anche per il livello 2 (eredita il tema).

Rimosso il parametro 'theme' spurio che il replace globale aveva aggiunto a
stepLayout (non lo usa: il tema lo applica renderShell).
This commit is contained in:
2026-07-10 14:14:24 +02:00
parent d331e609a5
commit e51828a8d0
2 changed files with 598 additions and 578 deletions

View File

@@ -6,8 +6,10 @@
* inesistente ed email non combaciante);
* - rate-limit base per shop+IP (hardening -> A9);
* - hashing payload per l'audit trail;
* - formattazione timestamp di TRASMISSIONE (Europe/Rome);
* - rendering HTML standalone (nessun Polaris, CSS inline minimale, accessibile).
* - formattazione timestamp di TRASMISSIONE (Europe/Rome).
*
* Il RENDERING vive in ./recesso.view.ts (modulo puro, condiviso con l'anteprima
* admin). Qui lo ri-esportiamo, cosi' i chiamanti non cambiano.
*
* NB: `import "server-only"` non è disponibile qui; il suffisso `.server.ts`
* garantisce che Remix non impacchetti questo modulo nel bundle client.
@@ -16,21 +18,13 @@
import { createHash } from "node:crypto";
import type { AdminApiContext } from "@shopify/shopify-app-remix/server";
import type { ExclusionRule } from "@prisma/client";
import { themeStyle, type ThemeTokens } from "./theme";
import {
FIELD,
INFO_TITLE,
INFO_BODY,
CONFIRM_LABEL,
PAGE_TITLE,
SCOPE_HINT,
} from "./recesso.copy";
// ---------------------------------------------------------------------------
// Costanti path storefront (prefix "apps" + subpath "recesso" da shopify.app.toml).
// Le form fanno POST a questo path: Shopify appende la firma e forwarda a /proxy.
// ---------------------------------------------------------------------------
export const PROXY_STOREFRONT_PATH = "/apps/recesso";
export {
renderStep1,
renderStep2,
renderStep3,
renderStep4,
} from "./recesso.view";
// Locale MVP fisso (multi-lingua -> A7).
export const MVP_LOCALE = "it";
@@ -39,20 +33,6 @@ export const MVP_LOCALE = "it";
// Utility
// ---------------------------------------------------------------------------
/** Escape dei caratteri HTML per prevenire XSS su tutto l'input riflesso. */
export function escapeHtml(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
/** Attributo HTML sicuro (per value="..."): riusa escapeHtml. */
export function attr(value: string): string {
return escapeHtml(value);
}
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export function isValidEmail(value: string): boolean {
@@ -105,7 +85,6 @@ const RATE_WINDOW_MS = 15 * 60 * 1000; // 15 minuti
const RATE_MAX_ATTEMPTS = 8; // tentativi di lookup per finestra, per shop+IP
const rateBucket = new Map<string, { count: number; resetAt: number }>();
/** Ritorna true se la richiesta è consentita, false se ha superato la soglia. */
let lastRatePruneAt = 0;
/** Rimuove le voci scadute dal bucket (evita crescita illimitata della Map). */
function pruneRateBucket(now: number): void {
@@ -116,6 +95,7 @@ function pruneRateBucket(now: number): void {
}
}
/** Ritorna true se la richiesta è consentita, false se ha superato la soglia. */
export function checkRateLimit(shop: string, ip: string): boolean {
const key = `${shop}:${ip}`;
const now = Date.now();
@@ -653,553 +633,6 @@ export async function lookupOrder(
return null;
}
// ---------------------------------------------------------------------------
// Rendering HTML - documento standalone, servito sul dominio storefront.
// Niente Polaris, niente root layout admin: solo HTML+CSS inline accessibile.
// ---------------------------------------------------------------------------
const PAGE_CSS = `
:root {
color-scheme: light dark;
--bg: #f1f2f4;
--surface: #ffffff;
--text: #1a1a1a;
--text-muted: #5c5f62;
--border: #d7dadf;
--border-input: #8a8f96;
--border-input-hover: #6d7175;
--accent: #005bd3;
--focus-ring: rgba(0, 91, 211, 0.24);
--primary-bg: #1a1a1a;
--primary-bg-hover: #000000;
--primary-text: #ffffff;
--secondary-text: #1a1a1a;
--subtle-bg: #f6f7f8;
--tag-bg: #e4ecf9;
--tag-text: #17457f;
--info-bg: #eef4fb;
--info-border: #cbdcf2;
--info-text: #1f3a5f;
--coexist-bg: #f6f7f8;
--coexist-border: #c7cbd0;
--coexist-text: #4a4f54;
--error-bg: #fdece8;
--error-border: #e3a596;
--error-text: #8b1f0e;
--success: #0f6b3a;
--success-bg: #e4f3ea;
--shadow: 0 1px 2px rgba(0, 0, 0, 0.05), 0 10px 30px rgba(18, 24, 40, 0.08);
--radius: 14px;
--radius-sm: 9px;
--font: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Inter", sans-serif;
--card-max: 520px;
}
* { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body {
margin: 0;
font-family: var(--font);
line-height: 1.55;
color: var(--text);
background: var(--bg);
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
.wrap { max-width: var(--card-max); margin: 0 auto; padding: 32px 16px 72px; }
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 28px 26px;
box-shadow: var(--shadow);
}
@media (max-width: 480px) {
.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; }
.muted { color: var(--text-muted); font-size: 0.95rem; }
.muted:last-of-type { margin-bottom: 0; }
/* Indicatore di step (discreto, non dark-pattern) */
.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); }
input[type="text"], input[type="email"], textarea {
width: 100%;
min-height: 46px;
padding: 11px 13px;
font-size: 1rem;
font-family: inherit;
line-height: 1.5;
color: var(--text);
background: var(--surface);
border: 1px solid var(--border-input);
border-radius: var(--radius-sm);
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
input::placeholder, textarea::placeholder { color: var(--text-muted); opacity: 0.8; }
textarea { min-height: 128px; resize: vertical; }
input:hover, textarea:hover { border-color: var(--border-input-hover); }
input:focus, textarea:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--focus-ring);
outline: none;
}
.hint { font-weight: 400; color: var(--text-muted); font-size: 0.85rem; margin: 6px 0 0; }
/* Email ricevuta: de-enfatizzata ma editabile */
.receipt-field {
margin-top: 20px;
padding: 14px 15px 15px;
background: var(--subtle-bg);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
}
.receipt-field label { margin-top: 0; font-size: 0.9rem; }
.receipt-field .hint { margin-top: 8px; }
.tag {
display: inline-block;
margin-left: 6px;
padding: 2px 8px;
font-size: 0.66rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
vertical-align: middle;
color: var(--tag-text);
background: var(--tag-bg);
border-radius: 999px;
}
/* Bottoni */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 46px;
font-size: 1rem;
font-weight: 600;
font-family: inherit;
padding: 12px 22px;
border-radius: var(--radius-sm);
border: 1px solid transparent;
cursor: pointer;
margin-top: 24px;
transition: background 0.15s ease, border-color 0.15s ease, transform 0.05s ease;
}
.btn:active { transform: translateY(1px); }
.btn-primary { background: var(--primary-bg); color: var(--primary-text); }
.btn-primary:hover { background: var(--primary-bg-hover); }
.btn-secondary { background: var(--surface); color: var(--secondary-text); border-color: var(--border-input); }
.btn-secondary:hover { background: var(--subtle-bg); border-color: var(--border-input-hover); }
.btn:focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; }
.btn:focus:not(:focus-visible) { outline: none; }
/* Riga azioni (step riepilogo) */
.actions { display: flex; flex-direction: column; gap: 12px; margin-top: 26px; }
.actions form { margin: 0; }
.actions .btn { margin-top: 0; width: 100%; }
@media (min-width: 460px) {
.actions { flex-direction: row; }
.actions form { flex: 1; }
}
/* Info / coesistenza / errore */
.info {
background: var(--info-bg);
border: 1px solid var(--info-border);
color: var(--info-text);
border-radius: var(--radius-sm);
padding: 14px 16px;
margin: 18px 0 4px;
white-space: pre-line;
font-size: 0.92rem;
}
.coexist {
background: var(--coexist-bg);
border: 1px solid var(--coexist-border);
border-left: 3px solid var(--border-input);
border-radius: var(--radius-sm);
padding: 13px 16px;
margin: 26px 0 0;
white-space: pre-line;
font-size: 0.86rem;
color: var(--coexist-text);
}
.error {
display: flex;
align-items: flex-start;
gap: 10px;
background: var(--error-bg);
border: 1px solid var(--error-border);
border-radius: var(--radius-sm);
padding: 12px 14px;
margin: 0 0 18px;
color: var(--error-text);
font-weight: 600;
font-size: 0.92rem;
}
.error__icon { flex: none; width: 20px; height: 20px; margin-top: 1px; fill: currentColor; }
.notice {
display: flex;
align-items: flex-start;
gap: 10px;
background: rgba(240, 170, 40, 0.14);
border: 1px solid rgba(240, 170, 40, 0.55);
border-left: 3px solid rgba(240, 170, 40, 0.95);
border-radius: var(--radius-sm);
padding: 12px 14px;
margin: 0 0 18px;
color: var(--text);
font-size: 0.9rem;
line-height: 1.5;
}
.notice__icon { flex: none; width: 20px; height: 20px; margin-top: 1px; fill: #e0a020; }
/* Riepilogo (step 3) */
.summary { margin: 18px 0 4px; }
.summary dt { font-weight: 600; font-size: 0.82rem; text-transform: uppercase; letter-spacing: 0.03em; color: var(--text-muted); margin-top: 16px; }
.summary dt:first-child { margin-top: 0; }
.summary dd { margin: 3px 0 0; white-space: pre-line; color: var(--text); }
/* Successo (step 4) */
.success { text-align: center; padding: 6px 0 2px; }
.success__icon {
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: 24px; height: 24px; fill: var(--success); }
.success h1 { color: var(--success); }
.success p { color: var(--text-muted); }
@media (prefers-color-scheme: dark) {
:root {
--bg: #0f1114;
--surface: #1b1d21;
--text: #e7e9ec;
--text-muted: #a1a6ad;
--border: #34373d;
--border-input: #4c5058;
--border-input-hover: #676c75;
--accent: #5aa2ff;
--focus-ring: rgba(90, 162, 255, 0.34);
--primary-bg: #e7e9ec;
--primary-bg-hover: #ffffff;
--primary-text: #16181c;
--secondary-text: #e7e9ec;
--subtle-bg: #212429;
--tag-bg: #23374f;
--tag-text: #bcd6f7;
--info-bg: #15243a;
--info-border: #2d4a6b;
--info-text: #cfe0f5;
--coexist-bg: #212429;
--coexist-border: #3a3e45;
--coexist-text: #a1a6ad;
--error-bg: #3a1512;
--error-border: #7a2a1c;
--error-text: #ffb4a2;
--success: #5fd08a;
--success-bg: #163021;
--shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 12px 34px rgba(0, 0, 0, 0.45);
}
}
@media (prefers-reduced-motion: reduce) {
* { transition: none !important; }
}
`;
/** Wrapper documento HTML standalone. `inner` è già HTML sicuro. */
export function renderShell(inner: string, theme?: ThemeTokens | null): string {
// Gli override del merchant vengono DOPO il CSS di base: se sbaglia una
// configurazione, il default resta comunque valido.
const override = themeStyle(theme);
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 dark">
<meta name="robots" content="noindex">
<title>${escapeHtml(PAGE_TITLE)}</title>
<style>${PAGE_CSS}</style>${override ? `\n<style>${override}</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}
</div>
</main>
</body>
</html>`;
}
function errorBanner(message?: string): string {
if (!message) return "";
return `<div class="error" role="alert">
<svg class="error__icon" viewBox="0 0 20 20" aria-hidden="true" focusable="false"><path d="M10 1.5a8.5 8.5 0 1 0 0 17 8.5 8.5 0 0 0 0-17ZM9 6h2v6H9V6Zm0 7h2v2H9v-2Z"/></svg>
<span>${escapeHtml(message)}</span>
</div>`;
}
function noticeBanner(message?: string): string {
if (!message) return "";
return `<div class="notice" role="status">
<svg class="notice__icon" viewBox="0 0 20 20" aria-hidden="true" focusable="false"><path d="M10 1.5a8.5 8.5 0 1 0 0 17 8.5 8.5 0 0 0 0-17ZM9 5h2v2H9V5Zm0 4h2v6H9V9Z"/></svg>
<span>${escapeHtml(message)}</span>
</div>`;
}
/**
* Pulsante info "i" + popover nativo (mini-modal, zero JS via Popover API).
* Il popover vive nel top-layer; chiusura con Esc, click fuori o pulsante.
*/
function infoWidget(id: string, title: string, body: string): string {
return `<button type="button" class="rc-i" popovertarget="${id}" aria-label="${attr(title)}">i</button>
<div id="${id}" popover class="rc-pop" role="dialog" aria-label="${attr(title)}">
<div class="rc-pop__bar"><span>${escapeHtml(title)}</span><button type="button" class="rc-pop__x" popovertarget="${id}" popovertargetaction="hide" aria-label="Chiudi">&times;</button></div>
<div class="rc-pop__body">${escapeHtml(body)}</div>
</div>`;
}
/** Header comune: step + titolo con pulsante info. */
function stepHead(step: number, subtitle?: string): string {
return `${stepIndicator(step)}
<div class="rc-titlerow"><h1>${escapeHtml(PAGE_TITLE)}</h1>${infoWidget("rcinfo", INFO_TITLE, INFO_BODY)}</div>${
subtitle ? `\n<p class="muted">${escapeHtml(subtitle)}</p>` : ""
}`;
}
/**
* Indicatore di step discreto in cima alla card (accessibilità: la traccia è
* decorativa/aria-hidden, l'etichetta testuale resta leggibile). NON è un
* dark-pattern: comunica solo a che punto è l'utente.
*/
function stepIndicator(current: number): string {
const labels: Record<number, string> = {
1: "Passo 1 di 2",
2: "Passo 2 di 2",
3: "Conferma",
4: "Fatto",
};
const label = labels[current] ?? "";
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 }, theme?: ThemeTokens | null): 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 -------------------------------------------------
export function renderStep1(opts?: {
error?: string;
orderName?: string;
email?: string;
}, theme?: ThemeTokens | null): string {
const orderName = opts?.orderName ?? "";
const email = opts?.email ?? "";
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 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>
</form>`,
foot: `<button type="submit" form="rcform" class="btn btn-primary">Continua</button>`,
}),
theme,
);
}
// --- Step 2: form dati + dichiarazione ------------------------------------
export function renderStep2(data: {
orderId: string;
orderName: string;
email: string;
customerName?: string;
statementText: string;
error?: string;
notice?: string;
}, theme?: ThemeTokens | null): string {
const customerName = data.customerName ?? "";
return renderShell(
stepLayout({
head: stepHead(2, `Ordine ${data.orderName}`),
body: `${errorBanner(data.error)}${noticeBanner(data.notice)}
<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)}">
<label for="customerName">${escapeHtml(FIELD.name.label)}</label>
<input type="text" id="customerName" name="customerName" value="${attr(customerName)}" placeholder="${attr(FIELD.name.placeholder)}" autocomplete="name" required>
<label for="statementText">${escapeHtml(FIELD.statement.label)}</label>
<textarea id="statementText" name="statementText" required>${escapeHtml(data.statementText)}</textarea>
<p class="hint">${escapeHtml(SCOPE_HINT)}</p>
<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>`,
}),
theme,
);
}
// --- Step 3: riepilogo + conferma dedicata --------------------------------
export function renderStep3(data: {
orderId: string;
orderName: string;
email: string;
customerName: string;
statementText: string;
error?: string;
}, theme?: ThemeTokens | null): string {
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>
<dt>${escapeHtml(FIELD.name.label)}</dt>
<dd>${escapeHtml(data.customerName)}</dd>
<dt>${escapeHtml(FIELD.email.label)}</dt>
<dd>${escapeHtml(data.email)}</dd>
<dt>${escapeHtml(FIELD.statement.label)}</dt>
<dd>${escapeHtml(data.statementText)}</dd>
</dl>
<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>`,
}),
theme,
);
}
// --- Step 4: successo -----------------------------------------------------
export function renderStep4(data: {
line1: string;
line2: string;
line3: string;
}, theme?: ThemeTokens | null): string {
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>`,
}),
theme,
);
}
/** Helper: Response HTML standalone (status 200 di default per non leakare via status). */
export function htmlResponse(html: string, status = 200): Response {

587
app/app/lib/recesso.view.ts Normal file
View File

@@ -0,0 +1,587 @@
/**
* Vista del form di recesso — modulo PURO (niente node/server).
*
* Estratto da recesso.server.ts perche' l'anteprima nell'admin deve rendere
* ESATTAMENTE lo stesso markup e lo stesso CSS dello storefront. Un iframe che
* puntasse a una route admin non funzionerebbe: l'admin embedded si autentica
* con session token via App Bridge, che una navigazione iframe non porta.
*
* recesso.server.ts ri-esporta renderStep1..4, quindi nessun chiamante cambia.
*/
import { themeStyle, type ThemeTokens } from "./theme";
import {
FIELD,
INFO_TITLE,
INFO_BODY,
CONFIRM_LABEL,
PAGE_TITLE,
SCOPE_HINT,
} from "./recesso.copy";
// Costanti path storefront (prefix "apps" + subpath "recesso" da shopify.app.toml).
// Le form fanno POST a questo path: Shopify appende la firma e forwarda a /proxy.
// ---------------------------------------------------------------------------
export const PROXY_STOREFRONT_PATH = "/apps/recesso";
/** Escape dei caratteri HTML per prevenire XSS su tutto l'input riflesso. */
export function escapeHtml(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
/** Attributo HTML sicuro (per value="..."): riusa escapeHtml. */
export function attr(value: string): string {
return escapeHtml(value);
}
// ---------------------------------------------------------------------------
// Rendering HTML - documento standalone, servito sul dominio storefront.
// Niente Polaris, niente root layout admin: solo HTML+CSS inline accessibile.
// ---------------------------------------------------------------------------
const PAGE_CSS = `
:root {
color-scheme: light dark;
--bg: #f1f2f4;
--surface: #ffffff;
--text: #1a1a1a;
--text-muted: #5c5f62;
--border: #d7dadf;
--border-input: #8a8f96;
--border-input-hover: #6d7175;
--accent: #005bd3;
--focus-ring: rgba(0, 91, 211, 0.24);
--primary-bg: #1a1a1a;
--primary-bg-hover: #000000;
--primary-text: #ffffff;
--secondary-text: #1a1a1a;
--subtle-bg: #f6f7f8;
--tag-bg: #e4ecf9;
--tag-text: #17457f;
--info-bg: #eef4fb;
--info-border: #cbdcf2;
--info-text: #1f3a5f;
--coexist-bg: #f6f7f8;
--coexist-border: #c7cbd0;
--coexist-text: #4a4f54;
--error-bg: #fdece8;
--error-border: #e3a596;
--error-text: #8b1f0e;
--success: #0f6b3a;
--success-bg: #e4f3ea;
--shadow: 0 1px 2px rgba(0, 0, 0, 0.05), 0 10px 30px rgba(18, 24, 40, 0.08);
--radius: 14px;
--radius-sm: 9px;
--font: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Inter", sans-serif;
--card-max: 520px;
}
* { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body {
margin: 0;
font-family: var(--font);
line-height: 1.55;
color: var(--text);
background: var(--bg);
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
.wrap { max-width: var(--card-max); margin: 0 auto; padding: 32px 16px 72px; }
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 28px 26px;
box-shadow: var(--shadow);
}
@media (max-width: 480px) {
.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; }
.muted { color: var(--text-muted); font-size: 0.95rem; }
.muted:last-of-type { margin-bottom: 0; }
/* Indicatore di step (discreto, non dark-pattern) */
.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); }
input[type="text"], input[type="email"], textarea {
width: 100%;
min-height: 46px;
padding: 11px 13px;
font-size: 1rem;
font-family: inherit;
line-height: 1.5;
color: var(--text);
background: var(--surface);
border: 1px solid var(--border-input);
border-radius: var(--radius-sm);
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
input::placeholder, textarea::placeholder { color: var(--text-muted); opacity: 0.8; }
textarea { min-height: 128px; resize: vertical; }
input:hover, textarea:hover { border-color: var(--border-input-hover); }
input:focus, textarea:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--focus-ring);
outline: none;
}
.hint { font-weight: 400; color: var(--text-muted); font-size: 0.85rem; margin: 6px 0 0; }
/* Email ricevuta: de-enfatizzata ma editabile */
.receipt-field {
margin-top: 20px;
padding: 14px 15px 15px;
background: var(--subtle-bg);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
}
.receipt-field label { margin-top: 0; font-size: 0.9rem; }
.receipt-field .hint { margin-top: 8px; }
.tag {
display: inline-block;
margin-left: 6px;
padding: 2px 8px;
font-size: 0.66rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
vertical-align: middle;
color: var(--tag-text);
background: var(--tag-bg);
border-radius: 999px;
}
/* Bottoni */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 46px;
font-size: 1rem;
font-weight: 600;
font-family: inherit;
padding: 12px 22px;
border-radius: var(--radius-sm);
border: 1px solid transparent;
cursor: pointer;
margin-top: 24px;
transition: background 0.15s ease, border-color 0.15s ease, transform 0.05s ease;
}
.btn:active { transform: translateY(1px); }
.btn-primary { background: var(--primary-bg); color: var(--primary-text); }
.btn-primary:hover { background: var(--primary-bg-hover); }
.btn-secondary { background: var(--surface); color: var(--secondary-text); border-color: var(--border-input); }
.btn-secondary:hover { background: var(--subtle-bg); border-color: var(--border-input-hover); }
.btn:focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; }
.btn:focus:not(:focus-visible) { outline: none; }
/* Riga azioni (step riepilogo) */
.actions { display: flex; flex-direction: column; gap: 12px; margin-top: 26px; }
.actions form { margin: 0; }
.actions .btn { margin-top: 0; width: 100%; }
@media (min-width: 460px) {
.actions { flex-direction: row; }
.actions form { flex: 1; }
}
/* Info / coesistenza / errore */
.info {
background: var(--info-bg);
border: 1px solid var(--info-border);
color: var(--info-text);
border-radius: var(--radius-sm);
padding: 14px 16px;
margin: 18px 0 4px;
white-space: pre-line;
font-size: 0.92rem;
}
.coexist {
background: var(--coexist-bg);
border: 1px solid var(--coexist-border);
border-left: 3px solid var(--border-input);
border-radius: var(--radius-sm);
padding: 13px 16px;
margin: 26px 0 0;
white-space: pre-line;
font-size: 0.86rem;
color: var(--coexist-text);
}
.error {
display: flex;
align-items: flex-start;
gap: 10px;
background: var(--error-bg);
border: 1px solid var(--error-border);
border-radius: var(--radius-sm);
padding: 12px 14px;
margin: 0 0 18px;
color: var(--error-text);
font-weight: 600;
font-size: 0.92rem;
}
.error__icon { flex: none; width: 20px; height: 20px; margin-top: 1px; fill: currentColor; }
.notice {
display: flex;
align-items: flex-start;
gap: 10px;
background: rgba(240, 170, 40, 0.14);
border: 1px solid rgba(240, 170, 40, 0.55);
border-left: 3px solid rgba(240, 170, 40, 0.95);
border-radius: var(--radius-sm);
padding: 12px 14px;
margin: 0 0 18px;
color: var(--text);
font-size: 0.9rem;
line-height: 1.5;
}
.notice__icon { flex: none; width: 20px; height: 20px; margin-top: 1px; fill: #e0a020; }
/* Riepilogo (step 3) */
.summary { margin: 18px 0 4px; }
.summary dt { font-weight: 600; font-size: 0.82rem; text-transform: uppercase; letter-spacing: 0.03em; color: var(--text-muted); margin-top: 16px; }
.summary dt:first-child { margin-top: 0; }
.summary dd { margin: 3px 0 0; white-space: pre-line; color: var(--text); }
/* Successo (step 4) */
.success { text-align: center; padding: 6px 0 2px; }
.success__icon {
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: 24px; height: 24px; fill: var(--success); }
.success h1 { color: var(--success); }
.success p { color: var(--text-muted); }
@media (prefers-color-scheme: dark) {
:root {
--bg: #0f1114;
--surface: #1b1d21;
--text: #e7e9ec;
--text-muted: #a1a6ad;
--border: #34373d;
--border-input: #4c5058;
--border-input-hover: #676c75;
--accent: #5aa2ff;
--focus-ring: rgba(90, 162, 255, 0.34);
--primary-bg: #e7e9ec;
--primary-bg-hover: #ffffff;
--primary-text: #16181c;
--secondary-text: #e7e9ec;
--subtle-bg: #212429;
--tag-bg: #23374f;
--tag-text: #bcd6f7;
--info-bg: #15243a;
--info-border: #2d4a6b;
--info-text: #cfe0f5;
--coexist-bg: #212429;
--coexist-border: #3a3e45;
--coexist-text: #a1a6ad;
--error-bg: #3a1512;
--error-border: #7a2a1c;
--error-text: #ffb4a2;
--success: #5fd08a;
--success-bg: #163021;
--shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 12px 34px rgba(0, 0, 0, 0.45);
}
}
@media (prefers-reduced-motion: reduce) {
* { transition: none !important; }
}
`;
/** Wrapper documento HTML standalone. `inner` è già HTML sicuro. */
export function renderShell(inner: string, theme?: ThemeTokens | null): string {
// Gli override del merchant vengono DOPO il CSS di base: se sbaglia una
// configurazione, il default resta comunque valido.
const override = themeStyle(theme);
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 dark">
<meta name="robots" content="noindex">
<title>${escapeHtml(PAGE_TITLE)}</title>
<style>${PAGE_CSS}</style>${override ? `\n<style>${override}</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}
</div>
</main>
</body>
</html>`;
}
function errorBanner(message?: string): string {
if (!message) return "";
return `<div class="error" role="alert">
<svg class="error__icon" viewBox="0 0 20 20" aria-hidden="true" focusable="false"><path d="M10 1.5a8.5 8.5 0 1 0 0 17 8.5 8.5 0 0 0 0-17ZM9 6h2v6H9V6Zm0 7h2v2H9v-2Z"/></svg>
<span>${escapeHtml(message)}</span>
</div>`;
}
function noticeBanner(message?: string): string {
if (!message) return "";
return `<div class="notice" role="status">
<svg class="notice__icon" viewBox="0 0 20 20" aria-hidden="true" focusable="false"><path d="M10 1.5a8.5 8.5 0 1 0 0 17 8.5 8.5 0 0 0 0-17ZM9 5h2v2H9V5Zm0 4h2v6H9V9Z"/></svg>
<span>${escapeHtml(message)}</span>
</div>`;
}
/**
* Pulsante info "i" + popover nativo (mini-modal, zero JS via Popover API).
* Il popover vive nel top-layer; chiusura con Esc, click fuori o pulsante.
*/
function infoWidget(id: string, title: string, body: string): string {
return `<button type="button" class="rc-i" popovertarget="${id}" aria-label="${attr(title)}">i</button>
<div id="${id}" popover class="rc-pop" role="dialog" aria-label="${attr(title)}">
<div class="rc-pop__bar"><span>${escapeHtml(title)}</span><button type="button" class="rc-pop__x" popovertarget="${id}" popovertargetaction="hide" aria-label="Chiudi">&times;</button></div>
<div class="rc-pop__body">${escapeHtml(body)}</div>
</div>`;
}
/** Header comune: step + titolo con pulsante info. */
function stepHead(step: number, subtitle?: string): string {
return `${stepIndicator(step)}
<div class="rc-titlerow"><h1>${escapeHtml(PAGE_TITLE)}</h1>${infoWidget("rcinfo", INFO_TITLE, INFO_BODY)}</div>${
subtitle ? `\n<p class="muted">${escapeHtml(subtitle)}</p>` : ""
}`;
}
/**
* Indicatore di step discreto in cima alla card (accessibilità: la traccia è
* decorativa/aria-hidden, l'etichetta testuale resta leggibile). NON è un
* dark-pattern: comunica solo a che punto è l'utente.
*/
function stepIndicator(current: number): string {
const labels: Record<number, string> = {
1: "Passo 1 di 2",
2: "Passo 2 di 2",
3: "Conferma",
4: "Fatto",
};
const label = labels[current] ?? "";
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 -------------------------------------------------
export function renderStep1(opts?: {
error?: string;
orderName?: string;
email?: string;
}, theme?: ThemeTokens | null): string {
const orderName = opts?.orderName ?? "";
const email = opts?.email ?? "";
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 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>
</form>`,
foot: `<button type="submit" form="rcform" class="btn btn-primary">Continua</button>`,
}),
theme,
);
}
// --- Step 2: form dati + dichiarazione ------------------------------------
export function renderStep2(data: {
orderId: string;
orderName: string;
email: string;
customerName?: string;
statementText: string;
error?: string;
notice?: string;
}, theme?: ThemeTokens | null): string {
const customerName = data.customerName ?? "";
return renderShell(
stepLayout({
head: stepHead(2, `Ordine ${data.orderName}`),
body: `${errorBanner(data.error)}${noticeBanner(data.notice)}
<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)}">
<label for="customerName">${escapeHtml(FIELD.name.label)}</label>
<input type="text" id="customerName" name="customerName" value="${attr(customerName)}" placeholder="${attr(FIELD.name.placeholder)}" autocomplete="name" required>
<label for="statementText">${escapeHtml(FIELD.statement.label)}</label>
<textarea id="statementText" name="statementText" required>${escapeHtml(data.statementText)}</textarea>
<p class="hint">${escapeHtml(SCOPE_HINT)}</p>
<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>`,
}),
theme,
);
}
// --- Step 3: riepilogo + conferma dedicata --------------------------------
export function renderStep3(data: {
orderId: string;
orderName: string;
email: string;
customerName: string;
statementText: string;
error?: string;
}, theme?: ThemeTokens | null): string {
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>
<dt>${escapeHtml(FIELD.name.label)}</dt>
<dd>${escapeHtml(data.customerName)}</dd>
<dt>${escapeHtml(FIELD.email.label)}</dt>
<dd>${escapeHtml(data.email)}</dd>
<dt>${escapeHtml(FIELD.statement.label)}</dt>
<dd>${escapeHtml(data.statementText)}</dd>
</dl>
<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>`,
}),
theme,
);
}
// --- Step 4: successo -----------------------------------------------------
export function renderStep4(data: {
line1: string;
line2: string;
line3: string;
}, theme?: ThemeTokens | null): string {
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>`,
}),
theme,
);
}