feat(selection): lock single-option boxes, drop the about link
build / build (push) Successful in 1m38s
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:
@@ -16,7 +16,7 @@
|
|||||||
Datenschutzerklärung
|
Datenschutzerklärung
|
||||||
</Button>
|
</Button>
|
||||||
<Button @click="acceptCookies" data-cy="cookie-banner-accept" classes="bg-gray-800 py-1.5 px-4 !text-xs whitespace-nowrap">
|
<Button @click="acceptCookies" data-cy="cookie-banner-accept" classes="bg-gray-800 py-1.5 px-4 !text-xs whitespace-nowrap">
|
||||||
Akzeptieren
|
Schließen
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -45,12 +45,6 @@
|
|||||||
:path="`/notebooks/${item.slug}`"
|
:path="`/notebooks/${item.slug}`"
|
||||||
@click="trackEvent('header-item-clicked', { label: item.label })"
|
@click="trackEvent('header-item-clicked', { label: item.label })"
|
||||||
/>
|
/>
|
||||||
<HeaderNavLink
|
|
||||||
label="Über uns"
|
|
||||||
description="Über MUELLERPRINTS"
|
|
||||||
path="/about"
|
|
||||||
@click="trackEvent('header-item-clicked', { label: 'Über uns' })"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/cart"
|
to="/cart"
|
||||||
|
|||||||
@@ -64,11 +64,9 @@ const props = withDefaults(defineProps<{
|
|||||||
});
|
});
|
||||||
|
|
||||||
const shadowColors = computed(() => {
|
const shadowColors = computed(() => {
|
||||||
return (
|
return props.product?.pattern
|
||||||
props.product?.pattern && [
|
? [randomTailwindColor(props.product.pattern.id, "group-hover:shadow", 200, "/60")]
|
||||||
randomTailwindColor(props.product.pattern.id, "group-hover:shadow", 200, "/60")
|
: [];
|
||||||
]
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const hasProductOptions = computed(() => {
|
const hasProductOptions = computed(() => {
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<NuxtLink
|
<component
|
||||||
:to="path"
|
:is="locked ? 'div' : NuxtLink"
|
||||||
|
:to="locked ? undefined : path"
|
||||||
:title="ariaLabel"
|
:title="ariaLabel"
|
||||||
:aria-label="ariaLabel"
|
:aria-label="ariaLabel"
|
||||||
:aria-checked="isActive"
|
:aria-checked="isActive || locked"
|
||||||
:class="{
|
:class="{
|
||||||
'border-black bg-white': isActive,
|
'border-black bg-white': isActive || locked,
|
||||||
'border-transparent cursor-pointer bg-gray-100 opacity-80 hover:opacity-100 hover:border-gray-400 pointer': !isActive,
|
'cursor-default': locked,
|
||||||
|
'border-transparent cursor-pointer bg-gray-100 opacity-80 hover:opacity-100 hover:border-gray-400 pointer': !isActive && !locked,
|
||||||
'opacity-30 cursor-not-allowed pointer-events-none': ariaDisabled
|
'opacity-30 cursor-not-allowed pointer-events-none': ariaDisabled
|
||||||
}"
|
}"
|
||||||
class="flex flex-col items-center gap-y-4 rounded-md border-2 border-spacing-2 pt-2 pb-2 px-2 transition-all"
|
class="flex flex-col items-center gap-y-4 rounded-md border-2 border-spacing-2 pt-2 pb-2 px-2 transition-all"
|
||||||
@@ -17,20 +19,24 @@
|
|||||||
<div
|
<div
|
||||||
class="text-sm text-gray-800 pointer-events-none"
|
class="text-sm text-gray-800 pointer-events-none"
|
||||||
:class="{
|
:class="{
|
||||||
'font-semibold': isActive
|
'font-semibold': isActive || locked
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
{{ label }}
|
{{ label }}
|
||||||
</div>
|
</div>
|
||||||
</NuxtLink>
|
</component>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { NuxtLink } from "#components";
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
label: string;
|
label: string;
|
||||||
path: string;
|
path: string;
|
||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
ariaLabel?: string;
|
ariaLabel?: string;
|
||||||
ariaDisabled?: boolean;
|
ariaDisabled?: boolean;
|
||||||
|
// Pre-selected, non-interactive (single-option / fixed products)
|
||||||
|
locked?: boolean;
|
||||||
}>();
|
}>();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Product content keyed by cover type
|
// Product content keyed by cover type
|
||||||
// This content is displayed on product detail pages as storytelling modules
|
// This content is displayed on product detail pages as storytelling modules
|
||||||
|
|
||||||
export type CoverType = "Hardcover" | "Softcover" | "Heft" | "Spiralbindung";
|
export type CoverType = "Hardcover" | "Softcover" | "Heft" | "Spiralbindung" | "Promo";
|
||||||
|
|
||||||
export interface FeatureModule {
|
export interface FeatureModule {
|
||||||
eyebrow?: string;
|
eyebrow?: string;
|
||||||
@@ -199,6 +199,44 @@ export const FEATURE_MODULES: Record<CoverType, FeatureModule[]> = {
|
|||||||
imageRight: true,
|
imageRight: true,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
||||||
|
Promo: [
|
||||||
|
{
|
||||||
|
eyebrow: "12 Wochen · 3 Monate",
|
||||||
|
headline: "Struktur für deinen Alltag",
|
||||||
|
subtitle: "Planung, Intention & Reflexion",
|
||||||
|
body: `Dieses Journaling Booklet im Format A5 sorgt für mehr Produktivität im Alltag.
|
||||||
|
Es beinhaltet Monats-, Wochen- und Tagesplanungen sowie inspirierenden Zitaten
|
||||||
|
für insgesamt 12 Wochen bzw. 3 Monate. Jeder Tag bietet Platz für eine
|
||||||
|
Tagesintention, den täglichen Aufgaben, dem Notieren von Mahlzeiten und der
|
||||||
|
Fitnessroutine. Darüber hinaus bietet es die Möglichkeit der eigene
|
||||||
|
Tagesreflexion. Als tägliche Begleiter soll es helfen, deine Zeit effektiv zu
|
||||||
|
managen, um mit mehr Klarheit und Struktur deine Ziele anzugehen.`,
|
||||||
|
imageRight: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eyebrow: "Gestaltet von mk rietzl",
|
||||||
|
headline: "VIVUS 89 Recyclingpapier",
|
||||||
|
subtitle: "Matt, 120g/qm, fliederfarbener Umschlag",
|
||||||
|
body: `Das Journaling Booklet wurde konzipiert und gestaltet von Michèle Rietzl
|
||||||
|
(mk rietzl). Es ist auf VIVUS 89 Recyclingpapier gedruckt. Die matte,
|
||||||
|
ungestrichene Oberfläche mit feiner Textur verhindert Durchschlagen – selbst bei
|
||||||
|
Füllfederhaltern und Aquarellfarben. Mit 120g/qm und 1,2-fachem Volumen fühlt
|
||||||
|
sich jede Seite substanziell an, ohne starr zu wirken. Der fliederfarbene
|
||||||
|
Umschlag sorgt für schlichte Farbakzente.`,
|
||||||
|
imageRight: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eyebrow: "Handgefertigt in Stuttgart",
|
||||||
|
headline: "Fadenbindung mit offenem Buchrücken",
|
||||||
|
subtitle: "Vollständiges Aufklappen zum Schreiben",
|
||||||
|
body: `Bei der Bindung handelt es sich um eine klassische Fadenbindung mit offenem
|
||||||
|
Buchrücken. Dies ermöglicht ein vollständiges Aufklappen, was sich ideal zum
|
||||||
|
Schreiben eignet. Die Booklets werden mit größter Sorgfalt in Stuttgart
|
||||||
|
produziert. Jedes Exemplar ist handgefertigt.`,
|
||||||
|
imageRight: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -244,6 +282,15 @@ export const TECHNICAL_SPECS: Record<CoverType, Record<string, string>> = {
|
|||||||
Zertifizierung: "FSC® C018175, Blauer Engel, EU Ecolabel",
|
Zertifizierung: "FSC® C018175, Blauer Engel, EU Ecolabel",
|
||||||
Herstellung: "Stuttgart, Deutschland",
|
Herstellung: "Stuttgart, Deutschland",
|
||||||
},
|
},
|
||||||
|
Promo: {
|
||||||
|
Format: "150 × 210 mm (A5)",
|
||||||
|
Umfang: "12 Wochen / 3 Monate",
|
||||||
|
Layout: "Vorgedruckt (Tages-, Wochen- & Monatsplanung)",
|
||||||
|
Papier: "VIVUS 89, 120g/qm, 1,2-faches Volumen, ungestrichen matt",
|
||||||
|
Einband: "Fliederfarbener Softcover",
|
||||||
|
Bindung: "Fadenbindung mit offenem Buchrücken",
|
||||||
|
Herstellung: "Handgefertigt in Stuttgart",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -284,6 +331,8 @@ export function getCoverType(coverName?: string): CoverType {
|
|||||||
if (name.includes("hardcover")) return "Hardcover";
|
if (name.includes("hardcover")) return "Hardcover";
|
||||||
if (name.includes("softcover")) return "Softcover";
|
if (name.includes("softcover")) return "Softcover";
|
||||||
if (name.includes("spiral") || name.includes("wire")) return "Spiralbindung";
|
if (name.includes("spiral") || name.includes("wire")) return "Spiralbindung";
|
||||||
|
// Standalone promo bucket (cover named "Promo").
|
||||||
|
if (name.includes("promo")) return "Promo";
|
||||||
return "Hardcover"; // fallback
|
return "Hardcover"; // fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -314,10 +363,10 @@ export function useProductContent(
|
|||||||
derivedSpecs["Format"] = prod.cover.copyText.format;
|
derivedSpecs["Format"] = prod.cover.copyText.format;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Seitenanzahl from pages
|
// Seitenanzahl from pages (skip for Promo/standalone — page count not relevant)
|
||||||
if (prod.pages?.name) {
|
if (prod.pages?.name && coverType.value !== "Promo") {
|
||||||
derivedSpecs["Seitenanzahl"] = prod.pages.name;
|
derivedSpecs["Seitenanzahl"] = prod.pages.name;
|
||||||
} else if (prod.pages?.count) {
|
} else if (prod.pages?.count && coverType.value !== "Promo") {
|
||||||
const sheets = Math.floor(prod.pages.count / 2);
|
const sheets = Math.floor(prod.pages.count / 2);
|
||||||
derivedSpecs["Seitenanzahl"] = `${prod.pages.count} Seiten (${sheets} Blatt)`;
|
derivedSpecs["Seitenanzahl"] = `${prod.pages.count} Seiten (${sheets} Blatt)`;
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-1
@@ -45,7 +45,10 @@ export default defineNuxtConfig({
|
|||||||
routeRules: {
|
routeRules: {
|
||||||
// Cart & Checkout: Client-only (no SSR)
|
// Cart & Checkout: Client-only (no SSR)
|
||||||
"/cart": { ssr: false },
|
"/cart": { ssr: false },
|
||||||
"/checkout/**": { ssr: false }
|
"/checkout/**": { ssr: false },
|
||||||
|
// Local dev: proxy CMS media. In prod nginx routes /uploads → cms;
|
||||||
|
// without it the browser would hit the Nuxt origin and 404.
|
||||||
|
"/uploads/**": { proxy: "http://cms:5555/uploads/**" }
|
||||||
// All other pages use default SSR
|
// All other pages use default SSR
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
+41
-31
@@ -77,48 +77,40 @@
|
|||||||
{{ product?.cover?.copyText?.paper }}
|
{{ product?.cover?.copyText?.paper }}
|
||||||
</p>
|
</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="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">
|
<div class="rounded-lg bg-white bg-opacity-100 shadow-md mb-16 relative">
|
||||||
<!-- Pages Variant Selection -->
|
<!-- Pages Variant Selection (locked chip when only one option) -->
|
||||||
<section
|
<section v-if="productVariantsPages?.length" class="flex flex-col gap-3 py-6 px-6 xl:px-8">
|
||||||
v-if="productVariantsPages?.length"
|
<h2 class="tracking-tight opacity-70 text-lg font-semibold">Seiten</h2>
|
||||||
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"
|
<div class="flex flex-wrap gap-3">
|
||||||
>
|
|
||||||
<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">
|
|
||||||
<SelectionBox
|
<SelectionBox
|
||||||
v-for="pages in productVariantsPages"
|
v-for="pages in productVariantsPages"
|
||||||
:key="pages.id"
|
:key="pages.id"
|
||||||
:label="pages.name"
|
:label="pages.name"
|
||||||
:path="pages.productSlug ? `/details/${pages.productSlug}` : ''"
|
:path="pages.productSlug ? `/details/${pages.productSlug}` : ''"
|
||||||
:is-active="pages.active"
|
:is-active="pages.active"
|
||||||
|
:locked="productVariantsPages.length === 1"
|
||||||
:aria-disabled="isAddingToCart || isChangingVariant"
|
:aria-disabled="isAddingToCart || isChangingVariant"
|
||||||
@click="handleVariantClick('pages', pages)"
|
@click="productVariantsPages.length > 1 && handleVariantClick('pages', pages)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<hr v-if="productVariantsPages?.length" class="border-t-[1px] border-black opacity-40" />
|
<hr v-if="productVariantsPages?.length" class="border-t-[1px] border-black opacity-40" />
|
||||||
|
|
||||||
<!-- Ruling Variant Selection -->
|
<!-- Ruling Variant Selection -->
|
||||||
<section
|
<section v-if="productVariantsRuling?.length" class="flex flex-col gap-3 py-6 px-6 xl:px-8">
|
||||||
v-if="productVariantsRuling?.length"
|
<h2 class="tracking-tight opacity-70 text-lg font-semibold">Layout</h2>
|
||||||
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"
|
<div class="flex flex-wrap gap-3">
|
||||||
>
|
|
||||||
<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">
|
|
||||||
<SelectionBox
|
<SelectionBox
|
||||||
v-for="ruling in productVariantsRuling"
|
v-for="ruling in productVariantsRuling"
|
||||||
:key="ruling.id"
|
:key="ruling.id"
|
||||||
:label="ruling.name"
|
:label="ruling.name"
|
||||||
:path="ruling.productSlug ? `/details/${ruling.productSlug}` : ''"
|
:path="ruling.productSlug ? `/details/${ruling.productSlug}` : ''"
|
||||||
:is-active="ruling.active"
|
:is-active="ruling.active"
|
||||||
|
:locked="productVariantsRuling.length === 1"
|
||||||
:aria-disabled="isAddingToCart || isChangingVariant"
|
: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" />
|
<img v-if="ruling.iconUrl" :alt="ruling.name" :src="ruling.iconUrl" />
|
||||||
</SelectionBox>
|
</SelectionBox>
|
||||||
@@ -126,15 +118,10 @@
|
|||||||
</section>
|
</section>
|
||||||
<hr v-if="productVariantsRuling?.length" class="border-t-[1px] border-black opacity-40" />
|
<hr v-if="productVariantsRuling?.length" class="border-t-[1px] border-black opacity-40" />
|
||||||
|
|
||||||
<!-- Cover Variant Selection -->
|
<!-- Cover Variant Selection (only when a real choice exists) -->
|
||||||
<section
|
<section v-if="productVariantsCover?.length > 1" class="flex flex-col gap-3 py-6 px-6 xl:px-8">
|
||||||
v-if="productVariantsCover?.length"
|
<h2 class="tracking-tight opacity-70 text-lg font-semibold">Einband</h2>
|
||||||
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"
|
<div class="flex flex-wrap gap-3">
|
||||||
>
|
|
||||||
<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">
|
|
||||||
<SelectionBox
|
<SelectionBox
|
||||||
v-for="cover in productVariantsCover"
|
v-for="cover in productVariantsCover"
|
||||||
:key="cover.id"
|
:key="cover.id"
|
||||||
@@ -148,7 +135,18 @@
|
|||||||
</SelectionBox>
|
</SelectionBox>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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 -->
|
<!-- 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">
|
<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>
|
</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">
|
<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" />
|
<img :src="productImage.formats?.medium?.url" :alt="`Overhead product shot of MUELLERPRINTS. Paperwork ${product?.name}`" class="object-contain" />
|
||||||
</a>
|
</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
|
// Testimonials data
|
||||||
const testimonials: Testimonial[] = [
|
const testimonials: Testimonial[] = [
|
||||||
{
|
{
|
||||||
|
|||||||
+55
-13
@@ -49,23 +49,32 @@
|
|||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- CTA Section -->
|
<!-- Promo bands for standalone products (e.g. Journaling Booklet) -->
|
||||||
<section class="py-16 bg-gray-900 text-white">
|
<section
|
||||||
<div class="container mx-auto px-6 text-center">
|
v-for="promo in promoCovers"
|
||||||
<h3 class="text-3xl lg:text-4xl font-bebas mb-4">Premium Notizbücher für kreative Köpfe</h3>
|
:key="promo.id"
|
||||||
<p class="text-lg opacity-80 max-w-2xl mx-auto mb-8">
|
class="py-16 lg:py-24 bg-gradient-to-br from-violet-50 via-white to-rose-50"
|
||||||
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
|
<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>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
||||||
<!-- Testimonials (hidden for now)
|
<!-- Testimonials (hidden for now)
|
||||||
<section v-if="testimonials?.length > 0" class="px-6 lg:container mx-auto mt-20 mb-32">
|
<section v-if="testimonials?.length > 0" class="px-6 lg:container mx-auto mt-20 mb-32">
|
||||||
<div class="text-center mb-10">
|
<div class="text-center mb-10">
|
||||||
@@ -99,8 +108,11 @@ const shopApi = useShopApi();
|
|||||||
const { data: coversData } = await useAsyncData("landing-covers", async () => {
|
const { data: coversData } = await useAsyncData("landing-covers", async () => {
|
||||||
try {
|
try {
|
||||||
const { data } = await shopApi.getProductCovers();
|
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(
|
const covers = await Promise.all(
|
||||||
data.map(async (cover: any) => {
|
sellable.map(async (cover: any) => {
|
||||||
const productsResponse = await shopApi.getCheapestProducts(cover.id, 1, 10);
|
const productsResponse = await shopApi.getCheapestProducts(cover.id, 1, 10);
|
||||||
return {
|
return {
|
||||||
id: cover.id,
|
id: cover.id,
|
||||||
@@ -122,6 +134,36 @@ const { data: coversData } = await useAsyncData("landing-covers", async () => {
|
|||||||
|
|
||||||
const covers = computed(() => coversData.value ?? []);
|
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
|
// Testimonials
|
||||||
const testimonials: Testimonial[] = [
|
const testimonials: Testimonial[] = [
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user