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

178
app/composables/useCart.ts Normal file
View File

@@ -0,0 +1,178 @@
// composables/useCart.ts
import type { CartSummary } from '~/types/cart'
/**
* Shopping Cart composable
*
* Manages cart state and provides methods for cart operations
*
* Features:
* - Reactive cart state (cart, items, total, itemCount)
* - Auto-fetch cart on mount
* - Add, update, and remove items
* - Loading states for async operations
* - Error handling with notifications for removed items
*
* Usage:
* const { cart, items, total, itemCount, loading, addItem, updateItem, removeItem, fetchCart } = useCart()
*/
// Global cart state (shared across all components)
const cartState = ref<CartSummary | null>(null)
const loading = ref(false)
const initialized = ref(false)
export function useCart() {
// Computed reactive properties
const cart = computed(() => cartState.value?.cart ?? null)
const items = computed(() => cartState.value?.items ?? [])
const total = computed(() => cartState.value?.total ?? 0)
const itemCount = computed(() => cartState.value?.itemCount ?? 0)
/**
* Fetch cart from server
* Auto-cleans unavailable products and returns removed items
*/
async function fetchCart() {
loading.value = true
try {
const data = await $fetch<CartSummary>('/api/cart', {
method: 'GET',
})
cartState.value = data
// Show notification if products were removed
if (data.removedItems && data.removedItems.length > 0) {
// TODO: Show toast notification when toast composable is implemented
// For now, log to console
console.warn('Products removed from cart:', data.removedItems)
}
return data
} catch (error) {
console.error('Failed to fetch cart:', error)
// Set to null on error
cartState.value = null
throw error
} finally {
loading.value = false
}
}
/**
* Add item to cart
*
* @param productId - Product UUID
* @param quantity - Quantity to add (default: 1)
*/
async function addItem(productId: string, quantity: number = 1) {
loading.value = true
try {
await $fetch('/api/cart/items', {
method: 'POST',
body: {
productId,
quantity,
},
})
// Refresh cart to get updated state
await fetchCart()
} catch (error) {
console.error('Failed to add item to cart:', error)
throw error
} finally {
loading.value = false
}
}
/**
* Update cart item quantity
*
* @param itemId - Cart item UUID
* @param quantity - New quantity (must be >= 1)
*/
async function updateItem(itemId: string, quantity: number) {
if (quantity < 1) {
throw new Error('Quantity must be at least 1')
}
loading.value = true
try {
await $fetch(`/api/cart/items/${itemId}`, {
method: 'PATCH',
body: {
quantity,
},
})
// Refresh cart to get updated state
await fetchCart()
} catch (error) {
console.error('Failed to update cart item:', error)
throw error
} finally {
loading.value = false
}
}
/**
* Remove item from cart
*
* @param itemId - Cart item UUID
*/
async function removeItem(itemId: string) {
loading.value = true
try {
await $fetch(`/api/cart/items/${itemId}`, {
method: 'DELETE',
})
// Refresh cart to get updated state
await fetchCart()
} catch (error) {
console.error('Failed to remove cart item:', error)
throw error
} finally {
loading.value = false
}
}
/**
* Clear all items from cart
* For future use (not implemented in API yet)
*/
async function clearCart() {
// TODO: Implement when API endpoint is ready
console.warn('clearCart() not yet implemented')
}
/**
* Initialize cart on mount
* Only fetches once per session
*/
onMounted(async () => {
if (!initialized.value) {
await fetchCart()
initialized.value = true
}
})
return {
// State
cart,
items,
total,
itemCount,
loading,
// Methods
fetchCart,
addItem,
updateItem,
removeItem,
clearCart,
}
}

View File

@@ -0,0 +1,104 @@
// composables/useCartUI.ts
import { useMediaQuery } from '@vueuse/core'
/**
* Cart UI composable
*
* Manages cart sidebar/sheet UI state and responsive behavior
*
* Features:
* - Sidebar/sheet open/close state
* - Responsive detection (mobile vs desktop)
* - Body scroll lock when cart is open
* - Global state (shared across all components)
*
* Usage:
* const { isOpen, isMobile, open, close, toggle } = useCartUI()
*/
// Global cart UI state (shared across all components)
const isOpen = ref(false)
export function useCartUI() {
// Responsive breakpoint: mobile = width < 1024px (lg breakpoint)
const isMobile = useMediaQuery('(max-width: 1023px)')
/**
* Open cart sidebar/sheet
*/
function open() {
isOpen.value = true
lockBodyScroll()
}
/**
* Close cart sidebar/sheet
*/
function close() {
isOpen.value = false
unlockBodyScroll()
}
/**
* Toggle cart sidebar/sheet
*/
function toggle() {
if (isOpen.value) {
close()
} else {
open()
}
}
/**
* Lock body scroll (prevent scrolling when cart is open)
*/
function lockBodyScroll() {
if (import.meta.client) {
document.body.style.overflow = 'hidden'
}
}
/**
* Unlock body scroll
*/
function unlockBodyScroll() {
if (import.meta.client) {
document.body.style.overflow = ''
}
}
/**
* Clean up: Ensure body scroll is unlocked when component unmounts
*/
onUnmounted(() => {
unlockBodyScroll()
})
/**
* Watch for route changes: Close cart when navigating
*/
if (import.meta.client) {
const route = useRoute()
watch(
() => route.path,
() => {
if (isOpen.value) {
close()
}
}
)
}
return {
// State
isOpen: readonly(isOpen),
isMobile: readonly(isMobile),
// Methods
open,
close,
toggle,
}
}