R6: tab Aspetto con anteprima fedele del form

Sesto tab delle Impostazioni. Il merchant configura accento, sfondo/testo del
bottone, carattere, raggio, larghezza e CSS custom; l'anteprima e' resa con lo
STESSO renderer e lo STESSO CSS dello storefront (recesso.view), non con una
ricostruzione approssimata.

- Validazione al salvataggio: un valore non valido NON viene scritto (colori solo
  esadecimali, raggio e larghezza clampati, font da whitelist). Cosi' il form
  ricade sul default invece di rompersi.
- ColorField: campo testo + selettore nativo, con errore se l'esadecimale e' malformato.
- Verificato con build di produzione che nel bundle client non finisca codice
  server-only (crypto.server, nodemailer, PrismaClient, APP_ENCRYPTION_KEY: zero
  occorrenze); la vista pura invece c'e', come deve.
This commit is contained in:
2026-07-10 14:18:07 +02:00
parent e51828a8d0
commit 99935ab6fe

View File

@@ -12,6 +12,7 @@ import {
Card,
Tabs,
TextField,
Select,
Button,
ButtonGroup,
Banner,
@@ -27,6 +28,18 @@ import { authenticate } from "../shopify.server";
import db from "../db.server";
import { decryptSecret, encryptSecret } from "../lib/crypto.server";
import { sendTestEmail } from "../lib/mailer.server";
import { renderStep2 } from "../lib/recesso.view";
import { statementTemplate } from "../lib/recesso.copy";
import {
FONT_OPTIONS,
FONT_PRESETS,
RADIUS_MAX,
RADIUS_MIN,
WIDTH_MAX,
WIDTH_MIN,
isHexColor,
type ThemeTokens,
} from "../lib/theme";
import {
DEFAULT_INTRO,
DEFAULT_NOTE,
@@ -69,6 +82,13 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
// Esiste un SMTP di default a livello app (env/fly secret)? In prod oggi NO:
// senza SMTP per-shop non parte nessuna ricevuta.
appDefaultSmtp: !!process.env.SMTP_HOST,
themeAccent: s?.themeAccent ?? "",
themeButtonBg: s?.themeButtonBg ?? "",
themeButtonText: s?.themeButtonText ?? "",
themeRadius: s?.themeRadius != null ? String(s.themeRadius) : "",
themeFont: s?.themeFont ?? "system",
themeWidth: s?.themeWidth != null ? String(s.themeWidth) : "",
themeCustomCss: s?.themeCustomCss ?? "",
};
};
@@ -144,6 +164,15 @@ export const action = async ({ request }: ActionFunctionArgs) => {
smtpUser: String(f.get("smtpUser") ?? "").trim() || null,
smtpSecure: f.get("smtpSecure") === "true",
smtpFrom: String(f.get("smtpFrom") ?? "").trim() || null,
// Tema: si salva solo cio' che e' valido. Un valore sbagliato non viene
// scritto, cosi' il form ricade sul default invece di rompersi.
themeAccent: hexOrNull(f.get("themeAccent")),
themeButtonBg: hexOrNull(f.get("themeButtonBg")),
themeButtonText: hexOrNull(f.get("themeButtonText")),
themeRadius: intInRange(f.get("themeRadius"), RADIUS_MIN, RADIUS_MAX),
themeWidth: intInRange(f.get("themeWidth"), WIDTH_MIN, WIDTH_MAX),
themeFont: fontOrNull(f.get("themeFont")),
themeCustomCss: String(f.get("themeCustomCss") ?? "").trim() || null,
};
// Password SMTP: cifrata solo se fornita; vuota = invariata.
@@ -160,6 +189,65 @@ export const action = async ({ request }: ActionFunctionArgs) => {
return { ok: true, tested: false, error: null };
};
function hexOrNull(v: FormDataEntryValue | null): string | null {
const s = String(v ?? "").trim();
return isHexColor(s) ? s : null;
}
function intInRange(
v: FormDataEntryValue | null,
min: number,
max: number,
): number | null {
const n = Number(String(v ?? "").trim());
if (!Number.isFinite(n) || n === 0) return null;
return Math.min(max, Math.max(min, Math.round(n)));
}
function fontOrNull(v: FormDataEntryValue | null): string | null {
const s = String(v ?? "").trim();
return s && FONT_PRESETS[s] ? s : null;
}
function ColorField(props: {
label: string;
value: string;
onChange: (v: string) => void;
placeholder?: string;
helpText?: string;
}) {
const valid = isHexColor(props.value);
const invalid = !valid && props.value.trim().length > 0;
return (
<InlineStack gap="300" blockAlign="end" wrap={false}>
<div style={{ flexGrow: 1 }}>
<TextField
label={props.label}
value={props.value}
onChange={props.onChange}
autoComplete="off"
placeholder={props.placeholder}
helpText={props.helpText}
error={invalid ? "Serve un esadecimale, es. #005bd3" : undefined}
/>
</div>
<input
type="color"
aria-label={props.label}
value={valid ? props.value : "#000000"}
onChange={(e) => props.onChange(e.currentTarget.value)}
style={{
width: 40,
height: 36,
padding: 0,
border: "1px solid #c9cccf",
borderRadius: 8,
background: "none",
cursor: "pointer",
}}
/>
</InlineStack>
);
}
function opFrame(html: string, height = 130) {
const doc = `<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;">${html}</table>`;
return (
@@ -210,6 +298,13 @@ export default function SettingsPage() {
const [smtpSecure, setSmtpSecure] = useState(d.smtpSecure);
const [smtpFrom, setSmtpFrom] = useState(d.smtpFrom);
const [testTo, setTestTo] = useState(d.notifyEmail);
const [themeAccent, setThemeAccent] = useState(d.themeAccent);
const [themeButtonBg, setThemeButtonBg] = useState(d.themeButtonBg);
const [themeButtonText, setThemeButtonText] = useState(d.themeButtonText);
const [themeRadius, setThemeRadius] = useState(d.themeRadius);
const [themeFont, setThemeFont] = useState(d.themeFont);
const [themeWidth, setThemeWidth] = useState(d.themeWidth);
const [themeCustomCss, setThemeCustomCss] = useState(d.themeCustomCss);
const [showSaved, setShowSaved] = useState(false);
const saving = nav.state === "submitting";
@@ -255,6 +350,37 @@ export default function SettingsPage() {
[opTextShipped, opTextUnfulfilled, returnAddress, returnAtCustomerExpense],
);
// Anteprima FEDELE: stesso renderer e stesso CSS dello storefront (recesso.view).
const themePreview = useMemo(() => {
const tokens: ThemeTokens = {
accent: themeAccent,
buttonBg: themeButtonBg,
buttonText: themeButtonText,
radius: Number(themeRadius) || null,
font: themeFont,
width: Number(themeWidth) || null,
customCss: themeCustomCss,
};
return renderStep2(
{
orderId: "gid://shopify/Order/0",
orderName: "#1001",
email: "mario.rossi@example.com",
customerName: "Mario Rossi",
statementText: statementTemplate("#1001"),
},
tokens,
);
}, [
themeAccent,
themeButtonBg,
themeButtonText,
themeRadius,
themeFont,
themeWidth,
themeCustomCss,
]);
const handleTest = () => {
setShowSaved(false);
const fd = new FormData();
@@ -294,9 +420,27 @@ export default function SettingsPage() {
fd.set("smtpPass", smtpPass);
fd.set("smtpSecure", String(smtpSecure));
fd.set("smtpFrom", smtpFrom);
fd.set("themeAccent", themeAccent);
fd.set("themeButtonBg", themeButtonBg);
fd.set("themeButtonText", themeButtonText);
fd.set("themeRadius", themeRadius);
fd.set("themeFont", themeFont);
fd.set("themeWidth", themeWidth);
fd.set("themeCustomCss", themeCustomCss);
submit(fd, { method: "post" });
};
const resetTheme = () => {
setThemeAccent("");
setThemeButtonBg("");
setThemeButtonText("");
setThemeRadius("");
setThemeFont("system");
setThemeWidth("");
setThemeCustomCss("");
setShowSaved(false);
};
const resetEmail = () => {
setSubject(DEFAULT_SUBJECT);
setIntro(DEFAULT_INTRO);
@@ -318,6 +462,7 @@ export default function SettingsPage() {
{ id: "regole", content: "Regole recesso" },
{ id: "reso", content: "Reso e stato ordine" },
{ id: "smtp", content: "Email (SMTP)" },
{ id: "aspetto", content: "Aspetto" },
];
return (
@@ -706,6 +851,117 @@ export default function SettingsPage() {
</BlockStack>
</Card>
) : null}
{tab === 5 ? (
<BlockStack gap="400">
<Card>
<BlockStack gap="400">
<BlockStack gap="100">
<Text as="h2" variant="headingMd">
Aspetto del form
</Text>
<Text as="p" tone="subdued">
Campi vuoti = default. Un valore non valido non viene
salvato: il form ricade sul default invece di rompersi.
</Text>
</BlockStack>
<ColorField
label="Colore accento"
value={themeAccent}
onChange={setThemeAccent}
placeholder="#005bd3"
helpText="Link e anello di focus."
/>
<ColorField
label="Sfondo del bottone"
value={themeButtonBg}
onChange={setThemeButtonBg}
placeholder="#1a1a1a"
helpText="Lo stato hover viene calcolato da qui."
/>
<ColorField
label="Testo del bottone"
value={themeButtonText}
onChange={setThemeButtonText}
placeholder="#ffffff"
/>
<Select
label="Carattere"
options={FONT_OPTIONS}
value={themeFont}
onChange={setThemeFont}
/>
<TextField
label="Raggio degli angoli"
type="number"
value={themeRadius}
onChange={setThemeRadius}
autoComplete="off"
min={RADIUS_MIN}
max={RADIUS_MAX}
suffix="px"
placeholder="14"
/>
<TextField
label="Larghezza massima"
type="number"
value={themeWidth}
onChange={setThemeWidth}
autoComplete="off"
min={WIDTH_MIN}
max={WIDTH_MAX}
suffix="px"
placeholder="520"
/>
<TextField
label="CSS personalizzato (avanzato)"
value={themeCustomCss}
onChange={setThemeCustomCss}
autoComplete="off"
multiline={5}
monospaced
helpText="Iniettato dopo il CSS di base. Vietati @import e tag di chiusura; massimo 4000 caratteri."
/>
<ButtonGroup>
<Button
variant="primary"
loading={saving}
onClick={handleSave}
>
Salva
</Button>
<Button onClick={resetTheme}>Ripristina default</Button>
</ButtonGroup>
</BlockStack>
</Card>
<Card>
<BlockStack gap="200">
<Text as="h2" variant="headingMd">
Anteprima
</Text>
<Text as="p" tone="subdued">
Stesso markup e stesso CSS che vedra' il cliente.
</Text>
<iframe
title="Anteprima form di recesso"
srcDoc={themePreview}
style={{
width: "100%",
height: "720px",
border: "1px solid #e1e1e1",
borderRadius: "8px",
background: "#fff",
}}
/>
</BlockStack>
</Card>
</BlockStack>
) : null}
</BlockStack>
</Layout.Section>
</Layout>