A6-bis: testi riquadro operativo editabili (segnaposto) + Impostazioni in tab

- opTextUnfulfilled / opTextShipped editabili dal merchant, con segnaposto
  {{returnAddress}} {{returnCost}} {{orderState}} {{customerName}} {{orderName}}
  {{shopName}}. Default = testi attuali. returnCost deriva da returnAtCustomerExpense
  (a tuo/nostro carico, Art. 57). returnInstructions deprecato (assorbito da opTextShipped).
- Pagina Impostazioni riorganizzata in 4 tab (Email / Notifiche / Regole recesso /
  Reso e stato ordine) + anteprima live del riquadro per stato (non evaso e spedito).
- emailTemplate: renderOperationalBlock ora usa i template + sostituzione segnaposto;
  renderOperationalPreview esportato per l'admin. Migrazione op_texts (additiva).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mv83a29B4eFv5ixoj6PoE1
This commit is contained in:
2026-07-07 16:35:12 +02:00
parent a6a1900287
commit a550d8ff2e
5 changed files with 395 additions and 262 deletions

View File

@@ -61,39 +61,65 @@ export function renderSubject(subjectTpl: string | null | undefined, vars: Email
return substPlain(tpl, vars).trim() || substPlain(DEFAULT_SUBJECT, vars); return substPlain(tpl, vars).trim() || substPlain(DEFAULT_SUBJECT, vars);
} }
// Testi del riquadro operativo (A6-bis) - editabili dal merchant, con segnaposto.
export const OP_PLACEHOLDERS = [
"returnAddress",
"returnCost",
"orderState",
"customerName",
"orderName",
"shopName",
] as const;
export const DEFAULT_OP_UNFULFILLED =
"Il tuo ordine non risultava ancora spedito: procederemo all'annullamento e al rimborso. Non devi restituire nulla.";
export const DEFAULT_OP_SHIPPED =
"Il prodotto risulta {{orderState}}. Per ottenere il rimborso, restituisci la merce integra a: {{returnAddress}}. Le spese di restituzione sono {{returnCost}}. Il rimborso sarà disposto dopo il rientro della merce.";
export interface OperationalConfig { export interface OperationalConfig {
state: "unfulfilled" | "shipped" | "delivered"; state: "unfulfilled" | "shipped" | "delivered";
textUnfulfilled?: string | null;
textShipped?: string | null;
returnAddress?: string | null; returnAddress?: string | null;
atCustomerExpense: boolean; atCustomerExpense: boolean;
instructions?: string | null;
} }
/** Blocco operativo per stato ordine (A6-bis): annullo (non evaso) o istruzioni reso. */ /** Blocco operativo per stato ordine (A6-bis): testo editabile + segnaposto. */
function renderOperationalBlock(op: OperationalConfig): string { function renderOperationalBlock(op: OperationalConfig, vars: EmailVars): string {
let inner: string; const tpl =
if (op.state === "unfulfilled") { op.state === "unfulfilled"
inner = ? (op.textUnfulfilled && op.textUnfulfilled.trim()) || DEFAULT_OP_UNFULFILLED
"Il tuo ordine non risultava ancora spedito: procederemo all'annullamento e al rimborso. Non devi restituire nulla."; : (op.textShipped && op.textShipped.trim()) || DEFAULT_OP_SHIPPED;
} else { const map: Record<string, string> = {
const stateWord = op.state === "delivered" ? "consegnato" : "spedito"; returnAddress:
const addr =
op.returnAddress && op.returnAddress.trim() op.returnAddress && op.returnAddress.trim()
? `<strong>${escHtml(op.returnAddress.trim())}</strong>` ? op.returnAddress.trim()
: "l'indirizzo che ti comunicheremo"; : "l'indirizzo che ti comunicheremo",
const spese = op.atCustomerExpense returnCost: op.atCustomerExpense ? "a tuo carico" : "a nostro carico",
? "Le spese di restituzione sono a tuo carico." orderState: op.state === "delivered" ? "consegnato" : "spedito",
: "Le spese di restituzione sono a nostro carico."; customerName: vars.customerName,
const instr = orderName: vars.orderName,
op.instructions && op.instructions.trim() shopName: vars.shopName,
? `<br>${nl2br(escHtml(op.instructions.trim()))}` };
: ""; const withVars = escHtml(tpl).replace(
inner = `Il prodotto risulta ${stateWord}. Per ottenere il rimborso, restituisci la merce integra a: ${addr}. ${spese}${instr}<br>Il rimborso sara' disposto dopo il rientro della merce.`; /\{\{\s*(\w+)\s*\}\}/g,
} (_m, k: string) => escHtml(map[k] ?? ""),
);
const inner = nl2br(withVars);
return `<tr><td style="padding:4px 32px 8px;"> return `<tr><td style="padding:4px 32px 8px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tr><td style="padding:14px 16px;background:#fff7e6;border:1px solid #ffe2a8;border-radius:8px;font-size:14px;line-height:1.6;color:#6a5518;">${inner}</td></tr></table> <table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tr><td style="padding:14px 16px;background:#fff7e6;border:1px solid #ffe2a8;border-radius:8px;font-size:14px;line-height:1.6;color:#6a5518;">${inner}</td></tr></table>
</td></tr>`; </td></tr>`;
} }
/** Anteprima del riquadro operativo per un dato stato (per l'admin). */
export function renderOperationalPreview(
op: OperationalConfig,
vars: EmailVars,
): string {
return renderOperationalBlock(op, vars);
}
/** Corpo HTML fisso con intro/nota editabili inseriti. */ /** Corpo HTML fisso con intro/nota editabili inseriti. */
export function renderReceiptHtml( export function renderReceiptHtml(
vars: EmailVars, vars: EmailVars,
@@ -141,7 +167,7 @@ export function renderReceiptHtml(
<tr><td style="padding:12px 32px 4px;"> <tr><td style="padding:12px 32px 4px;">
<p style="margin:0;font-size:12.5px;line-height:1.6;color:#8a8a8a;">Questa comunicazione costituisce la ricevuta su supporto durevole ai sensi dell'art. 54-bis del Codice del Consumo. La data e l'ora indicate attestano il momento della trasmissione.</p> <p style="margin:0;font-size:12.5px;line-height:1.6;color:#8a8a8a;">Questa comunicazione costituisce la ricevuta su supporto durevole ai sensi dell'art. 54-bis del Codice del Consumo. La data e l'ora indicate attestano il momento della trasmissione.</p>
</td></tr> </td></tr>
${operational ? renderOperationalBlock(operational) : ""} ${operational ? renderOperationalBlock(operational, vars) : ""}
${note} ${note}
<tr><td style="padding:18px 32px 24px;border-top:1px solid #ececec;"> <tr><td style="padding:18px 32px 24px;border-top:1px solid #ececec;">
<p style="margin:0 0 8px;font-size:12.5px;line-height:1.6;color:#999;">Ti invieremo separatamente le istruzioni per l'eventuale reso e i tempi di rimborso.</p> <p style="margin:0 0 8px;font-size:12.5px;line-height:1.6;color:#999;">Ti invieremo separatamente le istruzioni per l'eventuale reso e i tempi di rimborso.</p>

View File

@@ -10,6 +10,7 @@ import {
Page, Page,
Layout, Layout,
Card, Card,
Tabs,
TextField, TextField,
Button, Button,
ButtonGroup, ButtonGroup,
@@ -28,59 +29,67 @@ import {
DEFAULT_INTRO, DEFAULT_INTRO,
DEFAULT_NOTE, DEFAULT_NOTE,
DEFAULT_SUBJECT, DEFAULT_SUBJECT,
DEFAULT_OP_SHIPPED,
DEFAULT_OP_UNFULFILLED,
OP_PLACEHOLDERS,
SAMPLE_VARS, SAMPLE_VARS,
TEXT_PLACEHOLDERS, TEXT_PLACEHOLDERS,
renderOperationalPreview,
renderReceiptHtml, renderReceiptHtml,
renderSubject, renderSubject,
} from "../lib/emailTemplate"; } from "../lib/emailTemplate";
export const loader = async ({ request }: LoaderFunctionArgs) => { export const loader = async ({ request }: LoaderFunctionArgs) => {
const { session } = await authenticate.admin(request); const { session } = await authenticate.admin(request);
const settings = await db.settings.findUnique({ where: { shop: session.shop } }); const s = await db.settings.findUnique({ where: { shop: session.shop } });
return { return {
subject: settings?.emailSubject ?? DEFAULT_SUBJECT, subject: s?.emailSubject ?? DEFAULT_SUBJECT,
intro: settings?.emailIntro ?? DEFAULT_INTRO, intro: s?.emailIntro ?? DEFAULT_INTRO,
note: settings?.emailNote ?? DEFAULT_NOTE, note: s?.emailNote ?? DEFAULT_NOTE,
notifyEnabled: settings?.notifyEnabled ?? true, notifyEnabled: s?.notifyEnabled ?? true,
notifyEmail: settings?.notifyEmail ?? "", notifyEmail: s?.notifyEmail ?? "",
tagEnabled: settings?.tagEnabled ?? true, tagEnabled: s?.tagEnabled ?? true,
enforceWindow: settings?.enforceWindow ?? false, enforceWindow: s?.enforceWindow ?? false,
windowDays: settings?.defaultWindowDays ?? 14, windowDays: s?.defaultWindowDays ?? 14,
enforceExclusions: settings?.enforceExclusions ?? false, enforceExclusions: s?.enforceExclusions ?? false,
stateAwareEmail: settings?.stateAwareEmail ?? true, stateAwareEmail: s?.stateAwareEmail ?? true,
autoCancelUnfulfilled: settings?.autoCancelUnfulfilled ?? false, autoCancelUnfulfilled: s?.autoCancelUnfulfilled ?? false,
returnAtCustomerExpense: settings?.returnAtCustomerExpense ?? true, returnAtCustomerExpense: s?.returnAtCustomerExpense ?? true,
returnAddress: settings?.returnAddress ?? "", returnAddress: s?.returnAddress ?? "",
returnInstructions: settings?.returnInstructions ?? "", opTextUnfulfilled: s?.opTextUnfulfilled ?? DEFAULT_OP_UNFULFILLED,
opTextShipped: s?.opTextShipped ?? DEFAULT_OP_SHIPPED,
}; };
}; };
export const action = async ({ request }: ActionFunctionArgs) => { export const action = async ({ request }: ActionFunctionArgs) => {
const { session } = await authenticate.admin(request); const { session } = await authenticate.admin(request);
const form = await request.formData(); const f = await request.formData();
const subject = String(form.get("subject") ?? "").trim(); const subject = String(f.get("subject") ?? "").trim();
const intro = String(form.get("intro") ?? "").trim(); const intro = String(f.get("intro") ?? "").trim();
const note = String(form.get("note") ?? "").trim(); const note = String(f.get("note") ?? "").trim();
const opUnf = String(f.get("opTextUnfulfilled") ?? "").trim();
const opShip = String(f.get("opTextShipped") ?? "").trim();
const data = { const data = {
emailSubject: subject && subject !== DEFAULT_SUBJECT ? subject : null, emailSubject: subject && subject !== DEFAULT_SUBJECT ? subject : null,
emailIntro: intro && intro !== DEFAULT_INTRO ? intro : null, emailIntro: intro && intro !== DEFAULT_INTRO ? intro : null,
emailNote: note || null, emailNote: note || null,
notifyEnabled: form.get("notifyEnabled") === "true", notifyEnabled: f.get("notifyEnabled") === "true",
notifyEmail: String(form.get("notifyEmail") ?? "").trim() || null, notifyEmail: String(f.get("notifyEmail") ?? "").trim() || null,
tagEnabled: form.get("tagEnabled") === "true", tagEnabled: f.get("tagEnabled") === "true",
enforceWindow: form.get("enforceWindow") === "true", enforceWindow: f.get("enforceWindow") === "true",
defaultWindowDays: Math.min( defaultWindowDays: Math.min(
365, 365,
Math.max(1, Number(form.get("windowDays")) || 14), Math.max(1, Number(f.get("windowDays")) || 14),
), ),
enforceExclusions: form.get("enforceExclusions") === "true", enforceExclusions: f.get("enforceExclusions") === "true",
stateAwareEmail: form.get("stateAwareEmail") === "true", stateAwareEmail: f.get("stateAwareEmail") === "true",
autoCancelUnfulfilled: form.get("autoCancelUnfulfilled") === "true", autoCancelUnfulfilled: f.get("autoCancelUnfulfilled") === "true",
returnAtCustomerExpense: form.get("returnAtCustomerExpense") === "true", returnAtCustomerExpense: f.get("returnAtCustomerExpense") === "true",
returnAddress: String(form.get("returnAddress") ?? "").trim() || null, returnAddress: String(f.get("returnAddress") ?? "").trim() || null,
returnInstructions: opTextUnfulfilled:
String(form.get("returnInstructions") ?? "").trim() || null, opUnf && opUnf !== DEFAULT_OP_UNFULFILLED ? opUnf : null,
opTextShipped: opShip && opShip !== DEFAULT_OP_SHIPPED ? opShip : null,
}; };
await db.settings.upsert({ await db.settings.upsert({
@@ -88,38 +97,52 @@ export const action = async ({ request }: ActionFunctionArgs) => {
create: { shop: session.shop, ...data }, create: { shop: session.shop, ...data },
update: data, update: data,
}); });
return { ok: true }; return { ok: true };
}; };
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 (
<iframe
title="Anteprima riquadro"
srcDoc={doc}
style={{
width: "100%",
height: `${height}px`,
border: "1px solid #e1e1e1",
borderRadius: "8px",
background: "#fff",
}}
/>
);
}
export default function SettingsPage() { export default function SettingsPage() {
const data = useLoaderData<typeof loader>(); const d = useLoaderData<typeof loader>();
const actionData = useActionData<typeof action>(); const actionData = useActionData<typeof action>();
const nav = useNavigation(); const nav = useNavigation();
const submit = useSubmit(); const submit = useSubmit();
const [subject, setSubject] = useState(data.subject); const [tab, setTab] = useState(0);
const [intro, setIntro] = useState(data.intro); const [subject, setSubject] = useState(d.subject);
const [note, setNote] = useState(data.note); const [intro, setIntro] = useState(d.intro);
const [notifyEnabled, setNotifyEnabled] = useState(data.notifyEnabled); const [note, setNote] = useState(d.note);
const [notifyEmail, setNotifyEmail] = useState(data.notifyEmail); const [notifyEnabled, setNotifyEnabled] = useState(d.notifyEnabled);
const [tagEnabled, setTagEnabled] = useState(data.tagEnabled); const [notifyEmail, setNotifyEmail] = useState(d.notifyEmail);
const [enforceWindow, setEnforceWindow] = useState(data.enforceWindow); const [tagEnabled, setTagEnabled] = useState(d.tagEnabled);
const [windowDays, setWindowDays] = useState(String(data.windowDays)); const [enforceWindow, setEnforceWindow] = useState(d.enforceWindow);
const [enforceExclusions, setEnforceExclusions] = useState( const [windowDays, setWindowDays] = useState(String(d.windowDays));
data.enforceExclusions, const [enforceExclusions, setEnforceExclusions] = useState(d.enforceExclusions);
); const [stateAwareEmail, setStateAwareEmail] = useState(d.stateAwareEmail);
const [stateAwareEmail, setStateAwareEmail] = useState(data.stateAwareEmail);
const [autoCancelUnfulfilled, setAutoCancelUnfulfilled] = useState( const [autoCancelUnfulfilled, setAutoCancelUnfulfilled] = useState(
data.autoCancelUnfulfilled, d.autoCancelUnfulfilled,
); );
const [returnAtCustomerExpense, setReturnAtCustomerExpense] = useState( const [returnAtCustomerExpense, setReturnAtCustomerExpense] = useState(
data.returnAtCustomerExpense, d.returnAtCustomerExpense,
);
const [returnAddress, setReturnAddress] = useState(data.returnAddress);
const [returnInstructions, setReturnInstructions] = useState(
data.returnInstructions,
); );
const [returnAddress, setReturnAddress] = useState(d.returnAddress);
const [opTextUnfulfilled, setOpTextUnfulfilled] = useState(d.opTextUnfulfilled);
const [opTextShipped, setOpTextShipped] = useState(d.opTextShipped);
const [showSaved, setShowSaved] = useState(false); const [showSaved, setShowSaved] = useState(false);
const saving = nav.state === "submitting"; const saving = nav.state === "submitting";
@@ -136,6 +159,34 @@ export default function SettingsPage() {
() => renderReceiptHtml(SAMPLE_VARS, intro, note), () => renderReceiptHtml(SAMPLE_VARS, intro, note),
[intro, note], [intro, note],
); );
const previewOpUnf = useMemo(
() =>
renderOperationalPreview(
{
state: "unfulfilled",
textUnfulfilled: opTextUnfulfilled,
textShipped: opTextShipped,
returnAddress,
atCustomerExpense: returnAtCustomerExpense,
},
SAMPLE_VARS,
),
[opTextUnfulfilled, opTextShipped, returnAddress, returnAtCustomerExpense],
);
const previewOpShip = useMemo(
() =>
renderOperationalPreview(
{
state: "shipped",
textUnfulfilled: opTextUnfulfilled,
textShipped: opTextShipped,
returnAddress,
atCustomerExpense: returnAtCustomerExpense,
},
SAMPLE_VARS,
),
[opTextShipped, opTextUnfulfilled, returnAddress, returnAtCustomerExpense],
);
const handleSave = () => { const handleSave = () => {
setShowSaved(false); setShowSaved(false);
@@ -153,20 +204,36 @@ export default function SettingsPage() {
fd.set("autoCancelUnfulfilled", String(autoCancelUnfulfilled)); fd.set("autoCancelUnfulfilled", String(autoCancelUnfulfilled));
fd.set("returnAtCustomerExpense", String(returnAtCustomerExpense)); fd.set("returnAtCustomerExpense", String(returnAtCustomerExpense));
fd.set("returnAddress", returnAddress); fd.set("returnAddress", returnAddress);
fd.set("returnInstructions", returnInstructions); fd.set("opTextUnfulfilled", opTextUnfulfilled);
fd.set("opTextShipped", opTextShipped);
submit(fd, { method: "post" }); submit(fd, { method: "post" });
}; };
const handleReset = () => { const resetEmail = () => {
setSubject(DEFAULT_SUBJECT); setSubject(DEFAULT_SUBJECT);
setIntro(DEFAULT_INTRO); setIntro(DEFAULT_INTRO);
setNote(DEFAULT_NOTE); setNote(DEFAULT_NOTE);
setShowSaved(false); setShowSaved(false);
}; };
const saveBtn = (
<div>
<Button variant="primary" loading={saving} onClick={handleSave}>
Salva
</Button>
</div>
);
const tabs = [
{ id: "email", content: "Email" },
{ id: "notifiche", content: "Notifiche" },
{ id: "regole", content: "Regole recesso" },
{ id: "reso", content: "Reso e stato ordine" },
];
return ( return (
<Page> <Page>
<TitleBar title="Email di ricevuta" /> <TitleBar title="Impostazioni recesso" />
<Layout> <Layout>
<Layout.Section> <Layout.Section>
<BlockStack gap="400"> <BlockStack gap="400">
@@ -176,199 +243,233 @@ export default function SettingsPage() {
</Banner> </Banner>
) : null} ) : null}
<Card> <Tabs tabs={tabs} selected={tab} onSelect={setTab} />
{tab === 0 ? (
<BlockStack gap="400"> <BlockStack gap="400">
<BlockStack gap="100"> <Card>
<BlockStack gap="400">
<BlockStack gap="100">
<Text as="h2" variant="headingMd">
Testi dell'email
</Text>
<Text as="p" tone="subdued">
Oggetto e testi. Dettagli ordine, dichiarazione, data/ora,
avviso di legge e layout sono fissi. Segnaposto:
</Text>
<InlineStack gap="200" wrap>
{TEXT_PLACEHOLDERS.map((p) => (
<Badge key={p}>{`{{${p}}}`}</Badge>
))}
</InlineStack>
</BlockStack>
<TextField
label="Oggetto"
value={subject}
onChange={setSubject}
autoComplete="off"
/>
<TextField
label="Introduzione"
value={intro}
onChange={setIntro}
autoComplete="off"
multiline={4}
/>
<TextField
label="Nota aggiuntiva (opzionale)"
value={note}
onChange={setNote}
autoComplete="off"
multiline={3}
helpText="Riquadro in fondo alla email. Vuoto = nascosto."
/>
<ButtonGroup>
<Button
variant="primary"
loading={saving}
onClick={handleSave}
>
Salva
</Button>
<Button onClick={resetEmail}>Ripristina default</Button>
</ButtonGroup>
</BlockStack>
</Card>
<Card>
<BlockStack gap="200">
<Text as="h2" variant="headingMd">
Anteprima
</Text>
<Text as="p" tone="subdued">
Oggetto: {previewSubject}
</Text>
<iframe
title="Anteprima email"
srcDoc={previewHtml}
style={{
width: "100%",
height: "640px",
border: "1px solid #e1e1e1",
borderRadius: "8px",
background: "#fff",
}}
/>
</BlockStack>
</Card>
</BlockStack>
) : null}
{tab === 1 ? (
<Card>
<BlockStack gap="400">
<Text as="h2" variant="headingMd"> <Text as="h2" variant="headingMd">
Testi dell'email Notifiche al merchant
</Text> </Text>
<Text as="p" tone="subdued"> <Checkbox
Personalizza oggetto e testi. Il resto (dettagli ordine, label="Invia email di notifica a ogni recesso"
dichiarazione, data/ora, avviso di legge, layout) è fisso e checked={notifyEnabled}
sempre conforme. Segnaposto disponibili: onChange={setNotifyEnabled}
</Text> />
<InlineStack gap="200" wrap> <TextField
{TEXT_PLACEHOLDERS.map((p) => ( label="Email notifiche"
<Badge key={p}>{`{{${p}}}`}</Badge> type="email"
))} value={notifyEmail}
</InlineStack> onChange={setNotifyEmail}
autoComplete="off"
disabled={!notifyEnabled}
helpText="Dove ricevere le notifiche. Senza indirizzo l'email non parte."
placeholder="ordini@tuonegozio.it"
/>
<Checkbox
label="Aggiungi il tag 'Recesso' all'ordine"
checked={tagEnabled}
onChange={setTagEnabled}
helpText="Rende l'ordine filtrabile nella lista ordini."
/>
{saveBtn}
</BlockStack> </BlockStack>
</Card>
) : null}
<TextField {tab === 2 ? (
label="Oggetto" <Card>
value={subject} <BlockStack gap="400">
onChange={setSubject} <BlockStack gap="100">
autoComplete="off" <Text as="h2" variant="headingMd">
/> Regole di recesso
<TextField </Text>
label="Introduzione" <Text as="p" tone="subdued">
value={intro} Attiva questi controlli solo dopo aver verificato i dati. Da
onChange={setIntro} spenti, il recesso è sempre accettato. Le regole di esclusione
autoComplete="off" si gestiscono nella pagina Esclusioni.
multiline={4} </Text>
helpText="Saluto e frase iniziale della email." </BlockStack>
/> <Checkbox
<TextField label="Blocca i recessi oltre il termine"
label="Nota aggiuntiva (opzionale)" checked={enforceWindow}
value={note} onChange={setEnforceWindow}
onChange={setNote} helpText="Calcolato dalla data di consegna + i giorni sotto."
autoComplete="off" />
multiline={3} <TextField
placeholder="Es. Per il reso, spedisci il pacco a..." label="Giorni di recesso"
helpText="Riquadro in fondo alla email. Lascia vuoto per non mostrarlo." type="number"
/> value={windowDays}
onChange={setWindowDays}
<ButtonGroup> autoComplete="off"
<Button variant="primary" loading={saving} onClick={handleSave}> min={1}
Salva max={365}
</Button> disabled={!enforceWindow}
<Button onClick={handleReset}>Ripristina default</Button> />
</ButtonGroup> <Checkbox
</BlockStack> label="Blocca i prodotti esclusi (Art. 59)"
</Card> checked={enforceExclusions}
onChange={setEnforceExclusions}
<Card> helpText="Blocca se l'intero ordine è escluso. Regole nella pagina Esclusioni."
<BlockStack gap="400"> />
<Text as="h2" variant="headingMd"> {saveBtn}
Notifiche al merchant
</Text>
<Checkbox
label="Invia email di notifica a ogni recesso"
checked={notifyEnabled}
onChange={setNotifyEnabled}
/>
<TextField
label="Email notifiche"
type="email"
value={notifyEmail}
onChange={setNotifyEmail}
autoComplete="off"
disabled={!notifyEnabled}
helpText="Dove ricevere le notifiche. Senza indirizzo l'email non parte."
placeholder="ordini@tuonegozio.it"
/>
<Checkbox
label="Aggiungi il tag 'Recesso' all'ordine"
checked={tagEnabled}
onChange={setTagEnabled}
helpText="Rende l'ordine filtrabile nella lista ordini."
/>
<div>
<Button variant="primary" loading={saving} onClick={handleSave}>
Salva
</Button>
</div>
</BlockStack>
</Card>
<Card>
<BlockStack gap="400">
<BlockStack gap="100">
<Text as="h2" variant="headingMd">
Regole di recesso
</Text>
<Text as="p" tone="subdued">
Attiva questi controlli solo dopo aver verificato i dati. Da
spenti, il recesso è sempre accettato.
</Text>
</BlockStack> </BlockStack>
<Checkbox </Card>
label="Blocca i recessi oltre il termine" ) : null}
checked={enforceWindow}
onChange={setEnforceWindow}
helpText="Calcolato dalla data di evasione (o d'ordine) + i giorni sotto."
/>
<TextField
label="Giorni di recesso"
type="number"
value={windowDays}
onChange={setWindowDays}
autoComplete="off"
min={1}
max={365}
disabled={!enforceWindow}
/>
<Checkbox
label="Blocca i prodotti esclusi (Art. 59)"
checked={enforceExclusions}
onChange={setEnforceExclusions}
helpText="Usa le regole nella pagina Esclusioni. Blocca se l'intero ordine è escluso."
/>
<div>
<Button variant="primary" loading={saving} onClick={handleSave}>
Salva
</Button>
</div>
</BlockStack>
</Card>
<Card> {tab === 3 ? (
<BlockStack gap="400"> <BlockStack gap="400">
<Text as="h2" variant="headingMd"> <Card>
Reso e stato ordine <BlockStack gap="400">
</Text> <BlockStack gap="100">
<Checkbox <Text as="h2" variant="headingMd">
label="Adatta la ricevuta allo stato dell'ordine" Reso e stato ordine
checked={stateAwareEmail} </Text>
onChange={setStateAwareEmail} <Text as="p" tone="subdued">
helpText="Non evaso → annullo+rimborso; spedito/consegnato → istruzioni di reso." Il riquadro nella ricevuta cambia in base allo stato.
/> Segnaposto nei testi:
<Checkbox </Text>
label="Annulla automaticamente gli ordini non ancora spediti" <InlineStack gap="200" wrap>
checked={autoCancelUnfulfilled} {OP_PLACEHOLDERS.map((p) => (
onChange={setAutoCancelUnfulfilled} <Badge key={p}>{`{{${p}}}`}</Badge>
helpText="Al recesso, se l'ordine non è evaso: annullo + rimborso automatici. Irreversibile." ))}
/> </InlineStack>
<Checkbox </BlockStack>
label="Spese di restituzione a carico del cliente (Art. 57)" <Checkbox
checked={returnAtCustomerExpense} label="Adatta la ricevuta allo stato dell'ordine"
onChange={setReturnAtCustomerExpense} checked={stateAwareEmail}
/> onChange={setStateAwareEmail}
<TextField />
label="Indirizzo per il reso" <Checkbox
value={returnAddress} label="Annulla automaticamente gli ordini non ancora spediti"
onChange={setReturnAddress} checked={autoCancelUnfulfilled}
autoComplete="off" onChange={setAutoCancelUnfulfilled}
multiline={2} helpText="Al recesso, se l'ordine non è evaso: annullo + rimborso automatici. Irreversibile."
placeholder="Via ..., CAP Città (PR)" />
/> <Checkbox
<TextField label="Spese di restituzione a carico del cliente (Art. 57)"
label="Istruzioni di reso (opzionale)" checked={returnAtCustomerExpense}
value={returnInstructions} onChange={setReturnAtCustomerExpense}
onChange={setReturnInstructions} helpText="Determina il valore di {{returnCost}}."
autoComplete="off" />
multiline={3} <TextField
helpText="Testo aggiuntivo mostrato nella ricevuta per ordini spediti/consegnati." label="Indirizzo per il reso"
/> value={returnAddress}
<div> onChange={setReturnAddress}
<Button variant="primary" loading={saving} onClick={handleSave}> autoComplete="off"
Salva multiline={2}
</Button> placeholder="Via ..., CAP Città (PR)"
</div> helpText="Valore di {{returnAddress}}."
/>
<TextField
label="Testo - ordine non evaso"
value={opTextUnfulfilled}
onChange={setOpTextUnfulfilled}
autoComplete="off"
multiline={3}
/>
<TextField
label="Testo - ordine spedito/consegnato"
value={opTextShipped}
onChange={setOpTextShipped}
autoComplete="off"
multiline={4}
/>
{saveBtn}
</BlockStack>
</Card>
<Card>
<BlockStack gap="300">
<Text as="h2" variant="headingMd">
Anteprima riquadro
</Text>
<Text as="p" tone="subdued">
Ordine non evaso
</Text>
{opFrame(previewOpUnf)}
<Text as="p" tone="subdued">
Ordine spedito/consegnato
</Text>
{opFrame(previewOpShip)}
</BlockStack>
</Card>
</BlockStack> </BlockStack>
</Card> ) : null}
<Card>
<BlockStack gap="200">
<Text as="h2" variant="headingMd">
Anteprima
</Text>
<Text as="p" tone="subdued">
Oggetto: {previewSubject}
</Text>
<iframe
title="Anteprima email"
srcDoc={previewHtml}
style={{
width: "100%",
height: "640px",
border: "1px solid #e1e1e1",
borderRadius: "8px",
background: "#fff",
}}
/>
</BlockStack>
</Card>
</BlockStack> </BlockStack>
</Layout.Section> </Layout.Section>
</Layout> </Layout>

View File

@@ -375,9 +375,10 @@ export const action = async ({ request }: ActionFunctionArgs) => {
(state === "unfulfilled" || state === "shipped" || state === "delivered") (state === "unfulfilled" || state === "shipped" || state === "delivered")
? { ? {
state, state,
textUnfulfilled: settings?.opTextUnfulfilled,
textShipped: settings?.opTextShipped,
returnAddress: settings?.returnAddress, returnAddress: settings?.returnAddress,
atCustomerExpense: settings?.returnAtCustomerExpense ?? true, atCustomerExpense: settings?.returnAtCustomerExpense ?? true,
instructions: settings?.returnInstructions,
} }
: null; : null;
const receipt = await sendWithdrawalReceipt({ const receipt = await sendWithdrawalReceipt({

View File

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "Settings" ADD COLUMN "opTextShipped" TEXT,
ADD COLUMN "opTextUnfulfilled" TEXT;

View File

@@ -60,7 +60,9 @@ model Settings {
stateAwareEmail Boolean @default(true) stateAwareEmail Boolean @default(true)
autoCancelUnfulfilled Boolean @default(false) autoCancelUnfulfilled Boolean @default(false)
returnAtCustomerExpense Boolean @default(true) returnAtCustomerExpense Boolean @default(true)
returnInstructions String? returnInstructions String? // deprecato: sostituito da opTextShipped
opTextUnfulfilled String?
opTextShipped String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt