Files
shop/pages/details/[slug].vue
Michael Czechowski 44107c0734
Some checks failed
Build and publish / build (push) Failing after 19s
feat: extract shop from mp/shop — initial libreshop/shop
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.
2026-04-29 17:48:56 +02:00

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>