43944d8cc8
Bug fixes:
- Navbar login/logout state now updates immediately (custom ep-auth-changed
event) instead of requiring a hard reload
- Status-change email links were broken by an un-encoded "#" in the order
number; fixed for all 4 status emails
- Cart discount code: manual input field restored (was removed entirely)
- Quote-label underline now scales with the label's actual text width
- Number Ranges admin list now shows the invoice prefix/counter columns
Pricing & VAT:
- Prices show the real per-product VAT rate ("inkl. X% MwSt.") instead of
a generic disclosure
- Cart/checkout/confirmation totals show the actual € amount of VAT
included, broken down per rate when a cart spans more than one
(new lib/taxBreakdown.ts, shared with the invoice PDF's own math)
- Account order pages gained product thumbnails and the same VAT breakdown
Low-stock warning: a "Nur noch wenige verfügbar" badge/hint across the
shop grid, spotlight, and add-to-cart variant pickers, driven by the
existing lowStockThreshold field (still never exposes raw stock counts).
Invoice PDFs: product thumbnails on every line item, a plain "Netto"
label (rate was redundant, already stated on the MwSt. line below), no
more duplicate USt-IdNr. in the header, and — for a Stornorechnung
specifically — an explicit "Versand" line that was previously only
folded silently into the tax totals.
Checkout:
- Optional deviating shipping address (separate from the billing address
used for the invoice), with its own toggle + address form
- Full checkout draft persistence (name/address/shipping/payment
selections) survives navigating away and back, via localStorage
- Invoice PDF shows a third "Lieferadresse" block when the shipping
address differs from billing
Mobile navigation: fullscreen panel with a circular reveal animation from
the hamburger's corner, replacing the old in-flow accordion drawer; no
login CTA inside it (redundant with the always-visible header icon).
Admin-facing (Payload backend, mirrored where the frontend has a ported
copy of the same renderer): dashboard rebuilt as individual cards, split
into 3 task queues (received/processing/returns) instead of 2, revenue
and order counts now exclude cancelled/returned orders immediately, and
the low-stock alert links to the specific affected product(s) instead of
the unfiltered list. A new immediate email notifies the shop owner the
moment an order comes in, instead of only via the daily digest.
Testimonials admin list now groups by page instead of interleaving all
three grids' entries. ~45 English admin field descriptions translated to
German for consistency.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
339 lines
16 KiB
TypeScript
339 lines
16 KiB
TypeScript
import { formatPrice, formatDate } from "./format";
|
||
import { computeTaxBreakdown } from "./taxBreakdown";
|
||
import type { CompanySettings } from "./payload";
|
||
|
||
// Pure string-building functions, no server-only or client-only imports —
|
||
// used both server-side for the actual email send (app/lib/orderEmail.ts,
|
||
// app/api/checkout/route.ts) and client-side for the Live Preview page
|
||
// (app/email-preview/[type]/page.tsx's client component), so a Live
|
||
// Preview edit and the real sent email are guaranteed to render
|
||
// identically — same function, same input shape, just different data
|
||
// (real order vs. SAMPLE_ORDER below).
|
||
//
|
||
// Inline-styled HTML, not Tailwind classes or a <style> block — most email
|
||
// clients strip external/embedded CSS and only reliably honor inline
|
||
// `style` attributes. Structure (this file) is fixed; only the wording
|
||
// (heading/bodyText/footerText, edited in Payload's email-templates
|
||
// collection) is admin-editable — see that collection's own comment for why.
|
||
//
|
||
// Visual language deliberately echoes /bestellbestaetigung (the on-screen
|
||
// confirmation page, app/bestellbestaetigung/components/BestellbestaetigungContent.tsx)
|
||
// rather than being a generic transactional-email template: same brand
|
||
// color/warm cream background, the same circular success-icon treatment,
|
||
// the same thin brand-colored divider under the headline, a serif display
|
||
// heading. Not literally shared code (that component is React+Tailwind,
|
||
// this is plain inline-styled HTML for email-client compatibility — no
|
||
// external fonts/CSS, no flexbox reliance), just matched by eye so a
|
||
// customer doesn't get a starkly different "brand voice" between the page
|
||
// they just saw and the email that follows it.
|
||
|
||
export type EmailTemplateContent = {
|
||
subject: string;
|
||
heading: string;
|
||
bodyText: string;
|
||
footerText: string | null;
|
||
};
|
||
|
||
// Same hex values as app/globals.css's --color-* tokens — kept as literal
|
||
// constants here rather than imported, since this file has no build-time
|
||
// access to CSS custom properties (email clients wouldn't resolve `var()`
|
||
// either, even if it did).
|
||
const BRAND = "#f6a701";
|
||
const BG_BASE = "#f8f5f1";
|
||
const BG_MUTED = "#f3efe9";
|
||
const TEXT_PRIMARY = "#1a1a18";
|
||
const TEXT_MUTED = "#6b6b69";
|
||
const BORDER = "#e5e0d8";
|
||
const SUCCESS = "#2f8f4e";
|
||
// Georgia, not a web font — most email clients strip @font-face/external
|
||
// font requests, so this is the closest reliably-available serif to
|
||
// Playfair Display/Lora's editorial feel rather than an attempt to load
|
||
// the real thing.
|
||
const FONT_SERIF = "Georgia,'Times New Roman',serif";
|
||
const FONT_SANS = "-apple-system,Helvetica,Arial,sans-serif";
|
||
|
||
function paragraphs(text: string, align: "center" | "left" = "left"): string {
|
||
return text
|
||
.split(/\n{2,}/)
|
||
.map(
|
||
(p) =>
|
||
`<p style="margin:0 0 12px;line-height:1.6;font-size:15px;color:${TEXT_MUTED};text-align:${align};">${escapeHtml(p).replace(/\n/g, "<br/>")}</p>`,
|
||
)
|
||
.join("");
|
||
}
|
||
|
||
function escapeHtml(s: string): string {
|
||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||
}
|
||
|
||
// Live-Preview-only fallback (no real order/company-settings fetch there,
|
||
// see /email-preview/[type]) — the actual send always passes the real
|
||
// seller (company-settings) through buildLegalFooterLines() below. Email
|
||
// deliberately doesn't match the real admin@mk360.de send address, so this
|
||
// never reads as a hardcoded real value in the preview — it's a visibly
|
||
// fake placeholder, same spirit as "Musterstraße 12".
|
||
export const DEFAULT_LEGAL_FOOTER_LINES: string[] = [
|
||
"einfach produktiv",
|
||
"Musterstraße 12",
|
||
"12345 Musterstadt",
|
||
"E-Mail: kontakt@musterfirma.de",
|
||
];
|
||
|
||
// Every business email needs an Anbieterkennzeichnung (§5 TMG-equivalent
|
||
// minimum for business correspondence: full name, postal address, contact,
|
||
// plus VAT ID once assigned) — not just a friendly "brand · email" line.
|
||
// Built from the same company-settings fields the invoice PDFs already
|
||
// render (app/lib/invoicePdf.tsx), so there is exactly one source of truth
|
||
// for this business's legal identity.
|
||
//
|
||
// registerCourt/registerNumber/managingDirector are conditionally required
|
||
// on the Payload side (CompanySettings.ts, gated by legalForm) — only
|
||
// appended here when actually present, so a sole proprietorship's footer
|
||
// stays exactly as short as before this field set existed. Keep this in
|
||
// sync with the Payload backend's own copy in src/lib/sellerInfo.ts.
|
||
export function buildLegalFooterLines(seller: CompanySettings | null): string[] {
|
||
if (!seller) return DEFAULT_LEGAL_FOOTER_LINES;
|
||
const lines = [
|
||
seller.sellerName,
|
||
seller.sellerStreet,
|
||
`${seller.sellerZip} ${seller.sellerCity}${seller.sellerCountry && seller.sellerCountry !== "Deutschland" ? `, ${seller.sellerCountry}` : ""}`,
|
||
`E-Mail: ${seller.sellerEmail}`,
|
||
];
|
||
if (seller.vatId) lines.push(`USt-IdNr.: ${seller.vatId}`);
|
||
if (seller.registerCourt && seller.registerNumber) lines.push(`${seller.registerCourt} · ${seller.registerNumber}`);
|
||
if (seller.managingDirector) lines.push(`Geschäftsführung: ${seller.managingDirector}`);
|
||
return lines;
|
||
}
|
||
|
||
// `icon`: a single glyph rendered inside the brand-tinted circle up top —
|
||
// "✓" for order-confirmation, "✉" for password-reset. Same circular
|
||
// treatment as the confirmation page's own success icon and its delivery-
|
||
// status panel icon. `footerLines`: the legal Anbieterkennzeichnung from
|
||
// buildLegalFooterLines() above — required on every email, not optional.
|
||
function emailShell(icon: string, headingHtml: string, bodyHtml: string, footerText: string | null, footerLines: string[]): string {
|
||
return `<body style="margin:0;padding:32px 16px;background:${BG_BASE};font-family:${FONT_SANS};">
|
||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:560px;margin:0 auto;">
|
||
<tr>
|
||
<td style="padding-bottom:20px;text-align:center;">
|
||
<span style="font-family:${FONT_SERIF};font-weight:700;font-size:15px;color:${TEXT_PRIMARY};letter-spacing:0.02em;">einfach produktiv.</span>
|
||
</td>
|
||
</tr>
|
||
<tr>
|
||
<td style="background:#ffffff;border:1px solid ${BORDER};border-radius:12px;padding:40px 32px;">
|
||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
|
||
<tr>
|
||
<td style="text-align:center;padding-bottom:20px;">
|
||
<table role="presentation" cellpadding="0" cellspacing="0" style="margin:0 auto;">
|
||
<tr>
|
||
<td width="56" height="56" style="background:${BRAND}1a;border-radius:50%;text-align:center;vertical-align:middle;font-size:24px;color:${BRAND};">
|
||
${icon}
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
</td>
|
||
</tr>
|
||
<tr>
|
||
<td style="text-align:center;padding-bottom:8px;">
|
||
<span style="font-family:${FONT_SERIF};font-weight:700;font-size:26px;color:${TEXT_PRIMARY};">${headingHtml}</span>
|
||
</td>
|
||
</tr>
|
||
<tr>
|
||
<td style="text-align:center;padding-bottom:20px;">
|
||
<div style="width:32px;height:2px;background:${BRAND};margin:0 auto;"></div>
|
||
</td>
|
||
</tr>
|
||
<tr>
|
||
<td>${bodyHtml}</td>
|
||
</tr>
|
||
</table>
|
||
</td>
|
||
</tr>
|
||
<tr>
|
||
<td style="padding-top:24px;text-align:center;">
|
||
${footerText ? `<p style="margin:0 0 8px;font-size:13px;color:${TEXT_MUTED};">${escapeHtml(footerText)}</p>` : ""}
|
||
${footerLines.map((line) => `<p style="margin:0;font-size:11px;line-height:1.6;color:${TEXT_MUTED};">${escapeHtml(line)}</p>`).join("")}
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
</body>`;
|
||
}
|
||
|
||
export type OrderConfirmationItem = {
|
||
productName: string;
|
||
quantity: number;
|
||
unitPrice: number;
|
||
imageUrl?: string | null;
|
||
bundleContents?: string | null;
|
||
variantName?: string | null;
|
||
taxRatePercent: number;
|
||
};
|
||
export type OrderConfirmationData = {
|
||
orderNumber: string;
|
||
createdAt: string;
|
||
items: OrderConfirmationItem[];
|
||
subtotal: number;
|
||
shippingCost: number;
|
||
discountAmount: number;
|
||
discountCode: string | null;
|
||
total: number;
|
||
};
|
||
|
||
export const SAMPLE_ORDER: OrderConfirmationData = {
|
||
orderNumber: "#EP-0001-A7K2",
|
||
createdAt: new Date().toISOString(),
|
||
items: [
|
||
{
|
||
productName: "ToDo-Karten – Set",
|
||
quantity: 1,
|
||
unitPrice: 12.9,
|
||
imageUrl: "https://payload.mk360.de/api/media/file/product-todo-karten.png",
|
||
bundleContents: null,
|
||
taxRatePercent: 19,
|
||
},
|
||
{ productName: "Wochenplaner – Überblick", quantity: 2, unitPrice: 14.9, imageUrl: null, taxRatePercent: 19 },
|
||
],
|
||
subtotal: 42.7,
|
||
shippingCost: 0,
|
||
discountAmount: 5,
|
||
discountCode: "WILLKOMMEN10",
|
||
total: 37.7,
|
||
};
|
||
|
||
export function renderOrderConfirmationHtml(template: EmailTemplateContent, order: OrderConfirmationData, seller: CompanySettings | null): string {
|
||
const rows = order.items
|
||
.map(
|
||
(item) => `<tr>
|
||
<td width="52" style="padding:10px 0;border-bottom:1px solid ${BORDER};">
|
||
${
|
||
item.imageUrl
|
||
? `<img src="${item.imageUrl}" width="44" height="44" alt="" style="display:block;width:44px;height:44px;border-radius:6px;object-fit:cover;border:1px solid ${BORDER};" />`
|
||
: `<div style="width:44px;height:44px;border-radius:6px;background:${BG_MUTED};"></div>`
|
||
}
|
||
</td>
|
||
<td style="padding:10px 0 10px 12px;border-bottom:1px solid ${BORDER};font-size:14px;color:${TEXT_PRIMARY};">${escapeHtml(item.productName)}${item.variantName ? ` (${escapeHtml(item.variantName)})` : ""} <span style="color:${TEXT_MUTED};">× ${item.quantity}</span>${item.bundleContents ? `<br/><span style="font-size:12px;color:${TEXT_MUTED};">${escapeHtml(item.bundleContents)}</span>` : ""}</td>
|
||
<td style="padding:10px 0;border-bottom:1px solid ${BORDER};text-align:right;white-space:nowrap;font-size:14px;color:${TEXT_PRIMARY};">${formatPrice(item.quantity * item.unitPrice)}</td>
|
||
</tr>`,
|
||
)
|
||
.join("");
|
||
|
||
const summaryRow = (label: string, value: string, color = TEXT_PRIMARY) =>
|
||
`<tr><td style="padding:4px 0;font-size:14px;color:${color};">${label}</td><td style="padding:4px 0;text-align:right;font-size:14px;color:${color};">${value}</td></tr>`;
|
||
|
||
const vatRow = (label: string, value: string) =>
|
||
`<tr><td style="padding-top:4px;font-size:12px;color:${TEXT_MUTED};">${label}</td><td style="padding-top:4px;text-align:right;font-size:12px;color:${TEXT_MUTED};">${value}</td></tr>`;
|
||
|
||
// Actual VAT amount included in the total, broken down per rate when the
|
||
// order spans more than one — mirrors /bestellbestaetigung's own
|
||
// VatBreakdown component (not shared code, this file is plain
|
||
// inline-styled HTML for email-client compatibility, see the top-of-file
|
||
// comment) and the same lib/taxBreakdown.ts math the invoice PDF uses.
|
||
const taxBreakdown = computeTaxBreakdown(
|
||
order.items.map((item) => ({ quantity: item.quantity, unitPrice: item.unitPrice, taxRatePercent: item.taxRatePercent })),
|
||
order.subtotal,
|
||
order.discountAmount,
|
||
order.shippingCost,
|
||
);
|
||
const taxRows =
|
||
taxBreakdown.length <= 1
|
||
? taxBreakdown[0]
|
||
? vatRow(`enthält ${taxBreakdown[0].rate}% MwSt.`, formatPrice(taxBreakdown[0].tax))
|
||
: ""
|
||
: taxBreakdown.map((g) => vatRow(`davon ${g.rate}% MwSt.`, formatPrice(g.tax))).join("");
|
||
|
||
const body = `
|
||
${paragraphs(template.bodyText, "center")}
|
||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin-top:16px;background:${BG_MUTED};border-radius:8px;padding:20px;">
|
||
<tr><td colspan="3" style="padding-bottom:10px;font-size:12px;color:${TEXT_MUTED};">Bestellnummer <strong style="color:${TEXT_PRIMARY};">${escapeHtml(order.orderNumber)}</strong> · ${formatDate(order.createdAt)}</td></tr>
|
||
${rows}
|
||
</table>
|
||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin-top:16px;">
|
||
${summaryRow("Zwischensumme", formatPrice(order.subtotal))}
|
||
${order.discountAmount > 0 ? summaryRow(`Rabattcode${order.discountCode ? ` (${escapeHtml(order.discountCode)})` : ""}`, `-${formatPrice(order.discountAmount)}`, SUCCESS) : ""}
|
||
${summaryRow("Versand", order.shippingCost === 0 ? "Kostenlos" : formatPrice(order.shippingCost))}
|
||
</table>
|
||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin-top:12px;padding-top:12px;border-top:1px solid ${BORDER};">
|
||
<tr>
|
||
<td style="font-family:${FONT_SERIF};font-weight:700;font-size:17px;color:${TEXT_PRIMARY};">Gesamtsumme</td>
|
||
<td style="text-align:right;font-weight:700;font-size:17px;color:${TEXT_PRIMARY};">${formatPrice(order.total)}</td>
|
||
</tr>
|
||
${taxRows}
|
||
</table>
|
||
`;
|
||
|
||
return emailShell("✓", escapeHtml(template.heading), body, template.footerText, buildLegalFooterLines(seller));
|
||
}
|
||
|
||
// Icon shown per status — matches the Payload-side send exactly (see
|
||
// STATUS_EMAIL in the backend repo's src/collections/Orders.ts), kept here
|
||
// only for the Live Preview approximation (the real send happens from
|
||
// Payload itself, not this repo — see that file's own comment on why the
|
||
// two aren't pixel-identical, same established gap as password-reset).
|
||
export const ORDER_STATUS_EMAIL_ICON: Record<string, string> = {
|
||
"order-shipped": "→",
|
||
"order-cancelled": "✕",
|
||
"order-return-requested": "↩",
|
||
"order-returned": "✓",
|
||
};
|
||
|
||
export function renderOrderStatusHtml(
|
||
template: EmailTemplateContent,
|
||
icon: string,
|
||
orderNumber: string,
|
||
orderUrl: string,
|
||
seller: CompanySettings | null,
|
||
): string {
|
||
const body = `
|
||
${paragraphs(template.bodyText, "center")}
|
||
<p style="text-align:center;font-size:13px;color:${TEXT_MUTED};margin:0 0 4px;">Bestellnummer ${escapeHtml(orderNumber)}</p>
|
||
<table role="presentation" cellpadding="0" cellspacing="0" style="margin:20px auto 8px;">
|
||
<tr>
|
||
<td style="background:${BRAND};border-radius:6px;">
|
||
<a href="${orderUrl}" style="display:inline-block;padding:13px 28px;font-weight:700;font-size:15px;color:${TEXT_PRIMARY};text-decoration:none;">Bestellung ansehen</a>
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
`;
|
||
|
||
return emailShell(icon, escapeHtml(template.heading), body, template.footerText, buildLegalFooterLines(seller));
|
||
}
|
||
|
||
export function renderPasswordResetHtml(template: EmailTemplateContent, resetUrl: string, seller: CompanySettings | null): string {
|
||
const body = `
|
||
${paragraphs(template.bodyText, "center")}
|
||
<table role="presentation" cellpadding="0" cellspacing="0" style="margin:20px auto 8px;">
|
||
<tr>
|
||
<td style="background:${BRAND};border-radius:6px;">
|
||
<a href="${resetUrl}" style="display:inline-block;padding:13px 28px;font-weight:700;font-size:15px;color:${TEXT_PRIMARY};text-decoration:none;">Neues Passwort vergeben</a>
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
<p style="text-align:center;font-size:12px;color:${TEXT_MUTED};word-break:break-all;">${resetUrl}</p>
|
||
<p style="text-align:center;font-size:12px;color:${TEXT_MUTED};">Der Link ist 1 Stunde gültig.</p>
|
||
`;
|
||
|
||
return emailShell("✉", escapeHtml(template.heading), body, template.footerText, buildLegalFooterLines(seller));
|
||
}
|
||
|
||
// Not a Payload-editable template like the others above (see alertAdmin.ts's
|
||
// own comment on why this one send path exists outside the email-templates
|
||
// collection) — wording is fixed in code, but it still goes through the
|
||
// same emailShell()/buildLegalFooterLines() so the resend-verification email
|
||
// carries the same branding and legally required footer as every other
|
||
// email this app sends.
|
||
export function renderVerificationEmailHtml(firstName: string, verifyUrl: string, seller: CompanySettings | null): string {
|
||
const body = `
|
||
${paragraphs(`Hallo ${firstName},\n\nbitte bestätige deine E-Mail-Adresse für dein Konto bei einfach produktiv.`, "center")}
|
||
<table role="presentation" cellpadding="0" cellspacing="0" style="margin:20px auto 8px;">
|
||
<tr>
|
||
<td style="background:${BRAND};border-radius:6px;">
|
||
<a href="${verifyUrl}" style="display:inline-block;padding:13px 28px;font-weight:700;font-size:15px;color:${TEXT_PRIMARY};text-decoration:none;">E-Mail-Adresse bestätigen</a>
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
<p style="text-align:center;font-size:12px;color:${TEXT_MUTED};word-break:break-all;">${verifyUrl}</p>
|
||
<p style="text-align:center;font-size:12px;color:${TEXT_MUTED};">Der Link ist 24 Stunden gültig.</p>
|
||
`;
|
||
|
||
return emailShell("✉", "Bitte bestätige deine E-Mail-Adresse", body, null, buildLegalFooterLines(seller));
|
||
}
|