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"> |
|||
// Default layout with Header and Footer components |
|||
import AppHeader from '~/components/navigation/AppHeader.vue' |
|||
import BottomNav from '~/components/navigation/BottomNav.vue' |
|||
</script> |
|||
|
|||
<template> |
|||
<div class="min-h-screen flex flex-col"> |
|||
<!-- Header --> |
|||
<CommonHeader /> |
|||
<!-- New App Header with full navigation --> |
|||
<AppHeader /> |
|||
|
|||
<!-- Main content --> |
|||
<main class="flex-1"> |
|||
<!-- Main content with bottom padding for mobile nav --> |
|||
<main class="flex-1 pb-20 md:pb-0"> |
|||
<slot /> |
|||
</main> |
|||
|
|||
<!-- Footer --> |
|||
<CommonFooter /> |
|||
|
|||
<!-- Mobile Bottom Navigation --> |
|||
<BottomNav /> |
|||
</div> |
|||
</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