Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 99 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,105 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
</head>
<body>
<!--
#299: No-script fallback for key UI actions. The marketplace is a
client-rendered SPA (Vite), so without JavaScript the `#root`
container stays empty and every interactive surface — search,
filters, buy/sell, wallet connect, profile pattern — is
unavailable. This block is the one place a real user with JS
disabled can see explanatory copy; the same noscript element
covers the whole interactive area in a single place rather than
being sprinkled per-action across React components (which would
never mount without JS anyway).

Styling is inlined so it works even if external CSS is not
fetched, and the tone matches the rest of the app.
-->
<noscript>
<style>
.noscript-fallback {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem 1.5rem;
background: linear-gradient(
160deg,
#08111f 0%,
#10213b 45%,
#f0b14d 160%
);
color: #ffffff;
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
sans-serif;
}
.noscript-fallback__card {
max-width: 36rem;
padding: 2rem;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 1.5rem;
background: rgba(8, 17, 31, 0.75);
backdrop-filter: blur(8px);
text-align: center;
}
.noscript-fallback__eyebrow {
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.25em;
text-transform: uppercase;
color: rgba(251, 191, 36, 0.85);
margin-bottom: 0.5rem;
}
.noscript-fallback__title {
font-size: clamp(1.5rem, 4vw, 2.25rem);
font-weight: 900;
letter-spacing: -0.02em;
margin: 0.5rem 0 1rem 0;
}
.noscript-fallback__body {
font-size: 1rem;
line-height: 1.5;
color: rgba(255, 255, 255, 0.7);
margin: 0 0 1.25rem 0;
}
.noscript-fallback__list {
text-align: left;
padding-left: 1.25rem;
margin: 0 0 1.25rem 0;
color: rgba(255, 255, 255, 0.75);
font-size: 0.9rem;
line-height: 1.6;
}
.noscript-fallback__footer {
font-size: 0.85rem;
color: rgba(255, 255, 255, 0.55);
margin: 0;
}
</style>
<div class="noscript-fallback" role="alert">
<div class="noscript-fallback__card">
<p class="noscript-fallback__eyebrow">JavaScript required</p>
<h1 class="noscript-fallback__title">
This marketplace needs JavaScript to run
</h1>
<p class="noscript-fallback__body">
Access Layer is an interactive client-rendered app. The
key actions on this page rely on JavaScript:
</p>
<ul class="noscript-fallback__list">
<li>Browsing and searching creator profiles</li>
<li>Connecting a Stellar wallet</li>
<li>Buying or selling creator keys</li>
<li>Viewing live prices and transaction status</li>
</ul>
<p class="noscript-fallback__footer">
Please enable JavaScript in your browser settings and
refresh the page to continue.
</p>
</div>
</div>
</noscript>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
Expand Down
55 changes: 49 additions & 6 deletions src/components/common/CreatorCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<CreatorCardProps> = ({ creator, className }) => {
const CreatorCard: React.FC<CreatorCardProps> = ({
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<
Expand Down Expand Up @@ -158,15 +174,15 @@ const CreatorCard: React.FC<CreatorCardProps> = ({ creator, className }) => {
<KeySupplyBadge supply={creator.creatorShareSupply} />
</div>
<p className="marketplace-label-muted font-jakarta text-sm">
@{creator.instructorId || 'creator'}
{displayInstructorHandle}
</p>

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

{creator.socialHandle ? (
<div className="marketplace-label-muted mt-2 flex items-center gap-1.5 text-xs">
<LinkIcon className="creator-action-icon text-amber-500/70" />
<span className="truncate">@{creator.socialHandle}</span>
<span className="truncate">{displaySocialHandle}</span>
</div>
) : (
<div
Expand Down Expand Up @@ -204,12 +220,12 @@ const CreatorCard: React.FC<CreatorCardProps> = ({ creator, className }) => {
}
value={
creator.socialHandle
? `@${creator.socialHandle}`
? displaySocialHandle
: 'No public handle'
}
valueTitle={
creator.socialHandle
? `@${creator.socialHandle}`
? displaySocialHandle
: undefined
}
valueClassName={
Expand All @@ -220,7 +236,34 @@ const CreatorCard: React.FC<CreatorCardProps> = ({ creator, className }) => {
/>
<CardMetaRow
label="Key Price"
value={`${formatNumber(creator.price)} ETH`}
// During a background price refresh (#305) the value
// stays visible — we only swap to a muted style and add
// an `aria-busy` marker so assistive tech announces that
// the figure may change. Wrapping the text in a fixed-
// width container preserves layout so the badge does
// not shift while refreshing.
value={
<span
aria-busy={isPriceRefreshing || undefined}
data-testid="creator-card-price-badge"
className={cn(
'inline-flex min-w-[6.5rem] items-center gap-1.5 tabular-nums',
isPriceRefreshing && 'opacity-60'
)}
>
{isPriceRefreshing && (
<span
aria-hidden="true"
data-testid="creator-card-price-refresh-indicator"
className="inline-block size-3 shrink-0 animate-spin rounded-full border-2 border-amber-400/30 border-t-amber-400"
/>
)}
<span>{`${formatNumber(creator.price)} ETH`}</span>
{isPriceRefreshing && (
<span className="sr-only">Refreshing price</span>
)}
</span>
}
truncateValue={false}
valueClassName="font-grotesque text-base font-black text-amber-400"
/>
Expand Down
9 changes: 7 additions & 2 deletions src/components/common/CreatorProfileHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -31,13 +32,17 @@ const CreatorProfileHeader: React.FC<CreatorProfileHeaderProps> = ({
}) => {
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) {
Expand Down Expand Up @@ -97,7 +102,7 @@ const CreatorProfileHeader: React.FC<CreatorProfileHeaderProps> = ({
CREATOR_PROFILE_SUBTITLE_WRAP_CLASS_NAME
)}
>
@{handle}
{displayHandle || `@${handle}`}
</p>
<CreatorBio bio={bio} variant="profile" className="mt-2 max-w-md" />
</div>
Expand Down
41 changes: 35 additions & 6 deletions src/pages/LandingPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -440,7 +460,11 @@ function LandingPage() {
};

return (
<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">
// #306: the outer wrapper is just a decorative shell; the actual
// landmark structure is a top-level <header> sibling of the <main>
// below, so screen-reader landmark navigation lands directly on the
// marketplace content rather than on the brand banner.
<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">
<div className="absolute left-[-4rem] top-[10%] size-72 rounded-full bg-amber-300/20 blur-[100px]" />
<div className="absolute bottom-[8%] right-[-3rem] size-72 rounded-full bg-emerald-300/15 blur-[100px]" />
<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%)]" />
Expand Down Expand Up @@ -471,9 +495,13 @@ function LandingPage() {
</div>
</MarketplaceSection>

<SectionDivider title="Discover creators" spacing="relaxed" />
<main
id="creator-marketplace-main"
aria-label="Creator marketplace"
>
<SectionDivider title="Discover creators" spacing="relaxed" />

<StickyFilterBar
<StickyFilterBar
eyebrow="Marketplace filters"
title="Find creators without losing your place"
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."
Expand Down Expand Up @@ -536,7 +564,7 @@ function LandingPage() {
</div>
<div className="grid grid-cols-1 gap-8 sm:grid-cols-2 lg:grid-cols-3 opacity-50">
{pagedCreators.map(creator => (
<CreatorCard key={creator.id} creator={creator} />
<CreatorCard key={creator.id} creator={creator} isPriceRefreshing={isPriceRefreshing} />
))}
</div>
</div>
Expand All @@ -560,7 +588,7 @@ function LandingPage() {
)}
<div className="grid grid-cols-1 gap-8 sm:grid-cols-2 lg:grid-cols-3">
{pagedCreators.map(creator => (
<CreatorCard key={creator.id} creator={creator} />
<CreatorCard key={creator.id} creator={creator} isPriceRefreshing={isPriceRefreshing} />
))}
</div>
<div className="mt-8 flex items-center justify-center gap-3">
Expand Down Expand Up @@ -806,6 +834,7 @@ function LandingPage() {
<MarketplaceSection spacing="relaxed">
<EmptyTransactionTimelineState />
</MarketplaceSection>
</main>
</div>

<TradeDialog
Expand All @@ -826,7 +855,7 @@ function LandingPage() {
description="Waiting for Stellar confirmation, then refreshing holdings."
/>
<ScrollToTop />
</main>
</div>
);
}

Expand Down
46 changes: 46 additions & 0 deletions src/utils/__tests__/handleDisplay.utils.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
Loading
Loading