Base legale (ANALISI-REQUISITI-LEGALI.md §5): il considerando 37 della Dir. (UE)
2023/2673 dice che il professionista *can* offrire il recesso su parte del
contratto, non che *deve*. Un pulsante a livello di ordine e' conforme. Ma il form
non lo diceva, mentre le automazioni agivano in modo totale.
- statementTemplate: 'relativo all'INTERO ordine #X' (la dichiarazione ora
corrisponde a cio' che l'app fa davvero).
- SCOPE_HINT sotto la dichiarazione: il recesso riguarda l'intero ordine, per il
parziale contattare il negozio (canale modulo tipo/email, sempre valido).
- Admin: banner quando autoCancelUnfulfilled e' attivo -> annulla e rimborsa
l'INTERO ordine, irreversibile; con ordini multi-articolo meglio tenerlo spento.
- PLAN: A6-ter (recesso parziale) come attivita' OPZIONALE con toggle per-shop
partialWithdrawalEnabled, default OFF. Include la nota che l'idempotenza
(shop, orderId) di fa8ee9c andra' riportata a (shop, orderId, articoli).
- ANALISI-REQUISITI-LEGALI.md §5: punto chiarito + panorama concorrenti (Revize
fa il parziale, Rescindly no, Shopify non ha pulsante nativo, LegalBlink non e'
un concorrente ma un generatore di documenti).
700 lines
26 KiB
TypeScript
700 lines
26 KiB
TypeScript
import { useEffect, useMemo, useState } from "react";
|
|
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node";
|
|
import {
|
|
useActionData,
|
|
useLoaderData,
|
|
useNavigation,
|
|
useSubmit,
|
|
} from "@remix-run/react";
|
|
import {
|
|
Page,
|
|
Layout,
|
|
Card,
|
|
Tabs,
|
|
TextField,
|
|
Button,
|
|
ButtonGroup,
|
|
Banner,
|
|
Text,
|
|
BlockStack,
|
|
InlineStack,
|
|
Badge,
|
|
Checkbox,
|
|
} from "@shopify/polaris";
|
|
import { TitleBar } from "@shopify/app-bridge-react";
|
|
|
|
import { authenticate } from "../shopify.server";
|
|
import db from "../db.server";
|
|
import { decryptSecret, encryptSecret } from "../lib/crypto.server";
|
|
import { sendTestEmail } from "../lib/mailer.server";
|
|
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 s = await db.settings.findUnique({ where: { shop: session.shop } });
|
|
return {
|
|
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,
|
|
smtpHost: s?.smtpHost ?? "",
|
|
smtpPort: s?.smtpPort != null ? String(s.smtpPort) : "",
|
|
smtpUser: s?.smtpUser ?? "",
|
|
smtpSecure: s?.smtpSecure ?? false,
|
|
smtpFrom: s?.smtpFrom ?? "",
|
|
smtpPassSet: !!s?.smtpPass,
|
|
};
|
|
};
|
|
|
|
export const action = async ({ request }: ActionFunctionArgs) => {
|
|
const { session } = await authenticate.admin(request);
|
|
const f = await request.formData();
|
|
|
|
// Invio di prova: NON salva, usa i valori correnti del form (password digitata
|
|
// oppure quella gia' salvata, decifrata). Ritorna l'errore SMTP grezzo.
|
|
if (String(f.get("intent") ?? "save") === "test") {
|
|
const to = String(f.get("testTo") ?? "").trim();
|
|
if (!to) {
|
|
return {
|
|
ok: false,
|
|
tested: true,
|
|
error: "Inserisci un destinatario per la prova.",
|
|
};
|
|
}
|
|
const host = String(f.get("smtpHost") ?? "").trim();
|
|
let pass: string | null = String(f.get("smtpPass") ?? "").trim() || null;
|
|
if (!pass) {
|
|
const saved = await db.settings.findUnique({
|
|
where: { shop: session.shop },
|
|
});
|
|
pass = saved?.smtpPass ? decryptSecret(saved.smtpPass) : null;
|
|
}
|
|
const smtp = host
|
|
? {
|
|
host,
|
|
port:
|
|
Number(f.get("smtpPort")) > 0
|
|
? Math.trunc(Number(f.get("smtpPort")))
|
|
: null,
|
|
user: String(f.get("smtpUser") ?? "").trim() || null,
|
|
pass,
|
|
secure: f.get("smtpSecure") === "true",
|
|
from: String(f.get("smtpFrom") ?? "").trim() || null,
|
|
}
|
|
: null;
|
|
const r = await sendTestEmail({ smtp, to });
|
|
return { ok: r.ok, tested: true, error: r.ok ? null : r.error };
|
|
}
|
|
|
|
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: 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(f.get("windowDays")) || 14),
|
|
),
|
|
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,
|
|
smtpHost: String(f.get("smtpHost") ?? "").trim() || null,
|
|
smtpPort:
|
|
Number(f.get("smtpPort")) > 0 ? Math.trunc(Number(f.get("smtpPort"))) : null,
|
|
smtpUser: String(f.get("smtpUser") ?? "").trim() || null,
|
|
smtpSecure: f.get("smtpSecure") === "true",
|
|
smtpFrom: String(f.get("smtpFrom") ?? "").trim() || null,
|
|
};
|
|
|
|
// Password SMTP: cifrata solo se fornita; vuota = invariata.
|
|
const newPass = String(f.get("smtpPass") ?? "").trim();
|
|
const finalData = newPass
|
|
? { ...data, smtpPass: encryptSecret(newPass) }
|
|
: data;
|
|
|
|
await db.settings.upsert({
|
|
where: { shop: session.shop },
|
|
create: { shop: session.shop, ...finalData },
|
|
update: finalData,
|
|
});
|
|
return { ok: true, tested: false, error: null };
|
|
};
|
|
|
|
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 d = useLoaderData<typeof loader>();
|
|
const actionData = useActionData<typeof action>();
|
|
const nav = useNavigation();
|
|
const submit = useSubmit();
|
|
|
|
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(
|
|
d.autoCancelUnfulfilled,
|
|
);
|
|
const [returnAtCustomerExpense, setReturnAtCustomerExpense] = useState(
|
|
d.returnAtCustomerExpense,
|
|
);
|
|
const [returnAddress, setReturnAddress] = useState(d.returnAddress);
|
|
const [opTextUnfulfilled, setOpTextUnfulfilled] = useState(d.opTextUnfulfilled);
|
|
const [opTextShipped, setOpTextShipped] = useState(d.opTextShipped);
|
|
const [smtpHost, setSmtpHost] = useState(d.smtpHost);
|
|
const [smtpPort, setSmtpPort] = useState(d.smtpPort);
|
|
const [smtpUser, setSmtpUser] = useState(d.smtpUser);
|
|
const [smtpPass, setSmtpPass] = useState("");
|
|
const [smtpSecure, setSmtpSecure] = useState(d.smtpSecure);
|
|
const [smtpFrom, setSmtpFrom] = useState(d.smtpFrom);
|
|
const [testTo, setTestTo] = useState(d.notifyEmail);
|
|
const [showSaved, setShowSaved] = useState(false);
|
|
|
|
const saving = nav.state === "submitting";
|
|
|
|
useEffect(() => {
|
|
if (actionData?.ok && !actionData.tested) setShowSaved(true);
|
|
}, [actionData]);
|
|
|
|
const previewSubject = useMemo(
|
|
() => renderSubject(subject, SAMPLE_VARS),
|
|
[subject],
|
|
);
|
|
const previewHtml = useMemo(
|
|
() => 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 handleTest = () => {
|
|
setShowSaved(false);
|
|
const fd = new FormData();
|
|
fd.set("intent", "test");
|
|
fd.set("testTo", testTo);
|
|
fd.set("smtpHost", smtpHost);
|
|
fd.set("smtpPort", smtpPort);
|
|
fd.set("smtpUser", smtpUser);
|
|
fd.set("smtpPass", smtpPass);
|
|
fd.set("smtpSecure", String(smtpSecure));
|
|
fd.set("smtpFrom", smtpFrom);
|
|
submit(fd, { method: "post" });
|
|
};
|
|
|
|
const handleSave = () => {
|
|
setShowSaved(false);
|
|
const fd = new FormData();
|
|
fd.set("intent", "save");
|
|
fd.set("subject", subject);
|
|
fd.set("intro", intro);
|
|
fd.set("note", note);
|
|
fd.set("notifyEnabled", String(notifyEnabled));
|
|
fd.set("notifyEmail", notifyEmail);
|
|
fd.set("tagEnabled", String(tagEnabled));
|
|
fd.set("enforceWindow", String(enforceWindow));
|
|
fd.set("windowDays", windowDays);
|
|
fd.set("enforceExclusions", String(enforceExclusions));
|
|
fd.set("stateAwareEmail", String(stateAwareEmail));
|
|
fd.set("autoCancelUnfulfilled", String(autoCancelUnfulfilled));
|
|
fd.set("returnAtCustomerExpense", String(returnAtCustomerExpense));
|
|
fd.set("returnAddress", returnAddress);
|
|
fd.set("opTextUnfulfilled", opTextUnfulfilled);
|
|
fd.set("opTextShipped", opTextShipped);
|
|
fd.set("smtpHost", smtpHost);
|
|
fd.set("smtpPort", smtpPort);
|
|
fd.set("smtpUser", smtpUser);
|
|
fd.set("smtpPass", smtpPass);
|
|
fd.set("smtpSecure", String(smtpSecure));
|
|
fd.set("smtpFrom", smtpFrom);
|
|
submit(fd, { method: "post" });
|
|
};
|
|
|
|
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" },
|
|
{ id: "smtp", content: "Email (SMTP)" },
|
|
];
|
|
|
|
return (
|
|
<Page>
|
|
<TitleBar title="Impostazioni recesso" />
|
|
<Layout>
|
|
<Layout.Section>
|
|
<BlockStack gap="400">
|
|
{showSaved ? (
|
|
<Banner tone="success" onDismiss={() => setShowSaved(false)}>
|
|
Salvato.
|
|
</Banner>
|
|
) : null}
|
|
|
|
{actionData?.tested ? (
|
|
actionData.ok ? (
|
|
<Banner tone="success">
|
|
Email di prova inviata a {testTo}. Controlla anche lo spam.
|
|
</Banner>
|
|
) : (
|
|
<Banner tone="critical" title="Invio di prova fallito">
|
|
<Text as="p">{actionData.error}</Text>
|
|
</Banner>
|
|
)
|
|
) : null}
|
|
|
|
<Tabs tabs={tabs} selected={tab} onSelect={setTab} />
|
|
|
|
{tab === 0 ? (
|
|
<BlockStack gap="400">
|
|
<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">
|
|
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"
|
|
/>
|
|
{notifyEnabled && !notifyEmail.trim() ? (
|
|
<Banner tone="warning">
|
|
Notifiche attive ma nessun indirizzo: al momento non ricevi
|
|
nulla. Inserisci un'email.
|
|
</Banner>
|
|
) : null}
|
|
{notifyEnabled &&
|
|
notifyEmail.trim() &&
|
|
smtpFrom.trim() &&
|
|
smtpFrom.toLowerCase().includes(notifyEmail.trim().toLowerCase()) ? (
|
|
<Banner tone="warning">
|
|
L'indirizzo di notifica coincide con il mittente SMTP.
|
|
Spedire da un indirizzo a sé stesso passando da un relay
|
|
esterno viene spesso bloccato o scartato dai provider
|
|
(Gmail in primis). Usa un destinatario diverso.
|
|
</Banner>
|
|
) : null}
|
|
<Checkbox
|
|
label="Aggiungi il tag 'Recesso' all'ordine"
|
|
checked={tagEnabled}
|
|
onChange={setTagEnabled}
|
|
helpText="Rende l'ordine filtrabile nella lista ordini."
|
|
/>
|
|
{saveBtn}
|
|
</BlockStack>
|
|
</Card>
|
|
) : null}
|
|
|
|
{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>
|
|
</Card>
|
|
) : null}
|
|
|
|
{tab === 3 ? (
|
|
<BlockStack gap="400">
|
|
<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."
|
|
/>
|
|
{autoCancelUnfulfilled ? (
|
|
<Banner tone="warning" title="Annulla l'INTERO ordine">
|
|
<Text as="p">
|
|
Il recesso da questa funzione riguarda tutto l'ordine.
|
|
Se il cliente voleva restituire solo alcuni articoli,
|
|
l'annullo automatico gli cancella e rimborsa l'intero
|
|
ordine, e non è reversibile. Con ordini multi-articolo
|
|
valuta di tenerlo spento e decidere caso per caso.
|
|
</Text>
|
|
</Banner>
|
|
) : null}
|
|
<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>
|
|
) : null}
|
|
|
|
{tab === 4 ? (
|
|
<Card>
|
|
<BlockStack gap="400">
|
|
<BlockStack gap="100">
|
|
<Text as="h2" variant="headingMd">
|
|
Email (SMTP)
|
|
</Text>
|
|
<Text as="p" tone="subdued">
|
|
Vuoto = provider di default dell'app. Compila per inviare dal
|
|
tuo SMTP (email dal tuo dominio). La password è cifrata a
|
|
riposo.
|
|
</Text>
|
|
</BlockStack>
|
|
<TextField
|
|
label="Host SMTP"
|
|
value={smtpHost}
|
|
onChange={setSmtpHost}
|
|
autoComplete="off"
|
|
placeholder="smtp-relay.brevo.com"
|
|
/>
|
|
<TextField
|
|
label="Porta"
|
|
type="number"
|
|
value={smtpPort}
|
|
onChange={setSmtpPort}
|
|
autoComplete="off"
|
|
placeholder="587"
|
|
/>
|
|
<TextField
|
|
label="Utente"
|
|
value={smtpUser}
|
|
onChange={setSmtpUser}
|
|
autoComplete="off"
|
|
/>
|
|
<TextField
|
|
label="Password"
|
|
type="password"
|
|
value={smtpPass}
|
|
onChange={setSmtpPass}
|
|
autoComplete="off"
|
|
helpText={
|
|
d.smtpPassSet
|
|
? "Impostata. Lascia vuoto per non cambiarla."
|
|
: "Non impostata."
|
|
}
|
|
/>
|
|
<Checkbox
|
|
label="Connessione sicura diretta (SSL/TLS)"
|
|
checked={smtpSecure}
|
|
onChange={setSmtpSecure}
|
|
helpText="Ignorato sulle porte standard: 465 usa sempre TLS diretto, 587 usa sempre STARTTLS. Vale solo su porte non standard."
|
|
/>
|
|
<TextField
|
|
label="Mittente (From)"
|
|
value={smtpFrom}
|
|
onChange={setSmtpFrom}
|
|
autoComplete="off"
|
|
placeholder="Il tuo negozio <no-reply@tuodominio.it>"
|
|
/>
|
|
|
|
{smtpHost && !smtpFrom ? (
|
|
<Banner tone="warning">
|
|
Host SMTP impostato ma mittente vuoto. Brevo (come quasi
|
|
tutti i provider) rifiuta un mittente non verificato:
|
|
compila "Mittente (From)" con un indirizzo verificato nel
|
|
tuo account.
|
|
</Banner>
|
|
) : null}
|
|
|
|
<TextField
|
|
label="Destinatario dell'email di prova"
|
|
type="email"
|
|
value={testTo}
|
|
onChange={setTestTo}
|
|
autoComplete="off"
|
|
placeholder="tu@tuodominio.it"
|
|
helpText="La prova usa i valori qui sopra, anche se non ancora salvati."
|
|
/>
|
|
|
|
<ButtonGroup>
|
|
<Button
|
|
variant="primary"
|
|
loading={saving}
|
|
onClick={handleSave}
|
|
>
|
|
Salva
|
|
</Button>
|
|
<Button loading={saving} onClick={handleTest}>
|
|
Invia email di prova
|
|
</Button>
|
|
</ButtonGroup>
|
|
</BlockStack>
|
|
</Card>
|
|
) : null}
|
|
</BlockStack>
|
|
</Layout.Section>
|
|
</Layout>
|
|
</Page>
|
|
);
|
|
}
|