1- import { useState , useEffect , useCallback } from 'react'
1+ import { useState , useEffect , useCallback , useRef } from 'react'
22import { Button } from '@/components/ui/button'
33import { Card } from '@/components/ui/card'
44import { Badge } from '@/components/ui/badge'
@@ -53,6 +53,21 @@ interface AssignmentViewProps {
5353 emailConfigured ?: boolean
5454}
5555
56+ const CONFETTI_COLORS = [ '#F43F5E' , '#F59E0B' , '#10B981' , '#3B82F6' , '#A855F7' , '#EC4899' ]
57+ // Using a prime factor avoids repeating columns too early and helps spread 24 pieces across the viewport.
58+ const CONFETTI_HORIZONTAL_SPACING_FACTOR = 11
59+ const CONFETTI_PIECES = Array . from ( { length : 24 } , ( _ , index ) => ( {
60+ id : index ,
61+ color : CONFETTI_COLORS [ index % CONFETTI_COLORS . length ] ,
62+ left : `${ ( index * CONFETTI_HORIZONTAL_SPACING_FACTOR ) % 100 } %` ,
63+ delay : ( index % 6 ) * 0.08 ,
64+ duration : 1.8 + ( index % 5 ) * 0.2 ,
65+ drift : ( ( index % 7 ) - 3 ) * 14
66+ } ) )
67+ const CONFETTI_DISPLAY_DURATION_MS = (
68+ Math . max ( ...CONFETTI_PIECES . map ( ( piece ) => piece . delay + piece . duration ) ) * 1000
69+ )
70+
5671export function AssignmentView ( {
5772 game,
5873 participant,
@@ -75,6 +90,8 @@ export function AssignmentView({
7590 const [ isConfirming , setIsConfirming ] = useState ( false )
7691 const [ isRefreshing , setIsRefreshing ] = useState ( false )
7792 const [ giverHasConfirmed , setGiverHasConfirmed ] = useState ( false )
93+ const [ showConfetti , setShowConfetti ] = useState ( false )
94+ const lastConfettiReceiverId = useRef < string | null > ( null )
7895
7996 // Refresh game data from API
8097 const refreshGameData = useCallback ( async ( ) => {
@@ -142,6 +159,15 @@ export function AssignmentView({
142159 return ( ) => clearTimeout ( timer )
143160 } , [ currentReceiver ] )
144161
162+ useEffect ( ( ) => {
163+ if ( ! currentReceiver || lastConfettiReceiverId . current === currentReceiver . id ) return
164+
165+ lastConfettiReceiverId . current = currentReceiver . id
166+ setShowConfetti ( true )
167+ const timer = setTimeout ( ( ) => setShowConfetti ( false ) , CONFETTI_DISPLAY_DURATION_MS )
168+ return ( ) => clearTimeout ( timer )
169+ } , [ currentReceiver ] )
170+
145171 // Note: No mount-time refresh needed - game data is already loaded when entering this view
146172 // refreshGameData is available for manual refresh via the refresh button only
147173
@@ -454,6 +480,34 @@ export function AssignmentView({
454480
455481 return (
456482 < div className = "min-h-screen bg-background" >
483+ { showConfetti && (
484+ < div
485+ aria-hidden = "true"
486+ data-testid = "assignment-confetti-overlay"
487+ className = "fixed inset-0 pointer-events-none overflow-hidden z-50"
488+ >
489+ { CONFETTI_PIECES . map ( ( piece ) => (
490+ < motion . span
491+ key = { piece . id }
492+ className = "absolute top-[-12vh] h-3 w-2 rounded-sm"
493+ style = { { left : piece . left , backgroundColor : piece . color } }
494+ initial = { { opacity : 0 , y : '-12vh' , rotate : 0 } }
495+ animate = { {
496+ opacity : [ 0 , 1 , 1 , 0 ] ,
497+ y : '112vh' ,
498+ x : [ 0 , piece . drift , piece . drift / 2 ] ,
499+ rotate : [ 0 , 280 ]
500+ } }
501+ transition = { {
502+ duration : piece . duration ,
503+ delay : piece . delay ,
504+ ease : 'easeOut'
505+ } }
506+ />
507+ ) ) }
508+ </ div >
509+ ) }
510+
457511 < header className = "flex justify-between items-center p-4 border-b" >
458512 < Button variant = "ghost" onClick = { onBack } className = "gap-2" >
459513 < ArrowLeft size = { 20 } />
0 commit comments