feat: Wabenhain Honig-Shop Anwendung

Vollstaendige Next.js/Prisma-E-Commerce-Anwendung fuer eine Imkerei:
Shop, Warenkorb, Stripe-Checkout, Kundenkonto mit 2FA, Admin-Bereich
(Produkte, Ernten, Chargen, Bestellungen, Gutscheine, admin-editierbare
Website-Inhalte), i18n (DE/EN), Rate-Limiting/CSP/Audit-Log-Haertung,
PostgreSQL- und MySQL-Unterstuetzung ueber austauschbare Prisma-
Treiber-Adapter, Unit-/Komponenten-/E2E-Tests.
This commit is contained in:
Nick Szaunig committed 2026-08-17 14:30:28 +02:00
1 parent 5c76fcc266
commit c7927f2883
240 files changed
+26096 -137

No files matched your search

+371
View File
@@ -0,0 +1,371 @@
import { formatPrice } from "@/lib/format";
import { siteConfig } from "@/lib/site-config";
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)}</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)}</td></tr>
<tr><td>${s.shipping}</td><td style="text-align:right;">${order.shippingCents === 0 ? s.freeShipping : formatPrice(order.shippingCents)}</td></tr>
${order.discountCents ? `<tr><td>${s.discount}</td><td style="text-align:right;">−${formatPrice(order.discountCents)}</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)}</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)}`)
.join("\n")}\n\n${s.subtotal}: ${formatPrice(order.subtotalCents)}\n${s.shipping}: ${
order.shippingCents === 0 ? s.freeShipping : formatPrice(order.shippingCents)
}${order.discountCents ? `\n${s.discount}: −${formatPrice(order.discountCents)}` : ""}\n${s.total}: ${formatPrice(order.totalCents)}${
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 = `https://${siteConfig.domain}/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;
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>
`
);
return {
subject: s.subject(order.orderNumber),
html,
text: `${s.intro(order.orderNumber).replace(/<\/?strong>/g, "")} ${s.retryNote}`,
};
}