Compare commits
8
Commits
0561f7ce94
..
v0.1.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fccff147ba | ||
|
|
60b8f1ab58
|
||
|
|
024df873ac
|
||
|
|
a2058927d9
|
||
|
|
a052c846f7
|
||
|
|
376e50357a
|
||
|
|
08f24534b6
|
||
|
|
3581015486
|
No files matched your search
@@ -16,7 +16,7 @@
|
||||
Datenschutzerklärung
|
||||
</Button>
|
||||
<Button @click="acceptCookies" data-cy="cookie-banner-accept" classes="bg-gray-800 py-1.5 px-4 !text-xs whitespace-nowrap">
|
||||
Schließen
|
||||
Akzeptieren
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -45,6 +45,12 @@
|
||||
:path="`/notebooks/${item.slug}`"
|
||||
@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>
|
||||
<NuxtLink
|
||||
to="/cart"
|
||||
|
||||
@@ -64,9 +64,11 @@ const props = withDefaults(defineProps<{
|
||||
});
|
||||
|
||||
const shadowColors = computed(() => {
|
||||
return props.product?.pattern
|
||||
? [randomTailwindColor(props.product.pattern.id, "group-hover:shadow", 200, "/60")]
|
||||
: [];
|
||||
return (
|
||||
props.product?.pattern && [
|
||||
randomTailwindColor(props.product.pattern.id, "group-hover:shadow", 200, "/60")
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
const hasProductOptions = computed(() => {
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
<template>
|
||||
<component
|
||||
:is="locked ? 'div' : NuxtLink"
|
||||
:to="locked ? undefined : path"
|
||||
<NuxtLink
|
||||
:to="path"
|
||||
:title="ariaLabel"
|
||||
:aria-label="ariaLabel"
|
||||
:aria-checked="isActive || locked"
|
||||
:aria-checked="isActive"
|
||||
:class="{
|
||||
'border-black bg-white': isActive || locked,
|
||||
'cursor-default': locked,
|
||||
'border-transparent cursor-pointer bg-gray-100 opacity-80 hover:opacity-100 hover:border-gray-400 pointer': !isActive && !locked,
|
||||
'border-black bg-white': isActive,
|
||||
'border-transparent cursor-pointer bg-gray-100 opacity-80 hover:opacity-100 hover:border-gray-400 pointer': !isActive,
|
||||
'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"
|
||||
@@ -19,24 +17,20 @@
|
||||
<div
|
||||
class="text-sm text-gray-800 pointer-events-none"
|
||||
:class="{
|
||||
'font-semibold': isActive || locked
|
||||
'font-semibold': isActive
|
||||
}"
|
||||
>
|
||||
{{ label }}
|
||||
</div>
|
||||
</component>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { NuxtLink } from "#components";
|
||||
|
||||
defineProps<{
|
||||
label: string;
|
||||
path: string;
|
||||
isActive?: boolean;
|
||||
ariaLabel?: string;
|
||||
ariaDisabled?: boolean;
|
||||
// Pre-selected, non-interactive (single-option / fixed products)
|
||||
locked?: boolean;
|
||||
}>();
|
||||
</script>
|
||||
@@ -1,7 +1,7 @@
|
||||
// Product content keyed by cover type
|
||||
// This content is displayed on product detail pages as storytelling modules
|
||||
|
||||
export type CoverType = "Hardcover" | "Softcover" | "Heft" | "Spiralbindung" | "Promo";
|
||||
export type CoverType = "Hardcover" | "Softcover" | "Heft" | "Spiralbindung";
|
||||
|
||||
export interface FeatureModule {
|
||||
eyebrow?: string;
|
||||
@@ -199,44 +199,6 @@ export const FEATURE_MODULES: Record<CoverType, FeatureModule[]> = {
|
||||
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,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
@@ -282,15 +244,6 @@ export const TECHNICAL_SPECS: Record<CoverType, Record<string, string>> = {
|
||||
Zertifizierung: "FSC® C018175, Blauer Engel, EU Ecolabel",
|
||||
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",
|
||||
},
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
@@ -331,8 +284,6 @@ export function getCoverType(coverName?: string): CoverType {
|
||||
if (name.includes("hardcover")) return "Hardcover";
|
||||
if (name.includes("softcover")) return "Softcover";
|
||||
if (name.includes("spiral") || name.includes("wire")) return "Spiralbindung";
|
||||
// Standalone promo bucket (cover named "Promo").
|
||||
if (name.includes("promo")) return "Promo";
|
||||
return "Hardcover"; // fallback
|
||||
}
|
||||
|
||||
@@ -363,10 +314,10 @@ export function useProductContent(
|
||||
derivedSpecs["Format"] = prod.cover.copyText.format;
|
||||
}
|
||||
|
||||
// Seitenanzahl from pages (skip for Promo/standalone — page count not relevant)
|
||||
if (prod.pages?.name && coverType.value !== "Promo") {
|
||||
// Seitenanzahl from pages
|
||||
if (prod.pages?.name) {
|
||||
derivedSpecs["Seitenanzahl"] = prod.pages.name;
|
||||
} else if (prod.pages?.count && coverType.value !== "Promo") {
|
||||
} else if (prod.pages?.count) {
|
||||
const sheets = Math.floor(prod.pages.count / 2);
|
||||
derivedSpecs["Seitenanzahl"] = `${prod.pages.count} Seiten (${sheets} Blatt)`;
|
||||
}
|
||||
|
||||
+1
-4
@@ -46,10 +46,7 @@ export default defineNuxtConfig({
|
||||
routeRules: {
|
||||
// Cart & Checkout: Client-only (no SSR)
|
||||
"/cart": { 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/**" }
|
||||
"/checkout/**": { ssr: false }
|
||||
// All other pages use default SSR
|
||||
},
|
||||
|
||||
|
||||
@@ -151,6 +151,23 @@ onMounted(async () => {
|
||||
orderTotal: order.value.total,
|
||||
itemCount: order.value.cart?.length ?? 0
|
||||
});
|
||||
|
||||
// Revenue for Umami's revenue report: exactly once per order, even if the
|
||||
// customer reloads this page or opens it again from the confirmation mail.
|
||||
const purchaseKey = `mp-purchase-tracked-${order.value.uuid}`;
|
||||
if (!localStorage.getItem(purchaseKey)) {
|
||||
const products = (order.value.cart ?? []).map((item) => item.product?.slug).filter(Boolean);
|
||||
trackEvent(
|
||||
"purchase-completed",
|
||||
{
|
||||
revenue: order.value.total,
|
||||
currency: "EUR",
|
||||
itemCount: (order.value.cart ?? []).reduce((sum, item) => sum + (item.count ?? 1), 0),
|
||||
products: products.join(",").slice(0, 400)
|
||||
},
|
||||
() => localStorage.setItem(purchaseKey, new Date().toISOString())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (order.value?.invoice?.url) {
|
||||
|
||||
+31
-41
@@ -77,40 +77,48 @@
|
||||
{{ 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="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 (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">
|
||||
<!-- 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"
|
||||
:locked="productVariantsPages.length === 1"
|
||||
:aria-disabled="isAddingToCart || isChangingVariant"
|
||||
@click="productVariantsPages.length > 1 && handleVariantClick('pages', pages)"
|
||||
@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="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">
|
||||
<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"
|
||||
:locked="productVariantsRuling.length === 1"
|
||||
:aria-disabled="isAddingToCart || isChangingVariant"
|
||||
@click="productVariantsRuling.length > 1 && handleVariantClick('ruling', ruling)"
|
||||
@click="handleVariantClick('ruling', ruling)"
|
||||
>
|
||||
<img v-if="ruling.iconUrl" :alt="ruling.name" :src="ruling.iconUrl" />
|
||||
</SelectionBox>
|
||||
@@ -118,10 +126,15 @@
|
||||
</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">
|
||||
<!-- 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"
|
||||
@@ -135,18 +148,7 @@
|
||||
</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>
|
||||
<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">
|
||||
@@ -184,7 +186,7 @@
|
||||
</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">
|
||||
<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>
|
||||
@@ -419,18 +421,6 @@ 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[] = [
|
||||
{
|
||||
|
||||
+14
-56
@@ -49,32 +49,23 @@
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<!-- 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" />
|
||||
<!-- 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
|
||||
</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">
|
||||
@@ -108,11 +99,8 @@ 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(
|
||||
sellable.map(async (cover: any) => {
|
||||
data.map(async (cover: any) => {
|
||||
const productsResponse = await shopApi.getCheapestProducts(cover.id, 1, 10);
|
||||
return {
|
||||
id: cover.id,
|
||||
@@ -134,36 +122,6 @@ 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[] = [
|
||||
{
|
||||
|
||||
+31
-11
@@ -1,15 +1,35 @@
|
||||
export default defineNuxtPlugin(() => {
|
||||
const config = useRuntimeConfig();
|
||||
const scriptUrl = config.public.umamiScriptUrl as string;
|
||||
const websiteId = config.public.umamiWebsiteId as string;
|
||||
if (!scriptUrl || !websiteId) return;
|
||||
|
||||
if (config.public.umamiScriptUrl && config.public.umamiWebsiteId) {
|
||||
useHead({
|
||||
script: [
|
||||
{
|
||||
src: config.public.umamiScriptUrl,
|
||||
defer: true,
|
||||
"data-website-id": config.public.umamiWebsiteId
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
useHead({
|
||||
script: [
|
||||
{
|
||||
src: scriptUrl,
|
||||
defer: true,
|
||||
"data-website-id": websiteId,
|
||||
"data-do-not-track": "true"
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
// Heatmap recorder only on product pages (no personal data there). It needs
|
||||
// heatmapEnabled on the Umami website, set via umami/analytics/reports.json.
|
||||
const recorderUrl = scriptUrl.replace(/script\.js(\?.*)?$/, "recorder.js");
|
||||
let recorderLoaded = false;
|
||||
const loadRecorder = (path: string) => {
|
||||
if (recorderLoaded || !path.startsWith("/details/")) return;
|
||||
if (navigator.doNotTrack === "1") return;
|
||||
recorderLoaded = true;
|
||||
const el = document.createElement("script");
|
||||
el.src = recorderUrl;
|
||||
el.defer = true;
|
||||
el.dataset.websiteId = websiteId;
|
||||
document.head.appendChild(el);
|
||||
};
|
||||
const router = useRouter();
|
||||
loadRecorder(router.currentRoute.value.path);
|
||||
router.afterEach((to) => loadRecorder(to.path));
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { afterEach, beforeEach, expect, test } from "bun:test";
|
||||
|
||||
const sent: Array<[string, unknown]> = [];
|
||||
beforeEach(() => {
|
||||
sent.length = 0;
|
||||
(globalThis as any).window = {};
|
||||
});
|
||||
afterEach(async () => {
|
||||
const { __resetTrackQueue } = await import("./trackEvent");
|
||||
__resetTrackQueue();
|
||||
delete (globalThis as any).window;
|
||||
});
|
||||
const umami = { track: (e: string, d: unknown) => sent.push([e, d]) };
|
||||
const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
test("sends immediately when umami is loaded", async () => {
|
||||
const { trackEvent } = await import("./trackEvent");
|
||||
(globalThis as any).window.umami = umami;
|
||||
trackEvent("a", { x: 1 });
|
||||
expect(sent).toEqual([["a", { x: 1 }]]);
|
||||
});
|
||||
|
||||
test("queues until umami appears, then flushes in order and calls onSent", async () => {
|
||||
const { trackEvent } = await import("./trackEvent");
|
||||
let confirmed = false;
|
||||
trackEvent("first");
|
||||
trackEvent("purchase-completed", { revenue: 12.5, currency: "EUR" }, () => (confirmed = true));
|
||||
expect(sent).toEqual([]);
|
||||
await wait(300);
|
||||
(globalThis as any).window.umami = umami;
|
||||
await wait(400);
|
||||
expect(sent.map((s) => s[0])).toEqual(["first", "purchase-completed"]);
|
||||
expect(confirmed).toBe(true);
|
||||
});
|
||||
|
||||
test("never throws and drops events when umami never loads", async () => {
|
||||
const { trackEvent } = await import("./trackEvent");
|
||||
let confirmed = false;
|
||||
trackEvent("lost", undefined, () => (confirmed = true));
|
||||
await wait(10_500);
|
||||
(globalThis as any).window.umami = umami;
|
||||
await wait(400);
|
||||
expect(sent).toEqual([]);
|
||||
expect(confirmed).toBe(false);
|
||||
}, 15_000);
|
||||
|
||||
test("is a no-op on the server", async () => {
|
||||
delete (globalThis as any).window;
|
||||
const { trackEvent } = await import("./trackEvent");
|
||||
expect(() => trackEvent("ssr")).not.toThrow();
|
||||
});
|
||||
+46
-3
@@ -6,8 +6,51 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
export const trackEvent = (event: string, data?: Record<string, unknown>) => {
|
||||
if (import.meta.client && window.umami) {
|
||||
window.umami.track(event, data);
|
||||
type Pending = { event: string; data?: Record<string, unknown>; onSent?: () => void };
|
||||
|
||||
// Events fired during page setup (onMounted) often run before the deferred
|
||||
// Umami script has loaded; they used to be dropped silently. Queue them and
|
||||
// flush once the tracker exists. If it never appears (blocked, offline),
|
||||
// they are discarded after ~10 s without throwing.
|
||||
const queue: Pending[] = [];
|
||||
let timer: ReturnType<typeof setInterval> | null = null;
|
||||
let attempts = 0;
|
||||
const MAX_ATTEMPTS = 40;
|
||||
const INTERVAL_MS = 250;
|
||||
|
||||
function send(item: Pending) {
|
||||
try {
|
||||
window.umami!.track(item.event, item.data);
|
||||
item.onSent?.();
|
||||
} catch {
|
||||
// tracking must never break the shop
|
||||
}
|
||||
}
|
||||
|
||||
function flush() {
|
||||
if (window.umami) {
|
||||
while (queue.length) send(queue.shift()!);
|
||||
}
|
||||
if (window.umami || ++attempts >= MAX_ATTEMPTS) {
|
||||
queue.length = 0;
|
||||
if (timer) clearInterval(timer);
|
||||
timer = null;
|
||||
attempts = 0;
|
||||
}
|
||||
}
|
||||
|
||||
export const trackEvent = (event: string, data?: Record<string, unknown>, onSent?: () => void) => {
|
||||
if (typeof window === "undefined") return;
|
||||
const item = { event, data, onSent };
|
||||
if (window.umami) return send(item);
|
||||
queue.push(item);
|
||||
if (!timer) timer = setInterval(flush, INTERVAL_MS);
|
||||
};
|
||||
|
||||
// Test hook: reset module state between cases.
|
||||
export const __resetTrackQueue = () => {
|
||||
queue.length = 0;
|
||||
if (timer) clearInterval(timer);
|
||||
timer = null;
|
||||
attempts = 0;
|
||||
};
|
||||
Reference in new issue
Block a user