diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 815f054..d48eea0 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -92,6 +92,7 @@ The repository also uses Husky plus `lint-staged` to run lightweight checks on s
- Prefer accessible, keyboard-friendly UI behavior.
- Keep new routes focused and incremental until the main marketplace flows land.
- See [docs/adding-page-routes.md](./docs/adding-page-routes.md) for how to register a new page, the file naming convention, and the recommended pattern for auth-protected routes.
+- Non-technical contributors can edit marketing page copy without a local setup — see [docs/marketing-page-copy.md](./docs/marketing-page-copy.md).
### Folder structure
diff --git a/docs/marketing-page-copy.md b/docs/marketing-page-copy.md
new file mode 100644
index 0000000..1b331bc
--- /dev/null
+++ b/docs/marketing-page-copy.md
@@ -0,0 +1,91 @@
+# Editing Marketing Page Copy
+
+This guide is for non-technical contributors who want to suggest changes to the
+marketing page copy. You can edit the text directly on GitHub and open a pull
+request — no local development environment is required.
+
+## Where the copy lives
+
+All marketing page copy is in a single file:
+
+**`src/pages/MarketingPage.tsx`**
+
+The page is a single React component. Each visible section is a block of JSX
+with inline text. Use the table below to find the section you want to change.
+
+| Visible section on the page | Location in `MarketingPage.tsx` | What to look for |
+| --- | --- | --- |
+| Page title ("Access Layer") | Hero / Title | `
` with the `Access Layer` heading |
+| Intro paragraph under the title | Intro | First ` ` after the title |
+| **The idea** | `{/* The idea */}` section | Eyebrow text `The idea` and the two body paragraphs below it |
+| **How it works** | `{/* How it works */}` section | Eyebrow text `How it works` and the two body paragraphs below it |
+| **What makes it different** | `{/* What makes it different */}` section | Eyebrow text `What makes it different` and the body paragraph below it |
+| **Built on Stellar** | `{/* Built on Stellar */}` section | Eyebrow text `Built on Stellar` and the two body paragraphs below it |
+| **Join the community** | `{/* Community */}` section | Eyebrow text `Join the community`, the subtitle, and the GitHub/Telegram links |
+| Footer | `{/* Footer */}` section | Logo label and the "Built on Stellar" tagline |
+
+Section eyebrows use this pattern — a short uppercase label in blue:
+
+```tsx
+
+ The idea
+
+```
+
+Body copy sits in `` tags directly below each eyebrow. Edit the text inside
+the quotes; leave the surrounding JSX and class names unchanged unless you know
+what you are doing.
+
+## Edit copy on GitHub (no local setup)
+
+You do not need to install Node.js, pnpm, or run the app locally to submit a
+copy change. GitHub's web editor lets you edit the file in your browser.
+
+### Step 1 — Open the file on GitHub
+
+1. Go to the repository on GitHub.
+2. Navigate to **`src/pages/MarketingPage.tsx`** using the file browser.
+3. Click the **pencil icon** (Edit this file) in the top-right corner of the
+ file view.
+
+### Step 2 — Make your copy changes
+
+1. Find the section you want to update using the table above.
+2. Edit only the visible text inside the JSX (the strings between tags).
+3. Do not change file structure, imports, or class names unless instructed.
+4. Scroll down and choose **"Create a new branch for this commit"**.
+5. Give the branch a short descriptive name (for example
+ `update-marketing-intro-copy`).
+6. Click **"Commit changes"**.
+
+### Step 3 — Open a pull request targeting `dev`
+
+1. After committing, GitHub shows a banner to **"Compare & pull request"**.
+ Click it (or go to the **Pull requests** tab and click **New pull request**).
+2. Set the **base branch** to **`dev`** (not `main`).
+3. Set the **compare branch** to the branch you just created.
+4. Write a clear title and description explaining what copy you changed and why.
+5. Click **Create pull request**.
+
+A maintainer will review your change and merge it when it looks good.
+
+## Verifying your change
+
+Copy-only edits do not require running the app locally. Review your diff on the
+pull request page to confirm the text reads correctly. Maintainers may preview
+the page in a staging environment before merging.
+
+If you do have a local setup and want to preview, run `pnpm dev` and open the
+marketing page route once it is registered in the app router. This step is
+optional for copy contributors.
+
+## Tips
+
+- Keep sentences concise and product-specific.
+- Preserve existing punctuation and paragraph breaks unless you are intentionally
+ restructuring the copy.
+- Link URLs (GitHub, Telegram) are in `` tags in the Community
+ section — update the link text, not the URL, unless you are changing the
+ destination.
+- If you are unsure which section a sentence belongs to, open an issue and ask
+ before editing.
diff --git a/src/hooks/__tests__/useDebounce.integration.test.tsx b/src/hooks/__tests__/useDebounce.integration.test.tsx
new file mode 100644
index 0000000..5340eaf
--- /dev/null
+++ b/src/hooks/__tests__/useDebounce.integration.test.tsx
@@ -0,0 +1,93 @@
+import { act, render, screen } from '@testing-library/react';
+import { useEffect, useState } from 'react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { useDebounce } from '@/hooks/useDebounce';
+
+const DEBOUNCE_DELAY_MS = 500;
+
+function DebouncedProbe({
+ initialValue = 'initial',
+ onDebouncedChange,
+}: {
+ initialValue?: string;
+ onDebouncedChange?: (value: string) => void;
+}) {
+ const [value, setValue] = useState(initialValue);
+ const debouncedValue = useDebounce(value, DEBOUNCE_DELAY_MS);
+
+ useEffect(() => {
+ onDebouncedChange?.(debouncedValue);
+ }, [debouncedValue, onDebouncedChange]);
+
+ return (
+
+
setValue('updated')}>
+ Update value
+
+
{debouncedValue}
+
+ );
+}
+
+describe('useDebounce integration (#498)', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it('clears the timeout on unmount so no state update fires after unmount', () => {
+ const consoleError = vi
+ .spyOn(console, 'error')
+ .mockImplementation(() => {});
+ const onDebouncedChange = vi.fn();
+
+ const { unmount } = render(
+
+ );
+
+ onDebouncedChange.mockClear();
+
+ act(() => {
+ screen.getByRole('button', { name: /update value/i }).click();
+ });
+
+ unmount();
+
+ act(() => {
+ vi.advanceTimersByTime(DEBOUNCE_DELAY_MS + 100);
+ });
+
+ expect(onDebouncedChange).not.toHaveBeenCalled();
+ expect(consoleError).not.toHaveBeenCalled();
+ consoleError.mockRestore();
+ });
+
+ it('does not apply a pending debounced update after unmount when timers advance', () => {
+ const onDebouncedChange = vi.fn();
+
+ const { unmount, getByTestId } = render(
+
+ );
+
+ expect(getByTestId('debounced-value')).toHaveTextContent('stable');
+ onDebouncedChange.mockClear();
+
+ act(() => {
+ screen.getByRole('button', { name: /update value/i }).click();
+ });
+
+ unmount();
+
+ act(() => {
+ vi.advanceTimersByTime(DEBOUNCE_DELAY_MS + 100);
+ });
+
+ expect(onDebouncedChange).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/hooks/__tests__/useIsMobile.integration.test.tsx b/src/hooks/__tests__/useIsMobile.integration.test.tsx
new file mode 100644
index 0000000..a1a0ae3
--- /dev/null
+++ b/src/hooks/__tests__/useIsMobile.integration.test.tsx
@@ -0,0 +1,79 @@
+import { act, render, screen } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { useIsMobile } from '@/hooks/useIsMobile';
+
+type MQCallback = (event: Pick) => void;
+
+interface MockMQL {
+ matches: boolean;
+ addEventListener: (event: string, cb: MQCallback) => void;
+ removeEventListener: (event: string, cb: MQCallback) => void;
+ _fire: (newMatches: boolean) => void;
+}
+
+function mockViewportWidth(widthPx: number): MockMQL {
+ const matches = widthPx < 768;
+ const listeners: MQCallback[] = [];
+ const mql: MockMQL = {
+ matches,
+ addEventListener: (_event: string, cb: MQCallback) => listeners.push(cb),
+ removeEventListener: (_event: string, cb: MQCallback) => {
+ const idx = listeners.indexOf(cb);
+ if (idx !== -1) listeners.splice(idx, 1);
+ },
+ _fire: (newMatches: boolean) => {
+ mql.matches = newMatches;
+ listeners.forEach(cb => cb({ matches: newMatches }));
+ },
+ };
+
+ Object.defineProperty(window, 'matchMedia', {
+ writable: true,
+ value: vi.fn().mockReturnValue(mql),
+ });
+
+ return mql;
+}
+
+function MobileProbe() {
+ const isMobile = useIsMobile();
+ return {isMobile ? 'mobile' : 'desktop'}
;
+}
+
+describe('useIsMobile integration (#485)', () => {
+ let mql: MockMQL;
+
+ beforeEach(() => {
+ mql = mockViewportWidth(500);
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('returns true below 768px', () => {
+ render( );
+ expect(screen.getByTestId('mobile-state')).toHaveTextContent('mobile');
+ });
+
+ it('returns false at or above 768px', () => {
+ mql = mockViewportWidth(1024);
+ render( );
+ expect(screen.getByTestId('mobile-state')).toHaveTextContent('desktop');
+ });
+
+ it('updates correctly when the viewport is resized in both directions', () => {
+ render( );
+ expect(screen.getByTestId('mobile-state')).toHaveTextContent('mobile');
+
+ act(() => {
+ mql._fire(false);
+ });
+ expect(screen.getByTestId('mobile-state')).toHaveTextContent('desktop');
+
+ act(() => {
+ mql._fire(true);
+ });
+ expect(screen.getByTestId('mobile-state')).toHaveTextContent('mobile');
+ });
+});
diff --git a/src/hooks/useIsMobile.ts b/src/hooks/useIsMobile.ts
new file mode 100644
index 0000000..d30ac86
--- /dev/null
+++ b/src/hooks/useIsMobile.ts
@@ -0,0 +1,29 @@
+import { useEffect, useState } from 'react';
+
+/** Viewport widths below this value (in px) are treated as mobile. */
+export const MOBILE_BREAKPOINT_PX = 768;
+
+const MOBILE_MEDIA_QUERY = `(max-width: ${MOBILE_BREAKPOINT_PX - 1}px)`;
+
+/**
+ * Returns `true` when the viewport width is below {@link MOBILE_BREAKPOINT_PX}.
+ */
+export function useIsMobile(): boolean {
+ const [isMobile, setIsMobile] = useState(() => {
+ if (typeof window === 'undefined') return false;
+ return window.matchMedia(MOBILE_MEDIA_QUERY).matches;
+ });
+
+ useEffect(() => {
+ const media = window.matchMedia(MOBILE_MEDIA_QUERY);
+ const onChange = (event: MediaQueryListEvent) => {
+ setIsMobile(event.matches);
+ };
+
+ setIsMobile(media.matches);
+ media.addEventListener('change', onChange);
+ return () => media.removeEventListener('change', onChange);
+ }, []);
+
+ return isMobile;
+}
diff --git a/src/pages/LandingPage.tsx b/src/pages/LandingPage.tsx
index db12816..3038ca5 100644
--- a/src/pages/LandingPage.tsx
+++ b/src/pages/LandingPage.tsx
@@ -475,7 +475,10 @@ function LandingPage() {
const params = {
...(minPrice !== undefined ? { min_price: minPrice } : {}),
...(maxPrice !== undefined ? { max_price: maxPrice } : {}),
- ...(debouncedSearchQuery.trim() ? { search: debouncedSearchQuery.trim() } : {}),
+ ...(debouncedSearchQuery.trim()
+ ? { search: debouncedSearchQuery.trim() }
+ : {}),
+ ...(sortOption !== 'featured' ? { sort: sortOption } : {}),
};
const data = await courseService.getCourses(
Object.keys(params).length > 0 ? params : undefined
@@ -519,7 +522,14 @@ function LandingPage() {
};
fetchCreators();
- }, [fetchRetryAttempt, fetchRequestId, maxPriceFilter, minPriceFilter, debouncedSearchQuery]);
+ }, [
+ fetchRetryAttempt,
+ fetchRequestId,
+ maxPriceFilter,
+ minPriceFilter,
+ debouncedSearchQuery,
+ sortOption,
+ ]);
const searchSuggestions = useMemo(() => {
const fromCategories = creators
diff --git a/src/pages/__tests__/LandingPage.sort.integration.test.tsx b/src/pages/__tests__/LandingPage.sort.integration.test.tsx
new file mode 100644
index 0000000..4a82b89
--- /dev/null
+++ b/src/pages/__tests__/LandingPage.sort.integration.test.tsx
@@ -0,0 +1,182 @@
+import type { ComponentProps, ReactNode } from 'react';
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { MemoryRouter } from 'react-router';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import LandingPage from '@/pages/LandingPage';
+import {
+ courseService,
+ type Course,
+ type GetCoursesParams,
+} 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('@/hooks/useStaleData', () => ({
+ useStaleData: () => ({
+ stale: false,
+ ageMs: 0,
+ msUntilStale: 60_000,
+ revalidate: vi.fn(),
+ }),
+}));
+
+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);
+ },
+ h1: ({ children, ...props }: ComponentProps<'h1'>) =>
+ React.createElement('h1', props, children),
+ button: ({ children, ...props }: ComponentProps<'button'>) =>
+ React.createElement('button', props, children),
+ },
+ };
+});
+
+const mockGetCourses = vi.mocked(courseService.getCourses);
+
+const creatorAlpha: Course = {
+ id: '1',
+ title: 'Creator Alpha',
+ description: 'Digital artist',
+ price: 0.5,
+ priceStroops: 5_000_000,
+ creatorShareSupply: 100,
+ instructorId: 'creator-alpha',
+ category: 'Art',
+ level: 'BEGINNER',
+ isVerified: true,
+};
+
+const creatorBeta: Course = {
+ id: '2',
+ title: 'Creator Beta',
+ description: 'Music producer',
+ price: 0.1,
+ priceStroops: 1_000_000,
+ creatorShareSupply: 50,
+ instructorId: 'creator-beta',
+ category: 'Music',
+ level: 'INTERMEDIATE',
+ isVerified: true,
+};
+
+const featuredOrder = [creatorAlpha, creatorBeta];
+const priceAscOrder = [creatorBeta, creatorAlpha];
+const priceDescOrder = [creatorAlpha, creatorBeta];
+
+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 getCreatorTitles = () =>
+ screen.getAllByRole('article').map(node => node.textContent);
+
+describe('LandingPage sort integration (#499)', () => {
+ beforeEach(() => {
+ mockMatchMedia();
+ window.localStorage.clear();
+ window.sessionStorage.clear();
+ mockGetCourses.mockReset();
+ mockGetCourses.mockImplementation(async (params?: GetCoursesParams) => {
+ if (params?.sort === 'price-asc') return priceAscOrder;
+ if (params?.sort === 'price-desc') return priceDescOrder;
+ return featuredOrder;
+ });
+ });
+
+ it('requests creators with the new sort param and renders the refreshed list', async () => {
+ render(
+
+
+
+ );
+
+ await waitFor(() => expect(mockGetCourses).toHaveBeenCalledTimes(1));
+ expect(mockGetCourses).toHaveBeenLastCalledWith(undefined);
+ await waitFor(() =>
+ expect(getCreatorTitles()).toEqual(['Creator Alpha', 'Creator Beta'])
+ );
+
+ fireEvent.change(screen.getByLabelText(/^sort$/i), {
+ target: { value: 'price-asc' },
+ });
+
+ await waitFor(() => expect(mockGetCourses).toHaveBeenCalledTimes(2));
+ expect(mockGetCourses).toHaveBeenLastCalledWith({ sort: 'price-asc' });
+ await waitFor(() =>
+ expect(getCreatorTitles()).toEqual(['Creator Beta', 'Creator Alpha'])
+ );
+
+ fireEvent.change(screen.getByLabelText(/^sort$/i), {
+ target: { value: 'price-desc' },
+ });
+
+ await waitFor(() => expect(mockGetCourses).toHaveBeenCalledTimes(3));
+ expect(mockGetCourses).toHaveBeenLastCalledWith({ sort: 'price-desc' });
+ expect(mockGetCourses.mock.calls[2]?.[0]).not.toHaveProperty(
+ 'sort',
+ 'price-asc'
+ );
+ await waitFor(() =>
+ expect(getCreatorTitles()).toEqual(['Creator Alpha', 'Creator Beta'])
+ );
+ });
+});
diff --git a/src/services/course.service.ts b/src/services/course.service.ts
index 9868cce..fdc4863 100644
--- a/src/services/course.service.ts
+++ b/src/services/course.service.ts
@@ -25,6 +25,12 @@ export interface Course {
isPinned?: boolean;
}
+export type CourseSortOption =
+ | 'featured'
+ | 'price-asc'
+ | 'price-desc'
+ | 'supply-desc';
+
export interface GetCoursesParams {
page?: number;
limit?: number;
@@ -32,6 +38,7 @@ export interface GetCoursesParams {
search?: string;
min_price?: number;
max_price?: number;
+ sort?: Exclude;
}
class CourseService extends BaseApiService {