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 ( +

+ +
{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 {