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,44 @@
/**
* GET /api/cart
*
* Get the current user's shopping cart with all items
*
* Features:
* - Returns cart for authenticated users (by userId)
* - Returns cart for guest users (by sessionId)
* - Automatically removes unavailable products (inactive or out of stock)
* - Calculates totals and subtotals
* - Returns list of removed items if any were auto-cleaned
*
* Response:
* {
* cart: { id, userId, sessionId, createdAt, updatedAt },
* items: [{ id, product, quantity, subtotal, addedAt }],
* total: number,
* itemCount: number,
* removedItems?: string[] // Names of removed products
* }
*/
export default defineEventHandler(async (event) => {
try {
// Get or create cart for current user/session
const cart = await getOrCreateCart(event)
// Get cart with items (auto-cleans unavailable products)
const cartSummary = await getCartWithItems(cart.id)
return cartSummary
} catch (error) {
// Log error for debugging
console.error('Error fetching cart:', error)
// Return empty cart on error
return {
cart: null,
items: [],
total: 0,
itemCount: 0,
}
}
})

View File

@@ -0,0 +1,91 @@
/**
* POST /api/cart/items
*
* Add a product to the shopping cart
*
* Request Body:
* {
* productId: string (UUID)
* quantity: number (positive integer, default: 1)
* }
*
* Behavior:
* - If product already in cart, increments quantity
* - Validates product exists, is active, and has sufficient stock
* - Checks role-based visibility permissions
* - Creates cart if it doesn't exist
*
* Response:
* {
* success: true,
* message: string,
* cart: CartSummary
* }
*/
import { z } from 'zod'
import { eq, and } from 'drizzle-orm'
import { cartItems } from '../../database/schema'
// Request validation schema
const addToCartSchema = z.object({
productId: z.string().uuid('Invalid product ID'),
quantity: z.number().int().positive().default(1),
})
export default defineEventHandler(async (event) => {
// Validate request body
const body = await readBody(event)
const { productId, quantity } = await addToCartSchema.parseAsync(body)
// Validate product availability and permissions
const product = await validateProductForCart(event, productId, quantity)
// Get or create cart
const cart = await getOrCreateCart(event)
const db = await useDatabase()
// Check if product already in cart
const existingItem = await db.query.cartItems.findFirst({
where: and(
eq(cartItems.cartId, cart.id),
eq(cartItems.productId, productId)
),
})
if (existingItem) {
// Product already in cart - increment quantity
const newQuantity = existingItem.quantity + quantity
// Validate new quantity against stock
validateQuantityUpdate(newQuantity, product.stockQuantity)
// Update quantity
await db
.update(cartItems)
.set({ quantity: newQuantity })
.where(eq(cartItems.id, existingItem.id))
} else {
// Add new item to cart
await db.insert(cartItems).values({
cartId: cart.id,
productId,
quantity,
})
}
// Update cart timestamp
await touchCart(cart.id)
// Return updated cart
const cartSummary = await getCartWithItems(cart.id)
return {
success: true,
message: existingItem
? `Quantity updated to ${existingItem.quantity + quantity}`
: 'Product added to cart',
cart: cartSummary,
}
})

View File

@@ -0,0 +1,65 @@
/**
* DELETE /api/cart/items/:id
*
* Remove an item from the shopping cart
*
* Validation:
* - Cart item must exist
* - Cart item must belong to current user/session
*
* Response:
* - 204 No Content on success
* - 404 Not Found if item doesn't exist or doesn't belong to user
*/
import { z } from 'zod'
import { eq } from 'drizzle-orm'
import { cartItems } from '../../../database/schema'
// Path params validation
const pathParamsSchema = z.object({
id: z.string().uuid('Invalid cart item ID'),
})
export default defineEventHandler(async (event) => {
// Validate path params
const params = await getValidatedRouterParams(event, pathParamsSchema.parse)
const cartItemId = params.id
// Verify cart item belongs to current user/session
const hasPermission = await verifyCartItemOwnership(event, cartItemId)
if (!hasPermission) {
throw createError({
statusCode: 404,
statusMessage: 'Cart item not found',
})
}
const db = await useDatabase()
// Fetch cart item to get cart ID for timestamp update
const cartItem = await db.query.cartItems.findFirst({
where: eq(cartItems.id, cartItemId),
with: {
cart: true,
},
})
if (!cartItem) {
throw createError({
statusCode: 404,
statusMessage: 'Cart item not found',
})
}
// Delete cart item
await db.delete(cartItems).where(eq(cartItems.id, cartItemId))
// Update cart timestamp
await touchCart(cartItem.cart.id)
// Return 204 No Content
setResponseStatus(event, 204)
return null
})

View File

@@ -0,0 +1,96 @@
/**
* PATCH /api/cart/items/:id
*
* Update the quantity of a cart item
*
* Request Body:
* {
* quantity: number (positive integer)
* }
*
* Validation:
* - Cart item must exist
* - Cart item must belong to current user/session
* - Quantity must be >= 1
* - Quantity must not exceed available stock
*
* Response:
* {
* success: true,
* message: string,
* cart: CartSummary
* }
*/
import { z } from 'zod'
import { eq } from 'drizzle-orm'
import { cartItems } from '../../../database/schema'
// Request validation schema
const updateQuantitySchema = z.object({
quantity: z.number().int().min(1, 'Quantity must be at least 1'),
})
// Path params validation
const pathParamsSchema = z.object({
id: z.string().uuid('Invalid cart item ID'),
})
export default defineEventHandler(async (event) => {
// Validate path params
const params = await getValidatedRouterParams(event, pathParamsSchema.parse)
const cartItemId = params.id
// Validate request body
const body = await readBody(event)
const { quantity } = await updateQuantitySchema.parseAsync(body)
// Verify cart item belongs to current user/session
const hasPermission = await verifyCartItemOwnership(event, cartItemId)
if (!hasPermission) {
throw createError({
statusCode: 404,
statusMessage: 'Cart item not found',
})
}
const db = await useDatabase()
// Fetch cart item with product details
const cartItem = await db.query.cartItems.findFirst({
where: eq(cartItems.id, cartItemId),
with: {
product: true,
cart: true,
},
})
if (!cartItem) {
throw createError({
statusCode: 404,
statusMessage: 'Cart item not found',
})
}
// Validate quantity against stock
validateQuantityUpdate(quantity, cartItem.product.stockQuantity)
// Update quantity
await db
.update(cartItems)
.set({ quantity })
.where(eq(cartItems.id, cartItemId))
// Update cart timestamp
await touchCart(cartItem.cart.id)
// Return updated cart
const cartSummary = await getCartWithItems(cartItem.cart.id)
return {
success: true,
message: 'Quantity updated successfully',
cart: cartSummary,
}
})

View File

@@ -0,0 +1,116 @@
import { and, lt, isNull } from 'drizzle-orm'
import { carts } from '../database/schema'
/**
* Cart Cleanup Utilities
*
* These functions prepare the structure for automatic cart cleanup.
* The actual cleanup job will be implemented in a later phase using BullMQ.
*
* Cleanup Strategy:
* - User carts: Keep until updated_at > CART_EXPIRY_DAYS
* - Guest carts: Keep until updated_at > CART_EXPIRY_DAYS
* - Rationale: Inactive carts consume database space and should be pruned
*
* Future Implementation:
* - BullMQ scheduled job runs daily at night (e.g., 3 AM)
* - Calls getExpiredCarts() to find carts to delete
* - Deletes expired carts (cascade deletes cart_items automatically)
* - Logs cleanup statistics for monitoring
*/
/**
* Get carts that are older than the configured expiry period
*
* @returns Array of expired cart IDs
*/
export async function getExpiredCarts(): Promise<string[]> {
const db = useDatabase()
const config = useRuntimeConfig()
// Calculate expiry date
const expiryDays = config.cart.expiryDays
const expiryDate = new Date()
expiryDate.setDate(expiryDate.getDate() - expiryDays)
// Find carts not updated since expiry date
const expiredCarts = await db
.select({ id: carts.id })
.from(carts)
.where(lt(carts.updatedAt, expiryDate))
return expiredCarts.map((cart) => cart.id)
}
/**
* Delete expired carts
*
* Note: cart_items are automatically deleted via CASCADE foreign key constraint
*
* @param cartIds - Array of cart UUIDs to delete
* @returns Number of carts deleted
*/
export async function deleteExpiredCarts(cartIds: string[]): Promise<number> {
if (cartIds.length === 0) {
return 0
}
const db = useDatabase()
// Delete carts (cart_items cascade automatically)
const result = await db
.delete(carts)
.where(
and(
...cartIds.map((id) => eq(carts.id, id))
)
)
return cartIds.length
}
/**
* Get cleanup statistics
*
* @returns Statistics about carts in the database
*/
export async function getCartStatistics() {
const db = useDatabase()
const config = useRuntimeConfig()
// Calculate expiry date
const expiryDays = config.cart.expiryDays
const expiryDate = new Date()
expiryDate.setDate(expiryDate.getDate() - expiryDays)
// Count carts by type
const [totalCarts] = await db.select({ count: count() }).from(carts)
const [userCarts] = await db
.select({ count: count() })
.from(carts)
.where(isNull(carts.userId).not())
const [guestCarts] = await db
.select({ count: count() })
.from(carts)
.where(isNull(carts.userId))
const [expiredCarts] = await db
.select({ count: count() })
.from(carts)
.where(lt(carts.updatedAt, expiryDate))
return {
totalCarts: totalCarts?.count || 0,
userCarts: userCarts?.count || 0,
guestCarts: guestCarts?.count || 0,
expiredCarts: expiredCarts?.count || 0,
expiryDays,
expiryDate: expiryDate.toISOString(),
}
}
// Note: Import count function
import { count } from 'drizzle-orm'
import { eq } from 'drizzle-orm'

View File

@@ -0,0 +1,202 @@
import type { H3Event } from 'h3'
import { and, eq, inArray } from 'drizzle-orm'
import { carts, cartItems, products } from '../database/schema'
// Re-export shared types
export type { CartItemWithProduct, CartSummary } from '~/types/cart'
import type { CartItemWithProduct, CartSummary } from '~/types/cart'
/**
* Get or create a cart for the current user/session
*
* @param event - H3 event object
* @returns Cart record
*/
export async function getOrCreateCart(event: H3Event) {
const db = useDatabase()
const { user } = await getUserSession(event)
if (user) {
// Authenticated user - find or create cart by userId
let cart = await db.query.carts.findFirst({
where: eq(carts.userId, user.id),
})
if (!cart) {
// Create new cart for user
const [newCart] = await db
.insert(carts)
.values({
userId: user.id,
sessionId: '', // Empty for user carts (not used)
})
.returning()
cart = newCart
}
return cart
} else {
// Guest user - find or create cart by sessionId
const sessionId = getOrCreateSessionId(event)
let cart = await db.query.carts.findFirst({
where: and(eq(carts.sessionId, sessionId), eq(carts.userId, null)),
})
if (!cart) {
// Create new cart for guest
const [newCart] = await db
.insert(carts)
.values({
userId: null,
sessionId,
})
.returning()
cart = newCart
}
return cart
}
}
/**
* Get cart with all items and product details
*
* Automatically filters out unavailable products (inactive or out of stock)
* and removes them from the cart.
*
* @param cartId - Cart UUID
* @returns Cart summary with items, totals, and removed items
*/
export async function getCartWithItems(cartId: string): Promise<CartSummary> {
const db = useDatabase()
// Fetch cart
const cart = await db.query.carts.findFirst({
where: eq(carts.id, cartId),
})
if (!cart) {
throw createError({
statusCode: 404,
statusMessage: 'Cart not found',
})
}
// Fetch cart items with product details
const items = await db.query.cartItems.findMany({
where: eq(cartItems.cartId, cartId),
with: {
product: true,
},
})
// Separate available and unavailable items
const availableItems: CartItemWithProduct[] = []
const unavailableItemIds: string[] = []
const removedProductNames: string[] = []
for (const item of items) {
// Check if product is available
const isAvailable = item.product.active && item.product.stockQuantity >= item.quantity
if (isAvailable) {
// Add to available items with subtotal calculation
availableItems.push({
id: item.id,
cartId: item.cartId,
productId: item.productId,
quantity: item.quantity,
addedAt: item.addedAt,
product: {
id: item.product.id,
name: item.product.name,
description: item.product.description,
price: item.product.price,
stockQuantity: item.product.stockQuantity,
active: item.product.active,
category: item.product.category,
imageUrl: item.product.imageUrl,
},
subtotal: Number.parseFloat(item.product.price) * item.quantity,
})
} else {
// Mark for removal
unavailableItemIds.push(item.id)
removedProductNames.push(item.product.name)
}
}
// Remove unavailable items from cart
if (unavailableItemIds.length > 0) {
await db.delete(cartItems).where(inArray(cartItems.id, unavailableItemIds))
// Update cart's updatedAt timestamp
await db
.update(carts)
.set({ updatedAt: new Date() })
.where(eq(carts.id, cartId))
}
// Calculate total
const total = availableItems.reduce((sum, item) => sum + item.subtotal, 0)
const itemCount = availableItems.reduce((sum, item) => sum + item.quantity, 0)
return {
cart,
items: availableItems,
total,
itemCount,
...(removedProductNames.length > 0 && { removedItems: removedProductNames }),
}
}
/**
* Update cart's updated_at timestamp
*
* @param cartId - Cart UUID
*/
export async function touchCart(cartId: string): Promise<void> {
const db = useDatabase()
await db
.update(carts)
.set({ updatedAt: new Date() })
.where(eq(carts.id, cartId))
}
/**
* Check if a cart item belongs to the current user/session
*
* @param event - H3 event object
* @param cartItemId - Cart item UUID
* @returns true if item belongs to current user/session, false otherwise
*/
export async function verifyCartItemOwnership(
event: H3Event,
cartItemId: string
): Promise<boolean> {
const db = useDatabase()
const { user } = await getUserSession(event)
// Fetch cart item with cart details
const item = await db.query.cartItems.findFirst({
where: eq(cartItems.id, cartItemId),
with: {
cart: true,
},
})
if (!item) {
return false
}
// Check ownership
if (user) {
// Authenticated user - check userId match
return item.cart.userId === user.id
} else {
// Guest user - check sessionId match
const sessionId = getSessionId(event)
return sessionId !== null && item.cart.sessionId === sessionId && item.cart.userId === null
}
}

View File

@@ -0,0 +1,65 @@
import type { H3Event } from 'h3'
import { v4 as uuidv4 } from 'uuid'
/**
* Get or create a session ID for guest cart management
*
* This session ID is stored in a secure HTTP-only cookie and used to
* identify guest carts. When a user logs in, their guest cart can be
* merged with their user cart (future enhancement).
*
* @param event - H3 event object
* @returns Session ID (UUID)
*/
export function getOrCreateSessionId(event: H3Event): string {
const config = useRuntimeConfig()
const cookieName = config.cart.sessionCookieName
// Try to get existing session ID from cookie
const existingSessionId = getCookie(event, cookieName)
if (existingSessionId) {
return existingSessionId
}
// Generate new session ID
const newSessionId = uuidv4()
// Calculate expiry date based on config
const expiryDays = config.cart.expiryDays
const maxAge = expiryDays * 24 * 60 * 60 // Convert days to seconds
// Set session cookie
setCookie(event, cookieName, newSessionId, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge,
path: '/',
})
return newSessionId
}
/**
* Get the current session ID without creating a new one
*
* @param event - H3 event object
* @returns Session ID or null if not found
*/
export function getSessionId(event: H3Event): string | null {
const config = useRuntimeConfig()
const cookieName = config.cart.sessionCookieName
return getCookie(event, cookieName) || null
}
/**
* Clear the session ID cookie
*
* @param event - H3 event object
*/
export function clearSessionId(event: H3Event): void {
const config = useRuntimeConfig()
const cookieName = config.cart.sessionCookieName
deleteCookie(event, cookieName)
}

View File

@@ -0,0 +1,100 @@
import { eq } from 'drizzle-orm'
import { products } from '../database/schema'
import type { H3Event } from 'h3'
/**
* Validate product availability for adding to cart
*
* Checks:
* - Product exists
* - Product is active
* - Product has sufficient stock
* - User has permission to view product (role-based visibility)
*
* @param event - H3 event object
* @param productId - Product UUID
* @param quantity - Requested quantity
* @returns Product details if valid
* @throws H3Error if validation fails
*/
export async function validateProductForCart(
event: H3Event,
productId: string,
quantity: number
) {
const db = useDatabase()
// Fetch product
const product = await db.query.products.findFirst({
where: eq(products.id, productId),
})
if (!product) {
throw createError({
statusCode: 404,
statusMessage: 'Product not found',
})
}
// Check if product is active
if (!product.active) {
throw createError({
statusCode: 400,
statusMessage: 'This product is no longer available',
})
}
// Check stock availability
if (product.stockQuantity < quantity) {
throw createError({
statusCode: 400,
statusMessage: `Insufficient stock. Only ${product.stockQuantity} available.`,
})
}
// Check role-based visibility
const { user } = await getUserSession(event)
if (!user) {
// Guest users cannot see products (MVP: no products visible to unauthenticated users)
throw createError({
statusCode: 403,
statusMessage: 'Please log in to add items to your cart',
})
}
// Check if user has permission to view this product
const canView = await isProductVisibleForUser(productId, user.id)
if (!canView) {
throw createError({
statusCode: 404,
statusMessage: 'Product not found',
})
}
return product
}
/**
* Validate quantity update for cart item
*
* @param newQuantity - New quantity value
* @param stockQuantity - Available stock
* @throws H3Error if validation fails
*/
export function validateQuantityUpdate(newQuantity: number, stockQuantity: number): void {
if (newQuantity < 1) {
throw createError({
statusCode: 400,
statusMessage: 'Quantity must be at least 1',
})
}
if (newQuantity > stockQuantity) {
throw createError({
statusCode: 400,
statusMessage: `Insufficient stock. Only ${stockQuantity} available.`,
})
}
}