Some checks failed
Build and publish / build (push) Failing after 19s
Source moved verbatim from mp/shop/ on 2026-04-29; mp was the first concrete adapter consuming the libreshop toolkit. Builds and publishes git.librete.ch/libreshop/shop on every main / v* push via the standard .gitea/workflows/build.yml shared across libreshop components.
597 lines
21 KiB
Vue
597 lines
21 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 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">
|
|
<SelectionBox
|
|
v-for="pages in productVariantsPages"
|
|
:key="pages.id"
|
|
:label="pages.name"
|
|
:path="pages.productSlug ? `/details/${pages.productSlug}` : ''"
|
|
:is-active="pages.active"
|
|
:aria-disabled="isAddingToCart || isChangingVariant"
|
|
@click="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">
|
|
<SelectionBox
|
|
v-for="ruling in productVariantsRuling"
|
|
:key="ruling.id"
|
|
:label="ruling.name"
|
|
:path="ruling.productSlug ? `/details/${ruling.productSlug}` : ''"
|
|
:is-active="ruling.active"
|
|
:aria-disabled="isAddingToCart || isChangingVariant"
|
|
@click="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 -->
|
|
<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">
|
|
<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" class="border-t-[1px] border-black opacity-40" />
|
|
|
|
<!-- 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 xl:pt-2 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
|
|
}));
|
|
});
|
|
|
|
// 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>
|