diff --git a/packages/shared/src/components/quest/QuestButton.spec.tsx b/packages/shared/src/components/quest/QuestButton.spec.tsx index 773910f96c..30878f5d64 100644 --- a/packages/shared/src/components/quest/QuestButton.spec.tsx +++ b/packages/shared/src/components/quest/QuestButton.spec.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { useRouter } from 'next/router'; import { QueryClient } from '@tanstack/react-query'; -import { act, render, screen } from '@testing-library/react'; +import { act, render, screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import type { ReactElement, ReactNode } from 'react'; import { TestBootProvider } from '../../../__tests__/helpers/boot'; @@ -840,6 +840,149 @@ describe('QuestButton', () => { } }); + it('should reveal claimed stamps for quests claimed back-to-back', async () => { + jest.useFakeTimers(); + + try { + const readyQuestOne = { + rotationId: 'daily-quest-ready-one', + userQuestId: 'user-quest-ready-one', + progress: 3, + status: QuestStatus.Completed, + completedAt: new Date('2026-03-18T12:00:00.000Z'), + claimedAt: null, + locked: false, + claimable: true, + quest: { + id: 'quest-ready-one', + name: 'Ready quest one', + description: 'Read 3 posts today', + type: QuestType.Daily, + eventType: 'read_post', + targetCount: 3, + }, + rewards: [{ type: QuestRewardType.Xp, amount: 50 }], + }; + const readyQuestTwo = { + rotationId: 'daily-quest-ready-two', + userQuestId: 'user-quest-ready-two', + progress: 3, + status: QuestStatus.Completed, + completedAt: new Date('2026-03-18T12:10:00.000Z'), + claimedAt: null, + locked: false, + claimable: true, + quest: { + id: 'quest-ready-two', + name: 'Ready quest two', + description: 'Read 3 more posts today', + type: QuestType.Daily, + eventType: 'read_post', + targetCount: 3, + }, + rewards: [{ type: QuestRewardType.Xp, amount: 75 }], + }; + const claimedQuestOne = { + ...readyQuestOne, + status: QuestStatus.Claimed, + claimable: false, + claimedAt: new Date('2026-03-18T12:05:00.000Z'), + }; + const claimedQuestTwo = { + ...readyQuestTwo, + status: QuestStatus.Claimed, + claimable: false, + claimedAt: new Date('2026-03-18T12:15:00.000Z'), + }; + const dashboardAfterFirstClaim = { + ...questDashboard, + daily: { + ...questDashboard.daily, + regular: [claimedQuestOne, readyQuestTwo], + }, + }; + const dashboardAfterSecondClaim = { + ...questDashboard, + daily: { + ...questDashboard.daily, + regular: [claimedQuestOne, claimedQuestTwo], + }, + }; + let claimCount = 0; + + mockUseQuestDashboard.mockReturnValue({ + data: { + ...questDashboard, + daily: { + ...questDashboard.daily, + regular: [readyQuestOne, readyQuestTwo], + }, + }, + isPending: false, + isError: false, + }); + mockUseClaimQuestReward.mockReturnValue({ + mutate: jest.fn((_variables, callbacks) => { + claimCount += 1; + + const claimedDashboard = + claimCount === 1 + ? dashboardAfterFirstClaim + : dashboardAfterSecondClaim; + + mockUseQuestDashboard.mockReturnValue({ + data: claimedDashboard, + isPending: false, + isError: false, + }); + callbacks.onSuccess?.(claimedDashboard); + }), + isPending: false, + variables: undefined, + }); + + renderComponent(false); + + act(() => { + screen + .getByRole('button', { + name: /Quests, level 7, 63% progress/i, + }) + .click(); + }); + + act(() => { + /* eslint-disable testing-library/no-node-access */ + within( + screen.getByText('Ready quest one').closest('article') as HTMLElement, + ) + .getByRole('button', { name: 'Claim' }) + .click(); + /* eslint-enable testing-library/no-node-access */ + }); + + act(() => { + /* eslint-disable testing-library/no-node-access */ + within( + screen.getByText('Ready quest two').closest('article') as HTMLElement, + ) + .getByRole('button', { name: 'Claim' }) + .click(); + /* eslint-enable testing-library/no-node-access */ + }); + + expect(screen.queryByText('CLAIMED')).not.toBeInTheDocument(); + + act(() => { + jest.advanceTimersByTime(3200); + }); + + expect(screen.getAllByText('CLAIMED')).toHaveLength(2); + } finally { + jest.useRealTimers(); + } + }); + it('should explain plus quests are additional slots', async () => { mockUseQuestDashboard.mockReturnValue({ data: { diff --git a/packages/shared/src/components/quest/QuestButton.tsx b/packages/shared/src/components/quest/QuestButton.tsx index 3efa113aae..b44795e780 100644 --- a/packages/shared/src/components/quest/QuestButton.tsx +++ b/packages/shared/src/components/quest/QuestButton.tsx @@ -174,6 +174,11 @@ type QuestRewardFlight = QuestRewardSource & { delayMs: number; }; +type QuestRewardFlightLayerState = { + claimRotationId: string; + flights: QuestRewardFlight[]; +}; + type QuestLevelFireworkParticle = { id: string; x: number; @@ -574,7 +579,7 @@ const QuestSection = ({ onDestinationClick, showLevelSystem, claimingQuestId, - animatingClaimRotationId, + animatingClaimRotationIds, claimedStampRotationIds, animatingClaimedStampRotationIds, deferredClaimedStampRotationIds, @@ -592,7 +597,7 @@ const QuestSection = ({ onDestinationClick: (destination: QuestDestination) => void; showLevelSystem: boolean; claimingQuestId?: string; - animatingClaimRotationId?: string; + animatingClaimRotationIds: Set; claimedStampRotationIds: Set; animatingClaimedStampRotationIds: Set; deferredClaimedStampRotationIds: Set; @@ -619,7 +624,7 @@ const QuestSection = ({ onDestinationClick={onDestinationClick} showLevelSystem={showLevelSystem} isClaiming={claimingQuestId === quest.userQuestId} - isClaimAnimating={animatingClaimRotationId === quest.rotationId} + isClaimAnimating={animatingClaimRotationIds.has(quest.rotationId)} showClaimedStamp={claimedStampRotationIds.has(quest.rotationId)} animateClaimedStamp={animatingClaimedStampRotationIds.has( quest.rotationId, @@ -998,7 +1003,7 @@ interface QuestDropdownPanelProps { isPending: boolean; isError: boolean; claimingQuestId?: string; - animatingClaimRotationId?: string; + animatingClaimRotationIds: Set; claimedStampRotationIds: Set; animatingClaimedStampRotationIds: Set; deferredClaimedStampRotationIds: Set; @@ -1019,7 +1024,7 @@ const QuestDropdownPanel = ({ isPending, isError, claimingQuestId, - animatingClaimRotationId, + animatingClaimRotationIds, claimedStampRotationIds, animatingClaimedStampRotationIds, deferredClaimedStampRotationIds, @@ -1071,7 +1076,7 @@ const QuestDropdownPanel = ({ showLevelSystem={showLevelSystem} onDestinationClick={onDestinationClick} claimingQuestId={claimingQuestId} - animatingClaimRotationId={animatingClaimRotationId} + animatingClaimRotationIds={animatingClaimRotationIds} claimedStampRotationIds={claimedStampRotationIds} animatingClaimedStampRotationIds={ animatingClaimedStampRotationIds @@ -1085,7 +1090,7 @@ const QuestDropdownPanel = ({ showLevelSystem={showLevelSystem} onDestinationClick={onDestinationClick} claimingQuestId={claimingQuestId} - animatingClaimRotationId={animatingClaimRotationId} + animatingClaimRotationIds={animatingClaimRotationIds} claimedStampRotationIds={claimedStampRotationIds} animatingClaimedStampRotationIds={ animatingClaimedStampRotationIds @@ -1102,7 +1107,7 @@ const QuestDropdownPanel = ({ showLevelSystem={showLevelSystem} onDestinationClick={onDestinationClick} claimingQuestId={claimingQuestId} - animatingClaimRotationId={animatingClaimRotationId} + animatingClaimRotationIds={animatingClaimRotationIds} claimedStampRotationIds={claimedStampRotationIds} animatingClaimedStampRotationIds={ animatingClaimedStampRotationIds @@ -1119,7 +1124,7 @@ const QuestDropdownPanel = ({ showLevelSystem={showLevelSystem} onDestinationClick={onDestinationClick} claimingQuestId={claimingQuestId} - animatingClaimRotationId={animatingClaimRotationId} + animatingClaimRotationIds={animatingClaimRotationIds} claimedStampRotationIds={claimedStampRotationIds} animatingClaimedStampRotationIds={ animatingClaimedStampRotationIds @@ -1199,7 +1204,9 @@ export const QuestButton = ({ ].filter((quest) => quest.claimable).length; }, [data]); const claimingQuestId = isClaimPending ? variables?.userQuestId : undefined; - const [rewardFlights, setRewardFlights] = useState([]); + const [rewardFlightLayers, setRewardFlightLayers] = useState< + QuestRewardFlightLayerState[] + >([]); const [levelFireworkParticles, setLevelFireworkParticles] = useState< QuestLevelFireworkParticle[] >([]); @@ -1207,9 +1214,9 @@ export const QuestButton = ({ const [animatedLevelProgress, setAnimatedLevelProgress] = useState< number | null >(null); - const [animatingClaimRotationId, setAnimatingClaimRotationId] = useState< - string | null - >(null); + const [animatingClaimRotationIds, setAnimatingClaimRotationIds] = useState< + string[] + >([]); const [claimedStampRotationIds, setClaimedStampRotationIds] = useState< string[] >([]); @@ -1221,9 +1228,7 @@ export const QuestButton = ({ useState([]); const progressTimersRef = useRef([]); const claimedStampTimersRef = useRef([]); - const claimProgressSnapshotRef = useRef(null); - const claimLevelSnapshotRef = useRef(null); - const claimAnimationRotationIdRef = useRef(null); + const currentProgressAnimationRotationIdRef = useRef(null); const renderedLevel = animatedLevel ?? level; const renderedLevelProgress = animatedLevelProgress ?? levelProgress; const triggerTooltipContent = data?.level @@ -1244,6 +1249,10 @@ export const QuestButton = ({ () => new Set(claimedStampRotationIds), [claimedStampRotationIds], ); + const animatingClaimRotationIdSet = useMemo( + () => new Set(animatingClaimRotationIds), + [animatingClaimRotationIds], + ); const animatingClaimedStampRotationIdSet = useMemo( () => new Set(animatingClaimedStampRotationIds), [animatingClaimedStampRotationIds], @@ -1303,24 +1312,26 @@ export const QuestButton = ({ claimedStampTimersRef.current.push(revealTimerId); }, []); - const clearRewardFlights = useCallback(() => { - const completedClaimRotationId = claimAnimationRotationIdRef.current; - - clearProgressTimers(); - setRewardFlights([]); - setAnimatedLevel(null); - setAnimatedLevelProgress(null); - setAnimatingClaimRotationId(null); - claimProgressSnapshotRef.current = null; - claimLevelSnapshotRef.current = null; - claimAnimationRotationIdRef.current = null; - - if (!completedClaimRotationId) { - return; - } + const handleRewardFlightLayerDone = useCallback( + (claimRotationId: string) => { + setRewardFlightLayers((current) => + current.filter((layer) => layer.claimRotationId !== claimRotationId), + ); + setAnimatingClaimRotationIds((current) => + current.filter((id) => id !== claimRotationId), + ); - scheduleClaimedStampReveal(completedClaimRotationId); - }, [clearProgressTimers, scheduleClaimedStampReveal]); + if (currentProgressAnimationRotationIdRef.current === claimRotationId) { + clearProgressTimers(); + setAnimatedLevel(null); + setAnimatedLevelProgress(null); + currentProgressAnimationRotationIdRef.current = null; + } + + scheduleClaimedStampReveal(claimRotationId); + }, + [clearProgressTimers, scheduleClaimedStampReveal], + ); const clearLevelFireworkParticles = useCallback(() => { setLevelFireworkParticles([]); }, []); @@ -1499,8 +1510,14 @@ export const QuestButton = ({ const startProgress = animatedLevelProgress ?? levelProgress; const startLevel = animatedLevel ?? level; - setAnimatingClaimRotationId(claimRotationId); - claimAnimationRotationIdRef.current = claimRotationId; + setAnimatingClaimRotationIds((current) => { + if (current.includes(claimRotationId)) { + return current; + } + + return [...current, claimRotationId]; + }); + currentProgressAnimationRotationIdRef.current = claimRotationId; setClaimedStampRotationIds((current) => current.filter((id) => id !== claimRotationId), ); @@ -1514,8 +1531,6 @@ export const QuestButton = ({ return [...current, claimRotationId]; }); - claimProgressSnapshotRef.current = startProgress; - claimLevelSnapshotRef.current = startLevel; setAnimatedLevel(startLevel); setAnimatedLevelProgress(startProgress); @@ -1527,49 +1542,62 @@ export const QuestButton = ({ const nextFlights = buildQuestRewardFlights(rewardSources); if (!nextFlights.length) { - setAnimatedLevel(null); - setAnimatedLevelProgress(null); - setAnimatingClaimRotationId(null); - claimProgressSnapshotRef.current = null; - claimLevelSnapshotRef.current = null; - claimAnimationRotationIdRef.current = null; + if ( + currentProgressAnimationRotationIdRef.current === claimRotationId + ) { + setAnimatedLevel(null); + setAnimatedLevelProgress(null); + currentProgressAnimationRotationIdRef.current = null; + } + setAnimatingClaimRotationIds((current) => + current.filter((id) => id !== claimRotationId), + ); scheduleClaimedStampReveal(claimRotationId); return; } - setRewardFlights(nextFlights); - const initialProgress = - claimProgressSnapshotRef.current ?? levelProgress; - const initialLevel = claimLevelSnapshotRef.current ?? level; + setRewardFlightLayers((current) => [ + ...current.filter( + (layer) => layer.claimRotationId !== claimRotationId, + ), + { claimRotationId, flights: nextFlights }, + ]); const finalProgress = getLevelProgress(claimedDashboard.level); const finalLevel = claimedDashboard.level.level; scheduleLevelProgressByHits({ flights: nextFlights, finalProgress, - startProgress: initialProgress, + startProgress, finalLevel, - startLevel: initialLevel, + startLevel, }); scheduleRewardCounterByHits({ claimId, flights: nextFlights, rewardSources, }); - claimProgressSnapshotRef.current = null; - claimLevelSnapshotRef.current = null; }, onError: () => { - claimProgressSnapshotRef.current = null; - claimLevelSnapshotRef.current = null; - claimAnimationRotationIdRef.current = null; setDeferredClaimedStampRotationIds((current) => current.filter((id) => id !== claimRotationId), ); - clearProgressTimers(); - setAnimatedLevel(null); - setAnimatedLevelProgress(null); - setAnimatingClaimRotationId(null); + setRewardFlightLayers((current) => + current.filter( + (layer) => layer.claimRotationId !== claimRotationId, + ), + ); + setAnimatingClaimRotationIds((current) => + current.filter((id) => id !== claimRotationId), + ); + if ( + currentProgressAnimationRotationIdRef.current === claimRotationId + ) { + clearProgressTimers(); + setAnimatedLevel(null); + setAnimatedLevelProgress(null); + currentProgressAnimationRotationIdRef.current = null; + } }, }, ); @@ -1703,7 +1731,7 @@ export const QuestButton = ({ isPending={isPending} isError={isError} claimingQuestId={claimingQuestId} - animatingClaimRotationId={animatingClaimRotationId ?? undefined} + animatingClaimRotationIds={animatingClaimRotationIdSet} claimedStampRotationIds={claimedStampRotationIdSet} animatingClaimedStampRotationIds={ animatingClaimedStampRotationIdSet @@ -1717,12 +1745,13 @@ export const QuestButton = ({ - {rewardFlights.length > 0 && ( + {rewardFlightLayers.map((layer) => ( handleRewardFlightLayerDone(layer.claimRotationId)} /> - )} + ))} {levelFireworkParticles.length > 0 && (