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:
96
app/components/navigation/AppHeader.vue
Normal file
96
app/components/navigation/AppHeader.vue
Normal 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>
|
||||
131
app/components/navigation/AreaTabs.vue
Normal file
131
app/components/navigation/AreaTabs.vue
Normal 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>
|
||||
147
app/components/navigation/BottomNav.vue
Normal file
147
app/components/navigation/BottomNav.vue
Normal 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>
|
||||
43
app/components/navigation/CartButton.vue
Normal file
43
app/components/navigation/CartButton.vue
Normal 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>
|
||||
142
app/components/navigation/RoleSwitcher.vue
Normal file
142
app/components/navigation/RoleSwitcher.vue
Normal 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>
|
||||
Reference in New Issue
Block a user