@ -2,6 +2,7 @@
import { Wrench , FlaskConical , Ticket , Sparkles , GraduationCap , Home } from 'lucide-vue-next'
import { Wrench , FlaskConical , Ticket , Sparkles , GraduationCap , Home } from 'lucide-vue-next'
import { Tabs , TabsList , TabsTrigger } from '@/components/ui/tabs'
import { Tabs , TabsList , TabsTrigger } from '@/components/ui/tabs'
import { Badge } from '@/components/ui/badge'
import { Badge } from '@/components/ui/badge'
import confetti from 'canvas-confetti'
type RoleCode = 'private' | 'educator' | 'company'
type RoleCode = 'private' | 'educator' | 'company'
@ -104,11 +105,77 @@ const currentArea = computed(() => {
return matchedArea ? . id || ''
return matchedArea ? . id || ''
} )
} )
/ / T r a c k p r e v i o u s a r e a I D s f o r a n i m a t i o n d e t e c t i o n
const previousAreaIds = ref < string [ ] > ( [ ] )
const newlyAddedAreaId = ref < string | null > ( null )
const tabRefs = ref < Record < string , HTMLElement > > ( { } )
/ / W a t c h f o r c h a n g e s i n v i s i b l e a r e a s t o t r i g g e r a n i m a t i o n s
watch ( visibleAreas , ( newAreas , oldAreas ) => {
const newAreaIds = newAreas . map ( a => a . id )
const oldAreaIds = oldAreas ? . map ( a => a . id ) || [ ]
/ / F i n d n e w l y a d d e d a r e a s
const addedIds = newAreaIds . filter ( id => ! oldAreaIds . includes ( id ) )
if ( addedIds . length > 0 ) {
/ / M a r k a s n e w l y a d d e d f o r h i g h l i g h t a n i m a t i o n
newlyAddedAreaId . value = addedIds [ 0 ]
/ / T r i g g e r c o n f e t t i a f t e r a s m a l l d e l a y ( s o e l e m e n t i s r e n d e r e d )
setTimeout ( ( ) => {
const areaId = addedIds [ 0 ]
const element = tabRefs . value [ areaId ]
if ( element ) {
triggerConfetti ( element )
}
} , 300 )
/ / C l e a r h i g h l i g h t a f t e r a n i m a t i o n
setTimeout ( ( ) => {
newlyAddedAreaId . value = null
} , 2000 )
}
previousAreaIds . value = newAreaIds
} , { deep : true } )
/ / I n i t i a l i z e p r e v i o u s a r e a I D s
onMounted ( ( ) => {
previousAreaIds . value = visibleAreas . value . map ( a => a . id )
} )
/ / C o n f e t t i e f f e c t f r o m e l e m e n t p o s i t i o n
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
/ / C o n f e t t i b u r s t f r o m t a b p o s i t i o n
confetti ( {
particleCount : 25 ,
spread : 50 ,
origin : { x , y } ,
colors : [ '#E91E85' , '#FF6B9D' , '#C77DFF' , '#9D4EDD' ] ,
ticks : 200 ,
gravity : 1.2 ,
scalar : 0.8 ,
} )
}
function navigateToArea ( area : ProductArea ) {
function navigateToArea ( area : ProductArea ) {
if ( area . enabled ) {
if ( area . enabled ) {
navigateTo ( area . route )
navigateTo ( area . route )
}
}
}
}
function setTabRef ( areaId : string , el : any ) {
if ( el ) {
/ / E x t r a c t t h e a c t u a l D O M e l e m e n t f r o m V u e c o m p o n e n t i n s t a n c e
const domElement = el . $el || el
tabRefs . value [ areaId ] = domElement
}
}
< / script >
< / script >
< template >
< template >
@ -116,10 +183,12 @@ function navigateToArea(area: ProductArea) {
<!-- Desktop : Tabs -- >
<!-- Desktop : Tabs -- >
< Tabs :model-value ="currentArea" class = "hidden md:block" >
< Tabs :model-value ="currentArea" class = "hidden md:block" >
< TabsList class = "h-auto p-2 bg-white/5" >
< TabsList class = "h-auto p-2 bg-white/5" >
< TabsTrigger v -for = " area in visibleAreas " :key ="area.id" :value ="area.id"
< TransitionGroup name = "tab" tag = "div" class = "flex items-center gap-1" >
: disabled = "!area.enabled" : class = " [
< TabsTrigger v -for = " area in visibleAreas " :key ="area.id" : ref = "(el) => setTabRef(area.id, el)"
'gap-2 py-3 md:py-4 data-[state=active]:bg-accent data-[state=active]:text-white data-[state=active]:shadow-md' ,
: value = "area.id" : disabled = "!area.enabled" : class = " [
'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' ,
! area . enabled && 'opacity-50 cursor-not-allowed' ,
newlyAddedAreaId === area . id && 'tab-highlight' ,
] " @click=" navigateToArea ( area ) " >
] " @click=" navigateToArea ( area ) " >
< component :is ="area.icon" class = "h-4 w-4" / >
< component :is ="area.icon" class = "h-4 w-4" / >
< span > { { area . label } } < / span >
< span > { { area . label } } < / span >
@ -132,18 +201,22 @@ function navigateToArea(area: ProductArea) {
{ { area . badge } }
{ { area . badge } }
< / Badge >
< / Badge >
< / TabsTrigger >
< / TabsTrigger >
< / TransitionGroup >
< / TabsList >
< / TabsList >
< / Tabs >
< / Tabs >
<!-- Mobile : Horizontal scroll with cards ( matching desktop styling ) -- >
<!-- Mobile : Horizontal scroll with cards ( matching desktop styling ) -- >
< div class = "md:hidden overflow-x-auto scrollbar-hide" >
< 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" >
< div class = "inline-flex h-auto items-center justify-center rounded-[35px] bg-white/5 p-2 min-w-max" >
< button v -for = " area in visibleAreas " :key ="area.id" :disabled ="!area.enabled" : class = " [
< TransitionGroup name = "tab" tag = "div" class = "inline-flex items-center gap-2" >
'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' ,
< button v -for = " area in visibleAreas " :key ="area.id" : ref = "(el) => setTabRef(area.id, el)"
: disabled = "!area.enabled" : class = " [
'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
currentArea === area . id
? 'bg-accent text-white shadow-md'
? 'bg-accent text-white shadow-md'
: 'text-white/70 hover:text-white' ,
: 'text-white/70 hover:text-white' ,
! area . enabled && 'opacity-50 cursor-not-allowed' ,
! area . enabled && 'opacity-50 cursor-not-allowed' ,
newlyAddedAreaId === area . id && 'tab-highlight' ,
] " @click=" navigateToArea ( area ) " >
] " @click=" navigateToArea ( area ) " >
< component :is ="area.icon" class = "h-4 w-4" / >
< component :is ="area.icon" class = "h-4 w-4" / >
< span > { { area . label } } < / span >
< span > { { area . label } } < / span >
@ -156,6 +229,7 @@ function navigateToArea(area: ProductArea) {
{ { area . badge } }
{ { area . badge } }
< / Badge >
< / Badge >
< / button >
< / button >
< / TransitionGroup >
< / div >
< / div >
< / div >
< / div >
< / div >
< / div >
@ -171,4 +245,60 @@ function navigateToArea(area: ProductArea) {
- ms - overflow - style : none ;
- ms - overflow - style : none ;
scrollbar - width : none ;
scrollbar - width : none ;
}
}
/* Vue TransitionGroup animations for tabs */
. tab - enter - active {
transition : all 0.5 s cubic - bezier ( 0.34 , 1.56 , 0.64 , 1 ) ;
}
. tab - leave - active {
transition : all 0.3 s cubic - bezier ( 0.4 , 0 , 0.2 , 1 ) ;
position : absolute ;
}
. tab - enter - from {
opacity : 0 ;
transform : translateX ( - 20 px ) scale ( 0.9 ) ;
}
. tab - leave - to {
opacity : 0 ;
transform : translateX ( - 10 px ) scale ( 0.95 ) ;
}
. tab - move {
transition : all 0.5 s cubic - bezier ( 0.4 , 0 , 0.2 , 1 ) ;
}
/* Highlight animation for newly added tabs */
@ keyframes highlight - glow {
0 % ,
100 % {
box - shadow : 0 0 0 0 rgba ( 233 , 30 , 133 , 0 ) ;
}
50 % {
box - shadow : 0 0 20 px 4 px rgba ( 233 , 30 , 133 , 0.6 ) ,
0 0 40 px 8 px rgba ( 201 , 125 , 255 , 0.4 ) ;
}
}
@ keyframes highlight - pulse {
0 % ,
100 % {
transform : scale ( 1 ) ;
}
50 % {
transform : scale ( 1.05 ) ;
}
}
. tab - highlight {
animation : highlight - glow 1 s ease - in - out 2 ,
highlight - pulse 0.5 s ease - in - out 2 ;
background : linear - gradient ( 135 deg , rgba ( 233 , 30 , 133 , 0.2 ) , rgba ( 201 , 125 , 255 , 0.2 ) ) ! important ;
}
< / style >
< / style >