From 58b5a50a00e1821e9650e1861918d6da804da77e Mon Sep 17 00:00:00 2001 From: Nwokedi Chigozirim Date: Thu, 28 May 2026 13:39:15 +0100 Subject: [PATCH] feat: marketplace landmark, price refresh state, no-script fallback, handle normalisation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #306, #305, #299, #298. - #306: Demoted the LandingPage's outer
to a decorative
and scoped a real
to the marketplace-and-below content. The page header (logo, hero copy) is now a sibling
rather than living inside
, so screen-reader landmark navigation jumps directly to the primary content. - #305: Added isPriceRefreshing prop to CreatorCard. When true the price badge keeps its visible value but applies a muted style, renders a small spinner, sets aria-busy, and includes an sr-only "Refreshing price" hint. min-width + tabular-nums on the value preserve layout so the badge does not collapse or shift. LandingPage drives the flag from a simple 30-second simulator gated on prefers-reduced-motion until the real price-refresh pipeline lands. - #299: Added a styled

- @{creator.instructorId || 'creator'} + {displayInstructorHandle}

@@ -166,7 +182,7 @@ const CreatorCard: React.FC = ({ creator, className }) => { {creator.socialHandle ? (
- @{creator.socialHandle} + {displaySocialHandle}
) : (
= ({ creator, className }) => { } value={ creator.socialHandle - ? `@${creator.socialHandle}` + ? displaySocialHandle : 'No public handle' } valueTitle={ creator.socialHandle - ? `@${creator.socialHandle}` + ? displaySocialHandle : undefined } valueClassName={ @@ -220,7 +236,34 @@ const CreatorCard: React.FC = ({ creator, className }) => { /> + {isPriceRefreshing && ( + + } truncateValue={false} valueClassName="font-grotesque text-base font-black text-amber-400" /> diff --git a/src/components/common/CreatorProfileHeader.tsx b/src/components/common/CreatorProfileHeader.tsx index a9d94cc..a3bf453 100644 --- a/src/components/common/CreatorProfileHeader.tsx +++ b/src/components/common/CreatorProfileHeader.tsx @@ -6,6 +6,7 @@ import { cn } from '@/lib/utils'; import VerifiedBadge from '@/components/common/VerifiedBadge'; import CreatorInitialsAvatar from '@/components/common/CreatorInitialsAvatar'; import CreatorBio from '@/components/common/CreatorBio'; +import { formatCreatorHandle } from '@/utils/handleDisplay.utils'; interface CreatorProfileHeaderProps { name: string; @@ -31,13 +32,17 @@ const CreatorProfileHeader: React.FC = ({ }) => { const [copied, setCopied] = useState(false); + // Display-normalised handle; raw `handle` is preserved for any equality / + // URL construction the caller might do via the prop. + const displayHandle = formatCreatorHandle(handle); + const handleShare = async () => { const url = window.location.href; if (navigator.share) { try { await navigator.share({ - title: `${name} (@${handle}) on Access Layer`, + title: `${name} (${displayHandle || `@${handle}`}) on Access Layer`, url, }); } catch (err) { @@ -97,7 +102,7 @@ const CreatorProfileHeader: React.FC = ({ CREATOR_PROFILE_SUBTITLE_WRAP_CLASS_NAME )} > - @{handle} + {displayHandle || `@${handle}`}

diff --git a/src/pages/LandingPage.tsx b/src/pages/LandingPage.tsx index fdbc095..d07fdeb 100644 --- a/src/pages/LandingPage.tsx +++ b/src/pages/LandingPage.tsx @@ -209,6 +209,12 @@ function LandingPage() { const [fetchRequestId, setFetchRequestId] = useState(0); const [showRetryBanner, setShowRetryBanner] = useState(false); const [finalFetchError, setFinalFetchError] = useState(''); + // Simulated background key-price refresh (#305). A real implementation + // would be driven by a WebSocket or polling hook; here we flip the flag + // on a fixed cadence so the card's loading state is observable until that + // 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 [page, setPage] = useState(() => { if (typeof window === 'undefined') return 0; const saved = window.sessionStorage.getItem(CREATOR_PAGE_KEY); @@ -262,6 +268,20 @@ function LandingPage() { window.scrollTo({ top: parsed }); }, []); + useEffect(() => { + if (typeof window === 'undefined') return; + const reduceMotion = window.matchMedia( + '(prefers-reduced-motion: reduce)' + ).matches; + if (reduceMotion) return; + // Every 30s, simulate an ~800ms in-flight refresh. + const intervalId = window.setInterval(() => { + setIsPriceRefreshing(true); + window.setTimeout(() => setIsPriceRefreshing(false), 800); + }, 30_000); + return () => window.clearInterval(intervalId); + }, []); + useEffect(() => { const fetchCreators = async () => { setIsLoading(true); @@ -440,7 +460,11 @@ function LandingPage() { }; return ( -
+ // #306: the outer wrapper is just a decorative shell; the actual + // landmark structure is a top-level
sibling of the
+ // below, so screen-reader landmark navigation lands directly on the + // marketplace content rather than on the brand banner. +
@@ -471,9 +495,13 @@ function LandingPage() {
- +
+ -
{pagedCreators.map(creator => ( - + ))}
@@ -560,7 +588,7 @@ function LandingPage() { )}
{pagedCreators.map(creator => ( - + ))}
@@ -806,6 +834,7 @@ function LandingPage() { +
-
+ ); } diff --git a/src/utils/__tests__/handleDisplay.utils.test.ts b/src/utils/__tests__/handleDisplay.utils.test.ts new file mode 100644 index 0000000..e93e0e1 --- /dev/null +++ b/src/utils/__tests__/handleDisplay.utils.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; +import { formatCreatorHandle } from '../handleDisplay.utils'; + +describe('formatCreatorHandle', () => { + it('lowercases mixed-case handles and prepends @', () => { + expect(formatCreatorHandle('ARivers')).toBe('@arivers'); + expect(formatCreatorHandle('Schen_Dev')).toBe('@schen_dev'); + }); + + it('strips an existing leading @ before re-prepending', () => { + expect(formatCreatorHandle('@ARivers')).toBe('@arivers'); + expect(formatCreatorHandle('@@nope')).toBe('@@nope'); // only one @ stripped + }); + + it('trims surrounding whitespace', () => { + expect(formatCreatorHandle(' ARivers ')).toBe('@arivers'); + expect(formatCreatorHandle(' @ARivers ')).toBe('@arivers'); + }); + + it('returns an empty string for empty / whitespace / nullish input', () => { + expect(formatCreatorHandle('')).toBe(''); + expect(formatCreatorHandle(' ')).toBe(''); + expect(formatCreatorHandle(null)).toBe(''); + expect(formatCreatorHandle(undefined)).toBe(''); + }); + + it('returns an empty string when the input is just an @', () => { + // A lone @ implies the user forgot to type their handle — no point + // rendering "@" alone on a card, callers can fall back to a placeholder. + expect(formatCreatorHandle('@')).toBe(''); + expect(formatCreatorHandle('@ ')).toBe(''); + }); + + it('is idempotent: formatting an already-formatted handle is a no-op', () => { + expect(formatCreatorHandle(formatCreatorHandle('ARivers'))).toBe('@arivers'); + }); + + it('does not modify the underlying string the caller passes in', () => { + // (Strings are immutable in JS, so this is really about not having + // side effects on, e.g., trimming the original via mutation — but the + // invariant matters: callers must keep the raw value for equality.) + const raw = 'ARivers'; + formatCreatorHandle(raw); + expect(raw).toBe('ARivers'); + }); +}); diff --git a/src/utils/handleDisplay.utils.ts b/src/utils/handleDisplay.utils.ts new file mode 100644 index 0000000..4d5ebef --- /dev/null +++ b/src/utils/handleDisplay.utils.ts @@ -0,0 +1,26 @@ +/** + * Display-only normalisation for creator handles (issue #298). + * + * Handles come from a few different sources — the API's `instructorId`, the + * user-provided `socialHandle`, and search input — with inconsistent casing + * and an optional leading "@". This helper produces a single display form so + * the same creator's handle renders the same way on the card, the profile + * header, and anywhere else we surface it. + * + * Rule: strip a leading "@", trim whitespace, lowercase, then prepend a + * single "@". Empty or whitespace-only input becomes an empty string so + * callers can decide whether to fall back to a placeholder. + * + * IMPORTANT: this is **display only**. Callers must keep using the raw + * stored value for equality checks, URL construction, etc.; passing the + * formatted value back to the API would lose the original casing. + */ +export const formatCreatorHandle = (raw: string | null | undefined): string => { + if (raw == null) return ''; + const trimmed = raw.trim(); + if (trimmed === '') return ''; + const withoutLeadingAt = trimmed.startsWith('@') ? trimmed.slice(1) : trimmed; + const normalised = withoutLeadingAt.trim().toLowerCase(); + if (normalised === '') return ''; + return `@${normalised}`; +};