diff --git a/packages/shared/src/components/filters/IntroQuestButton.spec.tsx b/packages/shared/src/components/filters/IntroQuestButton.spec.tsx
index 931716ad374..88ba7b43d7a 100644
--- a/packages/shared/src/components/filters/IntroQuestButton.spec.tsx
+++ b/packages/shared/src/components/filters/IntroQuestButton.spec.tsx
@@ -1,5 +1,5 @@
import React from 'react';
-import { act, render, screen } from '@testing-library/react';
+import { act, fireEvent, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useAuthContext } from '../../contexts/AuthContext';
import { useSettingsContext } from '../../contexts/SettingsContext';
@@ -160,6 +160,65 @@ describe('IntroQuestButton', () => {
expect(cta).toHaveAttribute('data-expanded', 'false');
});
+ it('shakes once the intro CTA collapses and then every 5 seconds', () => {
+ jest.useFakeTimers();
+
+ render();
+
+ const button = screen.getByRole('button', {
+ name: /Open introduction quests/,
+ });
+
+ expect(button).not.toHaveClass('animate-nudge-shake');
+
+ act(() => {
+ jest.advanceTimersByTime(2_500);
+ });
+
+ expect(button).toHaveClass('animate-nudge-shake');
+
+ act(() => {
+ jest.advanceTimersByTime(600);
+ });
+
+ expect(button).not.toHaveClass('animate-nudge-shake');
+
+ act(() => {
+ jest.advanceTimersByTime(4_400);
+ });
+
+ expect(button).toHaveClass('animate-nudge-shake');
+ });
+
+ it('stops shaking once the button is clicked', () => {
+ jest.useFakeTimers();
+
+ render();
+
+ const button = screen.getByRole('button', {
+ name: /Open introduction quests/,
+ });
+
+ act(() => {
+ jest.advanceTimersByTime(2_500);
+ });
+
+ expect(button).toHaveClass('animate-nudge-shake');
+
+ act(() => {
+ fireEvent.click(button);
+ });
+
+ expect(openModal).toHaveBeenCalledWith({ type: LazyModal.IntroQuests });
+ expect(button).not.toHaveClass('animate-nudge-shake');
+
+ act(() => {
+ jest.advanceTimersByTime(30_000);
+ });
+
+ expect(button).not.toHaveClass('animate-nudge-shake');
+ });
+
it('hides the badge after intro quests have been viewed and none are claimable', () => {
mockUseActions.mockReturnValue({
checkHasCompleted: jest.fn(
diff --git a/packages/shared/src/components/filters/IntroQuestButton.tsx b/packages/shared/src/components/filters/IntroQuestButton.tsx
index fff5da67b9f..ebe97b023e9 100644
--- a/packages/shared/src/components/filters/IntroQuestButton.tsx
+++ b/packages/shared/src/components/filters/IntroQuestButton.tsx
@@ -17,6 +17,9 @@ import { QuestStatus } from '../../graphql/quests';
const INTRO_QUEST_CTA = 'Get the most out of daily.dev';
const INTRO_QUEST_CTA_DURATION_MS = 2000;
+const INTRO_QUEST_CTA_COLLAPSE_MS = 500;
+const NUDGE_SHAKE_INTERVAL_MS = 5_000;
+const NUDGE_SHAKE_DURATION_MS = 600;
export function IntroQuestButton(): ReactElement | null {
const { isAuthReady, isLoggedIn } = useAuthContext();
@@ -37,7 +40,10 @@ export function IntroQuestButton(): ReactElement | null {
ActionType.IntroQuestsCompleted,
);
const [isIntroCtaVisible, setIsIntroCtaVisible] = useState(false);
+ const [hasIntroCtaFinished, setHasIntroCtaFinished] = useState(false);
+ const [isShaking, setIsShaking] = useState(false);
const hasShownIntroCta = useRef(false);
+ const hasClickedRef = useRef(false);
const shouldRenderButton =
isAuthReady &&
loadedSettings &&
@@ -56,14 +62,40 @@ export function IntroQuestButton(): ReactElement | null {
hasShownIntroCta.current = true;
setIsIntroCtaVisible(true);
- const timeout = setTimeout(
+ const visibilityTimeout = setTimeout(
() => setIsIntroCtaVisible(false),
INTRO_QUEST_CTA_DURATION_MS,
);
+ const finishedTimeout = setTimeout(
+ () => setHasIntroCtaFinished(true),
+ INTRO_QUEST_CTA_DURATION_MS + INTRO_QUEST_CTA_COLLAPSE_MS,
+ );
- return () => clearTimeout(timeout);
+ return () => {
+ clearTimeout(visibilityTimeout);
+ clearTimeout(finishedTimeout);
+ };
}, [shouldRenderButton]);
+ useEffect(() => {
+ if (!shouldRenderButton || !hasIntroCtaFinished || hasClickedRef.current) {
+ return undefined;
+ }
+
+ const triggerShake = () => {
+ if (hasClickedRef.current) {
+ return;
+ }
+ setIsShaking(true);
+ setTimeout(() => setIsShaking(false), NUDGE_SHAKE_DURATION_MS);
+ };
+
+ triggerShake();
+ const interval = setInterval(triggerShake, NUDGE_SHAKE_INTERVAL_MS);
+
+ return () => clearInterval(interval);
+ }, [shouldRenderButton, hasIntroCtaFinished]);
+
if (!shouldRenderButton) {
return null;
}
@@ -77,6 +109,12 @@ export function IntroQuestButton(): ReactElement | null {
const buttonLabel = `${completed}/${introQuests.length}`;
const buttonVariant = isLaptop ? ButtonVariant.Float : ButtonVariant.Tertiary;
+ const handleClick = () => {
+ hasClickedRef.current = true;
+ setIsShaking(false);
+ openModal({ type: LazyModal.IntroQuests });
+ };
+
return (
}
- className="relative"
- onClick={() => openModal({ type: LazyModal.IntroQuests })}
+ className={classNames(
+ 'relative',
+ isShaking && 'animate-nudge-shake motion-reduce:animate-none',
+ )}
+ onClick={handleClick}
aria-label={`Open introduction quests (${buttonLabel})${
showAttentionBadge ? ', attention needed' : ''
}`}
diff --git a/packages/shared/tailwind.config.ts b/packages/shared/tailwind.config.ts
index 368021dabbf..94e63624409 100644
--- a/packages/shared/tailwind.config.ts
+++ b/packages/shared/tailwind.config.ts
@@ -328,6 +328,14 @@ export default {
opacity: '0',
},
},
+ 'nudge-shake': {
+ '0%, 100%': { transform: 'translateX(0)' },
+ '15%': { transform: 'translateX(-4px) rotate(-3deg)' },
+ '30%': { transform: 'translateX(4px) rotate(3deg)' },
+ '45%': { transform: 'translateX(-3px) rotate(-2deg)' },
+ '60%': { transform: 'translateX(3px) rotate(2deg)' },
+ '75%': { transform: 'translateX(-2px) rotate(-1deg)' },
+ },
},
animation: {
'scale-down-pulse':
@@ -346,6 +354,7 @@ export default {
'tag-pop': 'tag-pop 0.45s cubic-bezier(0.34, 1.56, 0.64, 1) both',
'tag-spark': 'tag-spark 0.6s ease-out both',
'tag-fade-out': 'tag-fade-out 0.25s ease-in forwards',
+ 'nudge-shake': 'nudge-shake 600ms ease-in-out',
},
},
lineClamp: {