feat: extract shop from mp/shop — initial libreshop/shop
Some checks failed
Build and publish / build (push) Failing after 19s

Source moved verbatim from mp/shop/ on 2026-04-29; mp was the first
concrete adapter consuming the libreshop toolkit. Builds and publishes
git.librete.ch/libreshop/shop on every main / v* push via the standard
.gitea/workflows/build.yml shared across libreshop components.
This commit is contained in:
Michael Czechowski
2026-04-29 17:48:56 +02:00
commit 44107c0734
134 changed files with 19521 additions and 0 deletions

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

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

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

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

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

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

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

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

View File

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