Browse Source

Enhance navigation and UI components for improved user experience

- Added new AppHeader and BottomNav components for better navigation across the application.
- Introduced AreaTabs for product area navigation and integrated RoleSwitcher for user role management.
- Created CartButton component to display cart status and item count.
- Implemented UserMenu with login/logout functionality and user greeting.
- Added Badge component for notifications and status indicators.
- Updated layout to accommodate new navigation components and ensure mobile responsiveness.
- Created product detail demo page to showcase design patterns and features.
- Enhanced existing components with improved styling and functionality.
main
Bastian Masanek 2 months ago
parent
commit
81495d5e17
  1. 5
      .claude/settings.local.json
  2. 20
      app/app.vue
  3. 64
      app/components/UserMenu.vue
  4. 96
      app/components/navigation/AppHeader.vue
  5. 131
      app/components/navigation/AreaTabs.vue
  6. 147
      app/components/navigation/BottomNav.vue
  7. 43
      app/components/navigation/CartButton.vue
  8. 142
      app/components/navigation/RoleSwitcher.vue
  9. 24
      app/components/ui/badge/Badge.vue
  10. 24
      app/components/ui/badge/index.ts
  11. 32
      app/components/ui/separator/Separator.vue
  12. 1
      app/components/ui/separator/index.ts
  13. 23
      app/layouts/default.vue
  14. 195
      app/pages/internal/product-detail-demo.vue
  15. 57
      app/pages/internal/styleguide.vue
  16. 6
      app/pages/products/[id].vue
  17. 12
      tailwind.config.ts

5
.claude/settings.local.json

@ -62,7 +62,10 @@
"Bash(node check-user.mjs:*)", "Bash(node check-user.mjs:*)",
"Bash(xargs kill:*)", "Bash(xargs kill:*)",
"mcp__playwright__browser_resize", "mcp__playwright__browser_resize",
"Bash(pnpm db:seed:*)" "Bash(pnpm db:seed:*)",
"Bash(pnpm exec nuxi@latest module add shadcn-nuxt:*)",
"Bash(pnpm exec shadcn-nuxt@latest add:*)",
"Bash(pnpm exec shadcn-nuxt:*)"
], ],
"deny": [], "deny": [],
"ask": [] "ask": []

20
app/app.vue

@ -1,19 +1,17 @@
<script setup lang="ts"> <script setup lang="ts">
// scroll-body="false": Prevents body scrolling when modals/dialogs are open.
// When set to false, the body element's overflow is locked while any modal component
// (like Dialog, Sheet, etc.) is open, preventing background content from scrolling.
// This improves UX by keeping focus on the modal content.
//
// Bug fix: Without this setting, opening dropdown menus (e.g., the user profile menu
// when clicking the avatar icon) would cause the body to receive a padding-right of
// 15px, which shifted the entire page content 15px to the left. This prop prevents
// that unwanted padding injection and layout shift.
import { ConfigProvider } from 'reka-ui' import { ConfigProvider } from 'reka-ui'
</script> </script>
<template> <template>
<!--
scroll-body="false": Prevents body scrolling when modals/dialogs are open.
When set to false, the body element's overflow is locked while any modal component
(like Dialog, Sheet, etc.) is open, preventing background content from scrolling.
This improves UX by keeping focus on the modal content.
Bug fix: Without this setting, opening dropdown menus (e.g., the user profile menu
when clicking the avatar icon) would cause the body to receive a padding-right of
15px, which shifted the entire page content 15px to the left. This prop prevents
that unwanted padding injection and layout shift.
-->
<ConfigProvider :scroll-body="false"> <ConfigProvider :scroll-body="false">
<div> <div>
<NuxtPage /> <NuxtPage />

64
app/components/UserMenu.vue

@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { User, LogOut } from 'lucide-vue-next' import { User, LogOut, Package } from 'lucide-vue-next'
import { import {
Avatar, Avatar,
AvatarFallback, AvatarFallback,
@ -14,7 +14,7 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from './ui/dropdown-menu' } from './ui/dropdown-menu'
const { user, logout } = useAuth() const { user, loggedIn, logout } = useAuth()
/** /**
* Get user initials for Avatar fallback * Get user initials for Avatar fallback
@ -42,29 +42,51 @@ async function handleLogout() {
</script> </script>
<template> <template>
<DropdownMenu> <!-- Not logged in: Show login prompt -->
<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"
>
<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" />
</AvatarFallback>
</Avatar>
<span class="text-sm font-medium hidden sm:inline">Anmelden</span>
</NuxtLink>
<!-- Logged in: Show user menu -->
<DropdownMenu v-else>
<DropdownMenuTrigger as-child> <DropdownMenuTrigger as-child>
<button <button
class="flex items-center gap-2 rounded-full focus:outline-none focus:ring-2 focus:ring-experimenta-accent focus:ring-offset-2 focus:ring-offset-experimenta-purple transition-all hover:scale-105 hover:shadow-lg" 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"
> >
<Avatar class="h-12 w-12 border-3 border-experimenta-accent shadow-md bg-experimenta-accent"> <!-- Greeting text (Desktop only) -->
<span class="hidden md:inline text-sm 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">
<AvatarImage :src="undefined" :alt="user?.firstName" /> <AvatarImage :src="undefined" :alt="user?.firstName" />
<AvatarFallback class="bg-experimenta-accent text-white font-bold 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>
</button> </button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" class="w-56 z-[200]"> <DropdownMenuContent align="end" class="w-64 z-[200]">
<!-- User email (non-interactive) --> <!-- User info header -->
<DropdownMenuLabel class="font-normal"> <DropdownMenuLabel class="font-normal py-3">
<div class="flex flex-col space-y-1"> <div class="flex flex-col space-y-1.5">
<p class="text-sm font-medium leading-none"> <p class="text-base font-semibold leading-none">
{{ user?.firstName }} {{ user?.lastName }} {{ user?.firstName }} {{ user?.lastName }}
</p> </p>
<p class="text-xs leading-none text-muted-foreground"> <p class="text-sm leading-none text-muted-foreground">
{{ user?.email }} {{ user?.email }}
</p> </p>
</div> </div>
@ -72,18 +94,24 @@ async function handleLogout() {
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<!-- My Orders (Phase 5+ feature) -->
<DropdownMenuItem disabled class="py-2.5">
<Package class="mr-3 h-5 w-5" />
<span class="text-sm">Meine Bestellungen</span>
</DropdownMenuItem>
<!-- Profile (disabled for now, placeholder for Phase 2+) --> <!-- Profile (disabled for now, placeholder for Phase 2+) -->
<DropdownMenuItem disabled> <DropdownMenuItem disabled class="py-2.5">
<User class="mr-2 h-4 w-4" /> <User class="mr-3 h-5 w-5" />
<span>Profil</span> <span class="text-sm">Profil bearbeiten</span>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<!-- Logout --> <!-- Logout -->
<DropdownMenuItem @click="handleLogout"> <DropdownMenuItem @click="handleLogout" class="py-2.5">
<LogOut class="mr-2 h-4 w-4" /> <LogOut class="mr-3 h-5 w-5" />
<span>Abmelden</span> <span class="text-sm">Abmelden</span>
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>

96
app/components/navigation/AppHeader.vue

@ -0,0 +1,96 @@
<script setup lang="ts">
import RoleSwitcher from './RoleSwitcher.vue'
import AreaTabs from './AreaTabs.vue'
import CartButton from './CartButton.vue'
import UserMenu from '../UserMenu.vue'
const { loggedIn } = useAuth()
</script>
<template>
<header class="sticky top-0 z-50 w-full">
<!-- Main header bar -->
<div class="container flex h-24 items-center justify-between px-4 md:px-6 lg:px-8">
<!-- Left: Logo + Role Switcher -->
<div class="flex items-center gap-6 md:gap-8">
<NuxtLink to="/" class="flex-shrink-0 transition-transform hover:scale-105">
<img
src="/img/experimenta-logo-white.svg"
alt="experimenta Logo"
class="h-14 w-auto md:h-16 drop-shadow-lg"
/>
</NuxtLink>
<!-- Role Switcher (Desktop only) -->
<div class="hidden lg:block">
<RoleSwitcher />
</div>
</div>
<!-- Right: Cart + User Menu -->
<div class="flex items-center gap-3 md:gap-6">
<!-- Cart Button (only visible when logged in) -->
<CartButton v-if="loggedIn" />
<!-- User Menu -->
<UserMenu />
</div>
</div>
<!-- Secondary navigation bar (Product areas) - No borders, gradient separation -->
<div class="area-tabs-section">
<div class="container px-4 md:px-6 lg:px-8 py-3">
<AreaTabs />
</div>
</div>
<!-- Mobile: Role switcher in collapsible section (optional) -->
<div class="lg:hidden mobile-role-section">
<div class="container px-4 py-2.5">
<RoleSwitcher />
</div>
</div>
</header>
</template>
<style scoped>
/* Custom experimenta styling */
header {
background: linear-gradient(
135deg,
rgba(46, 16, 101, 0.98) 0%,
rgba(46, 16, 101, 0.95) 100%
);
color: white;
}
/* Area tabs section with subtle gradient separator (no borders) */
.area-tabs-section {
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.08) 0%,
rgba(255, 255, 255, 0.03) 100%
);
}
/* Mobile role section */
.mobile-role-section {
background: rgba(255, 255, 255, 0.05);
}
/* Logo styling */
img[alt='experimenta Logo'] {
filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.3));
}
/* Ensure text is white in header */
:deep(button),
:deep(a) {
color: white;
}
:deep(.text-foreground),
:deep(.text-muted-foreground) {
color: rgba(255, 255, 255, 0.9) !important;
}
</style>

131
app/components/navigation/AreaTabs.vue

@ -0,0 +1,131 @@
<script setup lang="ts">
import { Wrench, FlaskConical, Ticket, Sparkles } from 'lucide-vue-next'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Badge } from '@/components/ui/badge'
interface ProductArea {
id: string
label: string
icon: any
enabled: boolean
badge?: string
route: string
}
const areas: ProductArea[] = [
{
id: 'makerspace',
label: 'Makerspace',
icon: Wrench,
enabled: true,
route: '/products',
},
{
id: 'labs',
label: 'Labore',
icon: FlaskConical,
enabled: false,
badge: 'Demnächst',
route: '/labs',
},
{
id: 'experimenta',
label: 'experimenta',
icon: Sparkles,
enabled: false,
badge: 'Demnächst',
route: '/experimenta',
},
]
const route = useRoute()
const currentArea = computed(() => {
// Determine current area based on route
if (route.path.startsWith('/products') || route.path === '/') {
return 'makerspace'
} else if (route.path.startsWith('/labs')) {
return 'labs'
} else if (route.path.startsWith('/experimenta')) {
return 'experimenta'
}
return 'makerspace'
})
function navigateToArea(area: ProductArea) {
if (area.enabled) {
navigateTo(area.route)
}
}
</script>
<template>
<div class="w-full">
<!-- 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="[
'gap-2 data-[state=active]:bg-white dark:data-[state=active]:bg-zinc-900',
!area.enabled && 'opacity-60 cursor-not-allowed',
]"
@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">
{{ area.badge }}
</Badge>
</TabsTrigger>
</TabsList>
</Tabs>
<!-- 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' : '']"
/>
<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"
>
{{ area.badge }}
</Badge>
</button>
</div>
</div>
</div>
</template>
<style scoped>
/* Hide scrollbar but keep functionality */
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
</style>

147
app/components/navigation/BottomNav.vue

@ -0,0 +1,147 @@
<script setup lang="ts">
import { Home, Wrench, ShoppingCart, User } from 'lucide-vue-next'
import { Badge } from '@/components/ui/badge'
const route = useRoute()
const { loggedIn } = useAuth()
interface NavItem {
id: string
label: string
icon: any
route: string
badge?: number
requireAuth?: boolean
}
const navItems: NavItem[] = [
{
id: 'home',
label: 'Start',
icon: Home,
route: '/',
},
{
id: 'products',
label: 'Makerspace',
icon: Wrench,
route: '/products',
},
{
id: 'cart',
label: 'Warenkorb',
icon: ShoppingCart,
route: '/cart',
badge: 0, // TODO: Get from cart store
requireAuth: true,
},
{
id: 'profile',
label: 'Profil',
icon: User,
route: '/auth',
},
]
const isActive = (itemRoute: string) => {
if (itemRoute === '/') {
return route.path === '/'
}
return route.path.startsWith(itemRoute)
}
function handleNavClick(item: NavItem) {
if (item.requireAuth && !loggedIn.value) {
// Redirect to auth page
navigateTo('/auth')
return
}
navigateTo(item.route)
}
</script>
<template>
<!-- Mobile Bottom Navigation - only visible on small screens -->
<nav
class="fixed bottom-0 left-0 right-0 z-50 md:hidden border-t bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80 safe-area-inset-bottom"
role="navigation"
aria-label="Mobile Navigation"
>
<div class="flex items-center justify-around h-16 px-2">
<button
v-for="item in navItems"
:key="item.id"
:class="[
'flex flex-col items-center justify-center flex-1 gap-1 py-2 px-1 rounded-lg transition-all relative',
isActive(item.route)
? 'text-experimenta-accent bg-experimenta-accent/10'
: 'text-muted-foreground hover:text-foreground hover:bg-muted',
item.requireAuth && !loggedIn && 'opacity-75',
]"
@click="handleNavClick(item)"
:aria-label="item.label"
:aria-current="isActive(item.route) ? 'page' : undefined"
>
<!-- Icon with badge -->
<div class="relative">
<component
:is="item.icon"
:class="[
'h-5 w-5 transition-transform',
isActive(item.route) && 'scale-110',
]"
/>
<!-- Badge for cart -->
<Badge
v-if="item.badge && item.badge > 0"
class="absolute -top-2 -right-2 h-4 min-w-[16px] px-1 flex items-center justify-center text-[10px] bg-experimenta-accent"
>
{{ item.badge > 99 ? '99+' : item.badge }}
</Badge>
<!-- Login indicator dot for profile when not logged in -->
<span
v-if="item.id === 'profile' && !loggedIn"
class="absolute -top-1 -right-1 h-2 w-2 rounded-full bg-yellow-500 animate-pulse"
aria-label="Nicht angemeldet"
/>
</div>
<!-- Label -->
<span
:class="[
'text-[10px] font-medium transition-all',
isActive(item.route) && 'font-bold',
]"
>
{{ item.label }}
</span>
<!-- Active indicator bar -->
<span
v-if="isActive(item.route)"
class="absolute bottom-0 left-1/2 -translate-x-1/2 w-12 h-0.5 bg-experimenta-accent rounded-full"
aria-hidden="true"
/>
</button>
</div>
<!-- Safe area spacer for devices with notches/home indicators -->
<div class="h-[env(safe-area-inset-bottom)] bg-background" />
</nav>
</template>
<style scoped>
/* Support for safe area insets (iPhone X+) */
.safe-area-inset-bottom {
padding-bottom: env(safe-area-inset-bottom);
}
/* Ensure bottom nav doesn't overlay content */
@media (max-width: 768px) {
/* Add bottom padding to body/main to account for fixed bottom nav */
/* This will be handled in the layout component */
}
</style>

43
app/components/navigation/CartButton.vue

@ -0,0 +1,43 @@
<script setup lang="ts">
import { ShoppingCart } from 'lucide-vue-next'
import { Badge } from '@/components/ui/badge'
// TODO: This will come from cart store/composable in Phase 5
const cartItemCount = ref(0)
const hasItems = computed(() => cartItemCount.value > 0)
</script>
<template>
<NuxtLink
to="/cart"
class="relative inline-flex items-center justify-center rounded-full p-3 transition-all hover:bg-experimenta-accent/10 focus:outline-none focus:ring-2 focus:ring-experimenta-accent focus:ring-offset-2"
aria-label="Warenkorb"
>
<ShoppingCart class="h-6 w-6 text-foreground" />
<!-- Item count badge -->
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="scale-0 opacity-0"
enter-to-class="scale-100 opacity-100"
leave-active-class="transition-all duration-150 ease-in"
leave-from-class="scale-100 opacity-100"
leave-to-class="scale-0 opacity-0"
>
<Badge
v-if="hasItems"
class="absolute -top-1 -right-1 h-5 min-w-[20px] px-1 flex items-center justify-center bg-experimenta-accent text-white text-xs font-bold border-2 border-white dark:border-zinc-950"
>
{{ cartItemCount > 99 ? '99+' : cartItemCount }}
</Badge>
</Transition>
<!-- Pulse animation when items are added (optional) -->
<span
v-if="hasItems"
class="absolute inset-0 rounded-full bg-experimenta-accent/20 animate-ping opacity-75"
aria-hidden="true"
/>
</NuxtLink>
</template>

142
app/components/navigation/RoleSwitcher.vue

@ -0,0 +1,142 @@
<script setup lang="ts">
import { User, GraduationCap, Building2, Check } from 'lucide-vue-next'
import { Badge } from '@/components/ui/badge'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Button } from '@/components/ui/button'
// User role types matching our database schema
type UserRole = 'individual' | 'educator' | 'company'
interface RoleOption {
value: UserRole
label: string
description: string
icon: any
color: string
enabled: boolean
badge?: string
}
const roles: RoleOption[] = [
{
value: 'individual',
label: 'Privatperson',
description: 'für mich',
icon: User,
color: 'text-purple-600',
enabled: true,
},
{
value: 'educator',
label: 'Pädagoge',
description: 'für Schule/Kita',
icon: GraduationCap,
color: 'text-orange-500',
enabled: false,
badge: 'Demnächst',
},
{
value: 'company',
label: 'Firma',
description: 'für Unternehmen',
icon: Building2,
color: 'text-blue-600',
enabled: false,
badge: 'Demnächst',
},
]
// Current role - will come from user session later
const currentRole = ref<UserRole>('individual')
const currentRoleData = computed(() => {
return roles.find((r) => r.value === currentRole.value) || roles[0]
})
function switchRole(role: UserRole) {
if (roles.find((r) => r.value === role)?.enabled) {
currentRole.value = role
// TODO: Update user session/store with new role
}
}
</script>
<template>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button
variant="outline"
class="gap-2 border-2 border-white/30 bg-white/5 text-white hover:bg-white/10 hover:border-white/50 transition-all duration-200 group"
title="Rolle wechseln"
>
<!-- Context label + role -->
<span class="text-xs text-white/70 font-normal hidden md:inline">Du kaufst als:</span>
<component :is="currentRoleData.icon" class="h-4 w-4 text-white" />
<span class="font-medium text-white">{{ currentRoleData.label }}</span>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="ml-1 opacity-70 text-white group-hover:opacity-100 transition-opacity"
>
<polyline points="6 9 12 15 18 9" />
</svg>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" class="w-64">
<DropdownMenuLabel class="text-sm font-normal text-muted-foreground py-3">
Für wen kaufst du?
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem
v-for="role in roles"
:key="role.value"
:disabled="!role.enabled"
:class="[
'gap-3 py-3 cursor-pointer',
!role.enabled && 'opacity-60 cursor-not-allowed',
currentRole === role.value && 'bg-purple-50 dark:bg-purple-950',
]"
@click="switchRole(role.value)"
>
<component :is="role.icon" :class="['h-5 w-5', role.color]" />
<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>
</div>
<Check
v-if="currentRole === role.value"
class="h-5 w-5 text-green-600 flex-shrink-0"
/>
<Badge v-if="role.badge" variant="secondary" class="text-xs px-2 py-0.5 flex-shrink-0">
{{ role.badge }}
</Badge>
</DropdownMenuItem>
<DropdownMenuSeparator />
<div class="px-3 py-2.5 text-sm text-muted-foreground">
<p class="leading-relaxed">
💡 Weitere Rollen werden in Kürze verfügbar sein.
</p>
</div>
</DropdownMenuContent>
</DropdownMenu>
</template>

24
app/components/ui/badge/Badge.vue

@ -0,0 +1,24 @@
<script setup lang="ts">
import { type HTMLAttributes, computed } from 'vue'
import { type BadgeVariants, badgeVariants } from '.'
import { cn } from '@/lib/utils'
interface Props {
variant?: BadgeVariants['variant']
class?: HTMLAttributes['class']
}
const props = defineProps<Props>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
</script>
<template>
<div :class="cn(badgeVariants({ variant }), props.class)" v-bind="delegatedProps">
<slot />
</div>
</template>

24
app/components/ui/badge/index.ts

@ -0,0 +1,24 @@
import { type VariantProps, cva } from 'class-variance-authority'
export { default as Badge } from './Badge.vue'
export const badgeVariants = cva(
'inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
{
variants: {
variant: {
default: 'border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80',
secondary: 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
destructive: 'border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80',
outline: 'text-foreground',
success: 'border-transparent bg-green-500 text-white shadow hover:bg-green-600',
warning: 'border-transparent bg-yellow-500 text-white shadow hover:bg-yellow-600',
},
},
defaultVariants: {
variant: 'default',
},
}
)
export type BadgeVariants = VariantProps<typeof badgeVariants>

32
app/components/ui/separator/Separator.vue

@ -0,0 +1,32 @@
<script setup lang="ts">
import { type HTMLAttributes, computed } from 'vue'
import { SeparatorRoot, type SeparatorRootProps } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = withDefaults(
defineProps<SeparatorRootProps & { class?: HTMLAttributes['class'] }>(),
{
orientation: 'horizontal',
decorative: true,
}
)
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
</script>
<template>
<SeparatorRoot
v-bind="delegatedProps"
:class="
cn(
'shrink-0 bg-border',
props.orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',
props.class
)
"
/>
</template>

1
app/components/ui/separator/index.ts

@ -0,0 +1 @@
export { default as Separator } from './Separator.vue'

23
app/layouts/default.vue

@ -1,18 +1,31 @@
<script setup lang="ts"> <script setup lang="ts">
// Default layout with Header and Footer components import AppHeader from '~/components/navigation/AppHeader.vue'
import BottomNav from '~/components/navigation/BottomNav.vue'
</script> </script>
<template> <template>
<div class="min-h-screen flex flex-col"> <div class="min-h-screen flex flex-col">
<!-- Header --> <!-- New App Header with full navigation -->
<CommonHeader /> <AppHeader />
<!-- Main content --> <!-- Main content with bottom padding for mobile nav -->
<main class="flex-1"> <main class="flex-1 pb-20 md:pb-0">
<slot /> <slot />
</main> </main>
<!-- Footer --> <!-- Footer -->
<CommonFooter /> <CommonFooter />
<!-- Mobile Bottom Navigation -->
<BottomNav />
</div> </div>
</template> </template>
<style scoped>
/* Ensure main content doesn't get hidden behind bottom nav on mobile */
@media (max-width: 768px) {
main {
padding-bottom: calc(4rem + env(safe-area-inset-bottom, 0px));
}
}
</style>

195
app/pages/internal/product-detail-demo.vue

@ -0,0 +1,195 @@
<script setup lang="ts">
// Demo page to showcase product detail page design
definePageMeta({
layout: 'styleguide',
})
// Sample product data
const product = {
id: 'makerspace-jk-2025',
image: '/img/makerspace-jk-2025.jpg',
name: 'Makerspace Jahreskarte',
description:
'Unbegrenzter Zugang zum Makerspace für 365 Tage. Nutze modernste Werkzeuge, 3D-Drucker, Lasercutter und vieles mehr. Perfekt für Maker, Tüftler und kreative Köpfe.',
price: 120.0,
stockQuantity: 100,
}
// Format price in EUR
const formattedPrice = computed(() => {
return new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
}).format(product.price)
})
// Handle "Add to Cart" action (placeholder)
const handleAddToCart = () => {
alert('Add to Cart Demo - Diese Funktion wird in einer späteren Phase implementiert.')
}
</script>
<template>
<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">
Produktdetailseite Demo
</h1>
<p class="mx-auto max-w-2xl text-lg text-white/80">
Mobile-optimierte Produktdetailseite für Jahreskarten mit Glassmorphism-Design.
</p>
</div>
<!-- Product Detail Example -->
<div 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 (flush with top) -->
<div class="relative aspect-[16/9] w-full overflow-hidden bg-purple-dark">
<img
:src="product.image"
: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 -->
<h2 class="text-3xl font-bold text-white md:text-4xl">
{{ product.name }}
</h2>
<!-- 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 text-green">
<CheckCircle :size="24" />
<span>Sofort</span>
</div>
</div>
</div>
<!-- Features / Benefits -->
<div class="rounded-xl border border-white/20 bg-white/5 p-6 backdrop-blur-sm">
<h3 class="mb-4 text-xl font-semibold text-white">
Was du mit dieser Karte bekommst:
</h3>
<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"
@click="handleAddToCart"
>
In den Warenkorb
</Button>
<NuxtLink to="/internal/products-demo" class="btn-secondary flex-1 text-center">
Weitere Produkte ansehen
</NuxtLink>
</div>
</div>
</div>
</div>
<!-- Design Features -->
<div class="mx-auto mt-20 max-w-container-wide">
<div class="rounded-2xl border border-white/20 bg-white/10 p-8 backdrop-blur-lg">
<h2 class="mb-6 text-2xl font-bold text-white">
Design Features
</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><strong>Hero Image:</strong> 16:9 Aspect Ratio, bündig mit oberer Kartenrundung</span>
</li>
<li class="flex items-start gap-2">
<span class="mt-1 text-experimenta-accent"></span>
<span><strong>Glassmorphism Card:</strong> Backdrop blur mit experimenta Farbpalette</span>
</li>
<li class="flex items-start gap-2">
<span class="mt-1 text-experimenta-accent"></span>
<span><strong>Info Cards:</strong> Preis und Verfügbarkeit in separaten Cards mit Icons</span>
</li>
<li class="flex items-start gap-2">
<span class="mt-1 text-experimenta-accent"></span>
<span><strong>Feature List:</strong> Mit Häkchen-Icons in experimenta-accent Farbe</span>
</li>
<li class="flex items-start gap-2">
<span class="mt-1 text-experimenta-accent"></span>
<span><strong>Button Combo:</strong> Primary (experimenta) + Secondary (transparent border)</span>
</li>
<li class="flex items-start gap-2">
<span class="mt-1 text-experimenta-accent"></span>
<span><strong>Mobile-First:</strong> Responsive Grid (2 Spalten 1 Spalte auf Mobile)</span>
</li>
<li class="flex items-start gap-2">
<span class="mt-1 text-experimenta-accent"></span>
<span><strong>Status-Anzeige:</strong> "Verfügbarkeit: Sofort" mit CheckCircle Icon (statt "Auf Lager")</span>
</li>
</ul>
</div>
</div>
<!-- Usage Example -->
<div class="mx-auto mt-12 max-w-container-wide">
<div class="rounded-2xl border border-white/20 bg-purple-darker/50 p-8 backdrop-blur-lg">
<h2 class="mb-4 text-2xl font-bold text-white">
Implementation
</h2>
<p class="mb-4 text-white/90">
Die Produktdetailseite ist unter <code class="rounded bg-white/10 px-2 py-1">/products/[id]</code> verfügbar.
Produktdaten werden aus der PostgreSQL-Datenbank über die API geladen.
</p>
<pre class="overflow-x-auto rounded-lg bg-purple-darkest p-4 text-sm text-white/90"><code>// API Endpoints
GET /api/products - List all products
GET /api/products/[id] - Get single product
// Page Routes
/products - Product listing (ProductGrid)
/products/[id] - Product detail page</code></pre>
</div>
</div>
</div>
</template>

57
app/pages/internal/styleguide.vue

@ -56,6 +56,7 @@ const copyCode = async (code: string) => {
<li><a href="#progress" class="link-primary">Progress Bars</a></li> <li><a href="#progress" class="link-primary">Progress Bars</a></li>
<li><a href="#components" class="link-primary">Components</a></li> <li><a href="#components" class="link-primary">Components</a></li>
<li><a href="/internal/products-demo" class="link-accent"> Product Cards Demo</a></li> <li><a href="/internal/products-demo" class="link-accent"> Product Cards Demo</a></li>
<li><a href="/internal/product-detail-demo" class="link-accent"> Product Detail Demo</a></li>
</ul> </ul>
</nav> </nav>
@ -243,6 +244,46 @@ const copyCode = async (code: string) => {
</details> </details>
</div> </div>
<!-- Secondary Button (btn-secondary) -->
<div class="card-glass mb-8">
<h3 class="text-2xl font-semibold mb-4 text-white">Secondary Button (.btn-secondary)</h3>
<p class="mb-6 text-white/90">
Transparent button with white border. Perfect for secondary actions alongside primary buttons.
Features a smooth hover effect that fills with white background.
</p>
<div class="flex flex-wrap gap-4 items-center mb-6">
<a href="#" class="btn-secondary" @click.prevent="handleClick">
Secondary Action
</a>
<NuxtLink to="#" class="btn-secondary">
As NuxtLink
</NuxtLink>
</div>
<div class="bg-white/5 p-4 rounded-lg mb-4">
<p class="text-sm text-white/70 mb-2"><strong>Usage Example:</strong> Product detail pages</p>
<p class="text-sm text-white/70">
Use alongside primary "In den Warenkorb" button for actions like "Weitere Produkte ansehen".
The transparent background integrates beautifully with glassmorphism design.
</p>
</div>
<details class="bg-white/5 p-4 rounded-lg">
<summary class="cursor-pointer text-white/90 font-semibold">Show Code</summary>
<pre class="mt-4 text-sm text-white/80 overflow-x-auto"><code>&lt;!-- As Link --&gt;
&lt;a href="#" class="btn-secondary"&gt;
Secondary Action
&lt;/a&gt;
&lt;!-- As NuxtLink --&gt;
&lt;NuxtLink to="/products" class="btn-secondary"&gt;
Weitere Produkte ansehen
&lt;/NuxtLink&gt;</code></pre>
</details>
</div>
<!-- shadcn-nuxt Button Variants --> <!-- shadcn-nuxt Button Variants -->
<div class="card-glass"> <div class="card-glass">
<h3 class="text-2xl font-semibold mb-4 text-white">shadcn-nuxt Button Variants</h3> <h3 class="text-2xl font-semibold mb-4 text-white">shadcn-nuxt Button Variants</h3>
@ -672,7 +713,7 @@ const copyCode = async (code: string) => {
</div> </div>
<!-- Product Cards Demo Link --> <!-- Product Cards Demo Link -->
<div class="card-glass"> <div class="card-glass mb-8">
<h3 class="text-2xl font-semibold mb-4 text-white">Product Cards</h3> <h3 class="text-2xl font-semibold mb-4 text-white">Product Cards</h3>
<p class="text-white/90 mb-6"> <p class="text-white/90 mb-6">
Speziell gestaltete Produktkarten für Jahreskarten und andere Produkte der experimenta. Speziell gestaltete Produktkarten für Jahreskarten und andere Produkte der experimenta.
@ -684,6 +725,20 @@ const copyCode = async (code: string) => {
</NuxtLink> </NuxtLink>
</div> </div>
</div> </div>
<!-- Product Detail Demo Link -->
<div class="card-glass">
<h3 class="text-2xl font-semibold mb-4 text-white">Product Detail Page</h3>
<p class="text-white/90 mb-6">
Vollständige Produktdetailseite für Jahreskarten mit Hero-Image, Preis- und Verfügbarkeitsanzeige,
Feature-Liste und Call-to-Action Buttons. Zeigt alle Design-Patterns für Produktseiten.
</p>
<div class="flex gap-4">
<NuxtLink to="/internal/product-detail-demo" class="btn-experimenta">
Zur Product Detail Demo
</NuxtLink>
</div>
</div>
</section> </section>
<!-- Footer --> <!-- Footer -->

6
app/pages/products/[id].vue

@ -181,10 +181,8 @@ const handleAddToCart = () => {
> >
{{ product.stockQuantity > 0 ? 'In den Warenkorb' : 'Nicht verfügbar' }} {{ product.stockQuantity > 0 ? 'In den Warenkorb' : 'Nicht verfügbar' }}
</Button> </Button>
<NuxtLink to="/products" class="flex-1"> <NuxtLink to="/products" class="btn-secondary flex-1 text-center">
<Button variant="outline" size="experimenta" class="w-full"> Weitere Produkte ansehen
Weitere Produkte ansehen
</Button>
</NuxtLink> </NuxtLink>
</div> </div>
</div> </div>

12
tailwind.config.ts

@ -65,7 +65,19 @@ export default {
red: '#E40521', red: '#E40521',
// Purple Variants (Background) // Purple Variants (Background)
'experimenta-purple': '#2e1065',
purple: { purple: {
50: '#f5f3ff',
100: '#ede9fe',
200: '#ddd6fe',
300: '#c4b5fd',
400: '#a78bfa',
500: '#8b5cf6',
600: '#7c3aed',
700: '#6d28d9',
800: '#5b21b6',
900: '#4c1d95',
950: '#2e1065',
dark: '#2e1065', dark: '#2e1065',
deeper: '#1a0a3a', deeper: '#1a0a3a',
darkest: '#0f051d', darkest: '#0f051d',

Loading…
Cancel
Save