Piattaforma ITI TECHNOLOGY: monorepo, Medusa, API, sito, area riservata e deploy
Deploy stack su Proxmox / deploy (push) Failing after 3s
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:
@@ -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.`,
|
||||
})
|
||||
}
|
||||
@@ -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.`,
|
||||
})
|
||||
}
|
||||
@@ -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> = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }
|
||||
|
||||
/** 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))
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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`,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user