Files
kaiser-natron/src/pages/ShopPage.vue
Dorian ab0f7da50d feat: crimson CTA accents, white surfaces, Zeitung font, diagonal dividers
- Add --color-accent-fill (#cc0230) / -hover / -ink tokens; repoint all
  solid yellow "button" surfaces (Button accent, IconButton accent,
  language-selector active pill, Navbar cart CTA, Kaiserhacks chip,
  accent Badge) to crimson fill + white ink. Yellow --color-accent kept
  for functional text accents.
- Secondary button: transparent fill, white border, white text/icons,
  translucent-white hover (no longer mimics the primary default).
- Surfaces to pure white: --color-cream and --color-surface -> #ffffff
  (cream-toned logo follows via text-cream); theme-color -> #ffffff.
- Self-host Zeitung (Regular/Bold woff2, mirrored from production);
  point --font-sans/--font-serif at Zeitung; drop Google Fonts CDN.
- Remove yellow highlighting on hero/title emphasis and hero eyebrows
  (text-accent-soft -> text-cream, eyebrows -> text-cream/75).
- WaveDivider + homepage section dividers: straight diagonal (low-left,
  high-right) at double height for ~2x the right-to-left difference.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 09:47:06 +01:00

284 lines
11 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 {
products,
productsByUseCase,
USE_CASES,
fetchCart,
addToCart,
updateCartItem,
removeFromCart,
} from '@/api/index.js'
import { useCartStore } from '@/stores/cart.js'
import { useI18n } from '@/i18n/index.js'
const { t } = useI18n()
const cart = useCartStore()
const router = useRouter()
const cartOpen = ref(false)
// Cart drawer's checkout event lands here so the drawer can stay
// presentational; this page decides to close the drawer and route
// the user to /checkout.
function goCheckout() {
cartOpen.value = false
router.push('/checkout')
}
// Site-wide nav split mirrors HomePage so the header reads as one
// piece of chrome across both routes. On /shop the category links
// point at in-page anchors (`#cook`, `#clean`, `#care`) so they
// scroll within the page rather than re-route.
// Same top-left list as HomePage — Shop + supporting pages.
// Category shortcuts (Cook / Clean / Care) live in the Shop hero
// banner's three-tile row, not the global nav, so the chrome stays
// stable across home and shop.
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 = []
// Group products by use-case once; iterate a stable config below.
// The featured tiles in the top banner each pick the FIRST product
// in their group — a representative face for that band. The
// sections themselves display every product in the group.
const grouped = computed(() => productsByUseCase(products))
const featuredTiles = computed(() =>
USE_CASES.map((id) => ({
id,
label: t(`shop.feature.${id}`),
cta: t(`shop.feature.cta.${id}`),
product: grouped.value[id][0],
})),
)
const sections = computed(() =>
USE_CASES.map((id, i) => ({
id,
headline: t(`shop.section.${id}.headline`),
headlineEm: t(`shop.section.${id}.headline.em`),
sub: t(`shop.section.${id}.sub`),
products: grouped.value[id],
// Alternate tones so neighbouring sections read as distinct
// surfaces without a hard rule. Cook/Care land on cream;
// Clean sits on surface between them for contrast.
tone: i % 2 === 0 ? 'cream' : 'surface',
})),
)
async function onAdd(product) {
await addToCart(product.id, 1)
cartOpen.value = true
}
async function onSearchSelect(product) {
await addToCart(product.id, 1)
cartOpen.value = true
}
function scrollToSection(event, id) {
const target = document.getElementById(id)
if (!target) return
event.preventDefault()
target.scrollIntoView({ behavior: 'smooth', block: 'start' })
history.pushState(null, '', `#${id}`)
}
async function onQty({ productId, quantity }) {
await updateCartItem(productId, quantity)
}
async function onRemove(productId) {
await removeFromCart(productId)
}
// `--nav-h` is defaulted in global styles; this ResizeObserver refines
// it so deep-link anchor targets (e.g. `/shop#care`) land just below
// the sticky nav via each section's `scroll-mt` offset.
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"
/>
<!-- First-fold green banner full viewport height, pulled up under
the sticky nav so the hero content centers at the TRUE viewport
midpoint (matches the home page's first fold). The content is a
page-title band + three featured product tiles, one per use-case
section, with anchor links into the sections below. -->
<div
class="flex flex-col bg-brand text-cream md:min-h-[calc(100svh-var(--nav-h))] md:justify-center"
>
<div class="mx-auto w-full max-w-6xl px-6 py-12 sm:px-8 sm:py-16 md:px-12 md:py-12 lg:px-16 lg:py-16">
<!-- Title band. Mixed-font banner treatment (Hero-style): normal
display weight + italic light for the emphasis phrase. -->
<div class="flex flex-col items-center text-center gap-4 max-w-3xl mx-auto">
<h1 class="font-display font-normal leading-[1.05] tracking-tight text-cream text-headline-lg">
{{ t('shop.headline') }}
<em class="italic font-light text-cream">{{ t('shop.headline.em') }}</em>
</h1>
<p class="text-base md:text-lg leading-relaxed text-cream/80 max-w-2xl">
{{ t('shop.sub') }}
</p>
</div>
<!-- Three featured tiles — rule of 3. One representative product
per use-case, each tile is an anchor into its section. Uses
the cream-wash-on-brand container pattern from the
LanguageSwitcher's `brand` tone so tiles read as lifted off
the green ground without another heavy surface.
Image uses aspect-[4/3] (not aspect-square) with a svh-
relative height cap so the whole fold title + sub + three
tiles + padding stays inside 100svh at desktop heights
from ~720px up. Mobile stacks the tiles; the fold can
overflow below there because phones scroll naturally. -->
<ul class="mt-8 md:mt-10 grid gap-4 sm:gap-5 md:gap-6 grid-cols-1 sm:grid-cols-3">
<li v-for="tile in featuredTiles" :key="tile.id">
<a
:href="`#${tile.id}`"
class="group flex h-full flex-col items-center gap-4 p-4 md:p-5 rounded-md border border-cream-line bg-cream-wash hover:bg-cream-wash-strong transition-colors duration-base"
@click="scrollToSection($event, tile.id)"
>
<div class="w-full aspect-[4/3] max-h-[30svh] md:max-h-[26svh] lg:max-h-[28svh] rounded-md bg-paper overflow-hidden flex items-center justify-center">
<img
:src="tile.product.image"
:alt="tile.product.title"
loading="lazy"
decoding="async"
class="w-full h-full object-contain p-4 md:p-5"
/>
</div>
<div class="flex flex-col items-center text-center gap-1">
<p class="eyebrow text-cream/75">{{ tile.label }}</p>
<h3 class="font-display text-lg md:text-xl font-normal leading-tight text-cream">
{{ tile.product.title }}
</h3>
</div>
<span
class="mt-auto inline-flex items-center gap-1.5 text-sm font-semibold tracking-label text-cream/80 group-hover:text-accent transition-colors duration-base"
>
{{ tile.cta }}
<span aria-hidden="true" class="transition-transform duration-base group-hover:translate-x-1"></span>
</span>
</a>
</li>
</ul>
</div>
</div>
<!-- Three use-case sections. Each has its own banner-style mixed-font
heading (no eyebrow pill), a 3-up product grid capped at the
rule of 3 per row, and wave dividers on either side so the
page reads with the same rhythm as the home page. -->
<template v-for="(section, i) in sections" :key="section.id">
<!-- Wave into the section. Previous surface is `brand` for the
first divider (coming out of the green fold), then alternates
between cream/surface to match the previous section's tone. -->
<WaveDivider :from="i === 0 ? 'brand' : sections[i - 1].tone" :to="section.tone" />
<section
:id="section.id"
:class="[
'-mt-px scroll-mt-[calc(var(--nav-h)+1rem)]',
section.tone === 'cream' ? 'bg-cream text-ink' : '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 lg:py-28">
<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-lg">
{{ section.headline }}
<em class="italic font-light text-brand">{{ section.headlineEm }}</em>
</h2>
<p class="text-lg leading-relaxed text-muted max-w-2xl">
{{ section.sub }}
</p>
</div>
<div
v-if="section.products.length"
class="mt-12 md:mt-14 grid gap-5 md:gap-7 grid-cols-1 sm:grid-cols-2 md:grid-cols-3"
>
<!-- Add-to-cart variant alternates by section index so
the three bands read as a pattern: green yellow
green. Only the middle band uses the accent (yellow)
variant; first and last stay on primary (brand green).
`i % 2 === 1` keeps this rhythm working if more
sections are ever appended. -->
<ProductCard
v-for="product in section.products"
: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"
:cta-variant="i % 2 === 1 ? 'accent' : 'primary'"
@add="onAdd(product)"
/>
</div>
</div>
</section>
</template>
<Footer />
<!-- Bottom clearance for the mobile floating cluster (search / cart /
menu), same pattern as HomePage so both pages align visually.
Tied to the navbar's 1100 px collapse threshold. -->
<div
aria-hidden="true"
class="min-[1100px]: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>