diff --git a/app/app/lib/recesso.server.ts b/app/app/lib/recesso.server.ts
index 7e85307..7f23108 100644
--- a/app/app/lib/recesso.server.ts
+++ b/app/app/lib/recesso.server.ts
@@ -16,6 +16,7 @@
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,
@@ -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);
--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: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Inter", sans-serif;
+ font-family: var(--font);
line-height: 1.55;
color: var(--text);
background: var(--bg);
-webkit-font-smoothing: antialiased;
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 {
background: var(--surface);
border: 1px solid var(--border);
@@ -967,7 +970,10 @@ const PAGE_CSS = `
`;
/** 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 `
@@ -976,7 +982,7 @@ export function renderShell(inner: string): string {
${escapeHtml(PAGE_TITLE)}
-
+${override ? `\n` : ""}
@@ -1048,7 +1054,7 @@ function stepIndicator(current: number): string {
* (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 {
+function stepLayout(parts: { head: string; body: string; foot?: string }, theme?: ThemeTokens | null): string {
return `
${parts.head}
@@ -1069,7 +1075,7 @@ export function renderStep1(opts?: {
error?: string;
orderName?: string;
email?: string;
-}): string {
+}, theme?: ThemeTokens | null): string {
const orderName = opts?.orderName ?? "";
const email = opts?.email ?? "";
return renderShell(
@@ -1086,6 +1092,7 @@ ${errorBanner(opts?.error)}
`,
foot: ``,
}),
+ theme,
);
}
@@ -1098,7 +1105,7 @@ export function renderStep2(data: {
statementText: string;
error?: string;
notice?: string;
-}): string {
+}, theme?: ThemeTokens | null): string {
const customerName = data.customerName ?? "";
return renderShell(
stepLayout({
@@ -1122,6 +1129,7 @@ export function renderStep2(data: {
`,
foot: ``,
}),
+ theme,
);
}
@@ -1133,7 +1141,7 @@ export function renderStep3(data: {
customerName: string;
statementText: string;
error?: string;
-}): string {
+}, theme?: ThemeTokens | null): string {
return renderShell(
stepLayout({
head: stepHead(3, "Controlla i dati prima di confermare."),
@@ -1169,6 +1177,7 @@ export function renderStep3(data: {
`,
}),
+ theme,
);
}
@@ -1177,7 +1186,7 @@ export function renderStep4(data: {
line1: string;
line2: string;
line3: string;
-}): string {
+}, theme?: ThemeTokens | null): string {
return renderShell(
stepLayout({
head: `${stepIndicator(4)}`,
@@ -1188,6 +1197,7 @@ export function renderStep4(data: {
${escapeHtml(data.line3)}
`,
}),
+ theme,
);
}
diff --git a/app/app/lib/theme.ts b/app/app/lib/theme.ts
new file mode 100644
index 0000000..85f27c9
--- /dev/null
+++ b/app/app/lib/theme.ts
@@ -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 = {
+ 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
+> = {
+ radius: 14,
+ width: 520,
+ font: "system",
+};
diff --git a/app/app/routes/proxy.tsx b/app/app/routes/proxy.tsx
index 22b5408..b27b9a1 100644
--- a/app/app/routes/proxy.tsx
+++ b/app/app/routes/proxy.tsx
@@ -55,6 +55,24 @@ import {
sha256,
} 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 {
+ 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. */
function redactErr(msg: string): string {
@@ -109,8 +127,9 @@ async function checkCompliance(
// GET /apps/recesso -> Step 1 (form di lookup).
export const loader = async ({ request }: LoaderFunctionArgs) => {
- await authenticate.public.appProxy(request);
- return htmlResponse(renderStep1());
+ const { session } = await authenticate.public.appProxy(request);
+ const theme = session ? await loadTheme(session.shop) : null;
+ return htmlResponse(renderStep1(undefined, theme));
};
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.
// Multi-tenant: lo shop lo prendiamo SOLO da session.shop, mai dal client.
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 theme = await loadTheme(shop);
const form = await request.formData();
const intent = String(form.get("intent") ?? "");
@@ -141,7 +162,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
error: ERROR.missingField,
orderName: orderNameInput,
email: emailInput,
- }),
+ }, theme),
);
}
if (!isValidEmail(emailInput)) {
@@ -150,7 +171,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
error: ERROR.invalidEmail,
orderName: orderNameInput,
email: emailInput,
- }),
+ }, theme),
);
}
@@ -169,7 +190,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
error: ERROR.generic,
orderName: orderNameInput,
email: emailInput,
- }),
+ }, theme),
);
}
@@ -191,7 +212,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
error: ERROR.lookupNoMatch,
orderName: orderNameInput,
email: emailInput,
- }),
+ }, theme),
);
}
@@ -203,7 +224,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
error: block,
orderName: orderNameInput,
email: emailInput,
- }),
+ }, theme),
);
}
@@ -218,7 +239,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
email: match.email,
statementText: statementTemplate(match.orderName),
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),
// riparti dallo Step 1 senza rivelare nulla.
if (!orderId || !orderName) {
- return htmlResponse(renderStep1({ error: ERROR.generic }));
+ return htmlResponse(renderStep1({ error: ERROR.generic }, theme));
}
if (!customerName || !email || !statementText) {
@@ -247,7 +268,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
customerName,
statementText: statementText || statementTemplate(orderName),
error: ERROR.missingField,
- }),
+ }, theme),
);
}
if (!isValidEmail(email)) {
@@ -259,12 +280,12 @@ export const action = async ({ request }: ActionFunctionArgs) => {
customerName,
statementText,
error: ERROR.invalidEmail,
- }),
+ }, theme),
);
}
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 statementText = String(form.get("statementText") ?? "").trim();
if (!orderId || !orderName) {
- return htmlResponse(renderStep1({ error: ERROR.generic }));
+ return htmlResponse(renderStep1({ error: ERROR.generic }, theme));
}
return htmlResponse(
renderStep2({
@@ -287,7 +308,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
email,
customerName,
statementText: statementText || statementTemplate(orderName),
- }),
+ }, theme),
);
}
@@ -310,7 +331,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
!statementText ||
!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:
@@ -324,20 +345,20 @@ export const action = async ({ request }: ActionFunctionArgs) => {
detail: orderName,
},
});
- return htmlResponse(renderStep1({ error: ERROR.generic }));
+ return htmlResponse(renderStep1({ error: ERROR.generic }, theme));
}
// Re-verifica server-side (integrità hidden fields / anti-tamper):
// l'ordine deve ancora esistere e l'email combaciare per questo shop.
const match = await lookupOrder(admin, orderName, email);
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.
const block = await checkCompliance(shop, match);
if (block) {
- return htmlResponse(renderStep1({ error: block }));
+ return htmlResponse(renderStep1({ error: block }, theme));
}
// IDEMPOTENZA: il diritto di recesso si esercita UNA volta per contratto.
@@ -391,7 +412,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
},
});
} catch {
- return htmlResponse(renderStep1({ error: ERROR.generic }));
+ return htmlResponse(renderStep1({ error: ERROR.generic }, theme));
}
} else {
await db.auditLog.create({
@@ -520,6 +541,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
return htmlResponse(
renderStep4(
duplicateMessage(match.orderName, transmittedLabel, email, resent),
+ theme,
),
);
}
@@ -666,11 +688,11 @@ export const action = async ({ request }: ActionFunctionArgs) => {
email,
!!receipt?.ok,
);
- return htmlResponse(renderStep4(msg));
+ return htmlResponse(renderStep4(msg, theme));
}
default:
// Intent sconosciuto: torna allo Step 1 senza rivelare dettagli.
- return htmlResponse(renderStep1());
+ return htmlResponse(renderStep1(undefined, theme));
}
};
diff --git a/app/fly.toml b/app/fly.toml
index 9fb91c1..709185c 100644
--- a/app/fly.toml
+++ b/app/fly.toml
@@ -17,10 +17,11 @@ primary_region = "fra" # Frankfurt (EU data residency)
force_https = true
auto_stop_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
- # utilizzabile", e 38 secondi dopo il clic sono un ostacolo. Costo: 1 macchina.
- min_machines_running = 1
+ # utilizzabile". Rimandato per scelta (costo: 1 macchina sempre accesa).
+ # Vedi PROTECTED-CUSTOMER-DATA.md §5.2.
+ min_machines_running = 0
[[vm]]
size = "shared-cpu-1x"
diff --git a/app/prisma/migrations/20260710110500_theme_tokens/migration.sql b/app/prisma/migrations/20260710110500_theme_tokens/migration.sql
new file mode 100644
index 0000000..8db275b
--- /dev/null
+++ b/app/prisma/migrations/20260710110500_theme_tokens/migration.sql
@@ -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;
diff --git a/app/prisma/schema.prisma b/app/prisma/schema.prisma
index 21ec71c..67f467a 100644
--- a/app/prisma/schema.prisma
+++ b/app/prisma/schema.prisma
@@ -69,6 +69,14 @@ model Settings {
smtpPass String? // cifrato AES-256-GCM (mai in chiaro)
smtpSecure Boolean @default(false)
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())
updatedAt DateTime @updatedAt