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.
607 lines
22 KiB
Vue
607 lines
22 KiB
Vue
<template>
|
|
<div v-if="displayState === 'loading'" class="pt-0">
|
|
<div class="flex items-center justify-center h-[60vh] mt-24">
|
|
<div class="text-center">
|
|
<LoadingSpinner />
|
|
<p class="text-2xl mt-8 font-semibold">Produktbeschreibung wird geladen...</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-else-if="displayState === 'not-found-error'" class="pt-0">
|
|
<div class="flex items-center justify-center h-[60vh] mt-24">
|
|
<div class="text-center">
|
|
<div class="w-12 h-12"></div>
|
|
<p class="text-2xl mt-8 font-semibold">Produkt nicht gefunden</p>
|
|
<p class="text-lg mt-6">
|
|
Vielleicht ist das Produkt aktuell nicht lieferbar. Falls du Auskunft erhalten möchtest,
|
|
<a href="/kontakt" class="underline">kontaktiere uns</a> und schicke diese Produkt-Nr. mit:
|
|
</p>
|
|
<pre class="mt-3 p-2 rounded-sm bg-gray-100">Produkt-Nr.: {{ slug }}</pre>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<Background
|
|
v-else-if="displayState === 'main-content'"
|
|
:coverId="product?.cover?.id"
|
|
:patternId="product?.pattern?.id"
|
|
:shade="200"
|
|
class="pt-0"
|
|
itemscope
|
|
:gradient="true"
|
|
itemtype="https://schema.org/Product"
|
|
>
|
|
<!-- Schema.org metadata -->
|
|
<meta itemprop="name" :content="product?.name" />
|
|
<meta itemprop="description" :content="getProductDescription()" />
|
|
<meta itemprop="image" :content="productImage?.url" />
|
|
<meta itemprop="sku" :content="String(product?.id)" />
|
|
<div itemprop="brand" itemscope itemtype="https://schema.org/Brand">
|
|
<meta itemprop="name" content="MUELLERPRINTS. Paperwork" />
|
|
<meta itemprop="image" content="https://muellerprints-paperwork.com/paperwork-logo.png" />
|
|
</div>
|
|
|
|
<!-- Aggregate Rating -->
|
|
<div v-if="aggregateRating" itemprop="aggregateRating" itemscope itemtype="https://schema.org/AggregateRating">
|
|
<meta itemprop="ratingValue" :content="aggregateRating.ratingValue" />
|
|
<meta itemprop="ratingCount" :content="aggregateRating.ratingCount" />
|
|
<meta itemprop="reviewCount" :content="aggregateRating.reviewCount" />
|
|
<meta itemprop="bestRating" content="5" />
|
|
<meta itemprop="worstRating" content="1" />
|
|
</div>
|
|
|
|
<!-- Reviews -->
|
|
<div v-for="(testimonial, index) in testimonials" :key="index" itemprop="review" itemscope itemtype="https://schema.org/Review">
|
|
<div itemprop="reviewRating" itemscope itemtype="https://schema.org/Rating">
|
|
<meta itemprop="ratingValue" :content="String(testimonial.rating)" />
|
|
<meta itemprop="worstRating" content="1" />
|
|
<meta itemprop="bestRating" content="5" />
|
|
</div>
|
|
<div itemprop="author" itemscope itemtype="https://schema.org/Person">
|
|
<meta itemprop="name" :content="testimonial.name ?? 'Unknown'" />
|
|
</div>
|
|
<meta itemprop="reviewBody" :content="testimonial.comment" />
|
|
</div>
|
|
|
|
<main class="pt-10 px-6 xl:container lg:mx-auto">
|
|
<div>
|
|
<p class="text-gray-800 text-lg">{{ product?.cover?.copyText?.format }}</p>
|
|
<Heading :level="1" html-tag="h1" classes="xl:w-2/3">
|
|
<span data-e2e="title">{{ product?.name }}</span>
|
|
</Heading>
|
|
<Heading :level="2" html-tag="h2" classes="xl:w-2/3">
|
|
<span data-e2e="subtitle" v-html="product?.cover?.copyText?.details"></span>
|
|
</Heading>
|
|
<p class="text-gray-800 text-lg lg:w-4/5" data-e2e="subtitle">
|
|
{{ product?.cover?.copyText?.paper }}
|
|
</p>
|
|
|
|
<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 (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="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="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="productVariantsRuling.length > 1 && handleVariantClick('ruling', ruling)"
|
|
>
|
|
<img v-if="ruling.iconUrl" :alt="ruling.name" :src="ruling.iconUrl" />
|
|
</SelectionBox>
|
|
</div>
|
|
</section>
|
|
<hr v-if="productVariantsRuling?.length" class="border-t-[1px] border-black opacity-40" />
|
|
|
|
<!-- 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"
|
|
:label="cover.name"
|
|
:path="cover.productSlug ? `/details/${cover.productSlug}` : ''"
|
|
:is-active="cover.active"
|
|
:aria-disabled="isAddingToCart || isChangingVariant"
|
|
@click="handleVariantClick('cover', cover)"
|
|
>
|
|
<img v-if="cover.iconUrl" :alt="cover.name" :src="cover.iconUrl" />
|
|
</SelectionBox>
|
|
</div>
|
|
</section>
|
|
<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">
|
|
<div class="w-full lg:w-1/3 flex flex-row-reverse lg:flex-row" itemprop="offers" itemscope itemtype="http://schema.org/Offer">
|
|
<meta itemprop="priceCurrency" content="EUR" />
|
|
<meta itemprop="price" :content="formatPriceForSchema(product.totalProductPrice)" />
|
|
<meta itemprop="priceValidUntil" :content="getPriceValidUntilDate()" />
|
|
<meta itemprop="availability" content="https://schema.org/InStock" />
|
|
<meta itemprop="url" :content="getProductUrl()" />
|
|
<meta itemprop="itemCondition" content="https://schema.org/NewCondition" />
|
|
<span class="oldstyle-nums text-3xl tracking-tight font-bold text-nowrap">
|
|
{{ numberFormatter(product.totalProductPrice) }} €
|
|
</span>
|
|
</div>
|
|
<div class="w-full lg:w-2/3">
|
|
<Button @click="addToCart" :is-pending="isAddingToCart" classes="w-full">In den Warenkorb</Button>
|
|
</div>
|
|
</section>
|
|
<section v-else class="py-6 px-6 lg:px-8">
|
|
<div itemprop="offers" itemscope itemtype="http://schema.org/Offer">
|
|
<meta itemprop="availability" content="https://schema.org/OutOfStock" />
|
|
<meta itemprop="url" :content="getProductUrl()" />
|
|
<meta itemprop="itemCondition" content="https://schema.org/NewCondition" />
|
|
</div>
|
|
<Button :classes="`w-full ${ctaBgColor}`">Produktanfrage</Button>
|
|
<p class="text-xs p-4 px-6 text-gray-600">
|
|
Aktuell können wir diesen Artikel nicht liefern. Schicke uns bitte eine Anfrage, dann erfährst du als Erstes sobald wir wieder lieferfähig sind. Danke für deine Geduld!
|
|
</p>
|
|
</section>
|
|
</div>
|
|
|
|
<div class="flex flex-col my-4 items-center gap-4">
|
|
<img class="h-10" src="~/assets/visa-mastercard-paypal.svg" alt="VISA Mastercard PayPal Logos" />
|
|
<span class="text-sm text-center w-4/5 opacity-60">Unterstützte Zahlungsanbieter: VISA, Mastercard und PayPal mit SSL Verschlüsselung</span>
|
|
</div>
|
|
</div>
|
|
|
|
<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>
|
|
<div v-else class="bg-black opacity-5 shadow-sm w-full object-cover min-h-48"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
</Background>
|
|
|
|
<!-- Trust Bar -->
|
|
<TrustBar v-if="displayState === 'main-content'" variant="light" />
|
|
|
|
<!-- Feature Modules -->
|
|
<template v-if="displayState === 'main-content' && featureModules.length">
|
|
<FeatureModule
|
|
v-for="(module, i) in featureModules"
|
|
:key="i"
|
|
:id="`feature-${i}`"
|
|
:eyebrow="module.eyebrow"
|
|
:headline="module.headline"
|
|
:subtitle="module.subtitle"
|
|
:body="module.body"
|
|
:image="module.image"
|
|
:image-right="module.imageRight"
|
|
:class="i % 2 === 0 ? 'bg-white' : 'bg-gray-50'"
|
|
/>
|
|
</template>
|
|
|
|
<!-- Technical Specs -->
|
|
<TechnicalSpecs v-if="displayState === 'main-content'" :specs="technicalSpecs" />
|
|
|
|
<!-- Use Case Grid -->
|
|
<UseCaseGrid v-if="displayState === 'main-content'" :use-cases="useCases" />
|
|
|
|
<!-- Pattern Variants Section -->
|
|
<section
|
|
class="py-16 xl:container mx-auto px-6"
|
|
v-if="displayState === 'main-content' && productVariantsPatterns?.length"
|
|
data-e2e="pattern-variants"
|
|
>
|
|
<div class="text-center mb-10">
|
|
<h3 class="text-2xl font-bold mb-3">{{ productVariantsPatterns.length }} weitere Muster</h3>
|
|
<p class="text-gray-600 max-w-2xl mx-auto">
|
|
Jedes Muster ist ein Unikat. Finde das Design, das zu dir passt.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-6 lg:gap-8">
|
|
<div v-for="pattern in productVariantsPatterns" :key="pattern.id">
|
|
<ProductCard :product="pattern.product" variant="neutral" />
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Testimonials Section (hidden for now)
|
|
<section v-if="displayState === 'main-content' && testimonials.length > 0" class="py-16 px-6 xl:container mx-auto">
|
|
<div class="text-center mb-10">
|
|
<h3 class="text-2xl font-bold mb-3">Kundenstimmen</h3>
|
|
<p class="text-gray-600 max-w-2xl mx-auto">Was unsere Kunden über ihre Notizbücher sagen.</p>
|
|
</div>
|
|
<LazyTestimonial
|
|
:testimonials="testimonials"
|
|
:show-title="false"
|
|
:show-rating="true"
|
|
:show-avatar="false"
|
|
:show-quote-icon="true"
|
|
primary-color="#374151"
|
|
star-color="#374151"
|
|
/>
|
|
</section>
|
|
-->
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { numberFormatter } from "~/utils/numberFormatter";
|
|
import { randomTailwindColor } from "~/utils/randomTailwindColor";
|
|
import { trackEvent } from "~/utils/trackEvent";
|
|
import { scrollProgress } from "~/utils/scrollProgress";
|
|
import { useProductContent } from "~/composables/useProductContent";
|
|
import type { Product, ProductVariantResponse, PatternVariantsResponse, Testimonial } from "~/types";
|
|
|
|
const route = useRoute();
|
|
const slug = computed(() => route.params.slug as string);
|
|
|
|
const shopApi = useShopApi();
|
|
const { addProduct: cartAddProduct } = useCart();
|
|
|
|
const isAddingToCart = ref(false);
|
|
const isChangingVariant = ref(false);
|
|
const hasScrolled50 = ref(false);
|
|
const hasScrolled100 = ref(false);
|
|
|
|
// Fetch all product data in one useAsyncData call for reliable SSR
|
|
const { data: pageData, status, refresh } = await useAsyncData(
|
|
() => `product-page-${slug.value}`,
|
|
async () => {
|
|
// First fetch the product
|
|
const product = await $fetch<Product | null>(`/api/product/${slug.value}`);
|
|
|
|
if (!product?.id) {
|
|
return { product: null, productVariants: { pages: [], ruling: [], cover: [] }, patternVariants: { patterns: [] } };
|
|
}
|
|
|
|
// Then fetch variants in parallel
|
|
const [productVariants, patternVariants] = await Promise.all([
|
|
$fetch<ProductVariantResponse>(`/api/products/${product.id}/variants`).catch(() => ({ pages: [], ruling: [], cover: [] })),
|
|
$fetch<PatternVariantsResponse>(`/api/products/${product.id}/variants/pattern`).catch(() => ({ patterns: [] }))
|
|
]);
|
|
|
|
return { product, productVariants, patternVariants };
|
|
},
|
|
{
|
|
default: () => ({
|
|
product: null as Product | null,
|
|
productVariants: { pages: [], ruling: [], cover: [] } as ProductVariantResponse,
|
|
patternVariants: { patterns: [] } as PatternVariantsResponse
|
|
})
|
|
}
|
|
);
|
|
|
|
// Refetch when slug changes (client-side navigation)
|
|
watch(slug, async () => {
|
|
await refresh();
|
|
isChangingVariant.value = false;
|
|
});
|
|
|
|
const product = computed(() => pageData.value?.product ?? null);
|
|
const productVariants = computed(() => pageData.value?.productVariants ?? { pages: [], ruling: [], cover: [] });
|
|
const patternVariants = computed(() => pageData.value?.patternVariants ?? { patterns: [] });
|
|
|
|
// Product content for storytelling modules
|
|
const coverName = computed(() => product.value?.cover?.name);
|
|
const { coverType, featureModules: baseFeatureModules, technicalSpecs, useCases } = useProductContent(coverName, product);
|
|
|
|
// Merge feature modules with slide images from CMS
|
|
const featureModules = computed(() => {
|
|
const slides = product.value?.cover?.slides ?? [];
|
|
return baseFeatureModules.value.map((module, i) => ({
|
|
...module,
|
|
image: slides[i]?.url ?? module.image,
|
|
}));
|
|
});
|
|
|
|
const displayState = computed(() => {
|
|
// Show loading only on initial load (no data yet)
|
|
if (status.value === "pending" && !product.value?.id) return "loading";
|
|
// Keep showing current content while refreshing
|
|
if (product.value?.id && product.value.publishedAt) return "main-content";
|
|
// Only show error if we're not loading and have no product
|
|
if (status.value !== "pending" && !product.value?.id) return "not-found-error";
|
|
if (status.value !== "pending" && !product.value?.publishedAt) return "not-found-error";
|
|
return "main-content";
|
|
});
|
|
|
|
const productImage = computed(() => {
|
|
return product.value?.images?.images?.[0];
|
|
});
|
|
|
|
const ctaBgColor = computed(() => {
|
|
if (product.value?.pattern) {
|
|
return randomTailwindColor(product.value.pattern.id, "bg", 700);
|
|
}
|
|
return "bg-black";
|
|
});
|
|
|
|
// Product variants computed properties
|
|
const productVariantsPages = computed(() => {
|
|
const variants = productVariants.value;
|
|
const prod = product.value;
|
|
if (!variants?.pages || !prod?.pages) return [];
|
|
|
|
return variants.pages
|
|
.filter(({ productVariant, id }) => !!productVariant || id === prod?.pages?.id)
|
|
.map((pages) => ({
|
|
id: pages.id,
|
|
name: pages.name,
|
|
iconUrl: pages.icon?.url,
|
|
productId: pages.productVariant?.id,
|
|
productSlug: pages.productVariant?.slug,
|
|
active: prod?.pages?.id === pages.id
|
|
}));
|
|
});
|
|
|
|
const productVariantsRuling = computed(() => {
|
|
const variants = productVariants.value;
|
|
const prod = product.value;
|
|
if (!variants?.ruling || !prod?.ruling) return [];
|
|
|
|
return variants.ruling
|
|
.filter(({ productVariant, id }) => !!productVariant || id === prod?.ruling?.id)
|
|
.map((ruling) => ({
|
|
id: ruling.id,
|
|
name: ruling.name,
|
|
iconUrl: ruling.icon?.url,
|
|
productId: ruling.productVariant?.id,
|
|
productSlug: ruling.productVariant?.slug,
|
|
active: prod?.ruling?.id === ruling.id
|
|
}));
|
|
});
|
|
|
|
const productVariantsCover = computed(() => {
|
|
const variants = productVariants.value;
|
|
const prod = product.value;
|
|
if (!variants?.cover || !prod?.cover) return [];
|
|
|
|
return variants.cover
|
|
.filter(({ productVariant, id }) => !!productVariant || id === prod?.cover?.id)
|
|
.map((cover) => ({
|
|
id: cover.id,
|
|
name: cover.name,
|
|
iconUrl: cover.icon?.url,
|
|
productId: cover.productVariant?.id,
|
|
productSlug: cover.productVariant?.slug,
|
|
active: prod?.cover?.id === cover.id
|
|
}));
|
|
});
|
|
|
|
const productVariantsPatterns = computed(() => {
|
|
const variants = patternVariants.value;
|
|
if (!variants?.patterns) return [];
|
|
|
|
return variants.patterns
|
|
.filter(({ productVariant }) => !!productVariant)
|
|
.map((pattern) => ({
|
|
id: pattern.id,
|
|
name: pattern.name,
|
|
imageUrl: pattern.image?.url,
|
|
productId: pattern.productVariant?.id,
|
|
productSlug: pattern.productVariant?.slug,
|
|
product: pattern.productVariant
|
|
}));
|
|
});
|
|
|
|
// 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[] = [
|
|
{
|
|
name: "Maria S.",
|
|
purpose: "Skizzenbuch",
|
|
comment: "Endlich ein Papier, das meine Aquarellstifte aushält. Kein Durchdrücken, nichts wellt sich. Hab schon das dritte bestellt.",
|
|
rating: 5
|
|
},
|
|
{
|
|
name: "Thomas W.",
|
|
purpose: "Tagebuch",
|
|
comment: "Schreibe seit Jahren Tagebuch und das hier ist mit Abstand das beste Notizbuch, das ich hatte. Liegt super in der Hand und die Seiten sind angenehm dick.",
|
|
rating: 5
|
|
},
|
|
{
|
|
name: "Julia B.",
|
|
purpose: "Studium",
|
|
comment: "Benutze es für Vorlesungsmitschriften. Das Papier ist top, mein Füller schmiert nicht. Sehr zufrieden!",
|
|
rating: 4
|
|
},
|
|
{
|
|
name: "Sophia K.",
|
|
purpose: "Bullet Journal",
|
|
comment: "Die Bindung ist echt gut, das Buch bleibt flach liegen beim Schreiben. Und ich mag, dass es in Deutschland hergestellt wird.",
|
|
rating: 5
|
|
},
|
|
{
|
|
name: "Lukas M.",
|
|
purpose: "Arbeit",
|
|
comment: "Nutze es für Meeting-Notizen. Sieht professionell aus und hält was aus. Hab's jetzt seit 8 Monaten täglich dabei.",
|
|
rating: 5
|
|
}
|
|
];
|
|
|
|
// Aggregate rating computed
|
|
const aggregateRating = computed(() => {
|
|
if (!testimonials.length) return null;
|
|
|
|
const totalRating = testimonials.reduce((sum, testimonial) => sum + testimonial.rating, 0);
|
|
const ratingCount = testimonials.length;
|
|
|
|
return {
|
|
ratingValue: (totalRating / ratingCount).toFixed(1),
|
|
ratingCount: ratingCount.toString(),
|
|
reviewCount: ratingCount.toString()
|
|
};
|
|
});
|
|
|
|
function formatPriceForSchema(price: number) {
|
|
return price.toString().replace(",", ".");
|
|
}
|
|
|
|
function getPriceValidUntilDate() {
|
|
const date = new Date();
|
|
date.setFullYear(date.getFullYear() + 1);
|
|
return date.toISOString().split("T")[0];
|
|
}
|
|
|
|
function getProductUrl() {
|
|
if (import.meta.client) {
|
|
return `${window.location.origin}/details/${slug.value}`;
|
|
}
|
|
return `/details/${slug.value}`;
|
|
}
|
|
|
|
function stripHtml(html: string): string {
|
|
return html.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
|
|
}
|
|
|
|
function getProductDescription() {
|
|
let description = "";
|
|
|
|
if (product.value?.cover?.copyText) {
|
|
const copyText = product.value.cover.copyText;
|
|
|
|
if (copyText.details) description += stripHtml(copyText.details) + " ";
|
|
if (copyText.format) description += "Format: " + copyText.format + ". ";
|
|
if (copyText.paper) description += "Papier: " + copyText.paper + ". ";
|
|
if (copyText.cover) description += "Einband: " + copyText.cover + ". ";
|
|
if (copyText.banderole) description += "Banderole: " + copyText.banderole + ". ";
|
|
}
|
|
|
|
return description.trim() || product.value?.name || "";
|
|
}
|
|
|
|
interface VariantItem {
|
|
id: number;
|
|
name: string;
|
|
active: boolean;
|
|
productSlug?: string;
|
|
}
|
|
|
|
function handleVariantClick(type: string, variant: VariantItem) {
|
|
if (!variant.active) {
|
|
isChangingVariant.value = true;
|
|
}
|
|
trackEvent(`product-change-${type}-clicked`, {
|
|
product: product.value?.id,
|
|
label: variant.name
|
|
});
|
|
}
|
|
|
|
function trackScrolling() {
|
|
if (hasScrolled100.value) return;
|
|
|
|
const progress = scrollProgress();
|
|
|
|
if (progress >= 50 && !hasScrolled50.value) {
|
|
trackEvent("product-details-scrolled-50");
|
|
hasScrolled50.value = true;
|
|
}
|
|
|
|
if (progress === 100) {
|
|
trackEvent("product-details-scrolled-100");
|
|
hasScrolled100.value = true;
|
|
}
|
|
}
|
|
|
|
async function addToCart() {
|
|
if (!product.value?.id) return;
|
|
|
|
trackEvent("product-details-add-to-cart-clicked", {
|
|
product: product.value.id,
|
|
price: product.value.totalProductPrice
|
|
});
|
|
|
|
try {
|
|
isAddingToCart.value = true;
|
|
await cartAddProduct(product.value.id);
|
|
trackEvent("product-details-add-to-cart-succeeded", {
|
|
product: product.value.id
|
|
});
|
|
navigateTo("/cart");
|
|
} catch (error) {
|
|
console.error(`Could not add product ${product.value.id} to cart:`, error);
|
|
trackEvent("product-details-add-to-cart-failed", {
|
|
product: product.value.id
|
|
});
|
|
} finally {
|
|
isAddingToCart.value = false;
|
|
}
|
|
}
|
|
|
|
// Lifecycle hooks
|
|
onMounted(() => {
|
|
window.addEventListener("scroll", trackScrolling);
|
|
|
|
// Track product view
|
|
if (product.value?.id) {
|
|
trackEvent("product-viewed", {
|
|
productId: product.value.id,
|
|
productSlug: slug.value,
|
|
productName: product.value.name,
|
|
price: product.value.totalProductPrice,
|
|
coverType: product.value.cover?.name
|
|
});
|
|
}
|
|
});
|
|
|
|
onUnmounted(() => {
|
|
window.removeEventListener("scroll", trackScrolling);
|
|
});
|
|
|
|
// SEO
|
|
useSeoMeta({
|
|
title: () => `${product.value?.name ?? "Produkt"} | MUELLERPRINTS`,
|
|
description: () => getProductDescription(),
|
|
ogTitle: () => product.value?.name,
|
|
ogDescription: () => getProductDescription(),
|
|
ogImage: () => productImage.value?.url,
|
|
ogType: "product"
|
|
});
|
|
</script>
|