- 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>
284 lines
11 KiB
Vue
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>
|