You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

378 lines
10 KiB

<script setup lang="ts">
import { Wrench, FlaskConical, Ticket, Sparkles, GraduationCap, Home } from 'lucide-vue-next'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Badge } from '@/components/ui/badge'
import confetti from 'canvas-confetti'
type RoleCode = 'private' | 'educator' | 'company'
interface ProductArea {
id: string
label: string
icon: any
enabled: boolean
visible: boolean
badge?: string
route: string
roleVisibility?: 'all' | RoleCode[]
}
const areas: ProductArea[] = [
{
id: 'start',
label: 'Start',
icon: Home,
enabled: true,
visible: true,
route: '/',
roleVisibility: 'all',
},
{
id: 'makerspace',
label: 'Makerspace',
icon: Wrench,
enabled: true,
visible: true,
route: '/products',
roleVisibility: 'all',
},
{
id: 'educator',
label: 'Bildung',
icon: GraduationCap,
enabled: true,
visible: true,
badge: 'Demnächst',
route: '/educator',
roleVisibility: ['educator'],
},
{
id: 'experimenta',
label: 'experimenta',
icon: Sparkles,
enabled: true,
visible: true,
badge: 'Demnächst',
route: '/experimenta',
roleVisibility: 'all',
},
{
id: 'pedagogical-offers',
label: 'Pädagogische Angebote',
icon: GraduationCap,
enabled: true,
visible: true,
route: '/experimenta',
roleVisibility: ['educator'],
},
{
id: 'labs',
label: 'Labore',
icon: FlaskConical,
enabled: false,
visible: true,
badge: 'Demnächst',
route: '/labs',
roleVisibility: ['educator', 'company'],
},
]
const route = useRoute()
const { activeRole } = useActiveRole()
// Filter areas by role visibility
const visibleAreas = computed(() => {
return areas.filter(area => {
// Legacy visible flag check
if (!area.visible) return false
// No role requirement = visible to all (backward compatible)
if (!area.roleVisibility) return true
// Explicitly set to 'all'
if (area.roleVisibility === 'all') return true
// Check if user's active role matches
return area.roleVisibility.includes(activeRole.value as RoleCode)
})
})
const currentArea = computed(() => {
// Determine current area based on route - check visibleAreas array dynamically
const currentPath = route.path
// Exact match for root path
if (currentPath === '/') {
return visibleAreas.value.find(area => area.route === '/')?.id || ''
}
// Find area where route path starts with area.route
const matchedArea = visibleAreas.value.find(area =>
area.route !== '/' && currentPath.startsWith(area.route)
)
return matchedArea?.id || ''
})
// Track previous area IDs for animation detection
const previousAreaIds = ref<string[]>([])
const newlyAddedAreaIds = ref<string[]>([])
const tabRefs = ref<Record<string, HTMLElement>>({})
// Watch for changes in visible areas to trigger animations
watch(visibleAreas, (newAreas, oldAreas) => {
const newAreaIds = newAreas.map(a => a.id)
const oldAreaIds = oldAreas?.map(a => a.id) || []
// Check if current route is still accessible with new visible areas
const currentPath = route.path
if (currentPath !== '/') {
const isCurrentRouteStillVisible = newAreas.some(area =>
area.route !== '/' && currentPath.startsWith(area.route)
)
if (!isCurrentRouteStillVisible) {
// Delay navigation until fade-out animation completes (300ms + 50ms buffer)
setTimeout(() => {
navigateTo('/')
}, 350)
}
}
// Find newly added areas
const addedIds = newAreaIds.filter(id => !oldAreaIds.includes(id))
if (addedIds.length > 0) {
// Mark ALL newly added tabs for highlight animation
newlyAddedAreaIds.value = addedIds
// Trigger confetti for each new tab with staggered delay (wave effect)
addedIds.forEach((areaId, index) => {
setTimeout(() => {
const element = tabRefs.value[areaId]
if (element) {
triggerConfetti(element)
}
}, 300 + (index * 150)) // Stagger: 300ms, 450ms, 600ms, ...
})
// Clear all highlights after animation completes (including fade-out)
// Animation timeline: glow+pulse 2x (~1.4s) + fade-out (0.6s) = 2.0s
// Wait 2.1s to ensure fade-out completes before removing class
// TEMPORARILY DISABLED FOR TESTING
// setTimeout(() => {
// newlyAddedAreaIds.value = []
// }, 2100)
}
previousAreaIds.value = newAreaIds
}, { deep: true })
// Initialize previous area IDs
onMounted(() => {
previousAreaIds.value = visibleAreas.value.map(a => a.id)
})
// Confetti effect from element position
function triggerConfetti(element: HTMLElement) {
const rect = element.getBoundingClientRect()
const x = (rect.left + rect.width / 2) / window.innerWidth
const y = (rect.top + rect.height / 2) / window.innerHeight
// Confetti burst from tab position
confetti({
particleCount: 50,
spread: 120,
origin: { x, y },
colors: ['#E91E85', '#FF6B9D', '#C77DFF', '#9D4EDD'],
ticks: 60,
gravity: 1.3, // Increased gravity for faster fall
scalar: 0.8,
startVelocity: 15, // Reduced initial velocity (default: 45)
})
}
function navigateToArea(area: ProductArea) {
if (area.enabled) {
navigateTo(area.route)
}
}
function setTabRef(areaId: string, el: any) {
if (el) {
// Extract the actual DOM element from Vue component instance
const domElement = el.$el || el
tabRefs.value[areaId] = domElement
}
}
</script>
<template>
<div class="w-full">
<!-- Desktop: Tabs -->
<Tabs :model-value="currentArea" class="hidden md:block">
<TabsList class="h-auto p-2 bg-white/5">
<TransitionGroup name="tab" tag="div" class="flex items-center gap-1">
<TabsTrigger v-for="area in visibleAreas" :key="area.id" :ref="(el) => setTabRef(area.id, el)"
:value="area.id" :disabled="!area.enabled" :class="[
'relative gap-2 py-3 md:py-4 data-[state=active]:bg-accent data-[state=active]:text-white data-[state=active]:shadow-md transition-all duration-300',
!area.enabled && 'opacity-50 cursor-not-allowed',
newlyAddedAreaIds.includes(area.id) && 'tab-highlight',
]" @click="navigateToArea(area)">
<component :is="area.icon" class="h-4 w-4" />
<span>{{ area.label }}</span>
<Badge v-if="area.badge" :class="[
'ml-1 text-[10px] px-1.5 py-0.5 pointer-events-none rounded-[35px]',
currentArea === area.id
? 'bg-white/90 text-purple-950 border-white/50'
: 'bg-experimenta-accent/20 text-experimenta-accent border-experimenta-accent/30'
]">
{{ area.badge }}
</Badge>
</TabsTrigger>
</TransitionGroup>
</TabsList>
</Tabs>
<!-- Mobile: Horizontal scroll with cards (matching desktop styling) -->
<div class="md:hidden overflow-x-auto scrollbar-hide">
<div class="inline-flex h-auto items-center justify-center rounded-[35px] bg-white/5 p-2 min-w-max">
<TransitionGroup name="tab" tag="div" class="inline-flex items-center gap-2">
<button v-for="area in visibleAreas" :key="area.id" :ref="(el) => setTabRef(area.id, el)"
:disabled="!area.enabled" :class="[
'relative inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-[25px] px-4 py-3 text-lg font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-0 duration-300',
currentArea === area.id
? 'bg-accent text-white shadow-md'
: 'text-white/70 hover:text-white',
!area.enabled && 'opacity-50 cursor-not-allowed',
newlyAddedAreaIds.includes(area.id) && 'tab-highlight',
]" @click="navigateToArea(area)">
<component :is="area.icon" class="h-4 w-4" />
<span>{{ area.label }}</span>
<Badge v-if="area.badge" :class="[
'ml-1 text-[10px] px-1.5 py-0.5 pointer-events-none rounded-[35px]',
currentArea === area.id
? 'bg-white/90 text-purple-950 border-white/50'
: 'bg-experimenta-accent/20 text-experimenta-accent border-experimenta-accent/30'
]">
{{ area.badge }}
</Badge>
</button>
</TransitionGroup>
</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;
}
/* Vue TransitionGroup animations for tabs */
.tab-enter-active {
transition: all 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.tab-leave-active {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: absolute;
}
.tab-enter-from {
opacity: 0;
transform: translateX(-20px) scale(0.9);
}
.tab-leave-to {
opacity: 0;
transform: translateX(-10px) scale(0.95);
}
.tab-move {
transition: all 0.5s cubic-bezier(0.4, 0, 0.2, 1);
}
/* Highlight animation for newly added tabs - 2x blink then stay transparent */
@keyframes highlight-glow {
0% {
box-shadow: 0 0 0 0 rgba(233, 30, 133, 0);
}
/* First blink */
25% {
box-shadow: 0 0 20px 4px rgba(233, 30, 133, 0.6),
0 0 40px 8px rgba(201, 125, 255, 0.4);
}
50% {
box-shadow: 0 0 0 0 rgba(233, 30, 133, 0);
}
/* Second blink */
75% {
box-shadow: 0 0 20px 4px rgba(233, 30, 133, 0.6),
0 0 40px 8px rgba(201, 125, 255, 0.4);
}
/* Stay transparent after 2nd blink */
100% {
box-shadow: 0 0 0 0 rgba(233, 30, 133, 0);
}
}
@keyframes highlight-pulse {
0%,
100% {
transform: scale(1);
}
50% {
transform: scale(1.05);
}
}
/* Opacity fade for background (Chrome-compatible workaround) */
@keyframes highlight-opacity {
0% {
opacity: 1;
}
/* Stay visible during first blink */
25% {
opacity: 1;
}
/* Start fading slowly after first blink */
50% {
opacity: 1;
}
/* Fade to transparent gradually */
100% {
opacity: 0;
}
}
.tab-highlight {
/* Animations */
animation: highlight-glow 2s ease-in-out 1 forwards,
highlight-pulse 0.5s ease-in-out 2;
}
.tab-highlight::before {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
background: linear-gradient(135deg, rgba(233, 30, 133, 0.2), rgba(201, 125, 255, 0.2));
pointer-events: none;
animation: highlight-opacity 2s ease-in-out 1 forwards;
}
</style>