Files
pcrt-legal-return/app/app/routes/app.settings.tsx
tommaso aa0c77289d Ambito del recesso esplicito (intero ordine) + avviso auto-annullo + A6-ter in roadmap
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).
2026-07-10 10:56:50 +02:00

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>
);
}