Add product detail and listing pages with API integration

- Created a new product detail page to display individual product information, including images, descriptions, and pricing.
- Implemented a product listing page to showcase all available products using the ProductCard and ProductGrid components.
- Added API endpoints for fetching product data, ensuring only active products are returned.
- Introduced a database seed script to populate the database with initial mock product data for development and testing.
- Updated settings to include new database seeding command and adjusted routing for product links.
This commit is contained in:
Bastian Masanek
2025-11-01 19:07:59 +01:00
parent 9150af3ac2
commit 7ab80a6635
9 changed files with 600 additions and 60 deletions

195
app/pages/products/[id].vue Normal file
View File

@@ -0,0 +1,195 @@
<script setup lang="ts">
/**
* Product Detail Page
*
* Displays full details for a single product with large image and description.
* Includes placeholder "Add to Cart" functionality for future implementation.
*/
import { ArrowLeft, CheckCircle } from 'lucide-vue-next'
// 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))
})
// Handle "Add to Cart" action (placeholder for future implementation)
const handleAddToCart = () => {
// TODO: Implement cart functionality in future phase
alert('Add to Cart funktioniert noch nicht. Diese Funktion wird in einer späteren Phase implementiert.')
}
</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"
:disabled="product.stockQuantity === 0"
@click="handleAddToCart"
>
{{ product.stockQuantity > 0 ? 'In den Warenkorb' : 'Nicht verfügbar' }}
</Button>
<NuxtLink to="/products" class="flex-1">
<Button variant="outline" size="experimenta" class="w-full">
Weitere Produkte ansehen
</Button>
</NuxtLink>
</div>
</div>
</div>
</div>
</div>
</NuxtLayout>
</template>

View File

@@ -0,0 +1,139 @@
<script setup lang="ts">
/**
* Products Listing Page
*
* Displays all available products (annual passes, etc.) using ProductCard and ProductGrid components.
* Fetches products from the database via API.
*/
// Page metadata
definePageMeta({
layout: 'default',
})
// 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 products from API
const { data: products, error, pending } = await useFetch<Product[]>('/api/products')
// Map product categories to badges
const getBadge = (category: string): string | undefined => {
const badges: Record<string, string> = {
'makerspace-annual-pass': 'Beliebt',
'educator-annual-pass': 'Neu',
}
return badges[category]
}
// Map product categories to discount percentages (for demo)
const getDiscount = (category: string): number | undefined => {
const discounts: Record<string, number> = {
'educator-annual-pass': 15,
}
return discounts[category]
}
</script>
<template>
<NuxtLayout name="default">
<div class="min-h-screen bg-gradient-primary px-4 py-12 md:px-6 lg:px-8">
<!-- Page Header -->
<div class="mx-auto mb-12 max-w-container-wide text-center">
<h1 class="mb-4 text-4xl font-bold text-white md:text-5xl">
Unsere Jahreskarten
</h1>
<p class="mx-auto max-w-2xl text-lg text-white/80">
Wähle die passende Jahreskarte für deine Bedürfnisse. Alle Karten sind 365 Tage gültig
und sofort einsetzbar.
</p>
</div>
<!-- Loading State -->
<div v-if="pending" class="mx-auto max-w-container-wide text-center">
<div class="card-glass inline-block">
<p class="text-white/80">Produkte werden geladen...</p>
</div>
</div>
<!-- Error State -->
<div v-else-if="error" class="mx-auto max-w-container-wide">
<div class="card-glass border-red/50 bg-red/10">
<h2 class="mb-2 text-xl font-semibold text-red">
Fehler beim Laden der Produkte
</h2>
<p class="text-white/80">
{{ error.message || 'Ein unbekannter Fehler ist aufgetreten.' }}
</p>
</div>
</div>
<!-- Products Grid -->
<div v-else-if="products && products.length > 0" class="mx-auto max-w-container-wide">
<ProductGrid :columns="3">
<ProductCard
v-for="product in products"
:key="product.id"
image="/img/makerspace-jk-2025.jpg"
:title="product.name"
:description="product.description"
:price="Number(product.price)"
:badge="getBadge(product.category)"
:discount-percentage="getDiscount(product.category)"
:product-id="product.id"
/>
</ProductGrid>
</div>
<!-- Empty State -->
<div v-else class="mx-auto max-w-container-wide text-center">
<div class="card-glass">
<h2 class="mb-2 text-xl font-semibold text-white">
Keine Produkte verfügbar
</h2>
<p class="text-white/80">
Aktuell sind keine Produkte verfügbar. Bitte schaue später noch einmal vorbei.
</p>
</div>
</div>
<!-- Info Card -->
<div class="mx-auto mt-12 max-w-container-wide">
<div class="card-info card-accent-border">
<h2 class="mb-3 text-xl font-semibold text-experimenta-accent">
Warum eine Jahreskarte?
</h2>
<ul class="space-y-2 text-white/90">
<li class="flex items-start gap-2">
<span class="mt-1 text-experimenta-accent"></span>
<span>365 Tage unbegrenzter Zugang zu deinen Lieblingsbereichen</span>
</li>
<li class="flex items-start gap-2">
<span class="mt-1 text-experimenta-accent"></span>
<span>Keine versteckten Kosten ein Preis für das ganze Jahr</span>
</li>
<li class="flex items-start gap-2">
<span class="mt-1 text-experimenta-accent"></span>
<span>Exklusive Vorteile und Vergünstigungen für Jahreskarteninhaber</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>
</div>
</div>
</NuxtLayout>
</template>