Skip to content

Commit dd7f968

Browse files
authored
Merge pull request #310 from chigozirim007/feat/a11y-handle-norm-marketplace-landmarks
feat(a11y/ui): main landmark, price-refresh state, no-script fallback, handle norm (#306/#305/#299/#298)
2 parents 82f2846 + 58b5a50 commit dd7f968

6 files changed

Lines changed: 262 additions & 14 deletions

File tree

‎index.html‎

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,105 @@
5858
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
5959
</head>
6060
<body>
61+
<!--
62+
#299: No-script fallback for key UI actions. The marketplace is a
63+
client-rendered SPA (Vite), so without JavaScript the `#root`
64+
container stays empty and every interactive surface — search,
65+
filters, buy/sell, wallet connect, profile pattern — is
66+
unavailable. This block is the one place a real user with JS
67+
disabled can see explanatory copy; the same noscript element
68+
covers the whole interactive area in a single place rather than
69+
being sprinkled per-action across React components (which would
70+
never mount without JS anyway).
71+
72+
Styling is inlined so it works even if external CSS is not
73+
fetched, and the tone matches the rest of the app.
74+
-->
75+
<noscript>
76+
<style>
77+
.noscript-fallback {
78+
min-height: 100vh;
79+
display: flex;
80+
align-items: center;
81+
justify-content: center;
82+
padding: 2rem 1.5rem;
83+
background: linear-gradient(
84+
160deg,
85+
#08111f 0%,
86+
#10213b 45%,
87+
#f0b14d 160%
88+
);
89+
color: #ffffff;
90+
font-family:
91+
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
92+
sans-serif;
93+
}
94+
.noscript-fallback__card {
95+
max-width: 36rem;
96+
padding: 2rem;
97+
border: 1px solid rgba(255, 255, 255, 0.1);
98+
border-radius: 1.5rem;
99+
background: rgba(8, 17, 31, 0.75);
100+
backdrop-filter: blur(8px);
101+
text-align: center;
102+
}
103+
.noscript-fallback__eyebrow {
104+
font-size: 0.75rem;
105+
font-weight: 700;
106+
letter-spacing: 0.25em;
107+
text-transform: uppercase;
108+
color: rgba(251, 191, 36, 0.85);
109+
margin-bottom: 0.5rem;
110+
}
111+
.noscript-fallback__title {
112+
font-size: clamp(1.5rem, 4vw, 2.25rem);
113+
font-weight: 900;
114+
letter-spacing: -0.02em;
115+
margin: 0.5rem 0 1rem 0;
116+
}
117+
.noscript-fallback__body {
118+
font-size: 1rem;
119+
line-height: 1.5;
120+
color: rgba(255, 255, 255, 0.7);
121+
margin: 0 0 1.25rem 0;
122+
}
123+
.noscript-fallback__list {
124+
text-align: left;
125+
padding-left: 1.25rem;
126+
margin: 0 0 1.25rem 0;
127+
color: rgba(255, 255, 255, 0.75);
128+
font-size: 0.9rem;
129+
line-height: 1.6;
130+
}
131+
.noscript-fallback__footer {
132+
font-size: 0.85rem;
133+
color: rgba(255, 255, 255, 0.55);
134+
margin: 0;
135+
}
136+
</style>
137+
<div class="noscript-fallback" role="alert">
138+
<div class="noscript-fallback__card">
139+
<p class="noscript-fallback__eyebrow">JavaScript required</p>
140+
<h1 class="noscript-fallback__title">
141+
This marketplace needs JavaScript to run
142+
</h1>
143+
<p class="noscript-fallback__body">
144+
Access Layer is an interactive client-rendered app. The
145+
key actions on this page rely on JavaScript:
146+
</p>
147+
<ul class="noscript-fallback__list">
148+
<li>Browsing and searching creator profiles</li>
149+
<li>Connecting a Stellar wallet</li>
150+
<li>Buying or selling creator keys</li>
151+
<li>Viewing live prices and transaction status</li>
152+
</ul>
153+
<p class="noscript-fallback__footer">
154+
Please enable JavaScript in your browser settings and
155+
refresh the page to continue.
156+
</p>
157+
</div>
158+
</div>
159+
</noscript>
61160
<div id="root"></div>
62161
<script type="module" src="/src/main.tsx"></script>
63162
</body>

‎src/components/common/CreatorCard.tsx‎

Lines changed: 49 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { ShoppingCart, Link as LinkIcon, TrendingUp } from 'lucide-react';
66
import toast from 'react-hot-toast';
77
import showToast from '@/utils/toast.util';
88
import { formatCompactNumber, formatNumber } from '@/utils/numberFormat.utils';
9+
import { formatCreatorHandle } from '@/utils/handleDisplay.utils';
910
import { AsyncButton } from '@/components/ui/async-button';
1011
import { useNetworkMismatch } from '@/hooks/useNetworkMismatch';
1112
import { useTransactionTelemetry } from '@/hooks/useTransactionTelemetry';
@@ -30,11 +31,26 @@ import CreatorBio from '@/components/common/CreatorBio';
3031
interface CreatorCardProps {
3132
creator: Course;
3233
className?: string;
34+
/**
35+
* When true, render the price with a subtle refreshing indicator (#305).
36+
* Layout is preserved — the indicator overlays / sits next to the value
37+
* without changing the badge's box.
38+
*/
39+
isPriceRefreshing?: boolean;
3340
}
3441

3542
const creatorBadgeRowClass = 'mt-2 flex items-center gap-1.5';
3643

37-
const CreatorCard: React.FC<CreatorCardProps> = ({ creator, className }) => {
44+
const CreatorCard: React.FC<CreatorCardProps> = ({
45+
creator,
46+
className,
47+
isPriceRefreshing = false,
48+
}) => {
49+
// Display-normalised handles. Raw values stay on `creator` for any
50+
// equality / URL logic downstream.
51+
const displayInstructorHandle =
52+
formatCreatorHandle(creator.instructorId) || '@creator';
53+
const displaySocialHandle = formatCreatorHandle(creator.socialHandle);
3854
const { isConnected } = useAccount();
3955
const { isMismatch: isNetworkMismatch, expectedChainName } = useNetworkMismatch();
4056
const [transactionState, setTransactionState] = useState<
@@ -158,15 +174,15 @@ const CreatorCard: React.FC<CreatorCardProps> = ({ creator, className }) => {
158174
<KeySupplyBadge supply={creator.creatorShareSupply} />
159175
</div>
160176
<p className="marketplace-label-muted font-jakarta text-sm">
161-
@{creator.instructorId || 'creator'}
177+
{displayInstructorHandle}
162178
</p>
163179

164180
<CreatorBio bio={creator.description} variant="card" className="mt-2" />
165181

166182
{creator.socialHandle ? (
167183
<div className="marketplace-label-muted mt-2 flex items-center gap-1.5 text-xs">
168184
<LinkIcon className="creator-action-icon text-amber-500/70" />
169-
<span className="truncate">@{creator.socialHandle}</span>
185+
<span className="truncate">{displaySocialHandle}</span>
170186
</div>
171187
) : (
172188
<div
@@ -204,12 +220,12 @@ const CreatorCard: React.FC<CreatorCardProps> = ({ creator, className }) => {
204220
}
205221
value={
206222
creator.socialHandle
207-
? `@${creator.socialHandle}`
223+
? displaySocialHandle
208224
: 'No public handle'
209225
}
210226
valueTitle={
211227
creator.socialHandle
212-
? `@${creator.socialHandle}`
228+
? displaySocialHandle
213229
: undefined
214230
}
215231
valueClassName={
@@ -220,7 +236,34 @@ const CreatorCard: React.FC<CreatorCardProps> = ({ creator, className }) => {
220236
/>
221237
<CardMetaRow
222238
label="Key Price"
223-
value={`${formatNumber(creator.price)} ETH`}
239+
// During a background price refresh (#305) the value
240+
// stays visible — we only swap to a muted style and add
241+
// an `aria-busy` marker so assistive tech announces that
242+
// the figure may change. Wrapping the text in a fixed-
243+
// width container preserves layout so the badge does
244+
// not shift while refreshing.
245+
value={
246+
<span
247+
aria-busy={isPriceRefreshing || undefined}
248+
data-testid="creator-card-price-badge"
249+
className={cn(
250+
'inline-flex min-w-[6.5rem] items-center gap-1.5 tabular-nums',
251+
isPriceRefreshing && 'opacity-60'
252+
)}
253+
>
254+
{isPriceRefreshing && (
255+
<span
256+
aria-hidden="true"
257+
data-testid="creator-card-price-refresh-indicator"
258+
className="inline-block size-3 shrink-0 animate-spin rounded-full border-2 border-amber-400/30 border-t-amber-400"
259+
/>
260+
)}
261+
<span>{`${formatNumber(creator.price)} ETH`}</span>
262+
{isPriceRefreshing && (
263+
<span className="sr-only">Refreshing price</span>
264+
)}
265+
</span>
266+
}
224267
truncateValue={false}
225268
valueClassName="font-grotesque text-base font-black text-amber-400"
226269
/>

‎src/components/common/CreatorProfileHeader.tsx‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { cn } from '@/lib/utils';
66
import VerifiedBadge from '@/components/common/VerifiedBadge';
77
import CreatorInitialsAvatar from '@/components/common/CreatorInitialsAvatar';
88
import CreatorBio from '@/components/common/CreatorBio';
9+
import { formatCreatorHandle } from '@/utils/handleDisplay.utils';
910

1011
interface CreatorProfileHeaderProps {
1112
name: string;
@@ -31,13 +32,17 @@ const CreatorProfileHeader: React.FC<CreatorProfileHeaderProps> = ({
3132
}) => {
3233
const [copied, setCopied] = useState(false);
3334

35+
// Display-normalised handle; raw `handle` is preserved for any equality /
36+
// URL construction the caller might do via the prop.
37+
const displayHandle = formatCreatorHandle(handle);
38+
3439
const handleShare = async () => {
3540
const url = window.location.href;
3641

3742
if (navigator.share) {
3843
try {
3944
await navigator.share({
40-
title: `${name} (@${handle}) on Access Layer`,
45+
title: `${name} (${displayHandle || `@${handle}`}) on Access Layer`,
4146
url,
4247
});
4348
} catch (err) {
@@ -97,7 +102,7 @@ const CreatorProfileHeader: React.FC<CreatorProfileHeaderProps> = ({
97102
CREATOR_PROFILE_SUBTITLE_WRAP_CLASS_NAME
98103
)}
99104
>
100-
@{handle}
105+
{displayHandle || `@${handle}`}
101106
</p>
102107
<CreatorBio bio={bio} variant="profile" className="mt-2 max-w-md" />
103108
</div>

‎src/pages/LandingPage.tsx‎

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,12 @@ function LandingPage() {
209209
const [fetchRequestId, setFetchRequestId] = useState(0);
210210
const [showRetryBanner, setShowRetryBanner] = useState(false);
211211
const [finalFetchError, setFinalFetchError] = useState('');
212+
// Simulated background key-price refresh (#305). A real implementation
213+
// would be driven by a WebSocket or polling hook; here we flip the flag
214+
// on a fixed cadence so the card's loading state is observable until that
215+
// pipeline lands. `prefers-reduced-motion` disables the simulation so we
216+
// don't surface a non-essential animation to users who opted out.
217+
const [isPriceRefreshing, setIsPriceRefreshing] = useState(false);
212218
const [page, setPage] = useState(() => {
213219
if (typeof window === 'undefined') return 0;
214220
const saved = window.sessionStorage.getItem(CREATOR_PAGE_KEY);
@@ -262,6 +268,20 @@ function LandingPage() {
262268
window.scrollTo({ top: parsed });
263269
}, []);
264270

271+
useEffect(() => {
272+
if (typeof window === 'undefined') return;
273+
const reduceMotion = window.matchMedia(
274+
'(prefers-reduced-motion: reduce)'
275+
).matches;
276+
if (reduceMotion) return;
277+
// Every 30s, simulate an ~800ms in-flight refresh.
278+
const intervalId = window.setInterval(() => {
279+
setIsPriceRefreshing(true);
280+
window.setTimeout(() => setIsPriceRefreshing(false), 800);
281+
}, 30_000);
282+
return () => window.clearInterval(intervalId);
283+
}, []);
284+
265285
useEffect(() => {
266286
const fetchCreators = async () => {
267287
setIsLoading(true);
@@ -440,7 +460,11 @@ function LandingPage() {
440460
};
441461

442462
return (
443-
<main className="relative min-h-screen overflow-x-hidden bg-[linear-gradient(160deg,#08111f_0%,#10213b_45%,#f0b14d_160%)] px-6 pt-12 pb-28 md:px-12 md:pb-12">
463+
// #306: the outer wrapper is just a decorative shell; the actual
464+
// landmark structure is a top-level <header> sibling of the <main>
465+
// below, so screen-reader landmark navigation lands directly on the
466+
// marketplace content rather than on the brand banner.
467+
<div className="relative min-h-screen overflow-x-hidden bg-[linear-gradient(160deg,#08111f_0%,#10213b_45%,#f0b14d_160%)] px-6 pt-12 pb-28 md:px-12 md:pb-12">
444468
<div className="absolute left-[-4rem] top-[10%] size-72 rounded-full bg-amber-300/20 blur-[100px]" />
445469
<div className="absolute bottom-[8%] right-[-3rem] size-72 rounded-full bg-emerald-300/15 blur-[100px]" />
446470
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top,rgba(255,186,73,0.1),transparent_40%),radial-gradient(circle_at_bottom_left,rgba(74,222,128,0.08),transparent_35%)]" />
@@ -471,9 +495,13 @@ function LandingPage() {
471495
</div>
472496
</MarketplaceSection>
473497

474-
<SectionDivider title="Discover creators" spacing="relaxed" />
498+
<main
499+
id="creator-marketplace-main"
500+
aria-label="Creator marketplace"
501+
>
502+
<SectionDivider title="Discover creators" spacing="relaxed" />
475503

476-
<StickyFilterBar
504+
<StickyFilterBar
477505
eyebrow="Marketplace filters"
478506
title="Find creators without losing your place"
479507
description="Search by creator name or handle while you keep scrolling through the marketplace. The filter shell stays visible and compact so you can refine results without losing your place."
@@ -536,7 +564,7 @@ function LandingPage() {
536564
</div>
537565
<div className="grid grid-cols-1 gap-8 sm:grid-cols-2 lg:grid-cols-3 opacity-50">
538566
{pagedCreators.map(creator => (
539-
<CreatorCard key={creator.id} creator={creator} />
567+
<CreatorCard key={creator.id} creator={creator} isPriceRefreshing={isPriceRefreshing} />
540568
))}
541569
</div>
542570
</div>
@@ -560,7 +588,7 @@ function LandingPage() {
560588
)}
561589
<div className="grid grid-cols-1 gap-8 sm:grid-cols-2 lg:grid-cols-3">
562590
{pagedCreators.map(creator => (
563-
<CreatorCard key={creator.id} creator={creator} />
591+
<CreatorCard key={creator.id} creator={creator} isPriceRefreshing={isPriceRefreshing} />
564592
))}
565593
</div>
566594
<div className="mt-8 flex items-center justify-center gap-3">
@@ -806,6 +834,7 @@ function LandingPage() {
806834
<MarketplaceSection spacing="relaxed">
807835
<EmptyTransactionTimelineState />
808836
</MarketplaceSection>
837+
</main>
809838
</div>
810839

811840
<TradeDialog
@@ -826,7 +855,7 @@ function LandingPage() {
826855
description="Waiting for Stellar confirmation, then refreshing holdings."
827856
/>
828857
<ScrollToTop />
829-
</main>
858+
</div>
830859
);
831860
}
832861

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { formatCreatorHandle } from '../handleDisplay.utils';
3+
4+
describe('formatCreatorHandle', () => {
5+
it('lowercases mixed-case handles and prepends @', () => {
6+
expect(formatCreatorHandle('ARivers')).toBe('@arivers');
7+
expect(formatCreatorHandle('Schen_Dev')).toBe('@schen_dev');
8+
});
9+
10+
it('strips an existing leading @ before re-prepending', () => {
11+
expect(formatCreatorHandle('@ARivers')).toBe('@arivers');
12+
expect(formatCreatorHandle('@@nope')).toBe('@@nope'); // only one @ stripped
13+
});
14+
15+
it('trims surrounding whitespace', () => {
16+
expect(formatCreatorHandle(' ARivers ')).toBe('@arivers');
17+
expect(formatCreatorHandle(' @ARivers ')).toBe('@arivers');
18+
});
19+
20+
it('returns an empty string for empty / whitespace / nullish input', () => {
21+
expect(formatCreatorHandle('')).toBe('');
22+
expect(formatCreatorHandle(' ')).toBe('');
23+
expect(formatCreatorHandle(null)).toBe('');
24+
expect(formatCreatorHandle(undefined)).toBe('');
25+
});
26+
27+
it('returns an empty string when the input is just an @', () => {
28+
// A lone @ implies the user forgot to type their handle — no point
29+
// rendering "@" alone on a card, callers can fall back to a placeholder.
30+
expect(formatCreatorHandle('@')).toBe('');
31+
expect(formatCreatorHandle('@ ')).toBe('');
32+
});
33+
34+
it('is idempotent: formatting an already-formatted handle is a no-op', () => {
35+
expect(formatCreatorHandle(formatCreatorHandle('ARivers'))).toBe('@arivers');
36+
});
37+
38+
it('does not modify the underlying string the caller passes in', () => {
39+
// (Strings are immutable in JS, so this is really about not having
40+
// side effects on, e.g., trimming the original via mutation — but the
41+
// invariant matters: callers must keep the raw value for equality.)
42+
const raw = 'ARivers';
43+
formatCreatorHandle(raw);
44+
expect(raw).toBe('ARivers');
45+
});
46+
});

0 commit comments

Comments
 (0)