bundle page: split desktop layout (wide image left, copy right)

Reverts the full-bleed background-banner desktop hero. Desktop uses
a 1.4fr / 1fr grid: image column gets the heavier ratio so the 16:9
landscape source has room for its full composition; copy + items +
purchase cluster sits in the right column. Back button stays in its
own row above the hero on every viewport — no overlay.

BundleCard now uses RouterLink for internal hrefs (was rendering a
plain <a>, which triggered a hard navigation and lost vue-router's
saved scroll position when the user hit back from a bundle page).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-05-04 10:52:49 +01:00
parent 6c0002ad93
commit 34dcec28fa
14 changed files with 78 additions and 37 deletions

View File

@@ -1,10 +1,19 @@
<script setup>
import { computed } from 'vue'
import { RouterLink } from 'vue-router'
import Button from './Button.vue'
import Badge from './Badge.vue'
import Icon from './Icon.vue'
import { useI18n } from '@/i18n/index.js'
// Internal SPA paths render as <RouterLink> so vue-router's saved
// scroll position is restored when the user hits back from the
// detail page. External / hash / mailto links keep the plain <a>
// behaviour. Mirrors the ProductCard pattern.
function isInternalPath(href) {
return typeof href === 'string' && href.startsWith('/') && !href.startsWith('//')
}
const props = defineProps({
name: { type: String, required: true },
// Each item is a short label like "1× Kaiser-Natron Pulver 250g".
@@ -89,8 +98,39 @@ const extraCount = computed(() => Math.max(0, props.items.length - MAX_ITEMS))
>
<!-- Media. In horizontal mode the media column is a tighter ~38% of the
row from md up and drops its mobile aspect ratio so flex-stretch
can match the body height. -->
can match the body height. Internal SPA paths use RouterLink
so back-navigation restores the home grid's scroll position. -->
<RouterLink
v-if="href && isInternalPath(href)"
:to="href"
:class="[
'relative block overflow-hidden',
layout === 'horizontal'
? 'aspect-[4/3] md:aspect-auto md:w-[38%] md:shrink-0 md:min-h-[300px]'
: 'aspect-[4/3]',
tone.media,
]"
>
<Badge
v-if="badge"
:variant="badgeVariant"
class="absolute top-4 left-4 z-[1]"
>{{ badge }}</Badge>
<img
:src="image"
:alt="imageAlt || name"
loading="lazy"
decoding="async"
:class="[
'absolute inset-0 w-full h-full transition-transform duration-slow ease-out group-hover:scale-105',
imageFit === 'cover'
? 'object-cover'
: 'object-contain ' + (layout === 'horizontal' ? 'p-6 md:p-5' : 'p-8'),
]"
/>
</RouterLink>
<component
v-else
:is="href ? 'a' : 'div'"
:href="href || null"
:class="[
@@ -132,7 +172,13 @@ const extraCount = computed(() => Math.max(0, props.items.length - MAX_ITEMS))
v-if="usage"
class="text-xs font-semibold tracking-label text-muted uppercase"
>{{ usage }}</span>
<RouterLink
v-if="href && isInternalPath(href)"
:to="href"
class="font-display text-xl font-normal leading-tight text-ink hover:text-brand transition-colors duration-base"
>{{ name }}</RouterLink>
<component
v-else
:is="href ? 'a' : 'h3'"
:href="href || null"
:class="[

View File

@@ -182,10 +182,8 @@ onBeforeUnmount(() => {
</main>
<main v-else class="bg-brand text-cream">
<!-- Mobile-only back button row. On desktop the back button is
absolutely positioned over the hero image (see below) so the
layout doesn't waste a row of brand-green above the artwork. -->
<div class="lg:hidden mx-auto w-full max-w-7xl px-6 md:px-10 pt-6">
<!-- Back button row sits above the hero on every viewport. -->
<div class="mx-auto w-full max-w-7xl px-6 md:px-10 lg:px-16 pt-6">
<button
type="button"
class="inline-flex items-center gap-2 text-sm tracking-label uppercase text-cream/75 hover:text-cream transition-colors"
@@ -197,11 +195,11 @@ onBeforeUnmount(() => {
</div>
<!-- =========================================================
DESKTOP (lg+) — bundle image as a full-bleed hero background;
all copy + purchase actions overlay on the right side. The
landscape source art (≈ 16:9) drives the fold; a left → right
brand-green gradient softens the right edge so the cream copy
stays legible regardless of what the image carries underneath.
DESKTOP (lg+) bundle image as a full-bleed hero background
filling the fold. Heavy left right brand-green gradient
keeps the cream copy on the right legible without painting
an opaque sidebar over the artwork. Back button overlays the
top-left corner so we don't waste a row of green above.
========================================================= -->
<section
class="hidden lg:block relative overflow-hidden min-h-[calc(100svh-var(--nav-h))]"
@@ -213,15 +211,13 @@ onBeforeUnmount(() => {
decoding="async"
class="absolute inset-0 w-full h-full object-cover"
/>
<!-- Legibility gradient — heavy on the right so the cream
copy reads cleanly regardless of the underlying photo.
Stops: image fully visible until 25 %, ramps to opaque
brand-green by 70 %, solid green from there to the right
edge. Adjust the second / third stops to soften or
sharpen the transition. -->
<!-- Severe legibility gradient — image stays clean for the
first ~20%, then ramps fast to opaque brand-green by the
midpoint, with the right half fully solid so the copy
reads as if it were on the brand surface. -->
<div
aria-hidden="true"
class="absolute inset-0 bg-gradient-to-r from-brand/0 from-25% via-brand/90 via-65% to-brand to-80%"
class="absolute inset-0 bg-gradient-to-r from-brand/0 from-20% via-brand via-50% to-brand"
/>
<Badge
@@ -230,9 +226,8 @@ onBeforeUnmount(() => {
class="absolute top-6 left-6 z-[1] shadow-sm"
>{{ bundle.badge }}</Badge>
<!-- Back button overlaid on the hero (top-left corner of the
contained max-width column so it lines up with the rest
of the desktop chrome). -->
<!-- Back button overlaid on the hero, lined up with the
contained max-width column. -->
<div class="absolute top-6 left-0 right-0 z-10 mx-auto w-full max-w-7xl px-10 lg:px-16">
<button
type="button"
@@ -244,12 +239,12 @@ onBeforeUnmount(() => {
</button>
</div>
<!-- Foreground copy + purchase cluster, pinned to the right
half of the section and vertically centred in the fold. -->
<!-- Foreground copy + purchase cluster on the right, vertically
centred in the fold. -->
<div class="relative z-10 mx-auto w-full max-w-7xl h-full px-10 lg:px-16">
<div class="flex h-full min-h-[calc(100svh-var(--nav-h))] items-center justify-end">
<div class="w-full max-w-md xl:max-w-lg flex flex-col gap-6 text-cream">
<p v-if="bundle.usage" class="text-xs tracking-label uppercase text-cream/80">{{ bundle.usage }}</p>
<p v-if="bundle.usage" class="text-xs tracking-label uppercase text-cream/85">{{ bundle.usage }}</p>
<h1 class="font-display font-normal leading-[1.05] tracking-tight text-cream text-[2.5rem] xl:text-[3rem] 2xl:text-[3.5rem]">
{{ bundle.name }}
</h1>
@@ -258,7 +253,7 @@ onBeforeUnmount(() => {
</p>
<div class="flex flex-col gap-2">
<p class="text-xs tracking-label uppercase text-cream/80">{{ t('bundle.items') }}</p>
<p class="text-xs tracking-label uppercase text-cream/85">{{ t('bundle.items') }}</p>
<ul class="flex flex-col gap-1.5">
<li
v-for="(item, i) in resolvedItems"
@@ -278,7 +273,7 @@ onBeforeUnmount(() => {
<div class="flex flex-col gap-1">
<span class="font-display text-3xl xl:text-4xl text-cream">{{ priceLabel }}</span>
<span v-if="memberPriceLabel" class="text-sm text-cream/80">
<span v-if="memberPriceLabel" class="text-sm text-cream/85">
{{ t('bundle.memberPrice') }}
<span class="text-accent font-medium">{{ memberPriceLabel }}</span>
</span>