Nachzug zu d3020cd: email-templates.ts nutzte fuer den Bestaetigungs-Link
bereits getBaseUrl(), fuer den Produkt- und den Shop-Link in derselben
Datei aber weiterhin "https://" + siteConfig.domain. Eine
Wiederverfuegbarkeits-Mail konnte dadurch einen Link auf eine andere Domain
enthalten als die Bestellbestaetigung daneben.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
377 lines
14 KiB
TypeScript
377 lines
14 KiB
TypeScript
import { formatPrice } from "@/lib/format";
|
||
import { getSiteUrl } from "@/lib/url";
|
||
import { getCompanyInfo } from "@/lib/company-info";
|
||
import { getEmailStrings } from "@/lib/email-strings";
|
||
import type { Locale } from "@/i18n/config";
|
||
|
||
interface EmailContent {
|
||
subject: string;
|
||
html: string;
|
||
text: string;
|
||
}
|
||
|
||
/**
|
||
* Gemeinsamer Rahmen fuer alle Transaktions-E-Mails: bewusst mit
|
||
* Inline-Styles und Tabellen-Layout statt Tailwind/externem CSS, da viele
|
||
* E-Mail-Clients (Outlook, ältere Gmail-Ansichten) beides nicht zuverlaessig
|
||
* unterstuetzen.
|
||
*/
|
||
async function emailLayout(locale: Locale, preheader: string, bodyHtml: string): Promise<string> {
|
||
const company = await getCompanyInfo();
|
||
return `<!doctype html>
|
||
<html lang="${locale}">
|
||
<body style="margin:0;padding:0;background-color:#FAF8F3;font-family:Arial,Helvetica,sans-serif;color:#242424;">
|
||
<span style="display:none;font-size:1px;color:#FAF8F3;line-height:1px;">${preheader}</span>
|
||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color:#FAF8F3;padding:32px 16px;">
|
||
<tr>
|
||
<td align="center">
|
||
<table role="presentation" width="100%" style="max-width:560px;background-color:#ffffff;">
|
||
<tr>
|
||
<td style="background-color:#24352B;padding:24px 32px;">
|
||
<span style="font-size:20px;letter-spacing:2px;color:#F4EFE5;">WABENHAIN</span>
|
||
<div style="font-size:10px;letter-spacing:1px;color:#B88A44;margin-top:4px;">
|
||
HONIG AUS DEN WESTLICHEN WÄLDERN
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
<tr>
|
||
<td style="padding:32px;">${bodyHtml}</td>
|
||
</tr>
|
||
<tr>
|
||
<td style="padding:20px 32px;background-color:#F4EFE5;font-size:12px;color:#666;">
|
||
${company.name} · ${company.address.street}, ${company.address.zip} ${company.address.city}
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
</body>
|
||
</html>`;
|
||
}
|
||
|
||
function button(url: string, label: string): string {
|
||
return `<a href="${url}" style="display:inline-block;margin-top:16px;padding:12px 24px;background-color:#24352B;color:#F4EFE5;text-decoration:none;font-size:14px;letter-spacing:1px;">${label}</a>`;
|
||
}
|
||
|
||
interface OrderConfirmationInput {
|
||
locale: Locale;
|
||
orderNumber: string;
|
||
firstName: string;
|
||
fulfillmentMethod: "VERSAND" | "ABHOLUNG";
|
||
items: { productName: string; quantity: number; weightGrams: number; totalCents: number }[];
|
||
subtotalCents: number;
|
||
shippingCents: number;
|
||
discountCents?: number;
|
||
totalCents: number;
|
||
street: string;
|
||
zip: string;
|
||
city: string;
|
||
giftMessage?: string | null;
|
||
trackingUrl?: string;
|
||
}
|
||
|
||
export async function orderConfirmationEmail(order: OrderConfirmationInput): Promise<EmailContent> {
|
||
const s = getEmailStrings(order.locale).orderConfirmation;
|
||
const rows = order.items
|
||
.map(
|
||
(item) =>
|
||
`<tr><td style="padding:6px 0;">${item.quantity} × ${item.productName} (${item.weightGrams} g)</td><td style="padding:6px 0;text-align:right;">${formatPrice(item.totalCents, "EUR", order.locale)}</td></tr>`
|
||
)
|
||
.join("");
|
||
|
||
const deliveryHtml =
|
||
order.fulfillmentMethod === "VERSAND"
|
||
? `<p style="margin:4px 0;">${s.deliveryTo}<br>${order.street}<br>${order.zip} ${order.city}</p>`
|
||
: `<p style="margin:4px 0;">${s.pickupNote}</p>`;
|
||
|
||
const giftHtml = order.giftMessage
|
||
? `<p style="margin:16px 0 0;padding:12px 16px;background-color:#F4EFE5;font-size:13px;color:#24352B;">
|
||
${s.giftNote}
|
||
</p>`
|
||
: "";
|
||
|
||
const html = await emailLayout(
|
||
order.locale,
|
||
s.preheader(order.orderNumber),
|
||
`
|
||
<h1 style="font-size:20px;color:#24352B;margin:0 0 12px;">${s.heading(order.firstName)}</h1>
|
||
<p style="margin:0 0 20px;line-height:1.5;">${s.intro(order.orderNumber)}</p>
|
||
<table role="presentation" width="100%" style="font-size:14px;border-top:1px solid #eee;border-bottom:1px solid #eee;padding:8px 0;">
|
||
${rows}
|
||
</table>
|
||
<table role="presentation" width="100%" style="font-size:14px;margin-top:12px;">
|
||
<tr><td>${s.subtotal}</td><td style="text-align:right;">${formatPrice(order.subtotalCents, "EUR", order.locale)}</td></tr>
|
||
<tr><td>${s.shipping}</td><td style="text-align:right;">${order.shippingCents === 0 ? s.freeShipping : formatPrice(order.shippingCents, "EUR", order.locale)}</td></tr>
|
||
${order.discountCents ? `<tr><td>${s.discount}</td><td style="text-align:right;">−${formatPrice(order.discountCents, "EUR", order.locale)}</td></tr>` : ""}
|
||
<tr><td style="font-weight:bold;color:#24352B;padding-top:8px;">${s.total}</td><td style="text-align:right;font-weight:bold;color:#24352B;padding-top:8px;">${formatPrice(order.totalCents, "EUR", order.locale)}</td></tr>
|
||
</table>
|
||
${deliveryHtml}
|
||
${giftHtml}
|
||
${order.trackingUrl ? `<div style="margin-top:20px;">${button(order.trackingUrl, s.trackOrder)}</div>` : ""}
|
||
`
|
||
);
|
||
|
||
const text = `${s.heading(order.firstName)}\n\n${order.orderNumber}\n\n${order.items
|
||
.map((i) => `${i.quantity} x ${i.productName} (${i.weightGrams} g) — ${formatPrice(i.totalCents, "EUR", order.locale)}`)
|
||
.join("\n")}\n\n${s.subtotal}: ${formatPrice(order.subtotalCents, "EUR", order.locale)}\n${s.shipping}: ${
|
||
order.shippingCents === 0 ? s.freeShipping : formatPrice(order.shippingCents, "EUR", order.locale)
|
||
}${order.discountCents ? `\n${s.discount}: −${formatPrice(order.discountCents, "EUR", order.locale)}` : ""}\n${s.total}: ${formatPrice(order.totalCents, "EUR", order.locale)}${
|
||
order.giftMessage ? `\n\n${s.giftNote}` : ""
|
||
}${order.trackingUrl ? `\n\n${s.trackOrder}: ${order.trackingUrl}` : ""}`;
|
||
|
||
return { subject: s.subject(order.orderNumber), html, text };
|
||
}
|
||
|
||
export async function newsletterConfirmEmail(locale: Locale, confirmUrl: string): Promise<EmailContent> {
|
||
const s = getEmailStrings(locale).newsletterConfirm;
|
||
const html = await emailLayout(
|
||
locale,
|
||
s.preheader,
|
||
`
|
||
<h1 style="font-size:20px;color:#24352B;margin:0 0 12px;">${s.heading}</h1>
|
||
<p style="margin:0 0 8px;line-height:1.5;">${s.intro}</p>
|
||
${button(confirmUrl, s.button)}
|
||
<p style="margin:20px 0 0;font-size:12px;color:#888;">${s.ignoreNote}</p>
|
||
`
|
||
);
|
||
return {
|
||
subject: s.subject,
|
||
html,
|
||
text: `${s.intro}: ${confirmUrl}`,
|
||
};
|
||
}
|
||
|
||
export async function passwordResetEmail(locale: Locale, resetUrl: string): Promise<EmailContent> {
|
||
const s = getEmailStrings(locale).passwordReset;
|
||
const html = await emailLayout(
|
||
locale,
|
||
s.preheader,
|
||
`
|
||
<h1 style="font-size:20px;color:#24352B;margin:0 0 12px;">${s.heading}</h1>
|
||
<p style="margin:0 0 8px;line-height:1.5;">${s.intro}</p>
|
||
${button(resetUrl, s.button)}
|
||
<p style="margin:20px 0 0;font-size:12px;color:#888;">${s.ignoreNote}</p>
|
||
`
|
||
);
|
||
return {
|
||
subject: s.subject,
|
||
html,
|
||
text: `${s.intro} ${resetUrl}`,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Sicherheitshinweis-Mail fuer sensible Kontoaenderungen (Passwort geaendert,
|
||
* 2FA deaktiviert, Konto geloescht) — unabhaengig davon, ob die Aenderung
|
||
* durch die Kundschaft selbst oder potenziell durch jemand anderen ausgeloest
|
||
* wurde, damit ein Missbrauch schnellstmoeglich auffaellt.
|
||
*/
|
||
export type SecurityAlertEvent = "passwordChanged" | "twoFactorDisabled" | "accountDeleted";
|
||
|
||
export async function securityAlertEmail(locale: Locale, event: SecurityAlertEvent): Promise<EmailContent> {
|
||
const s = getEmailStrings(locale).securityAlert;
|
||
const eventDescription = s[event];
|
||
const html = await emailLayout(
|
||
locale,
|
||
s.preheader,
|
||
`
|
||
<h1 style="font-size:20px;color:#24352B;margin:0 0 12px;">${s.heading}</h1>
|
||
<p style="margin:0 0 8px;line-height:1.5;">${eventDescription}</p>
|
||
<p style="margin:20px 0 0;font-size:12px;color:#888;">${s.contactNote}</p>
|
||
`
|
||
);
|
||
return {
|
||
subject: s.subject,
|
||
html,
|
||
text: `${eventDescription}\n\n${s.contactNote}`,
|
||
};
|
||
}
|
||
|
||
export async function emailVerificationEmail(locale: Locale, verifyUrl: string): Promise<EmailContent> {
|
||
const s = getEmailStrings(locale).emailVerification;
|
||
const html = await emailLayout(
|
||
locale,
|
||
s.preheader,
|
||
`
|
||
<h1 style="font-size:20px;color:#24352B;margin:0 0 12px;">${s.heading}</h1>
|
||
<p style="margin:0 0 8px;line-height:1.5;">${s.intro}</p>
|
||
${button(verifyUrl, s.button)}
|
||
<p style="margin:20px 0 0;font-size:12px;color:#888;">${s.ignoreNote}</p>
|
||
`
|
||
);
|
||
return {
|
||
subject: s.subject,
|
||
html,
|
||
text: `${s.intro} ${verifyUrl}`,
|
||
};
|
||
}
|
||
|
||
interface OrderStatusUpdateInput {
|
||
locale: Locale;
|
||
orderNumber: string;
|
||
firstName: string;
|
||
}
|
||
|
||
interface ShipmentEmailInput extends OrderStatusUpdateInput {
|
||
carrier?: string | null;
|
||
trackingNumber?: string | null;
|
||
trackingUrl?: string | null;
|
||
}
|
||
|
||
export async function shipmentEmail(order: ShipmentEmailInput): Promise<EmailContent> {
|
||
const s = getEmailStrings(order.locale).shipment;
|
||
const hasTracking = Boolean(order.carrier && order.trackingNumber);
|
||
const trackingHtml = hasTracking
|
||
? `
|
||
<p style="margin:16px 0 0;line-height:1.5;">
|
||
${order.carrier}: <strong>${order.trackingNumber}</strong>
|
||
</p>
|
||
${order.trackingUrl ? button(order.trackingUrl, s.trackButton) : ""}
|
||
`
|
||
: "";
|
||
const html = await emailLayout(
|
||
order.locale,
|
||
s.preheader(order.orderNumber),
|
||
`
|
||
<h1 style="font-size:20px;color:#24352B;margin:0 0 12px;">${s.heading(order.firstName)}</h1>
|
||
<p style="margin:0 0 8px;line-height:1.5;">${s.intro(order.orderNumber)}</p>
|
||
${trackingHtml}
|
||
`
|
||
);
|
||
const trackingText = hasTracking
|
||
? `\n\n${order.carrier}: ${order.trackingNumber}${order.trackingUrl ? `\n${order.trackingUrl}` : ""}`
|
||
: "";
|
||
return {
|
||
subject: s.subject(order.orderNumber),
|
||
html,
|
||
text: `${s.heading(order.firstName)}${trackingText}`,
|
||
};
|
||
}
|
||
|
||
export async function pickupReadyEmail(order: OrderStatusUpdateInput): Promise<EmailContent> {
|
||
const s = getEmailStrings(order.locale).pickupReady;
|
||
const company = await getCompanyInfo();
|
||
const html = await emailLayout(
|
||
order.locale,
|
||
s.preheader(order.orderNumber),
|
||
`
|
||
<h1 style="font-size:20px;color:#24352B;margin:0 0 12px;">${s.heading(order.firstName)}</h1>
|
||
<p style="margin:0 0 8px;line-height:1.5;">${s.intro(order.orderNumber)}</p>
|
||
<p style="margin:16px 0 0;line-height:1.5;">
|
||
${company.address.street}<br>${company.address.zip} ${company.address.city}
|
||
</p>
|
||
`
|
||
);
|
||
return {
|
||
subject: s.subject(order.orderNumber),
|
||
html,
|
||
text: `${s.intro(order.orderNumber).replace(/<\/?strong>/g, "")}\n\n${company.address.street}\n${company.address.zip} ${company.address.city}`,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Fuer Zahlungsmethoden mit verzoegerter Bestaetigung (z.B.
|
||
* SEPA-Lastschrift), bei denen der Zahlungseinzug im Nachhinein fehlschlaegt
|
||
* (checkout.session.async_payment_failed) — die Bestellung wurde bereits
|
||
* storniert und der Bestand zurueckgebucht (siehe api/checkout/webhook).
|
||
*/
|
||
interface BackInStockInput {
|
||
locale: Locale;
|
||
name: string;
|
||
slug: string;
|
||
}
|
||
|
||
export async function backInStockEmail(product: BackInStockInput): Promise<EmailContent> {
|
||
const s = getEmailStrings(product.locale).backInStock;
|
||
const url = `${getSiteUrl()}/honig/${product.slug}`;
|
||
const html = await emailLayout(
|
||
product.locale,
|
||
s.preheader(product.name),
|
||
`
|
||
<h1 style="font-size:20px;color:#24352B;margin:0 0 12px;">${s.heading}</h1>
|
||
<p style="margin:0;line-height:1.5;">${s.intro(product.name)}</p>
|
||
${button(url, s.button)}
|
||
`
|
||
);
|
||
return {
|
||
subject: s.subject(product.name),
|
||
html,
|
||
text: `${s.preheader(product.name)} ${url}`,
|
||
};
|
||
}
|
||
|
||
export async function returnAcceptedEmail(order: OrderStatusUpdateInput): Promise<EmailContent> {
|
||
const s = getEmailStrings(order.locale).returnAccepted;
|
||
const html = await emailLayout(
|
||
order.locale,
|
||
s.preheader(order.orderNumber),
|
||
`
|
||
<h1 style="font-size:20px;color:#24352B;margin:0 0 12px;">${s.heading(order.firstName)}</h1>
|
||
<p style="margin:0;line-height:1.5;">${s.intro(order.orderNumber)}</p>
|
||
`
|
||
);
|
||
return {
|
||
subject: s.subject(order.orderNumber),
|
||
html,
|
||
text: s.intro(order.orderNumber).replace(/<\/?strong>/g, ""),
|
||
};
|
||
}
|
||
|
||
export async function returnRejectedEmail(order: OrderStatusUpdateInput): Promise<EmailContent> {
|
||
const s = getEmailStrings(order.locale).returnRejected;
|
||
const html = await emailLayout(
|
||
order.locale,
|
||
s.preheader(order.orderNumber),
|
||
`
|
||
<h1 style="font-size:20px;color:#24352B;margin:0 0 12px;">${s.heading(order.firstName)}</h1>
|
||
<p style="margin:0;line-height:1.5;">${s.intro(order.orderNumber)}</p>
|
||
`
|
||
);
|
||
return {
|
||
subject: s.subject(order.orderNumber),
|
||
html,
|
||
text: s.intro(order.orderNumber).replace(/<\/?strong>/g, ""),
|
||
};
|
||
}
|
||
|
||
export async function returnRefundedEmail(order: OrderStatusUpdateInput): Promise<EmailContent> {
|
||
const s = getEmailStrings(order.locale).returnRefunded;
|
||
const html = await emailLayout(
|
||
order.locale,
|
||
s.preheader(order.orderNumber),
|
||
`
|
||
<h1 style="font-size:20px;color:#24352B;margin:0 0 12px;">${s.heading(order.firstName)}</h1>
|
||
<p style="margin:0;line-height:1.5;">${s.intro(order.orderNumber)}</p>
|
||
`
|
||
);
|
||
return {
|
||
subject: s.subject(order.orderNumber),
|
||
html,
|
||
text: s.intro(order.orderNumber).replace(/<\/?strong>/g, ""),
|
||
};
|
||
}
|
||
|
||
export async function paymentFailedEmail(order: OrderStatusUpdateInput): Promise<EmailContent> {
|
||
const s = getEmailStrings(order.locale).paymentFailed;
|
||
// Der Text (retryNote) fordert bereits zur erneuten Bestellung auf — ohne
|
||
// einen tatsaechlich anklickbaren Button dazu bliebe das ein reiner
|
||
// Lauftext-Hinweis ohne direkten Weg zurueck in den Shop.
|
||
const shopUrl = `${getSiteUrl()}/shop`;
|
||
const html = await emailLayout(
|
||
order.locale,
|
||
s.preheader(order.orderNumber),
|
||
`
|
||
<h1 style="font-size:20px;color:#24352B;margin:0 0 12px;">${s.heading(order.firstName)}</h1>
|
||
<p style="margin:0 0 8px;line-height:1.5;">${s.intro(order.orderNumber)}</p>
|
||
<p style="margin:0;line-height:1.5;">${s.retryNote}</p>
|
||
<div style="margin-top:20px;">${button(shopUrl, s.button)}</div>
|
||
`
|
||
);
|
||
return {
|
||
subject: s.subject(order.orderNumber),
|
||
html,
|
||
text: `${s.intro(order.orderNumber).replace(/<\/?strong>/g, "")} ${s.retryNote}\n\n${s.button}: ${shopUrl}`,
|
||
};
|
||
}
|