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:
@@ -62,7 +62,10 @@
|
|||||||
"Bash(node check-user.mjs:*)",
|
"Bash(node check-user.mjs:*)",
|
||||||
"Bash(xargs kill:*)",
|
"Bash(xargs kill:*)",
|
||||||
"mcp__playwright__browser_resize",
|
"mcp__playwright__browser_resize",
|
||||||
"Bash(pnpm db:seed:*)"
|
"Bash(pnpm db:seed:*)",
|
||||||
|
"Bash(pnpm exec nuxi@latest module add shadcn-nuxt:*)",
|
||||||
|
"Bash(pnpm exec shadcn-nuxt@latest add:*)",
|
||||||
|
"Bash(pnpm exec shadcn-nuxt:*)"
|
||||||
],
|
],
|
||||||
"deny": [],
|
"deny": [],
|
||||||
"ask": []
|
"ask": []
|
||||||
|
|||||||
20
app/app.vue
20
app/app.vue
@@ -1,19 +1,17 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
// scroll-body="false": Prevents body scrolling when modals/dialogs are open.
|
||||||
|
// When set to false, the body element's overflow is locked while any modal component
|
||||||
|
// (like Dialog, Sheet, etc.) is open, preventing background content from scrolling.
|
||||||
|
// This improves UX by keeping focus on the modal content.
|
||||||
|
//
|
||||||
|
// Bug fix: Without this setting, opening dropdown menus (e.g., the user profile menu
|
||||||
|
// when clicking the avatar icon) would cause the body to receive a padding-right of
|
||||||
|
// 15px, which shifted the entire page content 15px to the left. This prop prevents
|
||||||
|
// that unwanted padding injection and layout shift.
|
||||||
import { ConfigProvider } from 'reka-ui'
|
import { ConfigProvider } from 'reka-ui'
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<!--
|
|
||||||
scroll-body="false": Prevents body scrolling when modals/dialogs are open.
|
|
||||||
When set to false, the body element's overflow is locked while any modal component
|
|
||||||
(like Dialog, Sheet, etc.) is open, preventing background content from scrolling.
|
|
||||||
This improves UX by keeping focus on the modal content.
|
|
||||||
|
|
||||||
Bug fix: Without this setting, opening dropdown menus (e.g., the user profile menu
|
|
||||||
when clicking the avatar icon) would cause the body to receive a padding-right of
|
|
||||||
15px, which shifted the entire page content 15px to the left. This prop prevents
|
|
||||||
that unwanted padding injection and layout shift.
|
|
||||||
-->
|
|
||||||
<ConfigProvider :scroll-body="false">
|
<ConfigProvider :scroll-body="false">
|
||||||
<div>
|
<div>
|
||||||
<NuxtPage />
|
<NuxtPage />
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { User, LogOut } from 'lucide-vue-next'
|
import { User, LogOut, Package } from 'lucide-vue-next'
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
AvatarFallback,
|
AvatarFallback,
|
||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from './ui/dropdown-menu'
|
} from './ui/dropdown-menu'
|
||||||
|
|
||||||
const { user, logout } = useAuth()
|
const { user, loggedIn, logout } = useAuth()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get user initials for Avatar fallback
|
* Get user initials for Avatar fallback
|
||||||
@@ -42,29 +42,51 @@ async function handleLogout() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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>
|
<DropdownMenuTrigger as-child>
|
||||||
<button
|
<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"
|
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" />
|
<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 }}
|
{{ userInitials }}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
</button>
|
</button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
<DropdownMenuContent align="end" class="w-56 z-[200]">
|
<DropdownMenuContent align="end" class="w-64 z-[200]">
|
||||||
<!-- User email (non-interactive) -->
|
<!-- User info header -->
|
||||||
<DropdownMenuLabel class="font-normal">
|
<DropdownMenuLabel class="font-normal py-3">
|
||||||
<div class="flex flex-col space-y-1">
|
<div class="flex flex-col space-y-1.5">
|
||||||
<p class="text-sm font-medium leading-none">
|
<p class="text-base font-semibold leading-none">
|
||||||
{{ user?.firstName }} {{ user?.lastName }}
|
{{ user?.firstName }} {{ user?.lastName }}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-xs leading-none text-muted-foreground">
|
<p class="text-sm leading-none text-muted-foreground">
|
||||||
{{ user?.email }}
|
{{ user?.email }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -72,18 +94,24 @@ async function handleLogout() {
|
|||||||
|
|
||||||
<DropdownMenuSeparator />
|
<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+) -->
|
<!-- Profile (disabled for now, placeholder for Phase 2+) -->
|
||||||
<DropdownMenuItem disabled>
|
<DropdownMenuItem disabled class="py-2.5">
|
||||||
<User class="mr-2 h-4 w-4" />
|
<User class="mr-3 h-5 w-5" />
|
||||||
<span>Profil</span>
|
<span class="text-sm">Profil bearbeiten</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
|
|
||||||
<!-- Logout -->
|
<!-- Logout -->
|
||||||
<DropdownMenuItem @click="handleLogout">
|
<DropdownMenuItem @click="handleLogout" class="py-2.5">
|
||||||
<LogOut class="mr-2 h-4 w-4" />
|
<LogOut class="mr-3 h-5 w-5" />
|
||||||
<span>Abmelden</span>
|
<span class="text-sm">Abmelden</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</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'
|
||||||
@@ -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>
|
||||||
|
|||||||
195
app/pages/internal/product-detail-demo.vue
Normal file
195
app/pages/internal/product-detail-demo.vue
Normal file
@@ -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>
|
||||||
@@ -56,6 +56,7 @@ const copyCode = async (code: string) => {
|
|||||||
<li><a href="#progress" class="link-primary">Progress Bars</a></li>
|
<li><a href="#progress" class="link-primary">Progress Bars</a></li>
|
||||||
<li><a href="#components" class="link-primary">Components</a></li>
|
<li><a href="#components" class="link-primary">Components</a></li>
|
||||||
<li><a href="/internal/products-demo" class="link-accent">→ Product Cards Demo</a></li>
|
<li><a href="/internal/products-demo" class="link-accent">→ Product Cards Demo</a></li>
|
||||||
|
<li><a href="/internal/product-detail-demo" class="link-accent">→ Product Detail Demo</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
@@ -243,6 +244,46 @@ const copyCode = async (code: string) => {
|
|||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Secondary Button (btn-secondary) -->
|
||||||
|
<div class="card-glass mb-8">
|
||||||
|
<h3 class="text-2xl font-semibold mb-4 text-white">Secondary Button (.btn-secondary)</h3>
|
||||||
|
<p class="mb-6 text-white/90">
|
||||||
|
Transparent button with white border. Perfect for secondary actions alongside primary buttons.
|
||||||
|
Features a smooth hover effect that fills with white background.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-4 items-center mb-6">
|
||||||
|
<a href="#" class="btn-secondary" @click.prevent="handleClick">
|
||||||
|
Secondary Action
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<NuxtLink to="#" class="btn-secondary">
|
||||||
|
As NuxtLink
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white/5 p-4 rounded-lg mb-4">
|
||||||
|
<p class="text-sm text-white/70 mb-2"><strong>Usage Example:</strong> Product detail pages</p>
|
||||||
|
<p class="text-sm text-white/70">
|
||||||
|
Use alongside primary "In den Warenkorb" button for actions like "Weitere Produkte ansehen".
|
||||||
|
The transparent background integrates beautifully with glassmorphism design.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<details class="bg-white/5 p-4 rounded-lg">
|
||||||
|
<summary class="cursor-pointer text-white/90 font-semibold">Show Code</summary>
|
||||||
|
<pre class="mt-4 text-sm text-white/80 overflow-x-auto"><code><!-- As Link -->
|
||||||
|
<a href="#" class="btn-secondary">
|
||||||
|
Secondary Action
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- As NuxtLink -->
|
||||||
|
<NuxtLink to="/products" class="btn-secondary">
|
||||||
|
Weitere Produkte ansehen
|
||||||
|
</NuxtLink></code></pre>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- shadcn-nuxt Button Variants -->
|
<!-- shadcn-nuxt Button Variants -->
|
||||||
<div class="card-glass">
|
<div class="card-glass">
|
||||||
<h3 class="text-2xl font-semibold mb-4 text-white">shadcn-nuxt Button Variants</h3>
|
<h3 class="text-2xl font-semibold mb-4 text-white">shadcn-nuxt Button Variants</h3>
|
||||||
@@ -672,7 +713,7 @@ const copyCode = async (code: string) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Product Cards Demo Link -->
|
<!-- Product Cards Demo Link -->
|
||||||
<div class="card-glass">
|
<div class="card-glass mb-8">
|
||||||
<h3 class="text-2xl font-semibold mb-4 text-white">Product Cards</h3>
|
<h3 class="text-2xl font-semibold mb-4 text-white">Product Cards</h3>
|
||||||
<p class="text-white/90 mb-6">
|
<p class="text-white/90 mb-6">
|
||||||
Speziell gestaltete Produktkarten für Jahreskarten und andere Produkte der experimenta.
|
Speziell gestaltete Produktkarten für Jahreskarten und andere Produkte der experimenta.
|
||||||
@@ -684,6 +725,20 @@ const copyCode = async (code: string) => {
|
|||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Product Detail Demo Link -->
|
||||||
|
<div class="card-glass">
|
||||||
|
<h3 class="text-2xl font-semibold mb-4 text-white">Product Detail Page</h3>
|
||||||
|
<p class="text-white/90 mb-6">
|
||||||
|
Vollständige Produktdetailseite für Jahreskarten mit Hero-Image, Preis- und Verfügbarkeitsanzeige,
|
||||||
|
Feature-Liste und Call-to-Action Buttons. Zeigt alle Design-Patterns für Produktseiten.
|
||||||
|
</p>
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<NuxtLink to="/internal/product-detail-demo" class="btn-experimenta">
|
||||||
|
Zur Product Detail Demo →
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
|
|||||||
@@ -181,10 +181,8 @@ const handleAddToCart = () => {
|
|||||||
>
|
>
|
||||||
{{ product.stockQuantity > 0 ? 'In den Warenkorb' : 'Nicht verfügbar' }}
|
{{ product.stockQuantity > 0 ? 'In den Warenkorb' : 'Nicht verfügbar' }}
|
||||||
</Button>
|
</Button>
|
||||||
<NuxtLink to="/products" class="flex-1">
|
<NuxtLink to="/products" class="btn-secondary flex-1 text-center">
|
||||||
<Button variant="outline" size="experimenta" class="w-full">
|
Weitere Produkte ansehen
|
||||||
Weitere Produkte ansehen
|
|
||||||
</Button>
|
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -65,7 +65,19 @@ export default {
|
|||||||
red: '#E40521',
|
red: '#E40521',
|
||||||
|
|
||||||
// Purple Variants (Background)
|
// Purple Variants (Background)
|
||||||
|
'experimenta-purple': '#2e1065',
|
||||||
purple: {
|
purple: {
|
||||||
|
50: '#f5f3ff',
|
||||||
|
100: '#ede9fe',
|
||||||
|
200: '#ddd6fe',
|
||||||
|
300: '#c4b5fd',
|
||||||
|
400: '#a78bfa',
|
||||||
|
500: '#8b5cf6',
|
||||||
|
600: '#7c3aed',
|
||||||
|
700: '#6d28d9',
|
||||||
|
800: '#5b21b6',
|
||||||
|
900: '#4c1d95',
|
||||||
|
950: '#2e1065',
|
||||||
dark: '#2e1065',
|
dark: '#2e1065',
|
||||||
deeper: '#1a0a3a',
|
deeper: '#1a0a3a',
|
||||||
darkest: '#0f051d',
|
darkest: '#0f051d',
|
||||||
|
|||||||
Reference in New Issue
Block a user