Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions e2e/app.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,56 @@ test.describe('Core User Flows', () => {
await expect(page.getByRole('button', { name: /ir al|go to/i })).toBeVisible()
})

test('should mark assignment as viewed on first participant visit', async ({ page }) => {
await page.goto('/')
await page.evaluate(() => localStorage.clear())

await page.getByRole('button', { name: /crear nuevo juego|create new game/i }).click()
await page.getByLabel(/nombre del evento|event name/i).fill('Confetti Test Event')
await page.getByLabel(/monto del regalo|gift amount/i).fill('35')
await page.getByLabel(/fecha del evento|event date/i).fill(getFutureDate())
await page.getByLabel(/lugar del evento|event location/i).fill('Office')
await page.getByRole('button', { name: /siguiente|next/i }).click()

const participantInput = page.getByPlaceholder(/maría garcía|mary smith/i)
const addButton = page.getByRole('button', { name: /agregar participante|add participant/i })
await participantInput.fill('Anna')
await addButton.click()
await participantInput.fill('Ben')
await addButton.click()
await participantInput.fill('Cara')
await addButton.click()
await page.getByRole('button', { name: /siguiente|next/i }).click()
await page.getByRole('button', { name: /finalizar|finish/i }).click()

let participantRouteData: { code?: string; participantId?: string; participantToken?: string } = {}
await expect
.poll(async () => {
participantRouteData = await page.evaluate(() => {
const games = JSON.parse(localStorage.getItem('ZavaGiftExchange:games') || '{}')
const game = (Object.values(games) as Array<{ name: string; code: string; participants: Array<{ id: string; token?: string }> }>)
.find((g) => g.name === 'Confetti Test Event')
const participant = game?.participants?.find(p => !!p.token)
return {
code: game?.code,
participantId: participant?.id,
participantToken: participant?.token
}
})
return Boolean(participantRouteData.code && participantRouteData.participantId && participantRouteData.participantToken)
}, {
message: 'Expected stored protected game and participant token to be available'
})
.toBe(true)

await page.goto(`/?code=${participantRouteData.code}&participant=${participantRouteData.participantToken}`)
await expect(page.getByRole('heading', { name: /tu asignación|your assignment/i })).toBeVisible()

await expect
.poll(async () => page.evaluate((key) => localStorage.getItem(key), `assignment-viewed-${participantRouteData.code}-${participantRouteData.participantId}`))
.toBe('true')
})

test('should toggle language', async ({ page }) => {
await page.goto('/')

Expand Down
11 changes: 11 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
"@radix-ui/react-tooltip": "^1.1.8",
"@tailwindcss/container-queries": "^0.1.1",
"@tailwindcss/vite": "^4.1.17",
"canvas-confetti": "^1.9.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
Expand Down
38 changes: 37 additions & 1 deletion src/components/AssignmentView.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from 'react'
import { useState, useEffect, useCallback, useRef } from 'react'
import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
Expand Down Expand Up @@ -75,6 +75,7 @@ export function AssignmentView({
const [isConfirming, setIsConfirming] = useState(false)
const [isRefreshing, setIsRefreshing] = useState(false)
const [giverHasConfirmed, setGiverHasConfirmed] = useState(false)
const lastTriggeredConfettiKeyRef = useRef<string | null>(null)

// Refresh game data from API
const refreshGameData = useCallback(async () => {
Expand Down Expand Up @@ -142,6 +143,41 @@ export function AssignmentView({
return () => clearTimeout(timer)
}, [currentReceiver])

useEffect(() => {
if (!isRevealed || !currentReceiver || typeof window === 'undefined') {
return
}

const confettiStorageKey = `assignment-viewed-${game.code}-${participant.id}`

if (lastTriggeredConfettiKeyRef.current === confettiStorageKey) {
return
}

if (window.localStorage.getItem(confettiStorageKey) === 'true') {
return
}

if (currentParticipant.hasConfirmedAssignment) {
Comment on lines +157 to +161
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

window.localStorage.getItem(...) can throw (e.g., storage disabled/quota exceeded). Since this effect is intended to be non-fatal (similar to the canvas-confetti import), please wrap the localStorage read in a try/catch (or use the existing useLocalStorage/safe-storage helper) so AssignmentView can’t error during reveal in hardened browser/privacy modes.

This issue also appears on line 165 of the same file.

Copilot uses AI. Check for mistakes.
return
}

lastTriggeredConfettiKeyRef.current = confettiStorageKey
window.localStorage.setItem(confettiStorageKey, 'true')

void import('canvas-confetti')
.then(({ default: confetti }) => {
confetti({
particleCount: 120,
spread: 80,
origin: { y: 0.65 }
})
})
.catch(() => {
// Ignore confetti loading errors to avoid breaking assignment view
})
}, [isRevealed, currentReceiver, game.code, participant.id, currentParticipant.hasConfirmedAssignment])

// Note: No mount-time refresh needed - game data is already loaded when entering this view
// refreshGameData is available for manual refresh via the refresh button only

Expand Down
Loading