✨ first version
CI / test (push) Successful in 5m3s
CI / build (push) Successful in 2m41s
CI / deploy (push) Successful in 18s

This commit is contained in:
2026-08-28 09:14:04 +02:00
parent 55a51a521d
commit 7c87867b41
70 changed files with 12771 additions and 483 deletions
+275
View File
@@ -0,0 +1,275 @@
import { Resend } from 'resend'
import type { RestaurantTable } from '#/db/schema'
const dateTimeFormatter = new Intl.DateTimeFormat('de-DE', {
dateStyle: 'full',
timeStyle: 'short',
})
const COLORS = {
bg: '#17120f',
bgRaised: '#1e1712',
line: '#3a2f26',
text: '#f3ead9',
textMuted: '#c9baa3',
gold: '#c9a15a',
goldBright: '#e0bb78',
}
function escapeHtml(value: string) {
return value
.replaceAll('&', '&')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;')
}
let resendClient: Resend | undefined
function getResendClient() {
const apiKey = process.env.RESEND_API_KEY
if (!apiKey) {
return null
}
resendClient ??= new Resend(apiKey)
return resendClient
}
type ReservationEmailInfo = {
customerName: string
customerEmail: string
message?: string | null
partySize: number
reservationTime: Date
cancelToken: string
table: Pick<RestaurantTable, 'name'>
}
const CONTACT_EMAIL = 'info@steakhaus-meppen.de'
function getSiteUrl() {
return process.env.BETTER_AUTH_URL ?? 'http://localhost:3000'
}
function buildCancelUrl(cancelToken: string) {
return `${getSiteUrl()}/stornieren/${cancelToken}`
}
function renderReservationEmailHtml(
heading: string,
intro: string,
reservation: ReservationEmailInfo,
showActions: boolean,
) {
const formattedTime = dateTimeFormatter.format(reservation.reservationTime)
const name = escapeHtml(reservation.customerName)
const tableName = escapeHtml(reservation.table.name)
const cancelUrl = buildCancelUrl(reservation.cancelToken)
const row = (label: string, value: string) => `
<tr>
<td style="padding:10px 0;border-top:1px solid ${COLORS.line};color:${COLORS.textMuted};font:400 14px/1.4 'Helvetica Neue',Arial,sans-serif;letter-spacing:0.03em;text-transform:uppercase;white-space:nowrap;">
${label}
</td>
<td style="padding:10px 0;border-top:1px solid ${COLORS.line};color:${COLORS.text};font:600 15px/1.4 Georgia,'Times New Roman',serif;text-align:right;">
${value}
</td>
</tr>`
return `
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>${heading}</title>
</head>
<body style="margin:0;padding:32px 16px;background:${COLORS.bg};font:400 16px/1.5 'Helvetica Neue',Arial,sans-serif;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr>
<td align="center">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="max-width:480px;">
<tr>
<td align="center" style="padding-bottom:28px;">
<span style="font:600 20px/1.3 Georgia,'Times New Roman',serif;letter-spacing:0.03em;color:${COLORS.text};">
Steakhaus <span style="color:${COLORS.goldBright};">bei Mirko</span>
</span>
</td>
</tr>
<tr>
<td style="background:${COLORS.bgRaised};border:1px solid ${COLORS.line};border-radius:10px;padding:32px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr>
<td align="center" style="padding-bottom:20px;">
<span style="display:inline-block;width:36px;height:2px;background:${COLORS.gold};"></span>
</td>
</tr>
<tr>
<td style="font:600 22px/1.3 Georgia,'Times New Roman',serif;color:${COLORS.goldBright};padding-bottom:12px;">
${heading}
</td>
</tr>
<tr>
<td style="color:${COLORS.textMuted};font-size:15px;padding-bottom:8px;">
Hallo ${name},
</td>
</tr>
<tr>
<td style="color:${COLORS.textMuted};font-size:15px;padding-bottom:20px;">
${intro}
</td>
</tr>
<tr>
<td>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
${row('Tisch', tableName)}
${row('Datum &amp; Uhrzeit', formattedTime)}
${row('Personen', String(reservation.partySize))}
</table>
</td>
</tr>
${
reservation.message
? `
<tr>
<td style="padding-top:16px;">
<div style="color:${COLORS.textMuted};font:400 12px/1.4 'Helvetica Neue',Arial,sans-serif;letter-spacing:0.03em;text-transform:uppercase;padding-bottom:4px;">
Nachricht
</div>
<div style="color:${COLORS.text};font-size:14px;">${escapeHtml(reservation.message)}</div>
</td>
</tr>`
: ''
}
${
showActions
? `
<tr>
<td align="center" style="padding-top:28px;">
<a href="${cancelUrl}" style="display:inline-block;padding:0.7rem 1.4rem;border:1px solid ${COLORS.line};border-radius:4px;color:${COLORS.text};font:600 13px/1 'Helvetica Neue',Arial,sans-serif;text-decoration:none;">
Reservierung stornieren
</a>
</td>
</tr>
<tr>
<td align="center" style="padding-top:16px;color:${COLORS.textMuted};font-size:13px;">
Möchten Sie etwas ändern? Schreiben Sie uns einfach eine E-Mail an
<a href="mailto:${CONTACT_EMAIL}" style="color:${COLORS.goldBright};">${CONTACT_EMAIL}</a>.
</td>
</tr>`
: ''
}
</table>
</td>
</tr>
<tr>
<td align="center" style="padding-top:28px;color:${COLORS.textMuted};font-size:13px;">
Wir freuen uns auf Sie!
</td>
</tr>
<tr>
<td align="center" style="padding-top:16px;color:${COLORS.textMuted};font-size:12px;">
Meppener Straße 8 · 49716 Meppen · 05931 - 12750
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`
}
function renderReservationEmailText(
heading: string,
intro: string,
reservation: ReservationEmailInfo,
showActions: boolean,
) {
const formattedTime = dateTimeFormatter.format(reservation.reservationTime)
return [
'Steakhaus bei Mirko',
'',
heading,
'',
`Hallo ${reservation.customerName},`,
'',
intro,
'',
`Tisch: ${reservation.table.name}`,
`Datum & Uhrzeit: ${formattedTime}`,
`Personen: ${reservation.partySize}`,
...(reservation.message ? [`Nachricht: ${reservation.message}`] : []),
'',
...(showActions
? [
`Reservierung stornieren: ${buildCancelUrl(reservation.cancelToken)}`,
`Möchten Sie etwas ändern? Schreiben Sie uns eine E-Mail an ${CONTACT_EMAIL}.`,
'',
]
: []),
'Wir freuen uns auf Sie!',
'Steakhaus bei Mirko',
'Meppener Straße 8 · 49716 Meppen · 05931 - 12750',
].join('\n')
}
async function sendReservationEmail(
subject: string,
intro: string,
reservation: ReservationEmailInfo,
showActions: boolean,
) {
const client = getResendClient()
if (!client) {
console.warn('RESEND_API_KEY is not set — skipping reservation email')
return
}
const fromAddress = process.env.RESERVATIONS_FROM_EMAIL ?? 'Steakhaus bei Mirko <reservierungen@steakhaus-meppen.de>'
try {
await client.emails.send({
from: fromAddress,
to: reservation.customerEmail,
subject,
html: renderReservationEmailHtml(subject, intro, reservation, showActions),
text: renderReservationEmailText(subject, intro, reservation, showActions),
})
} catch (err) {
console.error('Failed to send reservation email', err)
}
}
export function sendReservationConfirmationEmail(reservation: ReservationEmailInfo) {
return sendReservationEmail(
'Ihre Reservierung ist bestätigt',
'vielen Dank für Ihre Reservierung. Hier sind die Details:',
reservation,
true,
)
}
export function sendReservationUpdateEmail(reservation: ReservationEmailInfo) {
return sendReservationEmail(
'Ihre Reservierung wurde geändert',
'Ihre Reservierung wurde aktualisiert. Hier sind die neuen Details:',
reservation,
true,
)
}
export function sendReservationCancellationEmail(reservation: ReservationEmailInfo) {
return sendReservationEmail(
'Ihre Reservierung wurde storniert',
'Ihre folgende Reservierung wurde storniert:',
reservation,
false,
)
}