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:
180
app/components/Cart/CartItem.vue
Normal file
180
app/components/Cart/CartItem.vue
Normal 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>
|
||||
Reference in New Issue
Block a user