diff --git a/index.html b/index.html index a715d3c..55dfe3b 100644 --- a/index.html +++ b/index.html @@ -58,6 +58,105 @@ + +
diff --git a/src/components/common/CreatorCard.tsx b/src/components/common/CreatorCard.tsx index f49c88d..5e502e7 100644 --- a/src/components/common/CreatorCard.tsx +++ b/src/components/common/CreatorCard.tsx @@ -6,6 +6,7 @@ import { ShoppingCart, Link as LinkIcon, TrendingUp } from 'lucide-react'; import toast from 'react-hot-toast'; import showToast from '@/utils/toast.util'; import { formatCompactNumber, formatNumber } from '@/utils/numberFormat.utils'; +import { formatCreatorHandle } from '@/utils/handleDisplay.utils'; import { AsyncButton } from '@/components/ui/async-button'; import { useNetworkMismatch } from '@/hooks/useNetworkMismatch'; import { useTransactionTelemetry } from '@/hooks/useTransactionTelemetry'; @@ -30,11 +31,26 @@ import CreatorBio from '@/components/common/CreatorBio'; interface CreatorCardProps { creator: Course; className?: string; + /** + * When true, render the price with a subtle refreshing indicator (#305). + * Layout is preserved — the indicator overlays / sits next to the value + * without changing the badge's box. + */ + isPriceRefreshing?: boolean; } const creatorBadgeRowClass = 'mt-2 flex items-center gap-1.5'; -const CreatorCard: React.FC = ({ creator, className }) => { +const CreatorCard: React.FC = ({ + creator, + className, + isPriceRefreshing = false, +}) => { + // Display-normalised handles. Raw values stay on `creator` for any + // equality / URL logic downstream. + const displayInstructorHandle = + formatCreatorHandle(creator.instructorId) || '@creator'; + const displaySocialHandle = formatCreatorHandle(creator.socialHandle); const { isConnected } = useAccount(); const { isMismatch: isNetworkMismatch, expectedChainName } = useNetworkMismatch(); const [transactionState, setTransactionState] = useState< @@ -158,7 +174,7 @@ const CreatorCard: React.FC = ({ creator, className }) => {

- @{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}`; +};