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