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:
1 parent
5c76fcc266
commit
c7927f2883
240 files changed
+26096
-137
No files matched your search
@@ -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}`,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user