Implement experimenta product listing page and enhance navigation components
- Added a new page for displaying experimenta annual passes, integrating ProductCard and ProductGrid components for product presentation. - Updated API to filter products by category, allowing for specific product queries. - Enhanced navigation components, including AreaTabs and RoleSwitcher, to improve user experience and accessibility. - Refactored existing components for better styling consistency and responsiveness. - Improved dropdown menu components with updated styles and hover effects for better usability.
This commit is contained in:
@@ -43,12 +43,9 @@ async function handleLogout() {
|
||||
|
||||
<template>
|
||||
<!-- Not logged in: Show login prompt -->
|
||||
<NuxtLink
|
||||
v-if="!loggedIn"
|
||||
to="/auth"
|
||||
<NuxtLink v-if="!loggedIn" to="/auth"
|
||||
class="flex items-center gap-2 px-4 py-2 rounded-2xl border-2 border-dashed border-muted-foreground/30 hover:border-experimenta-accent hover:bg-experimenta-accent/10 transition-all"
|
||||
aria-label="Anmelden oder Registrieren"
|
||||
>
|
||||
aria-label="Anmelden oder Registrieren">
|
||||
<Avatar class="h-10 w-10 border-2 border-muted-foreground/30 rounded-2xl">
|
||||
<AvatarFallback class="bg-muted text-muted-foreground rounded-2xl">
|
||||
<User class="h-5 w-5" />
|
||||
@@ -62,17 +59,18 @@ async function handleLogout() {
|
||||
<DropdownMenuTrigger as-child>
|
||||
<button
|
||||
class="flex items-center gap-3 rounded-2xl px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-experimenta-accent focus:ring-offset-2 transition-all hover:bg-white/10"
|
||||
aria-label="Benutzermenü öffnen"
|
||||
>
|
||||
aria-label="Benutzermenü öffnen">
|
||||
<!-- Greeting text (Desktop only) -->
|
||||
<span class="hidden md:inline text-sm font-medium text-white">
|
||||
<span class="hidden md:inline font-medium text-white">
|
||||
Hallo, {{ user?.firstName }}
|
||||
</span>
|
||||
|
||||
<!-- Avatar with status ring (logged in indicator) -->
|
||||
<Avatar class="h-10 w-10 md:h-12 md:w-12 border-3 border-experimenta-purple shadow-lg bg-experimenta-accent ring-2 ring-experimenta-accent ring-offset-2 ring-offset-white dark:ring-offset-zinc-950">
|
||||
<Avatar
|
||||
class="h-10 w-10 md:h-12 md:w-12 border-3 border-experimenta-purple shadow-lg bg-experimenta-accent ring-2 ring-experimenta-accent ring-offset-2 ring-offset-white dark:ring-offset-zinc-950">
|
||||
<AvatarImage :src="undefined" :alt="user?.firstName" />
|
||||
<AvatarFallback class="bg-experimenta-accent text-white font-bold text-sm md:text-base flex items-center justify-center w-full h-full">
|
||||
<AvatarFallback
|
||||
class="bg-experimenta-accent text-white font-bold text-sm md:text-base flex items-center justify-center w-full h-full">
|
||||
{{ userInitials }}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
@@ -8,16 +8,26 @@ interface ProductArea {
|
||||
label: string
|
||||
icon: any
|
||||
enabled: boolean
|
||||
visible: boolean
|
||||
badge?: string
|
||||
route: string
|
||||
}
|
||||
|
||||
const areas: ProductArea[] = [
|
||||
{
|
||||
id: 'makerspace',
|
||||
label: 'Makerspace',
|
||||
id: 'experimenta-annual-passes',
|
||||
label: 'experimenta Jahreskarten',
|
||||
icon: Sparkles,
|
||||
enabled: true,
|
||||
visible: false,
|
||||
route: '/experimenta',
|
||||
},
|
||||
{
|
||||
id: 'makerspace-annual-passes',
|
||||
label: 'Makerspace Jahreskarten',
|
||||
icon: Wrench,
|
||||
enabled: true,
|
||||
visible: true,
|
||||
route: '/products',
|
||||
},
|
||||
{
|
||||
@@ -25,17 +35,10 @@ const areas: ProductArea[] = [
|
||||
label: 'Labore',
|
||||
icon: FlaskConical,
|
||||
enabled: false,
|
||||
visible: false,
|
||||
badge: 'Demnächst',
|
||||
route: '/labs',
|
||||
},
|
||||
{
|
||||
id: 'experimenta',
|
||||
label: 'experimenta',
|
||||
icon: Sparkles,
|
||||
enabled: false,
|
||||
badge: 'Demnächst',
|
||||
route: '/experimenta',
|
||||
},
|
||||
]
|
||||
|
||||
const route = useRoute()
|
||||
@@ -64,17 +67,11 @@ function navigateToArea(area: ProductArea) {
|
||||
<!-- Desktop: Tabs -->
|
||||
<Tabs :model-value="currentArea" class="hidden md:block">
|
||||
<TabsList class="h-auto p-1 bg-muted/50">
|
||||
<TabsTrigger
|
||||
v-for="area in areas"
|
||||
:key="area.id"
|
||||
:value="area.id"
|
||||
:disabled="!area.enabled"
|
||||
:class="[
|
||||
<TabsTrigger v-for="area in areas.filter(area => area.visible)" :key="area.id" :value="area.id"
|
||||
:disabled="!area.enabled" :class="[
|
||||
'gap-2 data-[state=active]:bg-white dark:data-[state=active]:bg-zinc-900',
|
||||
!area.enabled && 'opacity-60 cursor-not-allowed',
|
||||
]"
|
||||
@click="navigateToArea(area)"
|
||||
>
|
||||
]" @click="navigateToArea(area)">
|
||||
<component :is="area.icon" class="h-4 w-4" />
|
||||
<span>{{ area.label }}</span>
|
||||
<Badge v-if="area.badge" variant="secondary" class="ml-1 text-[10px] px-1.5 py-0">
|
||||
@@ -87,29 +84,17 @@ function navigateToArea(area: ProductArea) {
|
||||
<!-- Mobile: Horizontal scroll with cards -->
|
||||
<div class="md:hidden overflow-x-auto scrollbar-hide">
|
||||
<div class="flex gap-2 p-1 min-w-max">
|
||||
<button
|
||||
v-for="area in areas"
|
||||
:key="area.id"
|
||||
:disabled="!area.enabled"
|
||||
:class="[
|
||||
'flex items-center gap-2 px-4 py-2 rounded-lg border-2 transition-all whitespace-nowrap',
|
||||
currentArea === area.id
|
||||
? 'bg-purple-600 text-white border-purple-600'
|
||||
: 'bg-white dark:bg-zinc-900 border-border hover:border-purple-300',
|
||||
!area.enabled && 'opacity-60 cursor-not-allowed',
|
||||
]"
|
||||
@click="navigateToArea(area)"
|
||||
>
|
||||
<component
|
||||
:is="area.icon"
|
||||
:class="['h-4 w-4', currentArea === area.id ? 'text-white' : '']"
|
||||
/>
|
||||
<button v-for="area in areas.filter(area => area.visible)" :key="area.id" :disabled="!area.enabled" :class="[
|
||||
'flex items-center gap-2 px-4 py-2 rounded-lg border-2 transition-all whitespace-nowrap',
|
||||
currentArea === area.id
|
||||
? 'bg-purple-600 text-white border-purple-600'
|
||||
: 'bg-white dark:bg-zinc-900 border-border hover:border-purple-300',
|
||||
!area.enabled && 'opacity-60 cursor-not-allowed',
|
||||
]" @click="navigateToArea(area)">
|
||||
<component :is="area.icon" :class="['h-4 w-4', currentArea === area.id ? 'text-white' : '']" />
|
||||
<span class="font-medium">{{ area.label }}</span>
|
||||
<Badge
|
||||
v-if="area.badge"
|
||||
:variant="currentArea === area.id ? 'secondary' : 'outline'"
|
||||
class="text-[10px] px-1.5 py-0"
|
||||
>
|
||||
<Badge v-if="area.badge" :variant="currentArea === area.id ? 'secondary' : 'outline'"
|
||||
class="text-[10px] px-1.5 py-0">
|
||||
{{ area.badge }}
|
||||
</Badge>
|
||||
</button>
|
||||
|
||||
@@ -107,7 +107,7 @@ function switchRole(role: UserRole) {
|
||||
:key="role.value"
|
||||
:disabled="!role.enabled"
|
||||
:class="[
|
||||
'gap-3 py-3 cursor-pointer',
|
||||
'gap-3 py-3 cursor-pointer group',
|
||||
!role.enabled && 'opacity-60 cursor-not-allowed',
|
||||
currentRole === role.value && 'bg-purple-50 dark:bg-purple-950',
|
||||
]"
|
||||
@@ -117,7 +117,7 @@ function switchRole(role: UserRole) {
|
||||
|
||||
<div class="flex-1 flex flex-col gap-1">
|
||||
<span class="text-sm font-semibold">{{ role.label }}</span>
|
||||
<span class="text-sm text-muted-foreground">{{ role.description }}</span>
|
||||
<span class="text-sm text-muted-foreground group-hover:text-foreground/80 transition-colors">{{ role.description }}</span>
|
||||
</div>
|
||||
|
||||
<Check
|
||||
|
||||
@@ -27,7 +27,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent
|
||||
v-bind="forwarded"
|
||||
:class="cn('z-50 min-w-32 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2', props.class)"
|
||||
:class="cn('z-50 min-w-32 overflow-hidden rounded-2xl border border-white/10 bg-white/95 dark:bg-zinc-900/95 backdrop-blur-xl p-2 text-zinc-800 dark:text-zinc-100 shadow-glass data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</DropdownMenuContent>
|
||||
|
||||
@@ -16,7 +16,7 @@ const forwardedProps = useForwardProps(delegatedProps)
|
||||
<DropdownMenuItem
|
||||
v-bind="forwardedProps"
|
||||
:class="cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm gap-2 px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0',
|
||||
'relative flex cursor-default select-none items-center rounded-xl gap-2 px-3 py-2.5 text-sm outline-none transition-all duration-200 hover:bg-accent focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-40 [&>svg]:size-4 [&>svg]:shrink-0',
|
||||
inset && 'pl-8',
|
||||
props.class,
|
||||
)"
|
||||
|
||||
@@ -15,7 +15,7 @@ const forwardedProps = useForwardProps(delegatedProps)
|
||||
<template>
|
||||
<DropdownMenuLabel
|
||||
v-bind="forwardedProps"
|
||||
:class="cn('px-2 py-1.5 text-sm font-semibold', inset && 'pl-8', props.class)"
|
||||
:class="cn('px-3 py-2 text-sm font-semibold text-zinc-600 dark:text-zinc-400', inset && 'pl-8', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</DropdownMenuLabel>
|
||||
|
||||
142
app/pages/experimenta/index.vue
Normal file
142
app/pages/experimenta/index.vue
Normal file
@@ -0,0 +1,142 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* experimenta Products Listing Page
|
||||
*
|
||||
* Displays experimenta annual passes using ProductCard and ProductGrid components.
|
||||
* Fetches only experimenta category 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 - only experimenta passes
|
||||
const { data: products, error, pending } = await useFetch<Product[]>('/api/products', {
|
||||
query: {
|
||||
category: 'annual-pass',
|
||||
},
|
||||
})
|
||||
|
||||
// Map product categories to badges
|
||||
const getBadge = (category: string): string | undefined => {
|
||||
const badges: Record<string, string> = {
|
||||
'annual-pass': 'Beliebt',
|
||||
}
|
||||
return badges[category]
|
||||
}
|
||||
|
||||
// Map product categories to discount percentages (for demo)
|
||||
const getDiscount = (category: string): number | undefined => {
|
||||
const discounts: Record<string, number> = {
|
||||
// No discounts for now
|
||||
}
|
||||
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">
|
||||
experimenta Jahreskarten
|
||||
</h1>
|
||||
<p class="mx-auto max-w-2xl text-lg text-white/80">
|
||||
Erlebe die Ausstellungswelt der experimenta ein ganzes Jahr lang.
|
||||
Mit freiem Eintritt zu allen Ausstellungen, Science Dome Shows und Sonderausstellungen.
|
||||
</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 experimenta 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 zur Ausstellungswelt</span>
|
||||
</li>
|
||||
<li class="flex items-start gap-2">
|
||||
<span class="mt-1 text-experimenta-accent">✓</span>
|
||||
<span>Freier Eintritt zu allen Science Dome Shows</span>
|
||||
</li>
|
||||
<li class="flex items-start gap-2">
|
||||
<span class="mt-1 text-experimenta-accent">✓</span>
|
||||
<span>Zugang zu wechselnden Sonderausstellungen</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>
|
||||
@@ -25,8 +25,12 @@ interface Product {
|
||||
updatedAt: Date
|
||||
}
|
||||
|
||||
// Fetch products from API
|
||||
const { data: products, error, pending } = await useFetch<Product[]>('/api/products')
|
||||
// Fetch products from API - only Makerspace and Educator passes
|
||||
const { data: products, error, pending } = await useFetch<Product[]>('/api/products', {
|
||||
query: {
|
||||
category: 'makerspace-annual-pass,educator-annual-pass',
|
||||
},
|
||||
})
|
||||
|
||||
// Map product categories to badges
|
||||
const getBadge = (category: string): string | undefined => {
|
||||
@@ -52,11 +56,11 @@ const getDiscount = (category: string): number | undefined => {
|
||||
<!-- 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
|
||||
Makerspace 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.
|
||||
Dein Zugang zum Makerspace. Nutze modernste Werkzeuge, 3D-Drucker, Lasercutter und vieles mehr.
|
||||
Alle Karten sind 365 Tage gültig und sofort einsetzbar.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user