From e3c60bb66610cfd23545b7929d4dae63417df0b0 Mon Sep 17 00:00:00 2001 From: Big Della Date: Sat, 27 Jun 2026 06:46:10 +0000 Subject: [PATCH] address creator list stability and filter coverage --- docs/shared-hooks.md | 52 ++++++ src/pages/LandingPage.tsx | 157 ++++++++++++++---- ...ingPage.apiErrorToast.integration.test.tsx | 138 +++++++++++++++ ...dingPage.priceFilters.integration.test.tsx | 146 ++++++++++++++++ src/services/course.service.ts | 2 + .../__tests__/creatorListKey.utils.test.ts | 12 ++ src/utils/creatorListKey.utils.ts | 2 + 7 files changed, 481 insertions(+), 28 deletions(-) create mode 100644 docs/shared-hooks.md create mode 100644 src/pages/__tests__/LandingPage.apiErrorToast.integration.test.tsx create mode 100644 src/pages/__tests__/LandingPage.priceFilters.integration.test.tsx create mode 100644 src/utils/__tests__/creatorListKey.utils.test.ts create mode 100644 src/utils/creatorListKey.utils.ts diff --git a/docs/shared-hooks.md b/docs/shared-hooks.md new file mode 100644 index 0000000..08106ee --- /dev/null +++ b/docs/shared-hooks.md @@ -0,0 +1,52 @@ +# Contributing Shared Hooks + +The `src/hooks` folder is for reusable stateful logic that is not specific to +one component. Put a hook here when multiple screens or components can share the +same state management, browser event handling, async coordination, or derived +behavior. Keep component-only logic near the component that owns it. + +## Naming + +Shared hooks must: + +- Start with the `use` prefix. +- Export a hook whose name matches the file name. +- Use a file name that is identical to the hook name, for example + `useExample.ts`. + +## Tests + +Every shared hook must include a corresponding test file in +`src/hooks/__tests__`. Name the test after the hook, for example +`useExample.test.ts` or `useExample.test.tsx`. + +## Minimal Example + +```ts +// src/hooks/useCounter.ts +import { useCallback, useState } from 'react'; + +export const useCounter = (initialValue = 0) => { + const [count, setCount] = useState(initialValue); + const increment = useCallback(() => setCount(value => value + 1), []); + + return { count, increment }; +}; +``` + +```ts +// src/hooks/__tests__/useCounter.test.ts +import { act, renderHook } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { useCounter } from '@/hooks/useCounter'; + +describe('useCounter', () => { + it('increments from the initial value', () => { + const { result } = renderHook(() => useCounter(2)); + + act(() => result.current.increment()); + + expect(result.current.count).toBe(3); + }); +}); +``` diff --git a/src/pages/LandingPage.tsx b/src/pages/LandingPage.tsx index 52b2d23..fe670bd 100644 --- a/src/pages/LandingPage.tsx +++ b/src/pages/LandingPage.tsx @@ -62,6 +62,7 @@ import { } from '@/utils/portfolioValue.utils'; import { usePrefersReducedMotion } from '@/hooks/usePrefersReducedMotion'; import { CREATOR_LIST_SORT_LAYOUT_TRANSITION } from '@/utils/creatorListSortTransition'; +import { creatorListKey } from '@/utils/creatorListKey.utils'; import { AlertCircle, ChevronDown, RefreshCw } from 'lucide-react'; import ClearedFiltersEmptyState from '@/components/common/ClearedFiltersEmptyState'; import CreatorListPagination from '@/components/common/CreatorListPagination'; @@ -228,6 +229,15 @@ const isCreatorRefreshShortcut = (event: KeyboardEvent) => !event.shiftKey && event.key.toLowerCase() === 'r'; +const toPriceFilterValue = (value: string) => { + if (!value.trim()) return undefined; + const parsed = Number(value); + return Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined; +}; + +const getCreatorListKey = (creator: Course) => + creatorListKey(Number(creator.id)); + type SortOption = 'featured' | 'price-asc' | 'price-desc' | 'supply-desc'; interface CreatorProfileLoadErrorProps { @@ -283,6 +293,8 @@ function LandingPage() { const [isFilterLoading, setIsFilterLoading] = useState(false); const [searchParams, setSearchParams] = useSearchParams(); const [searchQuery, setSearchQuery] = useState(''); + const [minPriceFilter, setMinPriceFilter] = useState(''); + const [maxPriceFilter, setMaxPriceFilter] = useState(''); const searchQueryRef = useRef(''); const sortOptionRef = useRef('featured'); const PROFILE_TABS = ['overview', 'creations', 'collectors', 'activity']; @@ -299,7 +311,10 @@ function LandingPage() { const prefersReducedMotion = usePrefersReducedMotion(); const [sortOption, setSortOption] = useState(() => { const sort = searchParams.get('sort') as SortOption | null; - if (sort && ['featured', 'price-asc', 'price-desc', 'supply-desc'].includes(sort)) { + if ( + sort && + ['featured', 'price-asc', 'price-desc', 'supply-desc'].includes(sort) + ) { sortOptionRef.current = sort; return sort; } @@ -386,7 +401,13 @@ function LandingPage() { setSearchQuery(''); } const sort = searchParams.get('sort') as SortOption | null; - if (sort && ['featured', 'price-asc', 'price-desc', 'supply-desc'].includes(sort) && sort !== sortOptionRef.current) { + if ( + sort && + ['featured', 'price-asc', 'price-desc', 'supply-desc'].includes( + sort + ) && + sort !== sortOptionRef.current + ) { setSortOption(sort); } else if (sort === null && sortOptionRef.current !== 'featured') { setSortOption('featured'); @@ -447,7 +468,15 @@ function LandingPage() { setShowRetryBanner(false); setFinalFetchError(''); try { - const data = await courseService.getCourses(); + const minPrice = toPriceFilterValue(minPriceFilter); + const maxPrice = toPriceFilterValue(maxPriceFilter); + const params = { + ...(minPrice !== undefined ? { min_price: minPrice } : {}), + ...(maxPrice !== undefined ? { max_price: maxPrice } : {}), + }; + const data = await courseService.getCourses( + Object.keys(params).length > 0 ? params : undefined + ); if (data && data.length > 0) { setCreators(data); } else { @@ -458,6 +487,11 @@ function LandingPage() { setCreatorsFetchedAt(Date.now()); setFetchRetryAttempt(0); } catch { + if (fetchRetryAttempt === 0) { + showToast.error( + 'Unable to load creators. Check your connection and try again.' + ); + } if (fetchRetryAttempt < MAX_CREATOR_FETCH_RETRIES) { const nextAttempt = fetchRetryAttempt + 1; setShowRetryBanner(true); @@ -482,7 +516,7 @@ function LandingPage() { }; fetchCreators(); - }, [fetchRetryAttempt, fetchRequestId]); + }, [fetchRetryAttempt, fetchRequestId, maxPriceFilter, minPriceFilter]); const searchSuggestions = useMemo(() => { const fromCategories = creators @@ -581,6 +615,10 @@ function LandingPage() { }; const handleResetSearch = () => setSearchQuery(''); + const handleClearPriceFilters = () => { + setMinPriceFilter(''); + setMaxPriceFilter(''); + }; const handleRetryCreatorFetch = useCallback(() => { setFinalFetchError(''); @@ -824,6 +862,57 @@ function LandingPage() { +
+
+ + + setMinPriceFilter(event.target.value) + } + className="mt-1 h-10 w-full rounded-lg border border-white/15 bg-slate-950/80 px-3 text-sm text-white outline-none focus:border-amber-400/60" + /> +
+
+ + + setMaxPriceFilter(event.target.value) + } + className="mt-1 h-10 w-full rounded-lg border border-white/15 bg-slate-950/80 px-3 text-sm text-white outline-none focus:border-amber-400/60" + /> +
+ +
Shortcut -