feat: extract shop from mp/shop — initial libreshop/shop
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.
This commit is contained in:
Michael Czechowski
2026-04-29 17:48:56 +02:00
commit 44107c0734
134 changed files with 19521 additions and 0 deletions

42
pages/about.vue Normal file
View File

@@ -0,0 +1,42 @@
<template>
<main class="pb-20">
<!-- Hero -->
<PageHero :title="ABOUT_CONTENT.hero.title" :subtitle="ABOUT_CONTENT.hero.subtitle" :image="ABOUT_CONTENT.hero.image" />
<!-- Main Content -->
<PageSection v-for="(section, index) in ABOUT_CONTENT.sections" :key="index" :title="section.title" :content="section.content" :variant="index % 2 === 0 ? 'white' : 'gray'" />
<!-- Production Images -->
<section class="py-12 lg:py-16 bg-gray-50">
<div class="xl:container mx-auto px-6">
<h2 class="text-2xl lg:text-3xl font-bold mb-8">Einblicke in unsere Werkstatt</h2>
<div class="grid md:grid-cols-3 gap-6">
<img v-for="(image, index) in ABOUT_CONTENT.images" :key="index" :src="image" alt="Produktion" class="rounded-xl shadow-lg w-full aspect-[4/3] object-cover" loading="lazy" />
</div>
</div>
</section>
<!-- Contact CTA -->
<section class="py-12 lg:py-16">
<div class="xl:container mx-auto px-6 text-center">
<h2 class="text-2xl lg:text-3xl font-bold mb-4">Fragen?</h2>
<p class="text-gray-600 mb-8 max-w-xl mx-auto">Wir freuen uns auf Ihre Nachricht. Besuchen Sie uns in Stuttgart oder schreiben Sie uns.</p>
<div class="flex flex-col sm:flex-row gap-4 justify-center">
<NuxtLink to="/kontakt" class="px-8 py-3 bg-gray-900 text-white font-semibold rounded-full hover:bg-gray-800 transition-colors"> Kontakt aufnehmen </NuxtLink>
<NuxtLink to="/anfahrt" class="px-8 py-3 border-2 border-gray-900 text-gray-900 font-semibold rounded-full hover:bg-gray-100 transition-colors"> Anfahrt </NuxtLink>
</div>
</div>
</section>
</main>
</template>
<script setup lang="ts">
import { ABOUT_CONTENT } from "~/composables/usePageContent";
useSeoMeta({
title: "Über uns | MUELLERPRINTS",
description: "Handarbeit aus Stuttgart seit über 30 Jahren. Wir kombinieren traditionelles Buchbinderhandwerk mit moderner Technik.",
ogTitle: "Über uns | MUELLERPRINTS",
ogDescription: "Handarbeit aus Stuttgart seit über 30 Jahren.",
});
</script>

15
pages/agb.vue Normal file
View File

@@ -0,0 +1,15 @@
<template>
<main class="pb-20">
<PageHero title="Allgemeine Geschäftsbedingungen" subtitle="AGB mit Kundeninformationen" />
<PageSection :content="AGB_CONTENT" />
</main>
</template>
<script setup lang="ts">
import { AGB_CONTENT } from "~/composables/usePageContent";
useSeoMeta({
title: "AGB | MUELLERPRINTS",
description: "Allgemeine Geschäftsbedingungen von MUELLERPRINTS. Informationen zu Vertragsschluss, Widerrufsrecht, Lieferung und mehr.",
});
</script>

67
pages/anfahrt.vue Normal file
View File

@@ -0,0 +1,67 @@
<template>
<main class="pb-20">
<!-- Hero -->
<PageHero title="Anfahrt" subtitle="So finden Sie uns in Stuttgart" />
<!-- Map and Address -->
<section class="py-12 lg:py-16">
<div class="xl:container mx-auto px-6">
<div class="grid lg:grid-cols-2 gap-12">
<!-- Map -->
<div>
<LocationMap
:address="{
name: CONTACT_INFO.name,
street: CONTACT_INFO.street,
postalCode: CONTACT_INFO.postalCode,
city: CONTACT_INFO.city,
}"
:show-address="false"
/>
</div>
<!-- Address and Directions -->
<div class="space-y-8">
<div class="bg-white rounded-xl shadow-lg p-8">
<h2 class="text-xl font-bold mb-6">Adresse</h2>
<ContactInfo :contact="CONTACT_INFO" />
</div>
<div class="bg-gray-50 rounded-xl p-8">
<h3 class="text-lg font-bold mb-4">Anfahrt mit dem Auto</h3>
<p class="text-gray-600">Wir befinden uns in Stuttgart-Ost im Stadtteil Gaisburg. Parkmöglichkeiten finden Sie in den umliegenden Straßen.</p>
</div>
<div class="bg-gray-50 rounded-xl p-8">
<h3 class="text-lg font-bold mb-4">Mit öffentlichen Verkehrsmitteln</h3>
<p class="text-gray-600">
<strong>Stadtbahn:</strong> Haltestelle Ostendplatz (U4, U9)<br />
Von dort ca. 5 Minuten Fußweg.
</p>
</div>
</div>
</div>
</div>
</section>
<!-- Opening Hours Teaser -->
<section class="py-12 lg:py-16 bg-gray-900 text-white">
<div class="xl:container mx-auto px-6 text-center">
<h2 class="text-2xl font-bold mb-4">Besuchen Sie uns</h2>
<p class="text-gray-300 mb-8">Wir freuen uns auf Ihren Besuch in unserer Werkstatt.</p>
<NuxtLink to="/oeffnungszeiten" class="inline-block px-8 py-3 bg-white text-gray-900 font-semibold rounded-full hover:bg-gray-100 transition-colors"> Öffnungszeiten ansehen </NuxtLink>
</div>
</section>
</main>
</template>
<script setup lang="ts">
import { CONTACT_INFO } from "~/composables/usePageContent";
useSeoMeta({
title: "Anfahrt | MUELLERPRINTS",
description: `Besuchen Sie uns in Stuttgart: ${CONTACT_INFO.street}, ${CONTACT_INFO.postalCode} ${CONTACT_INFO.city}. Jetzt Route planen.`,
ogTitle: "Anfahrt | MUELLERPRINTS",
ogDescription: `So finden Sie uns: ${CONTACT_INFO.street}, ${CONTACT_INFO.postalCode} ${CONTACT_INFO.city}`,
});
</script>

180
pages/cart.vue Normal file
View File

@@ -0,0 +1,180 @@
<template>
<main class="pt-4 pb-20 lg:container lg:max-w-screen-lg lg:mx-auto px-6">
<div class="relative w-full">
<!-- Loading state -->
<div v-if="isLoading" class="flex items-center justify-center h-[40vh]">
<div class="text-center">
<LoadingSpinner />
<p class="text-xl mt-4">Warenkorb wird geladen...</p>
</div>
</div>
<!-- Cart has products -->
<div v-else-if="cartProducts.length > 0" class="flex flex-col gap-8 w-full" data-e2e="cart">
<Heading :level="2" html-tag="h1" classes="text-center">
Im Warenkorb gesamt: <span class="text-nowrap">{{ numberFormatter(cart.total.value) }} </span>
</Heading>
<div class="grid place-content-center">
<Button href="/checkout" @click="handleCheckoutClickTop">Jetzt bezahlen</Button>
</div>
<hr />
<div class="flex flex-col gap-8">
<ul class="divide-y divide-gray-300" data-e2e="cart-products">
<li v-for="(position, index) in cartProducts" :key="index" class="py-6 gap-6 flex items-center justify-between">
<a v-if="position.product.images?.images" :href="`/details/${position.product.slug}`">
<img :src="position.product.images.images[0].formats.thumbnail.url" :alt="position.product.name" class="w-24 object-cover" />
</a>
<div v-else class="bg-black opacity-5 w-12 h-12"></div>
<div class="flex flex-col gap-3 lg:gap-6 lg:flex-row lg:items-center justify-between flex-grow">
<a :href="`/details/${position.product.slug}`" class="text-xl font-bold flex-grow">{{ position.product.name }}</a>
<div class="flex items-center justify-between">
<select
@change="changeCountCart(position, parseInt(($event.target as HTMLSelectElement).value))"
class="block appearance-none w-16 text-center bg-white border border-gray-300 hover:border-gray-500 px-4 py-2 rounded shadow leading-tight focus:outline-none focus:border-indigo-500 focus:shadow-outline"
>
<option v-for="count in 10" :key="count" :selected="position.count === count" :value="count">
{{ count }}
</option>
</select>
<div class="flex flex-col gap-2 flex-end w-24">
<span class="text-xl font-bold text-right text-nowrap">{{ numberFormatter(position.product.totalProductPrice * position.count) }} </span>
<button @click="removeFromCart(position.product.id, position.count)" class="text-gray-500 hover:text-gray-900 hover:underline text-right">
Entfernen
</button>
</div>
</div>
</div>
</li>
</ul>
<hr />
<div>
<div class="flex flex-col lg:flex-row justify-between mb-2">
<span class="text-2xl font-bold">Deine Gesamtsumme</span>
<div class="flex flex-col lg:flex-end">
<span class="text-2xl font-bold lg:text-right text-nowrap">{{ totalFormatted }} </span>
<span class="text-gray-600 text-sm lg:text-right">
Enthält MwSt. in Höhe von {{ VATFormatted }} {{ deliveryFormattedOrEmpty ? `inkl. ${deliveryFormattedOrEmpty}` : `zzgl.` }} Versandkosten
</span>
</div>
</div>
</div>
<div class="grid lg:place-content-end">
<Button href="/checkout" classes="w-full" @click="handleCheckoutClickBottom">Jetzt bezahlen</Button>
</div>
</div>
</div>
<!-- Cart is empty -->
<div v-else class="w-full">
<Heading :level="2" html-tag="h1" classes="text-center">Dein Warenkorb ist leer.</Heading>
<div class="grid place-content-center">
<NuxtLink
to="/notebooks"
class="inline-flex items-center justify-center px-8 py-3 rounded-full font-medium transition-all duration-200 bg-gray-900 text-white hover:bg-gray-700"
@click="handleContinueShopping"
>
Entdecke unsere Produkte
</NuxtLink>
</div>
</div>
</div>
</main>
</template>
<script setup lang="ts">
import { numberFormatter } from "~/utils/numberFormatter";
import { trackEvent } from "~/utils/trackEvent";
import type { CartProduct } from "~/types";
const shopApi = useShopApi();
const cart = useCart();
// Ensure cart is initialized on mount
onMounted(async () => {
await cart.ensureReady();
// Track cart view after cart is loaded
trackEvent("cart-viewed", {
itemCount: cart.productsCount.value,
cartValue: cart.total.value
});
});
// Local computed for cleaner template access
const isLoading = computed(() => !cart.isInitialized.value);
const cartProducts = computed(() => cart.products.value || ([] as CartProduct[]));
const totalFormatted = computed(() => numberFormatter(cart.total.value));
const VATFormatted = computed(() => numberFormatter(cart.VAT.value));
const deliveryFormattedOrEmpty = computed(() => (cart.delivery.value ? numberFormatter(cart.delivery.value, "€ ") : ""));
async function removeFromCart(productId: number, count = 1) {
try {
const product = cartProducts.value.find((p) => p.product.id === productId);
const cartAfterUpdate = await shopApi.removeProductFromCart(cart.uuid.value, productId, count);
cart.overwrite(cartAfterUpdate);
trackEvent("cart-product-removed", {
productId,
productSlug: product?.product.slug,
quantity: count,
cartValue: cart.total.value
});
} catch (error) {
console.error(`Could not remove product ${productId} from cart:`, error);
}
}
async function addToCart(productId: number, count = 1) {
try {
const cartAfterUpdate = await shopApi.addProductToCart(cart.uuid.value, productId, count);
cart.overwrite(cartAfterUpdate);
} catch (error) {
console.error(`Could not add product ${productId} to cart:`, error);
}
}
async function changeCountCart(position: CartProduct, count: number) {
const oldCount = position.count;
if (count > position.count) {
await addToCart(position.product.id, count - position.count);
} else if (count < position.count) {
await removeFromCart(position.product.id, position.count - count);
}
trackEvent("cart-quantity-changed", {
productId: position.product.id,
oldQuantity: oldCount,
newQuantity: count
});
}
function handleCheckoutClickTop() {
trackEvent("cart-cta-top-clicked", {
itemCount: cart.productsCount.value,
cartValue: cart.total.value
});
}
function handleCheckoutClickBottom() {
trackEvent("cart-cta-bottom-clicked", {
itemCount: cart.productsCount.value,
cartValue: cart.total.value
});
}
function handleContinueShopping() {
trackEvent("cart-empty-continue-shopping");
}
// SEO
useSeoMeta({
title: "Warenkorb | MUELLERPRINTS",
description: "Ihr Warenkorb bei MUELLERPRINTS"
});
</script>

150
pages/checkout/1.vue Normal file
View File

@@ -0,0 +1,150 @@
<template>
<main class="pt-4 lg:container mx-auto px-4 pb-20">
<!-- Error state -->
<div v-if="displayState === 'error'" class="py-12">
<CheckoutError @retry="cart.retry()" />
</div>
<!-- Loading skeleton -->
<div v-else-if="displayState === 'loading'">
<Stepper :step="1" />
<div class="mt-8 mb-12 max-w-screen-md mx-auto">
<div class="h-8 w-3/4 bg-gray-200 rounded mb-8 animate-pulse"></div>
<CheckoutSkeleton variant="form" :fields="1" />
<div class="flex gap-4 mt-6 animate-pulse">
<div class="h-5 w-5 bg-gray-200 rounded"></div>
<div class="h-4 w-64 bg-gray-200 rounded"></div>
</div>
<hr class="my-12" />
<div class="h-12 bg-gray-200 rounded-full w-full animate-pulse"></div>
</div>
</div>
<!-- Main content -->
<div v-else-if="displayState === 'main-content'">
<Stepper :step="1" />
<div class="mt-8 mb-12 flex flex-col gap-8 max-w-screen-md mx-auto">
<Heading :level="2" html-tag="h1">Wie lauten deine Kontaktinformationen?</Heading>
<div v-if="uuid">
<form @submit.prevent="submit" class="flex flex-col gap-12">
<Input label="E-Mail-Adresse" v-model="emailAddress" :required="true" autocomplete="email" />
<label class="flex gap-4 text-sm cursor-pointer">
<input v-model="acceptedTermsAndConditions" required type="checkbox" class="w-4 cursor-pointer" />
<span>
Mit der Anmeldung bestätige ich, die
<a href="/agb" target="_blank" class="underline" @click="trackEvent('checkout-terms-clicked')">AGB</a>
und
<a href="/datenschutz" target="_blank" class="underline" @click="trackEvent('checkout-privacy-clicked')">Datenschutzerklärung</a>
gelesen und verstanden zu haben und stimme diesen zu.
</span>
</label>
<hr />
<Button type="submit" classes="w-full" :is-pending="formSubmitIsPending">Weiter zur Lieferadresse</Button>
</form>
</div>
</div>
</div>
<div v-else-if="displayState === 'products-warning'">
<div class="bg-yellow-100 border-l-4 border-yellow-500 p-4 mb-8">
<p class="text-yellow-700 font-semibold mb-4">Dein Warenkorb ist leer, bitte füge Produkte hinzu, um fortzufahren.</p>
<NuxtLink to="/" class="text-yellow-700 hover:underline">Zurück zu unseren Produkten</NuxtLink>
</div>
</div>
</main>
</template>
<script setup lang="ts">
import { trackEvent } from "~/utils/trackEvent";
const COOKIE_CONSENT_KEY = "shop:cookie-consent";
const cart = useCart();
const emailAddress = ref("");
const acceptedTermsAndConditions = ref(false);
const formSubmitIsPending = ref(false);
const uuid = computed(() => cart.uuid.value);
const displayState = computed(() => {
// Error state - show retry option
if (cart.hasError.value) {
return "error";
}
// Still initializing - show skeleton
if (!cart.isInitialized.value) {
return "loading";
}
// Initialized but no products - show warning
if (cart.productsCount.value === 0) {
return "products-warning";
}
// Ready to show form
return "main-content";
});
onMounted(async () => {
// Ensure cart is ready before using its values
await cart.ensureReady();
emailAddress.value = cart.emailAddress.value || "";
// Track checkout step 1 view
trackEvent("checkout-step-1-viewed", {
cartValue: cart.total.value,
itemCount: cart.productsCount.value
});
});
// Track terms acceptance
watch(acceptedTermsAndConditions, (accepted) => {
if (accepted) {
trackEvent("checkout-step-1-terms-accepted");
}
});
async function submit() {
if (!uuid.value) {
console.error("Cart not found, cannot submit form");
return;
}
if (!acceptedTermsAndConditions.value) {
console.error("Terms and conditions not accepted, cannot submit form");
return;
}
try {
formSubmitIsPending.value = true;
// Use cart.update() to sync local state after API call
await cart.update({
email: emailAddress.value,
acceptedTermsAndConditionsAt: new Date().toISOString()
});
if (import.meta.client) {
localStorage.setItem(COOKIE_CONSENT_KEY, new Date().toISOString());
}
trackEvent("checkout-step-1-completed", {
cartValue: cart.total.value
});
navigateTo("/checkout/2");
} catch (error) {
console.error("Error submitting email address", error);
} finally {
formSubmitIsPending.value = false;
}
}
// SEO
useSeoMeta({
title: "Checkout - E-Mail | MUELLERPRINTS"
});
</script>

274
pages/checkout/2.vue Normal file
View File

@@ -0,0 +1,274 @@
<template>
<main class="pt-4 lg:container mx-auto px-4 pb-20">
<!-- Error state -->
<div v-if="displayState === 'error'" class="py-12">
<CheckoutError @retry="cart.retry()" />
</div>
<!-- Loading skeleton -->
<div v-else-if="displayState === 'loading'">
<Stepper :step="2" />
<div class="mt-8 mb-12 max-w-screen-md mx-auto">
<div class="h-8 w-3/4 bg-gray-200 rounded mb-8 animate-pulse"></div>
<CheckoutSkeleton variant="form" :fields="4" />
<hr class="my-12" />
<CheckoutSkeleton variant="delivery" />
<hr class="my-12" />
<div class="h-12 bg-gray-200 rounded-full w-full animate-pulse"></div>
</div>
</div>
<!-- Terms warning -->
<div v-else-if="displayState === 'terms-warning'">
<div class="bg-yellow-100 border-l-4 border-yellow-500 p-4 mb-8">
<p class="text-yellow-700 font-semibold mb-4">
Bitte, akzeptiere die
<a href="/agb" target="_blank" class="underline" @click="trackEvent('checkout-terms-clicked')">AGB</a>
und
<a href="/datenschutz" target="_blank" class="underline" @click="trackEvent('checkout-privacy-clicked')">Datenschutzerklärung</a>, um fortzufahren.
</p>
<NuxtLink to="/checkout/1" class="text-yellow-700 hover:underline">Zurück zur Kasse</NuxtLink>
</div>
</div>
<!-- Main content -->
<div v-else-if="displayState === 'main-content'">
<Stepper :step="2" />
<div class="mt-8 mb-12 flex flex-col gap-8 max-w-screen-md mx-auto">
<Heading html-tag="h1" :level="2">Gib deinen Namen und Adresse ein:</Heading>
<form @submit.prevent="submit" v-if="uuid">
<div class="flex flex-col gap-4 max-w-screen-md">
<Input label="Vorname" v-model="address.name" :required="true" autocomplete="given-name" />
<Input label="Nachname" v-model="address.surname" :required="true" autocomplete="family-name" />
<Input label="Straße und Hausnummer:" v-model="address.street" :required="true" autocomplete="street-address" />
<div class="flex gap-4">
<Input label="PLZ" v-model="address.postalCode" :required="true" label-class="w-1/2" autocomplete="postal-code" />
<Input label="Ort" v-model="address.city" :required="true" label-class="w-1/2" autocomplete="address-level2" />
</div>
<label class="flex items-center gap-2 my-4 cursor-pointer">
<input type="checkbox" v-model="showOptionalDeliveryAddress" />
<span>Abweichende Lieferadresse</span>
</label>
</div>
<div class="flex flex-col gap-4 max-w-screen-md" v-if="showOptionalDeliveryAddress">
<Input label="Vorname" v-model="deliveryAddress.name" :required="showOptionalDeliveryAddress" />
<Input label="Nachname" v-model="deliveryAddress.surname" :required="showOptionalDeliveryAddress" />
<Input label="Straße und Hausnummer:" v-model="deliveryAddress.street" :required="showOptionalDeliveryAddress" />
<div class="flex gap-4">
<Input label="PLZ" v-model="deliveryAddress.postalCode" label-class="w-1/2" :required="showOptionalDeliveryAddress" />
<Input label="Ort" v-model="deliveryAddress.city" label-class="w-1/2" :required="showOptionalDeliveryAddress" />
</div>
</div>
<hr class="my-12" />
<fieldset class="space-y-4 mb-8" aria-required="true">
<p class="text-md font-semibold">Wähle deine Versandart:</p>
<div v-for="method in deliveryMethods" :key="method.id" class="relative">
<input
type="radio"
name="delivery-method"
:id="'delivery-' + method.id"
:value="method.id"
v-model="selectedDeliveryMethod"
class="peer right-6 top-6 absolute"
required
/>
<label
:for="'delivery-' + method.id"
class="inline-flex items-center justify-between w-full p-5 bg-white border-2 rounded-lg cursor-pointer group border-neutral-200/70 text-neutral-600 peer-checked:border-blue-400 peer-checked:text-neutral-900 peer-checked:bg-blue-200/50 hover:text-neutral-900 hover:border-neutral-300"
>
<div class="flex items-center space-x-5">
<div class="flex flex-col justify-start">
<div class="w-full text-lg font-semibold">{{ method.name }}</div>
<div class="w-full text-sm opacity-60">{{ method.description }}</div>
<div class="w-full text-sm mt-2 font-medium">
{{ method.price === 0 ? "Kostenlos" : numberFormatter(method.price, "€") }}
</div>
</div>
</div>
</label>
</div>
</fieldset>
<hr class="my-12" />
<div class="mt-12">
<Button classes="w-full" type="submit" :is-pending="formSubmitIsPending">Weiter zur Zahlung</Button>
</div>
</form>
</div>
</div>
</main>
</template>
<script setup lang="ts">
import type { Address, StructuredAddress } from "~/types";
import { trackEvent } from "~/utils/trackEvent";
import { numberFormatter } from "~/utils/numberFormatter";
const cart = useCart();
const shopApi = useShopApi();
const isLoading = ref(true);
const displayState = computed(() => {
// Error state - show retry option
if (cart.hasError.value) {
return "error";
}
// Still loading
if (isLoading.value) {
return "loading";
}
// Terms not accepted - show warning
if (!acceptedTermsAndConditionsAt.value) {
return "terms-warning";
}
// Ready to show form
return "main-content";
});
const showOptionalDeliveryAddress = ref(false);
const formSubmitIsPending = ref(false);
const deliveryMethods = ref<any[]>([]);
const selectedDeliveryMethod = ref<number | null>(null);
const address = ref<Address>({
name: "",
surname: "",
street: "",
postalCode: "",
city: ""
});
const deliveryAddress = ref<Address>({
name: "",
surname: "",
street: "",
postalCode: "",
city: ""
});
const uuid = computed(() => cart.uuid.value);
const acceptedTermsAndConditionsAt = computed(() => cart.acceptedTermsAndConditionsAt.value);
onMounted(async () => {
try {
// Ensure cart is initialized first
await cart.ensureReady();
const { data } = await shopApi.getDeliveryMethods();
deliveryMethods.value = data.map(({ id, attributes }: any) => ({ id, ...attributes }));
selectedDeliveryMethod.value = cart.deliveryMethod.value;
if (cart.invoiceAddressStructured.value) {
address.value = {
name: cart.invoiceAddressStructured.value.givenName,
surname: cart.invoiceAddressStructured.value.familyName,
street: cart.invoiceAddressStructured.value.streetAddress,
city: cart.invoiceAddressStructured.value.addressLevel2,
postalCode: cart.invoiceAddressStructured.value.postalCode
};
}
if (cart.deliveryAddressStructured.value) {
deliveryAddress.value = {
name: cart.deliveryAddressStructured.value.givenName,
surname: cart.deliveryAddressStructured.value.familyName,
street: cart.deliveryAddressStructured.value.streetAddress,
city: cart.deliveryAddressStructured.value.addressLevel2,
postalCode: cart.deliveryAddressStructured.value.postalCode
};
}
// Track checkout step 2 view
trackEvent("checkout-step-2-viewed", {
cartValue: cart.total.value
});
} catch (e) {
console.error("Error fetching delivery methods", e);
} finally {
isLoading.value = false;
}
});
// Track delivery method selection
watch(selectedDeliveryMethod, (methodId) => {
if (methodId) {
const method = deliveryMethods.value.find((m) => m.id === methodId);
trackEvent("checkout-step-2-delivery-selected", {
methodId,
methodName: method?.name,
price: method?.price ?? 0
});
}
});
// Track different delivery address toggle
watch(showOptionalDeliveryAddress, (toggled) => {
if (toggled) {
trackEvent("checkout-step-2-different-delivery-toggled");
}
});
function mapToStructuredAddress(addr: Address): StructuredAddress {
return {
givenName: addr.name,
familyName: addr.surname,
streetAddress: addr.street,
postalCode: addr.postalCode,
addressLevel2: addr.city,
country: "DE"
};
}
function addressToString(addr: Address) {
return `${addr.name} ${addr.surname}\n${addr.street}\n${addr.postalCode} ${addr.city}`;
}
async function submit() {
if (!uuid.value) {
console.error("Cart not found, cannot submit address form");
return;
}
const invoiceAddressString = addressToString(address.value);
const deliveryAddressString = showOptionalDeliveryAddress.value ? addressToString(deliveryAddress.value) : invoiceAddressString;
const invoiceAddressStructured = mapToStructuredAddress(address.value);
const deliveryAddressStructured = showOptionalDeliveryAddress.value ? mapToStructuredAddress(deliveryAddress.value) : invoiceAddressStructured;
try {
formSubmitIsPending.value = true;
// Use cart.update() to sync local state after API call
await cart.update({
invoiceAddress: invoiceAddressString,
deliveryAddress: deliveryAddressString,
invoiceAddressStructured,
deliveryAddressStructured,
delivery: selectedDeliveryMethod.value
});
const selectedMethod = deliveryMethods.value.find((m) => m.id === selectedDeliveryMethod.value);
trackEvent("checkout-step-2-completed", {
cartValue: cart.total.value,
deliveryMethod: selectedMethod?.name
});
navigateTo("/checkout/3");
} catch (error) {
console.error("Error submitting address form:", error);
} finally {
formSubmitIsPending.value = false;
}
}
// SEO
useSeoMeta({
title: "Checkout - Adresse | MUELLERPRINTS"
});
</script>

374
pages/checkout/3.vue Normal file
View File

@@ -0,0 +1,374 @@
<template>
<main class="pt-4 lg:container mx-auto px-4 pb-20">
<!-- Error state -->
<div v-if="displayState === 'error'" class="py-12">
<CheckoutError
title="Bestellung nicht gefunden"
message="Deine Bestellung konnte nicht geladen werden. Bitte versuche es erneut."
@retry="fetchOrder"
/>
</div>
<!-- Loading skeleton -->
<div v-else-if="displayState === 'loading'">
<Stepper :step="3" />
<div class="mt-8 mb-12 flex flex-col-reverse lg:flex-row gap-8 mx-auto">
<div class="lg:w-1/2">
<div class="h-48 bg-gray-200 rounded-lg animate-pulse"></div>
</div>
<div class="lg:w-1/2">
<CheckoutSkeleton variant="summary" />
</div>
</div>
</div>
<!-- Main content -->
<div v-else-if="displayState === 'main-content'">
<Stepper :step="3" />
<div class="mt-8 mb-12 flex flex-col-reverse lg:flex-row gap-8 mx-auto relative">
<div class="lg:w-1/2">
<div
v-if="hasAuthorisedPayment"
id="alert-additional-content-3"
class="p-4 mb-4 text-green-800 border border-green-300 rounded-lg bg-green-50"
role="alert"
>
<div class="flex items-center">
<svg class="flex-shrink-0 w-4 h-4 me-2" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 20 20">
<path
d="M10 .5a9.5 9.5 0 1 0 9.5 9.5A9.51 9.51 0 0 0 10 .5ZM9.5 4a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3ZM12 15H8a1 1 0 0 1 0-2h1v-3H8a1 1 0 0 1 0-2h2a1 1 0 0 1 1 1v4h1a1 1 0 0 1 0 2Z"
/>
</svg>
<span class="sr-only">Info</span>
<h3 class="text-lg font-semibold">Das hat geklappt!</h3>
</div>
<div class="mt-2 mb-4">
<p>Deine Bezahlung ist erfolgreich angekommen. Herzlichen Glückwunsch!</p>
</div>
<div class="flex justify-center items-center my-6">
<LoadingSpinner v-if="isRedirecting" />
</div>
<div class="flex items-center gap-2 justify-between">
<a
:href="`/checkout/result/${orderData.uuid}`"
class="text-nowrap text-white bg-green-800 hover:bg-green-900 focus:ring-4 focus:outline-none focus:ring-green-300 font-medium rounded-lg text-md px-3 py-1.5 me-2 text-center inline-flex items-center"
>
Zur Bestellübersicht
</a>
<p class="text-sm">In wenigen Momenten wirst du weitergeleitet, dort erhälst du alle Informationen zu deiner Bestellung.</p>
</div>
</div>
<div v-else>
<div v-if="!hasPaymentError" id="paypal-button-container" class="payment sticky top-4" ref="paymentContainer"></div>
<div v-else class="p-4 lg:p-8 text-center mx-auto rounded-md bg-rose-100" data-e2e="payment-error">
<span class="text-rose-800">Es ist ein Fehler aufgetreten. Bitte versuche es erneut.</span>
</div>
<!-- Trust signals -->
<div class="mt-6 pt-6 border-t border-gray-200">
<div class="flex items-center gap-2 text-gray-600 text-sm mb-3">
<svg class="w-4 h-4 text-green-600" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75m-3-7.036A11.959 11.959 0 0 1 3.598 6 11.99 11.99 0 0 0 3 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285Z" />
</svg>
<span>Sichere SSL-Verschlüsselung</span>
</div>
<p class="text-xs text-gray-500">
Zahlungsarten: PayPal, Kreditkarte, Lastschrift, Rechnung
</p>
<p class="text-xs text-gray-500 mt-2">
Fragen? <a href="/kontakt" class="underline hover:text-gray-700">Kontakt</a> oder <a href="tel:+4971125350740" class="underline hover:text-gray-700">0711 253 507 40</a>
</p>
</div>
</div>
</div>
<div class="lg:w-1/2 flex flex-col-reverse lg:flex-col mt-10 lg:mt-0">
<div v-if="orderData && orderData.cart" class="flex flex-col gap-8">
<Heading :level="1">Deine Bestellung</Heading>
<ul class="divide-y divide-gray-300" data-e2e="cart-products">
<li v-for="(position, index) in orderData.cart" :key="index" class="py-6 gap-6 flex items-center justify-between">
<img
v-if="position.product?.images?.images"
:src="position.product?.images?.images[0]?.formats?.thumbnail?.url"
:alt="position.product.name"
class="w-16 lg:w-24 object-cover"
/>
<div v-else class="bg-black opacity-5 w-6 h-6"></div>
<span class="lg:text-xl flex-grow">{{ position.product.name }}</span>
<div class="block text-center bg-white border border-gray-300 px-4 py-2 rounded shadow leading-tight">
{{ position.count }}
</div>
<div class="flex flex-col gap-2 flex-end w-24">
<span class="lg:text-xl text-right text-nowrap">{{ numberFormatter(position.product.totalProductPrice * position.count) }} </span>
</div>
</li>
</ul>
<hr />
<div>
<div class="flex justify-between mb-2">
<span class="text-2xl">Zwischensumme</span>
<span class="text-2xl">{{ numberFormatter(orderData.subtotal) }} </span>
</div>
<div class="flex justify-between mb-2">
<span class="text-2xl">Versand</span>
<span class="text-2xl">{{ orderData.delivery?.price ? numberFormatter(orderData.delivery.price, "€") : "KOSTENFREI" }}</span>
</div>
<div class="flex justify-between mb-2">
<span class="text-2xl font-bold">Gesamtsumme</span>
<div class="flex flex-col flex-end">
<span class="text-2xl font-bold text-right">{{ numberFormatter(orderData.total) }} </span>
<span class="text-gray-600 text-sm text-right">inkl. MwSt. {{ numberFormatter(orderData.VAT) }} </span>
</div>
</div>
</div>
</div>
<div class="flex flex-col gap-8">
<div class="bg-gray-100 p-4 lg:p-8 text-gray-700 rounded-lg relative">
<div class="text-lg">
<Heading :level="3">Kontaktinformation</Heading>
<p>{{ orderData.email }}</p>
</div>
<a v-if="!hasAuthorisedPayment" href="/checkout/1" class="absolute bottom-8 right-12 hover:underline underline-offset-2" @click="trackEvent('checkout-change-email-clicked')">Ändern</a>
</div>
<div class="bg-gray-100 p-4 lg:p-8 text-gray-700 rounded-lg relative">
<Heading :level="3">Rechnungsadresse:</Heading>
<p class="text-lg whitespace-pre-line" v-text="orderData.invoiceAddress" />
<a v-if="!hasAuthorisedPayment" href="/checkout/2" class="absolute bottom-8 right-12 hover:underline underline-offset-2" @click="trackEvent('checkout-change-invoice-address-clicked')">Ändern</a>
</div>
<div v-if="orderData.deliveryAddress" class="bg-gray-100 p-4 lg:p-8 text-gray-700 rounded-lg relative">
<Heading :level="3">Lieferadresse:</Heading>
<p class="text-lg whitespace-pre-line" v-text="orderData.deliveryAddress" />
<a v-if="!hasAuthorisedPayment" href="/checkout/2" class="absolute bottom-8 right-12 hover:underline underline-offset-2" @click="trackEvent('checkout-change-delivery-address-clicked')">Ändern</a>
</div>
<div class="bg-gray-100 p-4 lg:p-8 text-gray-700 rounded-lg">
<div>
<Heading :level="4">Hinweise zum Datenschutz</Heading>
<p class="mt-1 text-sm">
Die personenbezogenen Daten werden für die Abwicklung der Bestellung automatisiert verarbeitet. Der Schutz Ihrer persönlichen Daten ist uns
wichtig. Daher verwenden wir bei der Übertragung moderne Verschlüsselungstechnologien. Weiteres entnehmen Sie bitte unseren
<a href="/datenschutz" target="_blank" class="underline" @click="trackEvent('checkout-privacy-clicked')">Datenschutzhinweisen</a>.
</p>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Terms warning -->
<div v-else-if="displayState === 'terms-warning'">
<div class="bg-yellow-100 border-l-4 border-yellow-500 p-4 mb-8">
<p class="text-yellow-700 font-semibold mb-4">
Bitte, akzeptiere die
<a href="/agb" target="_blank" class="underline" @click="trackEvent('checkout-terms-clicked')">AGB</a>
und
<a href="/datenschutz" target="_blank" class="underline" @click="trackEvent('checkout-privacy-clicked')">Datenschutzerklärung</a>, um fortzufahren.
</p>
<NuxtLink to="/checkout/1" class="text-yellow-700 hover:underline">Zurück zur Kasse</NuxtLink>
</div>
</div>
<!-- Address warning -->
<div v-else-if="displayState === 'address-warning'">
<div class="bg-yellow-100 border-l-4 border-yellow-500 p-4 mb-8">
<p class="text-yellow-700 font-semibold mb-4">Bitte, gib deine Lieferadresse an, um fortzufahren.</p>
<NuxtLink to="/checkout/2" class="text-yellow-700 hover:underline">Zurück zur Kasse</NuxtLink>
</div>
</div>
</main>
</template>
<script setup lang="ts">
import { numberFormatter } from "~/utils/numberFormatter";
import { trackEvent } from "~/utils/trackEvent";
import type { Order } from "~/types";
const config = useRuntimeConfig();
const shopApi = useShopApi();
const CART_UUID_KEY = "shop:cart";
const hasPaymentError = ref(false);
const hasOrderError = ref(false);
const hasAuthorisedPayment = ref(false);
const orderData = ref<Order>({} as Order);
const isLoading = ref(true);
const isRedirecting = ref(false);
const paymentContainer = ref<HTMLElement | null>(null);
const displayState = computed(() => {
// Error state
if (hasOrderError.value) {
return "error";
}
// Loading state
if (isLoading.value || !orderData.value) {
return "loading";
}
// Validation warnings
if (!orderData.value.acceptedTermsAndConditionsAt) {
return "terms-warning";
}
if (!orderData.value.invoiceAddress) {
return "address-warning";
}
return "main-content";
});
onMounted(async () => {
await fetchOrder();
if (displayState.value === "main-content") {
try {
await initializePayPalButtons();
} catch (error) {
console.error("Error initializing PayPal:", error);
hasPaymentError.value = true;
}
}
});
async function fetchOrder() {
try {
isLoading.value = true;
hasOrderError.value = false;
const uuidFromLocalStorage = import.meta.client ? localStorage.getItem(CART_UUID_KEY) : null;
if (!uuidFromLocalStorage) {
console.error("No UUID in local storage");
hasOrderError.value = true;
isLoading.value = false;
return;
}
orderData.value = await shopApi.getOrder(uuidFromLocalStorage);
if (orderData.value.paymentAuthorised) {
hasAuthorisedPayment.value = true;
}
// Track checkout step 3 view
trackEvent("checkout-step-3-viewed", {
orderTotal: orderData.value.total,
itemCount: orderData.value.cart?.length ?? 0
});
} catch (error) {
console.error("Error fetching order:", error);
hasOrderError.value = true;
} finally {
isLoading.value = false;
}
}
async function initializePayPalButtons() {
if (!orderData.value.total) {
console.error("Order total not available or zero");
hasPaymentError.value = true;
return;
}
const { loadScript } = await import("@paypal/paypal-js");
try {
const paypal = await loadScript({ clientId: config.public.paypalClientId as string, currency: "EUR" });
if (!paypal || !paypal.Buttons) {
console.error("PayPal script not loaded");
hasPaymentError.value = true;
return;
}
await paypal
.Buttons({
createOrder: async () => {
try {
trackEvent("checkout-paypal-initiated", {
orderTotal: orderData.value.total
});
const response = await shopApi.checkoutOrder(orderData.value.uuid);
return response.id;
} catch (error) {
console.error("Error creating PayPal order:", error);
hasPaymentError.value = true;
trackEvent("checkout-payment-error", {
stage: "create-order",
orderTotal: orderData.value.total
});
throw error;
}
},
onApprove: async (data) => {
try {
// Capture payment server-side (CMS captures via PayPal server SDK and updates order)
const capturedOrder = await shopApi.capturePayment(orderData.value.uuid, data.orderID!);
if (capturedOrder.paymentAuthorised) {
orderData.value = capturedOrder;
await handleSuccessfulPayment();
} else {
console.error("Payment capture did not result in authorisation");
hasPaymentError.value = true;
trackEvent("checkout-payment-error", {
stage: "capture-failed",
orderTotal: orderData.value.total
});
}
} catch (error) {
console.error("Error capturing payment:", error);
hasPaymentError.value = true;
trackEvent("checkout-payment-error", {
stage: "capture-exception",
orderTotal: orderData.value.total
});
}
},
onError: (err) => {
console.error("PayPal button error:", err);
hasPaymentError.value = true;
trackEvent("checkout-payment-error", {
stage: "paypal-button",
orderTotal: orderData.value.total
});
}
})
.render("#paypal-button-container");
} catch (error) {
console.error("Error loading PayPal script:", error);
hasPaymentError.value = true;
}
}
async function handleSuccessfulPayment() {
if (!orderData.value.uuid) {
console.error("Order UUID not available");
return;
}
hasAuthorisedPayment.value = true;
isRedirecting.value = true;
// Track successful payment
trackEvent("checkout-payment-completed", {
orderTotal: orderData.value.total,
itemCount: orderData.value.cart?.length ?? 0
});
setTimeout(() => {
navigateTo(`/checkout/result/${orderData.value.uuid}`);
}, 3000);
window.scrollTo(0, 0);
}
// SEO
useSeoMeta({
title: "Checkout - Zahlung | MUELLERPRINTS"
});
</script>

8
pages/checkout/index.vue Normal file
View File

@@ -0,0 +1,8 @@
<script setup lang="ts">
// Redirect /checkout to /checkout/1
definePageMeta({
redirect: "/checkout/1"
});
navigateTo("/checkout/1", { replace: true });
</script>

View File

@@ -0,0 +1,236 @@
<template>
<main class="pt-4 lg:container mx-auto px-4">
<div v-if="displayState === 'loading'">
<div class="flex items-center justify-center h-[60vh]">
<div class="text-center">
<LoadingSpinner />
<p class="text-2xl mt-8 font-semibold">Bestellung wird geladen...</p>
<p class="text-lg">Bitte warte einen Moment, während wir deine Bestellung vorbereiten.</p>
</div>
</div>
</div>
<div v-else-if="displayState === 'main-content'">
<Stepper :step="4" />
<div class="flex gap-12 relative max-w-screen-lg mx-auto my-8">
<div v-if="order && order.id" class="flex flex-col gap-8 w-full">
<ClientOnly>
<ConfettiExplosion :colors="['#2563eb', '#ec4899', '#16a34a']" />
</ClientOnly>
<div>
<Heading :level="1">Vielen Dank für deine Bestellung bei MUELLERPRINTS!</Heading>
<Heading :level="2">Deine Bestellnummer: {{ order.id }}</Heading>
</div>
<p class="text-lg lg:w-2/3">
In Kürze erhälst du von uns eine E-Mail mit allen Einzelheiten zu deiner Bestellung. Du kannst sie auch hier herunterladen, sobald sie erstellt
wurde.
</p>
<div>
<Button id="download-invoice" :is-pending="!hasReachedMaxFailedRequests && (!isReadyToDownload || isDownloadPending)" @click="downloadInvoice">
<span v-if="hasReachedMaxFailedRequests">Bald verfügbar</span>
<span v-else>Rechnung herunterladen</span>
</Button>
<div v-if="hasReachedMaxFailedRequests" class="text-sm mt-3">
Es konnte noch keine Rechnung ermittelt werden. Bitte prüfe deine E-Mails oder kontaktiere uns: order@muellerprints.de
</div>
</div>
<hr />
<div class="flex flex-col gap-8">
<ul class="divide-y divide-gray-300" data-e2e="order-products">
<li v-for="(position, index) in orderProducts" :key="index" class="py-6 gap-6">
<div v-if="position.product" class="flex items-center justify-between">
<img
v-if="position.product?.images?.images"
:src="position.product?.images?.images[0]?.formats?.thumbnail?.url"
:alt="position.product?.name"
class="w-16 lg:w-24 object-cover"
/>
<div v-else class="bg-black opacity-5 w-12 h-12"></div>
<a :href="`/details/${position.product?.slug}`" class="p-2 text-xl font-bold flex-grow">{{ position.product?.name }}</a>
<span
data-e2e="cart-products-item-count"
class="block appearance-none w-16 text-center bg-white border border-gray-300 hover:border-gray-500 px-4 py-2 rounded shadow leading-tight focus:outline-none focus:border-indigo-500 focus:shadow-outline"
>
{{ position?.count }}
</span>
</div>
</li>
</ul>
</div>
</div>
</div>
</div>
<div v-else-if="displayState === 'terms-warning'">
<div class="bg-yellow-100 border-l-4 border-yellow-500 p-4 mb-8">
<p class="text-yellow-700 font-semibold">
Bitte, akzeptiere die
<a href="/agb" target="_blank" class="underline">AGB</a>
und
<a href="/datenschutz" target="_blank" class="underline">Datenschutzerklärung</a>, um fortzufahren.
</p>
<NuxtLink to="/checkout/1" class="text-yellow-700 hover:underline">Zurück zur Kasse</NuxtLink>
</div>
</div>
<div v-else-if="displayState === 'address-warning'">
<div class="bg-yellow-100 border-l-4 border-yellow-500 p-4 mb-8">
<p class="text-yellow-700 font-semibold">Bitte, gib deine Lieferadresse an, um fortzufahren.</p>
<NuxtLink to="/checkout/2" class="text-yellow-700 hover:underline">Zurück zur Kasse</NuxtLink>
</div>
</div>
<div v-else-if="displayState === 'payment-warning'">
<div class="bg-yellow-100 border-l-4 border-yellow-500 p-4 mb-8">
<p class="text-yellow-700 font-semibold">Bitte, gib eine Zahlungsart an, um fortzufahren.</p>
<NuxtLink to="/checkout/3" class="text-yellow-700 hover:underline">Zurück zur Kasse</NuxtLink>
</div>
</div>
<div v-else>
<div class="flex items-center justify-center h-[60vh]">
<div class="text-center">
<div class="w-20 h-20"></div>
<p class="text-2xl mt-8 font-semibold">Bestellung nicht gefunden...</p>
<p class="text-lg">Leider konnte deine Bestellung nicht unter dieser Adresse gefunden werden.</p>
<p class="text-lg mt-6">
Falls das Problem bestehen bleibt, <a href="/kontakt" class="underline">kontaktiere uns</a> bitte. Schicke deine Bestell-ID bitte an
paperwork@muellerprints.de
</p>
<pre class="mt-3 p-2 rounded-sm bg-gray-100">Bestell-ID: {{ uuid }}</pre>
</div>
</div>
</div>
</main>
</template>
<script setup lang="ts">
import type { Order } from "~/types";
import { trackEvent } from "~/utils/trackEvent";
const route = useRoute();
const uuid = computed(() => route.params.uuid as string);
const shopApi = useShopApi();
const order = ref<Order | null>(null);
const isLoading = ref(true);
const isDownloadPending = ref(false);
const failedDownloadRequests = ref(0);
const hasReachedMaxFailedRequests = ref(false);
const displayState = ref("loading");
const maxFailedRequests = 20;
const orderProducts = computed(() => order.value?.cart ?? []);
const isReadyToDownload = computed(() => !!order.value?.invoice);
onMounted(async () => {
let hasTrackedConversion = false;
const fetchAndSetOrder = async () => {
try {
order.value = await shopApi.getOrder(uuid.value);
updateDisplayState();
if (!order.value?.acceptedTermsAndConditionsAt || !order.value?.invoiceAddress || !order.value?.paymentAuthorised) {
console.error("Unauthorised view");
isLoading.value = false;
return;
}
// Track order confirmation view (only once)
if (!hasTrackedConversion) {
hasTrackedConversion = true;
trackEvent("order-confirmation-viewed", {
orderId: order.value.id,
orderTotal: order.value.total,
itemCount: order.value.cart?.length ?? 0
});
}
if (order.value?.invoice?.url) {
console.log("Invoice fetched successfully");
isLoading.value = false;
return;
}
failedDownloadRequests.value++;
if (failedDownloadRequests.value < maxFailedRequests) {
console.log("Retrying to fetch invoice", failedDownloadRequests.value);
setTimeout(fetchAndSetOrder, 3000);
} else {
hasReachedMaxFailedRequests.value = true;
isLoading.value = false;
}
} catch (e) {
console.error("Error fetching order:", e);
isLoading.value = false;
updateDisplayState();
}
};
await fetchAndSetOrder();
});
function updateDisplayState() {
if (!order.value) {
displayState.value = "loading";
return;
}
if (!order.value.id) {
displayState.value = "not-found-error";
return;
}
if (!order.value.acceptedTermsAndConditionsAt) {
displayState.value = "terms-warning";
return;
}
if (!order.value.invoiceAddress) {
displayState.value = "address-warning";
return;
}
if (!order.value.paymentAuthorised) {
displayState.value = "payment-warning";
return;
}
displayState.value = "main-content";
}
function downloadInvoice() {
if (!isReadyToDownload.value) {
console.error("Invoice not ready to download");
return;
}
try {
isDownloadPending.value = true;
if (order.value.invoice) {
trackEvent("order-invoice-downloaded", {
orderId: order.value.id
});
window.open(order.value.invoice.url, "_blank");
} else {
console.error("Invoice URL not available");
}
} catch (e) {
console.error("Error downloading invoice:", e);
} finally {
isDownloadPending.value = false;
}
}
// SEO
useSeoMeta({
title: "Bestellbestätigung | MUELLERPRINTS"
});
</script>

15
pages/datenschutz.vue Normal file
View File

@@ -0,0 +1,15 @@
<template>
<main class="pb-20">
<PageHero title="Datenschutzerklärung" subtitle="Informationen zum Datenschutz" />
<PageSection :content="DATENSCHUTZ_CONTENT" />
</main>
</template>
<script setup lang="ts">
import { DATENSCHUTZ_CONTENT } from "~/composables/usePageContent";
useSeoMeta({
title: "Datenschutz | MUELLERPRINTS",
description: "Datenschutzerklärung von MUELLERPRINTS. Informationen zur Erhebung und Verarbeitung personenbezogener Daten.",
});
</script>

596
pages/details/[slug].vue Normal file
View File

@@ -0,0 +1,596 @@
<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>

15
pages/impressum.vue Normal file
View File

@@ -0,0 +1,15 @@
<template>
<main class="pb-20">
<PageHero title="Impressum" subtitle="Rechtliche Informationen" />
<PageSection :content="IMPRESSUM_CONTENT" />
</main>
</template>
<script setup lang="ts">
import { IMPRESSUM_CONTENT } from "~/composables/usePageContent";
useSeoMeta({
title: "Impressum | MUELLERPRINTS",
description: "Impressum und rechtliche Informationen von MUELLERPRINTS, Max Müller, Stuttgart.",
});
</script>

199
pages/index.vue Normal file
View File

@@ -0,0 +1,199 @@
<template>
<main class="pt-0">
<h1 class="sr-only">MUELLERPRINTS Handgefertigte Notizbücher aus Stuttgart</h1>
<Hero />
<!-- First cover section -->
<section v-if="covers[0]" class="my-20 lg:container mx-auto px-6">
<Heading v-if="covers[0]?.name" :level="2" html-tag="h2">{{ covers[0].name }}</Heading>
<p v-if="covers[0]?.copyText?.details" class="leading-6 tracking-tight xl:w-2/3" v-html="covers[0].copyText.details" />
<div class="mt-8">
<ClientOnly>
<LazyCarousel>
<Slide v-for="(item, j) in covers[0]?.products" :key="j">
<ProductCard :product="item" />
</Slide>
</LazyCarousel>
</ClientOnly>
</div>
</section>
<!-- Storytelling: Handwerk aus Stuttgart -->
<FeatureModule
eyebrow="Handwerk aus Stuttgart"
headline="Jedes Notizbuch ein Unikat"
subtitle="Tradition trifft Nachhaltigkeit"
:body="`
In unserer Werkstatt verbinden wir <strong>traditionelle Buchbindekunst</strong> mit modernem Design.
Jedes Notizbuch wird von Hand gebunden mit Fadenheftung, die ein flaches Aufschlagen ermöglicht.
Wir verwenden ausschließlich <strong>100% Recyclingpapier</strong> aus deutscher Produktion.
`"
:image="productionImage01"
class="bg-gray-50"
/>
<!-- Remaining cover sections with alternating backgrounds -->
<template v-for="(coverData, i) in covers.slice(1)" :key="i">
<section :class="['my-20 lg:container mx-auto px-6']">
<Heading v-if="coverData?.name" :level="2" html-tag="h2">{{ coverData.name }}</Heading>
<p v-if="coverData?.copyText?.details" class="leading-6 tracking-tight xl:w-2/3" v-html="coverData.copyText.details" />
<div class="mt-8">
<ClientOnly>
<LazyCarousel>
<Slide v-for="(item, j) in coverData?.products" :key="j">
<ProductCard :product="item" />
</Slide>
</LazyCarousel>
</ClientOnly>
</div>
</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
</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">
<h3 class="text-2xl font-bold mb-3">Was unsere Kunden sagen</h3>
<p class="text-gray-600 max-w-2xl mx-auto">Erfahrungen und Bewertungen von zufriedenen Kunden</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>
-->
</main>
</template>
<script setup lang="ts">
import { Slide } from "vue3-carousel";
import productionImage01 from "~/assets/production/01.png";
import { trackEvent } from "~/utils/trackEvent";
import { scrollProgress } from "~/utils/scrollProgress";
import type { Testimonial } from "~/types";
const shopApi = useShopApi();
// Fetch covers with their products
const { data: coversData } = await useAsyncData("landing-covers", async () => {
try {
const { data } = await shopApi.getProductCovers();
const covers = await Promise.all(
data.map(async (cover: any) => {
const productsResponse = await shopApi.getCheapestProducts(cover.id, 1, 10);
return {
id: cover.id,
name: cover.attributes?.name,
sort: cover.attributes?.sort ?? 0,
copyText: cover.attributes?.copyText,
products: productsResponse?.data ?? []
};
})
);
return covers
.filter((c) => c.products.length > 0)
.sort((a, b) => a.sort - b.sort);
} catch (error) {
console.error("Error fetching covers:", error);
return [];
}
});
const covers = computed(() => coversData.value ?? []);
// Testimonials
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.",
rating: 5
},
{
name: "Julia B.",
purpose: "Studium",
comment: "Benutze es für Vorlesungsmitschriften. Das Papier ist top, mein Füller schmiert nicht.",
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
}
];
// Scroll tracking
const hasScrolled50 = ref(false);
const hasScrolled100 = ref(false);
function trackScrolling() {
if (hasScrolled100.value) return;
const progress = scrollProgress();
if (progress >= 50 && !hasScrolled50.value) {
trackEvent("start-page-scrolled-50");
hasScrolled50.value = true;
}
if (progress === 100) {
trackEvent("start-page-scrolled-100");
hasScrolled100.value = true;
}
}
// Toggle dark mode for hero section
const toggleDarkMode = inject<(value: boolean) => void>("toggleDarkMode");
onMounted(() => {
toggleDarkMode?.(true);
window.addEventListener("scroll", trackScrolling);
// Track start page view
trackEvent("start-page-viewed", {
coverCount: covers.value.length
});
});
onUnmounted(() => {
toggleDarkMode?.(false);
window.removeEventListener("scroll", trackScrolling);
});
// SEO Meta
useSeoMeta({
title: "MUELLERPRINTS - Handgefertigte Notizbücher aus Stuttgart",
description: "Handgefertigte Notizbücher mit 100% Recyclingpapier, traditioneller Fadenheftung und einzigartigen Einbanddesigns. Made in Stuttgart.",
ogTitle: "MUELLERPRINTS - Handgefertigte Notizbücher",
ogDescription: "Handgefertigte Notizbücher mit 100% Recyclingpapier, traditioneller Fadenheftung und einzigartigen Einbanddesigns.",
ogType: "website"
});
</script>

44
pages/kontakt.vue Normal file
View File

@@ -0,0 +1,44 @@
<template>
<main class="pb-20">
<PageHero title="Kontakt" subtitle="Wir freuen uns auf Ihre Nachricht" />
<section class="py-12 lg:py-16">
<div class="xl:container mx-auto px-6">
<div class="grid lg:grid-cols-2 gap-12">
<!-- Contact Form -->
<div>
<h2 class="text-2xl font-bold mb-6">Schreiben Sie uns</h2>
<ContactForm />
</div>
<!-- Contact Info -->
<div class="space-y-8">
<div class="bg-gray-50 rounded-xl p-8">
<h2 class="text-xl font-bold mb-6">Kontaktdaten</h2>
<ContactInfo :contact="CONTACT_INFO" />
</div>
<div class="bg-gray-50 rounded-xl p-8">
<h3 class="text-lg font-bold mb-4">Öffnungszeiten</h3>
<OpeningHours :hours="OPENING_HOURS" />
</div>
<div class="flex flex-col sm:flex-row gap-4">
<NuxtLink to="/anfahrt" class="flex-1 text-center px-6 py-3 border-2 border-gray-900 text-gray-900 font-semibold rounded-full hover:bg-gray-100 transition-colors"> Anfahrt </NuxtLink>
<NuxtLink to="/oeffnungszeiten" class="flex-1 text-center px-6 py-3 border-2 border-gray-900 text-gray-900 font-semibold rounded-full hover:bg-gray-100 transition-colors"> Öffnungszeiten </NuxtLink>
</div>
</div>
</div>
</div>
</section>
</main>
</template>
<script setup lang="ts">
import { CONTACT_INFO, OPENING_HOURS } from "~/composables/usePageContent";
useSeoMeta({
title: "Kontakt | MUELLERPRINTS",
description: "Kontaktieren Sie MUELLERPRINTS per Telefon, E-Mail oder über unser Kontaktformular. Wir freuen uns auf Ihre Nachricht.",
});
</script>

182
pages/notebooks/[cover].vue Normal file
View File

@@ -0,0 +1,182 @@
<template>
<main>
<!-- Hero Section -->
<section class="bg-gray-900 text-white py-16 lg:py-24">
<div class="container mx-auto px-6 text-center">
<p class="text-sm uppercase tracking-widest opacity-60 mb-4">{{ coverData?.copyText?.format }}</p>
<h1 class="text-4xl lg:text-5xl font-bebas mb-4">{{ coverData?.name }}</h1>
<p v-if="coverData?.copyText?.details" class="text-lg opacity-80 max-w-2xl mx-auto" v-html="coverData.copyText.details" />
</div>
</section>
<!-- Category Navigation -->
<section class="py-8 border-b border-gray-200">
<div class="container mx-auto px-6">
<div class="flex flex-wrap justify-center gap-3">
<NuxtLink
to="/notebooks"
class="px-4 py-2 rounded-full text-sm font-medium transition-colors bg-gray-100 text-gray-700 hover:bg-gray-200"
>
Alle
</NuxtLink>
<NuxtLink
v-for="c in covers"
:key="c.id"
:to="`/notebooks/${c.slug}`"
class="px-4 py-2 rounded-full text-sm font-medium transition-colors"
:class="c.slug === cover ? 'bg-gray-900 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'"
>
{{ c.name }}
</NuxtLink>
</div>
</div>
</section>
<!-- Products Grid -->
<section class="py-12 lg:py-16">
<div class="container mx-auto px-6">
<!-- Section Header -->
<div class="flex justify-between items-center mb-8">
<div>
<h2 class="text-xl font-semibold">{{ pagination?.total ?? 0 }} Notizbücher</h2>
<p class="text-sm text-gray-500">Seite {{ currentPage }} von {{ pagination?.pageCount ?? 1 }}</p>
</div>
</div>
<!-- Loading State -->
<div v-if="pending" class="text-center py-12">
<LoadingSpinner />
<p class="mt-4 text-gray-500">Produkte werden geladen...</p>
</div>
<!-- Error State -->
<div v-else-if="error" class="text-center py-12 text-red-600">
<p>Fehler beim Laden der Produkte</p>
</div>
<!-- Products Grid -->
<div v-else class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-6">
<ProductCard v-for="(item, i) in products" :key="i" :product="item" />
</div>
<!-- Pagination -->
<div v-if="pagination && pagination.pageCount > 1" class="mt-12 flex justify-center gap-2">
<NuxtLink
v-for="pageNum in pagination.pageCount"
:key="pageNum"
:to="pageNum === 1 ? `/notebooks/${cover}` : `/notebooks/${cover}?page=${pageNum}`"
class="px-4 py-2 rounded-full text-sm font-medium transition-colors"
:class="pageNum === currentPage ? 'bg-gray-900 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'"
>
{{ pageNum }}
</NuxtLink>
</div>
</div>
</section>
<!-- Footer CTA -->
<section class="py-16 bg-gray-50 border-t border-gray-200">
<div class="container mx-auto px-6 text-center">
<h3 class="text-2xl font-bold mb-3">Fragen zu unseren Produkten?</h3>
<p class="text-gray-600 max-w-xl mx-auto mb-6">
Jedes Notizbuch wird in unserer Stuttgarter Werkstatt von Hand gebunden.
</p>
<NuxtLink
to="/about"
class="inline-block px-6 py-3 rounded-full font-medium bg-gray-900 text-white hover:bg-gray-700 transition-colors"
>
Mehr über uns
</NuxtLink>
</div>
</section>
</main>
</template>
<script setup lang="ts">
import { slugify } from "~/utils/slugify";
import { trackEvent } from "~/utils/trackEvent";
const route = useRoute();
const cover = computed(() => route.params.cover as string);
const currentPage = computed(() => parseInt(route.query.page as string) || 1);
const shopApi = useShopApi();
// Fetch cover data - key must be dynamic to avoid stale cache on navigation
const { data: coverResponse } = await useAsyncData(
() => `cover-${cover.value}`,
() => shopApi.getProductCoverById(cover.value),
{ watch: [cover] }
);
const coverData = computed(() => coverResponse.value?.data?.attributes);
// Fetch products
const { data, pending, error } = await useAsyncData(
() => `products-cover-${cover.value}-page-${currentPage.value}`,
() => shopApi.getCheapestProducts(cover.value, currentPage.value, 24),
{ watch: [cover, currentPage] }
);
// Fetch all covers for category navigation
const { data: coversData } = await useAsyncData("covers-nav", async () => {
try {
const { data } = await shopApi.getProductCovers();
return data
.map((c: any) => ({
id: c.id,
name: c.attributes?.name,
slug: slugify(c.attributes?.name ?? ""),
sort: c.attributes?.sort ?? 0
}))
.sort((a: any, b: any) => a.sort - b.sort);
} catch {
return [];
}
});
const covers = computed(() => coversData.value ?? []);
const products = computed(() => data.value?.data ?? []);
const pagination = computed(() => data.value?.meta?.pagination);
// Strip HTML tags from CMS rich text for use in meta tags
function stripHtml(html: string): string {
return html.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
}
// SEO
useSeoMeta({
title: () => `${coverData.value?.name ?? "Notizbücher"} | MUELLERPRINTS`,
description: () => stripHtml(coverData.value?.copyText?.details ?? "Entdecken Sie unsere handgefertigten Notizbücher aus Stuttgart."),
ogTitle: () => `${coverData.value?.name ?? "Notizbücher"} | MUELLERPRINTS`,
ogDescription: () => stripHtml(coverData.value?.copyText?.details ?? "Entdecken Sie unsere handgefertigten Notizbücher aus Stuttgart.")
});
// Track category view on mount
onMounted(() => {
trackEvent("category-viewed", {
category: cover.value,
page: currentPage.value,
totalProducts: pagination.value?.total ?? 0
});
});
// Track pagination clicks
watch(currentPage, (newPage, oldPage) => {
if (newPage !== oldPage) {
trackEvent("category-pagination-clicked", {
category: cover.value,
page: newPage
});
}
});
// Track category filter changes
watch(cover, (newCover, oldCover) => {
if (newCover !== oldCover) {
trackEvent("category-filter-applied", {
from: oldCover,
to: newCover
});
}
});
</script>

176
pages/notebooks/index.vue Normal file
View File

@@ -0,0 +1,176 @@
<template>
<main>
<!-- Hero Section -->
<section class="bg-gray-900 text-white py-16 lg:py-24">
<div class="container mx-auto px-6 text-center">
<p class="text-sm uppercase tracking-widest opacity-60 mb-4">Unsere Kollektion</p>
<h1 class="text-4xl lg:text-5xl font-bebas mb-4">Alle Notizbücher</h1>
<p class="text-lg opacity-80 max-w-2xl mx-auto">
Handgefertigte Notizbücher aus Stuttgart mit 100% Recyclingpapier und traditioneller Fadenheftung.
</p>
</div>
</section>
<!-- Category Navigation -->
<section class="py-8 border-b border-gray-200">
<div class="container mx-auto px-6">
<div class="flex flex-wrap justify-center gap-3">
<NuxtLink
to="/notebooks"
class="px-4 py-2 rounded-full text-sm font-medium transition-colors bg-gray-900 text-white"
>
Alle
</NuxtLink>
<NuxtLink
v-for="cover in covers"
:key="cover.id"
:to="`/notebooks/${cover.slug}`"
class="px-4 py-2 rounded-full text-sm font-medium transition-colors bg-gray-100 text-gray-700 hover:bg-gray-200"
>
{{ cover.name }}
</NuxtLink>
</div>
</div>
</section>
<!-- Products Grid -->
<section class="py-12 lg:py-16">
<div class="container mx-auto px-6">
<!-- Section Header -->
<div class="flex justify-between items-center mb-8">
<div>
<h2 class="text-xl font-semibold">{{ pagination?.total ?? 0 }} Notizbücher</h2>
<p class="text-sm text-gray-500">Seite {{ currentPage }} von {{ pagination?.pageCount ?? 1 }}</p>
</div>
</div>
<!-- Loading State -->
<div v-if="pending" class="text-center py-12">
<LoadingSpinner />
<p class="mt-4 text-gray-500">Produkte werden geladen...</p>
</div>
<!-- Error State -->
<div v-else-if="error" class="text-center py-12 text-red-600">
<p>Fehler beim Laden der Produkte</p>
</div>
<!-- Products Grid -->
<div v-else class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-6">
<ProductCard v-for="product in products" :key="product.id" :product="normalizeProduct(product)" />
</div>
<!-- Pagination -->
<div v-if="pagination && pagination.pageCount > 1" class="mt-12 flex justify-center gap-2">
<NuxtLink
v-for="pageNum in pagination.pageCount"
:key="pageNum"
:to="pageNum === 1 ? '/notebooks' : `/notebooks?page=${pageNum}`"
class="px-4 py-2 rounded-full text-sm font-medium transition-colors"
:class="pageNum === currentPage ? 'bg-gray-900 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'"
>
{{ pageNum }}
</NuxtLink>
</div>
</div>
</section>
<!-- Footer CTA -->
<section class="py-16 bg-gray-50 border-t border-gray-200">
<div class="container mx-auto px-6 text-center">
<h3 class="text-2xl font-bold mb-3">Fragen zu unseren Produkten?</h3>
<p class="text-gray-600 max-w-xl mx-auto mb-6">
Jedes Notizbuch wird in unserer Stuttgarter Werkstatt von Hand gebunden.
</p>
<NuxtLink
to="/about"
class="inline-block px-6 py-3 rounded-full font-medium bg-gray-900 text-white hover:bg-gray-700 transition-colors"
>
Mehr über uns
</NuxtLink>
</div>
</section>
</main>
</template>
<script setup lang="ts">
import type { Product } from "~/types";
import { slugify } from "~/utils/slugify";
import { trackEvent } from "~/utils/trackEvent";
const route = useRoute();
const currentPage = computed(() => parseInt(route.query.page as string) || 1);
const shopApi = useShopApi();
// Fetch products (cheapest variant per cover+pattern)
const { data, pending, error } = await useAsyncData(
() => `products-all-page-${currentPage.value}`,
() => shopApi.getCheapestProducts(undefined, currentPage.value, 20),
{ watch: [currentPage] }
);
// Fetch covers for category navigation
const { data: coversData } = await useAsyncData("covers-nav", async () => {
try {
const { data } = await shopApi.getProductCovers();
return data
.map((cover: any) => ({
id: cover.id,
name: cover.attributes?.name,
slug: slugify(cover.attributes?.name ?? ""),
sort: cover.attributes?.sort ?? 0
}))
.sort((a: any, b: any) => a.sort - b.sort);
} catch {
return [];
}
});
const covers = computed(() => coversData.value ?? []);
const products = computed(() => data.value?.data ?? []);
const pagination = computed(() => data.value?.meta?.pagination);
// Normalize Strapi response to Product type
function normalizeProduct(item: any): Product {
const attrs = item.attributes ?? item;
return {
id: item.id,
name: attrs.name,
slug: attrs.slug,
totalProductPrice: attrs.totalProductPrice ?? attrs.price,
pattern: attrs.pattern?.data ? { id: attrs.pattern.data.id, name: attrs.pattern.data.attributes?.name } : attrs.pattern,
cover: attrs.cover?.data ? { id: attrs.cover.data.id, name: attrs.cover.data.attributes?.name } : attrs.cover,
ruling: attrs.ruling?.data ? { id: attrs.ruling.data.id, name: attrs.ruling.data.attributes?.name } : attrs.ruling,
pages: attrs.pages?.data ? { id: attrs.pages.data.id, name: attrs.pages.data.attributes?.name } : attrs.pages,
images: attrs.images
};
}
// SEO
useSeoMeta({
title: "Alle Notizbücher | MUELLERPRINTS",
description: "Entdecken Sie unsere handgefertigten Notizbücher aus Stuttgart mit 100% Recyclingpapier.",
ogTitle: "Alle Notizbücher | MUELLERPRINTS",
ogDescription: "Entdecken Sie unsere handgefertigten Notizbücher aus Stuttgart mit 100% Recyclingpapier."
});
// Track category view on mount
onMounted(() => {
trackEvent("category-viewed", {
category: "all",
page: currentPage.value,
totalProducts: pagination.value?.total ?? 0
});
});
// Track pagination clicks
watch(currentPage, (newPage, oldPage) => {
if (newPage !== oldPage) {
trackEvent("category-pagination-clicked", {
category: "all",
page: newPage
});
}
});
</script>

54
pages/oeffnungszeiten.vue Normal file
View File

@@ -0,0 +1,54 @@
<template>
<main class="pb-20">
<!-- Hero -->
<PageHero title="Öffnungszeiten" subtitle="Besuchen Sie uns in unserer Werkstatt in Stuttgart" />
<!-- Opening Hours -->
<section class="py-12 lg:py-16">
<div class="xl:container mx-auto px-6">
<div class="max-w-lg mx-auto">
<div class="bg-white rounded-xl shadow-lg p-8">
<h2 class="text-xl font-bold mb-6">Unsere Öffnungszeiten</h2>
<OpeningHours :hours="OPENING_HOURS" />
</div>
<div class="mt-8 p-6 bg-gray-50 rounded-xl">
<p class="text-gray-600 text-sm">
<strong>Hinweis:</strong> Außerhalb der regulären Öffnungszeiten sind wir nach Vereinbarung erreichbar. Kontaktieren Sie uns gerne telefonisch oder per E-Mail.
</p>
</div>
</div>
</div>
</section>
<!-- Contact Info -->
<section class="py-12 lg:py-16 bg-gray-50">
<div class="xl:container mx-auto px-6">
<div class="max-w-lg mx-auto text-center">
<h2 class="text-2xl font-bold mb-6">Kontakt</h2>
<div class="bg-white rounded-xl shadow-lg p-8 text-left">
<ContactInfo :contact="CONTACT_INFO" />
</div>
<div class="mt-8">
<NuxtLink to="/anfahrt" class="inline-flex items-center gap-2 px-8 py-3 bg-gray-900 text-white font-semibold rounded-full hover:bg-gray-800 transition-colors">
<IconMapPin class="w-5 h-5" />
Anfahrt anzeigen
</NuxtLink>
</div>
</div>
</div>
</section>
</main>
</template>
<script setup lang="ts">
import { OPENING_HOURS, CONTACT_INFO } from "~/composables/usePageContent";
import IconMapPin from "~/components/icons/IconMapPin.vue";
useSeoMeta({
title: "Öffnungszeiten | MUELLERPRINTS",
description: "Besuchen Sie uns in Stuttgart. Montag bis Freitag geöffnet. Außerhalb der Öffnungszeiten nach Vereinbarung.",
ogTitle: "Öffnungszeiten | MUELLERPRINTS",
ogDescription: "Besuchen Sie uns in Stuttgart. Montag bis Freitag geöffnet.",
});
</script>

58
pages/versand.vue Normal file
View File

@@ -0,0 +1,58 @@
<template>
<main class="pb-20">
<PageHero title="Versand" subtitle="Lieferung und Versandkosten" />
<PageSection :content="VERSAND_CONTENT" />
<!-- Dynamic Delivery Methods from API -->
<section class="py-12 lg:py-16 bg-gray-50">
<div class="xl:container mx-auto px-6">
<h2 class="text-2xl font-bold mb-8">Verfügbare Versandarten</h2>
<div v-if="pending" class="text-center py-8">
<LoadingSpinner />
</div>
<div v-else-if="deliveryMethods.length > 0" class="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
<div v-for="method in deliveryMethods" :key="method.id" class="bg-white rounded-xl p-6 shadow-sm">
<div class="flex justify-between items-start mb-2">
<h3 class="text-lg font-semibold">{{ method.name }}</h3>
<span class="text-lg font-bold">{{ formatPrice(method.price) }}</span>
</div>
<p v-if="method.description" class="text-gray-600 text-sm">{{ method.description }}</p>
</div>
</div>
</div>
</section>
</main>
</template>
<script setup lang="ts">
import { VERSAND_CONTENT } from "~/composables/usePageContent";
import { numberFormatter } from "~/utils/numberFormatter";
const shopApi = useShopApi();
const { data: deliveryData, status } = await useAsyncData("delivery-methods", async () => {
const response = await shopApi.getDeliveryMethods();
// Flatten Strapi v4 attributes structure
return (response.data || []).map((item: any) => ({
id: item.id,
name: item.attributes?.name ?? item.name,
price: item.attributes?.price ?? item.price ?? 0,
description: item.attributes?.description ?? item.description
}));
});
const pending = computed(() => status.value === "pending");
const deliveryMethods = computed(() => deliveryData.value || []);
function formatPrice(price: number) {
if (price === 0) return "Kostenlos";
return numberFormatter(price, "EUR");
}
useSeoMeta({
title: "Versand | MUELLERPRINTS",
description: "Informationen zu Lieferung und Versandkosten bei MUELLERPRINTS. Schneller Versand mit DHL und Deutsche Post.",
});
</script>

68
pages/zahlung.vue Normal file
View File

@@ -0,0 +1,68 @@
<template>
<main class="pb-20">
<PageHero title="Zahlung" subtitle="Sichere Zahlungsmöglichkeiten für Ihren Einkauf" />
<!-- Dynamic Payment Methods from API -->
<section class="py-12 lg:py-16">
<div class="xl:container mx-auto px-6">
<div v-if="pending" class="text-center py-8">
<LoadingSpinner />
</div>
<div v-else-if="paymentMethods.length > 0" class="grid md:grid-cols-2 gap-8">
<div v-for="method in paymentMethods" :key="method.id" class="bg-gray-50 rounded-xl p-6">
<div class="flex justify-between items-start mb-3">
<h2 class="text-xl font-semibold">{{ method.name }}</h2>
<span v-if="method.price > 0" class="text-sm text-gray-500 bg-white px-2 py-1 rounded">+ {{ formatPrice(method.price) }}</span>
</div>
<p v-if="method.description" class="text-gray-600 leading-relaxed">{{ method.description }}</p>
</div>
</div>
</div>
</section>
<!-- Security Note -->
<section class="py-12 lg:py-16 bg-gray-50">
<div class="xl:container mx-auto px-6">
<div class="max-w-2xl mx-auto text-center">
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-white mb-6">
<svg class="w-8 h-8 text-gray-700" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</div>
<h2 class="text-2xl font-bold mb-4">Sicher bezahlen</h2>
<p class="text-gray-600">Alle Zahlungen werden über verschlüsselte Verbindungen abgewickelt. Ihre Daten sind bei uns sicher.</p>
</div>
</div>
</section>
</main>
</template>
<script setup lang="ts">
import { numberFormatter } from "~/utils/numberFormatter";
const shopApi = useShopApi();
const { data: paymentData, status } = await useAsyncData("payment-methods", async () => {
const response = await shopApi.getPaymentMethods();
// Flatten Strapi v4 attributes structure
return (response.data || []).map((item: any) => ({
id: item.id,
name: item.attributes?.name ?? item.name,
price: item.attributes?.price ?? item.price ?? 0,
description: item.attributes?.description ?? item.description
}));
});
const pending = computed(() => status.value === "pending");
const paymentMethods = computed(() => paymentData.value || []);
function formatPrice(price: number) {
return numberFormatter(price, "EUR");
}
useSeoMeta({
title: "Zahlung | MUELLERPRINTS",
description: "Zahlungsmöglichkeiten bei MUELLERPRINTS: PayPal, Kreditkarte, Lastschrift, Barzahlung. Sichere und bequeme Bezahlung."
});
</script>