Completed email renderer poc for appointment confirmation

This commit is contained in:
Karl Ludwig Weise
2025-11-24 21:42:14 +01:00
parent 80662c5da4
commit 1fac1e0e2c
12 changed files with 233 additions and 73 deletions
+11
View File
@@ -907,5 +907,16 @@
"reserved": "reserviert"
}
}
},
"emails": {
"poweredBy": "Betrieben mit",
"greeting": "Hallo {name},",
"oclock": "Uhr",
"appointmentBooked": {
"subject": "{channel} bei {tenant} gebucht",
"introduction": "Ihr Termin wurde gebucht.",
"action": "Termin absagen",
"reason": "Sie erhalten diese E-Mail, weil jemand mit Ihrer E-Mail Adresse einen Termin bei gebucht hat."
}
}
}
+11
View File
@@ -916,5 +916,16 @@
"reserved": "reserved"
}
}
},
"emails": {
"poweredBy": "Powered by",
"greeting": "Hello {name},",
"oclock": "",
"appointmentBooked": {
"subject": "{channel} at {tenant} booked successfully",
"introduction": "your appointment for has been booked.",
"action": "Cancel appointment",
"reason": "You are receiving this email because someone booked an appointment with your e-mail address."
}
}
}
+2
View File
@@ -2,6 +2,8 @@ import { m } from "$i18n/messages";
export const supportedLocales = ["en", "de"] as const;
export type SupportedLocale = (typeof supportedLocales)[number];
export const translatedLocales = {
en: m["locales.en"](),
de: m["locales.de"](),
+52 -20
View File
@@ -1,33 +1,65 @@
<script lang="js">
<script lang="ts">
import { m } from "$i18n/messages";
import { getLocale, setLocale } from "$i18n/runtime";
import { setLocale } from "$i18n/runtime";
import type { SupportedLocale } from "$lib/const/locales";
import type { SelectTenant } from "$lib/server/db/central-schema";
import { type SelectAppointment } from "$lib/server/db/tenant-schema";
import type { SelectClient } from "$lib/server/email/email-service";
import EmailButton from "./components/EmailButton.svelte";
import EmailHeadline from "./components/EmailHeadline.svelte";
import EmailLayout from "./components/EmailLayout.svelte";
import EmailText from "./components/EmailText.svelte";
import { renderAppointmentDate, renderAppointmentTime } from "./utils";
let { name, locale } = $props();
let {
locale,
user,
tenant,
channel,
appointment,
address,
cancelUrl,
}: {
locale: SupportedLocale;
user: SelectClient;
tenant: SelectTenant;
channel: string;
appointment: SelectAppointment & { agentName: string };
address: {
street: string;
number: string;
additionalAddressInfo?: string;
zip: string;
city: string;
};
cancelUrl: string;
} = $props();
setLocale(locale);
console.log("Locale in AppointmentBooked:", getLocale());
</script>
<EmailLayout>
<p>{m["absences.add.description"]()} {name},</p>
<p>your appointment has been booked.</p>
<h2>Vaccination Appointment</h2>
<p>
Dr. John Doe<br />
June 30, 2024<br />
10:00 AM - 10:15 AM
</p>
<h2>Praxis Dr. Jane Doe</h2>
<p>
Musterstraße 10<br />
Etage 4<br />
12345 Musterstadt
</p>
<EmailButton href="https://open-reception.org/appointments">View Appointments</EmailButton>
<EmailText variant="md">{m["emails.greeting"]({ name: user.email })}</EmailText>
<EmailText variant="md">
{m["emails.appointmentBooked.introduction"]()}
</EmailText>
<EmailHeadline>{channel}</EmailHeadline>
<EmailText variant="md">
{appointment.agentName}<br />
{renderAppointmentDate(appointment.appointmentDate, locale)}<br />
{renderAppointmentTime(appointment.appointmentDate, locale)}
{m["emails.oclock"]()}
</EmailText>
<EmailHeadline>{tenant.longName}</EmailHeadline>
<EmailText variant="md">
{address.street}
{address.number}<br />
{#if address.additionalAddressInfo}{address.additionalAddressInfo}<br />{/if}
{address.zip}
{address.city}
</EmailText>
<EmailButton href={cancelUrl}>{m["emails.appointmentBooked.action"]()}</EmailButton>
<EmailText variant="md" color="text-light">
You are receiving this email because someone booked an appointment with your e-mail address.
{m["emails.appointmentBooked.reason"]()}
</EmailText>
</EmailLayout>
+2 -3
View File
@@ -15,8 +15,7 @@
.button {
font-size: 16px;
line-height: 1.5;
color: #ffffff;
background-color: #000000;
border: 1px solid #000000;
padding: 12px 32px;
padding: 0.75rem 2rem;
text-decoration: none;
@@ -24,8 +23,8 @@
border-radius: 12px;
}
.button:hover {
background-color: #333333;
cursor: pointer;
background-color: rgb(0 0 0 / 0.1);
}
</style>
</svelte:head>
@@ -0,0 +1,7 @@
<script lang="js">
let { children, variant = "h2", class: className = "" } = $props();
</script>
<svelte:element this={variant} class={[className].filter((it) => it).join(" ")}>
{@render children?.()}
</svelte:element>
+7 -27
View File
@@ -1,4 +1,5 @@
<script lang="js">
import { m } from "$i18n/messages";
import EmailText from "./EmailText.svelte";
let { children } = $props();
@@ -6,16 +7,12 @@
<div class="page">
<div class="container">
<div class="paper">
<div class="content">
{@render children?.()}
</div>
</div>
<div class="branding">
<EmailText variant="sm" color="text-light">
Powered by <a href="https://open-reception.org">OpenReception</a>
</EmailText>
<div class="content">
{@render children?.()}
</div>
<EmailText variant="sm" color="text-light" class="branding">
{m["emails.poweredBy"]()} <a href="https://open-reception.org">OpenReception</a>
</EmailText>
</div>
</div>
@@ -53,36 +50,19 @@
margin-top: 0;
}
.page {
background-color: #f6f6f6;
min-height: 100%;
min-height: 100vh;
box-sizing: border-box;
width: 100%;
padding: 0 16px 16px;
padding: 0 1rem 1rem;
}
.container {
max-width: 600px;
margin: 0 auto;
}
.content {
padding: 24px;
padding: 1.5rem;
}
.paper {
background-color: white;
width: 100%;
box-shadow: 0 0 2px 2px rgba(0, 0, 0, 0.1);
overflow: hidden;
border-bottom-right-radius: 6px;
border-bottom-left-radius: 6px;
}
.branding {
margin: 16px 0;
margin: 1rem 0;
padding: 0 16px;
padding: 0 1rem;
text-align: center;
width: 100%;
}
</style>
</svelte:head>
+5 -2
View File
@@ -1,8 +1,8 @@
<script lang="js">
let { children, variant, color } = $props();
let { children, variant, color = "text-darkest", class: className = "" } = $props();
</script>
<span class={[variant, color].join(" ")}>{@render children?.()}</span>
<p class={[variant, color, className].filter((it) => it).join(" ")}>{@render children?.()}</p>
<svelte:head>
<style>
@@ -22,6 +22,9 @@
font-size: 10px;
line-height: 1.25;
}
.text-darkest {
color: #000000;
}
.text-light {
color: #888888;
}
+58
View File
@@ -1,3 +1,7 @@
import type { SupportedLocale } from "$lib/const/locales";
import { format, type Locale } from "date-fns";
import { de, enUS } from "date-fns/locale";
// is not exported from svelte
export type RenderOutput = {
head: string;
@@ -19,3 +23,57 @@ export const renderOutputToHtml = (renderOutput: RenderOutput) => {
</html>
`;
};
export const htmlToText = (html: string) => {
let text = html;
// Replace anchor tags with "Link Text: URL" format
const newLinePlaceholder = "{new-line}";
text = text.replace(/<a\s+(?:[^>]*?\s+)?href="([^"]*)"[^>]*>(.*?)<\/a>/gi, `$2: $1<br /><br />`);
// Remove script and style tags and their content
text = text.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, "");
text = text.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, "");
// Unify breaks
text = text.replace(new RegExp("<br />", "g"), "<br/>");
// Placeholder for new lines
text = text.replace(new RegExp("<br/>", "g"), newLinePlaceholder);
// Replace block-level tags with newline placeholders
const lineBreakingTags = ["p", "h1", "h2", "h3", "h4", "h5", "h6", "a"];
lineBreakingTags.forEach((tag) => {
const regexOpen = new RegExp(`</${tag}>`, "g");
const regexClose = new RegExp(`</${tag}>`, "g");
text = text
.replace(regexOpen, newLinePlaceholder + newLinePlaceholder)
.replace(regexClose, newLinePlaceholder + newLinePlaceholder);
});
// Remove all remaining HTML tags
text = text.replace(/<[^>]*>/g, "");
// Clean up extra whitespace
text = text.replace(/\s+/g, " ").trim();
// Replace newline placeholders with actual newlines
text = text.replace(new RegExp(`${newLinePlaceholder} `, "g"), "\n");
text = text.replace(new RegExp(newLinePlaceholder, "g"), "\n");
text = text.trim();
return text;
};
const localeMap: { [key: string]: Locale } = {
en: enUS,
de: de,
};
export const renderAppointmentDate = (date: Date, locale: SupportedLocale) => {
return format(date, "PPP", { locale: localeMap[locale] as unknown as Locale });
};
export const renderAppointmentTime = (date: Date, locale: SupportedLocale) => {
return format(date, "p", { locale: localeMap[locale] as unknown as Locale });
};
+36 -7
View File
@@ -10,8 +10,13 @@ import type { SelectTenant, SelectUser } from "$lib/server/db/central-schema";
import { getTenantDb } from "$lib/server/db";
import * as tenantSchema from "$lib/server/db/tenant-schema";
import { eq } from "drizzle-orm";
import { setLocale } from "$i18n/runtime";
import { m } from "$i18n/messages";
import { render } from "svelte/server";
import AppointmentBooked from "$lib/emails/AppointmentBooked.svelte";
import { htmlToText, renderOutputToHtml } from "$lib/emails/utils";
type SelectClient = {
export type SelectClient = {
email: string;
language: string;
};
@@ -195,15 +200,39 @@ export async function sendAppointmentCreatedEmail(
? { email: user.email, language: user.language }
: createEmailRecipient(user);
// Set language
const language = (recipient.language as Language) || "en";
const subject = language === "en" ? "Appointment Confirmed" : "Termin bestätigt";
setLocale(language);
await sendTemplatedEmail("appointment-created", recipient, subject, language, tenant, {
appointment,
appointmentDate: appointment.appointmentDate,
title: channelTitle || appointment.channelId,
cancelUrl,
// Generate email
const subject = m["emails.appointmentBooked.subject"]({
channel: channelTitle || appointment.channelId,
tenant: tenant.longName,
});
const emailRender = render(AppointmentBooked, {
props: {
locale: language,
channel: channelTitle || appointment.channelId,
user,
tenant,
// TODO: Add agentName
appointment: { ...appointment, agentName: "Dr. John Doe" },
// TODO: Add address info
address: {
street: "Musterstraße",
number: "1",
additionalAddressInfo: "Hinterhaus",
zip: "20000",
city: "Hamburg",
},
// TODO: Shouldn't cancelUrl always be defined?
cancelUrl: cancelUrl || "",
},
});
const html = renderOutputToHtml(emailRender);
const text = htmlToText(html);
await sendEmail(recipient, subject, html, text);
}
/**
@@ -1,14 +0,0 @@
import AppointmentBooked from "$lib/emails/AppointmentBooked.svelte";
import { renderOutputToHtml } from "$lib/emails/utils";
import type { RequestHandler } from "@sveltejs/kit";
import { render } from "svelte/server";
export const GET: RequestHandler = async () => {
const emailRender = render(AppointmentBooked, { props: { name: "John Doe", locale: "de" } });
return new Response(renderOutputToHtml(emailRender), {
headers: {
"Content-Type": "text/html",
},
});
};
@@ -0,0 +1,42 @@
import AppointmentBooked from "$lib/emails/AppointmentBooked.svelte";
import { renderOutputToHtml, htmlToText } from "$lib/emails/utils";
import type { SelectTenant } from "$lib/server/db/central-schema";
import type { SelectAppointment } from "$lib/server/db/tenant-schema";
import type { RequestHandler } from "@sveltejs/kit";
import { render } from "svelte/server";
export const GET: RequestHandler = async () => {
const emailRender = render(AppointmentBooked, {
props: {
locale: "en",
user: {
email: "max.mustermann@example.com",
language: "en",
},
tenant: { longName: "Praxis Dr. Jane Doe" } as SelectTenant,
appointment: {
appointmentDate: new Date("2024-06-30T10:00:00"),
agentName: "Dr. John Doe",
} as SelectAppointment & { agentName: string },
channel: "Vaccination Appointment",
address: {
street: "Musterstraße",
number: "1",
additionalAddressInfo: "Hinterhaus",
zip: "20000",
city: "Hamburg",
},
cancelUrl: "https://open-reception.org/appointments/cancel/abc123",
},
});
const html = renderOutputToHtml(emailRender);
const text = htmlToText(html);
// Output for testing purposes
console.log(text);
return new Response(html, {
headers: {
"Content-Type": "text/html",
},
});
};