- {t('featureGate.signIn')}
+ {isSigningIn ? (
+
+ )}
+ {t(isSigningIn ? 'auth.signing_in' : 'featureGate.signIn')}
{signInError ? (
diff --git a/src/components/SignInPromptModal.test.jsx b/src/components/SignInPromptModal.test.jsx
index f1d3758b..862cfd3a 100644
--- a/src/components/SignInPromptModal.test.jsx
+++ b/src/components/SignInPromptModal.test.jsx
@@ -78,6 +78,30 @@ describe('SignInPromptModal', () => {
expect(signInWithGoogle).toHaveBeenCalledTimes(1);
});
+ it('shows a loading state while signing in', async () => {
+ let resolveSignIn;
+ signInWithGoogle.mockReturnValue(
+ new Promise(resolve => {
+ resolveSignIn = resolve;
+ })
+ );
+ renderWithI18n(
+
+ );
+
+ fireEvent.click(screen.getByText('Sign in with Google'));
+
+ const button = screen.getByRole('button', { name: /signing in/i });
+ expect(button).toBeDisabled();
+ expect(button).toHaveAttribute('aria-busy', 'true');
+ expect(button.querySelector('.animate-spin')).toBeInTheDocument();
+
+ resolveSignIn();
+ await waitFor(() => {
+ expect(signInWithGoogle).toHaveBeenCalledTimes(1);
+ });
+ });
+
it('shows unavailable message when sign-in fails', async () => {
signInWithGoogle.mockRejectedValue({
message: 'Invalid payload sent to hook',
diff --git a/src/components/UserMenu.jsx b/src/components/UserMenu.jsx
index 94dfc0a9..b9c86e93 100644
--- a/src/components/UserMenu.jsx
+++ b/src/components/UserMenu.jsx
@@ -12,7 +12,7 @@ import {
menuExit,
menuTransition,
} from '@/motion/chromeMotion';
-import { CaretDown, Gear, SignOut } from '@phosphor-icons/react';
+import { CaretDown, Gear, SignOut, SpinnerGap } from '@phosphor-icons/react';
import { SiGoogle } from 'react-icons/si';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
@@ -112,27 +112,43 @@ function UserMenu({ variant = 'landing', hideAvatar = false }) {
}
if (!isAuthenticated || !profile) {
- const signInButtonClassName = `flex items-center justify-center bg-interactive-bg backdrop-blur-md rounded-md border border-interactive-border shadow-sm transition-colors duration-200 cursor-pointer touch-manipulation disabled:opacity-60 disabled:cursor-not-allowed ${
+ const signInButtonClassName = `flex items-center justify-center bg-interactive-bg backdrop-blur-md rounded-md border border-interactive-border shadow-sm transition-colors duration-200 cursor-pointer touch-manipulation ${
+ isSigningIn
+ ? 'disabled:cursor-wait'
+ : 'disabled:opacity-60 disabled:cursor-not-allowed'
+ } ${
isCompact
? 'h-8 w-8 sm:h-9 sm:w-9 p-0'
: 'gap-1.5 sm:gap-2 h-8 sm:h-9 px-2.5 sm:px-3 py-0 hover:shadow-md transition-all duration-200'
}`;
+ const signInLabel = isSigningIn
+ ? t('auth.signing_in')
+ : t('auth.sign_in_google');
if (isCompact) {
return (
);
diff --git a/src/components/landing/Hero.test.jsx b/src/components/landing/Hero.test.jsx
index 666d2ad1..26d58fab 100644
--- a/src/components/landing/Hero.test.jsx
+++ b/src/components/landing/Hero.test.jsx
@@ -4,11 +4,22 @@
* See LICENSE for details.
*/
-import { describe, it, expect, vi } from 'vitest';
-import { renderWithI18n, screen } from '../../test/testUtils';
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { renderWithProviders, screen } from '../../test/testUtils';
import { BrowserRouter } from 'react-router-dom';
+import i18n from '../../i18n';
import Hero from './Hero';
-// Mock UI components
+
+const reduceMotionRef = { current: false };
+
+vi.mock('framer-motion', async () => {
+ const { createFramerMotionMock } =
+ await import('../../test/framerMotionMock.jsx');
+ return createFramerMotionMock({
+ useReducedMotion: () => reduceMotionRef.current,
+ });
+});
+
vi.mock('../ui/Container', () => ({
default: ({ children, className = '' }) => (
@@ -25,8 +36,23 @@ vi.mock('../ui/Button', () => ({
),
}));
+vi.mock('./HeroVisualizerDemo', async () => {
+ const { useReducedMotion } = await import('framer-motion');
+ return {
+ default: function MockHeroVisualizerDemo() {
+ const reduceMotion = useReducedMotion();
+ return (
+
+ );
+ },
+ };
+});
+
const renderComponent = () => {
- return renderWithI18n(
+ return renderWithProviders(
@@ -34,6 +60,15 @@ const renderComponent = () => {
};
describe('Hero', () => {
+ beforeEach(async () => {
+ reduceMotionRef.current = false;
+ await i18n.changeLanguage('en');
+ });
+
+ afterEach(async () => {
+ await i18n.changeLanguage('en');
+ });
+
describe('Rendering', () => {
it('should render hero section', () => {
const { container } = renderComponent();
@@ -49,16 +84,16 @@ describe('Hero', () => {
it('should render title', () => {
renderComponent();
- // Title should be present (translated)
const heading = screen.getByRole('heading', { level: 1 });
expect(heading).toBeInTheDocument();
+ expect(heading).toHaveTextContent(i18n.t('landing.hero.title'));
});
it('should render subtitle', () => {
renderComponent();
- // Subtitle should be present
- const sections = screen.getAllByText(/./);
- expect(sections.length).toBeGreaterThan(0);
+ expect(
+ screen.getByText(i18n.t('landing.hero.subtitle'))
+ ).toBeInTheDocument();
});
it('should render CTA button', () => {
@@ -67,6 +102,49 @@ describe('Hero', () => {
expect(link).toBeInTheDocument();
expect(link).toHaveAttribute('href', '/app');
expect(link).toHaveAttribute('data-variant', 'cta');
+ expect(link).toHaveTextContent(i18n.t('landing.hero.cta'));
+ });
+
+ it('should render student outcome under the CTA', () => {
+ renderComponent();
+ expect(
+ screen.getByText(i18n.t('landing.hero.outcome'))
+ ).toBeInTheDocument();
+ });
+
+ it('should render HeroVisualizerDemo', () => {
+ renderComponent();
+ expect(screen.getByTestId('hero-visualizer-demo')).toBeInTheDocument();
+ });
+ });
+
+ describe('Locales', () => {
+ it('renders French hero copy', async () => {
+ await i18n.changeLanguage('fr');
+ renderComponent();
+ expect(
+ screen.getByText(i18n.t('landing.hero.subtitle'))
+ ).toBeInTheDocument();
+ expect(screen.getByRole('link')).toHaveTextContent(
+ i18n.t('landing.hero.cta')
+ );
+ expect(
+ screen.getByText(i18n.t('landing.hero.outcome'))
+ ).toBeInTheDocument();
+ });
+
+ it('renders Arabic hero copy', async () => {
+ await i18n.changeLanguage('ar');
+ renderComponent();
+ expect(
+ screen.getByText(i18n.t('landing.hero.subtitle'))
+ ).toBeInTheDocument();
+ expect(screen.getByRole('link')).toHaveTextContent(
+ i18n.t('landing.hero.cta')
+ );
+ expect(
+ screen.getByText(i18n.t('landing.hero.outcome'))
+ ).toBeInTheDocument();
});
});
@@ -81,6 +159,18 @@ describe('Hero', () => {
expect(section).toHaveClass('justify-center');
expect(section).toHaveClass('overflow-hidden');
});
+
+ it('uses a two-column layout with a dominant visualizer column', () => {
+ const { container } = renderComponent();
+ const layout = container.querySelector('div[class*="lg:flex-row"]');
+ expect(layout).toBeInTheDocument();
+ expect(
+ container.querySelector('div[class*="lg:w-[40%]"]')
+ ).toBeInTheDocument();
+ expect(
+ container.querySelector('div[class*="lg:w-[60%]"]')
+ ).toBeInTheDocument();
+ });
});
describe('Accessibility', () => {
@@ -97,12 +187,12 @@ describe('Hero', () => {
});
});
- describe('Content', () => {
- it('should render with translations', () => {
+ describe('Reduced motion', () => {
+ it('passes reduced motion into the demo as a static fallback', () => {
+ reduceMotionRef.current = true;
renderComponent();
- // Component should render even with translation keys
- expect(screen.getByRole('heading', { level: 1 })).toBeInTheDocument();
- expect(screen.getByRole('link')).toBeInTheDocument();
+ const demo = screen.getByTestId('hero-visualizer-demo');
+ expect(demo.getAttribute('data-reduced-motion')).toBe('true');
});
});
});
diff --git a/src/components/landing/HeroVisualizerDemo.jsx b/src/components/landing/HeroVisualizerDemo.jsx
new file mode 100644
index 00000000..bf0882c2
--- /dev/null
+++ b/src/components/landing/HeroVisualizerDemo.jsx
@@ -0,0 +1,98 @@
+/**
+ * Copyright (c) 2025 Bayan Flow
+ * Licensed under Elastic License 2.0 OR Commercial
+ * See LICENSE for details.
+ */
+
+import { useEffect, useMemo, useRef, useState } from 'react';
+import { useReducedMotion } from 'framer-motion';
+import ArrayVisualizer from '../ArrayVisualizer';
+import { useSortingVisualization } from '../../hooks/useSortingVisualization';
+import {
+ ALGORITHM_TYPES,
+ ELEMENT_STATES,
+ SORTING_ALGORITHMS,
+ VISUALIZATION_MODES,
+} from '../../constants';
+import { CATEGORY_CONFIG } from '../../registry/categoryConfig';
+import { HERO_DEMO_SIZE, HERO_STEP_MS } from './heroVisualizerDemoConfig';
+
+const HERO_ALGORITHM_KEY = SORTING_ALGORITHMS.BUBBLE_SORT;
+
+function HeroVisualizerDemo() {
+ const reduceMotion = useReducedMotion();
+ // Same generator as /app sorting (CATEGORY_CONFIG.generateData → generateRandomArray).
+ // Stable for the lifetime of this mount so playback does not reshuffle mid-run.
+ const [initialArray] = useState(() =>
+ CATEGORY_CONFIG[ALGORITHM_TYPES.SORTING].generateData(HERO_DEMO_SIZE)
+ );
+ const hasStartedRef = useRef(false);
+
+ const visualization = useSortingVisualization(
+ HERO_ALGORITHM_KEY,
+ initialArray,
+ HERO_STEP_MS,
+ VISUALIZATION_MODES.AUTOPLAY,
+ { enableSound: false }
+ );
+
+ const {
+ array,
+ states,
+ description,
+ isComplete,
+ play,
+ totalSteps,
+ currentStep,
+ } = visualization;
+
+ // Start autoplay once steps are ready (skip when reduced motion).
+ // Play once and stop; no replay loop.
+ useEffect(() => {
+ if (reduceMotion) return undefined;
+ if (hasStartedRef.current) return undefined;
+ if (totalSteps === 0) return undefined;
+
+ hasStartedRef.current = true;
+ play();
+ return undefined;
+ }, [reduceMotion, totalSteps, play]);
+
+ const staticSortedArray = useMemo(
+ () => [...initialArray].sort((a, b) => a - b),
+ [initialArray]
+ );
+ const staticSortedStates = useMemo(
+ () => Array(initialArray.length).fill(ELEMENT_STATES.SORTED),
+ [initialArray]
+ );
+
+ const displayArray = reduceMotion ? staticSortedArray : array;
+ const displayStates = reduceMotion ? staticSortedStates : states;
+
+ return (
+
+ );
+}
+
+export default HeroVisualizerDemo;
diff --git a/src/components/landing/HeroVisualizerDemo.test.jsx b/src/components/landing/HeroVisualizerDemo.test.jsx
new file mode 100644
index 00000000..c311ad94
--- /dev/null
+++ b/src/components/landing/HeroVisualizerDemo.test.jsx
@@ -0,0 +1,183 @@
+/**
+ * Copyright (c) 2025 Bayan Flow
+ * Licensed under Elastic License 2.0 OR Commercial
+ * See LICENSE for details.
+ */
+
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { screen, fireEvent, act, waitFor } from '@testing-library/react';
+import { renderWithProviders } from '../../test/testUtils';
+import HeroVisualizerDemo from './HeroVisualizerDemo';
+import { HERO_DEMO_SIZE, HERO_STEP_MS } from './heroVisualizerDemoConfig';
+import { ELEMENT_STATES, STATE_COLORS } from '../../constants';
+import i18n from '../../i18n';
+
+/** Deterministic stand-in for generateRandomArray in tests. */
+const MOCK_HERO_ARRAY = [42, 17, 88, 5, 63, 29];
+
+const reduceMotionRef = { current: false };
+
+vi.mock('framer-motion', async () => {
+ const { createFramerMotionMock } =
+ await import('../../test/framerMotionMock.jsx');
+ return createFramerMotionMock({
+ useReducedMotion: () => reduceMotionRef.current,
+ });
+});
+
+vi.mock('../../utils/arrayHelpers', async importOriginal => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ generateRandomArray: vi.fn(() => [...MOCK_HERO_ARRAY]),
+ };
+});
+
+function renderDemo() {
+ return renderWithProviders(
);
+}
+
+describe('HeroVisualizerDemo', () => {
+ beforeEach(async () => {
+ reduceMotionRef.current = false;
+ await i18n.changeLanguage('en');
+ vi.useFakeTimers({ shouldAdvanceTime: true });
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it('mounts with a random-sized demo array matching HERO_DEMO_SIZE', async () => {
+ renderDemo();
+
+ const demo = screen.getByTestId('hero-visualizer-demo');
+ expect(demo).toBeInTheDocument();
+ expect(demo.getAttribute('data-array-size')).toBe(String(HERO_DEMO_SIZE));
+ expect(HERO_DEMO_SIZE).toBe(6);
+
+ for (const value of MOCK_HERO_ARRAY) {
+ expect(screen.getByText(String(value))).toBeInTheDocument();
+ }
+ });
+
+ it('autoplays and advances steps on a timer', async () => {
+ renderDemo();
+ const demo = screen.getByTestId('hero-visualizer-demo');
+
+ await waitFor(() => {
+ expect(demo.getAttribute('data-current-step')).toBe('0');
+ });
+
+ await act(async () => {
+ vi.advanceTimersByTime(HERO_STEP_MS * 2);
+ });
+
+ await waitFor(() => {
+ expect(Number(demo.getAttribute('data-current-step'))).toBeGreaterThan(0);
+ });
+ });
+
+ it('does not render legend chrome but does show step captions', async () => {
+ const { container } = renderDemo();
+
+ expect(
+ screen.queryByLabelText(i18n.t('legend.show'))
+ ).not.toBeInTheDocument();
+
+ await waitFor(() => {
+ expect(container.querySelector('[role="status"]')).toBeInTheDocument();
+ });
+ });
+
+ it('ignores pointer interaction on the demo shell', async () => {
+ renderDemo();
+ const demo = screen.getByTestId('hero-visualizer-demo');
+
+ await waitFor(() => {
+ expect(demo.getAttribute('data-current-step')).toBe('0');
+ });
+
+ const stepBefore = demo.getAttribute('data-current-step');
+ fireEvent.click(demo);
+ fireEvent.pointerDown(demo);
+ fireEvent.mouseDown(demo);
+
+ expect(demo.getAttribute('data-current-step')).toBe(stepBefore);
+ expect(demo).toHaveClass('pointer-events-none');
+ });
+
+ it('stops after the first sort completes without replaying', async () => {
+ renderDemo();
+ const demo = screen.getByTestId('hero-visualizer-demo');
+
+ await waitFor(() => {
+ expect(demo.getAttribute('data-current-step')).toBe('0');
+ });
+
+ // Enough time for a 6-element bubble sort at FAST speed, plus buffer
+ await act(async () => {
+ vi.advanceTimersByTime(60 * HERO_STEP_MS);
+ });
+
+ await waitFor(() => {
+ expect(demo.getAttribute('data-complete')).toBe('true');
+ });
+
+ const stepAtComplete = Number(demo.getAttribute('data-current-step'));
+
+ await act(async () => {
+ vi.advanceTimersByTime(HERO_STEP_MS * 3);
+ });
+
+ expect(demo.getAttribute('data-complete')).toBe('true');
+ expect(Number(demo.getAttribute('data-current-step'))).toBe(stepAtComplete);
+ });
+
+ it('does not emit sound events during autoplay', async () => {
+ const { soundManager } = await import('../../utils/soundManager');
+ renderDemo();
+
+ await waitFor(() => {
+ expect(
+ screen
+ .getByTestId('hero-visualizer-demo')
+ .getAttribute('data-current-step')
+ ).toBe('0');
+ });
+
+ await act(async () => {
+ vi.advanceTimersByTime(HERO_STEP_MS * 5);
+ });
+
+ expect(soundManager.playEvents).not.toHaveBeenCalled();
+ });
+
+ it('renders a static settled sorted state when reduced motion is preferred', () => {
+ reduceMotionRef.current = true;
+ renderDemo();
+
+ const demo = screen.getByTestId('hero-visualizer-demo');
+ expect(demo.getAttribute('data-reduced-motion')).toBe('true');
+ expect(demo.getAttribute('data-current-step')).toBe('0');
+ expect(demo.getAttribute('data-complete')).toBe('false');
+
+ const sorted = [...MOCK_HERO_ARRAY].sort((a, b) => a - b);
+ for (const value of sorted) {
+ expect(screen.getByText(String(value))).toBeInTheDocument();
+ }
+
+ const sortedColor = STATE_COLORS[ELEMENT_STATES.SORTED];
+ const barsWithColor = Array.from(
+ document.querySelectorAll('[style*="background"]')
+ ).filter(el => {
+ const bg = el.style.backgroundColor;
+ return (
+ bg === sortedColor ||
+ bg === 'rgb(16, 185, 129)' ||
+ (typeof bg === 'string' && bg.includes('16, 185, 129'))
+ );
+ });
+ expect(barsWithColor.length).toBe(HERO_DEMO_SIZE);
+ });
+});
diff --git a/src/components/landing/LearnYourWay.jsx b/src/components/landing/LearnYourWay.jsx
deleted file mode 100644
index a45c8f34..00000000
--- a/src/components/landing/LearnYourWay.jsx
+++ /dev/null
@@ -1,112 +0,0 @@
-/**
- * Copyright (c) 2025 Bayan Flow
- * Licensed under Elastic License 2.0 OR Commercial
- * See LICENSE for details.
- */
-
-import { motion, useReducedMotion } from 'framer-motion';
-import { Play, Hand } from '@phosphor-icons/react';
-import { useTranslation } from 'react-i18next';
-import Container from '../ui/Container';
-import Section from '../ui/Section';
-import { marketingEnter, HOVER_SPRING } from '../../motion/chromeMotion';
-
-function LearnYourWay() {
- const { t } = useTranslation();
- const reduceMotion = useReducedMotion();
-
- const features = [
- {
- icon: Play,
- title: t('landing.learnYourWay.autoPlay.title'),
- description: t('landing.learnYourWay.autoPlay.description'),
- gradient: 'from-blue-500 to-cyan-500',
- },
- {
- icon: Hand,
- title: t('landing.learnYourWay.manual.title'),
- description: t('landing.learnYourWay.manual.description'),
- gradient: 'from-purple-500 to-pink-500',
- },
- ];
-
- return (
-
-
-
-
- {t('landing.learnYourWay.heading')}
-
-
-
-
- {features.map((feature, index) => (
-
- {/* Glass morphism card */}
-
- {/* Gradient glow on hover */}
-
-
- {/* Animated border glow */}
-
-
-
- {/* Icon with gradient and pulse */}
-
-
-
-
- {/* Content */}
-
-
- {feature.title}
-
-
- {feature.description}
-
-
-
-
-
- ))}
-
-
-
- {t('landing.learnYourWay.tagline')}
-
-
-
- );
-}
-
-export default LearnYourWay;
diff --git a/src/components/landing/LearnYourWay.test.jsx b/src/components/landing/LearnYourWay.test.jsx
deleted file mode 100644
index 76f408ea..00000000
--- a/src/components/landing/LearnYourWay.test.jsx
+++ /dev/null
@@ -1,308 +0,0 @@
-/**
- * Copyright (c) 2025 Bayan Flow
- * Licensed under Elastic License 2.0 OR Commercial
- * See LICENSE for details.
- */
-
-import { describe, it, expect, vi } from 'vitest';
-import { renderWithI18n, screen } from '../../test/testUtils';
-import { BrowserRouter } from 'react-router-dom';
-import LearnYourWay from './LearnYourWay';
-// Mock UI components
-vi.mock('../ui/Container', () => ({
- default: ({ children }) =>
{children}
,
-}));
-
-vi.mock('../ui/Section', () => ({
- default: ({ children, className = '' }) => (
-
- ),
-}));
-
-// Mock icons
-vi.mock('@phosphor-icons/react', () => ({
- Play: () =>
,
- Hand: () =>
,
-}));
-
-const renderComponent = () => {
- return renderWithI18n(
-
-
-
- );
-};
-
-describe('LearnYourWay', () => {
- describe('Rendering', () => {
- it('should render section element', () => {
- const { container } = renderComponent();
- expect(container.querySelector('section')).toBeInTheDocument();
- });
-
- it('should render main heading', () => {
- renderComponent();
- expect(screen.getByText(/Learn Your Way/i)).toBeInTheDocument();
- });
-
- it('should render autoplay feature', () => {
- renderComponent();
- expect(screen.getByText(/Auto-Play Mode/i)).toBeInTheDocument();
- });
-
- it('should render manual feature', () => {
- renderComponent();
- expect(screen.getByText(/Manual Control/i)).toBeInTheDocument();
- });
-
- it('should render both feature icons', () => {
- renderComponent();
- expect(screen.getByTestId('play-icon')).toBeInTheDocument();
- expect(screen.getByTestId('hand-icon')).toBeInTheDocument();
- });
-
- it('should display feature descriptions', () => {
- renderComponent();
- expect(
- screen.getByText(/Sit back and watch algorithms/i)
- ).toBeInTheDocument();
- expect(
- screen.getByText(/Step through each operation/i)
- ).toBeInTheDocument();
- });
- });
-
- describe('Layout', () => {
- it('should have two feature cards', () => {
- const { container } = renderComponent();
- const cards = container.querySelectorAll('div[class*="group"]');
- expect(cards.length).toBeGreaterThanOrEqual(2);
- });
-
- it('should apply grid layout', () => {
- const { container } = renderComponent();
- const grid = container.querySelector('div[class*="grid"]');
- expect(grid).toBeInTheDocument();
- expect(grid).toHaveClass('md:grid-cols-2');
- });
-
- it('should have proper gap between cards', () => {
- const { container } = renderComponent();
- const grid = container.querySelector('div[class*="gap-8"]');
- expect(grid).toBeInTheDocument();
- });
- });
-
- describe('Styling', () => {
- it('should apply section styling', () => {
- const { container } = renderComponent();
- const section = container.querySelector('section');
- expect(section).toHaveClass('relative', 'overflow-hidden');
- });
-
- it('should apply glass morphism to cards', () => {
- const { container } = renderComponent();
- const cards = container.querySelectorAll(
- 'div[class*="backdrop-blur-xl"]'
- );
- expect(cards.length).toBeGreaterThan(0);
- });
-
- it('should apply gradient effects', () => {
- const { container } = renderComponent();
- const gradients = container.querySelectorAll(
- 'div[class*="bg-linear-to-br"]'
- );
- expect(gradients.length).toBeGreaterThan(0);
- });
-
- it('should apply hover effects to cards', () => {
- const { container } = renderComponent();
- const hoverElements = container.querySelectorAll(
- 'div[class*="hover:shadow-2xl"]'
- );
- expect(hoverElements.length).toBeGreaterThan(0);
- });
-
- it('should have rounded corners', () => {
- const { container } = renderComponent();
- const rounded = container.querySelectorAll('div[class*="rounded-2xl"]');
- expect(rounded.length).toBeGreaterThan(0);
- });
- });
-
- describe('Icon Display', () => {
- it('should display play icon for autoplay', () => {
- renderComponent();
- expect(screen.getByTestId('play-icon')).toBeInTheDocument();
- });
-
- it('should display hand icon for manual', () => {
- renderComponent();
- expect(screen.getByTestId('hand-icon')).toBeInTheDocument();
- });
-
- it('should have icon containers', () => {
- const { container } = renderComponent();
- const iconContainers = container.querySelectorAll(
- 'div[class*="rounded-2xl"][class*="flex"]'
- );
- expect(iconContainers.length).toBeGreaterThan(0);
- });
-
- it('should style icons with gradients', () => {
- const { container } = renderComponent();
- const styledIcons = container.querySelectorAll(
- 'svg[class*="text-white"]'
- );
- expect(styledIcons.length).toBeGreaterThan(0);
- });
- });
-
- describe('Text Content', () => {
- it('should have proper heading hierarchy', () => {
- const { container } = renderComponent();
- const h2 = container.querySelector('h2');
- expect(h2).toBeInTheDocument();
- expect(h2).toHaveClass('landing-h2');
- });
-
- it('should have card titles', () => {
- const { container } = renderComponent();
- const h3s = container.querySelectorAll('h3');
- expect(h3s.length).toBeGreaterThanOrEqual(2);
- });
-
- it('should have descriptive text', () => {
- const { container } = renderComponent();
- const paragraphs = container.querySelectorAll('p');
- expect(paragraphs.length).toBeGreaterThan(0);
- });
-
- it('should display feature benefits', () => {
- const { container } = renderComponent();
- const benefits = container.querySelectorAll('li');
- expect(benefits.length).toBeGreaterThanOrEqual(0);
- });
- });
-
- describe('Animation Properties', () => {
- it('should have animation on heading', () => {
- const { container } = renderComponent();
- expect(container.querySelector('h2')).toBeInTheDocument();
- });
-
- it('should have animation on cards', () => {
- const { container } = renderComponent();
- const cards = container.querySelectorAll('div[class*="group"]');
- expect(cards.length).toBeGreaterThanOrEqual(2);
- });
-
- it('should have shimmer effect', () => {
- const { container } = renderComponent();
- const shimmers = container.querySelectorAll(
- 'div[class*="bg-linear-to-br"]'
- );
- expect(shimmers.length).toBeGreaterThan(0);
- });
-
- it('should have glow effects', () => {
- const { container } = renderComponent();
- const glows = container.querySelectorAll('div[class*="blur-xl"]');
- expect(glows.length).toBeGreaterThan(0);
- });
- });
-
- describe('Card Structure', () => {
- it('should have proper card hierarchy', () => {
- const { container } = renderComponent();
- const cards = container.querySelectorAll('div[class*="rounded-2xl"]');
- expect(cards.length).toBeGreaterThanOrEqual(2);
- });
-
- it('should have card shadows', () => {
- const { container } = renderComponent();
- const shadows = container.querySelectorAll('div[class*="shadow-lg"]');
- expect(shadows.length).toBeGreaterThan(0);
- });
-
- it('should have card borders', () => {
- const { container } = renderComponent();
- const bordered = container.querySelectorAll('div[class*="border-white"]');
- expect(bordered.length).toBeGreaterThan(0);
- });
-
- it('should have group hover states', () => {
- const { container } = renderComponent();
- const groups = container.querySelectorAll('div[class*="group"]');
- expect(groups.length).toBeGreaterThanOrEqual(2);
- });
- });
-
- describe('Responsive Design', () => {
- it('should be single column on mobile', () => {
- const { container } = renderComponent();
- // Grid applies md:grid-cols-2, so mobile is single column
- const grid = container.querySelector('div[class*="md:grid-cols-2"]');
- expect(grid).toBeInTheDocument();
- });
-
- it('should be two columns on desktop', () => {
- const { container } = renderComponent();
- const grid = container.querySelector('div[class*="md:grid-cols-2"]');
- expect(grid).toHaveClass('grid', 'md:grid-cols-2');
- });
-
- it('should have proper spacing', () => {
- const { container } = renderComponent();
- const grid = container.querySelector('div[class*="gap-8"]');
- expect(grid).toBeInTheDocument();
- });
- });
-
- describe('Accessibility', () => {
- it('should have semantic section', () => {
- const { container } = renderComponent();
- expect(container.querySelector('section')).toBeInTheDocument();
- });
-
- it('should have proper heading order', () => {
- const { container } = renderComponent();
- const h2 = container.querySelector('h2');
- const h3s = container.querySelectorAll('h3');
- expect(h2).toBeInTheDocument();
- expect(h3s.length).toBeGreaterThanOrEqual(2);
- });
-
- it('should have readable text contrast', () => {
- const { container } = renderComponent();
- const textElements = container.querySelectorAll('[class*="text-text-"]');
- expect(textElements.length).toBeGreaterThan(0);
- });
-
- it('should have proper color contrast for white text', () => {
- const { container } = renderComponent();
- const whiteText = container.querySelectorAll('svg[class*="text-white"]');
- expect(whiteText.length).toBeGreaterThan(0);
- });
- });
-
- describe('Edge Cases', () => {
- it('should render with all content', () => {
- const { container } = renderComponent();
- expect(container.querySelector('section')).toBeInTheDocument();
- });
-
- it('should handle long feature titles', () => {
- renderComponent();
- expect(screen.getByText(/Auto-Play Mode/i)).toBeInTheDocument();
- });
-
- it('should render even without extra content', () => {
- const { container } = renderComponent();
- // Core structure should always be present
- expect(container.querySelector('section')).toBeInTheDocument();
- expect(container.querySelector('h2')).toBeInTheDocument();
- });
- });
-});
diff --git a/src/components/landing/ProPreview.jsx b/src/components/landing/ProPreview.jsx
new file mode 100644
index 00000000..c6f53618
--- /dev/null
+++ b/src/components/landing/ProPreview.jsx
@@ -0,0 +1,174 @@
+/**
+ * Copyright (c) 2025 Bayan Flow
+ * Licensed under Elastic License 2.0 OR Commercial
+ * See LICENSE for details.
+ */
+
+import { useState } from 'react';
+import { motion, useReducedMotion } from 'framer-motion';
+import { useTranslation } from 'react-i18next';
+import { Check, X } from '@phosphor-icons/react';
+import Container from '../ui/Container';
+import Section from '../ui/Section';
+import Button from '../ui/Button';
+import { marketingEnter } from '../../motion/chromeMotion';
+import { WAITLIST_SOURCES } from '@/constants/waitlist';
+import { readStoredWaitlistEmail } from '@/services/waitlistService';
+
+// Feature rows in the Free vs Pro comparison table.
+// `free` / `pro` per row: `true` → green check, `false` → red x,
+// a string → i18n key for a text value (`landing.proPreview.table.
`).
+// The shared (check/check) rows mirror the Features-section cards above.
+// The video export row uses text cells (not icons): Free is unlimited with a
+// watermark, Pro is watermark-free. No daily export count is advertised.
+const TABLE_ROWS = [
+ { key: 'allAlgorithms', free: true, pro: true },
+ { key: 'customization', free: true, pro: true },
+ { key: 'pythonCode', free: true, pro: true },
+ { key: 'sound', free: true, pro: true },
+ { key: 'fullscreen', free: true, pro: true },
+ { key: 'insight', free: true, pro: true },
+ { key: 'pseudocode', free: true, pro: true },
+ { key: 'notes', free: true, pro: true },
+ { key: 'videoExport', free: 'videoExportFree', pro: 'videoExportPro' },
+ { key: 'customInput', free: false, pro: true },
+ { key: 'comparison', free: false, pro: true },
+ { key: 'presentation', free: false, pro: true },
+];
+
+function ProPreview() {
+ const { t } = useTranslation();
+ const reduceMotion = useReducedMotion();
+ const [joined] = useState(() => !!readStoredWaitlistEmail());
+
+ const renderCell = (value, isPro) => {
+ if (value === true) {
+ return (
+
+
+
+ );
+ }
+ if (value === false) {
+ return (
+
+
+
+ );
+ }
+ return (
+
+ {t(`landing.proPreview.table.${value}`)}
+
+ );
+ };
+
+ return (
+
+ {/* Subtle transition glow */}
+
+
+
+
+
+ {joined
+ ? t('landing.proPreview.badgeJoined')
+ : t('landing.proPreview.badge')}
+
+
+ {t('landing.proPreview.heading')}
+
+
+ {t('landing.proPreview.subheading')}
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+ {t('landing.proPreview.table.feature')}
+ |
+
+ {t('landing.proPreview.table.freeLabel')}
+ |
+
+ {t('landing.proPreview.table.proLabel')}
+ |
+
+
+
+ {TABLE_ROWS.map(row => (
+
+ |
+ {t(`landing.proPreview.table.${row.key}`)}
+ |
+
+ {renderCell(row.free, false)}
+ |
+
+ {renderCell(row.pro, true)}
+ |
+
+ ))}
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export default ProPreview;
diff --git a/src/components/landing/ProPreview.test.jsx b/src/components/landing/ProPreview.test.jsx
new file mode 100644
index 00000000..89b652b6
--- /dev/null
+++ b/src/components/landing/ProPreview.test.jsx
@@ -0,0 +1,333 @@
+/**
+ * Copyright (c) 2025 Bayan Flow
+ * Licensed under Elastic License 2.0 OR Commercial
+ * See LICENSE for details.
+ */
+
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { renderWithI18n, screen } from '../../test/testUtils';
+import { BrowserRouter } from 'react-router-dom';
+import i18n from '../../i18n';
+import ProPreview from './ProPreview';
+import { WAITLIST_EMAIL_STORAGE_KEY } from '@/constants/waitlist';
+
+// Mock UI components
+vi.mock('../ui/Container', () => ({
+ default: ({ children }) => {children}
,
+}));
+
+vi.mock('../ui/Section', () => ({
+ default: ({ children, className = '' }) => (
+
+ ),
+}));
+
+vi.mock('../ui/Button', () => ({
+ default: ({ children, variant, to, href, ...props }) => {
+ if (to || href) {
+ return (
+
+ {children}
+
+ );
+ }
+ return (
+
+ );
+ },
+}));
+
+const renderComponent = () => {
+ return renderWithI18n(
+
+
+
+ );
+};
+
+const includedIcons = container =>
+ [...container.querySelectorAll('svg')].filter(
+ el => el.getAttribute('aria-label') === 'Included'
+ );
+const excludedIcons = container =>
+ [...container.querySelectorAll('svg')].filter(
+ el => el.getAttribute('aria-label') === 'Not included'
+ );
+
+describe('ProPreview', () => {
+ beforeEach(async () => {
+ localStorage.clear();
+ await i18n.changeLanguage('en');
+ });
+
+ afterEach(() => {
+ localStorage.clear();
+ });
+
+ describe('Rendering', () => {
+ it('should render section element', () => {
+ const { container } = renderComponent();
+ expect(container.querySelector('section')).toBeInTheDocument();
+ });
+
+ it('should render heading', () => {
+ renderComponent();
+ expect(screen.getByText(/Go Further with Pro/i)).toBeInTheDocument();
+ });
+
+ it('should render subheading', () => {
+ renderComponent();
+ expect(
+ screen.getByText(/Built for interview prep and classroom teaching/i)
+ ).toBeInTheDocument();
+ });
+
+ it('should render plan labels', () => {
+ renderComponent();
+ expect(screen.getByText('Free')).toBeInTheDocument();
+ expect(screen.getByText('Pro')).toBeInTheDocument();
+ });
+ });
+
+ describe('Comparison Table', () => {
+ it('should render a table with fixed layout and equal plan columns', () => {
+ const { container } = renderComponent();
+ const table = container.querySelector('table');
+ expect(table).toBeInTheDocument();
+ expect(table).toHaveClass('table-fixed');
+ expect(table.querySelector('thead')).toBeInTheDocument();
+ expect(table.querySelector('tbody')).toBeInTheDocument();
+ const cols = table.querySelectorAll('col');
+ expect(cols.length).toBe(3);
+ expect(cols[1].className).toContain('w-1/4');
+ expect(cols[2].className).toContain('w-1/4');
+ });
+
+ it('should render feature header column', () => {
+ renderComponent();
+ expect(screen.getByText('Feature')).toBeInTheDocument();
+ });
+
+ it('should render all feature phrases once', () => {
+ renderComponent();
+ expect(screen.getByText('Visualize All Algorithms')).toBeInTheDocument();
+ expect(
+ screen.getByText(
+ 'Customizable: Adjust Array Size (5-100) or Grid Size (15×15 to 35×35)'
+ )
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText('Interactive Python Online Editor')
+ ).toBeInTheDocument();
+ expect(screen.getByText('Interactive Sound')).toBeInTheDocument();
+ expect(screen.getByText('Full Screen Mode')).toBeInTheDocument();
+ expect(screen.getByText('Algorithm Insight')).toBeInTheDocument();
+ expect(screen.getByText('Video Export')).toBeInTheDocument();
+ expect(screen.getByText('Custom Inputs')).toBeInTheDocument();
+ expect(
+ screen.getByText('Algorithms Comparison Mode')
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText('Presentation Mode (Dedicated For Teachers)')
+ ).toBeInTheDocument();
+ expect(screen.getByText('Step-by-step Pseudocode')).toBeInTheDocument();
+ expect(
+ screen.getByText('Save Your Notes on Every Algorithm')
+ ).toBeInTheDocument();
+ });
+
+ it('should render check icon for features on both plans', () => {
+ const { container } = renderComponent();
+ const checks = includedIcons(container);
+ expect(checks.length).toBe(19);
+ checks.forEach(check => {
+ expect(check).toHaveClass('text-emerald-500');
+ });
+ });
+
+ it('should render x icon for features missing on the free plan', () => {
+ const { container } = renderComponent();
+ const crosses = excludedIcons(container);
+ expect(crosses.length).toBe(3);
+ crosses.forEach(cross => {
+ expect(cross).toHaveClass('text-red-500');
+ });
+ });
+
+ it('should mark free features as included on both plans', () => {
+ const { container } = renderComponent();
+ const rows = [...container.querySelectorAll('tbody tr')];
+ const pythonRow = rows.find(tr =>
+ tr.textContent.includes('Interactive Python')
+ );
+ const pythonCells = pythonRow.querySelectorAll('td');
+ expect(
+ pythonCells[1].querySelector('svg').getAttribute('aria-label')
+ ).toBe('Included');
+ expect(
+ pythonCells[2].querySelector('svg').getAttribute('aria-label')
+ ).toBe('Included');
+ });
+
+ it('should mark pseudocode and notes as included on both plans', () => {
+ const { container } = renderComponent();
+ const rows = [...container.querySelectorAll('tbody tr')];
+ ['Step-by-step Pseudocode', 'Save Your Notes on Every Algorithm'].forEach(
+ label => {
+ const row = rows.find(tr => tr.textContent.includes(label));
+ const cells = row.querySelectorAll('td');
+ expect(cells[1].querySelector('svg').getAttribute('aria-label')).toBe(
+ 'Included'
+ );
+ expect(cells[2].querySelector('svg').getAttribute('aria-label')).toBe(
+ 'Included'
+ );
+ }
+ );
+ });
+
+ it('should render video export cells as text instead of icons', () => {
+ renderComponent();
+ expect(
+ screen.getByText('HD MP4 Exports (With Watermark)')
+ ).toBeInTheDocument();
+ expect(screen.getByText('Watermark-Free Exports')).toBeInTheDocument();
+ });
+
+ it('should not render any price', () => {
+ renderComponent();
+ expect(screen.queryByText('Free forever')).not.toBeInTheDocument();
+ expect(screen.queryByText('PRICING_TBD')).not.toBeInTheDocument();
+ });
+
+ it('should mark presentation mode as a pro-only feature', () => {
+ const { container } = renderComponent();
+ const presentationRow = [...container.querySelectorAll('tbody tr')].find(
+ tr =>
+ tr.textContent.includes('Presentation Mode (Dedicated For Teachers)')
+ );
+ const cells = presentationRow.querySelectorAll('td');
+ expect(cells[1].querySelector('svg').getAttribute('aria-label')).toBe(
+ 'Not included'
+ );
+ expect(cells[2].querySelector('svg').getAttribute('aria-label')).toBe(
+ 'Included'
+ );
+ });
+
+ it('should render pro features as included', () => {
+ const { container } = renderComponent();
+ const proColumnCells = [...container.querySelectorAll('tbody tr')].map(
+ tr => tr.querySelectorAll('td')[2]
+ );
+ expect(proColumnCells.length).toBe(12);
+ });
+ });
+
+ describe('Waitlist State', () => {
+ it('should show coming soon badge when not joined', () => {
+ renderComponent();
+ expect(screen.getByText('Coming Soon')).toBeInTheDocument();
+ });
+
+ it('should show joined badge when waitlist email is stored', () => {
+ localStorage.setItem(WAITLIST_EMAIL_STORAGE_KEY, 'test@example.com');
+ renderComponent();
+ expect(screen.getByText("You're on the list")).toBeInTheDocument();
+ expect(screen.getByText('View Your Spot')).toBeInTheDocument();
+ });
+ });
+
+ describe('CTA', () => {
+ it('should render CTA link with cta variant', () => {
+ renderComponent();
+ const link = screen.getByRole('link');
+ expect(link).toHaveAttribute('data-variant', 'cta');
+ });
+
+ it('should show waitlist CTA when not joined', () => {
+ renderComponent();
+ expect(screen.getByText('Join The Waitlist')).toBeInTheDocument();
+ expect(screen.queryByText('View Your Spot')).not.toBeInTheDocument();
+ });
+
+ it('should link to pro page with landing source', () => {
+ renderComponent();
+ const link = screen.getByRole('link');
+ expect(link).toHaveAttribute('href', '/pro?source=landing');
+ });
+
+ it('should not nest a button inside the link', () => {
+ renderComponent();
+ const link = screen.getByRole('link');
+ expect(link.querySelector('button')).not.toBeInTheDocument();
+ });
+ });
+
+ describe('Styling', () => {
+ it('should apply section styling', () => {
+ const { container } = renderComponent();
+ const section = container.querySelector('section');
+ expect(section).toHaveClass('relative', 'overflow-hidden');
+ });
+
+ it('should apply glass morphism to the table card', () => {
+ const { container } = renderComponent();
+ const card = container.querySelectorAll('div[class*="backdrop-blur-xl"]');
+ expect(card.length).toBe(1);
+ });
+
+ it('should have gradient background layer', () => {
+ const { container } = renderComponent();
+ const gradient = container.querySelector('div[class*="bg-linear-to-b"]');
+ expect(gradient).toBeInTheDocument();
+ });
+ });
+
+ describe('Text Hierarchy', () => {
+ it('should render h2 heading', () => {
+ const { container } = renderComponent();
+ const h2 = container.querySelector('h2');
+ expect(h2).toBeInTheDocument();
+ expect(h2).toHaveClass('landing-h2');
+ });
+
+ it('should display body text with proper styling', () => {
+ const { container } = renderComponent();
+ const bodyText = container.querySelector('p[class*="landing-body"]');
+ expect(bodyText).toBeInTheDocument();
+ });
+
+ it('should have proper color contrast for text', () => {
+ const { container } = renderComponent();
+ const textElements = container.querySelectorAll('[class*="text-text-"]');
+ expect(textElements.length).toBeGreaterThan(0);
+ });
+ });
+
+ describe('Accessibility', () => {
+ it('should have semantic section element', () => {
+ const { container } = renderComponent();
+ expect(container.querySelector('section')).toBeInTheDocument();
+ });
+
+ it('should have accessible table headers', () => {
+ const { container } = renderComponent();
+ const headers = container.querySelectorAll('th');
+ expect(headers.length).toBe(3);
+ });
+
+ it('should have accessible link', () => {
+ renderComponent();
+ expect(screen.getByRole('link')).toBeInTheDocument();
+ });
+
+ it('should render a single interactive element (no nested button)', () => {
+ renderComponent();
+ expect(screen.queryByRole('button')).not.toBeInTheDocument();
+ expect(screen.getByRole('link')).toBeInTheDocument();
+ });
+ });
+});
diff --git a/src/components/landing/SocialProofStrip.jsx b/src/components/landing/SocialProofStrip.jsx
new file mode 100644
index 00000000..a166b81f
--- /dev/null
+++ b/src/components/landing/SocialProofStrip.jsx
@@ -0,0 +1,44 @@
+/**
+ * Copyright (c) 2025 Bayan Flow
+ * Licensed under Elastic License 2.0 OR Commercial
+ * See LICENSE for details.
+ */
+
+import { useTranslation } from 'react-i18next';
+import Container from '../ui/Container';
+
+const PROOF_ITEM_KEYS = ['algorithms', 'locales', 'depth'];
+
+/**
+ * Honest product signals only — no fake logos or inflated metrics.
+ * Mounted from LandingPage only when SHOW_LANDING_SOCIAL_PROOF is true.
+ */
+function SocialProofStrip() {
+ const { t } = useTranslation();
+
+ return (
+
+
+
+ {PROOF_ITEM_KEYS.map(key => (
+ -
+
+ {t(`landing.socialProof.items.${key}.value`)}
+
+ {t(`landing.socialProof.items.${key}.label`)}
+
+ ))}
+
+
+
+ );
+}
+
+export default SocialProofStrip;
diff --git a/src/components/landing/SocialProofStrip.test.jsx b/src/components/landing/SocialProofStrip.test.jsx
new file mode 100644
index 00000000..4cbeae92
--- /dev/null
+++ b/src/components/landing/SocialProofStrip.test.jsx
@@ -0,0 +1,49 @@
+/**
+ * Copyright (c) 2025 Bayan Flow
+ * Licensed under Elastic License 2.0 OR Commercial
+ * See LICENSE for details.
+ */
+
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import { renderWithProviders, screen } from '../../test/testUtils';
+import i18n from '../../i18n';
+import SocialProofStrip from './SocialProofStrip';
+import { SHOW_LANDING_SOCIAL_PROOF } from './landingSocialProof';
+
+describe('landingSocialProof', () => {
+ it('keeps the strip gated off until real proof exists', () => {
+ expect(SHOW_LANDING_SOCIAL_PROOF).toBe(false);
+ });
+});
+
+describe('SocialProofStrip', () => {
+ beforeEach(async () => {
+ await i18n.changeLanguage('en');
+ });
+
+ afterEach(async () => {
+ await i18n.changeLanguage('en');
+ });
+
+ it('renders honest product signals without fake metrics', () => {
+ renderWithProviders();
+
+ expect(screen.getByTestId('social-proof-strip')).toBeInTheDocument();
+ expect(
+ screen.getByText(i18n.t('landing.socialProof.items.algorithms.value'))
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText(i18n.t('landing.socialProof.items.locales.value'))
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText(i18n.t('landing.socialProof.items.depth.value'))
+ ).toBeInTheDocument();
+ });
+
+ it('exposes an accessible region label', () => {
+ renderWithProviders();
+ expect(
+ screen.getByLabelText(i18n.t('landing.socialProof.ariaLabel'))
+ ).toBeInTheDocument();
+ });
+});
diff --git a/src/components/landing/heroVisualizerDemoConfig.js b/src/components/landing/heroVisualizerDemoConfig.js
new file mode 100644
index 00000000..1fffd4cf
--- /dev/null
+++ b/src/components/landing/heroVisualizerDemoConfig.js
@@ -0,0 +1,13 @@
+/**
+ * Copyright (c) 2025 Bayan Flow
+ * Licensed under Elastic License 2.0 OR Commercial
+ * See LICENSE for details.
+ */
+
+import { ANIMATION_SPEEDS } from '../../constants';
+
+/** Enough bars to read as a real sort without crowding the hero stage. */
+export const HERO_DEMO_SIZE = 6;
+
+/** Same delay as in-app autoplay "Fast" speed. */
+export const HERO_STEP_MS = ANIMATION_SPEEDS.FAST;
diff --git a/src/components/landing/landingSocialProof.js b/src/components/landing/landingSocialProof.js
new file mode 100644
index 00000000..b91271b2
--- /dev/null
+++ b/src/components/landing/landingSocialProof.js
@@ -0,0 +1,12 @@
+/**
+ * Copyright (c) 2025 Bayan Flow
+ * Licensed under Elastic License 2.0 OR Commercial
+ * See LICENSE for details.
+ */
+
+/**
+ * Flip to true when there is honest social proof worth showing
+ * (stars, Shorts strip, real testimonials). Until then LandingPage
+ * does not mount SocialProofStrip.
+ */
+export const SHOW_LANDING_SOCIAL_PROOF = false;
diff --git a/src/components/roadmap/TimelineItem.jsx b/src/components/roadmap/TimelineItem.jsx
index a02814f3..2ed93f43 100644
--- a/src/components/roadmap/TimelineItem.jsx
+++ b/src/components/roadmap/TimelineItem.jsx
@@ -6,7 +6,12 @@
import { motion, useReducedMotion } from 'framer-motion';
import { useTranslation } from 'react-i18next';
-import { CheckCircle, Lightning, Sparkle } from '@phosphor-icons/react';
+import {
+ ArrowUpRight,
+ CheckCircle,
+ Lightning,
+ Sparkle,
+} from '@phosphor-icons/react';
import YouTubeFacade from '../YouTubeFacade';
import { extractYoutubeVideoId } from '../../utils/youtubeVideoId';
import {
@@ -20,8 +25,9 @@ import {
function TimelineItem({
date,
title,
- description,
+ highlights,
videoUrl,
+ articleUrl,
status,
position,
index,
@@ -140,7 +146,7 @@ function TimelineItem({
{/* Date Label with Status Badge */}
-
+
{date}
@@ -152,31 +158,41 @@ function TimelineItem({
+
+ {/* Optional DevLog article link */}
+ {articleUrl && (
+
+ )}
{/* Optional YouTube Embed */}
{youtubeVideoId && (
diff --git a/src/components/roadmap/TimelineItem.test.jsx b/src/components/roadmap/TimelineItem.test.jsx
index 4fe9d4d5..b608742a 100644
--- a/src/components/roadmap/TimelineItem.test.jsx
+++ b/src/components/roadmap/TimelineItem.test.jsx
@@ -13,13 +13,15 @@ vi.mock('@phosphor-icons/react', () => ({
Lightning: () =>
,
}));
describe('TimelineItem', () => {
const defaultProps = {
date: '2025-01',
title: 'Test Feature',
- description: 'This is a test timeline item',
+ highlights: ['First highlight', 'Second highlight'],
+ articleUrl: '',
status: 'completed',
position: 'left',
index: 0,
@@ -30,9 +32,8 @@ describe('TimelineItem', () => {
render(
);
expect(screen.getByText('Test Feature')).toBeInTheDocument();
expect(screen.getByText('2025-01')).toBeInTheDocument();
- expect(
- screen.getByText('This is a test timeline item')
- ).toBeInTheDocument();
+ expect(screen.getByText('First highlight')).toBeInTheDocument();
+ expect(screen.getByText('Second highlight')).toBeInTheDocument();
});
it('should display date', () => {
@@ -45,11 +46,12 @@ describe('TimelineItem', () => {
expect(screen.getByText('Test Feature')).toBeInTheDocument();
});
- it('should display description', () => {
- render(
);
- expect(
- screen.getByText('This is a test timeline item')
- ).toBeInTheDocument();
+ it('should display highlights as a bullet list', () => {
+ const { container } = render(
);
+ expect(screen.getByText('First highlight')).toBeInTheDocument();
+ expect(screen.getByText('Second highlight')).toBeInTheDocument();
+ expect(container.querySelector('ul')).toBeInTheDocument();
+ expect(container.querySelectorAll('li')).toHaveLength(2);
});
it('should render video facade when videoUrl is provided', () => {
@@ -70,23 +72,54 @@ describe('TimelineItem', () => {
});
});
+ describe('Article Link', () => {
+ it('should render article link when articleUrl is provided', () => {
+ const props = {
+ ...defaultProps,
+ articleUrl: 'https://dev.to/example/post',
+ };
+ render(
);
+ const link = screen.getByRole('link', { name: /read article/i });
+ expect(link).toBeInTheDocument();
+ expect(link).toHaveAttribute('href', 'https://dev.to/example/post');
+ expect(link).toHaveAttribute('target', '_blank');
+ expect(link).toHaveAttribute('rel', 'noopener noreferrer');
+ expect(screen.getByTestId('arrow-up-right-icon')).toBeInTheDocument();
+ });
+
+ it('should not render article link when articleUrl is empty', () => {
+ render(
);
+ expect(
+ screen.queryByRole('link', { name: /read article/i })
+ ).not.toBeInTheDocument();
+ });
+
+ it('should not render article link when articleUrl is undefined', () => {
+ const props = { ...defaultProps, articleUrl: undefined };
+ render(
);
+ expect(
+ screen.queryByRole('link', { name: /read article/i })
+ ).not.toBeInTheDocument();
+ });
+ });
+
describe('Status Variants', () => {
it('should render with completed status', () => {
const props = { ...defaultProps, status: 'completed' };
render(
);
- expect(screen.getAllByTestId('check-icon')).toHaveLength(2);
+ expect(screen.getAllByTestId('check-icon')).toHaveLength(1);
});
it('should render with in-progress status', () => {
const props = { ...defaultProps, status: 'in-progress' };
render(
);
- expect(screen.getAllByTestId('zap-icon')).toHaveLength(2);
+ expect(screen.getAllByTestId('zap-icon')).toHaveLength(1);
});
it('should render with planned status', () => {
const props = { ...defaultProps, status: 'planned' };
render(
);
- expect(screen.getAllByTestId('sparkles-icon')).toHaveLength(2);
+ expect(screen.getAllByTestId('sparkles-icon')).toHaveLength(1);
});
it('should apply correct styling for completed status', () => {
@@ -122,7 +155,7 @@ describe('TimelineItem', () => {
it('should default to planned status when not specified', () => {
const props = { ...defaultProps, status: undefined };
render(
);
- expect(screen.getAllByTestId('sparkles-icon')).toHaveLength(2);
+ expect(screen.getAllByTestId('sparkles-icon')).toHaveLength(1);
});
});
@@ -131,19 +164,19 @@ describe('TimelineItem', () => {
const props = { ...defaultProps, status: 'completed' };
render(
);
// Status badge should be rendered through i18n
- expect(screen.getAllByTestId('check-icon')).toHaveLength(2);
+ expect(screen.getAllByTestId('check-icon')).toHaveLength(1);
});
it('should display status badge for in-progress', () => {
const props = { ...defaultProps, status: 'in-progress' };
render(
);
- expect(screen.getAllByTestId('zap-icon')).toHaveLength(2);
+ expect(screen.getAllByTestId('zap-icon')).toHaveLength(1);
});
it('should display status badge for planned', () => {
const props = { ...defaultProps, status: 'planned' };
render(
);
- expect(screen.getAllByTestId('sparkles-icon')).toHaveLength(2);
+ expect(screen.getAllByTestId('sparkles-icon')).toHaveLength(1);
});
});
@@ -224,12 +257,15 @@ describe('TimelineItem', () => {
expect(screen.getByText(longTitle)).toBeInTheDocument();
});
- it('should handle very long description', () => {
- const longDesc = 'Lorem ipsum '.repeat(50);
- const props = { ...defaultProps, description: longDesc };
+ it('should handle very long highlight', () => {
+ const longHighlight = 'Lorem ipsum '.repeat(50);
+ const props = {
+ ...defaultProps,
+ highlights: [longHighlight],
+ };
render(
);
expect(
- screen.getByText(new RegExp(longDesc.slice(0, 50)))
+ screen.getByText(new RegExp(longHighlight.slice(0, 50)))
).toBeInTheDocument();
});
@@ -274,7 +310,7 @@ describe('TimelineItem', () => {
expect(titleContainer).toBeInTheDocument();
});
- it('should maintain proper order: date → status → icon → title → description → video', () => {
+ it('should maintain proper order: date → status → icon → title → highlights → video', () => {
const props = {
...defaultProps,
videoUrl: 'https://www.youtube.com/embed/test123',
diff --git a/src/components/ui/Button.jsx b/src/components/ui/Button.jsx
index d562dce5..67fe760a 100644
--- a/src/components/ui/Button.jsx
+++ b/src/components/ui/Button.jsx
@@ -34,13 +34,17 @@ function Button({
// Render CTA variant with modern professional design
if (variant === 'cta') {
+ // When used as a link, keep a single focusable element (the