Browse Source
- 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
17 changed files with 982 additions and 40 deletions
@ -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> |
||||
@ -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> |
||||
@ -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> |
||||
@ -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> |
||||
@ -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> |
||||
@ -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> |
||||
@ -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> |
||||
@ -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> |
||||
@ -0,0 +1 @@ |
|||||
|
export { default as Separator } from './Separator.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> |
||||
|
|||||
@ -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> |
||||
Loading…
Reference in new issue