feat(selection): lock single-option boxes, drop the about link
build / build (push) Successful in 1m38s

Recovered work in progress that sat uncommitted since 2026-05: a
SelectionBox renders as a plain div instead of a NuxtLink when locked,
so a variant with only one choice reads as decided rather than
clickable, and it reports aria-checked in that state. useProductContent
gained the content lookups the details and index pages now use, the
cookie banner says Schließen instead of Akzeptieren, the Über uns
header entry is gone, and ProductCard's hover shadow no longer relies
on a short-circuit that could yield a non-array.
This commit is contained in:
2026-09-23 22:09:52 +02:00
parent e9cd3d9c20
commit 61a74e649b
8 changed files with 171 additions and 69 deletions
+41 -31
View File
@@ -77,48 +77,40 @@
{{ product?.cover?.copyText?.paper }}
</p>
<div class="flex flex-col-reverse lg:flex-row-reverse gap-4 xl:gap-12">
<div class="flex flex-col-reverse lg:flex-row-reverse lg:items-center gap-4 xl:gap-12">
<div class="lg:w-1/2 xl:w-2/5 xl:pt-8">
<div class="rounded-lg bg-white bg-opacity-100 shadow-md mb-16 relative">
<!-- Pages Variant Selection -->
<section
v-if="productVariantsPages?.length"
class="relative overflow-hidden flex flex-col xl:flex-row-reverse gap-4 xl:gap-12 py-6 px-6 xl:px-8 xl:pt-20"
>
<h2 class="hidden lg:block pointer-events-none absolute left-8 top-4 text-right xl:text-left pt-3 tracking-tight opacity-70 text-xl font-semibold">
Seiten
</h2>
<div class="flex gap-4 z-10">
<!-- Pages Variant Selection (locked chip when only one option) -->
<section v-if="productVariantsPages?.length" class="flex flex-col gap-3 py-6 px-6 xl:px-8">
<h2 class="tracking-tight opacity-70 text-lg font-semibold">Seiten</h2>
<div class="flex flex-wrap gap-3">
<SelectionBox
v-for="pages in productVariantsPages"
:key="pages.id"
:label="pages.name"
:path="pages.productSlug ? `/details/${pages.productSlug}` : ''"
:is-active="pages.active"
:locked="productVariantsPages.length === 1"
:aria-disabled="isAddingToCart || isChangingVariant"
@click="handleVariantClick('pages', pages)"
@click="productVariantsPages.length > 1 && handleVariantClick('pages', pages)"
/>
</div>
</section>
<hr v-if="productVariantsPages?.length" class="border-t-[1px] border-black opacity-40" />
<!-- Ruling Variant Selection -->
<section
v-if="productVariantsRuling?.length"
class="relative overflow-hidden flex flex-col xl:flex-row-reverse gap-4 xl:gap-12 py-6 px-6 xl:px-8 xl:pt-20"
>
<h2 class="hidden lg:block pointer-events-none absolute left-8 top-4 text-right xl:text-left pt-3 tracking-tight opacity-70 text-xl font-semibold">
Layout
</h2>
<div class="flex gap-4 z-10">
<section v-if="productVariantsRuling?.length" class="flex flex-col gap-3 py-6 px-6 xl:px-8">
<h2 class="tracking-tight opacity-70 text-lg font-semibold">Layout</h2>
<div class="flex flex-wrap gap-3">
<SelectionBox
v-for="ruling in productVariantsRuling"
:key="ruling.id"
:label="ruling.name"
:path="ruling.productSlug ? `/details/${ruling.productSlug}` : ''"
:is-active="ruling.active"
:locked="productVariantsRuling.length === 1"
:aria-disabled="isAddingToCart || isChangingVariant"
@click="handleVariantClick('ruling', ruling)"
@click="productVariantsRuling.length > 1 && handleVariantClick('ruling', ruling)"
>
<img v-if="ruling.iconUrl" :alt="ruling.name" :src="ruling.iconUrl" />
</SelectionBox>
@@ -126,15 +118,10 @@
</section>
<hr v-if="productVariantsRuling?.length" class="border-t-[1px] border-black opacity-40" />
<!-- Cover Variant Selection -->
<section
v-if="productVariantsCover?.length"
class="flex flex-col relative xl:flex-row-reverse gap-4 xl:gap-12 py-6 px-6 xl:px-8 xl:pt-20 overflow-hidden"
>
<h2 class="hidden lg:block pointer-events-none absolute left-8 top-4 text-right xl:text-left pt-3 tracking-tight opacity-70 text-xl font-semibold">
Einband
</h2>
<div class="flex gap-4 z-10">
<!-- Cover Variant Selection (only when a real choice exists) -->
<section v-if="productVariantsCover?.length > 1" class="flex flex-col gap-3 py-6 px-6 xl:px-8">
<h2 class="tracking-tight opacity-70 text-lg font-semibold">Einband</h2>
<div class="flex flex-wrap gap-3">
<SelectionBox
v-for="cover in productVariantsCover"
:key="cover.id"
@@ -148,7 +135,18 @@
</SelectionBox>
</div>
</section>
<hr v-if="productVariantsCover?.length" class="border-t-[1px] border-black opacity-40" />
<hr v-if="productVariantsCover?.length > 1" class="border-t-[1px] border-black opacity-40" />
<!-- Fixed product facts (e.g. Journaling Booklet) as pre-selected locked chips -->
<template v-for="fact in buyBoxFacts" :key="fact.label">
<section class="flex flex-col gap-3 py-6 px-6 xl:px-8">
<h2 class="tracking-tight opacity-70 text-lg font-semibold">{{ fact.label }}</h2>
<div class="flex flex-wrap gap-3">
<SelectionBox :label="fact.value" :path="''" :locked="true" :aria-label="`${fact.label}: ${fact.value}`" />
</div>
</section>
<hr class="border-t-[1px] border-black opacity-40" />
</template>
<!-- Price and Add to Cart -->
<section v-if="product?.totalProductPrice" class="flex flex-col lg:flex-row gap-6 py-6 px-6 lg:px-8 items-center">
@@ -186,7 +184,7 @@
</div>
</div>
<div class="flex flex-col items-center lg:w-1/2 xl:w-3/5 xl:pt-2 mx-auto">
<div class="flex flex-col items-center lg:w-1/2 xl:w-3/5 lg:py-12 xl:py-16 mx-auto">
<a v-if="productImage" :href="productImage.url" class="flex items-center justify-center mx-8" target="_blank" rel="noopener noreferrer">
<img :src="productImage.formats?.medium?.url" :alt="`Overhead product shot of MUELLERPRINTS. Paperwork ${product?.name}`" class="object-contain" />
</a>
@@ -421,6 +419,18 @@ const productVariantsPatterns = computed(() => {
}));
});
// Fixed/standalone products (no selectable pages & ruling, e.g. Journaling Booklet)
// surface their key specs as pre-selected locked chips in the buy box.
const buyBoxFacts = computed(() => {
if (productVariantsPages.value.length || productVariantsRuling.value.length) return [];
const s = (technicalSpecs.value ?? {}) as Record<string, string>;
const facts: { label: string; value: string }[] = [];
if (s.Umfang) facts.push({ label: "Umfang", value: s.Umfang });
if (s.Layout) facts.push({ label: "Layout", value: s.Layout });
if (s.Einband) facts.push({ label: "Einband", value: s.Bindung ? `${s.Einband} · ${s.Bindung}` : s.Einband });
return facts;
});
// Testimonials data
const testimonials: Testimonial[] = [
{
+56 -14
View File
@@ -49,23 +49,32 @@
</section>
</template>
<!-- CTA Section -->
<section class="py-16 bg-gray-900 text-white">
<div class="container mx-auto px-6 text-center">
<h3 class="text-3xl lg:text-4xl font-bebas mb-4">Premium Notizbücher für kreative Köpfe</h3>
<p class="text-lg opacity-80 max-w-2xl mx-auto mb-8">
Erfahre wie unsere handgefertigten Notizbücher aus recyceltem Papier hergestellt werden
</p>
<NuxtLink
to="/notebooks"
class="inline-block px-8 py-3 rounded-full font-medium bg-white text-gray-900 hover:bg-gray-200 transition-all duration-200"
@click="trackEvent('start-page-cta-clicked')"
>
Jetzt entdecken
<!-- Promo bands for standalone products (e.g. Journaling Booklet) -->
<section
v-for="promo in promoCovers"
:key="promo.id"
class="py-16 lg:py-24 bg-gradient-to-br from-violet-50 via-white to-rose-50"
>
<div class="container mx-auto px-6 flex flex-col-reverse lg:flex-row items-center gap-10 lg:gap-16">
<div class="lg:w-1/2">
<p class="text-sm uppercase tracking-widest opacity-60 mb-3">Neu im Sortiment</p>
<Heading :level="2" html-tag="h2">{{ promo.name }}</Heading>
<p v-if="promo.details" class="mt-4 text-lg leading-relaxed opacity-80 xl:w-5/6" v-html="promo.details" />
<NuxtLink
:to="`/details/${promo.productSlug}`"
class="mt-8 inline-block px-8 py-3 rounded-full font-medium bg-gray-900 text-white hover:bg-gray-700 transition-all duration-200"
@click="trackEvent('promo-cta-clicked', { product: promo.productSlug })"
>
{{ promo.name }} entdecken
</NuxtLink>
</div>
<NuxtLink :to="`/details/${promo.productSlug}`" class="lg:w-1/2 block">
<img v-if="promo.image" :src="promo.image" :alt="promo.name" loading="lazy" class="w-full rounded-lg shadow-xl" />
</NuxtLink>
</div>
</section>
<!-- Testimonials (hidden for now)
<section v-if="testimonials?.length > 0" class="px-6 lg:container mx-auto mt-20 mb-32">
<div class="text-center mb-10">
@@ -99,8 +108,11 @@ const shopApi = useShopApi();
const { data: coversData } = await useAsyncData("landing-covers", async () => {
try {
const { data } = await shopApi.getProductCovers();
// Standalone covers (e.g. Journaling Booklet) are excluded from the landing
// grid — they get a dedicated promo placement / direct link, not a row here.
const sellable = data.filter((cover: any) => !cover.attributes?.standalone);
const covers = await Promise.all(
data.map(async (cover: any) => {
sellable.map(async (cover: any) => {
const productsResponse = await shopApi.getCheapestProducts(cover.id, 1, 10);
return {
id: cover.id,
@@ -122,6 +134,36 @@ const { data: coversData } = await useAsyncData("landing-covers", async () => {
const covers = computed(() => coversData.value ?? []);
// Standalone covers (e.g. Journaling Booklet) → dedicated promo band with a
// direct link to the first product of that category. Link resolves by SLUG
// (never hardcoded ids — prod ids differ from dev).
const { data: promoData } = await useAsyncData("landing-promos", async () => {
try {
const { data } = await shopApi.getProductCovers();
const standalone = data.filter((c: any) => c.attributes?.standalone);
const promos = await Promise.all(
standalone.map(async (cover: any) => {
const productsResponse = await shopApi.getCheapestProducts(cover.id, 1, 1);
const first = productsResponse?.data?.[0] as any;
const img = first?.images?.images?.[0];
return {
id: cover.id,
name: first?.name ?? cover.attributes?.name,
details: cover.attributes?.copyText?.details,
sort: cover.attributes?.sort ?? 0,
productSlug: first?.slug,
image: img?.formats?.medium?.url ?? img?.formats?.small?.url ?? img?.url
};
})
);
return promos.filter((p) => p.productSlug).sort((a, b) => a.sort - b.sort);
} catch (error) {
console.error("Error fetching promos:", error);
return [];
}
});
const promoCovers = computed(() => promoData.value ?? []);
// Testimonials
const testimonials: Testimonial[] = [
{