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:
@@ -0,0 +1,75 @@
|
||||
import Image from "next/image";
|
||||
import { Reveal } from "../../components/Reveal";
|
||||
import { getProductBySlug } from "../../lib/payload";
|
||||
|
||||
// Only visually-verifiable claims (Metallkorpus/Chromringe from the
|
||||
// product photos) — no engraving/color claim, both still unconfirmed
|
||||
// (see plan notes: Farbvariante/Gravur-Merkmal open as of 2026-08-24).
|
||||
const bullets = [
|
||||
"Korpus aus Metall, kein Plastik",
|
||||
"Liegt gut in der Hand",
|
||||
"Passt in jede Tasche",
|
||||
];
|
||||
|
||||
// Same fix/reasoning as Hero.tsx's own IconCheck.
|
||||
function IconCheck() {
|
||||
return (
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" className="size-5 shrink-0 mt-1">
|
||||
<path d="M4 10.5l4.5 4.5L16 5.5" stroke="#f6a701" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// Unlike todo-cards/components/Focus.tsx, this product has no dedicated
|
||||
// lifestyle photo yet — only 3 supplier catalog macro shots exist, all
|
||||
// 338×338px (no larger source, confirmed against the Payload media API).
|
||||
// Rather than object-cover-stretching a small square into a wide rectangle
|
||||
// (blurs badly, and is exactly what Pricing.tsx's photo slot already does —
|
||||
// see plan notes on the "einheitlich" feedback), this uses a contained,
|
||||
// padded square tile so the source resolution reads as a deliberate macro
|
||||
// crop instead of a blown-up thumbnail. Uses gallery[1] (the chrome-rings
|
||||
// close-up) — deliberately not gallery[0], which shows an assortment
|
||||
// including a black pen and would imply a color variant that isn't
|
||||
// confirmed yet (see Focus's bullets comment).
|
||||
export async function Focus() {
|
||||
const product = await getProductBySlug("stift-kugelschreiber");
|
||||
if (!product) return null;
|
||||
const photo = product.gallery[1] ?? product.image;
|
||||
|
||||
return (
|
||||
<section className="w-full bg-bg-base flex flex-col lg:flex-row gap-10 lg:gap-16 items-center py-12 md:py-16 px-[var(--layout-padding-x)]">
|
||||
{/* Fixed max-width, not a viewport-percentage width — same reasoning
|
||||
as Hero.tsx's gallery cap: the source is only 338×338px, and a
|
||||
percentage width grows past that on wide screens, exaggerating
|
||||
the upscale blur. */}
|
||||
<Reveal className="group relative w-full max-w-[20rem] lg:shrink-0 aspect-square rounded-md overflow-hidden bg-bg-muted p-10">
|
||||
<Image
|
||||
src={photo}
|
||||
alt={product.name}
|
||||
fill
|
||||
sizes="320px"
|
||||
className="object-contain transition-transform duration-500 group-hover:scale-105"
|
||||
/>
|
||||
</Reveal>
|
||||
<Reveal className="flex flex-col gap-6 items-start flex-1 min-w-0 w-full" delay={0.1}>
|
||||
<p
|
||||
className="font-semibold text-h-emphasis text-text-primary"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
Ein Stift, der bleibt
|
||||
</p>
|
||||
<p className="text-body text-text-body">
|
||||
Die meisten Kugelschreiber landen irgendwann in einer Schublade und werden nicht wieder rausgeholt. Der Alltagsstift soll man behalten.
|
||||
</p>
|
||||
<ul className="flex flex-col gap-3 items-start w-full">
|
||||
{bullets.map((b) => (
|
||||
<li key={b} className="flex gap-[0.625rem] items-start w-full">
|
||||
<IconCheck />
|
||||
<span className="flex-1 font-semibold text-body text-text-primary">{b}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Reveal>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import Link from "next/link";
|
||||
import { AddToCartButton } from "../../components/AddToCartButton";
|
||||
import { ProductGallery } from "../../components/ProductGallery";
|
||||
import { Reveal } from "../../components/Reveal";
|
||||
import { getProductBySlug, getShippingSettings, getDefaultTaxRatePercent, getKleinunternehmer } from "../../lib/payload";
|
||||
import { formatPrice, discountPercent } from "../../lib/format";
|
||||
import { effectiveTaxRate } from "../../lib/cartTotals";
|
||||
|
||||
const checklist = [
|
||||
"Funktioniert ohne Strom oder Internet",
|
||||
"Aus Metall, kein Einwegstift",
|
||||
"Passt in jede Tasche",
|
||||
];
|
||||
|
||||
// Same fix/reasoning as TodoKartenHero.tsx's IconCheck — icon-check.svg's
|
||||
// fill can't be recolored from outside the SVG when loaded via <img
|
||||
// src>/next/image, so this stays an inline stroke path per page.
|
||||
function IconCheck() {
|
||||
return (
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" className="size-5 shrink-0 mt-1">
|
||||
<path d="M4 10.5l4.5 4.5L16 5.5" stroke="#f6a701" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// Same "compact early teaser + delivery-time repeated next to every buy
|
||||
// button" reasoning as TodoKartenHero.tsx.
|
||||
export async function Hero() {
|
||||
const [product, shipping, defaultTaxRate, kleinunternehmer] = await Promise.all([
|
||||
getProductBySlug("stift-kugelschreiber"),
|
||||
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">Der Alltagsstift</span>
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col gap-6 items-start w-full flex-1 lg:justify-center">
|
||||
<div className="flex flex-col gap-2 items-start w-full">
|
||||
<p
|
||||
className="font-semibold text-h-page text-text-primary"
|
||||
style={{ fontFamily: "var(--font-playfair)" }}
|
||||
>
|
||||
Der Alltagsstift<span className="text-brand">.</span>
|
||||
</p>
|
||||
<p
|
||||
className="font-semibold text-h3 text-text-primary"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
Ein Stift, der einfach da ist, wenn du ihn brauchst.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="text-body text-text-body">
|
||||
Der Kugelschreiber für die eine Sache, die heute zählt. Schlicht, hochwertig und immer griffbereit.
|
||||
</p>
|
||||
|
||||
<ul className="flex flex-col gap-3 items-start w-full">
|
||||
{checklist.map((item) => (
|
||||
<li key={item} className="flex gap-[0.625rem] items-start w-full">
|
||||
<IconCheck />
|
||||
<span className="flex-1 text-body text-text-primary">{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<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="Alltagsstift bestellen"
|
||||
numericId={product.numericId}
|
||||
outOfStock={!product.active || product.outOfStock}
|
||||
maxQty={product.maxQty}
|
||||
variants={product.active ? product.variants : []}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
{/* max-w cap, not the full lg:col-span-7 width — the source photos
|
||||
are only 338×338px (checked directly against the Payload media
|
||||
API, no larger size exists), so letting ProductGallery stretch
|
||||
to the full column blows them up hard. Capping the display
|
||||
width keeps the upscale factor small enough that it doesn't
|
||||
look broken; it can't fix the underlying resolution, only hide
|
||||
it. Real fix is new source photos, not a layout tweak. */}
|
||||
{product && product.gallery.length > 0 && (
|
||||
<Reveal className="order-2 lg:order-none lg:col-span-7 flex lg:items-center" delay={0.15}>
|
||||
<div className="w-full max-w-[28rem] mx-auto lg:mx-0">
|
||||
<ProductGallery image={product.image} gallery={product.gallery} alt={product.name} />
|
||||
</div>
|
||||
</Reveal>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { Fragment } from "react";
|
||||
import Image from "next/image";
|
||||
import { Reveal, RevealGroup, RevealItem } from "../../components/Reveal";
|
||||
import { StepArrow } from "../../components/StepArrow";
|
||||
|
||||
// Same 3 generic step icons as todo-cards/components/HowItWorks.tsx — their
|
||||
// artwork (pen tip, checklist, arrow) reads generically enough to carry a
|
||||
// different 3-step flow without looking mismatched.
|
||||
const steps = [
|
||||
{
|
||||
icon: "/icon-step-1.png",
|
||||
width: 165,
|
||||
height: 177,
|
||||
title: "1. Griffbereit halten",
|
||||
desc: "Der Alltagsstift liegt dort, wo du ihn brauchst – Tasche, Schreibtisch, Notizbuch.",
|
||||
},
|
||||
{
|
||||
icon: "/icon-step-2.png",
|
||||
width: 180,
|
||||
height: 168,
|
||||
title: "2. Sofort notieren",
|
||||
desc: "Du musst nicht erst dein Handy entsperren oder eine App öffnen.",
|
||||
},
|
||||
{
|
||||
icon: "/icon-step-3.png",
|
||||
width: 180,
|
||||
height: 177,
|
||||
title: "3. Weitermachen",
|
||||
desc: "Notiert ist notiert, du machst einfach da weiter, wo du warst.",
|
||||
},
|
||||
];
|
||||
|
||||
export function HowItWorks() {
|
||||
return (
|
||||
<section className="w-full bg-bg-base flex flex-col gap-12 items-center py-12 sm:py-16 px-[var(--layout-padding-x)]">
|
||||
<Reveal className="flex flex-col gap-2 items-center text-center">
|
||||
<p
|
||||
className="font-semibold text-h-emphasis text-text-primary"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
So einfach ist er im Alltag
|
||||
</p>
|
||||
<p className="text-body text-text-muted">
|
||||
Du nimmst ihn, schreibst, steckst ihn wieder ein. Mehr braucht es nicht.
|
||||
</p>
|
||||
</Reveal>
|
||||
|
||||
<RevealGroup className="flex flex-col sm:flex-row gap-8 items-center sm:items-start w-full lg:px-[16.25rem]">
|
||||
{steps.map((step, i) => (
|
||||
<Fragment key={step.title}>
|
||||
<RevealItem className="group flex flex-col gap-4 items-center text-center flex-1 max-w-xs sm:max-w-none">
|
||||
<Image
|
||||
src={step.icon}
|
||||
alt=""
|
||||
width={step.width}
|
||||
height={step.height}
|
||||
className="h-16 w-auto object-contain transition-transform duration-300 group-hover:scale-110"
|
||||
/>
|
||||
<p className="font-semibold text-body text-text-primary">{step.title}</p>
|
||||
<p className="text-body-sm text-text-primary text-center">{step.desc}</p>
|
||||
</RevealItem>
|
||||
{i < steps.length - 1 && (
|
||||
<div className="flex items-center justify-center shrink-0 sm:mt-[1.5rem]">
|
||||
<StepArrow className="w-8 h-8 rotate-90 sm:w-10 sm:h-4 sm:rotate-0" />
|
||||
</div>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</RevealGroup>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
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";
|
||||
|
||||
// Only visually-verifiable claims — same reasoning as Focus.tsx's bullets
|
||||
// (no engraving/color claim, both unconfirmed as of 2026-08-24).
|
||||
const bullets = [
|
||||
"Kugelschreiber aus Metall",
|
||||
"Chromfarbene Details",
|
||||
"Handlich, für jede Tasche",
|
||||
];
|
||||
|
||||
// Price/photo come from Payload (same "stift-kugelschreiber" product the
|
||||
// shop/cart use), not a hardcoded literal — same reasoning as
|
||||
// todo-cards/components/Pricing.tsx.
|
||||
export async function Pricing() {
|
||||
const [product, shipping, defaultTaxRate, kleinunternehmer] = await Promise.all([
|
||||
getProductBySlug("stift-kugelschreiber"),
|
||||
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">
|
||||
{/* object-contain on a bg-bg-base tile, not object-cover — the
|
||||
source is a 338×338px supplier macro shot (no larger original
|
||||
exists), so stretching it across a 410×227 landscape crop like
|
||||
before blows it up and blurs it. A contained square reads as a
|
||||
deliberate close-up instead. Uses product.image (id 85, the
|
||||
confident macro tip shot) — distinct from Focus.tsx's photo
|
||||
(gallery[1], the chrome-rings shot) so the two sections don't
|
||||
repeat the same frame. */}
|
||||
<div className="group relative w-full lg:w-[15.625rem] lg:shrink-0 aspect-square rounded-sm overflow-hidden bg-bg-base">
|
||||
<Image
|
||||
src={product.image}
|
||||
alt="Der Alltagsstift"
|
||||
fill
|
||||
sizes="(min-width: 1024px) 250px, 100vw"
|
||||
className="object-contain p-4 transition-transform duration-500 group-hover:scale-105"
|
||||
/>
|
||||
{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)" }}
|
||||
>
|
||||
Der Alltagsstift
|
||||
</p>
|
||||
<ul className="flex flex-col gap-[0.375rem] items-start">
|
||||
{bullets.map((b) => (
|
||||
<li key={b} className="flex gap-2 items-center">
|
||||
<Image alt="" src="/icon-bullet-dot.svg" width={4} height={4} className="size-1 shrink-0" />
|
||||
<span className="text-body-sm text-text-primary">{b}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Hero } from "./components/Hero";
|
||||
import { HowItWorks } from "./components/HowItWorks";
|
||||
import { Focus } from "./components/Focus";
|
||||
import { Pricing } from "./components/Pricing";
|
||||
import { Footer } from "../components/Footer";
|
||||
import { getProductBySlug, getCompanySettings } from "../lib/payload";
|
||||
import { buildProductSchema } from "../lib/structuredData";
|
||||
|
||||
const title = "Der Alltagsstift – Ein Stift, der einfach da ist, wenn du ihn brauchst.";
|
||||
const description =
|
||||
"Der Kugelschreiber für die eine Sache, die heute zählt. Schlicht, hochwertig und immer griffbereit.";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title,
|
||||
description,
|
||||
alternates: {
|
||||
canonical: "/der-alltagsstift",
|
||||
},
|
||||
openGraph: {
|
||||
title,
|
||||
description,
|
||||
url: "/der-alltagsstift",
|
||||
},
|
||||
twitter: {
|
||||
title,
|
||||
description,
|
||||
},
|
||||
};
|
||||
|
||||
export default async function DerAlltagsstiftPage() {
|
||||
const [product, seller] = await Promise.all([
|
||||
getProductBySlug("stift-kugelschreiber"),
|
||||
getCompanySettings(),
|
||||
]);
|
||||
const productSchema = product
|
||||
? buildProductSchema(product, "https://einfach-produktiv.mk360.de/der-alltagsstift", seller)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{productSchema && (
|
||||
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(productSchema) }} />
|
||||
)}
|
||||
<main className="flex flex-col flex-1">
|
||||
<Hero />
|
||||
<HowItWorks />
|
||||
<Focus />
|
||||
<Pricing />
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user