From 8575c5c2e9b9e4cfeca0af22cbc1498a28842750 Mon Sep 17 00:00:00 2001 From: Iwayemi-Kehinde Date: Sun, 31 May 2026 14:10:35 +0100 Subject: [PATCH 1/2] feat(creators): add keyboard shortcut for list refresh --- README.md | 6 + src/pages/LandingPage.tsx | 108 ++++++++++- .../__tests__/LandingPage.keyboard.test.tsx | 170 ++++++++++++++++++ 3 files changed, 281 insertions(+), 3 deletions(-) create mode 100644 src/pages/__tests__/LandingPage.keyboard.test.tsx diff --git a/README.md b/README.md index 7cc6620..ac51a1d 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,12 @@ The client is responsible for: - frontend infrastructure is in place for future marketplace routes - older template-era code still needs to be replaced with Stellar-specific flows +## Keyboard shortcuts + +- `Ctrl/Cmd + Alt + R` refreshes creator list data from the marketplace + page. The shortcut is ignored while focus is inside text inputs, + textareas, selects, or editable text regions. + ## Local setup ```bash diff --git a/src/pages/LandingPage.tsx b/src/pages/LandingPage.tsx index f33ec48..b44438f 100644 --- a/src/pages/LandingPage.tsx +++ b/src/pages/LandingPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { LayoutGroup, motion } from 'framer-motion'; import { courseService, type Course } from '@/services/course.service'; import SkipToContent from '@/components/common/SkipToContent'; @@ -14,6 +14,7 @@ import EmptyState from '@/components/common/EmptyState'; import EmptySearchSuggestions from '@/components/common/EmptySearchSuggestions'; import SectionDivider from '@/components/common/SectionDivider'; import { Button } from '@/components/ui/button'; +import { Kbd } from '@/components/ui/kbd'; import { UnavailableAction } from '@/components/ui/unavailable-action'; import SectionHeading from '@/components/common/SectionHeading'; import CompactSectionSubtitle from '@/components/common/CompactSectionSubtitle'; @@ -182,10 +183,35 @@ const PAGE_SIZE = 6; const FETCH_RETRY_ACTION_LABEL = 'Try again'; const FINAL_FETCH_ERROR_COPY = 'Unable to load live creators right now. Showing fallback creators.'; +const CREATOR_REFRESH_SHORTCUT_LABEL = 'Ctrl/Cmd + Alt + R'; +const CREATOR_REFRESH_SHORTCUT_DURATION_MS = 1800; const getFetchRetryHelperCopy = (attempt: number, maxAttempts: number) => `We couldn't load live creators yet. Retrying automatically (attempt ${attempt} of ${maxAttempts}).`; +const isEditableShortcutTarget = (target: EventTarget | null) => { + if (!(target instanceof Element)) return false; + + let element: Element | null = target; + while (element) { + if ( + element.matches('input, textarea, select, [role="textbox"]') || + (element instanceof HTMLElement && element.isContentEditable) + ) { + return true; + } + element = element.parentElement; + } + + return false; +}; + +const isCreatorRefreshShortcut = (event: KeyboardEvent) => + (event.ctrlKey || event.metaKey) && + event.altKey && + !event.shiftKey && + event.key.toLowerCase() === 'r'; + type SortOption = 'featured' | 'price-asc' | 'price-desc' | 'supply-desc'; interface CreatorProfileLoadErrorProps { @@ -267,6 +293,8 @@ function LandingPage() { // pipeline lands. `prefers-reduced-motion` disables the simulation so we // don't surface a non-essential animation to users who opted out. const [isPriceRefreshing, setIsPriceRefreshing] = useState(false); + const [showShortcutConfirmation, setShowShortcutConfirmation] = + useState(false); const [page, setPage] = useState(() => { if (typeof window === 'undefined') return 0; const saved = window.sessionStorage.getItem(CREATOR_PAGE_KEY); @@ -274,6 +302,7 @@ function LandingPage() { return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0; }); const pendingScrollRestoreRef = useRef(null); + const shortcutConfirmationTimerRef = useRef(null); // Use scroll preservation for profile tabs useScrollPreservation(activeProfileTab, { @@ -334,6 +363,14 @@ function LandingPage() { return () => window.clearInterval(intervalId); }, []); + useEffect(() => { + return () => { + if (shortcutConfirmationTimerRef.current != null) { + window.clearTimeout(shortcutConfirmationTimerRef.current); + } + }; + }, []); + useEffect(() => { const fetchCreators = async () => { setIsLoading(true); @@ -475,12 +512,44 @@ function LandingPage() { const handleResetSearch = () => setSearchQuery(''); - const handleRetryCreatorFetch = () => { + const handleRetryCreatorFetch = useCallback(() => { setFinalFetchError(''); setShowRetryBanner(false); setFetchRetryAttempt(0); setFetchRequestId(requestId => requestId + 1); - }; + }, []); + + const showCreatorRefreshShortcutConfirmation = useCallback(() => { + if (shortcutConfirmationTimerRef.current != null) { + window.clearTimeout(shortcutConfirmationTimerRef.current); + } + + setShowShortcutConfirmation(true); + shortcutConfirmationTimerRef.current = window.setTimeout(() => { + setShowShortcutConfirmation(false); + shortcutConfirmationTimerRef.current = null; + }, CREATOR_REFRESH_SHORTCUT_DURATION_MS); + }, []); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if ( + event.defaultPrevented || + event.repeat || + !isCreatorRefreshShortcut(event) || + isEditableShortcutTarget(event.target) + ) { + return; + } + + event.preventDefault(); + handleRetryCreatorFetch(); + showCreatorRefreshShortcutConfirmation(); + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [handleRetryCreatorFetch, showCreatorRefreshShortcutConfirmation]); // Stale-data detection (#301). 60s freshness window; when we cross it, // the hook fires a background refresh exactly once until the next @@ -537,6 +606,19 @@ function LandingPage() { return (
+ {showShortcutConfirmation && ( +
+
+ )} {/* #306: the outer wrapper is just a decorative shell; the actual landmark structure is a top-level
sibling of the
below, so screen-reader landmark navigation lands directly on the @@ -618,6 +700,26 @@ function LandingPage() {
+
+ + Shortcut + + + Refresh creators +
diff --git a/src/pages/__tests__/LandingPage.keyboard.test.tsx b/src/pages/__tests__/LandingPage.keyboard.test.tsx new file mode 100644 index 0000000..988594e --- /dev/null +++ b/src/pages/__tests__/LandingPage.keyboard.test.tsx @@ -0,0 +1,170 @@ +import type { ComponentProps, ReactNode } from 'react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import LandingPage from '@/pages/LandingPage'; +import { courseService, type Course } from '@/services/course.service'; + +vi.mock('@/services/course.service', () => ({ + courseService: { + getCourses: vi.fn(), + }, +})); + +vi.mock('@/hooks/useNetworkMismatch', () => ({ + useNetworkMismatch: () => ({ + isMismatch: false, + expectedChainName: 'Stellar Testnet', + }), +})); + +vi.mock('@/components/common/StellarConnectionQualityBadge', async () => { + const React = await import('react'); + + return { + default: () => + React.createElement('div', { role: 'status' }, 'RPC good'), + }; +}); + +vi.mock('@/components/common/CreatorCard', async () => { + const React = await import('react'); + + return { + default: ({ creator }: { creator: { title: string } }) => + React.createElement( + 'article', + { 'aria-label': `Creator ${creator.title}` }, + creator.title + ), + }; +}); + +vi.mock('framer-motion', async () => { + const React = await import('react'); + type MotionDivProps = ComponentProps<'div'> & { + layout?: boolean; + transition?: unknown; + }; + + return { + AnimatePresence: ({ children }: { children: ReactNode }) => + React.createElement(React.Fragment, null, children), + LayoutGroup: ({ children }: { children: ReactNode }) => + React.createElement(React.Fragment, null, children), + motion: { + div: ({ + children, + layout: _layout, + transition: _transition, + ...props + }: MotionDivProps) => React.createElement('div', props, children), + button: ({ children, ...props }: ComponentProps<'button'>) => + React.createElement('button', props, children), + }, + }; +}); + +const mockGetCourses = vi.mocked(courseService.getCourses); + +const creatorList: Course[] = [ + { + id: 'alex-rivers', + title: 'Alex Rivers', + description: 'Digital artist', + price: 0.05, + priceStroops: 500_000, + creatorShareSupply: 120, + instructorId: 'arivers', + category: 'Art', + level: 'BEGINNER', + isVerified: true, + }, +]; + +const mockMatchMedia = () => { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +}; + +const renderLandingPage = async () => { + render(); + await waitFor(() => expect(mockGetCourses).toHaveBeenCalledTimes(1)); +}; + +describe('LandingPage creator refresh shortcut', () => { + beforeEach(() => { + mockMatchMedia(); + window.localStorage.clear(); + window.sessionStorage.clear(); + mockGetCourses.mockReset(); + mockGetCourses.mockResolvedValue(creatorList); + }); + + it('refreshes creator list data with Ctrl/Cmd + Alt + R', async () => { + await renderLandingPage(); + + const shortcutEvent = new KeyboardEvent('keydown', { + key: 'r', + code: 'KeyR', + ctrlKey: true, + altKey: true, + bubbles: true, + cancelable: true, + }); + + fireEvent(window, shortcutEvent); + + expect(shortcutEvent.defaultPrevented).toBe(true); + expect( + screen.getByLabelText('Ctrl/Cmd + Alt + R refreshes creator list data') + ).toBeInTheDocument(); + expect( + await screen.findByText('Creator list refresh requested') + ).toBeInTheDocument(); + await waitFor(() => expect(mockGetCourses).toHaveBeenCalledTimes(2)); + }); + + it('does not trigger while focus is inside text inputs or textareas', async () => { + await renderLandingPage(); + + const input = document.createElement('input'); + const textarea = document.createElement('textarea'); + document.body.append(input, textarea); + + fireEvent.keyDown(input, { + key: 'r', + code: 'KeyR', + ctrlKey: true, + altKey: true, + bubbles: true, + }); + fireEvent.keyDown(textarea, { + key: 'r', + code: 'KeyR', + ctrlKey: true, + altKey: true, + bubbles: true, + }); + + await new Promise(resolve => window.setTimeout(resolve, 0)); + + expect(mockGetCourses).toHaveBeenCalledTimes(1); + expect( + screen.queryByText('Creator list refresh requested') + ).not.toBeInTheDocument(); + + input.remove(); + textarea.remove(); + }); +}); From 447fbad10f6924f99a15c7ac90345213d8007baa Mon Sep 17 00:00:00 2001 From: Iwayemi-Kehinde Date: Mon, 1 Jun 2026 13:08:22 +0100 Subject: [PATCH 2/2] fix(tests): resolve lint issues and sync with main --- src/pages/__tests__/LandingPage.keyboard.test.tsx | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/pages/__tests__/LandingPage.keyboard.test.tsx b/src/pages/__tests__/LandingPage.keyboard.test.tsx index 988594e..4d7f56c 100644 --- a/src/pages/__tests__/LandingPage.keyboard.test.tsx +++ b/src/pages/__tests__/LandingPage.keyboard.test.tsx @@ -52,12 +52,13 @@ vi.mock('framer-motion', async () => { LayoutGroup: ({ children }: { children: ReactNode }) => React.createElement(React.Fragment, null, children), motion: { - div: ({ - children, - layout: _layout, - transition: _transition, - ...props - }: MotionDivProps) => React.createElement('div', props, children), + div: ({ children, ...props }: MotionDivProps) => { + const { layout, transition, ...divProps } = props; + void layout; + void transition; + + return React.createElement('div', divProps, children); + }, button: ({ children, ...props }: ComponentProps<'button'>) => React.createElement('button', props, children), },