mirror of
https://github.com/open-reception/appointment-booking-software.git
synced 2026-09-23 01:14:52 +02:00
Completed email renderer poc for appointment confirmation
This commit is contained in:
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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"](),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user