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);
|
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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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)
|
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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user