diff --git a/README.md b/README.md index 4f122a9..b9b496b 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,9 @@ if unset, see `app/lib/payload.ts`). `PAYLOAD_PREVIEW_SECRET` (no safe default — required for Live Preview, see below; must match the value set on the Payload backend). `NEXT_PUBLIC_PAYLOAD_URL` (optional, defaults to the same `https://payload.mk360.de` — only needed if the client-side Live -Preview components should ever point somewhere else). Set in Coolify's app +Preview components should ever point somewhere else). `DISCOUNT_SERVICE_SECRET` +(no safe default — required for discount codes to validate/redeem at all; +must match the value set on the Payload backend). Set in Coolify's app settings for production, not in a committed `.env` — this app has no other secrets. @@ -69,12 +71,14 @@ API directly and reuse Next.js's fetch cache instead of an extra round trip. ### Collections used by this tenant -All reads are public (`access.read: () => true`); only writes are -admin-gated in the Payload admin UI at `payload.mk360.de/admin`. +All reads are public (`access.read: () => true`) **except `discount-codes`** +(see its own row below); writes are admin-gated in the Payload admin UI at +`payload.mk360.de/admin`. | Collection (slug) | Used for | Key fields | |---|---|---| -| `products` | `/shop` grid, homepage spotlight, cart, checkout | `name`, `slug` (cart item id — **not** Payload's numeric id, so existing localStorage carts survive catalog changes), `description`, `price`, `compareAtPrice` (optional strikethrough), `image`, `detailHref`, `sortOrder`, `spotlight` + `spotlightEyebrow`/`spotlightHeadline`/`spotlightText`/`spotlightImage` (homepage "Neu im Shop" section — falls back to `image` if no dedicated spotlight image is set) | +| `products` | `/shop` grid, homepage spotlight, cart, checkout | `name`, `slug` (cart item id — **not** Payload's numeric id, so existing localStorage carts survive catalog changes), `description`, `price`, `compareAtPrice` (optional strikethrough), `image`, `detailHref`, `sortOrder`, `active` (hides a product from the shop grid/spotlight/related-products only — cart/checkout/its own detail page still resolve it regardless, see Discount codes section below for the same opt-in-filtering principle), `spotlight` + `spotlightEyebrow`/`spotlightHeadline`/`spotlightText`/`spotlightImage` (homepage "Neu im Shop" section — falls back to `image` if no dedicated spotlight image is set; forced onto the sole active product when exactly 1 exists, see `getSpotlightProduct()`) | +| `discount-codes` | Cart discount input (`/cart`, display-only on `/checkout`) | `code`, `type` (`percent`/`fixed`), `value`, `validFrom`/`validUntil`, `minOrderValue`, `maxRedemptions`, `redemptionCount` (server-incremented only), `active`. **Not public-read** — see Discount codes section below | | `posts` | `/blog`, `/blog/[slug]` | `title`, `slug`, `category` (relation to `categories`), `excerpt`, `thumbnail`, `content` (richText), `readTime` (auto-calculated on save from word count), `featured` (shown as the `/blog` hero post; most-recently-published wins if several are marked), `publishedAt`, `quoteLabel` (label + icon + underline shown next to every blockquote in `content`, default `"Merke dir:"` — leave empty to hide that framing, the blockquote text itself still renders), `relatedProduct` (optional relation to `products`, powers the "Passend dazu" card at the end of the post — leave empty to hide that card, or empty if the linked product has no `detailHref`) | | `categories` | Blog post categorization | `name`, `slug` (unique per tenant, not globally) | | `legal-pages` | `/impressum`, `/datenschutz`, `/agb`, `/widerruf` | `type` (`impressum`/`datenschutz`/`agb`/`widerruf`, one doc per type per tenant), `title`, `content` (richText), `attachment` (optional file, e.g. the Muster-Widerrufsformular PDF) | @@ -88,12 +92,15 @@ admin-gated in the Payload admin UI at `payload.mk360.de/admin`. | `media` | Shared upload collection backing every `image`/`icon`/`thumbnail`/`attachment` field above | `alt` (required for images), `title` (optional display name for download links) | All of the above (except `media`, `users`, `tenants`) are grouped in the -Payload admin sidebar under **Commerce** (`products`, `shipping-methods`, -`shipping-settings`, `payment-methods`, `trust-badges`, `cart-trust-badges`) -or **Content** (`posts`, `categories`, `legal-pages`, `werkzeuge-cards`, -`testimonials`); `media`/`users`/`tenants` sit under **Platform** — `users` -and `tenants` are hidden from non-super-admins' nav entirely (see the infra -README's Payload CMS section for the access-control details). +Payload admin sidebar under **Commerce** (`products`, `discount-codes`, +`shipping-methods`, `shipping-settings`, `payment-methods`, `trust-badges`, +`cart-trust-badges`) or **Content** (`posts`, `categories`, `legal-pages`, +`werkzeuge-cards`, `testimonials`); `media`/`users`/`tenants` sit under +**Platform** — `users` and `tenants` are hidden from non-super-admins' nav +entirely, and every tenant-scoped collection's own "assigned tenant" field +is hidden from non-super-admins in the edit view (though not yet the list +view's column — see the infra README's Payload CMS section for why that +one's a harder fix). Editorial changes (prices, copy, images, toggling a shipping/payment method on or off) all happen in the Payload admin UI — no code deploy needed. Adding @@ -140,17 +147,59 @@ in an iframe, updating as you type, no save required. functions/types) tries to bundle it — a real RSC-boundary regression hit once already when adding this feature. +## Discount codes + +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. + +- **`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 + Payload's `discount-codes` collection using an `x-discount-service-secret` + header (`DISCOUNT_SERVICE_SECRET`), since that collection isn't + public-read. +- **`app/api/discount/validate/route.ts`** — read-only check (active / + validity window / minimum order value / remaining redemptions), called + when a shopper clicks "Anwenden" in the cart. +- **`app/api/discount/redeem/route.ts`** — re-validates, then increments + the collection's `redemptionCount`. Called exactly once, from + `CheckoutContent.tsx`'s `handlePurchase()`, right before the + `OrderSnapshot` is written — a code that expired or hit its redemption + cap between being applied in the cart and the actual purchase click + fails the purchase with an inline error instead of silently completing. +- **`app/lib/discount.ts`** mirrors `lib/cart.ts`'s exact `localStorage` + + `useSyncExternalStore` pattern, so the applied code survives the + `/cart` → `/checkout` navigation the same way the cart itself does. +- **`app/lib/cartTotals.ts`** — `computeSubtotal()`/`computeCartTotals()`, + factored out of what used to be independently-duplicated subtotal/ + savings/total math in `CartContent.tsx`, `CheckoutContent.tsx`, and + `BestellbestaetigungContent.tsx`; now also folds in the discount amount + (clamped so a total can never go negative). `OrderSnapshot` persists the + applied `discountCode`/`discountAmount` so the confirmation page shows + what actually happened, not a fresh re-derivation. +- Known, accepted limitation: the redeem route's read-then-increment isn't + atomic against a true concurrent race on a capped code's very last + redemption — not worth custom atomic SQL at this shop's traffic level. + ## Cart & checkout — demo status - **Cart** (`app/lib/cart.ts`) is entirely client-side, stored in `localStorage` under `ep_cart`, keyed by each product's `slug`. -- **Checkout has no real backend.** `/checkout`'s "Jetzt kaufen" click writes - a one-time snapshot (chosen shipping/payment method, cart contents) to - `sessionStorage` (`app/lib/order.ts`), which `/bestellbestaetigung` reads - once and displays — that snapshot *is* the order record. There is no - payment processing, no persisted order in Payload or anywhere else, and no - confirmation email yet. Treat this as a frontend/demo checkout flow, not a - functioning store. +- **`app/cart/components/RelatedProducts.tsx`** only ever suggests products + not already in the cart — it stopped falling back to re-suggesting an + already-in-cart product just to pad the grid out to 3 cards, so with a + small catalog it can render fewer cards (down to 1, centered in the + 12-column grid) rather than recommending something already added. +- **Checkout has no real backend for the *order* itself.** `/checkout`'s + "Jetzt kaufen" click writes a one-time snapshot (chosen shipping/payment + method, cart contents, applied discount) to `sessionStorage` + (`app/lib/order.ts`), which `/bestellbestaetigung` reads once and + displays — that snapshot *is* the order record. There is no payment + processing, no persisted order in Payload or anywhere else, and no + confirmation email yet — discount-code validation/redemption is the one + part of this flow with real server-side enforcement today (see above). + Treat the rest as a frontend/demo checkout flow, not a functioning store. ## Deployment