Files
shop/pages/checkout/3.vue
Michael Czechowski 44107c0734
Some checks failed
Build and publish / build (push) Failing after 19s
feat: extract shop from mp/shop — initial libreshop/shop
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.
2026-04-29 17:48:56 +02:00

375 lines
14 KiB
Vue

<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>