mirror of
https://github.com/open-reception/appointment-booking-software.git
synced 2026-08-17 21:25:52 +02:00
Feat/email renderer (#172)
* Added poc of new email renderer * Completed email renderer poc for appointment confirmation * Changed phone description in add-personal-data-form * Added used email types to new email system * Switched email templates to the new template engine. --------- Co-authored-by: Karl Ludwig Weise <ludwig@ludwigweise.de> Co-authored-by: Hendrik Belitz <hendrik@innovation-through-understanding.de>
This commit is contained in:
co-authored by
Karl Ludwig Weise
Hendrik Belitz
parent
efdae16249
commit
8c0343b1ec
@@ -1,5 +1,7 @@
|
||||
# Email System Documentation
|
||||
|
||||
> This doc is partially outdated. New templates can be viewed locally. See routes dir. Example: http://localhost:5173/local-only/e-mail-templates/user-invite Text versions are generated automatically and will be printed to console, when local page is opened.
|
||||
|
||||
The Open Reception appointment booking platform includes a comprehensive email system for automated communications with clients and staff members. This document provides technical details and administration guidance for managing the email system.
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -841,7 +841,7 @@
|
||||
"description": "Wir senden Ihnen eine Bestätigungs-E-Mail mit den Termindetails."
|
||||
},
|
||||
"phone": {
|
||||
"description": "Format: +491234567890. Wir kontaktieren Sie, falls es Fragen/Anderungen zu Ihrem Termin gibt.",
|
||||
"description": "Format: +491234567890. {name} kontaktiert Sie ggf. telefonisch, falls es Fragen/Änderungen zu Ihrem Termin gibt.",
|
||||
"optional": "(optional)"
|
||||
},
|
||||
"action": "Daten hinzufügen"
|
||||
@@ -909,5 +909,47 @@
|
||||
"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."
|
||||
},
|
||||
"appointmentReminder": {
|
||||
"subject": "Erinnerung: Ihr Termin bei {tenant}",
|
||||
"introduction": "Ihr Termin wurde bei {tenant} steht in Kürze bevor.",
|
||||
"action": "Termin absagen",
|
||||
"reason": "Sie erhalten diese E-Mail, weil jemand mit Ihrer E-Mail Adresse einen Termin bei gebucht hat."
|
||||
},
|
||||
"appointmentRequest": {
|
||||
"subject": "{channel} bei {tenant} angefragt",
|
||||
"introduction": "Ihr Termin wurde angefragt. Sie werden benachrichtigt, sobald der Termin bestätigt wurde.",
|
||||
"reason": "Sie erhalten diese E-Mail, weil jemand mit Ihrer E-Mail Adresse einen Termin bei angefragt hat."
|
||||
},
|
||||
"confirmation": {
|
||||
"subject": "E-Mail Adresse bestätigen",
|
||||
"introduction": "willkommen bei unserem Terminbuchungsportal. Bitte bestätige Deine E-Mail Adresse.",
|
||||
"action": "E-Mail Adresse bestätigen",
|
||||
"hint": "Dieser Link ist nur {expirationMinutes} Minuten gültig und kann nur einmal verwendet werden.",
|
||||
"reason": "Du erhältst diese E-Mail, weil jemand für Deine E-Mail Adresse ein Konto registriert hat."
|
||||
},
|
||||
"pinReset": {
|
||||
"subject": "PIN erfolgreich geändert",
|
||||
"introduction": "Sie haben erfolgreich Ihre PIN beim Terminbuchungsportal von {tenant} geändert und können sich ab sofort mit Ihrer PIN anmelden.",
|
||||
"action": "Anmelden",
|
||||
"reason": "Sie erhalten diese E-Mail, weil jemand für die PIN für Ihr Konto geändert hat."
|
||||
},
|
||||
"userInvite": {
|
||||
"subject": "E-Mail Adresse bestätigen für {tenant}",
|
||||
"introduction": "willkommen beim Terminbuchungsportal von {tenant}. Bitte bestätige Deine E-Mail Adresse.",
|
||||
"action": "E-Mail Adresse bestätigen",
|
||||
"hint": "Dieser Link ist nur {expirationMinutes} Minuten gültig und kann nur einmal verwendet werden.",
|
||||
"reason": "Du erhältst diese E-Mail, weil jemand für Deine E-Mail Adresse ein Konto registriert hat."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -562,7 +562,7 @@
|
||||
"absences": {
|
||||
"title": "Absences",
|
||||
"add": {
|
||||
"title": "Add Anbsence",
|
||||
"title": "Add Absence",
|
||||
"description": "Absences are a way to mark you agents as being unavailbale.",
|
||||
"fields": {
|
||||
"agent": {
|
||||
@@ -850,7 +850,7 @@
|
||||
"description": "We will use this to send you the appointment details."
|
||||
},
|
||||
"phone": {
|
||||
"description": "Format: +1234567890. We may contact you, if there are questions or changes regarding your appointment.",
|
||||
"description": "Format: +1234567890. {name} may contact you by phone, if there are questions or changes regarding your appointment.",
|
||||
"optional": "(optional)"
|
||||
},
|
||||
"action": "Add personal data"
|
||||
@@ -918,5 +918,47 @@
|
||||
"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."
|
||||
},
|
||||
"appointmentReminder": {
|
||||
"subject": "Reminder: Your appointment with {tenant}",
|
||||
"introduction": "your appointment with {tenant} is soon.",
|
||||
"action": "Cancel appointment",
|
||||
"reason": "You are receiving this email because someone booked an appointment with your e-mail address."
|
||||
},
|
||||
"appointmentRequest": {
|
||||
"subject": "{channel} with {tenant} requested",
|
||||
"introduction": "your appointment was requested. You will be notified once it is confirmed.",
|
||||
"reason": "You are receiving this email because someone requested an appointment with your e-mail address."
|
||||
},
|
||||
"confirmation": {
|
||||
"subject": "Confirm your E-Mail Address",
|
||||
"introduction": "welcome our appointment booking platform. Please confirm your e-mail address.",
|
||||
"action": "Confirm E-Mail Address",
|
||||
"hint": "This link is only valid for {expirationMinutes} minutes and can only be used once.",
|
||||
"reason": "You are receiving this email because someone registered an account with your e-mail address."
|
||||
},
|
||||
"pinReset": {
|
||||
"subject": "PIN successfully changed",
|
||||
"introduction": "you've successfully changed your PIN on the {tenant} appointment booking platform. You can now log-in with your new PIN.",
|
||||
"action": "Login",
|
||||
"reason": "You are receiving this email because someone changed the PIN-Code for your account."
|
||||
},
|
||||
"userInvite": {
|
||||
"subject": "Confirm your E-Mail Address",
|
||||
"introduction": "welcome our appointment booking platform. Please confirm your e-mail address.",
|
||||
"action": "Confirm E-Mail Address",
|
||||
"hint": "This link is only valid for {expirationMinutes} minutes and can only be used once.",
|
||||
"reason": "You are receiving this email because someone registered an account 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"](),
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
<script lang="ts">
|
||||
import { m } from "$i18n/messages";
|
||||
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 {
|
||||
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);
|
||||
</script>
|
||||
|
||||
<EmailLayout>
|
||||
<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">
|
||||
{m["emails.appointmentBooked.reason"]()}
|
||||
</EmailText>
|
||||
</EmailLayout>
|
||||
@@ -0,0 +1,65 @@
|
||||
<script lang="ts">
|
||||
import { m } from "$i18n/messages";
|
||||
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 {
|
||||
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);
|
||||
</script>
|
||||
|
||||
<EmailLayout>
|
||||
<EmailText variant="md">{m["emails.greeting"]({ name: user.email })}</EmailText>
|
||||
<EmailText variant="md">
|
||||
{m["emails.appointmentReminder.introduction"]({ tenant: tenant.longName })}
|
||||
</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.appointmentReminder.action"]()}</EmailButton>
|
||||
<EmailText variant="md" color="text-light">
|
||||
{m["emails.appointmentReminder.reason"]()}
|
||||
</EmailText>
|
||||
</EmailLayout>
|
||||
@@ -0,0 +1,62 @@
|
||||
<script lang="ts">
|
||||
import { m } from "$i18n/messages";
|
||||
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 {
|
||||
locale,
|
||||
user,
|
||||
tenant,
|
||||
channel,
|
||||
appointment,
|
||||
address,
|
||||
}: {
|
||||
locale: SupportedLocale;
|
||||
user: SelectClient;
|
||||
tenant: SelectTenant;
|
||||
channel: string;
|
||||
appointment: SelectAppointment & { agentName: string };
|
||||
address: {
|
||||
street: string;
|
||||
number: string;
|
||||
additionalAddressInfo?: string;
|
||||
zip: string;
|
||||
city: string;
|
||||
};
|
||||
} = $props();
|
||||
|
||||
setLocale(locale);
|
||||
</script>
|
||||
|
||||
<EmailLayout>
|
||||
<EmailText variant="md">{m["emails.greeting"]({ name: user.email })}</EmailText>
|
||||
<EmailText variant="md">
|
||||
{m["emails.appointmentRequest.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>
|
||||
<EmailText variant="md" color="text-light">
|
||||
{m["emails.appointmentRequest.reason"]()}
|
||||
</EmailText>
|
||||
</EmailLayout>
|
||||
@@ -0,0 +1,37 @@
|
||||
<script lang="ts">
|
||||
import { m } from "$i18n/messages";
|
||||
import { setLocale } from "$i18n/runtime";
|
||||
import type { SupportedLocale } from "$lib/const/locales";
|
||||
import type { SelectUserEmail } from "$lib/server/email/email-service";
|
||||
import EmailButton from "./components/EmailButton.svelte";
|
||||
import EmailLayout from "./components/EmailLayout.svelte";
|
||||
import EmailText from "./components/EmailText.svelte";
|
||||
|
||||
let {
|
||||
locale,
|
||||
user,
|
||||
confirmUrl,
|
||||
expirationMinutes,
|
||||
}: {
|
||||
locale: SupportedLocale;
|
||||
user: SelectUserEmail;
|
||||
confirmUrl: string;
|
||||
expirationMinutes: number;
|
||||
} = $props();
|
||||
|
||||
setLocale(locale);
|
||||
</script>
|
||||
|
||||
<EmailLayout>
|
||||
<EmailText variant="md">{m["emails.greeting"]({ name: user.name })}</EmailText>
|
||||
<EmailText variant="md">
|
||||
{m["emails.confirmation.introduction"]()}
|
||||
</EmailText>
|
||||
<EmailButton href={confirmUrl}>{m["emails.confirmation.action"]()}</EmailButton>
|
||||
<EmailText variant="md">
|
||||
{m["emails.confirmation.hint"]({ expirationMinutes })}
|
||||
</EmailText>
|
||||
<EmailText variant="md" color="text-light">
|
||||
{m["emails.confirmation.reason"]()}
|
||||
</EmailText>
|
||||
</EmailLayout>
|
||||
@@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
import { m } from "$i18n/messages";
|
||||
import { setLocale } from "$i18n/runtime";
|
||||
import type { SupportedLocale } from "$lib/const/locales";
|
||||
import { type SelectTenant } from "$lib/server/db/central-schema";
|
||||
import type { SelectClient } from "$lib/server/email/email-service";
|
||||
import EmailButton from "./components/EmailButton.svelte";
|
||||
import EmailLayout from "./components/EmailLayout.svelte";
|
||||
import EmailText from "./components/EmailText.svelte";
|
||||
|
||||
let {
|
||||
locale,
|
||||
user,
|
||||
tenant,
|
||||
loginUrl,
|
||||
}: {
|
||||
locale: SupportedLocale;
|
||||
user: SelectClient;
|
||||
tenant: SelectTenant;
|
||||
loginUrl: string;
|
||||
} = $props();
|
||||
|
||||
setLocale(locale);
|
||||
</script>
|
||||
|
||||
<EmailLayout>
|
||||
<EmailText variant="md">{m["emails.greeting"]({ name: user.email })}</EmailText>
|
||||
<EmailText variant="md">
|
||||
{m["emails.pinReset.introduction"]({ tenant: tenant.longName })}
|
||||
</EmailText>
|
||||
<EmailButton href={loginUrl}>{m["emails.pinReset.action"]()}</EmailButton>
|
||||
<EmailText variant="md" color="text-light">
|
||||
{m["emails.pinReset.reason"]()}
|
||||
</EmailText>
|
||||
</EmailLayout>
|
||||
@@ -0,0 +1,40 @@
|
||||
<script lang="ts">
|
||||
import { m } from "$i18n/messages";
|
||||
import { setLocale } from "$i18n/runtime";
|
||||
import type { SupportedLocale } from "$lib/const/locales";
|
||||
import { type SelectTenant } from "$lib/server/db/central-schema";
|
||||
import type { SelectUserEmail } from "$lib/server/email/email-service";
|
||||
import EmailButton from "./components/EmailButton.svelte";
|
||||
import EmailLayout from "./components/EmailLayout.svelte";
|
||||
import EmailText from "./components/EmailText.svelte";
|
||||
|
||||
let {
|
||||
locale,
|
||||
user,
|
||||
tenant,
|
||||
confirmUrl,
|
||||
expirationMinutes,
|
||||
}: {
|
||||
locale: SupportedLocale;
|
||||
user: SelectUserEmail;
|
||||
tenant: SelectTenant;
|
||||
confirmUrl: string;
|
||||
expirationMinutes: number;
|
||||
} = $props();
|
||||
|
||||
setLocale(locale);
|
||||
</script>
|
||||
|
||||
<EmailLayout>
|
||||
<EmailText variant="md">{m["emails.greeting"]({ name: user.name })}</EmailText>
|
||||
<EmailText variant="md">
|
||||
{m["emails.userInvite.introduction"]({ tenant: tenant.longName })}
|
||||
</EmailText>
|
||||
<EmailButton href={confirmUrl}>{m["emails.userInvite.action"]()}</EmailButton>
|
||||
<EmailText variant="md">
|
||||
{m["emails.userInvite.hint"]({ expirationMinutes })}
|
||||
</EmailText>
|
||||
<EmailText variant="md" color="text-light">
|
||||
{m["emails.userInvite.reason"]()}
|
||||
</EmailText>
|
||||
</EmailLayout>
|
||||
@@ -0,0 +1,30 @@
|
||||
<script lang="js">
|
||||
let { children, href } = $props();
|
||||
</script>
|
||||
|
||||
<div class="button-container">
|
||||
<a class="button" {href}>{@render children?.()}</a>
|
||||
</div>
|
||||
|
||||
<svelte:head>
|
||||
<style>
|
||||
.button-container {
|
||||
margin: 24px 0;
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
.button {
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
border: 1px solid #000000;
|
||||
padding: 12px 32px;
|
||||
padding: 0.75rem 2rem;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.button:hover {
|
||||
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>
|
||||
@@ -0,0 +1,71 @@
|
||||
<script lang="js">
|
||||
import { m } from "$i18n/messages";
|
||||
import EmailText from "./EmailText.svelte";
|
||||
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
<div class="container">
|
||||
<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>
|
||||
|
||||
<svelte:head>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100%;
|
||||
min-height: 100vh;
|
||||
box-sizing: border-box;
|
||||
font-family: "Inter", Arial, sans-serif;
|
||||
}
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: underline;
|
||||
}
|
||||
h1 {
|
||||
font-size: 18px;
|
||||
line-height: 1.5;
|
||||
font-weight: 600;
|
||||
}
|
||||
h2 {
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
font-weight: 600;
|
||||
margin-top: 24px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
h3 {
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
font-weight: 500;
|
||||
}
|
||||
h2 + p {
|
||||
margin-top: 0;
|
||||
}
|
||||
.page {
|
||||
padding: 0 16px;
|
||||
padding: 0 1rem;
|
||||
min-height: 100%;
|
||||
min-height: 100vh;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
font-family: "Inter", Arial, sans-serif;
|
||||
}
|
||||
.container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.branding {
|
||||
margin: 16px 0;
|
||||
margin: 1rem 0;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
</svelte:head>
|
||||
@@ -0,0 +1,32 @@
|
||||
<script lang="js">
|
||||
let { children, variant, color = "text-darkest", class: className = "" } = $props();
|
||||
</script>
|
||||
|
||||
<p class={[variant, color, className].filter((it) => it).join(" ")}>{@render children?.()}</p>
|
||||
|
||||
<svelte:head>
|
||||
<style>
|
||||
.lg {
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.md {
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.sm {
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.xs {
|
||||
font-size: 10px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.text-darkest {
|
||||
color: #000000;
|
||||
}
|
||||
.text-light {
|
||||
color: #888888;
|
||||
}
|
||||
</style>
|
||||
</svelte:head>
|
||||
@@ -0,0 +1,79 @@
|
||||
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;
|
||||
body: string;
|
||||
};
|
||||
|
||||
export const renderOutputToHtml = (renderOutput: RenderOutput) => {
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
${renderOutput.head}
|
||||
</head>
|
||||
<body>
|
||||
${renderOutput.body}
|
||||
</body>
|
||||
</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 });
|
||||
};
|
||||
@@ -460,24 +460,7 @@ describe("Email System", () => {
|
||||
|
||||
await sendConfirmationEmail(staffUser, mockTenant, confirmationCode, expirationMinutes);
|
||||
|
||||
expect(mockReadFile).toHaveBeenCalledWith(
|
||||
expect.stringContaining("confirmation.de.html"),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
expect(mockSendMail).toHaveBeenCalledWith({
|
||||
from: {
|
||||
name: "Test App",
|
||||
address: "noreply@test.com",
|
||||
},
|
||||
to: {
|
||||
name: "Max Mustermann",
|
||||
address: "test@example.com",
|
||||
},
|
||||
subject: "Registrierung bestätigen",
|
||||
html: "<h1>Bestätigungscode: ABC123</h1><p>Gültig für 15 Minuten</p>",
|
||||
text: "Bestätigungscode: ABC123 - Gültig für 15 Minuten",
|
||||
});
|
||||
expect(mockSendMail).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should send confirmation email in English", async () => {
|
||||
@@ -515,24 +498,7 @@ describe("Email System", () => {
|
||||
|
||||
await sendConfirmationEmail(staffUser, mockTenant, confirmationCode, expirationMinutes);
|
||||
|
||||
expect(mockReadFile).toHaveBeenCalledWith(
|
||||
expect.stringContaining("confirmation.en.html"),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
expect(mockSendMail).toHaveBeenCalledWith({
|
||||
from: {
|
||||
name: "Test App",
|
||||
address: "noreply@test.com",
|
||||
},
|
||||
to: {
|
||||
name: "John Doe",
|
||||
address: "test@example.com",
|
||||
},
|
||||
subject: "Confirm Your Registration",
|
||||
html: "<h1>Confirmation code: XYZ789</h1><p>Valid for 10 minutes</p>",
|
||||
text: "Confirmation code: XYZ789 - Valid for 10 minutes",
|
||||
});
|
||||
expect(mockSendMail).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle confirmation template rendering", async () => {
|
||||
@@ -566,8 +532,8 @@ describe("Email System", () => {
|
||||
expirationMinutes: 15,
|
||||
});
|
||||
|
||||
expect(result.html).toBe("Code: TEST123");
|
||||
expect(result.text).toBe("Code: TEST123");
|
||||
expect(result.html).toBe("<h1>Bestätigungscode: TEST123</h1><p>Gültig für 15 Minuten</p>");
|
||||
expect(result.text).toBe("Bestätigungscode: TEST123 - Gültig für 15 Minuten");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,12 +10,24 @@ 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";
|
||||
import { AgentService } from "../services/agent-service";
|
||||
import { TenantService } from "../db/tenant-service";
|
||||
import Confirmation from "$lib/emails/Confirmation.svelte";
|
||||
import PinReset from "$lib/emails/PinReset.svelte";
|
||||
import UserInvite from "$lib/emails/UserInvite.svelte";
|
||||
|
||||
type SelectClient = {
|
||||
export type SelectClient = {
|
||||
email: string;
|
||||
language: string;
|
||||
};
|
||||
|
||||
export type SelectUserEmail = Pick<SelectUser, "email" | "name" | "language">;
|
||||
|
||||
/**
|
||||
* Get channel title in the user's preferred language
|
||||
* @param {string} tenantId - Tenant ID
|
||||
@@ -118,12 +130,26 @@ export async function sendUserCreatedEmail(
|
||||
export async function sendPinResetEmail(
|
||||
user: SelectClient | SelectUser,
|
||||
tenant: SelectTenant,
|
||||
requestUrl: URL,
|
||||
): Promise<void> {
|
||||
const recipient = createEmailRecipient(user);
|
||||
const language = (recipient.language as Language) || "en";
|
||||
const subject = language === "en" ? "PIN Reset Information" : "PIN zurückgesetzt";
|
||||
const locale = (recipient.language as Language) || "en";
|
||||
|
||||
await sendTemplatedEmail("pin-reset", recipient, subject, language, tenant, {});
|
||||
const subject = m["emails.pinReset.subject"]({
|
||||
tenant: tenant.longName,
|
||||
});
|
||||
const emailRender = render(PinReset, {
|
||||
props: {
|
||||
locale,
|
||||
user,
|
||||
tenant,
|
||||
loginUrl: generateBaseUrl(requestUrl, tenant) ?? "http://localhost:5173",
|
||||
},
|
||||
});
|
||||
const html = renderOutputToHtml(emailRender);
|
||||
const text = htmlToText(html);
|
||||
|
||||
await sendEmail(recipient, subject, html, text);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -159,19 +185,53 @@ export async function sendAppointmentReminderEmail(
|
||||
appointment: SelectAppointment,
|
||||
cancelUrl?: string,
|
||||
): Promise<void> {
|
||||
const recipient = createEmailRecipient(user);
|
||||
const language = (recipient.language as Language) || "en";
|
||||
const subject = language === "en" ? "Appointment Reminder" : "Terminerinnerung";
|
||||
|
||||
await sendTemplatedEmail("appointment-reminder", recipient, subject, language, tenant, {
|
||||
appointment,
|
||||
appointmentDate: appointment.appointmentDate,
|
||||
appointmentTime: appointment.appointmentDate, // You might want to add a separate time field
|
||||
title: appointment.channelId,
|
||||
cancelUrl,
|
||||
const agentService = await AgentService.forTenant(tenant.id);
|
||||
const agent = await agentService.getAgentById(appointment.agentId);
|
||||
const { recipient, locale } = await getRecipient(user);
|
||||
const channelTitle = await getChannelTitle(tenant.id, appointment.channelId, locale);
|
||||
// Generate email
|
||||
const subject = m["emails.appointmentReminder.subject"]({
|
||||
tenant: tenant.longName,
|
||||
});
|
||||
const emailRender = render(AppointmentBooked, {
|
||||
props: {
|
||||
locale,
|
||||
channel: channelTitle || appointment.channelId,
|
||||
user,
|
||||
tenant,
|
||||
appointment: { ...appointment, agentName: agent?.name ?? "---" },
|
||||
address: await getAddressFromTenant(tenant.id),
|
||||
cancelUrl: cancelUrl || "",
|
||||
},
|
||||
});
|
||||
const html = renderOutputToHtml(emailRender);
|
||||
const text = htmlToText(html);
|
||||
|
||||
await sendEmail(recipient, subject, html, text);
|
||||
}
|
||||
|
||||
const getRecipient = async (user: SelectClient | SelectUser) => {
|
||||
const recipient: EmailRecipient =
|
||||
"email" in user && typeof user.email === "string" && "language" in user && !("name" in user)
|
||||
? { email: user.email, language: user.language }
|
||||
: createEmailRecipient(user);
|
||||
const locale = (recipient.language as Language) || "en";
|
||||
setLocale(locale);
|
||||
return { recipient, locale };
|
||||
};
|
||||
|
||||
const getAddressFromTenant = async (tenantId: string) => {
|
||||
const tenantService = await new TenantService(tenantId);
|
||||
const tenant = await tenantService.getConfig();
|
||||
return {
|
||||
street: (tenant["address.street"] || "") as string,
|
||||
number: (tenant["address.number"] || "") as string,
|
||||
additionalAddressInfo: (tenant["address.additionalAddressInfo"] || "") as string,
|
||||
zip: (tenant["address.zip"] || "") as string,
|
||||
city: (tenant["address.city"] || "") as string,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Send appointment confirmation email for newly created appointments
|
||||
* @param {SelectClient | SelectUser} user - Database user object or client data
|
||||
@@ -190,20 +250,32 @@ export async function sendAppointmentCreatedEmail(
|
||||
cancelUrl?: string,
|
||||
): Promise<void> {
|
||||
// Create recipient directly for SelectClient type, use helper for SelectUser
|
||||
const recipient: EmailRecipient =
|
||||
"email" in user && typeof user.email === "string" && "language" in user && !("name" in user)
|
||||
? { email: user.email, language: user.language }
|
||||
: createEmailRecipient(user);
|
||||
|
||||
const language = (recipient.language as Language) || "en";
|
||||
const subject = language === "en" ? "Appointment Confirmed" : "Termin bestätigt";
|
||||
// Set language
|
||||
const agentService = await AgentService.forTenant(tenant.id);
|
||||
const agent = await agentService.getAgentById(appointment.agentId);
|
||||
const { recipient, locale } = await getRecipient(user);
|
||||
|
||||
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,
|
||||
channel: channelTitle || appointment.channelId,
|
||||
user,
|
||||
tenant,
|
||||
appointment: { ...appointment, agentName: agent?.name ?? "---" },
|
||||
address: await getAddressFromTenant(tenant.id),
|
||||
cancelUrl: cancelUrl || "",
|
||||
},
|
||||
});
|
||||
const html = renderOutputToHtml(emailRender);
|
||||
const text = htmlToText(html);
|
||||
|
||||
await sendEmail(recipient, subject, html, text);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -223,21 +295,29 @@ export async function sendAppointmentRequestEmail(
|
||||
channelTitle?: string,
|
||||
cancelUrl?: string,
|
||||
): Promise<void> {
|
||||
// Create recipient directly for SelectClient type, use helper for SelectUser
|
||||
const recipient: EmailRecipient =
|
||||
"email" in user && typeof user.email === "string" && "language" in user && !("name" in user)
|
||||
? { email: user.email, language: user.language }
|
||||
: createEmailRecipient(user);
|
||||
|
||||
const language = (recipient.language as Language) || "en";
|
||||
const subject = language === "en" ? "Appointment Request Received" : "Terminanfrage erhalten";
|
||||
|
||||
await sendTemplatedEmail("appointment-request", recipient, subject, language, tenant, {
|
||||
appointment,
|
||||
appointmentDate: appointment.appointmentDate,
|
||||
title: channelTitle || appointment.channelId,
|
||||
cancelUrl,
|
||||
const agentService = await AgentService.forTenant(tenant.id);
|
||||
const agent = await agentService.getAgentById(appointment.agentId);
|
||||
const { recipient, locale } = await getRecipient(user);
|
||||
// Generate email
|
||||
const subject = m["emails.appointmentRequest.subject"]({
|
||||
channel: channelTitle || appointment.channelId,
|
||||
tenant: tenant.longName,
|
||||
});
|
||||
const emailRender = render(AppointmentBooked, {
|
||||
props: {
|
||||
locale,
|
||||
channel: channelTitle || appointment.channelId,
|
||||
user,
|
||||
tenant,
|
||||
appointment: { ...appointment, agentName: agent?.name ?? "---" },
|
||||
address: await getAddressFromTenant(tenant.id),
|
||||
cancelUrl: cancelUrl || "",
|
||||
},
|
||||
});
|
||||
const html = renderOutputToHtml(emailRender);
|
||||
const text = htmlToText(html);
|
||||
|
||||
await sendEmail(recipient, subject, html, text);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -314,29 +394,33 @@ export function generateBaseUrl(requestUrl: URL, tenant: SelectTenant | null): s
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function sendConfirmationEmail(
|
||||
user: { id: string; email: string | null; name: string | null; language?: string | null },
|
||||
user: { id: string; email: string; name: string; language?: string | null },
|
||||
tenant: SelectTenant,
|
||||
confirmationCode: string,
|
||||
expirationMinutes: number = 15,
|
||||
requestUrl?: URL,
|
||||
): Promise<void> {
|
||||
const recipient = createEmailRecipient(user);
|
||||
const language = (recipient.language as Language) || "en";
|
||||
const subject = language === "en" ? "Confirm Your Registration" : "Registrierung bestätigen";
|
||||
|
||||
// Generate appropriate base URL if request URL is provided
|
||||
const baseUrl = requestUrl ? generateBaseUrl(requestUrl, tenant) : "http://localhost:5173";
|
||||
|
||||
// Create enhanced tenant object with baseUrl
|
||||
const tenantWithBaseUrl = {
|
||||
...tenant,
|
||||
baseUrl,
|
||||
};
|
||||
|
||||
await sendTemplatedEmail("confirmation", recipient, subject, language, tenantWithBaseUrl, {
|
||||
confirmationCode,
|
||||
expirationMinutes,
|
||||
const confirmUrl = `${baseUrl}/confirm/${confirmationCode}`;
|
||||
const recipient = user;
|
||||
// Generate email
|
||||
const subject = m["emails.confirmation.subject"]({
|
||||
tenant: tenant.longName,
|
||||
});
|
||||
const emailRender = render(Confirmation, {
|
||||
props: {
|
||||
locale: (user.language as Language) ?? "en",
|
||||
user: user as SelectUserEmail,
|
||||
confirmUrl,
|
||||
expirationMinutes,
|
||||
},
|
||||
});
|
||||
const html = renderOutputToHtml(emailRender);
|
||||
const text = htmlToText(html);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await sendEmail(recipient as any, subject, html, text);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -364,12 +448,22 @@ export async function sendUserInviteEmail(
|
||||
language,
|
||||
};
|
||||
|
||||
const subject =
|
||||
language === "en"
|
||||
? `Invitation to ${tenant.longName || tenant.shortName}`
|
||||
: `Einladung zu ${tenant.longName || tenant.shortName}`;
|
||||
|
||||
await sendTemplatedEmail("user-invite", recipient, subject, language, tenant, {
|
||||
registrationUrl,
|
||||
// Generate email
|
||||
const subject = m["emails.userInvite.subject"]({
|
||||
tenant: tenant.longName,
|
||||
});
|
||||
const emailRender = render(UserInvite, {
|
||||
props: {
|
||||
locale: language ?? "en",
|
||||
user: recipient as SelectUserEmail,
|
||||
tenant,
|
||||
confirmUrl: registrationUrl,
|
||||
expirationMinutes: 30,
|
||||
},
|
||||
});
|
||||
const html = renderOutputToHtml(emailRender);
|
||||
const text = htmlToText(html);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await sendEmail(recipient as any, subject, html, text);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { dev } from "$app/environment";
|
||||
import type { ParamMatcher } from "@sveltejs/kit";
|
||||
|
||||
export const match: ParamMatcher = (param) => {
|
||||
// Doesn't have to be include, but needs to return a boolean
|
||||
return dev && param.includes("local-only");
|
||||
};
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import AppointmentReminder from "$lib/emails/AppointmentReminder.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(AppointmentReminder, {
|
||||
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",
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
import AppointmentRequested from "$lib/emails/AppointmentRequest.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(AppointmentRequested, {
|
||||
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",
|
||||
},
|
||||
},
|
||||
});
|
||||
const html = renderOutputToHtml(emailRender);
|
||||
const text = htmlToText(html);
|
||||
|
||||
// Output for testing purposes
|
||||
console.log(text);
|
||||
return new Response(html, {
|
||||
headers: {
|
||||
"Content-Type": "text/html",
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
import Confirmation from "$lib/emails/Confirmation.svelte";
|
||||
import { htmlToText, renderOutputToHtml } from "$lib/emails/utils";
|
||||
import type { RequestHandler } from "@sveltejs/kit";
|
||||
import { render } from "svelte/server";
|
||||
|
||||
export const GET: RequestHandler = async () => {
|
||||
const emailRender = render(Confirmation, {
|
||||
props: {
|
||||
locale: "en",
|
||||
user: {
|
||||
email: "max.mustermann@example.com",
|
||||
name: "Max Mustermann",
|
||||
language: "en",
|
||||
},
|
||||
confirmUrl: "https://open-reception.org/confirm/abc123",
|
||||
expirationMinutes: 10,
|
||||
},
|
||||
});
|
||||
const html = renderOutputToHtml(emailRender);
|
||||
const text = htmlToText(html);
|
||||
|
||||
// Output for testing purposes
|
||||
console.log(text);
|
||||
return new Response(html, {
|
||||
headers: {
|
||||
"Content-Type": "text/html",
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
import PinReset from "$lib/emails/PinReset.svelte";
|
||||
import { renderOutputToHtml, htmlToText } from "$lib/emails/utils";
|
||||
import type { SelectTenant } from "$lib/server/db/central-schema";
|
||||
import type { RequestHandler } from "@sveltejs/kit";
|
||||
import { render } from "svelte/server";
|
||||
|
||||
export const GET: RequestHandler = async () => {
|
||||
const emailRender = render(PinReset, {
|
||||
props: {
|
||||
locale: "en",
|
||||
user: {
|
||||
email: "max.mustermann@example.com",
|
||||
language: "en",
|
||||
},
|
||||
tenant: { longName: "Praxis Dr. Jane Doe" } as SelectTenant,
|
||||
loginUrl: "https://open-reception.org/login",
|
||||
},
|
||||
});
|
||||
const html = renderOutputToHtml(emailRender);
|
||||
const text = htmlToText(html);
|
||||
|
||||
// Output for testing purposes
|
||||
console.log(text);
|
||||
return new Response(html, {
|
||||
headers: {
|
||||
"Content-Type": "text/html",
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
import UserInvite from "$lib/emails/UserInvite.svelte";
|
||||
import { htmlToText, renderOutputToHtml } from "$lib/emails/utils";
|
||||
import type { SelectTenant } from "$lib/server/db/central-schema";
|
||||
import type { RequestHandler } from "@sveltejs/kit";
|
||||
import { render } from "svelte/server";
|
||||
|
||||
export const GET: RequestHandler = async () => {
|
||||
const emailRender = render(UserInvite, {
|
||||
props: {
|
||||
locale: "en",
|
||||
user: {
|
||||
email: "max.mustermann@example.com",
|
||||
name: "Max Mustermann",
|
||||
language: "en",
|
||||
},
|
||||
tenant: { longName: "Praxis Dr. Jane Doe" } as SelectTenant,
|
||||
confirmUrl: "https://open-reception.org/confirm/abc123",
|
||||
expirationMinutes: 10,
|
||||
},
|
||||
});
|
||||
const html = renderOutputToHtml(emailRender);
|
||||
const text = htmlToText(html);
|
||||
|
||||
// Output for testing purposes
|
||||
console.log(text);
|
||||
return new Response(html, {
|
||||
headers: {
|
||||
"Content-Type": "text/html",
|
||||
},
|
||||
});
|
||||
};
|
||||
+3
-3
@@ -52,6 +52,7 @@
|
||||
const { form: formData, enhance, validateForm } = form;
|
||||
</script>
|
||||
|
||||
{#if tenant}
|
||||
<div class="flex flex-col gap-4 pb-1">
|
||||
<Text style="sm" class="font-medium">
|
||||
{m["public.steps.data.title"]()}
|
||||
@@ -59,11 +60,9 @@
|
||||
<Alert.Root>
|
||||
<Lock />
|
||||
<Alert.Title>{m["public.steps.data.alert.title"]()}</Alert.Title>
|
||||
{#if tenant}
|
||||
<Alert.Description
|
||||
>{m["public.steps.data.alert.description"]({ name: tenant.longName })}</Alert.Description
|
||||
>
|
||||
{/if}
|
||||
</Alert.Root>
|
||||
<Form.Root {enhance} class="flex flex-col gap-4">
|
||||
<Form.Field {form} name="name">
|
||||
@@ -96,7 +95,7 @@
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
<Form.Description class="mt-1">
|
||||
{m["public.steps.data.phone.description"]()}
|
||||
{m["public.steps.data.phone.description"]({ name: tenant.longName })}
|
||||
{#if tenant?.requirePhone === false}
|
||||
{m["public.steps.data.phone.optional"]()}
|
||||
{/if}
|
||||
@@ -109,3 +108,4 @@
|
||||
</div>
|
||||
</Form.Root>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -11,6 +11,12 @@ import type { Handle } from "@sveltejs/kit";
|
||||
* @returns {Promise<Response>} The response with applied headers and rate limiting
|
||||
*/
|
||||
export const i18nHandle: Handle = ({ event, resolve }) => {
|
||||
// Disable i18n for API and local-only routes
|
||||
// Avoids using i18n middleware where emails are rendered
|
||||
if (event.url.pathname.startsWith("/api") || event.url.pathname.startsWith("/local-only")) {
|
||||
return resolve(event);
|
||||
}
|
||||
|
||||
// Determine browser locale and use it
|
||||
const cookieLocale = event.cookies.get(cookieName);
|
||||
let locale: Locale | undefined = cookieLocale as Locale;
|
||||
|
||||
Reference in New Issue
Block a user