Add bespoke product detail pages for Der Alltagsstift and the mug

/der-alltagsstift mirrors the todo-cards pattern (Hero/HowItWorks/
Focus/Pricing) — renamed from "Der Eine" for brand-tonality reasons,
copy grounded in visually-verified product facts only.

/tasse-die-pause is a deliberately minimal Hero+Pricing scaffold —
naming/design concept for that product is still undecided, so the
headline reads product.name live from Payload instead of a hardcoded
tagline.
This commit is contained in:
Marco
2026-08-24 22:52:41 +00:00
parent c065a3223f
commit b6e76ccd22
8 changed files with 690 additions and 0 deletions
+101
View File
@@ -0,0 +1,101 @@
import Link from "next/link";
import Image from "next/image";
import { AddToCartButton } from "../../components/AddToCartButton";
import { Reveal } from "../../components/Reveal";
import { getProductBySlug, getShippingSettings, getDefaultTaxRatePercent, getKleinunternehmer } from "../../lib/payload";
import { formatPrice, discountPercent } from "../../lib/format";
import { effectiveTaxRate } from "../../lib/cartTotals";
// Provisional scaffold — naming/design concept for this product is still
// undecided (parked 2026-08-24, see memory: project_tasse_die_pause_product).
// Headline reads product.name directly from Payload instead of a hardcoded
// string like every other bespoke page here (TodoKartenHero.tsx, der-
// alltagsstift/Hero.tsx) — so renaming in the CMS updates this page with no
// code change. No tagline/checklist/benefit copy yet; those depend on
// whatever concept the name ends up carrying, which isn't decided.
export async function Hero() {
const [product, shipping, defaultTaxRate, kleinunternehmer] = await Promise.all([
getProductBySlug("tasse-die-pause"),
getShippingSettings(),
getDefaultTaxRatePercent(),
getKleinunternehmer(),
]);
const discount = product ? discountPercent(product.price, product.compareAtPrice) : null;
const taxRate = product ? effectiveTaxRate(product, defaultTaxRate) : null;
const anyLowStock = product
? product.active && (product.variants.length > 0 ? product.variants.some((v) => v.lowStock) : product.lowStock)
: false;
return (
<section className="bg-bg-base w-full overflow-hidden">
<div className="flex flex-col lg:grid lg:grid-cols-12 gap-8 lg:gap-[var(--layout-grid-gap)] pt-10 md:pt-12">
<Reveal className="order-1 lg:order-none lg:col-span-5 flex flex-col gap-6 items-start pl-[var(--layout-padding-x)] pr-10 lg:pr-0">
<p className="flex items-center gap-2 text-body-sm text-text-muted">
<Link href="/" className="hover:text-brand transition-colors">
Startseite
</Link>
<span></span>
<Link href="/#werkzeuge" className="hover:text-brand transition-colors">
Werkzeuge
</Link>
<span></span>
<span className="text-text-primary">{product?.name ?? "Tasse"}</span>
</p>
<div className="flex flex-col gap-6 items-start w-full flex-1 lg:justify-center">
<p
className="font-semibold text-h-page text-text-primary"
style={{ fontFamily: "var(--font-playfair)" }}
>
{product?.name ?? "Tasse"}
</p>
{product?.description && <p className="text-body text-text-body">{product.description}</p>}
<div className="flex flex-col gap-1 items-start">
{product && (
<div className="flex gap-2 items-baseline">
{discount !== null && (
<p className="text-body text-text-muted line-through">{formatPrice(product.compareAtPrice!)}</p>
)}
<p className="font-bold text-h3 text-text-primary">{formatPrice(product.price)}</p>
<p className="text-label text-text-muted">
{kleinunternehmer
? product.noShippingCost
? "Keine Versandkosten"
: "zzgl. Versand"
: `inkl. ${taxRate}% MwSt. ${product.noShippingCost ? " keine Versandkosten" : "zzgl. Versand"}`}
</p>
</div>
)}
{!product?.noShippingCost && (
<p className="text-label text-text-muted">
Lieferzeit: {shipping.totalDays.min}{shipping.totalDays.max} Werktage innerhalb Deutschlands
</p>
)}
{anyLowStock && <p className="text-label font-bold text-warning">Nur noch wenige verfügbar</p>}
</div>
{product && (
<AddToCartButton
label="In den Warenkorb"
numericId={product.numericId}
outOfStock={!product.active || product.outOfStock}
maxQty={product.maxQty}
variants={product.active ? product.variants : []}
/>
)}
</div>
</Reveal>
{product && (
<Reveal className="order-2 lg:order-none lg:col-span-7 flex lg:items-center" delay={0.15}>
<div className="relative w-full max-w-[28rem] mx-auto lg:mx-0 aspect-[4/3.1] rounded-md overflow-hidden border border-border bg-bg-base">
<Image src={product.image} alt={product.name} fill sizes="(min-width: 1024px) 45vw, 100vw" className="object-cover" />
</div>
</Reveal>
)}
</div>
</section>
);
}
@@ -0,0 +1,88 @@
import Image from "next/image";
import { AddToCartButton } from "../../components/AddToCartButton";
import { Reveal } from "../../components/Reveal";
import { getProductBySlug, getShippingSettings, getDefaultTaxRatePercent, getKleinunternehmer } from "../../lib/payload";
import { formatPrice, discountPercent } from "../../lib/format";
import { effectiveTaxRate } from "../../lib/cartTotals";
// Provisional scaffold — see Hero.tsx's own comment. No spec bullets yet;
// material/capacity claims beyond what's already in product.description
// aren't confirmed (the product photo is an AI-generated design mockup,
// not a photo of the manufactured object).
export async function Pricing() {
const [product, shipping, defaultTaxRate, kleinunternehmer] = await Promise.all([
getProductBySlug("tasse-die-pause"),
getShippingSettings(),
getDefaultTaxRatePercent(),
getKleinunternehmer(),
]);
if (!product) return null;
const discount = discountPercent(product.price, product.compareAtPrice);
const taxRate = effectiveTaxRate(product, defaultTaxRate);
const fullyOutOfStock =
!product.active || (product.variants.length > 0 ? product.variants.every((v) => v.outOfStock) : product.outOfStock);
const anyLowStock = product.active && (product.variants.length > 0 ? product.variants.some((v) => v.lowStock) : product.lowStock);
return (
<section className="w-full bg-bg-base px-[var(--layout-padding-x)] py-8">
<Reveal className="bg-bg-muted rounded-md flex flex-col lg:flex-row gap-8 lg:gap-12 items-center p-6 lg:pl-8 lg:pr-10 lg:py-6">
<div className="relative w-full lg:w-[25.625rem] lg:shrink-0 aspect-[410/227] rounded-sm overflow-hidden">
<Image src={product.image} alt={product.name} fill sizes="(min-width: 1024px) 410px, 100vw" className="object-cover" />
{fullyOutOfStock ? (
<span className="absolute top-3 left-3 rounded-full bg-text-muted px-2.5 py-1 text-label font-bold text-bg-base">
Ausverkauft
</span>
) : (
discount !== null && (
<span className="absolute top-3 left-3 rounded-full bg-brand px-2.5 py-1 text-label font-bold text-text-primary">
-{discount}%
</span>
)
)}
</div>
<div className="flex flex-col gap-3 items-start flex-1 min-w-0 w-full">
<p
className="font-semibold text-h-small text-text-primary"
style={{ fontFamily: "var(--font-lora)" }}
>
{product.name}
</p>
{product.description && <p className="text-body-sm text-text-primary">{product.description}</p>}
</div>
<div className="flex flex-col gap-3 items-start w-full lg:w-[18.75rem] lg:shrink-0">
<div className="flex flex-col gap-1 items-start">
<div className="flex gap-2 items-baseline">
{discount !== null && (
<p className="text-body text-text-muted line-through">{formatPrice(product.compareAtPrice!)}</p>
)}
<p className="font-bold text-h3 text-text-primary">{formatPrice(product.price)}</p>
</div>
<p className="text-label text-text-muted">
{kleinunternehmer
? product.noShippingCost
? "Keine Versandkosten"
: "zzgl. Versand"
: `inkl. ${taxRate}% MwSt. ${product.noShippingCost ? " keine Versandkosten" : "zzgl. Versand"}`}
</p>
{!product.noShippingCost && (
<p className="text-label text-text-muted">
Lieferzeit: {shipping.totalDays.min}{shipping.totalDays.max} Werktage innerhalb Deutschlands
</p>
)}
</div>
{anyLowStock && <p className="text-label font-bold text-warning">Nur noch wenige verfügbar</p>}
<AddToCartButton
label="In den Warenkorb"
className="w-full inline-flex items-center justify-center px-6 py-[0.8125rem] rounded-sm bg-brand hover:bg-brand-hover active:scale-[0.97] transition-all font-bold text-body text-text-primary text-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-bg-muted"
numericId={product.numericId}
outOfStock={!product.active || product.outOfStock}
maxQty={product.maxQty}
variants={product.active ? product.variants : []}
/>
</div>
</Reveal>
</section>
);
}
+45
View File
@@ -0,0 +1,45 @@
import type { Metadata } from "next";
import { Hero } from "./components/Hero";
import { Pricing } from "./components/Pricing";
import { Footer } from "../components/Footer";
import { getProductBySlug, getCompanySettings } from "../lib/payload";
import { buildProductSchema } from "../lib/structuredData";
// Provisional scaffold page — naming/design concept still undecided (parked
// 2026-08-24). Metadata reads from the live product instead of a hardcoded
// tagline, unlike every other bespoke page here — see Hero.tsx's comment.
export async function generateMetadata(): Promise<Metadata> {
const product = await getProductBySlug("tasse-die-pause");
const title = product ? `${product.name} einfach produktiv` : "Tasse einfach produktiv";
const description = product?.description ?? undefined;
return {
title,
description,
alternates: { canonical: "/tasse-die-pause" },
openGraph: { title, description, url: "/tasse-die-pause" },
twitter: { title, description },
};
}
export default async function TasseDiePausePage() {
const [product, seller] = await Promise.all([
getProductBySlug("tasse-die-pause"),
getCompanySettings(),
]);
const productSchema = product
? buildProductSchema(product, "https://einfach-produktiv.mk360.de/tasse-die-pause", seller)
: null;
return (
<>
{productSchema && (
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(productSchema) }} />
)}
<main className="flex flex-col flex-1">
<Hero />
<Pricing />
</main>
<Footer />
</>
);
}