Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
110 changes: 106 additions & 4 deletions src/pages/LandingPage.tsx
Original file line number Diff line number Diff line change
@@ -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 { useSearchParams } from 'react-router';
import { courseService, type Course } from '@/services/course.service';
Expand All @@ -16,6 +16,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';
Expand Down Expand Up @@ -198,10 +199,35 @@ const FETCH_RETRY_ACTION_LABEL = 'Try again';
const DEMO_HELD_KEY_QUANTITIES = [0, 2, 1] as const;
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 {
Expand Down Expand Up @@ -301,13 +327,16 @@ 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);
const parsed = saved ? Number(saved) : 0;
return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0;
});
const pendingScrollRestoreRef = useRef<number | null>(null);
const shortcutConfirmationTimerRef = useRef<number | null>(null);

// Keep refs in sync with state
useEffect(() => {
Expand Down Expand Up @@ -407,6 +436,14 @@ function LandingPage() {
return () => window.clearInterval(intervalId);
}, []);

useEffect(() => {
return () => {
if (shortcutConfirmationTimerRef.current != null) {
window.clearTimeout(shortcutConfirmationTimerRef.current);
}
};
}, []);

useEffect(() => {
const fetchCreators = async () => {
setIsLoading(true);
Expand Down Expand Up @@ -548,12 +585,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
Expand Down Expand Up @@ -660,6 +729,19 @@ function LandingPage() {
targetId="main-creator-list"
label="Skip to creator list"
/>
{showShortcutConfirmation && (
<div
role="status"
aria-live="polite"
className="fixed right-4 top-4 z-50 inline-flex items-center gap-2 rounded-full border border-amber-400/35 bg-slate-950/90 px-4 py-2 text-xs font-bold uppercase tracking-[0.16em] text-amber-100 shadow-2xl shadow-black/30 backdrop-blur-md md:right-6 md:top-6"
>
<RefreshCw
className="size-3.5 animate-spin motion-reduce:animate-none"
aria-hidden="true"
/>
Creator list refresh requested
</div>
)}
{/* #306: the outer wrapper is just a decorative shell; the actual
landmark structure is a top-level <header> sibling of the <main>
below, so screen-reader landmark navigation lands directly on the
Expand Down Expand Up @@ -745,6 +827,26 @@ function LandingPage() {
</option>
</select>
</div>
<div
aria-label={`${CREATOR_REFRESH_SHORTCUT_LABEL} refreshes creator list data`}
className="flex flex-wrap items-center gap-2 text-xs text-white/55"
>
<span className="font-semibold uppercase tracking-[0.16em] text-white/40">
Shortcut
</span>
<span className="inline-flex items-center gap-1" aria-hidden="true">
<Kbd className="border border-white/10 bg-white/10 text-white/70">
Ctrl/Cmd
</Kbd>
<Kbd className="border border-white/10 bg-white/10 text-white/70">
Alt
</Kbd>
<Kbd className="border border-white/10 bg-white/10 text-white/70">
R
</Kbd>
</span>
<span>Refresh creators</span>
</div>
</div>
</StickyFilterBar>

Expand Down Expand Up @@ -1329,4 +1431,4 @@ function LandingPage() {
);
}

export default LandingPage;
export default LandingPage;
171 changes: 171 additions & 0 deletions src/pages/__tests__/LandingPage.keyboard.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
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, ...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),
},
};
});

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(<LandingPage />);
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();
});
});
Loading