Add password reset, order confirmation email with editable templates, and fix missing account entry points

Password reset uses Payload's built-in forgot/reset-password flow,
customized to link to this app instead of the Payload admin. Order
confirmation email and the password-reset email's wording both come from
a new Payload email-templates collection, editable without a deploy and
previewable via Live Preview at /email-preview/[type] (same mechanism as
Posts/LegalPages/Testimonials, sample data instead of a real document).

Also: order numbers get a random suffix (prevents guessing, motivated by
a considered-and-deferred guest order-lookup feature); the discount code
field only shows in the cart when a code is actually active (codes now
apply via a ?code= link instead of manual entry); and three navigation
gaps found while testing — no reachable login link with an empty cart, no
logout link anywhere, no way back from profile to order history.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Marco
2026-07-22 08:14:08 +00:00
parent adca6e0f64
commit f0df359db4
26 changed files with 865 additions and 81 deletions
+99 -12
View File
@@ -167,6 +167,16 @@ Applied in `/cart` only (`/checkout` displays the already-applied result,
no second input) — real server-side validation, not just a client-side
check against Payload's public API, unlike most content on this site.
- **No manual input field anymore** — the Rabattcode section on `/cart`
(`CartContent.tsx`) only renders at all when a code is actually applied;
there's no open "enter a code" box for every visitor (Nutzer-Entscheidung:
less visual noise, and codes are meant to be shared as marketing links,
not guessed/typed in). Instead, `?code=SAVE10` on the `/cart` URL
auto-applies once on arrival (a `useEffect` reading `useSearchParams()`
requires `/cart`'s `page.tsx` to wrap `CartContent` in `<Suspense>`, a
Next.js requirement for any `useSearchParams()` consumer). A code that
arrives via the URL but turns out invalid/expired still shows an inline
error, just without an input box to attach it to.
- **`app/lib/discountServer.ts`** (server-only, imported exclusively by the
two route handlers below — never by a `"use client"` component, same
reasoning as Live Preview's `next/headers` lesson above) talks to
@@ -217,11 +227,18 @@ check against Payload's public API, unlike most content on this site.
written to `sessionStorage` for `/bestellbestaetigung` to read once, but
its `orderNumber`/`orderDateIso` now come back from that Payload create
call, not generated client-side.
- Still not built: real payment processing (the checkout button is
labelled "zahlungspflichtig" but nothing captures a payment) and a
transactional confirmation email — see `project_backend_checkout_plan`
in the assistant's own memory for what's deliberately deferred to a
later stage.
- **Order confirmation email** is sent from `/api/checkout/route.ts`
right after a successful `createOrder()` — fire-and-forget
(`app/lib/orderEmail.ts`'s `sendOrderConfirmationEmail()`), never blocks
or fails the checkout response itself; a send failure alerts admin
instead (`sendCriticalAlert`, lower severity than the "order not
persisted" alert, since the order itself is safe either way). Content
comes from the **published** `order-confirmation` row in Payload's
`email-templates` collection — see "Email templates & Live Preview"
below for how that's edited/previewed.
- Still not built: real payment processing — the checkout button is
labelled "zahlungspflichtig" but nothing actually captures a payment
yet. See `project_backend_checkout_plan` in the assistant's own memory.
## Orders & customer accounts
@@ -246,15 +263,31 @@ without leaving the page.
the current password via a real login attempt before changing it,
doesn't just trust the caller), `cart` (GET/POST, see below),
`verify-email`, `resend-verification`, `delete`, `export` (see "Email
verification" and "GDPR self-service" below), and
verification" and "GDPR self-service" below), `forgot-password`,
`reset-password` (see "Password reset" below), and
`orders/[orderNumber]` (PATCH — cancel/return-request, see "Order
cancellation & returns" below).
- **`/konto/bestellungen`** lists a customer's own orders;
**`/konto/bestellungen/[orderNumber]`** shows one order's full detail
(items, address, totals, `status`). `status` (`received``processing`
`shipped``delivered`, plus `cancelled`/`return_requested`/`returned`)
is maintained by hand in the Payload admin for the shipping states — no
shipping-carrier API integration.
- **`/konto/bestellungen`** lists a customer's own orders (status shown as
a colored `OrderStatusBadge.tsx`, plus an "Abmelden" link —
`LogoutButton.tsx`); **`/konto/bestellungen/[orderNumber]`** shows one
order's full detail (items, address, totals, `status`). `status`
(`received``processing``shipped``delivered`, plus
`cancelled`/`return_requested`/`returned`) is maintained by hand in the
Payload admin for the shipping states — no shipping-carrier API
integration.
- **`Navbar.tsx`'s `AccountLink`** (account icon, desktop; "Anmelden"/"Mein
Konto" text link, mobile drawer) is the only *always*-reachable way into
`/konto/*` — added after discovering there previously wasn't one:
`/checkout`'s own login toggle only renders once the cart already has
items (its empty-cart state is an early return with no such toggle), and
`/bestellbestaetigung`'s "Meine Bestellungen ansehen" link only exists
after a completed order. A returning customer with an empty cart and no
recent order had no way to reach the login page at all before this.
Fetches auth state client-side via `/api/account/me` (not through the
server-rendered root layout) specifically so `app/layout.tsx` — otherwise
static/ISR-cacheable — doesn't get forced into per-request dynamic
rendering just to know one icon's href; briefly shows the logged-out
state on first paint until that fetch resolves.
- **`/konto/profil`** edits name + the one saved default address (deliberately
a single address, not a full address book — see the assistant's memory
note on optionally expanding this later), changes the password, shows
@@ -316,6 +349,60 @@ a resend link when `!profile.emailVerified`; resending
instead (`app/lib/alertAdmin.ts`'s `sendVerificationEmail()` — same
Hostinger SMTP, no Payload hook to piggyback on for a plain field update).
### Password reset
Unlike email verification, this needed no custom flag — `forgotPassword`
doesn't block login, so it's Payload's built-in flow as-is (see the
Payload README's `customers.auth.forgotPassword` section), just with the
email content/destination swapped so the link points here instead of the
Payload admin. `/konto/passwort-vergessen` (`ForgotPasswordForm.tsx`) →
`POST /api/account/forgot-password` → always responds `{ok:true}`
regardless of whether the email exists (same anti-enumeration reasoning as
Payload's own operation — the route must not leak a different response
shape for "no such account", see its own comment). `/konto/passwort-zuruecksetzen?token=...`
(`ResetPasswordForm.tsx`, token read server-side from `searchParams`
avoids needing a `<Suspense>` boundary, unlike the `?code=` cart case
above which genuinely needs client-side `useSearchParams()`) →
`POST /api/account/reset-password` → Payload logs the customer in on a
successful reset (returns the same `{token, user}` shape as login), so the
session cookie is set immediately, no separate login step. `LoginForm.tsx`
links to `/konto/passwort-vergessen`.
### Email templates & Live Preview
Both transactional emails (order confirmation, password reset) read their
subject/heading/body/footer wording from Payload's `email-templates`
collection — editable in the admin without a deploy, with a Live Preview
button using the exact same mechanism as Posts/LegalPages/Testimonials
(`useLivePreview()` from `@payloadcms/live-preview-react`, already a
dependency here for `LivePostContent.tsx`).
- **`app/lib/emailTemplates.ts`** — pure string-building functions
(`renderOrderConfirmationHtml()`, `renderPasswordResetHtml()`), no
server-only or client-only imports. Used **both** server-side for the
real send (`orderEmail.ts`) **and** client-side for the Live Preview
page — same function, same inputs, so a Live Preview edit and the real
sent email are guaranteed to render identically for order-confirmation
(password-reset's actual send uses Payload's own simple inline template
instead — see that repo's README for why — so its Live Preview
approximates rather than pixel-matches). Inline-styled HTML (`<table>`
layout, `style` attributes, no Tailwind/`<style>` block) — most email
clients strip external/embedded CSS.
- **`/email-preview/[type]/page.tsx`** — entered exclusively from Payload's
admin iframe (`EmailTemplates.ts`'s `admin.livePreview.url`), never a
real visitor destination (`noindex`). Always reads with `draft: true` so
an unsaved admin edit shows immediately. Renders against **sample data**
(`SAMPLE_ORDER` in `emailTemplates.ts`) — unlike the other three Live
Preview targets, there's no "current" real order/reset-link to preview
against generically.
- The *real* send always reads the **published** template
(`getEmailTemplate()` in `app/lib/payload.ts`, `draft` unset) — a Live
Preview edit never affects a live customer email until actually saved.
- `npx payload run src/seed-email-templates.ts` (Payload repo) seeds
sensible defaults for both rows; `sendOrderConfirmationEmail()` also has
a hardcoded fallback for the rare case a fresh install's order arrives
before that seed has run.
### GDPR self-service
`/konto/profil`'s "Konto & Daten" section:
+19
View File
@@ -0,0 +1,19 @@
import { NextResponse } from "next/server";
import { requestPasswordReset } from "../../../lib/customerAuth";
import { checkRateLimit, getClientIp } from "../../../lib/rateLimit";
// Always responds the same way regardless of whether the email exists —
// see requestPasswordReset()'s own comment. Rate-limited a bit tighter
// than login/register (5/15min) since there's no secondary defense here
// the way Payload's own per-account lockout backs up the login route.
export async function POST(request: Request) {
if (!checkRateLimit(`forgot-password:${getClientIp(request)}`, { limit: 5, windowMs: 15 * 60 * 1000 })) {
return NextResponse.json({ ok: true });
}
const body = await request.json().catch(() => null);
const email = typeof body?.email === "string" ? body.email : "";
if (email) await requestPasswordReset(email);
return NextResponse.json({ ok: true });
}
+22
View File
@@ -0,0 +1,22 @@
import { NextResponse } from "next/server";
import { resetPassword, setSessionCookie } from "../../../lib/customerAuth";
import { checkRateLimit, getClientIp } from "../../../lib/rateLimit";
export async function POST(request: Request) {
if (!checkRateLimit(`reset-password:${getClientIp(request)}`, { limit: 5, windowMs: 15 * 60 * 1000 })) {
return NextResponse.json({ ok: false, reason: "Zu viele Versuche. Bitte in ein paar Minuten erneut probieren." }, { status: 429 });
}
const body = await request.json().catch(() => null);
const token = typeof body?.token === "string" ? body.token : "";
const password = typeof body?.password === "string" ? body.password : "";
if (!token || !password || password.length < 8) {
return NextResponse.json({ ok: false, reason: "Bitte ein neues Passwort (mind. 8 Zeichen) angeben." }, { status: 400 });
}
const result = await resetPassword(token, password);
if (!result.ok) return NextResponse.json(result, { status: 400 });
await setSessionCookie(result.token);
return NextResponse.json({ ok: true, customer: result.customer });
}
+26
View File
@@ -6,6 +6,7 @@ import { createOrder } from "../../lib/orderServer";
import { getSessionCustomer, registerCustomer, setSessionCookie, type CustomerSummary } from "../../lib/customerAuth";
import { fetchProductsBySlug } from "../../lib/productsServer";
import { sendCriticalAlert } from "../../lib/alertAdmin";
import { sendOrderConfirmationEmail } from "../../lib/orderEmail";
type CheckoutBody = {
cart: CartItem[];
@@ -154,6 +155,31 @@ export async function POST(request: Request) {
return NextResponse.json({ ok: false, reason: "Bestellung konnte nicht gespeichert werden." }, { status: 500 });
}
// Fire-and-forget — a failed confirmation email must never undo an
// already-successful order or block the response the customer is
// waiting on. Lower severity than the "order lost" alert above (the
// order itself is safe either way), but still worth knowing about, since
// it's the one thing that would otherwise fail completely silently.
sendOrderConfirmationEmail(
{
orderNumber: order.orderNumber,
createdAt: order.createdAt,
items: items.map((i) => ({ productName: i.productName, quantity: i.quantity, unitPrice: i.unitPrice })),
subtotal,
shippingCost,
discountAmount,
discountCode: body.discountCode || null,
total,
},
body.email,
).catch((err) => {
sendCriticalAlert("Bestätigungs-Mail konnte nicht gesendet werden", {
orderNumber: order.orderNumber,
customerEmail: body.email,
error: String(err),
});
});
return NextResponse.json({
ok: true,
orderNumber: order.orderNumber,
+31 -31
View File
@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { useEffect, useState } from "react";
import { useSearchParams } from "next/navigation";
import Link from "next/link";
import Image from "next/image";
import { useCart, removeFromCart, setQuantity } from "../../lib/cart";
@@ -38,9 +39,9 @@ export function CartContent({
const cart = useCart();
const products = useProducts();
const discount = useDiscount();
const [discountInput, setDiscountInput] = useState("");
const [discountError, setDiscountError] = useState<string | null>(null);
const [discountLoading, setDiscountLoading] = useState(false);
const searchParams = useSearchParams();
// While the /api/products fetch is still pending, treat a non-empty
// cart as "loading" rather than "empty" — the old hardcoded PRODUCTS
// lookup was synchronous, so this distinction didn't exist before;
@@ -59,8 +60,7 @@ export function CartContent({
: shippingCost;
const { totalSavings, discountAmount, total } = computeCartTotals(items, shipping, discount);
async function handleApplyDiscount() {
const code = discountInput.trim();
async function handleApplyDiscount(code: string) {
if (!code) return;
setDiscountLoading(true);
setDiscountError(null);
@@ -73,7 +73,6 @@ export function CartContent({
const data = await res.json();
if (data.valid) {
applyDiscount({ code: code.toUpperCase(), type: data.type, value: data.value });
setDiscountInput("");
} else {
setDiscountError(data.reason || "Dieser Code ist ungültig.");
}
@@ -84,6 +83,20 @@ export function CartContent({
}
}
// No manual input field anymore (see the Rabattcode section below) —
// codes are shared as a direct link instead (e.g. "/cart?code=SAVE10"),
// auto-applied once on arrival. Only fires while nothing's applied yet
// and there's actually something in the cart to validate a minimum-order
// value against.
useEffect(() => {
const code = searchParams.get("code");
if (code && !discount && items.length > 0) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- one-time sync from the URL's ?code= param to app state on arrival, via an async server validation call, not a render-cascade
handleApplyDiscount(code);
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- only ever re-run when the cart finishes loading or the URL's code param itself changes, not on every discount/handleApplyDiscount identity change
}, [searchParams, items.length]);
return (
<>
{/* Page header */}
@@ -233,10 +246,15 @@ export function CartContent({
</div>
)}
{/* Rabattcode cart-only, /checkout only displays the
already-applied result (see lib/discount.ts, shared via
{/* Rabattcode no manual input anymore (Nutzer-Entscheidung:
kein offenes Eingabefeld für jede:n Besucher:in), nur noch
sichtbar wenn tatsächlich ein Code aktiv ist. Codes kommen
jetzt ausschließlich über einen Link mit vorausgefülltem
Code (siehe die useEffect oben), nicht mehr durch manuelle
Eingabe hier. /checkout zeigt weiterhin nur das bereits
angewendete Ergebnis (see lib/discount.ts, shared via
localStorage the same way the cart itself is). */}
{discount ? (
{discount && (
<div className="flex flex-col gap-2 w-full">
<div className="flex items-center w-full">
<span className="text-body-sm text-success">Rabattcode ({discount.code})</span>
@@ -251,30 +269,12 @@ export function CartContent({
Entfernen
</button>
</div>
) : (
<div className="flex flex-col gap-2 w-full">
<div className="flex gap-2 w-full">
<label className="sr-only" htmlFor="discount-code">Rabattcode</label>
<input
id="discount-code"
type="text"
value={discountInput}
onChange={(e) => setDiscountInput(e.target.value)}
placeholder="Rabattcode"
className="flex-1 min-w-0 border border-border rounded-sm px-3.5 py-2 text-body-sm text-text-primary outline-none focus:border-brand transition-colors"
/>
<button
type="button"
onClick={handleApplyDiscount}
disabled={discountLoading || !discountInput.trim()}
className="shrink-0 px-4 py-2 rounded-sm border border-border text-body-sm font-bold text-text-primary hover:border-brand hover:text-brand disabled:opacity-50 disabled:hover:border-border disabled:hover:text-text-primary transition-colors"
>
{discountLoading ? "…" : "Anwenden"}
</button>
</div>
{discountError && <p className="text-label text-red-600">{discountError}</p>}
</div>
)}
{/* Feedback for a code that arrived via URL (?code=...) but
turned out invalid/expired surfaced even though there's
no input field to attach it to anymore. */}
{!discount && discountError && <p className="text-label text-red-600 w-full">{discountError}</p>}
{!discount && discountLoading && <p className="text-label text-text-muted w-full">Rabattcode wird geprüft</p>}
<div className="flex flex-col gap-0.5 w-full">
<div className="flex items-center w-full">
+15 -6
View File
@@ -1,4 +1,5 @@
import type { Metadata } from "next";
import { Suspense } from "react";
import { CartContent } from "./components/CartContent";
import { RelatedProducts } from "./components/RelatedProducts";
import { TrustRow } from "../components/TrustRow";
@@ -38,12 +39,20 @@ export default async function CartPage() {
return (
<>
<main className="flex flex-col flex-1 bg-bg-base">
<CartContent
trustBadges={trustBadges}
shippingCost={defaultShipping?.price ?? 0}
freeShippingThreshold={freeShippingThreshold}
shippingSettings={shipping}
/>
{/* Suspense required CartContent uses useSearchParams() (?code=
auto-apply, see its own comment) which opts any consumer into
client-side rendering unless wrapped. fallback={null}: the cart
itself is entirely client-rendered from localStorage anyway
(see CartContent's own productsLoading handling), so there's no
meaningful server-rendered content this would flash away from. */}
<Suspense fallback={null}>
<CartContent
trustBadges={trustBadges}
shippingCost={defaultShipping?.price ?? 0}
freeShippingThreshold={freeShippingThreshold}
shippingSettings={shipping}
/>
</Suspense>
<RelatedProducts />
<TrustRow />
</main>
+16 -10
View File
@@ -311,16 +311,22 @@ export function CheckoutContent({
{/* Only needed for the inline-registration path an existing
session already has an account, no password to collect. */}
{!customerEmail && (
<FormField
label="Passwort (für dein neues Konto)"
name="password"
type="password"
placeholder="Mind. 8 Zeichen"
autoComplete="new-password"
required
minLength={8}
wrapperClassName="w-full sm:w-[calc(50%-0.5rem)] sm:flex-none min-w-0"
/>
<div className="flex flex-col gap-2 w-full sm:w-[calc(50%-0.5rem)] sm:flex-none min-w-0">
<FormField
label="Passwort (für dein neues Konto)"
name="password"
type="password"
placeholder="Mind. 8 Zeichen"
autoComplete="new-password"
required
minLength={8}
wrapperClassName="w-full"
/>
<p className="text-label text-text-muted">
Mit dem Kauf legen wir automatisch ein Konto für dich an damit du deine Bestellungen später
einsehen und bei Bedarf stornieren oder zurücksenden kannst.
</p>
</div>
)}
{/* Segmented control, same sm:w-[calc(50%-0.5rem)] half-row
width as the field(s) below it Lieferadresse keeps
+51
View File
@@ -71,6 +71,53 @@ function isNavLinkActive(href: string, pathname: string, activeSection: string):
return pathname === href || pathname.startsWith(`${href}/`);
}
// Account icon — no Figma source exists for this yet (added outside the
// normal Figma-first workflow, see the assistant's own project notes on
// why: without it there was no reachable way to log in at all once the
// cart was empty and no recent order existed — /checkout's own login
// toggle never even renders in that state, see CheckoutContent.tsx's
// early "Warenkorb ist leer" return). Fetches auth state client-side via
// /api/account/me rather than through the server-rendered layout — this
// component's parent (app/layout.tsx) is otherwise static/ISR-cacheable,
// and reading the session cookie there (next/headers' cookies()) would
// force the entire site into per-request dynamic rendering just for this.
// `loggedIn === null` is the brief "not checked yet" state on first paint.
function AccountLink({ variant }: { variant: "icon" | "mobile" }) {
const [loggedIn, setLoggedIn] = useState<boolean | null>(null);
useEffect(() => {
fetch("/api/account/me")
.then((res) => setLoggedIn(res.ok))
.catch(() => setLoggedIn(false));
}, []);
const href = loggedIn ? "/konto/bestellungen" : "/konto/login";
if (variant === "mobile") {
return (
<Link
href={href}
className="min-h-11 flex items-center justify-center px-6 py-4 rounded-sm border border-[#868686] text-h4 font-bold text-text-primary hover:border-brand hover:text-brand active:scale-[0.97] transition-all"
>
{loggedIn ? "Mein Konto" : "Anmelden"}
</Link>
);
}
return (
<Link
href={href}
aria-label={loggedIn ? "Mein Konto" : "Anmelden"}
className="relative flex h-11 w-11 items-center justify-center shrink-0 active:scale-[0.9] transition-transform"
>
<svg viewBox="0 0 24 24" className="h-6 w-6 text-text-primary" fill="none" aria-hidden="true">
<circle cx="12" cy="8" r="3.6" stroke="currentColor" strokeWidth="1.8" />
<path d="M4.5 20c1.2-4 4-6 7.5-6s6.3 2 7.5 6" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
</svg>
</Link>
);
}
// Cart icon + count badge — traced from the Figma Navbar/Default component's
// btn-cart (icon-cart 32x30 + cart-count-badge, node 4849:24). Visible at
// every breakpoint tier (unlike the nav links / CTA buttons, which move into
@@ -390,6 +437,7 @@ export function Navbar({ singleActiveProduct }: { singleActiveProduct: boolean }
(below lg). Grouped so spacing stays consistent as individual
children hide/show across the three breakpoint tiers. */}
<div className="flex items-center gap-2">
<AccountLink variant="icon" />
<CartLink />
{/* CTA buttons inline from md (768px) up, i.e. through both
@@ -519,6 +567,9 @@ export function Navbar({ singleActiveProduct }: { singleActiveProduct: boolean }
>
7-Tage-Challenge
</Link>
<div onClick={closeMobile}>
<AccountLink variant="mobile" />
</div>
</div>
</div>
</header>
@@ -0,0 +1,45 @@
"use client";
import { useLivePreview } from "@payloadcms/live-preview-react";
import {
renderOrderConfirmationHtml,
renderPasswordResetHtml,
SAMPLE_ORDER,
type EmailTemplateContent,
} from "../../../lib/emailTemplates";
import type { EmailTemplateType } from "../../../lib/payload";
const PAYLOAD_URL = process.env.NEXT_PUBLIC_PAYLOAD_URL || "https://payload.mk360.de";
// Same useLivePreview() mechanism as LivePostContent.tsx (blog) — connects
// to the Payload admin's iframe via postMessage and updates `data` as the
// admin edits fields, no save required. Renders through the exact same
// renderOrderConfirmationHtml/renderPasswordResetHtml functions that build
// the real sent email (app/lib/orderEmail.ts, Customers.ts's forgotPassword
// hook on the Payload side uses its own simple template instead — see that
// hook's own comment on why the two aren't pixel-identical for
// password-reset specifically) — order-confirmation previews exactly.
export function LiveEmailPreviewClient({
type,
initialTemplate,
}: {
type: EmailTemplateType;
initialTemplate: EmailTemplateContent;
}) {
const { data } = useLivePreview<EmailTemplateContent>({
initialData: initialTemplate,
serverURL: PAYLOAD_URL,
depth: 0,
});
const html =
type === "order-confirmation"
? renderOrderConfirmationHtml(data, SAMPLE_ORDER)
: renderPasswordResetHtml(data, "https://einfach-produktiv.mk360.de/konto/passwort-zuruecksetzen?token=beispiel-token");
return (
<div style={{ background: "#f4f2ee", minHeight: "100vh", padding: "32px 0" }}>
<div dangerouslySetInnerHTML={{ __html: html }} />
</div>
);
}
+35
View File
@@ -0,0 +1,35 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { draftMode } from "next/headers";
import { getEmailTemplate, type EmailTemplateType } from "../../lib/payload";
import { LiveEmailPreviewClient } from "./components/LiveEmailPreviewClient";
export const metadata: Metadata = {
title: "E-Mail-Vorschau",
robots: { index: false, follow: false },
};
const VALID_TYPES: EmailTemplateType[] = ["order-confirmation", "password-reset"];
// Entered exclusively via EmailTemplates.ts's admin.livePreview.url (a
// Payload-admin-only iframe target, see buildPreviewUrl()/api/preview) —
// not a page a real visitor would ever land on. Always reads with
// draft:true so an unsaved edit in the admin shows up here immediately;
// the actual sent email (orderEmail.ts) always reads the published version
// instead.
export default async function EmailPreviewPage({ params }: { params: Promise<{ type: string }> }) {
const { type } = await params;
if (!VALID_TYPES.includes(type as EmailTemplateType)) notFound();
const emailType = type as EmailTemplateType;
await draftMode();
const template = (await getEmailTemplate(emailType, { draft: true })) ?? {
type: emailType,
subject: "",
heading: emailType === "order-confirmation" ? "Vielen Dank für deine Bestellung!" : "Passwort zurücksetzen",
bodyText: "Noch kein Inhalt gespeichert — im Payload-Admin unter E-Mail-Vorlagen anlegen.",
footerText: null,
};
return <LiveEmailPreviewClient type={emailType} initialTemplate={template} />;
}
@@ -4,8 +4,9 @@ import Link from "next/link";
import { Reveal } from "../../../components/Reveal";
import { Footer } from "../../../components/Footer";
import { formatPrice, formatDate } from "../../../lib/format";
import { getSessionCustomer, getCustomerOrderDetail, ORDER_STATUS_LABEL, customerOrderAction } from "../../../lib/customerAuth";
import { getSessionCustomer, getCustomerOrderDetail, customerOrderAction } from "../../../lib/customerAuth";
import { OrderActionButton } from "./components/OrderActionButton";
import { OrderStatusBadge } from "../../components/OrderStatusBadge";
export const metadata: Metadata = {
title: "Bestelldetails",
@@ -45,7 +46,7 @@ export default async function KontoBestellungDetailPage({ params }: { params: Pr
</div>
<div className="flex flex-col gap-1">
<p className="text-label text-text-muted">Status</p>
<p className="text-body-sm text-text-primary">{ORDER_STATUS_LABEL[order.status] ?? order.status}</p>
<OrderStatusBadge status={order.status} />
</div>
<div className="flex flex-col gap-1">
<p className="text-label text-text-muted">Zahlungsart</p>
+5 -2
View File
@@ -4,7 +4,9 @@ import Link from "next/link";
import { Reveal } from "../../components/Reveal";
import { Footer } from "../../components/Footer";
import { formatPrice, formatDate } from "../../lib/format";
import { getSessionCustomer, getCustomerOrders, ORDER_STATUS_LABEL } from "../../lib/customerAuth";
import { getSessionCustomer, getCustomerOrders } from "../../lib/customerAuth";
import { OrderStatusBadge } from "../components/OrderStatusBadge";
import { LogoutButton } from "../components/LogoutButton";
// robots: noindex — account area, same reasoning as /checkout.
export const metadata: Metadata = {
@@ -57,7 +59,7 @@ export default async function KontoBestellungenPage() {
</div>
<div className="flex flex-col gap-1">
<p className="text-label text-text-muted">Status</p>
<p className="text-body-sm text-text-primary">{ORDER_STATUS_LABEL[order.status] ?? order.status}</p>
<OrderStatusBadge status={order.status} />
</div>
<div className="flex flex-col gap-1 ml-auto">
<p className="text-label text-text-muted">Gesamtbetrag</p>
@@ -75,6 +77,7 @@ export default async function KontoBestellungenPage() {
<Link href="/shop" className="underline text-body-sm text-text-primary hover:text-brand transition-colors">
Weiter einkaufen
</Link>
<LogoutButton />
</div>
</Reveal>
</main>
+19
View File
@@ -0,0 +1,19 @@
"use client";
import { useRouter } from "next/navigation";
export function LogoutButton() {
const router = useRouter();
async function handleLogout() {
await fetch("/api/account/logout", { method: "POST" });
router.push("/");
router.refresh();
}
return (
<button type="button" onClick={handleLogout} className="underline text-body-sm text-text-primary hover:text-brand transition-colors">
Abmelden
</button>
);
}
+28
View File
@@ -0,0 +1,28 @@
import { ORDER_STATUS_LABEL } from "../../lib/customerAuth";
// Colors are a rough "how good is this news" scale — neutral while
// in-progress, success once actually delivered, warm/red for anything
// that means the order didn't complete as planned. Reuses existing tokens
// where they exist (--color-brand, --color-success/-subtle); cancelled/
// return_requested borrow plain Tailwind red/orange since this codebase
// has no custom tokens for those (same reasoning as the existing
// text-red-600 error-text convention elsewhere).
const STYLES: Record<string, string> = {
received: "bg-bg-muted text-text-muted",
processing: "bg-brand/10 text-brand",
shipped: "bg-brand/10 text-brand",
delivered: "bg-success-subtle text-success",
cancelled: "bg-red-50 text-red-600",
return_requested: "bg-orange-50 text-orange-600",
returned: "bg-bg-muted text-text-light",
};
export function OrderStatusBadge({ status }: { status: string }) {
return (
<span
className={`inline-flex items-center px-2.5 py-1 rounded-full text-label font-bold whitespace-nowrap ${STYLES[status] ?? "bg-bg-muted text-text-muted"}`}
>
{ORDER_STATUS_LABEL[status] ?? status}
</span>
);
}
+3
View File
@@ -75,6 +75,9 @@ export function LoginForm() {
{loading ? "Einen Moment…" : "Einloggen"}
</button>
</form>
<Link href="/konto/passwort-vergessen" className="text-body-sm text-text-muted underline hover:text-brand transition-colors">
Passwort vergessen?
</Link>
<p className="text-body-sm text-text-muted">
Noch kein Konto? Einfach beim{" "}
<Link href="/checkout" className="underline hover:text-brand transition-colors">
@@ -0,0 +1,72 @@
"use client";
import { useState } from "react";
import { Reveal } from "../../../components/Reveal";
export function ForgotPasswordForm() {
const [email, setEmail] = useState("");
const [sent, setSent] = useState(false);
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setLoading(true);
try {
await fetch("/api/account/forgot-password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
});
} catch {
// Same "always show success" reasoning as the route itself — a
// network hiccup here shouldn't reveal anything either.
}
setSent(true);
setLoading(false);
}
if (sent) {
return (
<Reveal className="flex flex-col gap-4 items-start pt-10 pb-20 px-[var(--layout-padding-x)] w-full max-w-[26rem] mx-auto">
<p className="font-semibold text-h-feature text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>
E-Mail unterwegs
</p>
<p className="text-body text-text-muted">
Falls zu <strong>{email}</strong> ein Konto existiert, haben wir dir eine E-Mail mit einem Link zum
Zurücksetzen deines Passworts geschickt.
</p>
</Reveal>
);
}
return (
<Reveal className="flex flex-col gap-6 items-start pt-10 pb-20 px-[var(--layout-padding-x)] w-full max-w-[26rem] mx-auto">
<p className="font-semibold text-h-feature text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>
Passwort vergessen
</p>
<p className="text-body text-text-muted">
Gib deine E-Mail-Adresse ein wir schicken dir einen Link, mit dem du ein neues Passwort vergeben kannst.
</p>
<form onSubmit={handleSubmit} className="flex flex-col gap-4 items-start w-full">
<label className="flex flex-col gap-2 items-start w-full">
<span className="text-label text-text-muted">E-Mail-Adresse</span>
<input
type="email"
required
autoComplete="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full border border-border rounded-sm px-4 py-3 text-body-sm text-text-primary outline-none focus:border-brand transition-colors"
/>
</label>
<button
type="submit"
disabled={loading}
className={`w-full flex items-center justify-center py-4 rounded-sm bg-brand hover:bg-brand-hover active:scale-[0.97] transition-all font-bold text-body text-text-primary ${loading ? "opacity-70 pointer-events-none" : ""}`}
>
{loading ? "Einen Moment…" : "Link anfordern"}
</button>
</form>
</Reveal>
);
}
+19
View File
@@ -0,0 +1,19 @@
import type { Metadata } from "next";
import { ForgotPasswordForm } from "./components/ForgotPasswordForm";
import { Footer } from "../../components/Footer";
export const metadata: Metadata = {
title: "Passwort vergessen",
robots: { index: false, follow: true },
};
export default function PasswortVergessenPage() {
return (
<>
<main className="flex flex-col flex-1 bg-bg-base">
<ForgotPasswordForm />
</main>
<Footer />
</>
);
}
@@ -0,0 +1,80 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Reveal } from "../../../components/Reveal";
export function ResetPasswordForm({ token }: { token: string | null }) {
const router = useRouter();
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
if (!token) return;
setLoading(true);
setError(null);
try {
const res = await fetch("/api/account/reset-password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token, password }),
});
const data = await res.json();
if (!data.ok) {
setError(data.reason || "Passwort konnte nicht zurückgesetzt werden.");
setLoading(false);
return;
}
router.push("/konto/bestellungen");
router.refresh();
} catch {
setError("Passwort konnte gerade nicht zurückgesetzt werden.");
setLoading(false);
}
}
if (!token) {
return (
<Reveal className="flex flex-col gap-4 items-start pt-10 pb-20 px-[var(--layout-padding-x)] w-full max-w-[26rem] mx-auto">
<p className="font-semibold text-h-feature text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>
Link ungültig
</p>
<p className="text-body text-text-muted">
Dieser Link zum Zurücksetzen des Passworts ist ungültig oder abgelaufen. Fordere gerne einen neuen an.
</p>
</Reveal>
);
}
return (
<Reveal className="flex flex-col gap-6 items-start pt-10 pb-20 px-[var(--layout-padding-x)] w-full max-w-[26rem] mx-auto">
<p className="font-semibold text-h-feature text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>
Neues Passwort vergeben
</p>
<form onSubmit={handleSubmit} className="flex flex-col gap-4 items-start w-full">
<label className="flex flex-col gap-2 items-start w-full">
<span className="text-label text-text-muted">Neues Passwort</span>
<input
type="password"
required
minLength={8}
autoComplete="new-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full border border-border rounded-sm px-4 py-3 text-body-sm text-text-primary outline-none focus:border-brand transition-colors"
/>
</label>
{error && <p className="text-label text-red-600">{error}</p>}
<button
type="submit"
disabled={loading}
className={`w-full flex items-center justify-center py-4 rounded-sm bg-brand hover:bg-brand-hover active:scale-[0.97] transition-all font-bold text-body text-text-primary ${loading ? "opacity-70 pointer-events-none" : ""}`}
>
{loading ? "Einen Moment…" : "Passwort speichern"}
</button>
</form>
</Reveal>
);
}
+28
View File
@@ -0,0 +1,28 @@
import type { Metadata } from "next";
import { ResetPasswordForm } from "./components/ResetPasswordForm";
import { Footer } from "../../components/Footer";
export const metadata: Metadata = {
title: "Passwort zurücksetzen",
robots: { index: false, follow: true },
};
// token read server-side from searchParams (not the client-side
// useSearchParams() hook) — avoids needing a Suspense boundary here, same
// reasoning as /konto/profil's ?verified= handling.
export default async function PasswortZuruecksetzenPage({
searchParams,
}: {
searchParams: Promise<{ token?: string }>;
}) {
const { token } = await searchParams;
return (
<>
<main className="flex flex-col flex-1 bg-bg-base">
<ResetPasswordForm token={token ?? null} />
</main>
<Footer />
</>
);
}
+4
View File
@@ -1,5 +1,6 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import Link from "next/link";
import { Footer } from "../../components/Footer";
import { getSessionCustomer, getCustomerProfile } from "../../lib/customerAuth";
import { ProfileForm } from "./components/ProfileForm";
@@ -29,6 +30,9 @@ export default async function KontoProfilPage({
<>
<main className="flex flex-col flex-1 bg-bg-base">
<div className="flex flex-col gap-10 items-start pt-10 pb-16 px-[var(--layout-padding-x)] w-full max-w-[40rem] mx-auto">
<Link href="/konto/bestellungen" className="text-body-sm text-text-muted hover:text-brand transition-colors">
Meine Bestellungen
</Link>
<VerificationBanner emailVerified={profile.emailVerified} justVerified={verified === "1" || verified === "0" ? verified : undefined} />
<ProfileForm profile={profile} />
<PasswordForm email={profile.email} />
+1 -18
View File
@@ -1,21 +1,4 @@
import nodemailer from "nodemailer";
// Server-only. Deliberately its own SMTP transport, independent of
// Payload's (which now also sends mail — see Customers.ts's verification
// email) — the whole point of this module is alerting when something is
// broken, and if Payload itself is what's broken, routing the alert
// through it could mean the alert never arrives. Same Hostinger account
// either way (already proven working via Diun's update notifications),
// just a second, separate connection to it.
const transport = nodemailer.createTransport({
host: "smtp.hostinger.com",
port: 587,
secure: false,
auth: {
user: process.env.SMTP_USER || "",
pass: process.env.SMTP_PASSWORD || "",
},
});
import { transport } from "./mailer";
// Fire-and-forget by design — callers should not await this in a way that
// blocks or fails the actual error response the customer sees. Wrap
+46
View File
@@ -90,6 +90,52 @@ export async function loginCustomer(input: { email: string; password: string }):
};
}
// Always resolves — never throws or returns a distinguishable "email not
// found" shape. Mirrors Payload's own forgot-password operation, which
// fails silently on a non-existent email specifically to avoid leaking
// which addresses are registered (see auth/operations/forgotPassword.js);
// the caller (app/api/account/forgot-password/route.ts) must preserve that
// by always responding the same way regardless of this call's outcome.
export async function requestPasswordReset(email: string): Promise<void> {
await fetch(`${PAYLOAD_URL}/api/customers/forgot-password`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
}).catch(() => {
// Best-effort — same "never reveal anything" reasoning as above.
});
}
// Payload's reset-password operation logs the customer in on success (see
// auth/operations/resetPassword.js) and returns the same {token, user}
// shape as login — reused here so the frontend route can set the session
// cookie immediately, no separate login step needed after a reset.
export async function resetPassword(token: string, newPassword: string): Promise<AuthResult> {
const res = await fetch(`${PAYLOAD_URL}/api/customers/reset-password`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token, password: newPassword }),
});
if (!res.ok) return { ok: false, reason: "Der Link ist ungültig oder abgelaufen." };
const data: {
token: string;
user: { id: number; customerNumber: string; firstName: string; lastName: string; email: string; emailVerified: boolean };
} = await res.json();
return {
ok: true,
token: data.token,
customer: {
id: data.user.id,
customerNumber: data.user.customerNumber,
firstName: data.user.firstName,
lastName: data.user.lastName,
email: data.user.email,
emailVerified: data.user.emailVerified,
},
};
}
export async function getCustomerFromToken(token: string): Promise<CustomerSummary | null> {
const res = await fetch(`${PAYLOAD_URL}/api/customers/me`, {
headers: { Authorization: `JWT ${token}` },
+118
View File
@@ -0,0 +1,118 @@
import { formatPrice, formatDate } from "./format";
// 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.
export type EmailTemplateContent = {
subject: string;
heading: string;
bodyText: string;
footerText: string | null;
};
const BRAND = "#f6a701";
const TEXT_PRIMARY = "#1a1a18";
const BORDER = "#e5e0d8";
function paragraphs(text: string): string {
return text
.split(/\n{2,}/)
.map((p) => `<p style="margin:0 0 16px;line-height:1.6;">${escapeHtml(p).replace(/\n/g, "<br/>")}</p>`)
.join("");
}
function escapeHtml(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
function emailShell(bodyHtml: string): string {
return `<div style="max-width:600px;margin:0 auto;padding:32px 24px;font-family:Arial,Helvetica,sans-serif;color:${TEXT_PRIMARY};">
<p style="margin:0 0 24px;font-weight:700;font-size:18px;">einfach produktiv.</p>
${bodyHtml}
<p style="margin-top:32px;padding-top:16px;border-top:1px solid ${BORDER};font-size:12px;color:#6b6b66;">
einfach produktiv · admin@mk360.de
</p>
</div>`;
}
export type OrderConfirmationItem = { productName: string; quantity: number; unitPrice: 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 },
{ productName: "Wochenplaner Überblick", quantity: 2, unitPrice: 14.9 },
],
subtotal: 42.7,
shippingCost: 0,
discountAmount: 5,
discountCode: "WILLKOMMEN10",
total: 37.7,
};
export function renderOrderConfirmationHtml(template: EmailTemplateContent, order: OrderConfirmationData): string {
const rows = order.items
.map(
(item) => `<tr>
<td style="padding:8px 0;border-bottom:1px solid ${BORDER};">${escapeHtml(item.productName)} × ${item.quantity}</td>
<td style="padding:8px 0;border-bottom:1px solid ${BORDER};text-align:right;white-space:nowrap;">${formatPrice(item.quantity * item.unitPrice)}</td>
</tr>`,
)
.join("");
const summaryRow = (label: string, value: string) =>
`<tr><td style="padding:4px 0;">${label}</td><td style="padding:4px 0;text-align:right;">${value}</td></tr>`;
return emailShell(`
<h1 style="margin:0 0 16px;font-size:22px;">${escapeHtml(template.heading)}</h1>
${paragraphs(template.bodyText)}
<table style="width:100%;border-collapse:collapse;margin:24px 0;font-size:14px;">
<tr><td colspan="2" style="padding-bottom:8px;font-size:12px;color:#6b6b66;">Bestellnummer ${escapeHtml(order.orderNumber)} · ${formatDate(order.createdAt)}</td></tr>
${rows}
</table>
<table style="width:100%;border-collapse:collapse;font-size:14px;">
${summaryRow("Zwischensumme", formatPrice(order.subtotal))}
${order.discountAmount > 0 ? summaryRow(`Rabattcode${order.discountCode ? ` (${escapeHtml(order.discountCode)})` : ""}`, `-${formatPrice(order.discountAmount)}`) : ""}
${summaryRow("Versand", order.shippingCost === 0 ? "Kostenlos" : formatPrice(order.shippingCost))}
</table>
<table style="width:100%;border-collapse:collapse;margin-top:8px;padding-top:8px;border-top:1px solid ${BORDER};font-size:16px;font-weight:700;">
<tr><td>Gesamtsumme</td><td style="text-align:right;">${formatPrice(order.total)}</td></tr>
</table>
${template.footerText ? `<p style="margin-top:24px;font-size:14px;color:#6b6b66;">${escapeHtml(template.footerText)}</p>` : ""}
`);
}
export function renderPasswordResetHtml(template: EmailTemplateContent, resetUrl: string): string {
return emailShell(`
<h1 style="margin:0 0 16px;font-size:22px;">${escapeHtml(template.heading)}</h1>
${paragraphs(template.bodyText)}
<p style="margin:24px 0;">
<a href="${resetUrl}" style="display:inline-block;padding:12px 24px;background:${BRAND};color:${TEXT_PRIMARY};font-weight:700;text-decoration:none;border-radius:4px;">Neues Passwort vergeben</a>
</p>
<p style="font-size:13px;color:#6b6b66;">Falls der Button nicht funktioniert: ${resetUrl}</p>
<p style="font-size:13px;color:#6b6b66;">Der Link ist 1 Stunde gültig.</p>
${template.footerText ? `<p style="margin-top:24px;font-size:14px;color:#6b6b66;">${escapeHtml(template.footerText)}</p>` : ""}
`);
}
+19
View File
@@ -0,0 +1,19 @@
import nodemailer from "nodemailer";
// Server-only. This app's own SMTP connection, deliberately independent of
// Payload's (which also sends mail now — see Customers.ts's verification/
// password-reset emails on the Payload side) — critical-error alerts in
// particular need to still go out even if Payload itself is what's broken.
// Same Hostinger account either way (already proven working via Diun's
// update notifications), just a second, separate connection to it. Shared
// by alertAdmin.ts and orderEmail.ts so there's exactly one transport
// instance, not one per call site.
export const transport = nodemailer.createTransport({
host: "smtp.hostinger.com",
port: 587,
secure: false,
auth: {
user: process.env.SMTP_USER || "",
pass: process.env.SMTP_PASSWORD || "",
},
});
+28
View File
@@ -0,0 +1,28 @@
import { transport } from "./mailer";
import { getEmailTemplate } from "./payload";
import { renderOrderConfirmationHtml, type OrderConfirmationData } from "./emailTemplates";
// Called from app/api/checkout/route.ts right after a successful
// createOrder() — fire-and-forget, must never block or fail the checkout
// response itself. A missing/unpublished template falls back to a plain
// default so a first-ever order isn't silently un-confirmed just because
// nobody's visited the Payload admin yet (mirrors seed-email-templates.ts's
// defaults, kept in sync by hand — there are only two places this wording
// lives, seeding it is a one-time setup step, not a runtime dependency).
export async function sendOrderConfirmationEmail(order: OrderConfirmationData, customerEmail: string): Promise<boolean> {
const template = (await getEmailTemplate("order-confirmation")) ?? {
subject: "Deine Bestellung bei einfach produktiv",
heading: "Vielen Dank für deine Bestellung!",
bodyText: "Wir haben deine Bestellung erhalten und bereiten sie für den Versand vor.",
footerText: null,
};
const html = renderOrderConfirmationHtml(template, order);
await transport.sendMail({
from: '"einfach produktiv" <admin@mk360.de>',
to: customerEmail,
subject: template.subject,
html,
});
return true;
}
+33
View File
@@ -603,3 +603,36 @@ export async function getLegalPage(type: LegalPageType, options?: { draft?: bool
: null,
};
}
export type EmailTemplateType = "order-confirmation" | "password-reset";
type PayloadEmailTemplate = {
type: EmailTemplateType;
subject: string;
heading: string;
bodyText: string;
footerText: string | null;
};
// draft:true is used by app/email-preview/[type]/page.tsx (Live Preview,
// see EmailTemplates.ts in the Payload repo); the real send (orderEmail.ts,
// Customers.ts's forgotPassword hook) always reads the published version.
export async function getEmailTemplate(
type: EmailTemplateType,
options?: { draft?: boolean },
): Promise<PayloadEmailTemplate | null> {
const params = new URLSearchParams({
"where[tenant.slug][equals]": TENANT_SLUG,
"where[type][equals]": type,
limit: "1",
});
const res = await fetch(`${PAYLOAD_URL}/api/email-templates?${params}`, livePreviewCacheOption(Boolean(options?.draft)));
if (!res.ok) {
console.error(`getEmailTemplate: Payload returned ${res.status} ${res.statusText}`);
return null;
}
const data: { docs?: PayloadEmailTemplate[] } = await res.json();
return data.docs?.[0] ?? null;
}