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,
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user