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:
@@ -12,6 +12,7 @@ import {
|
|||||||
Card,
|
Card,
|
||||||
Tabs,
|
Tabs,
|
||||||
TextField,
|
TextField,
|
||||||
|
Select,
|
||||||
Button,
|
Button,
|
||||||
ButtonGroup,
|
ButtonGroup,
|
||||||
Banner,
|
Banner,
|
||||||
@@ -27,6 +28,18 @@ import { authenticate } from "../shopify.server";
|
|||||||
import db from "../db.server";
|
import db from "../db.server";
|
||||||
import { decryptSecret, encryptSecret } from "../lib/crypto.server";
|
import { decryptSecret, encryptSecret } from "../lib/crypto.server";
|
||||||
import { sendTestEmail } from "../lib/mailer.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 {
|
import {
|
||||||
DEFAULT_INTRO,
|
DEFAULT_INTRO,
|
||||||
DEFAULT_NOTE,
|
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:
|
// Esiste un SMTP di default a livello app (env/fly secret)? In prod oggi NO:
|
||||||
// senza SMTP per-shop non parte nessuna ricevuta.
|
// senza SMTP per-shop non parte nessuna ricevuta.
|
||||||
appDefaultSmtp: !!process.env.SMTP_HOST,
|
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,
|
smtpUser: String(f.get("smtpUser") ?? "").trim() || null,
|
||||||
smtpSecure: f.get("smtpSecure") === "true",
|
smtpSecure: f.get("smtpSecure") === "true",
|
||||||
smtpFrom: String(f.get("smtpFrom") ?? "").trim() || null,
|
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.
|
// 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 };
|
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) {
|
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>`;
|
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 (
|
return (
|
||||||
@@ -210,6 +298,13 @@ export default function SettingsPage() {
|
|||||||
const [smtpSecure, setSmtpSecure] = useState(d.smtpSecure);
|
const [smtpSecure, setSmtpSecure] = useState(d.smtpSecure);
|
||||||
const [smtpFrom, setSmtpFrom] = useState(d.smtpFrom);
|
const [smtpFrom, setSmtpFrom] = useState(d.smtpFrom);
|
||||||
const [testTo, setTestTo] = useState(d.notifyEmail);
|
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 [showSaved, setShowSaved] = useState(false);
|
||||||
|
|
||||||
const saving = nav.state === "submitting";
|
const saving = nav.state === "submitting";
|
||||||
@@ -255,6 +350,37 @@ export default function SettingsPage() {
|
|||||||
[opTextShipped, opTextUnfulfilled, returnAddress, returnAtCustomerExpense],
|
[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 = () => {
|
const handleTest = () => {
|
||||||
setShowSaved(false);
|
setShowSaved(false);
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
@@ -294,9 +420,27 @@ export default function SettingsPage() {
|
|||||||
fd.set("smtpPass", smtpPass);
|
fd.set("smtpPass", smtpPass);
|
||||||
fd.set("smtpSecure", String(smtpSecure));
|
fd.set("smtpSecure", String(smtpSecure));
|
||||||
fd.set("smtpFrom", smtpFrom);
|
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" });
|
submit(fd, { method: "post" });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const resetTheme = () => {
|
||||||
|
setThemeAccent("");
|
||||||
|
setThemeButtonBg("");
|
||||||
|
setThemeButtonText("");
|
||||||
|
setThemeRadius("");
|
||||||
|
setThemeFont("system");
|
||||||
|
setThemeWidth("");
|
||||||
|
setThemeCustomCss("");
|
||||||
|
setShowSaved(false);
|
||||||
|
};
|
||||||
|
|
||||||
const resetEmail = () => {
|
const resetEmail = () => {
|
||||||
setSubject(DEFAULT_SUBJECT);
|
setSubject(DEFAULT_SUBJECT);
|
||||||
setIntro(DEFAULT_INTRO);
|
setIntro(DEFAULT_INTRO);
|
||||||
@@ -318,6 +462,7 @@ export default function SettingsPage() {
|
|||||||
{ id: "regole", content: "Regole recesso" },
|
{ id: "regole", content: "Regole recesso" },
|
||||||
{ id: "reso", content: "Reso e stato ordine" },
|
{ id: "reso", content: "Reso e stato ordine" },
|
||||||
{ id: "smtp", content: "Email (SMTP)" },
|
{ id: "smtp", content: "Email (SMTP)" },
|
||||||
|
{ id: "aspetto", content: "Aspetto" },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -706,6 +851,117 @@ export default function SettingsPage() {
|
|||||||
</BlockStack>
|
</BlockStack>
|
||||||
</Card>
|
</Card>
|
||||||
) : null}
|
) : 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>
|
</BlockStack>
|
||||||
</Layout.Section>
|
</Layout.Section>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
Reference in New Issue
Block a user