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(xargs kill:*)",
|
||||
"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": [],
|
||||
"ask": []
|
||||
|
||||
20
app/app.vue
20
app/app.vue
@@ -1,19 +1,17 @@
|
||||
<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'
|
||||
</script>
|
||||
|
||||
<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">
|
||||
<div>
|
||||
<NuxtPage />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { User, LogOut } from 'lucide-vue-next'
|
||||
import { User, LogOut, Package } from 'lucide-vue-next'
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from './ui/dropdown-menu'
|
||||
|
||||
const { user, logout } = useAuth()
|
||||
const { user, loggedIn, logout } = useAuth()
|
||||
|
||||
/**
|
||||
* Get user initials for Avatar fallback
|
||||
@@ -42,29 +42,51 @@ async function handleLogout() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenu>
|
||||
<!-- Not logged in: Show login prompt -->
|
||||
<NuxtLink
|
||||
v-if="!loggedIn"
|
||||
to="/auth"
|
||||
class="flex items-center gap-2 px-4 py-2 rounded-2xl border-2 border-dashed border-muted-foreground/30 hover:border-experimenta-accent hover:bg-experimenta-accent/10 transition-all"
|
||||
aria-label="Anmelden oder Registrieren"
|
||||
>
|
||||
<Avatar class="h-10 w-10 border-2 border-muted-foreground/30 rounded-2xl">
|
||||
<AvatarFallback class="bg-muted text-muted-foreground rounded-2xl">
|
||||
<User class="h-5 w-5" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span class="text-sm font-medium hidden sm:inline">Anmelden</span>
|
||||
</NuxtLink>
|
||||
|
||||
<!-- Logged in: Show user menu -->
|
||||
<DropdownMenu v-else>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<button
|
||||
class="flex items-center gap-2 rounded-full focus:outline-none focus:ring-2 focus:ring-experimenta-accent focus:ring-offset-2 focus:ring-offset-experimenta-purple transition-all hover:scale-105 hover:shadow-lg"
|
||||
class="flex items-center gap-3 rounded-2xl px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-experimenta-accent focus:ring-offset-2 transition-all hover:bg-white/10"
|
||||
aria-label="Benutzermenü öffnen"
|
||||
>
|
||||
<Avatar class="h-12 w-12 border-3 border-experimenta-accent shadow-md bg-experimenta-accent">
|
||||
<!-- Greeting text (Desktop only) -->
|
||||
<span class="hidden md:inline text-sm font-medium text-white">
|
||||
Hallo, {{ user?.firstName }}
|
||||
</span>
|
||||
|
||||
<!-- Avatar with status ring (logged in indicator) -->
|
||||
<Avatar class="h-10 w-10 md:h-12 md:w-12 border-3 border-experimenta-purple shadow-lg bg-experimenta-accent ring-2 ring-experimenta-accent ring-offset-2 ring-offset-white dark:ring-offset-zinc-950">
|
||||
<AvatarImage :src="undefined" :alt="user?.firstName" />
|
||||
<AvatarFallback class="bg-experimenta-accent text-white font-bold text-base flex items-center justify-center w-full h-full">
|
||||
<AvatarFallback class="bg-experimenta-accent text-white font-bold text-sm md:text-base flex items-center justify-center w-full h-full">
|
||||
{{ userInitials }}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent align="end" class="w-56 z-[200]">
|
||||
<!-- User email (non-interactive) -->
|
||||
<DropdownMenuLabel class="font-normal">
|
||||
<div class="flex flex-col space-y-1">
|
||||
<p class="text-sm font-medium leading-none">
|
||||
<DropdownMenuContent align="end" class="w-64 z-[200]">
|
||||
<!-- User info header -->
|
||||
<DropdownMenuLabel class="font-normal py-3">
|
||||
<div class="flex flex-col space-y-1.5">
|
||||
<p class="text-base font-semibold leading-none">
|
||||
{{ user?.firstName }} {{ user?.lastName }}
|
||||
</p>
|
||||
<p class="text-xs leading-none text-muted-foreground">
|
||||
<p class="text-sm leading-none text-muted-foreground">
|
||||
{{ user?.email }}
|
||||
</p>
|
||||
</div>
|
||||
@@ -72,18 +94,24 @@ async function handleLogout() {
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<!-- My Orders (Phase 5+ feature) -->
|
||||
<DropdownMenuItem disabled class="py-2.5">
|
||||
<Package class="mr-3 h-5 w-5" />
|
||||
<span class="text-sm">Meine Bestellungen</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<!-- Profile (disabled for now, placeholder for Phase 2+) -->
|
||||
<DropdownMenuItem disabled>
|
||||
<User class="mr-2 h-4 w-4" />
|
||||
<span>Profil</span>
|
||||
<DropdownMenuItem disabled class="py-2.5">
|
||||
<User class="mr-3 h-5 w-5" />
|
||||
<span class="text-sm">Profil bearbeiten</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<!-- Logout -->
|
||||
<DropdownMenuItem @click="handleLogout">
|
||||
<LogOut class="mr-2 h-4 w-4" />
|
||||
<span>Abmelden</span>
|
||||
<DropdownMenuItem @click="handleLogout" class="py-2.5">
|
||||
<LogOut class="mr-3 h-5 w-5" />
|
||||
<span class="text-sm">Abmelden</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
96
app/components/navigation/AppHeader.vue
Normal file
96
app/components/navigation/AppHeader.vue
Normal file
@@ -0,0 +1,96 @@
|
||||
<script setup lang="ts">
|
||||
import RoleSwitcher from './RoleSwitcher.vue'
|
||||
import AreaTabs from './AreaTabs.vue'
|
||||
import CartButton from './CartButton.vue'
|
||||
import UserMenu from '../UserMenu.vue'
|
||||
|
||||
const { loggedIn } = useAuth()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="sticky top-0 z-50 w-full">
|
||||
<!-- Main header bar -->
|
||||
<div class="container flex h-24 items-center justify-between px-4 md:px-6 lg:px-8">
|
||||
<!-- Left: Logo + Role Switcher -->
|
||||
<div class="flex items-center gap-6 md:gap-8">
|
||||
<NuxtLink to="/" class="flex-shrink-0 transition-transform hover:scale-105">
|
||||
<img
|
||||
src="/img/experimenta-logo-white.svg"
|
||||
alt="experimenta Logo"
|
||||
class="h-14 w-auto md:h-16 drop-shadow-lg"
|
||||
/>
|
||||
</NuxtLink>
|
||||
|
||||
<!-- Role Switcher (Desktop only) -->
|
||||
<div class="hidden lg:block">
|
||||
<RoleSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: Cart + User Menu -->
|
||||
<div class="flex items-center gap-3 md:gap-6">
|
||||
<!-- Cart Button (only visible when logged in) -->
|
||||
<CartButton v-if="loggedIn" />
|
||||
|
||||
<!-- User Menu -->
|
||||
<UserMenu />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Secondary navigation bar (Product areas) - No borders, gradient separation -->
|
||||
<div class="area-tabs-section">
|
||||
<div class="container px-4 md:px-6 lg:px-8 py-3">
|
||||
<AreaTabs />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile: Role switcher in collapsible section (optional) -->
|
||||
<div class="lg:hidden mobile-role-section">
|
||||
<div class="container px-4 py-2.5">
|
||||
<RoleSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Custom experimenta styling */
|
||||
header {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(46, 16, 101, 0.98) 0%,
|
||||
rgba(46, 16, 101, 0.95) 100%
|
||||
);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Area tabs section with subtle gradient separator (no borders) */
|
||||
.area-tabs-section {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(255, 255, 255, 0.08) 0%,
|
||||
rgba(255, 255, 255, 0.03) 100%
|
||||
);
|
||||
}
|
||||
|
||||
/* Mobile role section */
|
||||
.mobile-role-section {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
/* Logo styling */
|
||||
img[alt='experimenta Logo'] {
|
||||
filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.3));
|
||||
}
|
||||
|
||||
/* Ensure text is white in header */
|
||||
:deep(button),
|
||||
:deep(a) {
|
||||
color: white;
|
||||
}
|
||||
|
||||
:deep(.text-foreground),
|
||||
:deep(.text-muted-foreground) {
|
||||
color: rgba(255, 255, 255, 0.9) !important;
|
||||
}
|
||||
</style>
|
||||
131
app/components/navigation/AreaTabs.vue
Normal file
131
app/components/navigation/AreaTabs.vue
Normal file
@@ -0,0 +1,131 @@
|
||||
<script setup lang="ts">
|
||||
import { Wrench, FlaskConical, Ticket, Sparkles } from 'lucide-vue-next'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
|
||||
interface ProductArea {
|
||||
id: string
|
||||
label: string
|
||||
icon: any
|
||||
enabled: boolean
|
||||
badge?: string
|
||||
route: string
|
||||
}
|
||||
|
||||
const areas: ProductArea[] = [
|
||||
{
|
||||
id: 'makerspace',
|
||||
label: 'Makerspace',
|
||||
icon: Wrench,
|
||||
enabled: true,
|
||||
route: '/products',
|
||||
},
|
||||
{
|
||||
id: 'labs',
|
||||
label: 'Labore',
|
||||
icon: FlaskConical,
|
||||
enabled: false,
|
||||
badge: 'Demnächst',
|
||||
route: '/labs',
|
||||
},
|
||||
{
|
||||
id: 'experimenta',
|
||||
label: 'experimenta',
|
||||
icon: Sparkles,
|
||||
enabled: false,
|
||||
badge: 'Demnächst',
|
||||
route: '/experimenta',
|
||||
},
|
||||
]
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const currentArea = computed(() => {
|
||||
// Determine current area based on route
|
||||
if (route.path.startsWith('/products') || route.path === '/') {
|
||||
return 'makerspace'
|
||||
} else if (route.path.startsWith('/labs')) {
|
||||
return 'labs'
|
||||
} else if (route.path.startsWith('/experimenta')) {
|
||||
return 'experimenta'
|
||||
}
|
||||
return 'makerspace'
|
||||
})
|
||||
|
||||
function navigateToArea(area: ProductArea) {
|
||||
if (area.enabled) {
|
||||
navigateTo(area.route)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<!-- Desktop: Tabs -->
|
||||
<Tabs :model-value="currentArea" class="hidden md:block">
|
||||
<TabsList class="h-auto p-1 bg-muted/50">
|
||||
<TabsTrigger
|
||||
v-for="area in areas"
|
||||
:key="area.id"
|
||||
:value="area.id"
|
||||
:disabled="!area.enabled"
|
||||
:class="[
|
||||
'gap-2 data-[state=active]:bg-white dark:data-[state=active]:bg-zinc-900',
|
||||
!area.enabled && 'opacity-60 cursor-not-allowed',
|
||||
]"
|
||||
@click="navigateToArea(area)"
|
||||
>
|
||||
<component :is="area.icon" class="h-4 w-4" />
|
||||
<span>{{ area.label }}</span>
|
||||
<Badge v-if="area.badge" variant="secondary" class="ml-1 text-[10px] px-1.5 py-0">
|
||||
{{ area.badge }}
|
||||
</Badge>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
<!-- Mobile: Horizontal scroll with cards -->
|
||||
<div class="md:hidden overflow-x-auto scrollbar-hide">
|
||||
<div class="flex gap-2 p-1 min-w-max">
|
||||
<button
|
||||
v-for="area in areas"
|
||||
:key="area.id"
|
||||
:disabled="!area.enabled"
|
||||
:class="[
|
||||
'flex items-center gap-2 px-4 py-2 rounded-lg border-2 transition-all whitespace-nowrap',
|
||||
currentArea === area.id
|
||||
? 'bg-purple-600 text-white border-purple-600'
|
||||
: 'bg-white dark:bg-zinc-900 border-border hover:border-purple-300',
|
||||
!area.enabled && 'opacity-60 cursor-not-allowed',
|
||||
]"
|
||||
@click="navigateToArea(area)"
|
||||
>
|
||||
<component
|
||||
:is="area.icon"
|
||||
:class="['h-4 w-4', currentArea === area.id ? 'text-white' : '']"
|
||||
/>
|
||||
<span class="font-medium">{{ area.label }}</span>
|
||||
<Badge
|
||||
v-if="area.badge"
|
||||
:variant="currentArea === area.id ? 'secondary' : 'outline'"
|
||||
class="text-[10px] px-1.5 py-0"
|
||||
>
|
||||
{{ area.badge }}
|
||||
</Badge>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Hide scrollbar but keep functionality */
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
</style>
|
||||
147
app/components/navigation/BottomNav.vue
Normal file
147
app/components/navigation/BottomNav.vue
Normal file
@@ -0,0 +1,147 @@
|
||||
<script setup lang="ts">
|
||||
import { Home, Wrench, ShoppingCart, User } from 'lucide-vue-next'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
|
||||
const route = useRoute()
|
||||
const { loggedIn } = useAuth()
|
||||
|
||||
interface NavItem {
|
||||
id: string
|
||||
label: string
|
||||
icon: any
|
||||
route: string
|
||||
badge?: number
|
||||
requireAuth?: boolean
|
||||
}
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{
|
||||
id: 'home',
|
||||
label: 'Start',
|
||||
icon: Home,
|
||||
route: '/',
|
||||
},
|
||||
{
|
||||
id: 'products',
|
||||
label: 'Makerspace',
|
||||
icon: Wrench,
|
||||
route: '/products',
|
||||
},
|
||||
{
|
||||
id: 'cart',
|
||||
label: 'Warenkorb',
|
||||
icon: ShoppingCart,
|
||||
route: '/cart',
|
||||
badge: 0, // TODO: Get from cart store
|
||||
requireAuth: true,
|
||||
},
|
||||
{
|
||||
id: 'profile',
|
||||
label: 'Profil',
|
||||
icon: User,
|
||||
route: '/auth',
|
||||
},
|
||||
]
|
||||
|
||||
const isActive = (itemRoute: string) => {
|
||||
if (itemRoute === '/') {
|
||||
return route.path === '/'
|
||||
}
|
||||
return route.path.startsWith(itemRoute)
|
||||
}
|
||||
|
||||
function handleNavClick(item: NavItem) {
|
||||
if (item.requireAuth && !loggedIn.value) {
|
||||
// Redirect to auth page
|
||||
navigateTo('/auth')
|
||||
return
|
||||
}
|
||||
|
||||
navigateTo(item.route)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Mobile Bottom Navigation - only visible on small screens -->
|
||||
<nav
|
||||
class="fixed bottom-0 left-0 right-0 z-50 md:hidden border-t bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80 safe-area-inset-bottom"
|
||||
role="navigation"
|
||||
aria-label="Mobile Navigation"
|
||||
>
|
||||
<div class="flex items-center justify-around h-16 px-2">
|
||||
<button
|
||||
v-for="item in navItems"
|
||||
:key="item.id"
|
||||
:class="[
|
||||
'flex flex-col items-center justify-center flex-1 gap-1 py-2 px-1 rounded-lg transition-all relative',
|
||||
isActive(item.route)
|
||||
? 'text-experimenta-accent bg-experimenta-accent/10'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-muted',
|
||||
item.requireAuth && !loggedIn && 'opacity-75',
|
||||
]"
|
||||
@click="handleNavClick(item)"
|
||||
:aria-label="item.label"
|
||||
:aria-current="isActive(item.route) ? 'page' : undefined"
|
||||
>
|
||||
<!-- Icon with badge -->
|
||||
<div class="relative">
|
||||
<component
|
||||
:is="item.icon"
|
||||
:class="[
|
||||
'h-5 w-5 transition-transform',
|
||||
isActive(item.route) && 'scale-110',
|
||||
]"
|
||||
/>
|
||||
|
||||
<!-- Badge for cart -->
|
||||
<Badge
|
||||
v-if="item.badge && item.badge > 0"
|
||||
class="absolute -top-2 -right-2 h-4 min-w-[16px] px-1 flex items-center justify-center text-[10px] bg-experimenta-accent"
|
||||
>
|
||||
{{ item.badge > 99 ? '99+' : item.badge }}
|
||||
</Badge>
|
||||
|
||||
<!-- Login indicator dot for profile when not logged in -->
|
||||
<span
|
||||
v-if="item.id === 'profile' && !loggedIn"
|
||||
class="absolute -top-1 -right-1 h-2 w-2 rounded-full bg-yellow-500 animate-pulse"
|
||||
aria-label="Nicht angemeldet"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Label -->
|
||||
<span
|
||||
:class="[
|
||||
'text-[10px] font-medium transition-all',
|
||||
isActive(item.route) && 'font-bold',
|
||||
]"
|
||||
>
|
||||
{{ item.label }}
|
||||
</span>
|
||||
|
||||
<!-- Active indicator bar -->
|
||||
<span
|
||||
v-if="isActive(item.route)"
|
||||
class="absolute bottom-0 left-1/2 -translate-x-1/2 w-12 h-0.5 bg-experimenta-accent rounded-full"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Safe area spacer for devices with notches/home indicators -->
|
||||
<div class="h-[env(safe-area-inset-bottom)] bg-background" />
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Support for safe area insets (iPhone X+) */
|
||||
.safe-area-inset-bottom {
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
/* Ensure bottom nav doesn't overlay content */
|
||||
@media (max-width: 768px) {
|
||||
/* Add bottom padding to body/main to account for fixed bottom nav */
|
||||
/* This will be handled in the layout component */
|
||||
}
|
||||
</style>
|
||||
43
app/components/navigation/CartButton.vue
Normal file
43
app/components/navigation/CartButton.vue
Normal file
@@ -0,0 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
import { ShoppingCart } from 'lucide-vue-next'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
|
||||
// TODO: This will come from cart store/composable in Phase 5
|
||||
const cartItemCount = ref(0)
|
||||
|
||||
const hasItems = computed(() => cartItemCount.value > 0)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NuxtLink
|
||||
to="/cart"
|
||||
class="relative inline-flex items-center justify-center rounded-full p-3 transition-all hover:bg-experimenta-accent/10 focus:outline-none focus:ring-2 focus:ring-experimenta-accent focus:ring-offset-2"
|
||||
aria-label="Warenkorb"
|
||||
>
|
||||
<ShoppingCart class="h-6 w-6 text-foreground" />
|
||||
|
||||
<!-- Item count badge -->
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-200 ease-out"
|
||||
enter-from-class="scale-0 opacity-0"
|
||||
enter-to-class="scale-100 opacity-100"
|
||||
leave-active-class="transition-all duration-150 ease-in"
|
||||
leave-from-class="scale-100 opacity-100"
|
||||
leave-to-class="scale-0 opacity-0"
|
||||
>
|
||||
<Badge
|
||||
v-if="hasItems"
|
||||
class="absolute -top-1 -right-1 h-5 min-w-[20px] px-1 flex items-center justify-center bg-experimenta-accent text-white text-xs font-bold border-2 border-white dark:border-zinc-950"
|
||||
>
|
||||
{{ cartItemCount > 99 ? '99+' : cartItemCount }}
|
||||
</Badge>
|
||||
</Transition>
|
||||
|
||||
<!-- Pulse animation when items are added (optional) -->
|
||||
<span
|
||||
v-if="hasItems"
|
||||
class="absolute inset-0 rounded-full bg-experimenta-accent/20 animate-ping opacity-75"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
142
app/components/navigation/RoleSwitcher.vue
Normal file
142
app/components/navigation/RoleSwitcher.vue
Normal file
@@ -0,0 +1,142 @@
|
||||
<script setup lang="ts">
|
||||
import { User, GraduationCap, Building2, Check } from 'lucide-vue-next'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
// User role types matching our database schema
|
||||
type UserRole = 'individual' | 'educator' | 'company'
|
||||
|
||||
interface RoleOption {
|
||||
value: UserRole
|
||||
label: string
|
||||
description: string
|
||||
icon: any
|
||||
color: string
|
||||
enabled: boolean
|
||||
badge?: string
|
||||
}
|
||||
|
||||
const roles: RoleOption[] = [
|
||||
{
|
||||
value: 'individual',
|
||||
label: 'Privatperson',
|
||||
description: 'für mich',
|
||||
icon: User,
|
||||
color: 'text-purple-600',
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
value: 'educator',
|
||||
label: 'Pädagoge',
|
||||
description: 'für Schule/Kita',
|
||||
icon: GraduationCap,
|
||||
color: 'text-orange-500',
|
||||
enabled: false,
|
||||
badge: 'Demnächst',
|
||||
},
|
||||
{
|
||||
value: 'company',
|
||||
label: 'Firma',
|
||||
description: 'für Unternehmen',
|
||||
icon: Building2,
|
||||
color: 'text-blue-600',
|
||||
enabled: false,
|
||||
badge: 'Demnächst',
|
||||
},
|
||||
]
|
||||
|
||||
// Current role - will come from user session later
|
||||
const currentRole = ref<UserRole>('individual')
|
||||
|
||||
const currentRoleData = computed(() => {
|
||||
return roles.find((r) => r.value === currentRole.value) || roles[0]
|
||||
})
|
||||
|
||||
function switchRole(role: UserRole) {
|
||||
if (roles.find((r) => r.value === role)?.enabled) {
|
||||
currentRole.value = role
|
||||
// TODO: Update user session/store with new role
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="gap-2 border-2 border-white/30 bg-white/5 text-white hover:bg-white/10 hover:border-white/50 transition-all duration-200 group"
|
||||
title="Rolle wechseln"
|
||||
>
|
||||
<!-- Context label + role -->
|
||||
<span class="text-xs text-white/70 font-normal hidden md:inline">Du kaufst als:</span>
|
||||
<component :is="currentRoleData.icon" class="h-4 w-4 text-white" />
|
||||
<span class="font-medium text-white">{{ currentRoleData.label }}</span>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="ml-1 opacity-70 text-white group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" class="w-64">
|
||||
<DropdownMenuLabel class="text-sm font-normal text-muted-foreground py-3">
|
||||
Für wen kaufst du?
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem
|
||||
v-for="role in roles"
|
||||
:key="role.value"
|
||||
:disabled="!role.enabled"
|
||||
:class="[
|
||||
'gap-3 py-3 cursor-pointer',
|
||||
!role.enabled && 'opacity-60 cursor-not-allowed',
|
||||
currentRole === role.value && 'bg-purple-50 dark:bg-purple-950',
|
||||
]"
|
||||
@click="switchRole(role.value)"
|
||||
>
|
||||
<component :is="role.icon" :class="['h-5 w-5', role.color]" />
|
||||
|
||||
<div class="flex-1 flex flex-col gap-1">
|
||||
<span class="text-sm font-semibold">{{ role.label }}</span>
|
||||
<span class="text-sm text-muted-foreground">{{ role.description }}</span>
|
||||
</div>
|
||||
|
||||
<Check
|
||||
v-if="currentRole === role.value"
|
||||
class="h-5 w-5 text-green-600 flex-shrink-0"
|
||||
/>
|
||||
|
||||
<Badge v-if="role.badge" variant="secondary" class="text-xs px-2 py-0.5 flex-shrink-0">
|
||||
{{ role.badge }}
|
||||
</Badge>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<div class="px-3 py-2.5 text-sm text-muted-foreground">
|
||||
<p class="leading-relaxed">
|
||||
💡 Weitere Rollen werden in Kürze verfügbar sein.
|
||||
</p>
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</template>
|
||||
24
app/components/ui/badge/Badge.vue
Normal file
24
app/components/ui/badge/Badge.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import { type HTMLAttributes, computed } from 'vue'
|
||||
import { type BadgeVariants, badgeVariants } from '.'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface Props {
|
||||
variant?: BadgeVariants['variant']
|
||||
class?: HTMLAttributes['class']
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
|
||||
return delegated
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn(badgeVariants({ variant }), props.class)" v-bind="delegatedProps">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
24
app/components/ui/badge/index.ts
Normal file
24
app/components/ui/badge/index.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { type VariantProps, cva } from 'class-variance-authority'
|
||||
|
||||
export { default as Badge } from './Badge.vue'
|
||||
|
||||
export const badgeVariants = cva(
|
||||
'inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80',
|
||||
secondary: 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
destructive: 'border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80',
|
||||
outline: 'text-foreground',
|
||||
success: 'border-transparent bg-green-500 text-white shadow hover:bg-green-600',
|
||||
warning: 'border-transparent bg-yellow-500 text-white shadow hover:bg-yellow-600',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export type BadgeVariants = VariantProps<typeof badgeVariants>
|
||||
32
app/components/ui/separator/Separator.vue
Normal file
32
app/components/ui/separator/Separator.vue
Normal file
@@ -0,0 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
import { type HTMLAttributes, computed } from 'vue'
|
||||
import { SeparatorRoot, type SeparatorRootProps } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<SeparatorRootProps & { class?: HTMLAttributes['class'] }>(),
|
||||
{
|
||||
orientation: 'horizontal',
|
||||
decorative: true,
|
||||
}
|
||||
)
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
|
||||
return delegated
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SeparatorRoot
|
||||
v-bind="delegatedProps"
|
||||
:class="
|
||||
cn(
|
||||
'shrink-0 bg-border',
|
||||
props.orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',
|
||||
props.class
|
||||
)
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
1
app/components/ui/separator/index.ts
Normal file
1
app/components/ui/separator/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Separator } from './Separator.vue'
|
||||
@@ -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>
|
||||
|
||||
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="#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/product-detail-demo" class="link-accent">→ Product Detail Demo</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
@@ -243,6 +244,46 @@ const copyCode = async (code: string) => {
|
||||
</details>
|
||||
</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 -->
|
||||
<div class="card-glass">
|
||||
<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>
|
||||
|
||||
<!-- 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>
|
||||
<p class="text-white/90 mb-6">
|
||||
Speziell gestaltete Produktkarten für Jahreskarten und andere Produkte der experimenta.
|
||||
@@ -684,6 +725,20 @@ const copyCode = async (code: string) => {
|
||||
</NuxtLink>
|
||||
</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>
|
||||
|
||||
<!-- Footer -->
|
||||
|
||||
@@ -181,10 +181,8 @@ const handleAddToCart = () => {
|
||||
>
|
||||
{{ product.stockQuantity > 0 ? 'In den Warenkorb' : 'Nicht verfügbar' }}
|
||||
</Button>
|
||||
<NuxtLink to="/products" class="flex-1">
|
||||
<Button variant="outline" size="experimenta" class="w-full">
|
||||
Weitere Produkte ansehen
|
||||
</Button>
|
||||
<NuxtLink to="/products" class="btn-secondary flex-1 text-center">
|
||||
Weitere Produkte ansehen
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -65,7 +65,19 @@ export default {
|
||||
red: '#E40521',
|
||||
|
||||
// Purple Variants (Background)
|
||||
'experimenta-purple': '#2e1065',
|
||||
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',
|
||||
deeper: '#1a0a3a',
|
||||
darkest: '#0f051d',
|
||||
|
||||
Reference in New Issue
Block a user