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.
This commit is contained in:
Bastian Masanek
2025-11-01 19:51:02 +01:00
parent 7ab80a6635
commit 81495d5e17
17 changed files with 982 additions and 40 deletions

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { User, LogOut } from 'lucide-vue-next'
import { User, LogOut, Package } from 'lucide-vue-next'
import {
Avatar,
AvatarFallback,
@@ -14,7 +14,7 @@ import {
DropdownMenuTrigger,
} from './ui/dropdown-menu'
const { user, logout } = useAuth()
const { user, loggedIn, logout } = useAuth()
/**
* Get user initials for Avatar fallback
@@ -42,29 +42,51 @@ async function handleLogout() {
</script>
<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>
<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"
>
<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" />
<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 }}
</AvatarFallback>
</Avatar>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="w-56 z-[200]">
<!-- User email (non-interactive) -->
<DropdownMenuLabel class="font-normal">
<div class="flex flex-col space-y-1">
<p class="text-sm font-medium leading-none">
<DropdownMenuContent align="end" class="w-64 z-[200]">
<!-- User info header -->
<DropdownMenuLabel class="font-normal py-3">
<div class="flex flex-col space-y-1.5">
<p class="text-base font-semibold leading-none">
{{ user?.firstName }} {{ user?.lastName }}
</p>
<p class="text-xs leading-none text-muted-foreground">
<p class="text-sm leading-none text-muted-foreground">
{{ user?.email }}
</p>
</div>
@@ -72,18 +94,24 @@ async function handleLogout() {
<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+) -->
<DropdownMenuItem disabled>
<User class="mr-2 h-4 w-4" />
<span>Profil</span>
<DropdownMenuItem disabled class="py-2.5">
<User class="mr-3 h-5 w-5" />
<span class="text-sm">Profil bearbeiten</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<!-- Logout -->
<DropdownMenuItem @click="handleLogout">
<LogOut class="mr-2 h-4 w-4" />
<span>Abmelden</span>
<DropdownMenuItem @click="handleLogout" class="py-2.5">
<LogOut class="mr-3 h-5 w-5" />
<span class="text-sm">Abmelden</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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