feat(theme): reduce Aspetto to a color-scheme preset, drop the token editor
The per-token editor (accent, button background, button text, font, radius, max width, custom CSS) let a merchant produce an unreadable or inaccessible form: the accent token only drives the keyboard focus ring, so a light accent hid it; button background/text had no contrast guard; custom CSS was arbitrary CSS injected into the page. Per the merchant's call, the appearance is now a fixed, accessible design with a single choice: light (default), dark, or auto. - Settings "Aspetto" tab: keep only the Schema colore select; remove the color fields, ColorField, the hex/int/font validators and their state. - proxy loadTheme: read only themeScheme; stop applying the other tokens. - Preview iframe made non-interactive (sandbox="allow-scripts", inert, tabIndex -1, pointer-events:none) with scrolling moved to the wrapper, since it demonstrates a fixed design and must not look clickable. The token columns stay in the schema, unread, so the customization surface can be re-exposed later without a migration. theme.ts (the engine) is untouched. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Mv83a29B4eFv5ixoj6PoE1
This commit is contained in:
@@ -32,14 +32,7 @@ import { renderStep2 } from "../lib/recesso.view";
|
||||
import { statementTemplate } from "../lib/recesso.copy";
|
||||
import {
|
||||
DEFAULT_SCHEME,
|
||||
FONT_OPTIONS,
|
||||
FONT_PRESETS,
|
||||
RADIUS_MAX,
|
||||
RADIUS_MIN,
|
||||
SCHEME_OPTIONS,
|
||||
WIDTH_MAX,
|
||||
WIDTH_MIN,
|
||||
isHexColor,
|
||||
schemeOrNull,
|
||||
type ThemeTokens,
|
||||
} from "../lib/theme";
|
||||
@@ -85,14 +78,7 @@ 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) : "",
|
||||
themeScheme: s?.themeScheme ?? DEFAULT_SCHEME,
|
||||
themeCustomCss: s?.themeCustomCss ?? "",
|
||||
};
|
||||
};
|
||||
|
||||
@@ -168,16 +154,9 @@ 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")),
|
||||
// Aspetto: solo lo schema colore. Le colonne dei token restano nel DB,
|
||||
// inerti, per poter riesporre la personalizzazione senza migrazioni.
|
||||
themeScheme: schemeOrNull(f.get("themeScheme")),
|
||||
themeCustomCss: String(f.get("themeCustomCss") ?? "").trim() || null,
|
||||
};
|
||||
|
||||
// Password SMTP: cifrata solo se fornita; vuota = invariata.
|
||||
@@ -194,64 +173,6 @@ 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>`;
|
||||
@@ -303,14 +224,7 @@ 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 [themeScheme, setThemeScheme] = useState(d.themeScheme);
|
||||
const [themeCustomCss, setThemeCustomCss] = useState(d.themeCustomCss);
|
||||
const [showSaved, setShowSaved] = useState(false);
|
||||
|
||||
const saving = nav.state === "submitting";
|
||||
@@ -358,16 +272,7 @@ export default function SettingsPage() {
|
||||
|
||||
// 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,
|
||||
scheme: themeScheme,
|
||||
customCss: themeCustomCss,
|
||||
};
|
||||
const tokens: ThemeTokens = { scheme: themeScheme };
|
||||
return renderStep2(
|
||||
{
|
||||
orderId: "gid://shopify/Order/0",
|
||||
@@ -378,16 +283,7 @@ export default function SettingsPage() {
|
||||
},
|
||||
tokens,
|
||||
);
|
||||
}, [
|
||||
themeAccent,
|
||||
themeButtonBg,
|
||||
themeButtonText,
|
||||
themeRadius,
|
||||
themeFont,
|
||||
themeWidth,
|
||||
themeScheme,
|
||||
themeCustomCss,
|
||||
]);
|
||||
}, [themeScheme]);
|
||||
|
||||
const handleTest = () => {
|
||||
setShowSaved(false);
|
||||
@@ -428,25 +324,12 @@ 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("themeScheme", themeScheme);
|
||||
fd.set("themeCustomCss", themeCustomCss);
|
||||
submit(fd, { method: "post" });
|
||||
};
|
||||
|
||||
const resetTheme = () => {
|
||||
setThemeAccent("");
|
||||
setThemeButtonBg("");
|
||||
setThemeButtonText("");
|
||||
setThemeRadius("");
|
||||
setThemeFont("system");
|
||||
setThemeWidth("");
|
||||
setThemeCustomCss("");
|
||||
setThemeScheme(DEFAULT_SCHEME);
|
||||
setShowSaved(false);
|
||||
};
|
||||
|
||||
@@ -870,8 +753,8 @@ export default function SettingsPage() {
|
||||
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.
|
||||
Il form usa una grafica gia' pronta, leggibile e
|
||||
accessibile. Scegli solo se mostrarla chiara o scura.
|
||||
</Text>
|
||||
</BlockStack>
|
||||
|
||||
@@ -883,66 +766,6 @@ export default function SettingsPage() {
|
||||
helpText="Il form si apre in un riquadro sopra il tuo tema, che l'app non puo' leggere. Scegli 'Segue il sistema' solo se il tuo tema ha una versione scura."
|
||||
/>
|
||||
|
||||
<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"
|
||||
@@ -962,19 +785,47 @@ export default function SettingsPage() {
|
||||
Anteprima
|
||||
</Text>
|
||||
<Text as="p" tone="subdued">
|
||||
Stesso markup e stesso CSS che vedra' il cliente.
|
||||
Stesso markup e stesso CSS che vedra' il cliente. Non e'
|
||||
interattiva: i campi e i bottoni non rispondono.
|
||||
</Text>
|
||||
<iframe
|
||||
title="Anteprima form di recesso"
|
||||
srcDoc={themePreview}
|
||||
{/*
|
||||
sandbox senza allow-forms / allow-same-origin /
|
||||
allow-top-navigation: l'anteprima non puo' inviare il
|
||||
form ne' navigare. `allow-scripts` serve solo allo script
|
||||
inline che riconosce l'iframe e applica il layout
|
||||
compatto, lo stesso che il cliente vede nel modal.
|
||||
pointer-events + inert tolgono anche mouse e tastiera,
|
||||
cosi' non sembra cliccabile.
|
||||
*/}
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "720px",
|
||||
maxHeight: "720px",
|
||||
overflowY: "auto",
|
||||
border: "1px solid #e1e1e1",
|
||||
borderRadius: "8px",
|
||||
background: "#fff",
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<iframe
|
||||
title="Anteprima form di recesso (non interattiva)"
|
||||
srcDoc={themePreview}
|
||||
sandbox="allow-scripts"
|
||||
// @ts-expect-error inert e' valido in HTML, non ancora nei tipi React 18
|
||||
inert=""
|
||||
tabIndex={-1}
|
||||
scrolling="no"
|
||||
style={{
|
||||
display: "block",
|
||||
width: "100%",
|
||||
// Alto abbastanza da contenere il form: l'iframe non
|
||||
// puo' scrollare (pointer-events: none), scrolla il
|
||||
// contenitore.
|
||||
height: "1100px",
|
||||
border: 0,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</BlockStack>
|
||||
</Card>
|
||||
</BlockStack>
|
||||
|
||||
@@ -63,16 +63,10 @@ async function loadTheme(shop: string): Promise<ThemeTokens | null> {
|
||||
.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,
|
||||
scheme: s.themeScheme,
|
||||
customCss: s.themeCustomCss,
|
||||
};
|
||||
// Solo lo schema colore e' configurabile. Le colonne dei token (accento,
|
||||
// bottone, raggio, carattere, larghezza, CSS custom) restano nel DB ma non
|
||||
// vengono piu' lette: la grafica del form e' fissa e accessibile.
|
||||
return { scheme: s.themeScheme };
|
||||
}
|
||||
|
||||
/** Errore diagnosticabile ma senza PII: maschera gli indirizzi email. */
|
||||
|
||||
Reference in New Issue
Block a user