Piattaforma ITI TECHNOLOGY: monorepo, Medusa, API, sito, area riservata e deploy
Deploy stack su Proxmox / deploy (push) Failing after 3s

Monorepo npm con apps/public (Angular SSR), apps/private (Angular SPA),
apps/api (Fastify), packages/shared e packages/styles; Medusa 2.21 in medusa/;
infrastruttura Docker (Postgres PostGIS+pgvector, Redis, Caddy, backup) e
workflow Gitea Actions per il deploy via SSH a ogni push su master.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jQyT6dR9czzGwqb7FkG6u
This commit is contained in:
Lorenzo Sanesi
2026-10-20 09:30:48 +02:00
co-authored by Claude Opus 5.5
commit b83ce039fb
505 changed files with 84544 additions and 0 deletions
+44
View File
@@ -0,0 +1,44 @@
import { html, oneLine } from "./html.js"
import { button, layout, paragraph, type RenderedEmail } from "./layout.js"
export type VerifyEmailData = { firstName: string; link: string }
export type ResetPasswordData = { firstName: string; link: string }
export type RegisterExistingData = { firstName: string; forgotPasswordUrl: string }
export function verifyEmail(d: VerifyEmailData): RenderedEmail {
return layout({
subject: oneLine("Conferma il tuo indirizzo email - ITI TECHNOLOGY"),
preheader: "Conferma il tuo indirizzo per completare la registrazione",
body: html`${paragraph(html`Ciao ${d.firstName},`)}
${paragraph("conferma il tuo indirizzo email con il pulsante qui sotto. Il link è valido 48 ore.")}
${button("Conferma email", d.link)}
${paragraph("Se non hai creato tu l'account, ignora questo messaggio.")}`,
text: `Ciao ${d.firstName},\n\nconferma il tuo indirizzo email aprendo questo link (valido 48 ore):\n${d.link}\n\nSe non hai creato tu l'account, ignora questo messaggio.`,
})
}
export function resetPassword(d: ResetPasswordData): RenderedEmail {
return layout({
subject: oneLine("Reimposta la password - ITI TECHNOLOGY"),
preheader: "Scegli una nuova password per il tuo account",
body: html`${paragraph(html`Ciao ${d.firstName},`)}
${paragraph("per scegliere una nuova password usa il pulsante qui sotto. Il link è valido 1 ora.")}
${button("Reimposta password", d.link)}
${paragraph("Se non hai richiesto tu il reset, ignora questo messaggio: la password attuale resta valida.")}`,
text: `Ciao ${d.firstName},\n\nper scegliere una nuova password apri questo link (valido 1 ora):\n${d.link}\n\nSe non hai richiesto tu il reset, ignora questo messaggio: la password attuale resta valida.`,
})
}
/** Avviso al titolare quando qualcuno prova a registrarsi con la sua email (anti-enumerazione). */
export function registerExisting(d: RegisterExistingData): RenderedEmail {
return layout({
subject: oneLine("Tentativo di registrazione con il tuo indirizzo - ITI TECHNOLOGY"),
preheader: "Il tuo indirizzo è già registrato",
body: html`${paragraph(html`Ciao ${d.firstName},`)}
${paragraph("qualcuno ha provato a creare un account con questo indirizzo, che è già registrato.")}
${paragraph("Se hai dimenticato la password puoi reimpostarla:")}
${button("Reimposta password", d.forgotPasswordUrl)}
${paragraph("Se non sei stato tu, ignora questo messaggio.")}`,
text: `Ciao ${d.firstName},\n\nqualcuno ha provato a creare un account con questo indirizzo, che è già registrato.\nSe hai dimenticato la password puoi reimpostarla da: ${d.forgotPasswordUrl}\n\nSe non sei stato tu, ignora questo messaggio.`,
})
}
+178
View File
@@ -0,0 +1,178 @@
import { formatCents, formatDate, formatDateTime, html, oneLine } from "./html.js"
import { button, layout, paragraph, type RenderedEmail, table } from "./layout.js"
export type QuoteRequestAdminData = {
name: string
email: string
phone: string | null
province: string | null
/** Sintesi leggibile della richiesta (es. risposte del configuratore): coppie etichetta/valore. */
summary: { label: string; value: string }[]
adminUrl: string
}
/** Conferma all'utente della richiesta inviata dal configuratore. */
export type QuoteRequestReceivedData = {
name: string
solutionTitle: string
summary: { label: string; value: string }[]
disclaimer: string
}
export type QuoteSentData = {
firstName: string
quoteNumber: string
title: string
amountCents: number
/** Data di scadenza (ISO) o null. */
validUntil: string | null
link: string
}
export type InstallerApprovedData = { firstName: string; businessName: string; link: string }
export type InstallerRejectedData = { firstName: string; businessName: string; reason: string | null }
/** Profilo installatore approvato: accesso a listino riservato e documentazione tecnica. */
export function installerApproved(d: InstallerApprovedData): RenderedEmail {
const what = "da ora vedi i prezzi del listino riservato agli installatori e puoi scaricare la documentazione tecnica dall'area riservata."
return layout({
subject: oneLine("Account installatore approvato - ITI TECHNOLOGY"),
preheader: "Il tuo profilo installatore è stato approvato",
body: html`${paragraph(html`Ciao ${d.firstName},`)}
${paragraph(html`il profilo installatore di <strong>${d.businessName}</strong> è stato approvato: ${what}`)}
${button("Vai all'area installatori", d.link)}
${paragraph("Se eri già collegato, esci e rientra per vedere subito i prezzi aggiornati.")}`,
text: `Ciao ${d.firstName},\n\nil profilo installatore di ${d.businessName} è stato approvato: ${what}\n\nArea installatori: ${d.link}\n\nSe eri già collegato, esci e rientra per vedere subito i prezzi aggiornati.`,
})
}
export function installerRejected(d: InstallerRejectedData): RenderedEmail {
const outro = "Per chiarimenti o per inviare documentazione aggiuntiva rispondi a questa email."
return layout({
subject: oneLine("Richiesta account installatore - ITI TECHNOLOGY"),
preheader: "Esito della richiesta di account installatore",
body: html`${paragraph(html`Ciao ${d.firstName},`)}
${paragraph(html`al momento non possiamo approvare il profilo installatore di <strong>${d.businessName}</strong>.`)}
${d.reason ? paragraph(html`Motivo: ${d.reason}`) : ""}
${paragraph("Puoi comunque usare il tuo account come cliente.")}
${paragraph(outro)}`,
text:
`Ciao ${d.firstName},\n\nal momento non possiamo approvare il profilo installatore di ${d.businessName}.` +
(d.reason ? `\nMotivo: ${d.reason}` : "") +
`\n\nPuoi comunque usare il tuo account come cliente.\n${outro}`,
})
}
/** Notifica generica allo staff (nuovo ticket, risposta del cliente, preventivo accettato/rifiutato). */
export type StaffNotificationData = { title: string; intro: string; rows: { label: string; value: string }[]; url: string }
export type TicketReplyData = { firstName: string; ticketSubject: string; message: string; link: string }
export type ShowroomBookingConfirmedData = {
name: string
/** Inizio dello slot (ISO). */
requestedAt: string
people: number
notes: string | null
}
/** Nuova richiesta di preventivo (notifica interna). */
export function quoteRequestAdmin(d: QuoteRequestAdminData): RenderedEmail {
const contact: [string, string][] = [
["Nome", d.name],
["Email", d.email],
["Telefono", d.phone ?? "-"],
["Provincia", d.province ?? "-"],
]
return layout({
subject: oneLine(`Nuova richiesta di preventivo - ${d.name}`),
preheader: `Richiesta da ${d.email}`,
body: html`${paragraph("È arrivata una nuova richiesta di preventivo.")}
${table(contact)}
${d.summary.length ? table(d.summary.map((s) => [s.label, s.value] as const)) : ""}
${button("Apri nel pannello admin", d.adminUrl)}`,
text:
`È arrivata una nuova richiesta di preventivo.\n\n${contact.map(([k, v]) => `${k}: ${v}`).join("\n")}` +
(d.summary.length ? `\n\n${d.summary.map((s) => `${s.label}: ${s.value}`).join("\n")}` : "") +
`\n\nApri nel pannello admin: ${d.adminUrl}`,
})
}
export function quoteRequestReceived(d: QuoteRequestReceivedData): RenderedEmail {
const intro = "abbiamo ricevuto la tua richiesta: un nostro tecnico ti contatterà per verificare i dati e preparare il preventivo."
return layout({
subject: oneLine("Abbiamo ricevuto la tua richiesta - ITI TECHNOLOGY"),
preheader: `Soluzione indicativa: ${d.solutionTitle}`,
body: html`${paragraph(html`Ciao ${d.name},`)}
${paragraph(intro)}
${paragraph(html`Soluzione indicativa: <strong>${d.solutionTitle}</strong>`)}
${d.summary.length ? table(d.summary.map((s) => [s.label, s.value] as const)) : ""}
<p style="margin:0 0 16px;color:#6b7280;font-size:13px">${d.disclaimer}</p>`,
text:
`Ciao ${d.name},\n\n${intro}\n\nSoluzione indicativa: ${d.solutionTitle}` +
(d.summary.length ? `\n\n${d.summary.map((s) => `${s.label}: ${s.value}`).join("\n")}` : "") +
`\n\n${d.disclaimer}`,
})
}
/** Preventivo inviato al cliente. */
export function quoteSent(d: QuoteSentData): RenderedEmail {
const rows: [string, string][] = [
["Preventivo", d.quoteNumber],
["Oggetto", d.title],
["Importo (IVA inclusa)", formatCents(d.amountCents)],
]
if (d.validUntil) rows.push(["Valido fino al", formatDate(d.validUntil)])
return layout({
subject: oneLine(`Preventivo ${d.quoteNumber} - ITI TECHNOLOGY`),
preheader: `Il preventivo ${d.quoteNumber} è disponibile`,
body: html`${paragraph(html`Ciao ${d.firstName},`)}
${paragraph("abbiamo preparato il tuo preventivo.")}
${table(rows)}
${button("Visualizza il preventivo", d.link)}
${paragraph("Per domande o modifiche rispondi a questa email.")}`,
text: `Ciao ${d.firstName},\n\nabbiamo preparato il tuo preventivo.\n\n${rows.map(([k, v]) => `${k}: ${v}`).join("\n")}\n\nVisualizzalo nell'area clienti: ${d.link}\n\nPer domande o modifiche rispondi a questa email.`,
})
}
export function staffNotification(d: StaffNotificationData): RenderedEmail {
return layout({
subject: oneLine(`[ITI] ${d.title}`),
preheader: d.intro,
body: html`${paragraph(d.intro)}
${d.rows.length ? table(d.rows.map((r) => [r.label, r.value] as const)) : ""}
${button("Apri nel pannello admin", d.url)}`,
text: `${d.intro}\n\n${d.rows.map((r) => `${r.label}: ${r.value}`).join("\n")}\n\nApri nel pannello admin: ${d.url}`,
})
}
/** Nuova risposta a un ticket di assistenza. */
export function ticketReply(d: TicketReplyData): RenderedEmail {
return layout({
subject: oneLine(`Risposta alla richiesta "${d.ticketSubject}" - ITI TECHNOLOGY`),
preheader: "C'è una nuova risposta alla tua richiesta di assistenza",
body: html`${paragraph(html`Ciao ${d.firstName},`)}
${paragraph(html`c'è una nuova risposta alla tua richiesta <strong>${d.ticketSubject}</strong>:`)}
<blockquote style="margin:0 0 16px;padding:12px 16px;border-left:4px solid #0b5c7a;background:#f9fafb;white-space:pre-wrap">${d.message}</blockquote>
${button("Rispondi", d.link)}`,
text: `Ciao ${d.firstName},\n\nc'è una nuova risposta alla tua richiesta "${d.ticketSubject}":\n\n${d.message}\n\nPer rispondere: ${d.link}`,
})
}
/** Conferma della prenotazione di una visita allo showroom. */
export function showroomBookingConfirmed(d: ShowroomBookingConfirmedData): RenderedEmail {
const rows: [string, string][] = [
["Data e ora", formatDateTime(d.requestedAt)],
["Persone", String(d.people)],
]
if (d.notes) rows.push(["Note", d.notes])
return layout({
subject: oneLine("Visita allo showroom confermata - ITI TECHNOLOGY"),
preheader: `Ti aspettiamo ${formatDateTime(d.requestedAt)}`,
body: html`${paragraph(html`Ciao ${d.name},`)}
${paragraph("la tua visita allo showroom è confermata.")}
${table(rows)}
${paragraph("Se non puoi più venire, rispondi a questa email così liberiamo lo slot.")}`,
text: `Ciao ${d.name},\n\nla tua visita allo showroom è confermata.\n\n${rows.map(([k, v]) => `${k}: ${v}`).join("\n")}\n\nSe non puoi più venire, rispondi a questa email così liberiamo lo slot.`,
})
}
+68
View File
@@ -0,0 +1,68 @@
/**
* Mini-helper per HTML sicuro nelle email: ogni valore interpolato con il tag `html` viene
* escapato, tranne i frammenti già sicuri (SafeHtml, prodotti da `html` stesso).
*/
export class SafeHtml {
constructor(readonly value: string) {}
toString() {
return this.value
}
}
const ESCAPES: Record<string, string> = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }
/** Escape per contenuto e attributi HTML. */
export const escapeHtml = (value: unknown) => String(value ?? "").replace(/[&<>"']/g, (c) => ESCAPES[c] ?? c)
type HtmlValue = SafeHtml | string | number | null | undefined | false | readonly HtmlValue[]
function render(value: HtmlValue): string {
if (value === null || value === undefined || value === false) return ""
if (value instanceof SafeHtml) return value.value
if (Array.isArray(value)) return value.map(render).join("")
return escapeHtml(value)
}
/** Template literal con escaping automatico: html`<p>${nome}</p>`. */
export function html(strings: TemplateStringsArray, ...values: HtmlValue[]): SafeHtml {
let out = strings[0] ?? ""
values.forEach((v, i) => {
out += render(v) + (strings[i + 1] ?? "")
})
return new SafeHtml(out)
}
/** Solo URL http(s): un valore diverso (es. "javascript:") diventa "#". */
export function safeUrl(url: string): string {
try {
const parsed = new URL(url)
return parsed.protocol === "https:" || parsed.protocol === "http:" ? parsed.toString() : "#"
} catch {
return "#"
}
}
/** Oggetto email su una riga (niente a capo: evita header injection e oggetti spezzati). */
export const oneLine = (value: string) => value.replace(/[\r\n]+/g, " ").trim()
/**
* Centesimi → "1.190,00 €". useGrouping "always": per it-IT il CLDR non separa le migliaia sotto le
* 5 cifre; il valore stringa è ES2023 (Node 24), i tipi della lib ES2022 ammettono solo boolean.
*/
const euro = new Intl.NumberFormat("it-IT", {
style: "currency",
currency: "EUR",
useGrouping: "always" as unknown as boolean,
})
export const formatCents = (cents: number) => euro.format(cents / 100)
const dateTime = new Intl.DateTimeFormat("it-IT", {
dateStyle: "full",
timeStyle: "short",
timeZone: "Europe/Rome",
})
const dateOnly = new Intl.DateTimeFormat("it-IT", { dateStyle: "long", timeZone: "Europe/Rome" })
/** Data e ora in italiano, fuso Europe/Rome: "sabato 10 ottobre 2026 alle ore 15:00". */
export const formatDateTime = (iso: string) => dateTime.format(new Date(iso))
export const formatDate = (iso: string) => dateOnly.format(new Date(iso))
+78
View File
@@ -0,0 +1,78 @@
import { registerExisting, type RegisterExistingData, resetPassword, type ResetPasswordData, verifyEmail, type VerifyEmailData } from "./auth.js"
import {
installerApproved,
type InstallerApprovedData,
installerRejected,
type InstallerRejectedData,
quoteRequestAdmin,
type QuoteRequestAdminData,
quoteRequestReceived,
type QuoteRequestReceivedData,
quoteSent,
type QuoteSentData,
showroomBookingConfirmed,
type ShowroomBookingConfirmedData,
staffNotification,
type StaffNotificationData,
ticketReply,
type TicketReplyData,
} from "./business.js"
import type { RenderedEmail } from "./layout.js"
import {
orderCanceled,
type OrderCanceledData,
orderConfirmation,
type OrderConfirmationData,
orderPlacedAdmin,
type OrderPlacedAdminData,
orderShipped,
type OrderShippedData,
} from "./orders.js"
export type { RenderedEmail } from "./layout.js"
export type { OrderEmailData } from "./orders.js"
/** Dati richiesti da ogni template (il nome è anche il tag inviato al provider). */
export type EmailTemplates = {
"verify-email": VerifyEmailData
"reset-password": ResetPasswordData
"register-existing": RegisterExistingData
"order-confirmation": OrderConfirmationData
"order-placed-admin": OrderPlacedAdminData
"order-canceled": OrderCanceledData
"order-shipped": OrderShippedData
"quote-request-admin": QuoteRequestAdminData
"quote-request-received": QuoteRequestReceivedData
"quote-sent": QuoteSentData
"ticket-reply": TicketReplyData
"showroom-booking-confirmed": ShowroomBookingConfirmedData
"staff-notification": StaffNotificationData
"installer-approved": InstallerApprovedData
"installer-rejected": InstallerRejectedData
}
export type EmailTemplate = keyof EmailTemplates
const renderers: { [K in EmailTemplate]: (data: EmailTemplates[K]) => RenderedEmail } = {
"verify-email": verifyEmail,
"reset-password": resetPassword,
"register-existing": registerExisting,
"order-confirmation": orderConfirmation,
"order-placed-admin": orderPlacedAdmin,
"order-canceled": orderCanceled,
"order-shipped": orderShipped,
"quote-request-admin": quoteRequestAdmin,
"quote-request-received": quoteRequestReceived,
"quote-sent": quoteSent,
"ticket-reply": ticketReply,
"showroom-booking-confirmed": showroomBookingConfirmed,
"staff-notification": staffNotification,
"installer-approved": installerApproved,
"installer-rejected": installerRejected,
}
export const EMAIL_TEMPLATES = Object.keys(renderers) as EmailTemplate[]
/** Renderizza un template (oggetto, HTML con layout comune, testo). */
export function renderEmail<T extends EmailTemplate>(template: T, data: EmailTemplates[T]): RenderedEmail {
return renderers[template](data)
}
+58
View File
@@ -0,0 +1,58 @@
import { html, SafeHtml, safeUrl } from "./html.js"
/** Email renderizzata: oggetto, HTML (layout comune) e testo semplice. */
export type RenderedEmail = { subject: string; html: string; text: string }
const BRAND = "ITI TECHNOLOGY"
const COLOR = "#0b5c7a"
const FOOTER_TEXT = `${BRAND} · trattamento acqua, energia rinnovabile, comfort e mobilità elettrica`
/** Pulsante (link) in stile brand; l'URL passa da safeUrl. */
export const button = (label: string, url: string) => html`<table role="presentation" cellpadding="0" cellspacing="0" style="margin:24px 0">
<tr><td style="background:${COLOR};border-radius:6px">
<a href="${safeUrl(url)}" style="display:inline-block;padding:12px 24px;color:#ffffff;text-decoration:none;font-weight:bold">${label}</a>
</td></tr>
</table>`
export const paragraph = (content: SafeHtml | string) => html`<p style="margin:0 0 16px">${content}</p>`
/** Tabella chiave/valore (riepiloghi d'ordine, preventivi, prenotazioni). */
export function table(rows: readonly (readonly [string, SafeHtml | string])[], opts: { boldLast?: boolean } = {}) {
return html`<table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="border-collapse:collapse;margin:0 0 16px">
${rows.map(
([label, value], i) =>
html`<tr>
<td style="padding:6px 0;border-bottom:1px solid #e5e7eb${opts.boldLast && i === rows.length - 1 ? ";font-weight:bold" : ""}">${label}</td>
<td style="padding:6px 0;border-bottom:1px solid #e5e7eb;text-align:right${opts.boldLast && i === rows.length - 1 ? ";font-weight:bold" : ""}">${value}</td>
</tr>`,
)}
</table>`
}
/**
* Layout comune: intestazione brand, contenuto, piè di pagina. HTML semplice a tabelle con stili
* inline (compatibile con i client di posta). `preheader` = anteprima mostrata nella lista messaggi.
*/
export function layout(opts: { subject: string; preheader: string; body: SafeHtml; text: string }): RenderedEmail {
const page = html`<!doctype html>
<html lang="it">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${opts.subject}</title></head>
<body style="margin:0;padding:0;background:#f3f4f6;font-family:Arial,Helvetica,sans-serif;color:#111827;line-height:1.5">
<span style="display:none;max-height:0;overflow:hidden;opacity:0">${opts.preheader}</span>
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="background:#f3f4f6;padding:24px 0">
<tr><td align="center">
<table role="presentation" cellpadding="0" cellspacing="0" width="600" style="max-width:600px;width:100%;background:#ffffff;border-radius:8px;overflow:hidden">
<tr><td style="background:${COLOR};padding:20px 32px;color:#ffffff;font-size:20px;font-weight:bold;letter-spacing:1px">${BRAND}</td></tr>
<tr><td style="padding:32px">${opts.body}</td></tr>
<tr><td style="padding:16px 32px;background:#f9fafb;color:#6b7280;font-size:12px">${FOOTER_TEXT}<br>Questa è un'email automatica.</td></tr>
</table>
</td></tr>
</table>
</body>
</html>`
return {
subject: opts.subject,
html: page.value,
text: `${opts.text.trim()}\n\n--\n${FOOTER_TEXT}\n`,
}
}
+122
View File
@@ -0,0 +1,122 @@
import { formatCents, html, oneLine } from "./html.js"
import { button, layout, paragraph, type RenderedEmail, table } from "./layout.js"
/** Dati d'ordine per le email (indipendenti da Medusa). Importi in centesimi IVA inclusa. */
export type OrderEmailData = {
number: number
email: string
customerName: string
guest: boolean
items: { name: string; quantity: number; total: number }[]
itemTotal: number
shippingTotal: number
discountTotal: number
taxTotal: number
total: number
/** Righe dell'indirizzo di spedizione (vuoto se assente). */
shippingAddress: string[]
}
export type OrderConfirmationData = { order: OrderEmailData; ordersUrl: string }
export type OrderPlacedAdminData = { order: OrderEmailData }
export type OrderCanceledData = { order: OrderEmailData }
export type OrderShippedData = { order: OrderEmailData; tracking: { number: string | null; url: string | null }[] }
const SIGN = "\n\nGrazie,\nITI TECHNOLOGY"
function itemsText(o: OrderEmailData) {
return o.items.map((i) => `- ${i.quantity} x ${i.name}: ${formatCents(i.total)}`).join("\n")
}
function totalsRows(o: OrderEmailData) {
const rows: [string, string][] = [
["Prodotti", formatCents(o.itemTotal)],
["Spedizione", formatCents(o.shippingTotal)],
]
if (o.discountTotal) rows.push(["Sconti", `-${formatCents(o.discountTotal)}`])
rows.push([`Totale (di cui IVA ${formatCents(o.taxTotal)})`, formatCents(o.total)])
return rows
}
const totalsText = (o: OrderEmailData) => totalsRows(o).map(([k, v]) => `${k}: ${v}`).join("\n")
function summaryHtml(o: OrderEmailData) {
return html`${table(o.items.map((i) => [`${i.quantity} × ${i.name}`, formatCents(i.total)] as const))}
${table(totalsRows(o), { boldLast: true })}
${o.shippingAddress.length
? html`<p style="margin:0 0 16px"><strong>Spedizione a</strong><br>${o.shippingAddress.map((l, i) => html`${i ? html`<br>` : ""}${l}`)}</p>`
: ""}`
}
const addressText = (o: OrderEmailData, label: string) =>
o.shippingAddress.length ? `\n\n${label}:\n${o.shippingAddress.join("\n")}` : ""
export function orderConfirmation(d: OrderConfirmationData): RenderedEmail {
const o = d.order
return layout({
subject: oneLine(`Conferma ordine n. ${o.number} - ITI TECHNOLOGY`),
preheader: `Abbiamo ricevuto il tuo ordine n. ${o.number}`,
body: html`${paragraph(html`Ciao ${o.customerName},`)}
${paragraph(html`abbiamo ricevuto il tuo ordine <strong>n. ${o.number}</strong>.`)}
${summaryHtml(o)}
${paragraph("Ti scriveremo quando l'ordine verrà spedito.")}
${o.guest ? "" : button("Vedi i tuoi ordini", d.ordersUrl)}`,
text:
`Ciao ${o.customerName},\n\nabbiamo ricevuto il tuo ordine n. ${o.number}.\n\n${itemsText(o)}\n\n${totalsText(o)}` +
addressText(o, "Spedizione a") +
`\n\nTi scriveremo quando l'ordine verrà spedito.` +
(o.guest ? "" : ` Puoi seguirlo dall'area clienti: ${d.ordersUrl}`) +
SIGN,
})
}
/** Notifica interna di nuovo ordine. */
export function orderPlacedAdmin(d: OrderPlacedAdminData): RenderedEmail {
const o = d.order
return layout({
subject: oneLine(`Nuovo ordine n. ${o.number} - ${formatCents(o.total)}`),
preheader: `Ordine di ${o.email}`,
body: html`${paragraph(html`Nuovo ordine <strong>n. ${o.number}</strong> da ${o.email}${o.guest ? " (guest)" : ""}.`)}
${summaryHtml(o)}
${paragraph("Gestione dal dashboard Medusa.")}`,
text:
`Nuovo ordine n. ${o.number} da ${o.email}${o.guest ? " (guest)" : ""}.\n\n${itemsText(o)}\n\n${totalsText(o)}` +
addressText(o, "Spedizione a") +
`\n\nGestione dal dashboard Medusa.`,
})
}
export function orderCanceled(d: OrderCanceledData): RenderedEmail {
const o = d.order
const refund =
"Se il pagamento era già stato addebitato riceverai il rimborso sullo stesso metodo di pagamento. Per qualsiasi domanda rispondi a questa email."
return layout({
subject: oneLine(`Ordine n. ${o.number} annullato - ITI TECHNOLOGY`),
preheader: `Il tuo ordine n. ${o.number} è stato annullato`,
body: html`${paragraph(html`Ciao ${o.customerName},`)}
${paragraph(html`il tuo ordine <strong>n. ${o.number}</strong> è stato annullato.`)}
${table(o.items.map((i) => [`${i.quantity} × ${i.name}`, formatCents(i.total)] as const))}
${paragraph(refund)}`,
text: `Ciao ${o.customerName},\n\nil tuo ordine n. ${o.number} è stato annullato.\n\n${itemsText(o)}\n\n${refund}${SIGN}`,
})
}
export function orderShipped(d: OrderShippedData): RenderedEmail {
const o = d.order
const tracking = d.tracking.filter((t) => t.number || t.url)
return layout({
subject: oneLine(`Ordine n. ${o.number} spedito - ITI TECHNOLOGY`),
preheader: `Il tuo ordine n. ${o.number} è in spedizione`,
body: html`${paragraph(html`Ciao ${o.customerName},`)}
${paragraph(html`il tuo ordine <strong>n. ${o.number}</strong> è in spedizione.`)}
${tracking.map((t) => (t.url ? button(t.number ? `Traccia ${t.number}` : "Traccia la spedizione", t.url) : paragraph(html`Tracciamento: <strong>${t.number}</strong>`)))}
${o.shippingAddress.length
? html`<p style="margin:0 0 16px"><strong>Indirizzo di consegna</strong><br>${o.shippingAddress.map((l, i) => html`${i ? html`<br>` : ""}${l}`)}</p>`
: ""}`,
text:
`Ciao ${o.customerName},\n\nil tuo ordine n. ${o.number} è in spedizione.` +
(tracking.length ? `\n\nTracciamento:\n${tracking.map((t) => `- ${[t.number, t.url].filter(Boolean).join(" - ")}`).join("\n")}` : "") +
addressText(o, "Indirizzo di consegna") +
SIGN,
})
}
+93
View File
@@ -0,0 +1,93 @@
/**
* Anteprima di tutti i template: npm run email:preview -w apps/api [-- destinatario@example.com]
* Li invia con il trasporto configurato (EMAIL_PROVIDER). Con "console" li logga e, se
* EMAIL_OUTBOX_DIR è impostata, salva .txt e .html da aprire nel browser.
* Con brevo/resend invia davvero: usare un destinatario proprio.
*/
import pino from "pino"
import { loadConfig } from "../config/env.js"
import { createEmailTransport, EmailSender } from "../services/email.js"
import { EMAIL_TEMPLATES, type EmailTemplate, type EmailTemplates } from "./index.js"
const order = {
number: 1042,
email: "mario.rossi@example.com",
customerName: "Mario",
guest: false,
items: [
{ name: "Addolcitore 30 L - Standard", quantity: 1, total: 119000 },
{ name: "Cartuccia filtro - Confezione da 3", quantity: 2, total: 5980 },
],
itemTotal: 124980,
shippingTotal: 1500,
discountTotal: 0,
taxTotal: 22808,
total: 126480,
shippingAddress: ["Mario Rossi", "Via Roma 1", "20100 Milano (MI)"],
}
const samples: { [K in EmailTemplate]: EmailTemplates[K] } = {
"verify-email": { firstName: "Mario", link: "http://localhost:4300/verify-email?token=anteprima" },
"reset-password": { firstName: "Mario", link: "http://localhost:4300/reset-password?token=anteprima" },
"register-existing": { firstName: "Mario", forgotPasswordUrl: "http://localhost:4300/forgot-password" },
"order-confirmation": { order, ordersUrl: "http://localhost:4300/ordini" },
"order-placed-admin": { order },
"order-canceled": { order },
"order-shipped": { order, tracking: [{ number: "BRT123456", url: "https://tracking.example/BRT123456" }] },
"quote-request-admin": {
name: "Giulia Bianchi",
email: "giulia@example.com",
phone: "333 1234567",
province: "TO",
summary: [
{ label: "Soluzione", value: "Fotovoltaico + accumulo" },
{ label: "Consumo annuo", value: "4.500 kWh" },
],
adminUrl: "http://localhost:4300/admin/preventivi",
},
"quote-request-received": {
name: "Giulia",
solutionTitle: "Fotovoltaico 4,5 kWp con accumulo 10 kWh",
summary: [
{ label: "Investimento indicativo", value: "11.100 €" },
{ label: "Risparmio annuo stimato", value: "1.150 €" },
],
disclaimer: "Stima indicativa basata su valori medi: non costituisce un'offerta.",
},
"quote-sent": {
firstName: "Giulia",
quoteNumber: "2026-0042",
title: "Impianto fotovoltaico 6 kW con accumulo",
amountCents: 1290000,
validUntil: "2026-11-30",
link: "http://localhost:4300/preventivi",
},
"ticket-reply": {
firstName: "Giulia",
ticketSubject: "Addolcitore in allarme",
message: "Buongiorno,\nil tecnico passerà giovedì mattina.\nCordiali saluti",
link: "http://localhost:4300/assistenza",
},
"staff-notification": {
title: "Preventivo 2026-0042 accettato",
intro: "Il cliente ha accettato il preventivo.",
rows: [
{ label: "Cliente", value: "Giulia Bianchi (giulia@example.com)" },
{ label: "Importo", value: "12.900,00 €" },
],
url: "http://localhost:4300/admin/preventivi",
},
"installer-approved": { firstName: "Paolo", businessName: "Impianti Verdi Srl", link: "http://localhost:4300/installatori" },
"installer-rejected": { firstName: "Paolo", businessName: "Impianti Verdi Srl", reason: "Partita IVA non verificabile" },
"showroom-booking-confirmed": { name: "Giulia", requestedAt: "2026-10-10T13:00:00.000Z", people: 2, notes: null },
}
const config = loadConfig()
const log = pino({ level: "info" })
const to = process.argv[2] ?? "anteprima@example.com"
const email = new EmailSender(createEmailTransport(config, log), log)
for (const template of EMAIL_TEMPLATES) {
await email.send({ to, template, data: samples[template] } as never)
}
await email.flush()
log.info({ provider: config.EMAIL_PROVIDER, outbox: config.EMAIL_OUTBOX_DIR, count: EMAIL_TEMPLATES.length }, "anteprima completata")