diff --git a/README.md b/README.md index 5db54da..cb77f48 100644 --- a/README.md +++ b/README.md @@ -123,7 +123,11 @@ Every page can carry real per-page metadata — `/blog/[slug]`'s `generateMetadata()` reads a post's own `seoTitle`/`seoDescription`/`seoImage` (see the `posts` collection below); `app/layout.tsx`'s `generateMetadata()` reads `getSeoSettings()` (ISR-cached, backed by `company-settings`) for -the site-wide fallback. +the site-wide fallback. That same `generateMetadata()` also sets +`verification: { google: seo.googleSearchConsoleVerification }` when an +admin has filled in company-settings' SEO-tab verification field — Next.js +renders that natively as ``, no +separate literal tag in this file. JSON-LD structured data (`app/lib/structuredData.ts`, no headless external validation step, just direct grepping of the rendered diff --git a/app/cart/components/RelatedProducts.tsx b/app/cart/components/RelatedProducts.tsx index d03cb82..5811ac5 100644 --- a/app/cart/components/RelatedProducts.tsx +++ b/app/cart/components/RelatedProducts.tsx @@ -1,13 +1,10 @@ "use client"; import { useEffect, useMemo, useRef, useState } from "react"; -import Image from "next/image"; import { useProducts } from "../../lib/products"; -import { formatPrice, discountPercent } from "../../lib/format"; -import { effectiveTaxRate } from "../../lib/cartTotals"; import { Reveal } from "../../components/Reveal"; -import { AddToCartInlineButton, FEEDBACK_MS } from "../../components/AddToCartInlineButton"; -import { WishlistButton } from "../../components/WishlistButton"; +import { ProductCard } from "../../components/ProductCard"; +import { FEEDBACK_MS } from "../../components/AddToCartInlineButton"; import { useCart } from "../../lib/cart"; const DISPLAY_COUNT = 3; @@ -133,17 +130,16 @@ export function RelatedProducts({ scroll-reveal nicety on a list that mutates; a static grid renders correctly with no animation risk. */}
- {displayProducts.map((product, i) => { - const discount = discountPercent(product.price, product.compareAtPrice); - const taxRate = effectiveTaxRate(product, defaultTaxRate); - // Same "any vs. every" split as ProductGrid.tsx. - const fullyOutOfStock = product.variants.length > 0 ? product.variants.every((v) => v.outOfStock) : product.outOfStock; - const anyLowStock = product.variants.length > 0 ? product.variants.some((v) => v.lowStock) : product.lowStock; - return ( -
( + -
- {product.name} - {/* Same top-left pill pattern as ProductGrid.tsx/ - ProductSpotlight.tsx — position: absolute, so it never - affects this card's height. Only the discount/Ausverkauft - pill lives here now; the low-stock hint moved to a - reserved-height text line below (see the min-h paragraph - under the price) — plain conditional text here is what - broke equal card heights in this grid before. */} - {fullyOutOfStock ? ( - - Ausverkauft - - ) : ( - discount !== null && ( - - -{discount}% - - ) - )} - {wishlistEnabled && ( - - )} -
-
-
- {/* Same eyebrow treatment as ProductGrid.tsx/Blog.tsx's - category line — omitted for an uncategorized product. */} - {product.categories.length > 0 && ( -

{product.categories.join(", ")}

- )} -

- {product.name} -

-
-

- {discount !== null && ( - {formatPrice(product.compareAtPrice!)} - )} - {formatPrice(product.price)} - {!kleinunternehmer && inkl. {taxRate}% MwSt.} -

- {/* Always rendered, text conditional — min-h reserves this - line's height in both states so cards in the same row - stay equal height regardless of low-stock status. */} -

- {anyLowStock ? "Nur noch wenige verfügbar" : null} -

- - {/* flex-1 spacer — pins every card's button to the same Y - regardless of whether `product.name` wraps to one or two - lines (see ProductGrid.tsx's identical spacer). */} -
- - -
-
- ); - })} + /> + ))}
); diff --git a/app/components/ProductCard.tsx b/app/components/ProductCard.tsx new file mode 100644 index 0000000..f63abe6 --- /dev/null +++ b/app/components/ProductCard.tsx @@ -0,0 +1,129 @@ +import Link from "next/link"; +import Image from "next/image"; +import type { ReactNode } from "react"; +import { formatPrice, discountPercent } from "../lib/format"; +import { effectiveTaxRate } from "../lib/cartTotals"; +import { AddToCartInlineButton } from "./AddToCartInlineButton"; +import { WishlistButton } from "./WishlistButton"; +import type { Product } from "../lib/payload"; + +// Single shared card markup for every product grid (ProductGrid, +// RelatedProducts, MerklisteGrid) — these three used to each duplicate +// this JSX independently and had drifted (different image aspect ratios, +// a "Mehr erfahren" link present on some but not others, purchased/ +// out-of-stock badges only on some). The title itself is now the card's +// only link (product.href, when set) — no separate "Mehr erfahren" CTA +// line, which is also what makes the card more compact than before. +// No "use client" — plain enough (no hooks/browser APIs of its own) to +// render from both ProductGrid's Server Component and RelatedProducts'/ +// MerklisteGrid's Client Components. +export function ProductCard({ + product, + defaultTaxRate, + kleinunternehmer, + wishlistEnabled, + wishlistRevealOnHover = false, + topLeftBadge, + belowPrice, + className = "", +}: { + product: Product; + defaultTaxRate: number; + kleinunternehmer: boolean; + wishlistEnabled: boolean; + /** See WishlistButton's own doc: true for any grid where an unprompted + * heart on every card would read as noise (ProductGrid, RelatedProducts); + * false (default) for /konto/merkliste, where every card is already + * wishlisted. */ + wishlistRevealOnHover?: boolean; + /** Overrides the default Ausverkauft/discount pill — used by + * MerklisteGrid for its "Gekauft am ..." badge. Pass `null` to render + * no badge at all. */ + topLeftBadge?: ReactNode; + /** Rendered directly under the price — e.g. ProductGrid's delivery-time + * line. Omitted entirely by grids that don't have anything to say there + * (RelatedProducts, MerklisteGrid), rather than every card carrying a + * fixed slot for content only one of the three actually has. */ + belowPrice?: ReactNode; + className?: string; +}) { + const discount = discountPercent(product.price, product.compareAtPrice); + const taxRate = effectiveTaxRate(product, defaultTaxRate); + const fullyOutOfStock = product.variants.length > 0 ? product.variants.every((v) => v.outOfStock) : product.outOfStock; + const anyLowStock = product.variants.length > 0 ? product.variants.some((v) => v.lowStock) : product.lowStock; + + return ( +
+
+ {product.name} + {topLeftBadge !== undefined ? ( + topLeftBadge + ) : fullyOutOfStock ? ( + + Ausverkauft + + ) : ( + discount !== null && ( + + -{discount}% + + ) + )} + {wishlistEnabled && ( + + )} +
+
+
+ {product.categories.length > 0 && ( +

{product.categories.join(", ")}

+ )} + {product.href ? ( + + {product.name} + + ) : ( +

+ {product.name} +

+ )} +
+

+ {discount !== null && ( + {formatPrice(product.compareAtPrice!)} + )} + {formatPrice(product.price)} + {!kleinunternehmer && inkl. {taxRate}% MwSt.} +

+ {belowPrice} + {/* Always rendered, text conditional — reserved height keeps every + card in a row equal height regardless of low-stock status. */} +

{anyLowStock ? "Nur noch wenige verfügbar" : null}

+ + {/* flex-1 spacer — pins every card's button to the same Y + regardless of whether the title/category line wraps. */} +
+ + +
+
+ ); +} diff --git a/app/components/ProductGallery.tsx b/app/components/ProductGallery.tsx index d8aec08..227807e 100644 --- a/app/components/ProductGallery.tsx +++ b/app/components/ProductGallery.tsx @@ -1,8 +1,10 @@ "use client"; -import { useState } from "react"; +import { useRef, useState } from "react"; import Image from "next/image"; +const SWIPE_THRESHOLD_PX = 40; + /** * Main image + thumbnail strip, swappable on click. `image` is always * slide zero, `gallery` (Products.gallery, optional/empty for most @@ -16,6 +18,7 @@ import Image from "next/image"; export function ProductGallery({ image, gallery, alt }: { image: string; gallery: string[]; alt: string }) { const slides = [image, ...gallery]; const [current, setCurrent] = useState(0); + const touchStartX = useRef(null); if (slides.length <= 1) { return ( @@ -32,9 +35,24 @@ export function ProductGallery({ image, gallery, alt }: { image: string; gallery setCurrent((c) => (c - 1 + slides.length) % slides.length); } + function handleTouchStart(e: React.TouchEvent) { + touchStartX.current = e.touches[0].clientX; + } + function handleTouchEnd(e: React.TouchEvent) { + if (touchStartX.current === null) return; + const delta = e.changedTouches[0].clientX - touchStartX.current; + touchStartX.current = null; + if (delta <= -SWIPE_THRESHOLD_PX) next(); + else if (delta >= SWIPE_THRESHOLD_PX) prev(); + } + return (
-
+
{alt}
-
+
+ {/* Name/Adresse/E-Mail kommen direkt aus company-settings, nicht + aus der CMS-Richtext unten — single-sourced, gleiche + Begründung wie Impressum's AnbieterAngaben.tsx. */} + {seller && } {page ? ( isPreview ? : ) : ( diff --git a/app/konto/merkliste/components/MerklisteGrid.tsx b/app/konto/merkliste/components/MerklisteGrid.tsx index ccb0b5f..9651be6 100644 --- a/app/konto/merkliste/components/MerklisteGrid.tsx +++ b/app/konto/merkliste/components/MerklisteGrid.tsx @@ -1,12 +1,9 @@ "use client"; -import Image from "next/image"; import { RevealGroup, RevealItem } from "../../../components/Reveal"; -import { formatPrice, discountPercent, formatDate } from "../../../lib/format"; -import { AddToCartInlineButton } from "../../../components/AddToCartInlineButton"; -import { WishlistButton } from "../../../components/WishlistButton"; +import { formatDate } from "../../../lib/format"; +import { ProductCard } from "../../../components/ProductCard"; import { useWishlist } from "../../../lib/useWishlist"; -import { effectiveTaxRate } from "../../../lib/cartTotals"; import type { Product } from "../../../lib/payload"; // Client Component so removing an item (WishlistButton toggling it off) @@ -43,73 +40,32 @@ export function MerklisteGrid({ return ( - {visibleEntries.map(({ item, product }) => { - const discount = discountPercent(product.price, product.compareAtPrice); - const taxRate = effectiveTaxRate(product, defaultTaxRate); - const fullyOutOfStock = product.variants.length > 0 ? product.variants.every((v) => v.outOfStock) : product.outOfStock; - // Already-purchased takes precedence over "Ausverkauft" — a - // customer who already bought this doesn't need a restock notice, - // they need to know they already own it (and can still remove it - // manually via WishlistButton — this is a status note, not an - // auto-removal, see feedback discussion this implements). - return ( - -
- {product.name} - {item.purchasedAt ? ( + {visibleEntries.map(({ item, product }) => ( + + Gekauft am {formatDate(item.purchasedAt)} - ) : ( - fullyOutOfStock && ( - - Ausverkauft - - ) - )} - -
-
-
- {/* Same eyebrow treatment as ProductGrid.tsx/Blog.tsx's - category line — omitted for an uncategorized product. */} - {product.categories.length > 0 && ( -

{product.categories.join(", ")}

- )} -

- {product.name} -

-
-
-

- {discount !== null && ( - {formatPrice(product.compareAtPrice!)} - )} - {formatPrice(product.price)} - {!kleinunternehmer && inkl. {taxRate}% MwSt.} -

-
- {/* flex-1 spacer — pins every card's button to the same Y - regardless of whether `product.name` wraps to one or two - lines (see ProductGrid.tsx's identical spacer). */} -
- - {/* No className override — AddToCartInlineButton's `className` - prop REPLACES its whole default styling (`?? defaultClass`, - not a merge), so passing just "w-full" here previously threw - away all the button's actual styling. Its default is - already `w-full`. */} - -
- - ); - })} + ) : undefined + } + /> + + ))} ); } diff --git a/app/layout.tsx b/app/layout.tsx index cc45144..4a44722 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -56,6 +56,9 @@ export async function generateMetadata(): Promise { card: "summary_large_image", images: seo.defaultOgImage ? [seo.defaultOgImage] : undefined, }, + verification: seo.googleSearchConsoleVerification + ? { google: seo.googleSearchConsoleVerification } + : undefined, }; } diff --git a/app/lib/payload.ts b/app/lib/payload.ts index 8dbe8e7..28acf5f 100644 --- a/app/lib/payload.ts +++ b/app/lib/payload.ts @@ -1133,6 +1133,7 @@ export type SeoSettings = { titleTemplate: string | null; defaultDescription: string | null; defaultOgImage: string | null; + googleSearchConsoleVerification: string | null; }; // Fallback matches the values hardcoded in app/layout.tsx before this field @@ -1145,6 +1146,7 @@ const SEO_SETTINGS_FALLBACK: SeoSettings = { defaultDescription: "Kleine Impulse, praktische Werkzeuge und ehrliche Gedanken für mehr Klarheit im Alltag – weil du auch noch ein Leben hast.", defaultOgImage: "/og-image.png", + googleSearchConsoleVerification: null, }; // Same ISR-cached, public-catalog-freshness fetch as getKleinunternehmer() @@ -1166,6 +1168,7 @@ export async function getSeoSettings(): Promise { seoTitleTemplate?: string | null; seoDefaultDescription?: string | null; seoDefaultOgImage?: { url?: string } | number | null; + googleSearchConsoleVerification?: string | null; }[]; } = await res.json(); const doc = data.docs?.[0]; @@ -1176,6 +1179,8 @@ export async function getSeoSettings(): Promise { defaultDescription: doc.seoDefaultDescription || SEO_SETTINGS_FALLBACK.defaultDescription, defaultOgImage: (typeof doc.seoDefaultOgImage === "object" && doc.seoDefaultOgImage?.url) || SEO_SETTINGS_FALLBACK.defaultOgImage, + googleSearchConsoleVerification: + doc.googleSearchConsoleVerification || SEO_SETTINGS_FALLBACK.googleSearchConsoleVerification, }; } diff --git a/app/shop/components/ProductGrid.tsx b/app/shop/components/ProductGrid.tsx index 10f9cc9..43303f6 100644 --- a/app/shop/components/ProductGrid.tsx +++ b/app/shop/components/ProductGrid.tsx @@ -1,12 +1,6 @@ -import Link from "next/link"; -import Image from "next/image"; import { getProducts, getShippingSettings, getDefaultTaxRatePercent, getKleinunternehmer, getWishlistEnabled, getShopFilterEnabled } from "../../lib/payload"; -import { effectiveTaxRate } from "../../lib/cartTotals"; -import { formatPrice, discountPercent } from "../../lib/format"; import { RevealGroup, RevealItem } from "../../components/Reveal"; -import { AddToCartInlineButton } from "../../components/AddToCartInlineButton"; -import { WishlistButton } from "../../components/WishlistButton"; -import { ArrowRightIcon } from "../../components/ArrowRightIcon"; +import { ProductCard } from "../../components/ProductCard"; import { PriceRangeFilter } from "./PriceRangeFilter"; import { CategoryFilter } from "./CategoryFilter"; @@ -124,105 +118,23 @@ export async function ProductGrid({ key={products.map((p) => p.id).join(",")} className="grid items-start grid-cols-1 sm:grid-cols-2 lg:grid-cols-12 gap-6 sm:gap-[var(--layout-grid-gap)] w-full" > - {products.map((product) => { - const discount = discountPercent(product.price, product.compareAtPrice); - const taxRate = effectiveTaxRate(product, defaultTaxRate); - // A varianted product only reads as "ausverkauft" overall once - // every one of its variants is — a single sold-out variant just - // shows as such in the picker itself (AddToCartInlineButton), - // not as a blanket badge that would misleadingly suggest the - // whole product is unavailable while other variants still are. - const fullyOutOfStock = isProductFullyOutOfStock(product); - // Mirrors fullyOutOfStock's "any vs. every" split — a varianted - // product reads as low-stock as soon as one variant is, since a - // shopper landing on the grid hasn't picked a variant yet. - const anyLowStock = product.variants.length > 0 ? product.variants.some((v) => v.lowStock) : product.lowStock; - return ( - -
- {product.name} - {fullyOutOfStock ? ( - - Ausverkauft - - ) : ( - discount !== null && ( - - -{discount}% - - ) - )} - {wishlistEnabled && ( - - )} -
-
-
- {/* Same eyebrow treatment as the blog cards' category line - (Blog.tsx) — omitted entirely for an uncategorized - product rather than showing an empty line. */} - {product.categories.length > 0 && ( -

{product.categories.join(", ")}

- )} -

- {product.name} -

-
-
-

- {discount !== null && ( - {formatPrice(product.compareAtPrice!)} - )} - {formatPrice(product.price)} - {!kleinunternehmer && inkl. {taxRate}% MwSt.} -

-

- Lieferzeit: {shipping.totalDays.min}–{shipping.totalDays.max} Werktage innerhalb Deutschlands -

-
- {/* Always rendered, text conditional — not a conditional - block — so this line's height (min-h as a cross-browser - safety net for the empty case) is identical whether or - not the product is low-stock. See AddToCartButton.tsx's - own comment: an earlier text-based low-stock hint here - broke equal card heights across the grid, which is why - it moved to the image-overlay pill in the first place. */} -

- {anyLowStock ? "Nur noch wenige verfügbar" : null} -

- {product.href && ( - - Mehr erfahren - - - )} - - {/* flex-1 spacer — pins every card's button to the same Y - regardless of the "Mehr erfahren" line only products with - a detail page have (see figma-to-nextjs skill's Tools.tsx - equal-height lesson). */} -
- - -
- - ); - })} + {products.map((product) => ( + + + Lieferzeit: {shipping.totalDays.min}–{shipping.totalDays.max} Werktage innerhalb Deutschlands +

+ } + className="h-full" + /> +
+ ))}