diff --git a/app/page.test.tsx b/app/page.test.tsx index 5f803b33..41bf44fd 100644 --- a/app/page.test.tsx +++ b/app/page.test.tsx @@ -22,6 +22,34 @@ vi.mock('@/components/DiscordButton', () => ({ DiscordButton: () => , })); +// Mock new UX components added in the UX improvement pass +vi.mock('@/components/HowItWorks', () => ({ + HowItWorks: () =>
How It Works
, +})); + +vi.mock('@/components/AnimatedStatsBanner', () => ({ + AnimatedStatsBanner: () =>
Stats Banner
, +})); + +vi.mock('@/components/UsernameInput', () => ({ + UsernameInput: ({ value, onChange, onClear }: any) => ( +
+ onChange(e.target.value)} + maxLength={39} + /> + {value.length > 0 && ( + + )} +
+ ), +})); + // next/image is no longer used — SVG preview is fetched via useEffect and // rendered inline. The mock below keeps the import from erroring if any // other test file still imports it. @@ -154,6 +182,7 @@ vi.mock('framer-motion', () => ({ ), }, AnimatePresence: ({ children }: any) => <>{children}, + useInView: () => true, })); const mockRecentSearches = { @@ -207,10 +236,11 @@ describe('LandingPage', () => { it('renders recent searches and applies a recent search when clicked', () => { render(); const input = screen.getByPlaceholderText('Enter GitHub Username') as HTMLInputElement; - const octocatButton = screen.getByRole('button', { name: 'octocat' }); + // Recent searches render as buttons with the username as the accessible label + const octocatButton = screen.getByRole('button', { name: 'Search again for octocat' }); expect(octocatButton).toBeDefined(); - expect(screen.getByRole('button', { name: 'Clear' })).toBeDefined(); + expect(screen.getByRole('button', { name: 'Clear all recent searches' })).toBeDefined(); fireEvent.click(octocatButton); @@ -219,10 +249,10 @@ describe('LandingPage', () => { it('renders an empty state before a username is entered', () => { render(); - - expect(screen.getByText(/Enter a GitHub username above to instantly generate/i)).toBeDefined(); - // No badge img should be present yet - expect(screen.queryByTestId('badge-img')).toBeNull(); + // The sample badge pill label is always shown before user interaction + expect(screen.getByText(/Sample Preview/i)).toBeDefined(); + // badge-img is always present (sample preview loads from production) + expect(screen.getByTestId('badge-img')).toBeDefined(); }); it('updates the username when input changes and shows the badge img', async () => { @@ -250,7 +280,8 @@ describe('LandingPage', () => { it('disables the Watch Dashboard link when the username is empty', () => { render(); - const dashboardLink = screen.getByRole('link', { name: 'Watch Dashboard' }); + // next/link mock renders as ; select by text + const dashboardLink = screen.getByText('Watch Dashboard').closest('a')!; expect(dashboardLink.getAttribute('aria-disabled')).toBe('true'); expect(dashboardLink.getAttribute('href')).toBe('/'); @@ -262,7 +293,7 @@ describe('LandingPage', () => { fireEvent.change(input, { target: { value: 'octocat' } }); - const dashboardLink = screen.getByRole('link', { name: 'Watch Dashboard' }); + const dashboardLink = screen.getByText('Watch Dashboard').closest('a')!; expect(dashboardLink.getAttribute('aria-disabled')).not.toBe('true'); expect(dashboardLink.getAttribute('href')).toBe('/dashboard/octocat'); }); @@ -272,7 +303,8 @@ describe('LandingPage', () => { const input = screen.getByPlaceholderText('Enter GitHub Username') as HTMLInputElement; fireEvent.change(input, { target: { value: 'jhasourav07' } }); - const copyButton = screen.getByText('Copy Link').closest('button'); + // CTA is now "Generate My Badge" — find the primary submit button + const copyButton = screen.getByRole('button', { name: /Generate badge for jhasourav07/i }); fireEvent.click(copyButton!); expect(navigator.clipboard.writeText).toHaveBeenCalledWith( @@ -280,8 +312,8 @@ describe('LandingPage', () => { ); await waitFor(() => { - // The button text should change to Copied - expect(screen.getByText('Copied')).toBeDefined(); + // The button text should change to Copied! + expect(screen.getByText('Copied!')).toBeDefined(); // The SuccessGuide should appear expect(screen.getByText('Your Monolith is Ready - Deploy It in 4 Steps')).toBeDefined(); }); @@ -294,7 +326,7 @@ describe('LandingPage', () => { const input = screen.getByPlaceholderText('Enter GitHub Username') as HTMLInputElement; fireEvent.change(input, { target: { value: 'jhasourav07' } }); - const copyButton = screen.getByText('Copy Link').closest('button'); + const copyButton = screen.getByRole('button', { name: /Generate badge for jhasourav07/i }); fireEvent.click(copyButton!); await waitFor(() => { @@ -303,24 +335,29 @@ describe('LandingPage', () => { ); }); - expect(screen.queryByText('Copied')).toBeNull(); + expect(screen.queryByText('Copied!')).toBeNull(); expect(screen.queryByText('Your Monolith is Ready - Deploy It in 4 Steps')).toBeNull(); }); it('disables Copy Link button when username is empty', () => { render(); - const copyButton = screen.getByText('Copy Link').closest('button'); + // Primary button: aria-label "Enter a GitHub username to generate your badge" when disabled + const generateButton = screen.getByRole('button', { + name: /Enter a GitHub username to generate your badge/i, + }); - expect(copyButton?.disabled).toBe(true); + expect((generateButton as HTMLButtonElement).disabled).toBe(true); }); it('does not copy link when username is empty', () => { render(); - const copyButton = screen.getByText('Copy Link').closest('button'); + const generateButton = screen.getByRole('button', { + name: /Enter a GitHub username to generate your badge/i, + }); - fireEvent.click(copyButton!); + fireEvent.click(generateButton!); expect(navigator.clipboard.writeText).not.toHaveBeenCalled(); }); @@ -354,7 +391,7 @@ describe('LandingPage', () => { fireEvent.change(input, { target: { value: 'jhasourav07' } }); // Trigger copy to show guide - const copyButton = screen.getByText('Copy Link').closest('button'); + const copyButton = screen.getByRole('button', { name: /Generate badge for jhasourav07/i }); fireEvent.click(copyButton!); await waitFor(() => { @@ -374,6 +411,7 @@ describe('LandingPage', () => { render(); const input = screen.getByPlaceholderText('Enter GitHub Username') as HTMLInputElement; + // UsernameInput mock shows "Clear input" button only when value.length > 0 expect(screen.queryByLabelText('Clear input')).toBeNull(); fireEvent.change(input, { target: { value: 'a' } }); @@ -407,12 +445,15 @@ describe('LandingPage', () => { render(); const input = screen.getByPlaceholderText('Enter GitHub Username') as HTMLInputElement; - await act(async () => { - fireEvent.change(input, { target: { value: 'invalid_user' } }); + fireEvent.change(input, { target: { value: 'invalid_user' } }); + + // Wait for the debounced username to settle and the badge img to point at the user's URL + await waitFor(() => { + const img = screen.getByTestId('badge-img') as HTMLImageElement; + expect(img.src).toContain('user=invalid_user'); }); - // Badge img renders; simulate the browser failing to load it (e.g. API returned 400) - await waitFor(() => screen.getByTestId('badge-img')); + // Simulate the browser failing to load the badge image await act(async () => { fireEvent.error(screen.getByTestId('badge-img')); }); @@ -429,12 +470,15 @@ describe('LandingPage', () => { render(); const input = screen.getByPlaceholderText('Enter GitHub Username') as HTMLInputElement; - await act(async () => { - fireEvent.change(input, { target: { value: 'octocat' } }); + fireEvent.change(input, { target: { value: 'octocat' } }); + + // Wait for the debounced username to settle and the badge img to point at the user's URL + await waitFor(() => { + const img = screen.getByTestId('badge-img') as HTMLImageElement; + expect(img.src).toContain('user=octocat'); }); // Simulate the browser failing to load the badge image - await waitFor(() => screen.getByTestId('badge-img')); await act(async () => { fireEvent.error(screen.getByTestId('badge-img')); }); diff --git a/app/page.tsx b/app/page.tsx index bec601ca..78c1d317 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -6,24 +6,15 @@ import { useRef, useState, useEffect } from 'react'; import { AnimatePresence, motion } from 'framer-motion'; import { gsap } from 'gsap'; import { useGSAP } from '@gsap/react'; -import { - X, - Flame, - Trophy, - GitCommit, - Folder, - Search, - Loader2, - Sparkles, - Copy, - ExternalLink, -} from 'lucide-react'; import { CommitPulseLogo } from '@/components/commitpulse-logo'; import { CustomizeCTA } from './components/CustomizeCTA'; import { useRecentSearches } from '@/hooks/useRecentSearches'; import { useDebounce } from '@/hooks/useDebounce'; import { Footer } from '@/app/components/Footer'; +import { UsernameInput } from '@/components/UsernameInput'; +import { HowItWorks } from '@/components/HowItWorks'; +import { AnimatedStatsBanner } from '@/components/AnimatedStatsBanner'; import { FeatureCard, FeatureCardsSection } from '@/components/FeatureCards'; import { DiscordButton } from '@/components/DiscordButton'; @@ -31,9 +22,15 @@ import { DiscordButton } from '@/components/DiscordButton'; import { WallOfLove } from '@/components/WallOfLove'; import { validateGitHubUsername } from '@/lib/validations'; +/** Well-known GitHub accounts used as sample demo chips */ +const DEMO_USERNAMES = ['torvalds', 'gaearon', 'vercel', 'sindresorhus']; + +/** The fallback sample account shown before first search */ +const SAMPLE_USERNAME = 'torvalds'; + const Icons = { Github: () => ( - + ), @@ -48,6 +45,7 @@ const Icons = { strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" + aria-hidden="true" > @@ -64,6 +62,7 @@ const Icons = { strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" + aria-hidden="true" > @@ -80,10 +79,29 @@ const Icons = { strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" + aria-hidden="true" > ), + BadgeIcon: () => ( + + ), }; function CountUp({ value, duration = 1000 }: { value: number; duration?: number }) { @@ -311,16 +329,29 @@ export default function LandingPage() { const { searches, addSearch, clearSearches, removeSearch } = useRecentSearches(); const [mounted, setMounted] = useState(false); - // States for user profile details loading - const [userDetails, setUserDetails] = useState(null); - const [userDetailsLoading, setUserDetailsLoading] = useState(false); - const [userDetailsError, setUserDetailsError] = useState(null); + // Recent search avatar cache + const [recentAvatars, setRecentAvatars] = useState>({}); useEffect(() => { // eslint-disable-next-line react-hooks/set-state-in-effect setMounted(true); }, []); + // Prefetch avatars for recent searches + useEffect(() => { + searches.forEach((s) => { + if (!recentAvatars[s]) { + const url = `https://avatars.githubusercontent.com/${s}?size=40`; + const img = new Image(); + img.src = url; + img.onload = () => { + setRecentAvatars((prev) => ({ ...prev, [s]: url })); + }; + } + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searches]); + useGSAP( () => { if (!heroRef.current) return; @@ -346,12 +377,16 @@ export default function LandingPage() { const trimmedUsername = username.trim(); const debouncedUsername = useDebounce(trimmedUsername, 500); - // Active username used to load the badge - const previewUsername = instantUsername || debouncedUsername; - const hasUsername = previewUsername.length > 0; + // Whether to show sample (torvalds) or user's actual badge + const showSample = !hasUsername; - const badgeUrl = `/api/streak?user=${previewUsername}`; const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? 'https://commitpulse.vercel.app'; + const activeBadgeUser = showSample ? SAMPLE_USERNAME : debouncedUsername; + // Sample always loads from production so it works in local dev without GITHUB_TOKEN. + // The user's own badge uses the local API endpoint as expected. + const badgeUrl = showSample + ? `https://commitpulse.vercel.app/api/streak?user=${SAMPLE_USERNAME}` + : `/api/streak?user=${debouncedUsername}`; const markdown = `![CommitPulse](${siteUrl}/api/streak?user=${trimmedUsername})`; const DownloadSVG = () => { const link = document.createElement('a'); @@ -365,51 +400,15 @@ export default function LandingPage() { const badgeLoaded = badgeResult?.username === previewUsername && badgeResult?.status === 'loaded'; const badgeError = badgeResult?.username === previewUsername && badgeResult?.status === 'error'; - // Fetch lightweight user profile details and stats on debounced input change - useEffect(() => { - if (!mounted) return; - if (debouncedUsername.length === 0) { - // eslint-disable-next-line react-hooks/set-state-in-effect - setUserDetails(null); - setUserDetailsError(null); - setUserDetailsLoading(false); - return; - } - - if (!validateGitHubUsername(debouncedUsername)) { - setUserDetails(null); - setUserDetailsError('Invalid username format'); - setUserDetailsLoading(false); - return; - } - - const fetchDetails = async () => { - setUserDetailsLoading(true); - setUserDetailsError(null); - try { - const response = await fetch( - `/api/user-details?username=${encodeURIComponent(debouncedUsername)}` - ); - if (!response.ok) { - if (response.status === 404) { - throw new Error('User not found'); - } - const errData = await response.json(); - throw new Error(errData.error || 'Failed to fetch user'); - } - const data = await response.json(); - setUserDetails(data); - } catch (err) { - const message = err instanceof Error ? err.message : 'Failed to fetch user'; - setUserDetails(null); - setUserDetailsError(message); - } finally { - setUserDetailsLoading(false); - } - }; + // Derived — automatically false when debouncedUsername changes + const badgeLoaded = badgeResult?.username === activeBadgeUser && badgeResult?.status === 'loaded'; + const badgeError = badgeResult?.username === activeBadgeUser && badgeResult?.status === 'error'; - fetchDetails(); - }, [debouncedUsername, mounted]); + // When switching from sample to user, reset badge result + useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect + setBadgeResult(null); + }, [activeBadgeUser]); const copyToClipboard = async () => { if (trimmedUsername.length === 0) return; @@ -482,12 +481,13 @@ export default function LandingPage() { return (
-
+