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:
@@ -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, """)
|
||||
.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<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">×</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
587
app/app/lib/recesso.view.ts
Normal 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, "&")
|
||||
.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 `<!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">×</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,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user