diff --git a/e2e/app.spec.ts b/e2e/app.spec.ts index d8907e1..f22f6a2 100644 --- a/e2e/app.spec.ts +++ b/e2e/app.spec.ts @@ -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('/') diff --git a/package-lock.json b/package-lock.json index 75c4c43..c02c6c4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,6 +39,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", @@ -3899,6 +3900,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/canvas-confetti": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/canvas-confetti/-/canvas-confetti-1.9.4.tgz", + "integrity": "sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw==", + "license": "ISC", + "funding": { + "type": "donate", + "url": "https://www.paypal.me/kirilvatev" + } + }, "node_modules/class-variance-authority": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", diff --git a/package.json b/package.json index 6c9da65..3ee11a8 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/AssignmentView.tsx b/src/components/AssignmentView.tsx index 32fed52..137eb5f 100644 --- a/src/components/AssignmentView.tsx +++ b/src/components/AssignmentView.tsx @@ -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' @@ -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(null) // Refresh game data from API const refreshGameData = useCallback(async () => { @@ -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) { + 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