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:
@@ -61,39 +61,65 @@ export function renderSubject(subjectTpl: string | null | undefined, vars: Email
|
||||
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 {
|
||||
state: "unfulfilled" | "shipped" | "delivered";
|
||||
textUnfulfilled?: string | null;
|
||||
textShipped?: string | null;
|
||||
returnAddress?: string | null;
|
||||
atCustomerExpense: boolean;
|
||||
instructions?: string | null;
|
||||
}
|
||||
|
||||
/** Blocco operativo per stato ordine (A6-bis): annullo (non evaso) o istruzioni reso. */
|
||||
function renderOperationalBlock(op: OperationalConfig): string {
|
||||
let inner: string;
|
||||
if (op.state === "unfulfilled") {
|
||||
inner =
|
||||
"Il tuo ordine non risultava ancora spedito: procederemo all'annullamento e al rimborso. Non devi restituire nulla.";
|
||||
} else {
|
||||
const stateWord = op.state === "delivered" ? "consegnato" : "spedito";
|
||||
const addr =
|
||||
/** Blocco operativo per stato ordine (A6-bis): testo editabile + segnaposto. */
|
||||
function renderOperationalBlock(op: OperationalConfig, vars: EmailVars): string {
|
||||
const tpl =
|
||||
op.state === "unfulfilled"
|
||||
? (op.textUnfulfilled && op.textUnfulfilled.trim()) || DEFAULT_OP_UNFULFILLED
|
||||
: (op.textShipped && op.textShipped.trim()) || DEFAULT_OP_SHIPPED;
|
||||
const map: Record<string, string> = {
|
||||
returnAddress:
|
||||
op.returnAddress && op.returnAddress.trim()
|
||||
? `<strong>${escHtml(op.returnAddress.trim())}</strong>`
|
||||
: "l'indirizzo che ti comunicheremo";
|
||||
const spese = op.atCustomerExpense
|
||||
? "Le spese di restituzione sono a tuo carico."
|
||||
: "Le spese di restituzione sono a nostro carico.";
|
||||
const instr =
|
||||
op.instructions && op.instructions.trim()
|
||||
? `<br>${nl2br(escHtml(op.instructions.trim()))}`
|
||||
: "";
|
||||
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.`;
|
||||
}
|
||||
? op.returnAddress.trim()
|
||||
: "l'indirizzo che ti comunicheremo",
|
||||
returnCost: op.atCustomerExpense ? "a tuo carico" : "a nostro carico",
|
||||
orderState: op.state === "delivered" ? "consegnato" : "spedito",
|
||||
customerName: vars.customerName,
|
||||
orderName: vars.orderName,
|
||||
shopName: vars.shopName,
|
||||
};
|
||||
const withVars = escHtml(tpl).replace(
|
||||
/\{\{\s*(\w+)\s*\}\}/g,
|
||||
(_m, k: string) => escHtml(map[k] ?? ""),
|
||||
);
|
||||
const inner = nl2br(withVars);
|
||||
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>
|
||||
</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. */
|
||||
export function renderReceiptHtml(
|
||||
vars: EmailVars,
|
||||
@@ -141,7 +167,7 @@ export function renderReceiptHtml(
|
||||
<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>
|
||||
</td></tr>
|
||||
${operational ? renderOperationalBlock(operational) : ""}
|
||||
${operational ? renderOperationalBlock(operational, vars) : ""}
|
||||
${note}
|
||||
<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>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Page,
|
||||
Layout,
|
||||
Card,
|
||||
Tabs,
|
||||
TextField,
|
||||
Button,
|
||||
ButtonGroup,
|
||||
@@ -28,59 +29,67 @@ import {
|
||||
DEFAULT_INTRO,
|
||||
DEFAULT_NOTE,
|
||||
DEFAULT_SUBJECT,
|
||||
DEFAULT_OP_SHIPPED,
|
||||
DEFAULT_OP_UNFULFILLED,
|
||||
OP_PLACEHOLDERS,
|
||||
SAMPLE_VARS,
|
||||
TEXT_PLACEHOLDERS,
|
||||
renderOperationalPreview,
|
||||
renderReceiptHtml,
|
||||
renderSubject,
|
||||
} from "../lib/emailTemplate";
|
||||
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
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 {
|
||||
subject: settings?.emailSubject ?? DEFAULT_SUBJECT,
|
||||
intro: settings?.emailIntro ?? DEFAULT_INTRO,
|
||||
note: settings?.emailNote ?? DEFAULT_NOTE,
|
||||
notifyEnabled: settings?.notifyEnabled ?? true,
|
||||
notifyEmail: settings?.notifyEmail ?? "",
|
||||
tagEnabled: settings?.tagEnabled ?? true,
|
||||
enforceWindow: settings?.enforceWindow ?? false,
|
||||
windowDays: settings?.defaultWindowDays ?? 14,
|
||||
enforceExclusions: settings?.enforceExclusions ?? false,
|
||||
stateAwareEmail: settings?.stateAwareEmail ?? true,
|
||||
autoCancelUnfulfilled: settings?.autoCancelUnfulfilled ?? false,
|
||||
returnAtCustomerExpense: settings?.returnAtCustomerExpense ?? true,
|
||||
returnAddress: settings?.returnAddress ?? "",
|
||||
returnInstructions: settings?.returnInstructions ?? "",
|
||||
subject: s?.emailSubject ?? DEFAULT_SUBJECT,
|
||||
intro: s?.emailIntro ?? DEFAULT_INTRO,
|
||||
note: s?.emailNote ?? DEFAULT_NOTE,
|
||||
notifyEnabled: s?.notifyEnabled ?? true,
|
||||
notifyEmail: s?.notifyEmail ?? "",
|
||||
tagEnabled: s?.tagEnabled ?? true,
|
||||
enforceWindow: s?.enforceWindow ?? false,
|
||||
windowDays: s?.defaultWindowDays ?? 14,
|
||||
enforceExclusions: s?.enforceExclusions ?? false,
|
||||
stateAwareEmail: s?.stateAwareEmail ?? true,
|
||||
autoCancelUnfulfilled: s?.autoCancelUnfulfilled ?? false,
|
||||
returnAtCustomerExpense: s?.returnAtCustomerExpense ?? true,
|
||||
returnAddress: s?.returnAddress ?? "",
|
||||
opTextUnfulfilled: s?.opTextUnfulfilled ?? DEFAULT_OP_UNFULFILLED,
|
||||
opTextShipped: s?.opTextShipped ?? DEFAULT_OP_SHIPPED,
|
||||
};
|
||||
};
|
||||
|
||||
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
const { session } = await authenticate.admin(request);
|
||||
const form = await request.formData();
|
||||
const subject = String(form.get("subject") ?? "").trim();
|
||||
const intro = String(form.get("intro") ?? "").trim();
|
||||
const note = String(form.get("note") ?? "").trim();
|
||||
const f = await request.formData();
|
||||
const subject = String(f.get("subject") ?? "").trim();
|
||||
const intro = String(f.get("intro") ?? "").trim();
|
||||
const note = String(f.get("note") ?? "").trim();
|
||||
const opUnf = String(f.get("opTextUnfulfilled") ?? "").trim();
|
||||
const opShip = String(f.get("opTextShipped") ?? "").trim();
|
||||
|
||||
const data = {
|
||||
emailSubject: subject && subject !== DEFAULT_SUBJECT ? subject : null,
|
||||
emailIntro: intro && intro !== DEFAULT_INTRO ? intro : null,
|
||||
emailNote: note || null,
|
||||
notifyEnabled: form.get("notifyEnabled") === "true",
|
||||
notifyEmail: String(form.get("notifyEmail") ?? "").trim() || null,
|
||||
tagEnabled: form.get("tagEnabled") === "true",
|
||||
enforceWindow: form.get("enforceWindow") === "true",
|
||||
notifyEnabled: f.get("notifyEnabled") === "true",
|
||||
notifyEmail: String(f.get("notifyEmail") ?? "").trim() || null,
|
||||
tagEnabled: f.get("tagEnabled") === "true",
|
||||
enforceWindow: f.get("enforceWindow") === "true",
|
||||
defaultWindowDays: Math.min(
|
||||
365,
|
||||
Math.max(1, Number(form.get("windowDays")) || 14),
|
||||
Math.max(1, Number(f.get("windowDays")) || 14),
|
||||
),
|
||||
enforceExclusions: form.get("enforceExclusions") === "true",
|
||||
stateAwareEmail: form.get("stateAwareEmail") === "true",
|
||||
autoCancelUnfulfilled: form.get("autoCancelUnfulfilled") === "true",
|
||||
returnAtCustomerExpense: form.get("returnAtCustomerExpense") === "true",
|
||||
returnAddress: String(form.get("returnAddress") ?? "").trim() || null,
|
||||
returnInstructions:
|
||||
String(form.get("returnInstructions") ?? "").trim() || null,
|
||||
enforceExclusions: f.get("enforceExclusions") === "true",
|
||||
stateAwareEmail: f.get("stateAwareEmail") === "true",
|
||||
autoCancelUnfulfilled: f.get("autoCancelUnfulfilled") === "true",
|
||||
returnAtCustomerExpense: f.get("returnAtCustomerExpense") === "true",
|
||||
returnAddress: String(f.get("returnAddress") ?? "").trim() || null,
|
||||
opTextUnfulfilled:
|
||||
opUnf && opUnf !== DEFAULT_OP_UNFULFILLED ? opUnf : null,
|
||||
opTextShipped: opShip && opShip !== DEFAULT_OP_SHIPPED ? opShip : null,
|
||||
};
|
||||
|
||||
await db.settings.upsert({
|
||||
@@ -88,38 +97,52 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
create: { shop: session.shop, ...data },
|
||||
update: data,
|
||||
});
|
||||
|
||||
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() {
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const d = useLoaderData<typeof loader>();
|
||||
const actionData = useActionData<typeof action>();
|
||||
const nav = useNavigation();
|
||||
const submit = useSubmit();
|
||||
|
||||
const [subject, setSubject] = useState(data.subject);
|
||||
const [intro, setIntro] = useState(data.intro);
|
||||
const [note, setNote] = useState(data.note);
|
||||
const [notifyEnabled, setNotifyEnabled] = useState(data.notifyEnabled);
|
||||
const [notifyEmail, setNotifyEmail] = useState(data.notifyEmail);
|
||||
const [tagEnabled, setTagEnabled] = useState(data.tagEnabled);
|
||||
const [enforceWindow, setEnforceWindow] = useState(data.enforceWindow);
|
||||
const [windowDays, setWindowDays] = useState(String(data.windowDays));
|
||||
const [enforceExclusions, setEnforceExclusions] = useState(
|
||||
data.enforceExclusions,
|
||||
);
|
||||
const [stateAwareEmail, setStateAwareEmail] = useState(data.stateAwareEmail);
|
||||
const [tab, setTab] = useState(0);
|
||||
const [subject, setSubject] = useState(d.subject);
|
||||
const [intro, setIntro] = useState(d.intro);
|
||||
const [note, setNote] = useState(d.note);
|
||||
const [notifyEnabled, setNotifyEnabled] = useState(d.notifyEnabled);
|
||||
const [notifyEmail, setNotifyEmail] = useState(d.notifyEmail);
|
||||
const [tagEnabled, setTagEnabled] = useState(d.tagEnabled);
|
||||
const [enforceWindow, setEnforceWindow] = useState(d.enforceWindow);
|
||||
const [windowDays, setWindowDays] = useState(String(d.windowDays));
|
||||
const [enforceExclusions, setEnforceExclusions] = useState(d.enforceExclusions);
|
||||
const [stateAwareEmail, setStateAwareEmail] = useState(d.stateAwareEmail);
|
||||
const [autoCancelUnfulfilled, setAutoCancelUnfulfilled] = useState(
|
||||
data.autoCancelUnfulfilled,
|
||||
d.autoCancelUnfulfilled,
|
||||
);
|
||||
const [returnAtCustomerExpense, setReturnAtCustomerExpense] = useState(
|
||||
data.returnAtCustomerExpense,
|
||||
);
|
||||
const [returnAddress, setReturnAddress] = useState(data.returnAddress);
|
||||
const [returnInstructions, setReturnInstructions] = useState(
|
||||
data.returnInstructions,
|
||||
d.returnAtCustomerExpense,
|
||||
);
|
||||
const [returnAddress, setReturnAddress] = useState(d.returnAddress);
|
||||
const [opTextUnfulfilled, setOpTextUnfulfilled] = useState(d.opTextUnfulfilled);
|
||||
const [opTextShipped, setOpTextShipped] = useState(d.opTextShipped);
|
||||
const [showSaved, setShowSaved] = useState(false);
|
||||
|
||||
const saving = nav.state === "submitting";
|
||||
@@ -136,6 +159,34 @@ export default function SettingsPage() {
|
||||
() => renderReceiptHtml(SAMPLE_VARS, 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 = () => {
|
||||
setShowSaved(false);
|
||||
@@ -153,20 +204,36 @@ export default function SettingsPage() {
|
||||
fd.set("autoCancelUnfulfilled", String(autoCancelUnfulfilled));
|
||||
fd.set("returnAtCustomerExpense", String(returnAtCustomerExpense));
|
||||
fd.set("returnAddress", returnAddress);
|
||||
fd.set("returnInstructions", returnInstructions);
|
||||
fd.set("opTextUnfulfilled", opTextUnfulfilled);
|
||||
fd.set("opTextShipped", opTextShipped);
|
||||
submit(fd, { method: "post" });
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
const resetEmail = () => {
|
||||
setSubject(DEFAULT_SUBJECT);
|
||||
setIntro(DEFAULT_INTRO);
|
||||
setNote(DEFAULT_NOTE);
|
||||
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 (
|
||||
<Page>
|
||||
<TitleBar title="Email di ricevuta" />
|
||||
<TitleBar title="Impostazioni recesso" />
|
||||
<Layout>
|
||||
<Layout.Section>
|
||||
<BlockStack gap="400">
|
||||
@@ -176,199 +243,233 @@ export default function SettingsPage() {
|
||||
</Banner>
|
||||
) : null}
|
||||
|
||||
<Card>
|
||||
<Tabs tabs={tabs} selected={tab} onSelect={setTab} />
|
||||
|
||||
{tab === 0 ? (
|
||||
<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">
|
||||
Testi dell'email
|
||||
Notifiche al merchant
|
||||
</Text>
|
||||
<Text as="p" tone="subdued">
|
||||
Personalizza oggetto e testi. Il resto (dettagli ordine,
|
||||
dichiarazione, data/ora, avviso di legge, layout) è fisso e
|
||||
sempre conforme. Segnaposto disponibili:
|
||||
</Text>
|
||||
<InlineStack gap="200" wrap>
|
||||
{TEXT_PLACEHOLDERS.map((p) => (
|
||||
<Badge key={p}>{`{{${p}}}`}</Badge>
|
||||
))}
|
||||
</InlineStack>
|
||||
<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."
|
||||
/>
|
||||
{saveBtn}
|
||||
</BlockStack>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<TextField
|
||||
label="Oggetto"
|
||||
value={subject}
|
||||
onChange={setSubject}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<TextField
|
||||
label="Introduzione"
|
||||
value={intro}
|
||||
onChange={setIntro}
|
||||
autoComplete="off"
|
||||
multiline={4}
|
||||
helpText="Saluto e frase iniziale della email."
|
||||
/>
|
||||
<TextField
|
||||
label="Nota aggiuntiva (opzionale)"
|
||||
value={note}
|
||||
onChange={setNote}
|
||||
autoComplete="off"
|
||||
multiline={3}
|
||||
placeholder="Es. Per il reso, spedisci il pacco a..."
|
||||
helpText="Riquadro in fondo alla email. Lascia vuoto per non mostrarlo."
|
||||
/>
|
||||
|
||||
<ButtonGroup>
|
||||
<Button variant="primary" loading={saving} onClick={handleSave}>
|
||||
Salva
|
||||
</Button>
|
||||
<Button onClick={handleReset}>Ripristina default</Button>
|
||||
</ButtonGroup>
|
||||
</BlockStack>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<BlockStack gap="400">
|
||||
<Text as="h2" variant="headingMd">
|
||||
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>
|
||||
{tab === 2 ? (
|
||||
<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. Le regole di esclusione
|
||||
si gestiscono nella pagina Esclusioni.
|
||||
</Text>
|
||||
</BlockStack>
|
||||
<Checkbox
|
||||
label="Blocca i recessi oltre il termine"
|
||||
checked={enforceWindow}
|
||||
onChange={setEnforceWindow}
|
||||
helpText="Calcolato dalla data di consegna + 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="Blocca se l'intero ordine è escluso. Regole nella pagina Esclusioni."
|
||||
/>
|
||||
{saveBtn}
|
||||
</BlockStack>
|
||||
<Checkbox
|
||||
label="Blocca i recessi oltre il termine"
|
||||
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>
|
||||
) : null}
|
||||
|
||||
<Card>
|
||||
{tab === 3 ? (
|
||||
<BlockStack gap="400">
|
||||
<Text as="h2" variant="headingMd">
|
||||
Reso e stato ordine
|
||||
</Text>
|
||||
<Checkbox
|
||||
label="Adatta la ricevuta allo stato dell'ordine"
|
||||
checked={stateAwareEmail}
|
||||
onChange={setStateAwareEmail}
|
||||
helpText="Non evaso → annullo+rimborso; spedito/consegnato → istruzioni di reso."
|
||||
/>
|
||||
<Checkbox
|
||||
label="Annulla automaticamente gli ordini non ancora spediti"
|
||||
checked={autoCancelUnfulfilled}
|
||||
onChange={setAutoCancelUnfulfilled}
|
||||
helpText="Al recesso, se l'ordine non è evaso: annullo + rimborso automatici. Irreversibile."
|
||||
/>
|
||||
<Checkbox
|
||||
label="Spese di restituzione a carico del cliente (Art. 57)"
|
||||
checked={returnAtCustomerExpense}
|
||||
onChange={setReturnAtCustomerExpense}
|
||||
/>
|
||||
<TextField
|
||||
label="Indirizzo per il reso"
|
||||
value={returnAddress}
|
||||
onChange={setReturnAddress}
|
||||
autoComplete="off"
|
||||
multiline={2}
|
||||
placeholder="Via ..., CAP Città (PR)"
|
||||
/>
|
||||
<TextField
|
||||
label="Istruzioni di reso (opzionale)"
|
||||
value={returnInstructions}
|
||||
onChange={setReturnInstructions}
|
||||
autoComplete="off"
|
||||
multiline={3}
|
||||
helpText="Testo aggiuntivo mostrato nella ricevuta per ordini spediti/consegnati."
|
||||
/>
|
||||
<div>
|
||||
<Button variant="primary" loading={saving} onClick={handleSave}>
|
||||
Salva
|
||||
</Button>
|
||||
</div>
|
||||
<Card>
|
||||
<BlockStack gap="400">
|
||||
<BlockStack gap="100">
|
||||
<Text as="h2" variant="headingMd">
|
||||
Reso e stato ordine
|
||||
</Text>
|
||||
<Text as="p" tone="subdued">
|
||||
Il riquadro nella ricevuta cambia in base allo stato.
|
||||
Segnaposto nei testi:
|
||||
</Text>
|
||||
<InlineStack gap="200" wrap>
|
||||
{OP_PLACEHOLDERS.map((p) => (
|
||||
<Badge key={p}>{`{{${p}}}`}</Badge>
|
||||
))}
|
||||
</InlineStack>
|
||||
</BlockStack>
|
||||
<Checkbox
|
||||
label="Adatta la ricevuta allo stato dell'ordine"
|
||||
checked={stateAwareEmail}
|
||||
onChange={setStateAwareEmail}
|
||||
/>
|
||||
<Checkbox
|
||||
label="Annulla automaticamente gli ordini non ancora spediti"
|
||||
checked={autoCancelUnfulfilled}
|
||||
onChange={setAutoCancelUnfulfilled}
|
||||
helpText="Al recesso, se l'ordine non è evaso: annullo + rimborso automatici. Irreversibile."
|
||||
/>
|
||||
<Checkbox
|
||||
label="Spese di restituzione a carico del cliente (Art. 57)"
|
||||
checked={returnAtCustomerExpense}
|
||||
onChange={setReturnAtCustomerExpense}
|
||||
helpText="Determina il valore di {{returnCost}}."
|
||||
/>
|
||||
<TextField
|
||||
label="Indirizzo per il reso"
|
||||
value={returnAddress}
|
||||
onChange={setReturnAddress}
|
||||
autoComplete="off"
|
||||
multiline={2}
|
||||
placeholder="Via ..., CAP Città (PR)"
|
||||
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>
|
||||
</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>
|
||||
) : null}
|
||||
</BlockStack>
|
||||
</Layout.Section>
|
||||
</Layout>
|
||||
|
||||
@@ -375,9 +375,10 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
(state === "unfulfilled" || state === "shipped" || state === "delivered")
|
||||
? {
|
||||
state,
|
||||
textUnfulfilled: settings?.opTextUnfulfilled,
|
||||
textShipped: settings?.opTextShipped,
|
||||
returnAddress: settings?.returnAddress,
|
||||
atCustomerExpense: settings?.returnAtCustomerExpense ?? true,
|
||||
instructions: settings?.returnInstructions,
|
||||
}
|
||||
: null;
|
||||
const receipt = await sendWithdrawalReceipt({
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Settings" ADD COLUMN "opTextShipped" TEXT,
|
||||
ADD COLUMN "opTextUnfulfilled" TEXT;
|
||||
@@ -60,7 +60,9 @@ model Settings {
|
||||
stateAwareEmail Boolean @default(true)
|
||||
autoCancelUnfulfilled Boolean @default(false)
|
||||
returnAtCustomerExpense Boolean @default(true)
|
||||
returnInstructions String?
|
||||
returnInstructions String? // deprecato: sostituito da opTextShipped
|
||||
opTextUnfulfilled String?
|
||||
opTextShipped String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
|
||||
Reference in New Issue
Block a user