- 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)
258 lines
8.6 KiB
Vue
258 lines
8.6 KiB
Vue
<script setup lang="ts">
|
||
/**
|
||
* Product Detail Page
|
||
*
|
||
* Displays full details for a single product with large image and description.
|
||
* Includes functional "Add to Cart" functionality with notifications and cart UI integration.
|
||
*/
|
||
|
||
import { ArrowLeft, CheckCircle, Loader2 } from 'lucide-vue-next'
|
||
import { toast } from 'vue-sonner'
|
||
|
||
// Page metadata
|
||
definePageMeta({
|
||
layout: 'default',
|
||
})
|
||
|
||
// Get product ID from route
|
||
const route = useRoute()
|
||
const productId = route.params.id as string
|
||
|
||
// Type definition for product
|
||
interface Product {
|
||
id: string
|
||
navProductId: string
|
||
name: string
|
||
description: string
|
||
price: string
|
||
stockQuantity: number
|
||
category: string
|
||
active: boolean
|
||
createdAt: Date
|
||
updatedAt: Date
|
||
}
|
||
|
||
// Fetch product from API
|
||
const { data: product, error, pending } = await useFetch<Product>(`/api/products/${productId}`)
|
||
|
||
// Format price in EUR
|
||
const formattedPrice = computed(() => {
|
||
if (!product.value) return ''
|
||
return new Intl.NumberFormat('de-DE', {
|
||
style: 'currency',
|
||
currency: 'EUR',
|
||
}).format(Number(product.value.price))
|
||
})
|
||
|
||
// Cart composables
|
||
const { addItem, loading: cartLoading, items } = useCart()
|
||
const { open: openCart } = useCartUI()
|
||
|
||
// Local loading state for this specific button
|
||
const isAddingToCart = ref(false)
|
||
|
||
// Check if product is already in cart
|
||
const isInCart = computed(() => {
|
||
if (!product.value) return false
|
||
return items.value.some((item) => item.productId === product.value.id)
|
||
})
|
||
|
||
// Handle "Add to Cart" action
|
||
const handleAddToCart = async () => {
|
||
if (!product.value) return
|
||
|
||
// Prevent adding if out of stock
|
||
if (product.value.stockQuantity === 0) {
|
||
toast.error('Nicht verfügbar', {
|
||
description: 'Dieses Produkt ist derzeit nicht auf Lager.',
|
||
})
|
||
return
|
||
}
|
||
|
||
isAddingToCart.value = true
|
||
|
||
try {
|
||
// Add item to cart (quantity: 1)
|
||
await addItem(product.value.id, 1)
|
||
|
||
// Show success notification
|
||
toast.success('In den Warenkorb gelegt', {
|
||
description: `${product.value.name} wurde zu deinem Warenkorb hinzugefügt.`,
|
||
})
|
||
|
||
// Open cart sidebar/sheet to show added item
|
||
openCart()
|
||
} catch (error) {
|
||
console.error('Failed to add item to cart:', error)
|
||
|
||
// Show error notification
|
||
toast.error('Fehler beim Hinzufügen', {
|
||
description: 'Das Produkt konnte nicht in den Warenkorb gelegt werden. Bitte versuche es erneut.',
|
||
})
|
||
} finally {
|
||
isAddingToCart.value = false
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<NuxtLayout name="default">
|
||
<div class="min-h-screen bg-gradient-primary px-4 py-12 md:px-6 lg:px-8">
|
||
<!-- Back Button -->
|
||
<div class="mx-auto mb-8 max-w-container-narrow">
|
||
<NuxtLink
|
||
to="/products"
|
||
class="inline-flex items-center gap-2 text-white/80 transition-colors hover:text-white"
|
||
>
|
||
<ArrowLeft :size="20" />
|
||
<span>Zurück zur Übersicht</span>
|
||
</NuxtLink>
|
||
</div>
|
||
|
||
<!-- Loading State -->
|
||
<div v-if="pending" class="mx-auto max-w-container-narrow text-center">
|
||
<div class="card-glass inline-block">
|
||
<p class="text-white/80">Produkt wird geladen...</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Error State (404 or other errors) -->
|
||
<div v-else-if="error" class="mx-auto max-w-container-narrow">
|
||
<div class="card-glass border-red/50 bg-red/10">
|
||
<h2 class="mb-2 text-2xl font-semibold text-red">
|
||
{{ error.statusCode === 404 ? 'Produkt nicht gefunden' : 'Fehler beim Laden' }}
|
||
</h2>
|
||
<p class="mb-6 text-white/80">
|
||
{{
|
||
error.statusCode === 404
|
||
? 'Das angeforderte Produkt existiert nicht oder ist nicht verfügbar.'
|
||
: error.message || 'Ein unbekannter Fehler ist aufgetreten.'
|
||
}}
|
||
</p>
|
||
<NuxtLink to="/products">
|
||
<Button variant="experimenta" size="experimenta">
|
||
Zur Produktübersicht
|
||
</Button>
|
||
</NuxtLink>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Product Detail -->
|
||
<div v-else-if="product" class="mx-auto max-w-container-narrow">
|
||
<div class="overflow-hidden rounded-2xl border border-white/20 bg-white/10 shadow-glass backdrop-blur-lg">
|
||
<!-- Product Image (no padding, flush with top) -->
|
||
<div class="relative aspect-[16/9] w-full overflow-hidden bg-purple-dark">
|
||
<img
|
||
src="/img/makerspace-jk-2025.jpg"
|
||
:alt="product.name"
|
||
class="h-full w-full object-cover"
|
||
/>
|
||
<!-- Gradient overlay -->
|
||
<div
|
||
class="absolute inset-0 bg-gradient-to-t from-purple-darkest/80 via-transparent to-transparent"
|
||
/>
|
||
</div>
|
||
|
||
<!-- Product Content -->
|
||
<div class="space-y-6 p-8">
|
||
<!-- Title -->
|
||
<h1 class="text-3xl font-bold text-white md:text-4xl">
|
||
{{ product.name }}
|
||
</h1>
|
||
|
||
<!-- Description -->
|
||
<p class="text-lg leading-relaxed text-white/90">
|
||
{{ product.description }}
|
||
</p>
|
||
|
||
<!-- Product Details -->
|
||
<div class="grid gap-4 sm:grid-cols-2">
|
||
<!-- Price Card -->
|
||
<div class="rounded-xl bg-white/5 p-4 backdrop-blur-sm">
|
||
<span class="mb-1 block text-xs uppercase tracking-wide text-white/60">Preis</span>
|
||
<span class="text-3xl font-bold text-experimenta-accent">
|
||
{{ formattedPrice }}
|
||
</span>
|
||
</div>
|
||
|
||
<!-- Availability Card -->
|
||
<div class="rounded-xl bg-white/5 p-4 backdrop-blur-sm">
|
||
<span class="mb-1 block text-xs uppercase tracking-wide text-white/60">Verfügbarkeit</span>
|
||
<div
|
||
:class="[
|
||
'flex items-center gap-2 text-xl font-semibold',
|
||
product.stockQuantity > 0 ? 'text-green' : 'text-red',
|
||
]"
|
||
>
|
||
<CheckCircle v-if="product.stockQuantity > 0" :size="24" />
|
||
<span>{{ product.stockQuantity > 0 ? 'Sofort' : 'Nicht verfügbar' }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Features / Benefits -->
|
||
<div class="rounded-xl border border-white/20 bg-white/5 p-6 backdrop-blur-sm">
|
||
<h2 class="mb-4 text-xl font-semibold text-white">
|
||
Was du mit dieser Karte bekommst:
|
||
</h2>
|
||
<ul class="space-y-3 text-white/90">
|
||
<li class="flex items-start gap-2">
|
||
<span class="mt-1 text-experimenta-accent">✓</span>
|
||
<span>365 Tage unbegrenzter Zugang</span>
|
||
</li>
|
||
<li class="flex items-start gap-2">
|
||
<span class="mt-1 text-experimenta-accent">✓</span>
|
||
<span>Keine versteckten Kosten</span>
|
||
</li>
|
||
<li class="flex items-start gap-2">
|
||
<span class="mt-1 text-experimenta-accent">✓</span>
|
||
<span>Sofort einsatzbereit nach Kauf</span>
|
||
</li>
|
||
<li class="flex items-start gap-2">
|
||
<span class="mt-1 text-experimenta-accent">✓</span>
|
||
<span>Flexible Nutzung – komme so oft du möchtest</span>
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
|
||
<!-- Action Buttons -->
|
||
<div class="flex flex-col gap-4 sm:flex-row">
|
||
<Button
|
||
variant="experimenta"
|
||
size="experimenta"
|
||
class="flex-1 relative"
|
||
:disabled="product.stockQuantity === 0 || isAddingToCart"
|
||
@click="handleAddToCart"
|
||
>
|
||
<!-- Loading spinner -->
|
||
<Loader2
|
||
v-if="isAddingToCart"
|
||
:size="20"
|
||
class="mr-2 animate-spin"
|
||
/>
|
||
|
||
<!-- Button text -->
|
||
<span v-if="isAddingToCart">Wird hinzugefügt...</span>
|
||
<span v-else-if="product.stockQuantity === 0">Nicht verfügbar</span>
|
||
<span v-else>In den Warenkorb</span>
|
||
</Button>
|
||
|
||
<NuxtLink to="/products" class="btn-secondary flex-1 text-center">
|
||
Weitere Produkte ansehen
|
||
</NuxtLink>
|
||
</div>
|
||
|
||
<!-- Already in cart hint -->
|
||
<div
|
||
v-if="isInCart && product.stockQuantity > 0"
|
||
class="mt-2 text-center text-sm text-white/70"
|
||
>
|
||
Dieses Produkt befindet sich bereits in deinem Warenkorb.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</NuxtLayout>
|
||
</template>
|