Document discount codes, active-column, and RelatedProducts changes
New Discount codes section (validate/redeem routes, discountServer.ts, cartTotals.ts, localStorage persistence), DISCOUNT_SERVICE_SECRET env var, discount-codes collection row (first non-public-read collection), and a note on RelatedProducts no longer padding its grid with already-in-cart products.
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user