13 Commits
Author SHA1 Message Date
libretech 0561f7ce94 ci: run the unit tests; build pull requests, publish only release tags
build / test (pull_request) Successful in 11s
build / build (pull_request) Successful in 1m1s
As libreshop/cms adopted: a test job pipes the source into the image's base
(the Dockerfile's first FROM, node:22-slim) and runs npm test there without
network. The image build needs it and runs for pull requests (build only)
and v* tags (published if PUBLISH_ENABLED). main no longer publishes :main
and :sha-* images; mp pins the shop image by version tag.

Refs libretech/mp#71
2026-10-09 02:33:17 +02:00
libretech 6c41faf36a fix(contact): send the contact form through the mail service's /v1/send/message
POST /api/contact posted { to, subject, body, replyTo } to ${mailApiUrl}/send,
which the mail service (libreshop/mail src/app.py) does not have, so every
message failed with a 500. It now posts { to_email, subject, message } to
POST /v1/send/message, still to paperwork@muellerprints.de, with the same
subject and text as before. The service sets no Reply-To; the sender's
address stays in the text. A line break in the subject is replaced by a
space, so it cannot start another mail header.

mailApiUrl was read from runtimeConfig without being declared there, so
NUXT_MAIL_API_URL could not set it and the fallback http://mail:2222 was
always used. It is declared now, with that default; mp's compose.yml sets
no NUXT_MAIL_API_URL.

contactMail and mailSendUrl (server/utils/contactMail.ts) are pure and tested.

Refs libretech/mp#71
2026-10-09 02:32:44 +02:00
libretech 6f434c21ba fix(checkout): take a repeated capture as a successful payment
The CMS answers a capture of an order already paid with this PayPal order
with { success: true, alreadyCaptured: true } instead of the order. Step 3
took that answer for a capture without authorisation and showed a payment
error. It now reloads the paid order (keeping the shown one if the reload
fails) and continues as after a first capture: confirmation, the
checkout-payment-completed event, and the redirect to the order's result
page. capturePayment is typed as Order | AlreadyCaptured, and the pure
isAlreadyCaptured (utils/captureResponse.ts) tells them apart.

Refs libretech/mp#71
2026-10-09 02:32:38 +02:00
libretech 8931afc670 feat(checkout): show a German message when a checkout step fails
checkoutErrorMessage (utils/checkoutError.ts) maps the status and body of a
failed request, the shop's answer or a raw CMS (Strapi) error, to one fixed
message for the customer, addressed with "du": an invalid e-mail address
(when data.email is among the rejected fields), other invalid input, an order
already paid, a product no longer available, an order not ready for checkout
(naming the missing steps), a payment that does not fit the order or an order
changed during the payment ("Bitte starte die Zahlung neu"), PayPal not
reachable ("versuche es in ein paar Minuten noch einmal"), a payment PayPal
may have taken that the CMS could not confirm or record ("Bitte bezahle nicht
noch einmal"), and a general fallback. It never shows the server's text.

Steps 1 and 2 show the message above their submit button, in the style of
step 3's payment error, instead of logging the error only. Step 3 shows it
for a failed PayPal order creation or capture. The e-mail input of step 1 is
type="email" (Input.vue takes a type).

Refs libretech/mp#71
2026-10-09 02:32:32 +02:00
libretech bfcbbfac39 fix(orders): forward only the checkout's fields on an order update
PUT /api/orders/:uuid forwarded the browser's body to the CMS unchanged. It
now forwards { data } with only the seven fields of the checkout's steps:
email, acceptedTermsAndConditionsAt, invoiceAddress, deliveryAddress,
invoiceAddressStructured, deliveryAddressStructured and delivery. Any other
field, a field beside data, or a body of another shape is answered 400
"Invalid order update" with the CMS's error format, without calling the
CMS. The values are left to the CMS, which checks them and stays the
authority; this is defence in depth.

pickCustomerUpdate (server/utils/customerUpdate.ts) is pure and tested with
the exact payloads of steps 1 and 2 and with every server-only attribute of
the order.

Refs libretech/mp#71
2026-10-09 02:32:20 +02:00
libretech 5c773a99e6 feat(orders): pass the CMS's status and safe error fields to the browser
The order routes (get, put, add-product, remove-product, checkout, capture)
call forwardToCms, which throws a CMS error on as
createError({ statusCode, statusMessage, data: { message, errors?, missing? } }),
built by the pure shopErrorFromCms (server/utils/cmsError.ts).

Before, the FetchError was thrown on as it was: the browser got the CMS's
status, but Nitro treated it as unhandled, answered "Server Error" without
data and logged every CMS 4xx as [unhandled]. Now the browser also gets the
CMS's message, the errors of a rejected update and the fields a checkout
misses, and no other field. A status that is the shop's own fault (401, 403,
...) is answered 500, a CMS that does not answer 503; 5xx are logged without
the query and the order uuid.

npm test runs tests/unit with Node's type stripping and no dependencies, as
in libreshop/cms. nuxt.config keeps tests/ out of the app's type check.

Refs libretech/mp#71
2026-10-09 02:32:14 +02:00
libretech 61a74e649b feat(selection): lock single-option boxes, drop the about link
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.
2026-09-23 22:09:52 +02:00
libretech e9cd3d9c20 ci: bump runner-image pin v0.1.0 → v0.2.0 (#15)
build / build (push) Successful in 1m18s
Co-authored-by: Michael Czechowski <mail@dailysh.it>
Co-committed-by: Michael Czechowski <mail@dailysh.it>
2026-05-04 23:46:04 +02:00
libretech cfe37519bd fix(ci): correct image digest separator
build / build (push) Successful in 1m6s
2026-04-30 12:20:40 +02:00
libretech 60ab9dcc23 ci: digest-pin runner-image v0.1.0
build / build (push) Failing after 0s
2026-04-30 12:18:04 +02:00
libretech 4fe5b9043d ci: update runner-image path to public/ namespace
build / build (push) Failing after 1s
2026-04-30 11:57:33 +02:00
libretech f8d4c6a47d chore: update action ref to public/actions
build / build (push) Successful in 1m28s
2026-04-29 22:51:08 +02:00
libretech ed6771e03a chore: slim build.yml to use shared docker-build action (#1)
build / build (push) Successful in 1m22s
2026-04-29 22:08:36 +02:00
12 changed files with 185 additions and 214 deletions

No files matched your search

+1 -1
View File
@@ -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">
Akzeptieren
Schließen
</Button>
</div>
</div>
-6
View File
@@ -45,12 +45,6 @@
: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"
+3 -5
View File
@@ -64,11 +64,9 @@ 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(() => {
+13 -7
View File
@@ -1,12 +1,14 @@
<template>
<NuxtLink
:to="path"
<component
:is="locked ? 'div' : NuxtLink"
:to="locked ? undefined : path"
:title="ariaLabel"
:aria-label="ariaLabel"
:aria-checked="isActive"
:aria-checked="isActive || locked"
:class="{
'border-black bg-white': isActive,
'border-transparent cursor-pointer bg-gray-100 opacity-80 hover:opacity-100 hover:border-gray-400 pointer': !isActive,
'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,
'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"
@@ -17,20 +19,24 @@
<div
class="text-sm text-gray-800 pointer-events-none"
:class="{
'font-semibold': isActive
'font-semibold': isActive || locked
}"
>
{{ label }}
</div>
</NuxtLink>
</component>
</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>
+53 -4
View File
@@ -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";
export type CoverType = "Hardcover" | "Softcover" | "Heft" | "Spiralbindung" | "Promo";
export interface FeatureModule {
eyebrow?: string;
@@ -199,6 +199,44 @@ 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,
},
],
};
// =============================================================================
@@ -244,6 +282,15 @@ 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",
},
};
// =============================================================================
@@ -284,6 +331,8 @@ 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
}
@@ -314,10 +363,10 @@ export function useProductContent(
derivedSpecs["Format"] = prod.cover.copyText.format;
}
// Seitenanzahl from pages
if (prod.pages?.name) {
// Seitenanzahl from pages (skip for Promo/standalone — page count not relevant)
if (prod.pages?.name && coverType.value !== "Promo") {
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);
derivedSpecs["Seitenanzahl"] = `${prod.pages.count} Seiten (${sheets} Blatt)`;
}
+4 -1
View File
@@ -46,7 +46,10 @@ export default defineNuxtConfig({
routeRules: {
// Cart & Checkout: Client-only (no SSR)
"/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
},
-17
View File
@@ -151,23 +151,6 @@ 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) {
+41 -31
View File
@@ -77,48 +77,40 @@
{{ product?.cover?.copyText?.paper }}
</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="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">
<!-- 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="handleVariantClick('pages', pages)"
@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="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">
<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="handleVariantClick('ruling', ruling)"
@click="productVariantsRuling.length > 1 && handleVariantClick('ruling', ruling)"
>
<img v-if="ruling.iconUrl" :alt="ruling.name" :src="ruling.iconUrl" />
</SelectionBox>
@@ -126,15 +118,10 @@
</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">
<!-- 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"
@@ -148,7 +135,18 @@
</SelectionBox>
</div>
</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 -->
<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 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">
<img :src="productImage.formats?.medium?.url" :alt="`Overhead product shot of MUELLERPRINTS. Paperwork ${product?.name}`" class="object-contain" />
</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
const testimonials: Testimonial[] = [
{
+56 -14
View File
@@ -49,23 +49,32 @@
</section>
</template>
<!-- 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
<!-- 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" />
</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">
@@ -99,8 +108,11 @@ 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(
data.map(async (cover: any) => {
sellable.map(async (cover: any) => {
const productsResponse = await shopApi.getCheapestProducts(cover.id, 1, 10);
return {
id: cover.id,
@@ -122,6 +134,36 @@ 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[] = [
{
+11 -31
View File
@@ -1,35 +1,15 @@
export default defineNuxtPlugin(() => {
const config = useRuntimeConfig();
const scriptUrl = config.public.umamiScriptUrl as string;
const websiteId = config.public.umamiWebsiteId as string;
if (!scriptUrl || !websiteId) return;
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));
if (config.public.umamiScriptUrl && config.public.umamiWebsiteId) {
useHead({
script: [
{
src: config.public.umamiScriptUrl,
defer: true,
"data-website-id": config.public.umamiWebsiteId
}
]
});
}
});
-51
View File
@@ -1,51 +0,0 @@
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();
});
+3 -46
View File
@@ -6,51 +6,8 @@ declare global {
}
}
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
export const trackEvent = (event: string, data?: Record<string, unknown>) => {
if (import.meta.client && window.umami) {
window.umami.track(event, data);
}
}
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;
};