Repository Public
1import type { NotificationKind } from "../services/notify";23export type Locale = "fr" | "en";45export interface MailContext {6 locale: Locale;7 kind: NotificationKind;8 ownerName: string;9 brandColor: string;10 typeTitle: string;11 visitorName: string;12 /** Instant shown, and the zone it is shown in (visitor's zone for visitors, owner's zone for the owner). */13 start: Date | null; end: Date | null; tz: string;14 /** Previous / proposed time when relevant. */15 otherStart?: Date | null;16 locationKind: string; locationText: string; // locationText is filled only for confirmed appointments17 instructions: string;18 message: string; // decline or proposal message written by the owner19 manageUrl: string | null;20 adminUrl: string;21 cancelledBy?: string;22 hoursBefore?: number | null;23}2425const esc = (s: string) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);2627export function formatWhen(start: Date, end: Date | null, tz: string, locale: Locale): string {28 const tag = locale === "fr" ? "fr-CA" : "en-CA";29 const day = new Intl.DateTimeFormat(tag, { timeZone: tz, weekday: "long", day: "numeric", month: "long", year: "numeric" }).format(start);30 const time = new Intl.DateTimeFormat(tag, { timeZone: tz, hour: "numeric", minute: "2-digit" });31 const zone = new Intl.DateTimeFormat(tag, { timeZone: tz, timeZoneName: "short" }).formatToParts(start).find((p) => p.type === "timeZoneName")?.value ?? tz;32 const range = end ? `${time.format(start)} – ${time.format(end)}` : time.format(start);33 return locale === "fr" ? `${day}, ${range} (${zone}, ${tz})` : `${day}, ${range} (${zone}, ${tz})`;34}3536const LOC: Record<Locale, Record<string, string>> = {37 fr: { video: "Visioconférence", phone: "Téléphone", in_person: "En personne" },38 en: { video: "Video call", phone: "Phone", in_person: "In person" },39};4041interface Copy { subject: string; title: string; lines: string[]; cta?: { label: string; url: string | null }; showLocation?: boolean; whenLabel?: string; otherLabel?: string }4243function copy(c: MailContext): Copy {44 const fr = c.locale === "fr";45 const o = c.ownerName, t = c.typeTitle, v = c.visitorName;46 const manage = { label: fr ? "Voir ou gérer ma demande" : "View or manage my request", url: c.manageUrl };47 const manageBooking = { label: fr ? "Gérer mon rendez-vous" : "Manage my appointment", url: c.manageUrl };48 const admin = (label: string) => ({ label, url: c.adminUrl });49 switch (c.kind) {50 case "request_received": return fr51 ? { subject: `Demande reçue — ${t}`, title: "Votre demande est reçue", lines: [`Bonjour ${v},`, `${o} a bien reçu votre demande de rendez-vous. Elle est en attente d'approbation : ce n'est pas encore un rendez-vous confirmé.`, "Vous recevrez un courriel dès qu'elle aura été acceptée ou refusée."], cta: manage, whenLabel: "Moment demandé" }52 : { subject: `Request received — ${t}`, title: "Your request was received", lines: [`Hello ${v},`, `${o} received your appointment request. It is awaiting approval: this is not a confirmed appointment yet.`, "You will get an e-mail as soon as it is accepted or declined."], cta: manage, whenLabel: "Requested time" };53 case "booking_confirmed": return fr54 ? { subject: `Rendez-vous confirmé — ${t}`, title: "Votre rendez-vous est confirmé", lines: [`Bonjour ${v},`, `Votre rendez-vous avec ${o} est confirmé. L'invitation de calendrier est jointe à ce courriel.`], cta: manageBooking, showLocation: true, whenLabel: "Date et heure" }55 : { subject: `Appointment confirmed — ${t}`, title: "Your appointment is confirmed", lines: [`Hello ${v},`, `Your appointment with ${o} is confirmed. The calendar invitation is attached.`], cta: manageBooking, showLocation: true, whenLabel: "Date and time" };56 case "request_declined": return fr57 ? { subject: `Demande non retenue — ${t}`, title: "Votre demande n'a pas pu être acceptée", lines: [`Bonjour ${v},`, `${o} ne peut malheureusement pas vous recevoir au moment demandé.`, "Vous pouvez soumettre une nouvelle demande pour un autre moment."], whenLabel: "Moment demandé" }58 : { subject: `Request declined — ${t}`, title: "Your request could not be accepted", lines: [`Hello ${v},`, `Unfortunately ${o} cannot meet you at the requested time.`, "You are welcome to submit a new request for another time."], whenLabel: "Requested time" };59 case "reschedule_declined": return fr60 ? { subject: `Changement non retenu — ${t}`, title: "Le changement d'horaire n'a pas pu être accepté", lines: [`Bonjour ${v},`, "Votre rendez-vous reste confirmé à l'heure initiale, indiquée ci-dessous."], cta: manageBooking, whenLabel: "Rendez-vous maintenu" }61 : { subject: `Change declined — ${t}`, title: "The new time could not be accepted", lines: [`Hello ${v},`, "Your appointment stays confirmed at its original time, shown below."], cta: manageBooking, whenLabel: "Appointment kept" };62 case "proposal": return fr63 ? { subject: `Autre moment proposé — ${t}`, title: "Un autre moment vous est proposé", lines: [`Bonjour ${v},`, `${o} n'est pas disponible au moment demandé et vous propose l'horaire ci-dessous. Rien n'est confirmé tant que vous ne l'avez pas accepté.`], cta: { label: "Accepter ou refuser la proposition", url: c.manageUrl }, whenLabel: "Moment proposé", otherLabel: "Moment demandé à l'origine" }64 : { subject: `Another time proposed — ${t}`, title: "Another time is proposed", lines: [`Hello ${v},`, `${o} is not available at the requested time and proposes the time below. Nothing is confirmed until you accept it.`], cta: { label: "Accept or decline the proposal", url: c.manageUrl }, whenLabel: "Proposed time", otherLabel: "Originally requested" };65 case "booking_cancelled": return fr66 ? { subject: `Rendez-vous annulé — ${t}`, title: "Votre rendez-vous est annulé", lines: [`Bonjour ${v},`, c.cancelledBy === "visitor" ? "Votre annulation est enregistrée." : `${o} a dû annuler ce rendez-vous.`, "L'annulation de l'invitation de calendrier est jointe."], whenLabel: "Rendez-vous annulé" }67 : { subject: `Appointment cancelled — ${t}`, title: "Your appointment is cancelled", lines: [`Hello ${v},`, c.cancelledBy === "visitor" ? "Your cancellation is recorded." : `${o} had to cancel this appointment.`, "The calendar cancellation is attached."], whenLabel: "Cancelled appointment" };68 case "booking_rescheduled": return fr69 ? { subject: `Rendez-vous déplacé — ${t}`, title: "Votre rendez-vous a un nouvel horaire", lines: [`Bonjour ${v},`, "Le nouvel horaire confirmé est indiqué ci-dessous. L'invitation de calendrier mise à jour est jointe."], cta: manageBooking, showLocation: true, whenLabel: "Nouvel horaire" }70 : { subject: `Appointment moved — ${t}`, title: "Your appointment has a new time", lines: [`Hello ${v},`, "The new confirmed time is shown below. The updated calendar invitation is attached."], cta: manageBooking, showLocation: true, whenLabel: "New time" };71 case "request_expired": return fr72 ? { subject: `Demande expirée — ${t}`, title: "Votre demande a expiré", lines: [`Bonjour ${v},`, "Votre demande n'a pas reçu de réponse à temps et a expiré. Aucun rendez-vous n'a été confirmé.", "Vous pouvez soumettre une nouvelle demande."], whenLabel: "Moment demandé" }73 : { subject: `Request expired — ${t}`, title: "Your request has expired", lines: [`Hello ${v},`, "Your request was not answered in time and has expired. No appointment was confirmed.", "You are welcome to submit a new request."], whenLabel: "Requested time" };74 case "reminder": return fr75 ? { subject: `Rappel — ${t}`, title: "Rappel de votre rendez-vous", lines: [`Bonjour ${v},`, `Ceci est un rappel de votre rendez-vous avec ${o}.`], cta: manageBooking, showLocation: true, whenLabel: "Date et heure" }76 : { subject: `Reminder — ${t}`, title: "Appointment reminder", lines: [`Hello ${v},`, `This is a reminder of your appointment with ${o}.`], cta: manageBooking, showLocation: true, whenLabel: "Date and time" };77 case "admin_new_request": return fr78 ? { subject: `Nouvelle demande — ${v} (${t})`, title: "Nouvelle demande à traiter", lines: [`${v} demande un rendez-vous « ${t} ».`, "Le motif et les réponses sont visibles dans l'espace d'administration."], cta: admin("Ouvrir la demande"), whenLabel: "Moment demandé" }79 : { subject: `New request — ${v} (${t})`, title: "New request to review", lines: [`${v} asks for a “${t}” appointment.`, "The reason and answers are visible in the admin area."], cta: admin("Open the request"), whenLabel: "Requested time" };80 case "admin_new_booking": return fr81 ? { subject: `Nouveau rendez-vous confirmé — ${v} (${t})`, title: "Rendez-vous confirmé automatiquement", lines: [`${v} a réservé « ${t} » (confirmation automatique).`], cta: admin("Ouvrir l'agenda"), whenLabel: "Date et heure" }82 : { subject: `New confirmed appointment — ${v} (${t})`, title: "Appointment confirmed automatically", lines: [`${v} booked “${t}” (automatic confirmation).`], cta: admin("Open the agenda"), whenLabel: "Date and time" };83 case "admin_visitor_cancelled": return fr84 ? { subject: `Annulation — ${v} (${t})`, title: "Un rendez-vous a été annulé par le visiteur", lines: [`${v} a annulé son rendez-vous « ${t} ». Le créneau est de nouveau libre.`], cta: admin("Ouvrir l'agenda"), whenLabel: "Rendez-vous annulé" }85 : { subject: `Cancellation — ${v} (${t})`, title: "An appointment was cancelled by the visitor", lines: [`${v} cancelled the “${t}” appointment. The slot is free again.`], cta: admin("Open the agenda"), whenLabel: "Cancelled appointment" };86 case "admin_request_withdrawn": return fr87 ? { subject: `Demande retirée — ${v} (${t})`, title: "Une demande a été retirée", lines: [`${v} a retiré sa demande « ${t} ».`], cta: admin("Ouvrir les demandes"), whenLabel: "Moment demandé" }88 : { subject: `Request withdrawn — ${v} (${t})`, title: "A request was withdrawn", lines: [`${v} withdrew the “${t}” request.`], cta: admin("Open requests"), whenLabel: "Requested time" };89 case "admin_reschedule_request": return fr90 ? { subject: `Changement d'horaire demandé — ${v} (${t})`, title: "Un visiteur demande un autre horaire", lines: [`${v} souhaite déplacer son rendez-vous confirmé. Le rendez-vous reste à son heure actuelle tant que vous n'avez pas répondu.`], cta: admin("Ouvrir la demande"), whenLabel: "Nouvel horaire demandé", otherLabel: "Horaire actuel" }91 : { subject: `Reschedule requested — ${v} (${t})`, title: "A visitor asks for another time", lines: [`${v} would like to move a confirmed appointment. It stays at its current time until you answer.`], cta: admin("Open the request"), whenLabel: "Requested new time", otherLabel: "Current time" };92 case "admin_proposal_accepted": return fr93 ? { subject: `Proposition acceptée — ${v} (${t})`, title: "Votre proposition a été acceptée", lines: [`${v} a accepté l'horaire proposé. Le rendez-vous est confirmé.`], cta: admin("Ouvrir l'agenda"), whenLabel: "Date et heure" }94 : { subject: `Proposal accepted — ${v} (${t})`, title: "Your proposal was accepted", lines: [`${v} accepted the proposed time. The appointment is confirmed.`], cta: admin("Open the agenda"), whenLabel: "Date and time" };95 case "test": return fr96 ? { subject: "SP Calendar — courriel de test", title: "Le transport de courriel fonctionne", lines: ["Ce message a été envoyé depuis les paramètres de SP Calendar pour vérifier la configuration."] }97 : { subject: "SP Calendar — test e-mail", title: "The e-mail transport works", lines: ["This message was sent from the SP Calendar settings to check the configuration."] };98 }99}100101export function renderMail(c: MailContext): { subject: string; text: string; html: string } {102 const k = copy(c);103 const fr = c.locale === "fr";104 const rows: [string, string][] = [];105 if (c.kind !== "test") rows.push([fr ? "Type" : "Type", c.typeTitle]);106 if (c.start) rows.push([k.whenLabel ?? (fr ? "Moment" : "Time"), formatWhen(c.start, c.end, c.tz, c.locale)]);107 if (c.otherStart && k.otherLabel) rows.push([k.otherLabel, formatWhen(c.otherStart, null, c.tz, c.locale)]);108 if (c.kind !== "test") rows.push([fr ? "Format" : "Format", LOC[c.locale][c.locationKind] ?? c.locationKind]);109 if (k.showLocation && c.locationText) rows.push([fr ? "Accès" : "Details", c.locationText]);110 if (k.showLocation && c.instructions) rows.push([fr ? "Instructions" : "Instructions", c.instructions]);111 if (c.message) rows.push([fr ? "Message" : "Message", c.message]);112113 const footer = fr114 ? `Vous recevez ce courriel parce qu'une demande de rendez-vous a été faite avec cette adresse auprès de ${c.ownerName}.`115 : `You receive this e-mail because an appointment request was made with this address to ${c.ownerName}.`;116117 const text = [k.title, "", ...k.lines, "", ...rows.map(([a, b]) => `${a} : ${b}`), ...(k.cta?.url ? ["", `${k.cta.label} : ${k.cta.url}`] : []), "", "—", footer, "SP Calendar"].join("\n");118119 const html = `<!doctype html><html lang="${c.locale}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${esc(k.subject)}</title></head>120<body style="margin:0;background:#f3f4f7;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#0a0d14;">121<div style="max-width:560px;margin:0 auto;padding:28px 16px;">122<div style="font-size:13px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:#596273;margin:0 0 12px 6px;">SP Calendar</div>123<div style="background:#ffffff;border-radius:22px;padding:28px 24px;border:1px solid #e3e6ec;">124<h1 style="font-size:22px;line-height:1.25;margin:0 0 16px;font-weight:650;letter-spacing:-.02em;">${esc(k.title)}</h1>125${k.lines.map((l) => `<p style="font-size:15px;line-height:1.55;margin:0 0 10px;">${esc(l)}</p>`).join("")}126${rows.length ? `<table role="presentation" style="width:100%;border-collapse:collapse;margin:18px 0 6px;">${rows.map(([a, b]) => `<tr><td style="padding:10px 12px 10px 0;border-top:1px solid #eceef3;font-size:13px;color:#596273;vertical-align:top;white-space:nowrap;">${esc(a)}</td><td style="padding:10px 0;border-top:1px solid #eceef3;font-size:15px;line-height:1.45;">${esc(b).replace(/\n/g, "<br>")}</td></tr>`).join("")}</table>` : ""}127${k.cta?.url ? `<p style="margin:22px 0 4px;"><a href="${esc(k.cta.url)}" style="display:inline-block;background:#0a0d14;color:#ffffff;text-decoration:none;font-size:15px;font-weight:600;padding:13px 20px;border-radius:16px;">${esc(k.cta.label)}</a></p>` : ""}128</div>129<p style="font-size:12px;line-height:1.5;color:#596273;margin:16px 6px 0;">${esc(footer)}</p>130</div></body></html>`;131 return { subject: k.subject, text, html };132}133