feat: extract shop from mp/shop — initial libreshop/shop
Some checks failed
Build and publish / build (push) Failing after 19s
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:
42
pages/about.vue
Normal file
42
pages/about.vue
Normal 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
15
pages/agb.vue
Normal 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
67
pages/anfahrt.vue
Normal 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
180
pages/cart.vue
Normal 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
150
pages/checkout/1.vue
Normal 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
274
pages/checkout/2.vue
Normal 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
374
pages/checkout/3.vue
Normal 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
8
pages/checkout/index.vue
Normal file
@@ -0,0 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
// Redirect /checkout to /checkout/1
|
||||
definePageMeta({
|
||||
redirect: "/checkout/1"
|
||||
});
|
||||
|
||||
navigateTo("/checkout/1", { replace: true });
|
||||
</script>
|
||||
236
pages/checkout/result/[uuid].vue
Normal file
236
pages/checkout/result/[uuid].vue
Normal 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
15
pages/datenschutz.vue
Normal 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
596
pages/details/[slug].vue
Normal 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
15
pages/impressum.vue
Normal 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
199
pages/index.vue
Normal 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
44
pages/kontakt.vue
Normal 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
182
pages/notebooks/[cover].vue
Normal 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
176
pages/notebooks/index.vue
Normal 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
54
pages/oeffnungszeiten.vue
Normal 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
58
pages/versand.vue
Normal 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
68
pages/zahlung.vue
Normal 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>
|
||||
Reference in New Issue
Block a user