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:
180
pages/cart.vue
Normal file
180
pages/cart.vue
Normal file
@@ -0,0 +1,180 @@
|
||||
<template>
|
||||
<main class="pt-4 pb-20 lg:container lg:max-w-screen-lg lg:mx-auto px-6">
|
||||
<div class="relative w-full">
|
||||
<!-- Loading state -->
|
||||
<div v-if="isLoading" class="flex items-center justify-center h-[40vh]">
|
||||
<div class="text-center">
|
||||
<LoadingSpinner />
|
||||
<p class="text-xl mt-4">Warenkorb wird geladen...</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Cart has products -->
|
||||
<div v-else-if="cartProducts.length > 0" class="flex flex-col gap-8 w-full" data-e2e="cart">
|
||||
<Heading :level="2" html-tag="h1" classes="text-center">
|
||||
Im Warenkorb gesamt: <span class="text-nowrap">{{ numberFormatter(cart.total.value) }} €</span>
|
||||
</Heading>
|
||||
|
||||
<div class="grid place-content-center">
|
||||
<Button href="/checkout" @click="handleCheckoutClickTop">Jetzt bezahlen</Button>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<div class="flex flex-col gap-8">
|
||||
<ul class="divide-y divide-gray-300" data-e2e="cart-products">
|
||||
<li v-for="(position, index) in cartProducts" :key="index" class="py-6 gap-6 flex items-center justify-between">
|
||||
<a v-if="position.product.images?.images" :href="`/details/${position.product.slug}`">
|
||||
<img :src="position.product.images.images[0].formats.thumbnail.url" :alt="position.product.name" class="w-24 object-cover" />
|
||||
</a>
|
||||
<div v-else class="bg-black opacity-5 w-12 h-12"></div>
|
||||
|
||||
<div class="flex flex-col gap-3 lg:gap-6 lg:flex-row lg:items-center justify-between flex-grow">
|
||||
<a :href="`/details/${position.product.slug}`" class="text-xl font-bold flex-grow">{{ position.product.name }}</a>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<select
|
||||
@change="changeCountCart(position, parseInt(($event.target as HTMLSelectElement).value))"
|
||||
class="block appearance-none w-16 text-center bg-white border border-gray-300 hover:border-gray-500 px-4 py-2 rounded shadow leading-tight focus:outline-none focus:border-indigo-500 focus:shadow-outline"
|
||||
>
|
||||
<option v-for="count in 10" :key="count" :selected="position.count === count" :value="count">
|
||||
{{ count }}
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<div class="flex flex-col gap-2 flex-end w-24">
|
||||
<span class="text-xl font-bold text-right text-nowrap">{{ numberFormatter(position.product.totalProductPrice * position.count) }} €</span>
|
||||
<button @click="removeFromCart(position.product.id, position.count)" class="text-gray-500 hover:text-gray-900 hover:underline text-right">
|
||||
Entfernen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<hr />
|
||||
|
||||
<div>
|
||||
<div class="flex flex-col lg:flex-row justify-between mb-2">
|
||||
<span class="text-2xl font-bold">Deine Gesamtsumme</span>
|
||||
<div class="flex flex-col lg:flex-end">
|
||||
<span class="text-2xl font-bold lg:text-right text-nowrap">{{ totalFormatted }} €</span>
|
||||
<span class="text-gray-600 text-sm lg:text-right">
|
||||
Enthält MwSt. in Höhe von {{ VATFormatted }} € {{ deliveryFormattedOrEmpty ? `inkl. ${deliveryFormattedOrEmpty}` : `zzgl.` }} Versandkosten
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid lg:place-content-end">
|
||||
<Button href="/checkout" classes="w-full" @click="handleCheckoutClickBottom">Jetzt bezahlen</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Cart is empty -->
|
||||
<div v-else class="w-full">
|
||||
<Heading :level="2" html-tag="h1" classes="text-center">Dein Warenkorb ist leer.</Heading>
|
||||
|
||||
<div class="grid place-content-center">
|
||||
<NuxtLink
|
||||
to="/notebooks"
|
||||
class="inline-flex items-center justify-center px-8 py-3 rounded-full font-medium transition-all duration-200 bg-gray-900 text-white hover:bg-gray-700"
|
||||
@click="handleContinueShopping"
|
||||
>
|
||||
Entdecke unsere Produkte
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { numberFormatter } from "~/utils/numberFormatter";
|
||||
import { trackEvent } from "~/utils/trackEvent";
|
||||
import type { CartProduct } from "~/types";
|
||||
|
||||
const shopApi = useShopApi();
|
||||
const cart = useCart();
|
||||
|
||||
// Ensure cart is initialized on mount
|
||||
onMounted(async () => {
|
||||
await cart.ensureReady();
|
||||
// Track cart view after cart is loaded
|
||||
trackEvent("cart-viewed", {
|
||||
itemCount: cart.productsCount.value,
|
||||
cartValue: cart.total.value
|
||||
});
|
||||
});
|
||||
|
||||
// Local computed for cleaner template access
|
||||
const isLoading = computed(() => !cart.isInitialized.value);
|
||||
const cartProducts = computed(() => cart.products.value || ([] as CartProduct[]));
|
||||
|
||||
const totalFormatted = computed(() => numberFormatter(cart.total.value));
|
||||
const VATFormatted = computed(() => numberFormatter(cart.VAT.value));
|
||||
const deliveryFormattedOrEmpty = computed(() => (cart.delivery.value ? numberFormatter(cart.delivery.value, "€ ") : ""));
|
||||
|
||||
async function removeFromCart(productId: number, count = 1) {
|
||||
try {
|
||||
const product = cartProducts.value.find((p) => p.product.id === productId);
|
||||
const cartAfterUpdate = await shopApi.removeProductFromCart(cart.uuid.value, productId, count);
|
||||
cart.overwrite(cartAfterUpdate);
|
||||
trackEvent("cart-product-removed", {
|
||||
productId,
|
||||
productSlug: product?.product.slug,
|
||||
quantity: count,
|
||||
cartValue: cart.total.value
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Could not remove product ${productId} from cart:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
async function addToCart(productId: number, count = 1) {
|
||||
try {
|
||||
const cartAfterUpdate = await shopApi.addProductToCart(cart.uuid.value, productId, count);
|
||||
cart.overwrite(cartAfterUpdate);
|
||||
} catch (error) {
|
||||
console.error(`Could not add product ${productId} to cart:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
async function changeCountCart(position: CartProduct, count: number) {
|
||||
const oldCount = position.count;
|
||||
if (count > position.count) {
|
||||
await addToCart(position.product.id, count - position.count);
|
||||
} else if (count < position.count) {
|
||||
await removeFromCart(position.product.id, position.count - count);
|
||||
}
|
||||
trackEvent("cart-quantity-changed", {
|
||||
productId: position.product.id,
|
||||
oldQuantity: oldCount,
|
||||
newQuantity: count
|
||||
});
|
||||
}
|
||||
|
||||
function handleCheckoutClickTop() {
|
||||
trackEvent("cart-cta-top-clicked", {
|
||||
itemCount: cart.productsCount.value,
|
||||
cartValue: cart.total.value
|
||||
});
|
||||
}
|
||||
|
||||
function handleCheckoutClickBottom() {
|
||||
trackEvent("cart-cta-bottom-clicked", {
|
||||
itemCount: cart.productsCount.value,
|
||||
cartValue: cart.total.value
|
||||
});
|
||||
}
|
||||
|
||||
function handleContinueShopping() {
|
||||
trackEvent("cart-empty-continue-shopping");
|
||||
}
|
||||
|
||||
// SEO
|
||||
useSeoMeta({
|
||||
title: "Warenkorb | MUELLERPRINTS",
|
||||
description: "Ihr Warenkorb bei MUELLERPRINTS"
|
||||
});
|
||||
</script>
|
||||
Reference in New Issue
Block a user