R6 L1+L3: motore di stile del form (token + CSS custom)
Il form era gia' tokenizzato (~30 var CSS in :root): non riscriviamo nulla, iniettiamo un blocco di override DOPO il CSS di base, cosi' un errore di configurazione del merchant non puo' rompere il default. - theme.ts (modulo PURO, condiviso con la futura anteprima admin): ThemeTokens, FONT_PRESETS, themeStyle(). Sanificazione a monte: colori solo esadecimali, raggio e larghezza clampati, font da whitelist, CSS custom con rimozione di </style, @import, expression(), javascript: e cap a 4000 char. Derivati: --focus-ring da accent (rgba), --primary-bg-hover per shading. - recesso.server: tokenizzati anche font (--font) e larghezza (--card-max), che erano hardcoded; renderShell(inner, theme) inietta l'override; renderStep1..4 accettano e propagano il tema. - Settings: themeAccent/ButtonBg/ButtonText/Radius/Font/Width/CustomCss + migrazione. - proxy: loadTheme(shop) in loader e action, propagato ai 22 punti di render. Nell'early-return senza sessione il tema e' null (non conosciamo lo shop). fly.toml: min_machines_running resta 0 per scelta (TODO go-live, vedi PROTECTED-CUSTOMER-DATA.md §5.2).
This commit is contained in:
@@ -16,6 +16,7 @@
|
|||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import type { AdminApiContext } from "@shopify/shopify-app-remix/server";
|
import type { AdminApiContext } from "@shopify/shopify-app-remix/server";
|
||||||
import type { ExclusionRule } from "@prisma/client";
|
import type { ExclusionRule } from "@prisma/client";
|
||||||
|
import { themeStyle, type ThemeTokens } from "./theme";
|
||||||
import {
|
import {
|
||||||
FIELD,
|
FIELD,
|
||||||
INFO_TITLE,
|
INFO_TITLE,
|
||||||
@@ -690,19 +691,21 @@ const PAGE_CSS = `
|
|||||||
--shadow: 0 1px 2px rgba(0, 0, 0, 0.05), 0 10px 30px rgba(18, 24, 40, 0.08);
|
--shadow: 0 1px 2px rgba(0, 0, 0, 0.05), 0 10px 30px rgba(18, 24, 40, 0.08);
|
||||||
--radius: 14px;
|
--radius: 14px;
|
||||||
--radius-sm: 9px;
|
--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; }
|
* { box-sizing: border-box; }
|
||||||
html { -webkit-text-size-adjust: 100%; }
|
html { -webkit-text-size-adjust: 100%; }
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Inter", sans-serif;
|
font-family: var(--font);
|
||||||
line-height: 1.55;
|
line-height: 1.55;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
text-rendering: optimizeLegibility;
|
text-rendering: optimizeLegibility;
|
||||||
}
|
}
|
||||||
.wrap { max-width: 520px; margin: 0 auto; padding: 32px 16px 72px; }
|
.wrap { max-width: var(--card-max); margin: 0 auto; padding: 32px 16px 72px; }
|
||||||
.card {
|
.card {
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
@@ -967,7 +970,10 @@ const PAGE_CSS = `
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
/** Wrapper documento HTML standalone. `inner` è già HTML sicuro. */
|
/** Wrapper documento HTML standalone. `inner` è già HTML sicuro. */
|
||||||
export function renderShell(inner: string): string {
|
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>
|
return `<!doctype html>
|
||||||
<html lang="it">
|
<html lang="it">
|
||||||
<head>
|
<head>
|
||||||
@@ -976,7 +982,7 @@ export function renderShell(inner: string): string {
|
|||||||
<meta name="color-scheme" content="light dark">
|
<meta name="color-scheme" content="light dark">
|
||||||
<meta name="robots" content="noindex">
|
<meta name="robots" content="noindex">
|
||||||
<title>${escapeHtml(PAGE_TITLE)}</title>
|
<title>${escapeHtml(PAGE_TITLE)}</title>
|
||||||
<style>${PAGE_CSS}</style>
|
<style>${PAGE_CSS}</style>${override ? `\n<style>${override}</style>` : ""}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<script>(function(){if(window.self!==window.top){try{document.body.className="embed";}catch(e){}}})();</script>
|
<script>(function(){if(window.self!==window.top){try{document.body.className="embed";}catch(e){}}})();</script>
|
||||||
@@ -1048,7 +1054,7 @@ function stepIndicator(current: number): string {
|
|||||||
* (pattern dei modal moderni). I bottoni stanno nel footer e referenziano la form
|
* (pattern dei modal moderni). I bottoni stanno nel footer e referenziano la form
|
||||||
* via attributo `form=` (HTML5), così restano sempre visibili.
|
* via attributo `form=` (HTML5), così restano sempre visibili.
|
||||||
*/
|
*/
|
||||||
function stepLayout(parts: { head: string; body: string; foot?: string }): string {
|
function stepLayout(parts: { head: string; body: string; foot?: string }, theme?: ThemeTokens | null): string {
|
||||||
return `<div class="rc-head">
|
return `<div class="rc-head">
|
||||||
${parts.head}
|
${parts.head}
|
||||||
</div>
|
</div>
|
||||||
@@ -1069,7 +1075,7 @@ export function renderStep1(opts?: {
|
|||||||
error?: string;
|
error?: string;
|
||||||
orderName?: string;
|
orderName?: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
}): string {
|
}, theme?: ThemeTokens | null): string {
|
||||||
const orderName = opts?.orderName ?? "";
|
const orderName = opts?.orderName ?? "";
|
||||||
const email = opts?.email ?? "";
|
const email = opts?.email ?? "";
|
||||||
return renderShell(
|
return renderShell(
|
||||||
@@ -1086,6 +1092,7 @@ ${errorBanner(opts?.error)}
|
|||||||
</form>`,
|
</form>`,
|
||||||
foot: `<button type="submit" form="rcform" class="btn btn-primary">Continua</button>`,
|
foot: `<button type="submit" form="rcform" class="btn btn-primary">Continua</button>`,
|
||||||
}),
|
}),
|
||||||
|
theme,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1098,7 +1105,7 @@ export function renderStep2(data: {
|
|||||||
statementText: string;
|
statementText: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
notice?: string;
|
notice?: string;
|
||||||
}): string {
|
}, theme?: ThemeTokens | null): string {
|
||||||
const customerName = data.customerName ?? "";
|
const customerName = data.customerName ?? "";
|
||||||
return renderShell(
|
return renderShell(
|
||||||
stepLayout({
|
stepLayout({
|
||||||
@@ -1122,6 +1129,7 @@ export function renderStep2(data: {
|
|||||||
</form>`,
|
</form>`,
|
||||||
foot: `<button type="submit" form="rcform" class="btn btn-primary">Continua</button>`,
|
foot: `<button type="submit" form="rcform" class="btn btn-primary">Continua</button>`,
|
||||||
}),
|
}),
|
||||||
|
theme,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1133,7 +1141,7 @@ export function renderStep3(data: {
|
|||||||
customerName: string;
|
customerName: string;
|
||||||
statementText: string;
|
statementText: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
}): string {
|
}, theme?: ThemeTokens | null): string {
|
||||||
return renderShell(
|
return renderShell(
|
||||||
stepLayout({
|
stepLayout({
|
||||||
head: stepHead(3, "Controlla i dati prima di confermare."),
|
head: stepHead(3, "Controlla i dati prima di confermare."),
|
||||||
@@ -1169,6 +1177,7 @@ export function renderStep3(data: {
|
|||||||
<button type="submit" form="rcconfirm" class="btn btn-primary">${escapeHtml(CONFIRM_LABEL)}</button>
|
<button type="submit" form="rcconfirm" class="btn btn-primary">${escapeHtml(CONFIRM_LABEL)}</button>
|
||||||
</div>`,
|
</div>`,
|
||||||
}),
|
}),
|
||||||
|
theme,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1177,7 +1186,7 @@ export function renderStep4(data: {
|
|||||||
line1: string;
|
line1: string;
|
||||||
line2: string;
|
line2: string;
|
||||||
line3: string;
|
line3: string;
|
||||||
}): string {
|
}, theme?: ThemeTokens | null): string {
|
||||||
return renderShell(
|
return renderShell(
|
||||||
stepLayout({
|
stepLayout({
|
||||||
head: `${stepIndicator(4)}`,
|
head: `${stepIndicator(4)}`,
|
||||||
@@ -1188,6 +1197,7 @@ export function renderStep4(data: {
|
|||||||
<p>${escapeHtml(data.line3)}</p>
|
<p>${escapeHtml(data.line3)}</p>
|
||||||
</div>`,
|
</div>`,
|
||||||
}),
|
}),
|
||||||
|
theme,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
146
app/app/lib/theme.ts
Normal file
146
app/app/lib/theme.ts
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
/**
|
||||||
|
* Motore di stile del form di recesso — Livello 1 (token) e Livello 3 (CSS custom).
|
||||||
|
*
|
||||||
|
* Il form e' gia' interamente tokenizzato (`:root` in recesso.server). Qui NON
|
||||||
|
* riscriviamo il CSS: generiamo un blocco di override che viene iniettato DOPO
|
||||||
|
* quello di base. Cosi' il default resta sempre valido anche se il merchant
|
||||||
|
* sbaglia una configurazione.
|
||||||
|
*
|
||||||
|
* Modulo PURO (niente node/server): lo usa sia il render dello storefront sia
|
||||||
|
* l'anteprima nell'admin.
|
||||||
|
*
|
||||||
|
* ⚠ Tutto cio' che arriva dal merchant e' sanificato qui, non a valle.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ThemeTokens {
|
||||||
|
accent?: string | null; // link, focus
|
||||||
|
buttonBg?: string | null; // bottone primario
|
||||||
|
buttonText?: string | null;
|
||||||
|
radius?: number | null; // px
|
||||||
|
font?: string | null; // chiave di FONT_PRESETS
|
||||||
|
width?: number | null; // px, larghezza max della card
|
||||||
|
customCss?: string | null; // livello 3
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FONT_PRESETS: Record<string, string> = {
|
||||||
|
system:
|
||||||
|
'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
|
||||||
|
sans: 'Helvetica, Arial, "Helvetica Neue", sans-serif',
|
||||||
|
serif: 'Georgia, "Times New Roman", Times, serif',
|
||||||
|
mono: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FONT_OPTIONS = [
|
||||||
|
{ label: "Di sistema (consigliato)", value: "system" },
|
||||||
|
{ label: "Sans serif", value: "sans" },
|
||||||
|
{ label: "Serif", value: "serif" },
|
||||||
|
{ label: "Monospazio", value: "mono" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const RADIUS_MIN = 0;
|
||||||
|
export const RADIUS_MAX = 32;
|
||||||
|
export const WIDTH_MIN = 360;
|
||||||
|
export const WIDTH_MAX = 900;
|
||||||
|
export const CUSTOM_CSS_MAX = 4000;
|
||||||
|
|
||||||
|
const HEX = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
|
||||||
|
|
||||||
|
/** Colore valido? Accettiamo solo esadecimali: niente url(), niente espressioni. */
|
||||||
|
export function isHexColor(v: unknown): v is string {
|
||||||
|
return typeof v === "string" && HEX.test(v.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function expand(hex: string): [number, number, number] {
|
||||||
|
let h = hex.trim().slice(1);
|
||||||
|
if (h.length === 3) h = h[0]! + h[0]! + h[1]! + h[1]! + h[2]! + h[2]!;
|
||||||
|
return [
|
||||||
|
parseInt(h.slice(0, 2), 16),
|
||||||
|
parseInt(h.slice(2, 4), 16),
|
||||||
|
parseInt(h.slice(4, 6), 16),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** rgba() dal colore, per l'anello di focus. */
|
||||||
|
export function hexToRgba(hex: string, alpha: number): string {
|
||||||
|
const [r, g, b] = expand(hex);
|
||||||
|
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Scurisce (amount<0) o schiarisce (amount>0) verso nero/bianco. */
|
||||||
|
export function shade(hex: string, amount: number): string {
|
||||||
|
const [r, g, b] = expand(hex);
|
||||||
|
const t = amount < 0 ? 0 : 255;
|
||||||
|
const p = Math.abs(amount);
|
||||||
|
const mix = (c: number) => Math.round((t - c) * p + c);
|
||||||
|
const to2 = (c: number) => mix(c).toString(16).padStart(2, "0");
|
||||||
|
return `#${to2(r)}${to2(g)}${to2(b)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(n: number, min: number, max: number): number {
|
||||||
|
return Math.min(max, Math.max(min, n));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CSS custom: non possiamo permettere che il merchant esca dal blocco <style>
|
||||||
|
* o carichi risorse esterne. Niente JS possibile via CSS sui browser moderni,
|
||||||
|
* ma `</style` e `@import` vanno tolti comunque.
|
||||||
|
*/
|
||||||
|
export function sanitizeCustomCss(css: string): string {
|
||||||
|
return css
|
||||||
|
.slice(0, CUSTOM_CSS_MAX)
|
||||||
|
.replace(/<\/\s*style/gi, "")
|
||||||
|
.replace(/@import[^;]*;?/gi, "")
|
||||||
|
.replace(/expression\s*\(/gi, "")
|
||||||
|
.replace(/javascript\s*:/gi, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Blocco di override. Stringa vuota se il merchant non ha configurato nulla:
|
||||||
|
* in quel caso valgono i default del form.
|
||||||
|
*/
|
||||||
|
export function themeStyle(t?: ThemeTokens | null): string {
|
||||||
|
if (!t) return "";
|
||||||
|
const vars: string[] = [];
|
||||||
|
|
||||||
|
if (isHexColor(t.accent)) {
|
||||||
|
const a = t.accent.trim();
|
||||||
|
vars.push(`--accent: ${a};`);
|
||||||
|
vars.push(`--focus-ring: ${hexToRgba(a, 0.24)};`);
|
||||||
|
}
|
||||||
|
if (isHexColor(t.buttonBg)) {
|
||||||
|
const b = t.buttonBg.trim();
|
||||||
|
vars.push(`--primary-bg: ${b};`);
|
||||||
|
vars.push(`--primary-bg-hover: ${shade(b, -0.18)};`);
|
||||||
|
}
|
||||||
|
if (isHexColor(t.buttonText)) {
|
||||||
|
vars.push(`--primary-text: ${t.buttonText.trim()};`);
|
||||||
|
}
|
||||||
|
if (typeof t.radius === "number" && Number.isFinite(t.radius)) {
|
||||||
|
const r = clamp(Math.round(t.radius), RADIUS_MIN, RADIUS_MAX);
|
||||||
|
vars.push(`--radius: ${r}px;`);
|
||||||
|
vars.push(`--radius-sm: ${Math.max(0, r - 5)}px;`);
|
||||||
|
}
|
||||||
|
if (typeof t.width === "number" && Number.isFinite(t.width)) {
|
||||||
|
vars.push(
|
||||||
|
`--card-max: ${clamp(Math.round(t.width), WIDTH_MIN, WIDTH_MAX)}px;`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const font = t.font && FONT_PRESETS[t.font] ? FONT_PRESETS[t.font] : null;
|
||||||
|
if (font) vars.push(`--font: ${font};`);
|
||||||
|
|
||||||
|
const root = vars.length ? `:root{${vars.join("")}}` : "";
|
||||||
|
const custom = t.customCss?.trim()
|
||||||
|
? sanitizeCustomCss(t.customCss.trim())
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return `${root}${custom}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Anteprima admin: token di esempio quando il merchant non ha ancora salvato. */
|
||||||
|
export const THEME_DEFAULTS: Required<
|
||||||
|
Pick<ThemeTokens, "radius" | "width" | "font">
|
||||||
|
> = {
|
||||||
|
radius: 14,
|
||||||
|
width: 520,
|
||||||
|
font: "system",
|
||||||
|
};
|
||||||
@@ -55,6 +55,24 @@ import {
|
|||||||
sha256,
|
sha256,
|
||||||
} from "../lib/recesso.server";
|
} from "../lib/recesso.server";
|
||||||
import type { MatchedOrder } from "../lib/recesso.server";
|
import type { MatchedOrder } from "../lib/recesso.server";
|
||||||
|
import type { ThemeTokens } from "../lib/theme";
|
||||||
|
|
||||||
|
/** Token di stile del merchant (livello 1 + 3). Null = default del form. */
|
||||||
|
async function loadTheme(shop: string): Promise<ThemeTokens | null> {
|
||||||
|
const s = await db.settings
|
||||||
|
.findUnique({ where: { shop } })
|
||||||
|
.catch(() => null);
|
||||||
|
if (!s) return null;
|
||||||
|
return {
|
||||||
|
accent: s.themeAccent,
|
||||||
|
buttonBg: s.themeButtonBg,
|
||||||
|
buttonText: s.themeButtonText,
|
||||||
|
radius: s.themeRadius,
|
||||||
|
font: s.themeFont,
|
||||||
|
width: s.themeWidth,
|
||||||
|
customCss: s.themeCustomCss,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** Errore diagnosticabile ma senza PII: maschera gli indirizzi email. */
|
/** Errore diagnosticabile ma senza PII: maschera gli indirizzi email. */
|
||||||
function redactErr(msg: string): string {
|
function redactErr(msg: string): string {
|
||||||
@@ -109,8 +127,9 @@ async function checkCompliance(
|
|||||||
|
|
||||||
// GET /apps/recesso -> Step 1 (form di lookup).
|
// GET /apps/recesso -> Step 1 (form di lookup).
|
||||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||||
await authenticate.public.appProxy(request);
|
const { session } = await authenticate.public.appProxy(request);
|
||||||
return htmlResponse(renderStep1());
|
const theme = session ? await loadTheme(session.shop) : null;
|
||||||
|
return htmlResponse(renderStep1(undefined, theme));
|
||||||
};
|
};
|
||||||
|
|
||||||
export const action = async ({ request }: ActionFunctionArgs) => {
|
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||||
@@ -119,9 +138,11 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||||||
// Narrowing: senza sessione offline non abbiamo Admin API per lo shop.
|
// Narrowing: senza sessione offline non abbiamo Admin API per lo shop.
|
||||||
// Multi-tenant: lo shop lo prendiamo SOLO da session.shop, mai dal client.
|
// Multi-tenant: lo shop lo prendiamo SOLO da session.shop, mai dal client.
|
||||||
if (!session || !admin) {
|
if (!session || !admin) {
|
||||||
return htmlResponse(renderStep1({ error: ERROR.generic }));
|
// Senza sessione non conosciamo lo shop: nessun tema da applicare.
|
||||||
|
return htmlResponse(renderStep1({ error: ERROR.generic }, null));
|
||||||
}
|
}
|
||||||
const shop = session.shop;
|
const shop = session.shop;
|
||||||
|
const theme = await loadTheme(shop);
|
||||||
|
|
||||||
const form = await request.formData();
|
const form = await request.formData();
|
||||||
const intent = String(form.get("intent") ?? "");
|
const intent = String(form.get("intent") ?? "");
|
||||||
@@ -141,7 +162,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||||||
error: ERROR.missingField,
|
error: ERROR.missingField,
|
||||||
orderName: orderNameInput,
|
orderName: orderNameInput,
|
||||||
email: emailInput,
|
email: emailInput,
|
||||||
}),
|
}, theme),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!isValidEmail(emailInput)) {
|
if (!isValidEmail(emailInput)) {
|
||||||
@@ -150,7 +171,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||||||
error: ERROR.invalidEmail,
|
error: ERROR.invalidEmail,
|
||||||
orderName: orderNameInput,
|
orderName: orderNameInput,
|
||||||
email: emailInput,
|
email: emailInput,
|
||||||
}),
|
}, theme),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,7 +190,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||||||
error: ERROR.generic,
|
error: ERROR.generic,
|
||||||
orderName: orderNameInput,
|
orderName: orderNameInput,
|
||||||
email: emailInput,
|
email: emailInput,
|
||||||
}),
|
}, theme),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,7 +212,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||||||
error: ERROR.lookupNoMatch,
|
error: ERROR.lookupNoMatch,
|
||||||
orderName: orderNameInput,
|
orderName: orderNameInput,
|
||||||
email: emailInput,
|
email: emailInput,
|
||||||
}),
|
}, theme),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,7 +224,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||||||
error: block,
|
error: block,
|
||||||
orderName: orderNameInput,
|
orderName: orderNameInput,
|
||||||
email: emailInput,
|
email: emailInput,
|
||||||
}),
|
}, theme),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,7 +239,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||||||
email: match.email,
|
email: match.email,
|
||||||
statementText: statementTemplate(match.orderName),
|
statementText: statementTemplate(match.orderName),
|
||||||
notice: orderClosed ? NOTICE.orderClosed : undefined,
|
notice: orderClosed ? NOTICE.orderClosed : undefined,
|
||||||
}),
|
}, theme),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,7 +256,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||||||
// Sicurezza: se mancano i riferimenti d'ordine (tamper/link diretto),
|
// Sicurezza: se mancano i riferimenti d'ordine (tamper/link diretto),
|
||||||
// riparti dallo Step 1 senza rivelare nulla.
|
// riparti dallo Step 1 senza rivelare nulla.
|
||||||
if (!orderId || !orderName) {
|
if (!orderId || !orderName) {
|
||||||
return htmlResponse(renderStep1({ error: ERROR.generic }));
|
return htmlResponse(renderStep1({ error: ERROR.generic }, theme));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!customerName || !email || !statementText) {
|
if (!customerName || !email || !statementText) {
|
||||||
@@ -247,7 +268,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||||||
customerName,
|
customerName,
|
||||||
statementText: statementText || statementTemplate(orderName),
|
statementText: statementText || statementTemplate(orderName),
|
||||||
error: ERROR.missingField,
|
error: ERROR.missingField,
|
||||||
}),
|
}, theme),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!isValidEmail(email)) {
|
if (!isValidEmail(email)) {
|
||||||
@@ -259,12 +280,12 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||||||
customerName,
|
customerName,
|
||||||
statementText,
|
statementText,
|
||||||
error: ERROR.invalidEmail,
|
error: ERROR.invalidEmail,
|
||||||
}),
|
}, theme),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return htmlResponse(
|
return htmlResponse(
|
||||||
renderStep3({ orderId, orderName, email, customerName, statementText }),
|
renderStep3({ orderId, orderName, email, customerName, statementText }, theme),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,7 +299,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||||||
const email = String(form.get("email") ?? "").trim();
|
const email = String(form.get("email") ?? "").trim();
|
||||||
const statementText = String(form.get("statementText") ?? "").trim();
|
const statementText = String(form.get("statementText") ?? "").trim();
|
||||||
if (!orderId || !orderName) {
|
if (!orderId || !orderName) {
|
||||||
return htmlResponse(renderStep1({ error: ERROR.generic }));
|
return htmlResponse(renderStep1({ error: ERROR.generic }, theme));
|
||||||
}
|
}
|
||||||
return htmlResponse(
|
return htmlResponse(
|
||||||
renderStep2({
|
renderStep2({
|
||||||
@@ -287,7 +308,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||||||
email,
|
email,
|
||||||
customerName,
|
customerName,
|
||||||
statementText: statementText || statementTemplate(orderName),
|
statementText: statementText || statementTemplate(orderName),
|
||||||
}),
|
}, theme),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,7 +331,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||||||
!statementText ||
|
!statementText ||
|
||||||
!isValidEmail(email)
|
!isValidEmail(email)
|
||||||
) {
|
) {
|
||||||
return htmlResponse(renderStep1({ error: ERROR.generic }));
|
return htmlResponse(renderStep1({ error: ERROR.generic }, theme));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rate-limit ANCHE sulla conferma. Senza, l'endpoint e' un amplificatore:
|
// Rate-limit ANCHE sulla conferma. Senza, l'endpoint e' un amplificatore:
|
||||||
@@ -324,20 +345,20 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||||||
detail: orderName,
|
detail: orderName,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return htmlResponse(renderStep1({ error: ERROR.generic }));
|
return htmlResponse(renderStep1({ error: ERROR.generic }, theme));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-verifica server-side (integrità hidden fields / anti-tamper):
|
// Re-verifica server-side (integrità hidden fields / anti-tamper):
|
||||||
// l'ordine deve ancora esistere e l'email combaciare per questo shop.
|
// l'ordine deve ancora esistere e l'email combaciare per questo shop.
|
||||||
const match = await lookupOrder(admin, orderName, email);
|
const match = await lookupOrder(admin, orderName, email);
|
||||||
if (!match || match.orderId !== orderId) {
|
if (!match || match.orderId !== orderId) {
|
||||||
return htmlResponse(renderStep1({ error: ERROR.lookupNoMatch }));
|
return htmlResponse(renderStep1({ error: ERROR.lookupNoMatch }, theme));
|
||||||
}
|
}
|
||||||
|
|
||||||
// A6: re-check finestra + esclusioni (anti-tamper) prima di registrare.
|
// A6: re-check finestra + esclusioni (anti-tamper) prima di registrare.
|
||||||
const block = await checkCompliance(shop, match);
|
const block = await checkCompliance(shop, match);
|
||||||
if (block) {
|
if (block) {
|
||||||
return htmlResponse(renderStep1({ error: block }));
|
return htmlResponse(renderStep1({ error: block }, theme));
|
||||||
}
|
}
|
||||||
|
|
||||||
// IDEMPOTENZA: il diritto di recesso si esercita UNA volta per contratto.
|
// IDEMPOTENZA: il diritto di recesso si esercita UNA volta per contratto.
|
||||||
@@ -391,7 +412,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
return htmlResponse(renderStep1({ error: ERROR.generic }));
|
return htmlResponse(renderStep1({ error: ERROR.generic }, theme));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
await db.auditLog.create({
|
await db.auditLog.create({
|
||||||
@@ -520,6 +541,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||||||
return htmlResponse(
|
return htmlResponse(
|
||||||
renderStep4(
|
renderStep4(
|
||||||
duplicateMessage(match.orderName, transmittedLabel, email, resent),
|
duplicateMessage(match.orderName, transmittedLabel, email, resent),
|
||||||
|
theme,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -666,11 +688,11 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||||||
email,
|
email,
|
||||||
!!receipt?.ok,
|
!!receipt?.ok,
|
||||||
);
|
);
|
||||||
return htmlResponse(renderStep4(msg));
|
return htmlResponse(renderStep4(msg, theme));
|
||||||
}
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
// Intent sconosciuto: torna allo Step 1 senza rivelare dettagli.
|
// Intent sconosciuto: torna allo Step 1 senza rivelare dettagli.
|
||||||
return htmlResponse(renderStep1());
|
return htmlResponse(renderStep1(undefined, theme));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -17,10 +17,11 @@ primary_region = "fra" # Frankfurt (EU data residency)
|
|||||||
force_https = true
|
force_https = true
|
||||||
auto_stop_machines = true
|
auto_stop_machines = true
|
||||||
auto_start_machines = true
|
auto_start_machines = true
|
||||||
# Una macchina sempre calda. Con 0, un cold start misurato ha richiesto ~38s:
|
# TODO(go-live): portare a 1. Con 0 un cold start misurato ha richiesto ~38s, e
|
||||||
# l'art. 54-bis pretende una funzione "sempre accessibile" e "facilmente
|
# l'art. 54-bis pretende una funzione "sempre accessibile" e "facilmente
|
||||||
# utilizzabile", e 38 secondi dopo il clic sono un ostacolo. Costo: 1 macchina.
|
# utilizzabile". Rimandato per scelta (costo: 1 macchina sempre accesa).
|
||||||
min_machines_running = 1
|
# Vedi PROTECTED-CUSTOMER-DATA.md §5.2.
|
||||||
|
min_machines_running = 0
|
||||||
|
|
||||||
[[vm]]
|
[[vm]]
|
||||||
size = "shared-cpu-1x"
|
size = "shared-cpu-1x"
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Settings" ADD COLUMN "themeAccent" TEXT,
|
||||||
|
ADD COLUMN "themeButtonBg" TEXT,
|
||||||
|
ADD COLUMN "themeButtonText" TEXT,
|
||||||
|
ADD COLUMN "themeCustomCss" TEXT,
|
||||||
|
ADD COLUMN "themeFont" TEXT,
|
||||||
|
ADD COLUMN "themeRadius" INTEGER,
|
||||||
|
ADD COLUMN "themeWidth" INTEGER;
|
||||||
@@ -69,6 +69,14 @@ model Settings {
|
|||||||
smtpPass String? // cifrato AES-256-GCM (mai in chiaro)
|
smtpPass String? // cifrato AES-256-GCM (mai in chiaro)
|
||||||
smtpSecure Boolean @default(false)
|
smtpSecure Boolean @default(false)
|
||||||
smtpFrom String?
|
smtpFrom String?
|
||||||
|
// Motore di stile del form (livello 1 = token, livello 3 = CSS custom).
|
||||||
|
themeAccent String?
|
||||||
|
themeButtonBg String?
|
||||||
|
themeButtonText String?
|
||||||
|
themeRadius Int?
|
||||||
|
themeFont String?
|
||||||
|
themeWidth Int?
|
||||||
|
themeCustomCss String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user