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:
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>
|
||||
Reference in New Issue
Block a user