Files
kaiser-natron/src/pages/CategoryPage.vue
Dorian 15c951c2a0 feat: shop 4-section category banners + brand-feedback pass
Shop page rebuilt into four brand colour-coded use-group sections (Küche/lime,
Reinigung/grapefruit, Wäsche/plum, Pflege/care/orange): colour banners with diagonal
dividers, per-section grid titles, crimson 'add to cart' + white-outline 'learn more'
CTAs, plain-name eyebrows, first-fold halved + thin white band. Split old Haushalt
group into clean+wash.

Also in this checkpoint (brand-owner feedback items):
- L11: Kaiser-Natron® on every visible mention, hyphenated (de+en, products, logo, nav)
- L4: product-hero headline reworded to the brand versatility voice
- L5/L6: faint 'AI Edited' badge on AI bundle images (data-driven aiEdited flag)
- L10: Revitalization animation row + early-access CTA removed
- U2: membership 'become a member' button removed
- New category tokens (cat-kitchen/clean/wash/care) + Hero/WaveDivider tones
- New /kueche category page (route + copy) — not yet linked (see STYLE-CHANGES caveat)
- Homepage reverted to original ProductTeaser (banners belong on /shop)

dist/ rebuilt and included for the Portainer/gitea deploy. Open decisions tracked
in STYLE-CHANGES.md §20 and memory.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 12:35:52 +01:00

246 lines
7.7 KiB
Vue

<script setup>
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import Navbar from '@/design-system/components/Navbar.vue'
import ProductCard from '@/design-system/components/ProductCard.vue'
import WaveDivider from '@/design-system/components/WaveDivider.vue'
import Footer from '@/design-system/components/Footer.vue'
import CartDrawer from '@/design-system/components/CartDrawer.vue'
import Button from '@/design-system/components/Button.vue'
import {
products,
productsByUseCase,
fetchCart,
addToCart,
updateCartItem,
removeFromCart,
} from '@/api/index.js'
import { useCartStore } from '@/stores/cart.js'
import { useI18n } from '@/i18n/index.js'
const props = defineProps({
/** Slug used for i18n keys and as the page identifier. */
slug: {
type: String,
required: true,
validator: (v) => ['pflege', 'haushalt', 'kueche'].includes(v),
},
/** Maps to the existing use-case grouping (cook / clean / care). */
useCase: {
type: String,
required: true,
validator: (v) => ['cook', 'clean', 'care'].includes(v),
},
})
const { t } = useI18n()
const cart = useCartStore()
const router = useRouter()
const cartOpen = ref(false)
function goCheckout() {
cartOpen.value = false
router.push('/checkout')
}
const navItems = [
{ key: 'nav.shop', href: '/shop' },
{ key: 'nav.bundles', href: '/#bundles' },
{ key: 'nav.revitalization', href: '/#revitalize' },
{ key: 'nav.about', href: '/#about' },
{ key: 'nav.kaiserhacks', href: '/kaiserhacks' },
]
const navSecondaryItems = []
const categoryProducts = computed(() => productsByUseCase(products)[props.useCase] || [])
const benefits = computed(() => [1, 2, 3].map((i) => ({
title: t(`category.${props.slug}.benefit.${i}.title`),
text: t(`category.${props.slug}.benefit.${i}.text`),
})))
async function onAdd(product) {
await addToCart(product.id, 1)
cartOpen.value = true
}
async function onSearchSelect(product) {
await addToCart(product.id, 1)
cartOpen.value = true
}
async function onQty({ productId, quantity }) {
await updateCartItem(productId, quantity)
}
async function onRemove(productId) {
await removeFromCart(productId)
}
const navRef = ref(null)
let navResizeObserver = null
function syncNavHeight() {
const el = navRef.value
const node = el && (el.$el || el)
if (!node || typeof window === 'undefined') return
const h = Math.round(node.getBoundingClientRect().height)
document.documentElement.style.setProperty('--nav-h', `${h}px`)
}
onMounted(() => {
fetchCart()
syncNavHeight()
if (typeof ResizeObserver !== 'undefined' && navRef.value) {
const node = navRef.value.$el || navRef.value
navResizeObserver = new ResizeObserver(syncNavHeight)
navResizeObserver.observe(node)
}
window.addEventListener('resize', syncNavHeight)
})
onBeforeUnmount(() => {
if (navResizeObserver) navResizeObserver.disconnect()
if (typeof window !== 'undefined') window.removeEventListener('resize', syncNavHeight)
})
</script>
<template>
<Navbar
ref="navRef"
variant="brand"
layout="standard"
:items="navItems"
:secondary-items="navSecondaryItems"
:cart-count="cart.count"
:products="products"
@cart="cartOpen = true"
@search="onSearchSelect"
/>
<section
class="bg-brand text-cream md:min-h-[calc(70svh-var(--nav-h))] md:flex md:items-center"
>
<div
class="mx-auto w-full max-w-4xl px-6 py-16 sm:px-8 sm:py-20 md:px-12 md:py-24 lg:px-16 text-center flex flex-col gap-5"
>
<p class="eyebrow text-cream/75">{{ t(`category.${slug}.eyebrow`) }}</p>
<h1
class="font-display font-normal leading-[1.05] tracking-tight text-cream text-headline-lg"
>
{{ t(`category.${slug}.title.a`) }}
<em class="italic font-light text-cream">{{ t(`category.${slug}.title.em`) }}</em>
</h1>
<p class="text-base md:text-lg leading-relaxed text-cream/80 max-w-2xl mx-auto">
{{ t(`category.${slug}.sub`) }}
</p>
</div>
</section>
<WaveDivider from="brand" to="cream" />
<section class="-mt-px bg-cream text-ink">
<div
class="mx-auto w-full max-w-6xl px-6 py-16 sm:px-8 sm:py-20 md:px-12 md:py-24 lg:px-16"
>
<div class="flex flex-col gap-4 max-w-3xl">
<h2
class="font-display font-normal leading-[1.05] tracking-tight text-ink text-headline-md"
>
{{ t(`category.${slug}.products.headline`) }}
<em class="italic font-light text-brand">{{ t(`category.${slug}.products.headline.em`) }}</em>
</h2>
<p class="text-lg leading-relaxed text-muted max-w-2xl">
{{ t(`category.${slug}.products.sub`) }}
</p>
</div>
<div
v-if="categoryProducts.length"
class="mt-12 md:mt-14 grid gap-5 md:gap-7 grid-cols-1 sm:grid-cols-2 md:grid-cols-3"
>
<ProductCard
v-for="product in categoryProducts"
:key="product.id"
:title="product.title"
:size="product.size"
:price="product.price"
:image="product.image"
:image-alt="product.title"
:href="product.href"
tone="cream"
:in-stock="product.inStock"
@add="onAdd(product)"
/>
</div>
</div>
</section>
<WaveDivider from="cream" to="surface" />
<section class="-mt-px bg-surface text-ink">
<div
class="mx-auto w-full max-w-6xl px-6 py-16 sm:px-8 sm:py-20 md:px-12 md:py-24 lg:px-16"
>
<div class="flex flex-col gap-4 max-w-3xl">
<p class="eyebrow">{{ t(`category.${slug}.benefits.eyebrow`) }}</p>
<h2
class="font-display font-normal leading-[1.05] tracking-tight text-ink text-headline-md"
>
{{ t(`category.${slug}.benefits.headline`) }}
<em class="italic font-light text-brand">{{ t(`category.${slug}.benefits.headline.em`) }}</em>
</h2>
</div>
<ul class="mt-12 md:mt-14 grid gap-6 md:gap-8 grid-cols-1 sm:grid-cols-3">
<li
v-for="(benefit, i) in benefits"
:key="i"
class="flex flex-col gap-3 rounded-md border border-line bg-paper p-6 md:p-8"
>
<span
class="inline-flex items-center justify-center w-10 h-10 rounded-full bg-brand text-accent font-display text-base"
>{{ i + 1 }}</span>
<h3 class="font-display text-xl font-normal text-brand leading-tight">
{{ benefit.title }}
</h3>
<p class="text-[15px] leading-relaxed text-ink">{{ benefit.text }}</p>
</li>
</ul>
</div>
</section>
<WaveDivider from="surface" to="brand" />
<section class="-mt-px bg-brand text-cream">
<div
class="mx-auto w-full max-w-3xl px-6 py-16 sm:px-8 sm:py-20 md:px-12 md:py-24 lg:px-16 text-center flex flex-col items-center gap-5"
>
<h2
class="font-display font-normal leading-[1.05] tracking-tight text-cream text-headline-md"
>
{{ t(`category.${slug}.cta.headline`) }}
<em class="italic font-light text-cream">{{ t(`category.${slug}.cta.headline.em`) }}</em>
</h2>
<p class="text-base md:text-lg leading-relaxed text-cream/80 max-w-xl">
{{ t(`category.${slug}.cta.sub`) }}
</p>
<RouterLink to="/shop" class="inline-flex">
<Button variant="accent" size="lg">{{ t(`category.${slug}.cta.button`) }}</Button>
</RouterLink>
</div>
</section>
<Footer variant="cream" />
<div
aria-hidden="true"
class="md:hidden"
style="height: calc(100px + env(safe-area-inset-bottom));"
/>
<CartDrawer
v-model="cartOpen"
:items="cart.items"
:subtotal="cart.subtotal"
:count="cart.count"
@update-quantity="onQty"
@remove="onRemove"
@checkout="goCheckout"
/>
</template>