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
1 change: 1 addition & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
91 changes: 91 additions & 0 deletions docs/marketing-page-copy.md
Original file line number Diff line number Diff line change
@@ -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 | `<h1>` with the `Access Layer` heading |
| Intro paragraph under the title | Intro | First `<p>` 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
<p className="font-mono text-[10px] uppercase tracking-[0.22em] text-blue-400">
The idea
</p>
```

Body copy sits in `<p>` 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 `<a href="...">` 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.
93 changes: 93 additions & 0 deletions src/hooks/__tests__/useDebounce.integration.test.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div>
<button type="button" onClick={() => setValue('updated')}>
Update value
</button>
<div data-testid="debounced-value">{debouncedValue}</div>
</div>
);
}

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(
<DebouncedProbe onDebouncedChange={onDebouncedChange} />
);

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(
<DebouncedProbe
initialValue="stable"
onDebouncedChange={onDebouncedChange}
/>
);

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();
});
});
79 changes: 79 additions & 0 deletions src/hooks/__tests__/useIsMobile.integration.test.tsx
Original file line number Diff line number Diff line change
@@ -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<MediaQueryListEvent, 'matches'>) => 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 <div data-testid="mobile-state">{isMobile ? 'mobile' : 'desktop'}</div>;
}

describe('useIsMobile integration (#485)', () => {
let mql: MockMQL;

beforeEach(() => {
mql = mockViewportWidth(500);
});

afterEach(() => {
vi.restoreAllMocks();
});

it('returns true below 768px', () => {
render(<MobileProbe />);
expect(screen.getByTestId('mobile-state')).toHaveTextContent('mobile');
});

it('returns false at or above 768px', () => {
mql = mockViewportWidth(1024);
render(<MobileProbe />);
expect(screen.getByTestId('mobile-state')).toHaveTextContent('desktop');
});

it('updates correctly when the viewport is resized in both directions', () => {
render(<MobileProbe />);
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');
});
});
29 changes: 29 additions & 0 deletions src/hooks/useIsMobile.ts
Original file line number Diff line number Diff line change
@@ -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;
}
14 changes: 12 additions & 2 deletions src/pages/LandingPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -519,7 +522,14 @@ function LandingPage() {
};

fetchCreators();
}, [fetchRetryAttempt, fetchRequestId, maxPriceFilter, minPriceFilter, debouncedSearchQuery]);
}, [
fetchRetryAttempt,
fetchRequestId,
maxPriceFilter,
minPriceFilter,
debouncedSearchQuery,
sortOption,
]);

const searchSuggestions = useMemo(() => {
const fromCategories = creators
Expand Down
Loading
Loading