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:
@@ -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>
|
||||
|
||||
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>
|
||||
24
app/components/ui/badge/Badge.vue
Normal file
24
app/components/ui/badge/Badge.vue
Normal 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>
|
||||
24
app/components/ui/badge/index.ts
Normal file
24
app/components/ui/badge/index.ts
Normal 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>
|
||||
32
app/components/ui/separator/Separator.vue
Normal file
32
app/components/ui/separator/Separator.vue
Normal 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>
|
||||
1
app/components/ui/separator/index.ts
Normal file
1
app/components/ui/separator/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Separator } from './Separator.vue'
|
||||
Reference in New Issue
Block a user