Browse Source

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.
main
Bastian Masanek 2 months ago
parent
commit
a826ea1344
  1. 18
      app/components/UserMenu.vue
  2. 67
      app/components/navigation/AreaTabs.vue
  3. 4
      app/components/navigation/RoleSwitcher.vue
  4. 2
      app/components/ui/dropdown-menu/DropdownMenuContent.vue
  5. 2
      app/components/ui/dropdown-menu/DropdownMenuItem.vue
  6. 2
      app/components/ui/dropdown-menu/DropdownMenuLabel.vue
  7. 142
      app/pages/experimenta/index.vue
  8. 14
      app/pages/products/index.vue
  9. 20
      server/api/products/index.get.ts

18
app/components/UserMenu.vue

@ -43,12 +43,9 @@ async function handleLogout() {
<template> <template>
<!-- Not logged in: Show login prompt --> <!-- Not logged in: Show login prompt -->
<NuxtLink <NuxtLink v-if="!loggedIn" to="/auth"
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" 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"> <Avatar class="h-10 w-10 border-2 border-muted-foreground/30 rounded-2xl">
<AvatarFallback class="bg-muted text-muted-foreground rounded-2xl"> <AvatarFallback class="bg-muted text-muted-foreground rounded-2xl">
<User class="h-5 w-5" /> <User class="h-5 w-5" />
@ -62,17 +59,18 @@ async function handleLogout() {
<DropdownMenuTrigger as-child> <DropdownMenuTrigger as-child>
<button <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" 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) --> <!-- 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 }} Hallo, {{ user?.firstName }}
</span> </span>
<!-- Avatar with status ring (logged in indicator) --> <!-- 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" /> <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 }} {{ userInitials }}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>

67
app/components/navigation/AreaTabs.vue

@ -8,16 +8,26 @@ interface ProductArea {
label: string label: string
icon: any icon: any
enabled: boolean enabled: boolean
visible: boolean
badge?: string badge?: string
route: string route: string
} }
const areas: ProductArea[] = [ const areas: ProductArea[] = [
{ {
id: 'makerspace', id: 'experimenta-annual-passes',
label: 'Makerspace', label: 'experimenta Jahreskarten',
icon: Sparkles,
enabled: true,
visible: false,
route: '/experimenta',
},
{
id: 'makerspace-annual-passes',
label: 'Makerspace Jahreskarten',
icon: Wrench, icon: Wrench,
enabled: true, enabled: true,
visible: true,
route: '/products', route: '/products',
}, },
{ {
@ -25,17 +35,10 @@ const areas: ProductArea[] = [
label: 'Labore', label: 'Labore',
icon: FlaskConical, icon: FlaskConical,
enabled: false, enabled: false,
visible: false,
badge: 'Demnächst', badge: 'Demnächst',
route: '/labs', route: '/labs',
}, },
{
id: 'experimenta',
label: 'experimenta',
icon: Sparkles,
enabled: false,
badge: 'Demnächst',
route: '/experimenta',
},
] ]
const route = useRoute() const route = useRoute()
@ -64,17 +67,11 @@ function navigateToArea(area: ProductArea) {
<!-- Desktop: Tabs --> <!-- Desktop: Tabs -->
<Tabs :model-value="currentArea" class="hidden md:block"> <Tabs :model-value="currentArea" class="hidden md:block">
<TabsList class="h-auto p-1 bg-muted/50"> <TabsList class="h-auto p-1 bg-muted/50">
<TabsTrigger <TabsTrigger v-for="area in areas.filter(area => area.visible)" :key="area.id" :value="area.id"
v-for="area in areas" :disabled="!area.enabled" :class="[
:key="area.id"
:value="area.id"
:disabled="!area.enabled"
:class="[
'gap-2 data-[state=active]:bg-white dark:data-[state=active]:bg-zinc-900', 'gap-2 data-[state=active]:bg-white dark:data-[state=active]:bg-zinc-900',
!area.enabled && 'opacity-60 cursor-not-allowed', !area.enabled && 'opacity-60 cursor-not-allowed',
]" ]" @click="navigateToArea(area)">
@click="navigateToArea(area)"
>
<component :is="area.icon" class="h-4 w-4" /> <component :is="area.icon" class="h-4 w-4" />
<span>{{ area.label }}</span> <span>{{ area.label }}</span>
<Badge v-if="area.badge" variant="secondary" class="ml-1 text-[10px] px-1.5 py-0"> <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 --> <!-- Mobile: Horizontal scroll with cards -->
<div class="md:hidden overflow-x-auto scrollbar-hide"> <div class="md:hidden overflow-x-auto scrollbar-hide">
<div class="flex gap-2 p-1 min-w-max"> <div class="flex gap-2 p-1 min-w-max">
<button <button v-for="area in areas.filter(area => area.visible)" :key="area.id" :disabled="!area.enabled" :class="[
v-for="area in areas" 'flex items-center gap-2 px-4 py-2 rounded-lg border-2 transition-all whitespace-nowrap',
:key="area.id" currentArea === area.id
:disabled="!area.enabled" ? 'bg-purple-600 text-white border-purple-600'
:class="[ : 'bg-white dark:bg-zinc-900 border-border hover:border-purple-300',
'flex items-center gap-2 px-4 py-2 rounded-lg border-2 transition-all whitespace-nowrap', !area.enabled && 'opacity-60 cursor-not-allowed',
currentArea === area.id ]" @click="navigateToArea(area)">
? 'bg-purple-600 text-white border-purple-600' <component :is="area.icon" :class="['h-4 w-4', currentArea === area.id ? 'text-white' : '']" />
: '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> <span class="font-medium">{{ area.label }}</span>
<Badge <Badge v-if="area.badge" :variant="currentArea === area.id ? 'secondary' : 'outline'"
v-if="area.badge" class="text-[10px] px-1.5 py-0">
:variant="currentArea === area.id ? 'secondary' : 'outline'"
class="text-[10px] px-1.5 py-0"
>
{{ area.badge }} {{ area.badge }}
</Badge> </Badge>
</button> </button>

4
app/components/navigation/RoleSwitcher.vue

@ -107,7 +107,7 @@ function switchRole(role: UserRole) {
:key="role.value" :key="role.value"
:disabled="!role.enabled" :disabled="!role.enabled"
:class="[ :class="[
'gap-3 py-3 cursor-pointer', 'gap-3 py-3 cursor-pointer group',
!role.enabled && 'opacity-60 cursor-not-allowed', !role.enabled && 'opacity-60 cursor-not-allowed',
currentRole === role.value && 'bg-purple-50 dark:bg-purple-950', 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"> <div class="flex-1 flex flex-col gap-1">
<span class="text-sm font-semibold">{{ role.label }}</span> <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> </div>
<Check <Check

2
app/components/ui/dropdown-menu/DropdownMenuContent.vue

@ -27,7 +27,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
<DropdownMenuPortal> <DropdownMenuPortal>
<DropdownMenuContent <DropdownMenuContent
v-bind="forwarded" 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 /> <slot />
</DropdownMenuContent> </DropdownMenuContent>

2
app/components/ui/dropdown-menu/DropdownMenuItem.vue

@ -16,7 +16,7 @@ const forwardedProps = useForwardProps(delegatedProps)
<DropdownMenuItem <DropdownMenuItem
v-bind="forwardedProps" v-bind="forwardedProps"
:class="cn( :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', inset && 'pl-8',
props.class, props.class,
)" )"

2
app/components/ui/dropdown-menu/DropdownMenuLabel.vue

@ -15,7 +15,7 @@ const forwardedProps = useForwardProps(delegatedProps)
<template> <template>
<DropdownMenuLabel <DropdownMenuLabel
v-bind="forwardedProps" 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 /> <slot />
</DropdownMenuLabel> </DropdownMenuLabel>

142
app/pages/experimenta/index.vue

@ -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>

14
app/pages/products/index.vue

@ -25,8 +25,12 @@ interface Product {
updatedAt: Date updatedAt: Date
} }
// Fetch products from API // Fetch products from API - only Makerspace and Educator passes
const { data: products, error, pending } = await useFetch<Product[]>('/api/products') const { data: products, error, pending } = await useFetch<Product[]>('/api/products', {
query: {
category: 'makerspace-annual-pass,educator-annual-pass',
},
})
// Map product categories to badges // Map product categories to badges
const getBadge = (category: string): string | undefined => { const getBadge = (category: string): string | undefined => {
@ -52,11 +56,11 @@ const getDiscount = (category: string): number | undefined => {
<!-- Page Header --> <!-- Page Header -->
<div class="mx-auto mb-12 max-w-container-wide text-center"> <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"> <h1 class="mb-4 text-4xl font-bold text-white md:text-5xl">
Unsere Jahreskarten Makerspace Jahreskarten
</h1> </h1>
<p class="mx-auto max-w-2xl text-lg text-white/80"> <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 Dein Zugang zum Makerspace. Nutze modernste Werkzeuge, 3D-Drucker, Lasercutter und vieles mehr.
und sofort einsetzbar. Alle Karten sind 365 Tage gültig und sofort einsetzbar.
</p> </p>
</div> </div>

20
server/api/products/index.get.ts

@ -3,18 +3,32 @@
* *
* Returns a list of all active products available for purchase. * Returns a list of all active products available for purchase.
* Products are sorted by category and name. * Products are sorted by category and name.
*
* Query Parameters:
* - category: Filter by category (optional, comma-separated for multiple)
*/ */
import { eq } from 'drizzle-orm' import { eq, and, inArray } from 'drizzle-orm'
import { products } from '../../database/schema' import { products } from '../../database/schema'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const db = useDatabase() const db = useDatabase()
const query = getQuery(event)
const categoryParam = query.category as string | undefined
try { try {
// Fetch all active products // Build where conditions
const conditions = [eq(products.active, true)]
// Filter by category if provided
if (categoryParam) {
const categories = categoryParam.split(',').map((c) => c.trim())
conditions.push(inArray(products.category, categories))
}
// Fetch products with filters
const allProducts = await db.query.products.findMany({ const allProducts = await db.query.products.findMany({
where: eq(products.active, true), where: and(...conditions),
orderBy: (products, { asc }) => [asc(products.category), asc(products.name)], orderBy: (products, { asc }) => [asc(products.category), asc(products.name)],
}) })

Loading…
Cancel
Save