Implement shopping cart functionality with UI components and API integration

- Added CartItem, CartSummary, CartEmpty, CartSidebar, and CartSheet components for managing cart display and interactions.
- Integrated useCart and useCartUI composables for cart state management and UI control.
- Implemented API endpoints for cart operations, including fetching, adding, updating, and removing items.
- Enhanced user experience with loading states and notifications using vue-sonner for cart actions.
- Configured session management for guest and authenticated users, ensuring cart persistence across sessions.

This commit completes the shopping cart feature, enabling users to add items, view their cart, and proceed to checkout.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
Bastian Masanek
2025-11-03 12:43:13 +01:00
parent 9d0e77fc98
commit b372e2cf78
44 changed files with 2209 additions and 123 deletions

View File

@@ -0,0 +1,45 @@
<script setup lang="ts">
import { ShoppingBag } from 'lucide-vue-next'
import { cn } from '~/lib/utils'
interface Props {
/**
* Additional CSS classes
*/
class?: string
}
const props = defineProps<Props>()
</script>
<template>
<Card :class="cn('', props.class)">
<div class="flex flex-col items-center justify-center py-16 px-6 text-center space-y-6">
<!-- Shopping Bag Icon -->
<div
class="flex items-center justify-center w-24 h-24 rounded-full bg-white/5 border-2 border-white/20"
>
<ShoppingBag class="w-12 h-12 text-white/60" stroke-width="1.5" />
</div>
<!-- Text Content -->
<div class="space-y-2">
<h2 class="text-2xl font-bold text-white">Dein Warenkorb ist leer</h2>
<p class="text-white/70 max-w-md">
Entdecke unsere Produkte und füge deine Favoriten zum Warenkorb hinzu.
</p>
</div>
<!-- CTA Button -->
<Button
as-child
class="mt-4 bg-gradient-button bg-size-300 bg-left hover:bg-right transition-all duration-300 font-bold text-white shadow-lg hover:shadow-2xl"
size="lg"
>
<NuxtLink to="/products">
Produkte entdecken
</NuxtLink>
</Button>
</div>
</Card>
</template>

View File

@@ -0,0 +1,180 @@
<script setup lang="ts">
import { Minus, Plus, Trash2 } from 'lucide-vue-next'
import { cn } from '~/lib/utils'
import type { CartItemWithProduct } from '~/types/cart'
interface Props {
/**
* Cart item with product details
*/
item: CartItemWithProduct
/**
* Loading state for update/remove operations
*/
loading?: boolean
/**
* Additional CSS classes
*/
class?: string
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:quantity': [quantity: number]
remove: []
}>()
// Format price as EUR currency
const formatPrice = (price: string | number) => {
const numPrice = typeof price === 'string' ? Number.parseFloat(price) : price
return new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
}).format(numPrice)
}
// Format subtotal
const formattedSubtotal = computed(() => formatPrice(props.item.subtotal))
const formattedUnitPrice = computed(() => formatPrice(props.item.product.price))
// Local quantity for input binding
const localQuantity = ref(props.item.quantity)
// Watch for prop changes
watch(
() => props.item.quantity,
(newQuantity) => {
localQuantity.value = newQuantity
}
)
// Handle quantity decrease
const decreaseQuantity = () => {
if (localQuantity.value > 1) {
localQuantity.value--
emit('update:quantity', localQuantity.value)
}
}
// Handle quantity increase
const increaseQuantity = () => {
if (localQuantity.value < props.item.product.stockQuantity) {
localQuantity.value++
emit('update:quantity', localQuantity.value)
}
}
// Handle manual quantity input
const handleQuantityInput = (event: Event) => {
const input = event.target as HTMLInputElement
const value = Number.parseInt(input.value, 10)
// Validate input
if (Number.isNaN(value) || value < 1) {
localQuantity.value = 1
} else if (value > props.item.product.stockQuantity) {
localQuantity.value = props.item.product.stockQuantity
} else {
localQuantity.value = value
}
// Update input value to match validated value
input.value = String(localQuantity.value)
// Emit update
emit('update:quantity', localQuantity.value)
}
// Handle remove item
const handleRemove = () => {
emit('remove')
}
// Placeholder image for products without images
const placeholderImage = '/img/makerspace-jk-2025.jpg'
</script>
<template>
<Card :class="cn('relative overflow-hidden', props.class)">
<div class="p-4">
<!-- Top Row: Image + Title/Description + Remove Button -->
<div class="flex gap-4">
<!-- Product Image -->
<div class="flex-shrink-0">
<div class="relative h-20 w-20 overflow-hidden rounded-lg bg-purple-dark/50 sm:h-24 sm:w-24">
<img :src="item.product.imageUrl || placeholderImage" :alt="item.product.name"
class="h-full w-full object-cover" loading="lazy" />
</div>
</div>
<!-- Product Details -->
<div class="flex flex-1 flex-col gap-1 min-w-0">
<div class="flex items-start justify-between gap-2">
<h3 class="font-bold text-white text-sm sm:text-base leading-tight line-clamp-2 flex-1">
{{ item.product.name }}
</h3>
<!-- Remove Button (Desktop) -->
<Button variant="ghost" size="icon"
class="hidden sm:flex text-white/60 hover:text-red hover:bg-red/10 flex-shrink-0 h-8 w-8" :disabled="loading"
@click="handleRemove">
<Trash2 class="h-4 w-4" />
</Button>
</div>
<p v-if="item.product.description" class="text-xs text-white/70 line-clamp-2">
{{ item.product.description }}
</p>
</div>
</div>
<!-- Bottom Row: Quantity Controls (left) + Subtotal (right) -->
<div class="flex items-center justify-between mt-4">
<!-- Quantity Controls -->
<div class="flex items-center gap-2">
<!-- Decrease Button -->
<Button variant="outline" size="icon" class="h-8 w-8 text-white border-white/20 hover:bg-white/10"
:disabled="loading || localQuantity <= 1" @click="decreaseQuantity">
<Minus class="h-4 w-4" />
</Button>
<!-- Quantity Display -->
<div class="flex h-8 w-16 items-center justify-center rounded-md border border-white/20 bg-white/5 text-sm font-medium text-white">
{{ localQuantity }}
</div>
<!-- Increase Button -->
<Button variant="outline" size="icon" class="h-8 w-8 text-white border-white/20 hover:bg-white/10"
:disabled="loading || localQuantity >= item.product.stockQuantity" @click="increaseQuantity">
<Plus class="h-4 w-4" />
</Button>
</div>
<!-- Subtotal + Remove Button -->
<div class="flex items-center gap-2">
<!-- Subtotal -->
<div class="flex flex-col text-right">
<div class="text-xs text-white/60 uppercase tracking-wide">
Summe
</div>
<div class="text-base font-bold text-experimenta-accent sm:text-lg">
{{ formattedSubtotal }}
</div>
</div>
<!-- Remove Button (Mobile) -->
<Button variant="ghost" size="icon" class="sm:hidden text-white/60 hover:text-red hover:bg-red/10 h-8 w-8"
:disabled="loading" @click="handleRemove">
<Trash2 class="h-4 w-4" />
</Button>
</div>
</div>
</div>
<!-- Loading Overlay -->
<div v-if="loading" class="absolute inset-0 bg-purple-darkest/80 backdrop-blur-sm flex items-center justify-center">
<div class="animate-spin rounded-full h-8 w-8 border-4 border-white/20 border-t-experimenta-accent" />
</div>
</Card>
</template>

View File

@@ -0,0 +1,77 @@
<script setup lang="ts">
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet'
import { ScrollArea } from '@/components/ui/scroll-area'
import CartEmpty from './CartEmpty.vue'
import CartItem from './CartItem.vue'
import CartSummary from './CartSummary.vue'
// Get cart state and UI controls
const { items, itemCount, total, loading, updateItem, removeItem } = useCart()
const { isOpen, close } = useCartUI()
// Handle quantity update
async function handleUpdateQuantity(itemId: string, quantity: number) {
await updateItem(itemId, quantity)
}
// Handle item removal
async function handleRemoveItem(itemId: string) {
await removeItem(itemId)
}
// Navigate to checkout
function handleCheckout() {
close()
navigateTo('/checkout')
}
</script>
<template>
<Sheet :open="isOpen" @update:open="(open) => !open && close()">
<SheetContent side="bottom" class="h-[90vh] p-0 flex flex-col">
<!-- Header -->
<SheetHeader class="px-6 py-4 border-b">
<SheetTitle class="text-xl font-bold">
Warenkorb ({{ itemCount }})
</SheetTitle>
</SheetHeader>
<!-- Empty State -->
<div v-if="itemCount === 0" class="flex-1 flex items-center justify-center px-6">
<CartEmpty />
</div>
<!-- Cart Items + Summary -->
<template v-else>
<!-- Scrollable Items List -->
<ScrollArea class="flex-1 px-6">
<div class="space-y-4 py-4">
<CartItem
v-for="item in items"
:key="item.id"
:item="item"
:loading="loading"
@update:quantity="(qty) => handleUpdateQuantity(item.id, qty)"
@remove="handleRemoveItem(item.id)"
/>
</div>
</ScrollArea>
<!-- Sticky Footer with Summary -->
<div class="border-t px-6 py-4 bg-background">
<CartSummary
:items="items"
:total="total"
:loading="loading"
@checkout="handleCheckout"
/>
</div>
</template>
</SheetContent>
</Sheet>
</template>

View File

@@ -0,0 +1,77 @@
<script setup lang="ts">
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet'
import { ScrollArea } from '@/components/ui/scroll-area'
import CartEmpty from './CartEmpty.vue'
import CartItem from './CartItem.vue'
import CartSummary from './CartSummary.vue'
// Get cart state and UI controls
const { items, itemCount, total, loading, updateItem, removeItem } = useCart()
const { isOpen, close } = useCartUI()
// Handle quantity update
async function handleUpdateQuantity(itemId: string, quantity: number) {
await updateItem(itemId, quantity)
}
// Handle item removal
async function handleRemoveItem(itemId: string) {
await removeItem(itemId)
}
// Navigate to checkout
function handleCheckout() {
close()
navigateTo('/checkout')
}
</script>
<template>
<Sheet :open="isOpen" @update:open="(open) => !open && close()">
<SheetContent side="right" class="w-full sm:w-[400px] p-0 flex flex-col">
<!-- Header -->
<SheetHeader class="px-6 py-4 border-b">
<SheetTitle class="text-xl font-bold">
Warenkorb ({{ itemCount }})
</SheetTitle>
</SheetHeader>
<!-- Empty State -->
<div v-if="itemCount === 0" class="flex-1 flex items-center justify-center px-6">
<CartEmpty />
</div>
<!-- Cart Items + Summary -->
<template v-else>
<!-- Scrollable Items List -->
<ScrollArea class="flex-1 px-6">
<div class="space-y-4 py-4">
<CartItem
v-for="item in items"
:key="item.id"
:item="item"
:loading="loading"
@update:quantity="(qty) => handleUpdateQuantity(item.id, qty)"
@remove="handleRemoveItem(item.id)"
/>
</div>
</ScrollArea>
<!-- Sticky Footer with Summary -->
<div class="border-t px-6 py-4 bg-background">
<CartSummary
:items="items"
:total="total"
:loading="loading"
@checkout="handleCheckout"
/>
</div>
</template>
</SheetContent>
</Sheet>
</template>

View File

@@ -0,0 +1,116 @@
<script setup lang="ts">
import { cn } from '~/lib/utils'
import type { CartItemWithProduct } from '~/types/cart'
interface Props {
/**
* Cart items for summary calculation
*/
items: CartItemWithProduct[]
/**
* Total amount in EUR
*/
total: number
/**
* Loading state (e.g., during checkout)
*/
loading?: boolean
/**
* Additional CSS classes
*/
class?: string
}
const props = defineProps<Props>()
// Format currency in EUR
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
}).format(amount)
}
// Calculate item count
const itemCount = computed(() => {
return props.items.reduce((sum, item) => sum + item.quantity, 0)
})
// Calculate VAT (7% already included in total)
// Formula: VAT = total * (VAT_rate / (1 + VAT_rate))
const vatAmount = computed(() => {
return props.total * (0.07 / 1.07)
})
// Format values
const formattedSubtotal = computed(() => formatCurrency(props.total))
const formattedVat = computed(() => formatCurrency(vatAmount.value))
const formattedTotal = computed(() => formatCurrency(props.total))
// Item count text (singular/plural)
const itemCountText = computed(() => {
return itemCount.value === 1 ? '1 Artikel' : `${itemCount.value} Artikel`
})
</script>
<template>
<Card :class="cn('sticky top-4', props.class)">
<div class="p-6 space-y-4">
<!-- Header -->
<div>
<h2 class="text-xl font-bold text-white">Zusammenfassung</h2>
<p class="text-sm text-white/60 mt-1">{{ itemCountText }}</p>
</div>
<Separator class="bg-white/20" />
<!-- Price Breakdown -->
<div class="space-y-3">
<!-- Subtotal -->
<div class="flex items-center justify-between text-white/80">
<span class="text-sm">Zwischensumme</span>
<span class="font-medium">{{ formattedSubtotal }}</span>
</div>
<!-- VAT (included) -->
<div class="flex items-center justify-between text-white/60 text-sm">
<span>inkl. MwSt. (7%)</span>
<span>{{ formattedVat }}</span>
</div>
</div>
<Separator class="bg-white/20" />
<!-- Total -->
<div class="flex items-center justify-between">
<span class="text-lg font-bold text-white">Gesamt</span>
<span class="text-2xl font-bold text-experimenta-accent">
{{ formattedTotal }}
</span>
</div>
<!-- Checkout Button -->
<Button
class="w-full bg-gradient-button bg-size-300 bg-left hover:bg-right transition-all duration-300 font-bold text-white shadow-lg hover:shadow-2xl"
size="lg" :disabled="loading || items.length === 0" @click="$emit('checkout')">
<span v-if="!loading">Zur Kasse</span>
<span v-else class="flex items-center gap-2">
<div class="animate-spin rounded-full h-4 w-4 border-2 border-white/20 border-t-white" />
Wird verarbeitet...
</span>
</Button>
<!-- Additional Info -->
<!-- <div class="pt-2 space-y-2 text-xs text-white/60">
<p class="flex items-start gap-2">
<span class="text-experimenta-accent"></span>
<span>Sichere Zahlung mit PayPal</span>
</p>
<p class="flex items-start gap-2">
<span class="text-experimenta-accent"></span>
<span>Versandkostenfrei</span>
</p>
</div> -->
</div>
</Card>
</template>

View File

@@ -0,0 +1,6 @@
export { default as CartItem } from './CartItem.vue'
export { default as CartSummary } from './CartSummary.vue'
export { default as CartEmpty } from './CartEmpty.vue'
export { default as CartFAB } from './CartFAB.vue'
export { default as CartSidebar } from './CartSidebar.vue'
export { default as CartSheet } from './CartSheet.vue'