diff --git a/app/app/lib/recesso.server.ts b/app/app/lib/recesso.server.ts index 7f23108..eb76673 100644 --- a/app/app/lib/recesso.server.ts +++ b/app/app/lib/recesso.server.ts @@ -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, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); -} - -/** 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(); -/** 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 ` - - - - - - -${escapeHtml(PAGE_TITLE)} -${override ? `\n` : ""} - - - -
-
-${inner} -
-
- -`; -} - -function errorBanner(message?: string): string { - if (!message) return ""; - return ``; -} - -function noticeBanner(message?: string): string { - if (!message) return ""; - return `
- - ${escapeHtml(message)} -
`; -} - -/** - * 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 ` -`; -} - -/** Header comune: step + titolo con pulsante info. */ -function stepHead(step: number, subtitle?: string): string { - return `${stepIndicator(step)} -

${escapeHtml(PAGE_TITLE)}

${infoWidget("rcinfo", INFO_TITLE, INFO_BODY)}
${ - subtitle ? `\n

${escapeHtml(subtitle)}

` : "" - }`; -} - -/** - * 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 = { - 1: "Passo 1 di 2", - 2: "Passo 2 di 2", - 3: "Conferma", - 4: "Fatto", - }; - const label = labels[current] ?? ""; - return `

${escapeHtml(label)}

`; -} - -/** - * 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 `
-${parts.head} -
-
-${parts.body} -
${ - parts.foot - ? ` -
-${parts.foot} -
` - : "" - }`; -} - -// --- 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: `

Inserisci numero dell'ordine ed email dell'acquisto. Non serve un account.

-${errorBanner(opts?.error)} -
- - - - - -
`, - foot: ``, - }), - 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)} -
- - - - - - - - - -

${escapeHtml(SCOPE_HINT)}

- - - -

Ti invieremo qui la ricevuta.

-
`, - foot: ``, - }), - 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)} -
-
${escapeHtml(FIELD.orderName.label)}
-
${escapeHtml(data.orderName)}
-
${escapeHtml(FIELD.name.label)}
-
${escapeHtml(data.customerName)}
-
${escapeHtml(FIELD.email.label)}
-
${escapeHtml(data.email)}
-
${escapeHtml(FIELD.statement.label)}
-
${escapeHtml(data.statementText)}
-
-
- - - - - - -
-
- - - - - - -
`, - foot: `
- - -
`, - }), - 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: `
- -

${escapeHtml(data.line1)}

-

${escapeHtml(data.line2)}

-

${escapeHtml(data.line3)}

-
`, - }), - theme, - ); -} /** Helper: Response HTML standalone (status 200 di default per non leakare via status). */ export function htmlResponse(html: string, status = 200): Response { diff --git a/app/app/lib/recesso.view.ts b/app/app/lib/recesso.view.ts new file mode 100644 index 0000000..b767c71 --- /dev/null +++ b/app/app/lib/recesso.view.ts @@ -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, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +/** 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 ` + + + + + + +${escapeHtml(PAGE_TITLE)} +${override ? `\n` : ""} + + + +
+
+${inner} +
+
+ +`; +} + +function errorBanner(message?: string): string { + if (!message) return ""; + return ``; +} + +function noticeBanner(message?: string): string { + if (!message) return ""; + return `
+ + ${escapeHtml(message)} +
`; +} + +/** + * 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 ` +`; +} + +/** Header comune: step + titolo con pulsante info. */ +function stepHead(step: number, subtitle?: string): string { + return `${stepIndicator(step)} +

${escapeHtml(PAGE_TITLE)}

${infoWidget("rcinfo", INFO_TITLE, INFO_BODY)}
${ + subtitle ? `\n

${escapeHtml(subtitle)}

` : "" + }`; +} + +/** + * 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 = { + 1: "Passo 1 di 2", + 2: "Passo 2 di 2", + 3: "Conferma", + 4: "Fatto", + }; + const label = labels[current] ?? ""; + return `

${escapeHtml(label)}

`; +} + +/** + * 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 `
+${parts.head} +
+
+${parts.body} +
${ + parts.foot + ? ` +
+${parts.foot} +
` + : "" + }`; +} + +// --- 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: `

Inserisci numero dell'ordine ed email dell'acquisto. Non serve un account.

+${errorBanner(opts?.error)} +
+ + + + + +
`, + foot: ``, + }), + 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)} +
+ + + + + + + + + +

${escapeHtml(SCOPE_HINT)}

+ + + +

Ti invieremo qui la ricevuta.

+
`, + foot: ``, + }), + 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)} +
+
${escapeHtml(FIELD.orderName.label)}
+
${escapeHtml(data.orderName)}
+
${escapeHtml(FIELD.name.label)}
+
${escapeHtml(data.customerName)}
+
${escapeHtml(FIELD.email.label)}
+
${escapeHtml(data.email)}
+
${escapeHtml(FIELD.statement.label)}
+
${escapeHtml(data.statementText)}
+
+
+ + + + + + +
+
+ + + + + + +
`, + foot: `
+ + +
`, + }), + 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: `
+ +

${escapeHtml(data.line1)}

+

${escapeHtml(data.line2)}

+

${escapeHtml(data.line3)}

+
`, + }), + theme, + ); +}