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
4 changes: 4 additions & 0 deletions src/components/common/CreatorCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import KeySupplyBadge from '@/components/common/KeySupplyBadge';
import CreatorListRowDivider from '@/components/common/CreatorListRowDivider';
import BuyActionHelperText from '@/components/common/BuyActionHelperText';
import CreatorLabeledStatRow from '@/components/common/CreatorLabeledStatRow';
import { useTransactionTelemetry } from '@/hooks/useTransactionTelemetry';

interface CreatorCardProps {
creator: Course;
Expand All @@ -31,9 +32,11 @@ const CreatorCard: React.FC<CreatorCardProps> = ({ creator, className }) => {
'idle' | 'submitting' | 'failed' | 'success'
>('idle');
const hasFailedOnceRef = useRef(false);
const trackTransactionEvent = useTransactionTelemetry();

const runPurchaseAttempt = () => {
setTransactionState('submitting');
trackTransactionEvent('tx_submitted', { creatorId: creator.id, creatorTitle: creator.title });
showToast.loading(`Purchasing keys for ${creator.title}...`);

window.setTimeout(() => {
Expand All @@ -47,6 +50,7 @@ const CreatorCard: React.FC<CreatorCardProps> = ({ creator, className }) => {

hasFailedOnceRef.current = false;
setTransactionState('success');
trackTransactionEvent('tx_confirmed', { creatorId: creator.id, creatorTitle: creator.title });
showToast.transactionSuccess(
'Purchase Successful!',
`You successfully bought a key for ${creator.title}`,
Expand Down
25 changes: 25 additions & 0 deletions src/components/common/EmptyTransactionTimelineState.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { Button } from '@/components/ui/button';
import { Clock3 } from 'lucide-react';

const EmptyTransactionTimelineState: React.FC = () => {
return (
<section className="rounded-2xl border border-white/10 bg-white/5 p-6 md:p-8">
<div className="mx-auto max-w-2xl text-center">
<div className="mx-auto mb-4 inline-flex size-12 items-center justify-center rounded-full border border-white/15 bg-white/10 text-amber-300">
<Clock3 className="size-5" />
</div>
<h3 className="font-grotesque text-2xl font-bold text-white">
No transactions yet
</h3>
<p className="mt-2 text-sm text-white/65">
New users will see transaction milestones here after submitting their first buy or sell action.
</p>
<div className="mt-5 flex justify-center">
<Button className="rounded-xl">Buy your first key</Button>
</div>
</div>
</section>
);
};

export default EmptyTransactionTimelineState;
11 changes: 11 additions & 0 deletions src/hooks/useTransactionTelemetry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { useCallback } from 'react';

type TelemetryEvent = 'tx_submitted' | 'tx_confirmed';

export function useTransactionTelemetry() {
return useCallback((event: TelemetryEvent, metadata: Record<string, unknown>) => {
// Scoped client-side telemetry hook for transaction milestones.
// Replace with backend analytics transport when available.
console.info('[telemetry]', event, metadata);
}, []);
}
130 changes: 115 additions & 15 deletions src/pages/LandingPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import MarketplaceSection from '@/components/common/MarketplaceSection';
import { ProfileTabPillGroup } from '@/components/common/ProfileTabPill';
import CreatorBreadcrumb from '@/components/common/CreatorBreadcrumb';
import CreatorProfileHeader from '@/components/common/CreatorProfileHeader';
import TransactionRetryNotice from '@/components/common/TransactionRetryNotice';
import EmptyTransactionTimelineState from '@/components/common/EmptyTransactionTimelineState';

const FEATURED_CREATOR_FACTS = [
{ label: 'Membership', value: 'Collectors Circle' },
Expand Down Expand Up @@ -107,45 +109,83 @@ const DEMO_CREATORS: Course[] = [
},
];

const CREATOR_SORT_KEY = 'accesslayer.creator-sort';
const MAX_CREATOR_FETCH_RETRIES = 3;
const BASE_RETRY_DELAY_MS = 800;

type SortOption = 'featured' | 'price-asc' | 'price-desc' | 'supply-desc';

function LandingPage() {
const [creators, setCreators] = useState<Course[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [activeProfileTab, setActiveProfileTab] = useState('overview');
const [sortOption, setSortOption] = useState<SortOption>(() => {
if (typeof window === 'undefined') return 'featured';
const saved = window.localStorage.getItem(CREATOR_SORT_KEY) as SortOption | null;
return saved ?? 'featured';
});
const [fetchRetryAttempt, setFetchRetryAttempt] = useState(0);
const [showRetryBanner, setShowRetryBanner] = useState(false);
const [finalFetchError, setFinalFetchError] = useState('');

const trimmedSearchQuery = searchQuery.trim();
const hasInvalidSearchInput = /[^a-zA-Z0-9_\s-]/.test(trimmedSearchQuery);
const searchValidationMessage = hasInvalidSearchInput
? 'Only letters, numbers, spaces, hyphens, and underscores are supported.'
: undefined;

useEffect(() => {
if (typeof window !== 'undefined') {
window.localStorage.setItem(CREATOR_SORT_KEY, sortOption);
}
}, [sortOption]);

useEffect(() => {
const fetchCreators = async () => {
setIsLoading(true);
setShowRetryBanner(false);
setFinalFetchError('');
try {
const data = await courseService.getCourses();
if (data && data.length > 0) {
setCreators(data);
} else {
setCreators(DEMO_CREATORS);
}
} catch (error) {
console.error('Failed to fetch creators:', error);
setFetchRetryAttempt(0);
} catch {
if (fetchRetryAttempt < MAX_CREATOR_FETCH_RETRIES) {
const nextAttempt = fetchRetryAttempt + 1;
setShowRetryBanner(true);
const backoffDelay = Math.min(
BASE_RETRY_DELAY_MS * 2 ** fetchRetryAttempt,
5000
);
window.setTimeout(() => setFetchRetryAttempt(nextAttempt), backoffDelay);
return;
}

setFinalFetchError(
'Unable to load live creators right now. Showing fallback creators.'
);
setShowRetryBanner(false);
setFetchRetryAttempt(0);
setCreators(DEMO_CREATORS);
} finally {
setTimeout(() => setIsLoading(false), 800);
}
};

fetchCreators();
}, []);
}, [fetchRetryAttempt]);

const filteredCreators = useMemo(() => {
if (hasInvalidSearchInput) {
return [];
}

return creators.filter(
const filtered = creators.filter(
creator =>
creator.title
.toLowerCase()
Expand All @@ -154,7 +194,24 @@ function LandingPage() {
.toLowerCase()
.includes(trimmedSearchQuery.toLowerCase())
);
}, [creators, trimmedSearchQuery, hasInvalidSearchInput]);
const sorted = [...filtered];
switch (sortOption) {
case 'price-asc':
sorted.sort((a, b) => (a.price ?? 0) - (b.price ?? 0));
break;
case 'price-desc':
sorted.sort((a, b) => (b.price ?? 0) - (a.price ?? 0));
break;
case 'supply-desc':
sorted.sort(
(a, b) => (b.creatorShareSupply ?? 0) - (a.creatorShareSupply ?? 0)
);
break;
default:
break;
}
return sorted;
}, [creators, trimmedSearchQuery, hasInvalidSearchInput, sortOption]);

const handleResetSearch = () => setSearchQuery('');

Expand Down Expand Up @@ -200,12 +257,35 @@ function LandingPage() {
onReset={handleResetSearch}
showReset={searchQuery.length > 0}
>
<SearchBar
value={searchQuery}
onChange={setSearchQuery}
validationMessage={searchValidationMessage}
className="max-w-none shadow-2xl shadow-black/20"
/>
<div className="space-y-3">
<SearchBar
value={searchQuery}
onChange={setSearchQuery}
validationMessage={searchValidationMessage}
className="max-w-none shadow-2xl shadow-black/20"
/>
<div className="flex items-center gap-3">
<label
htmlFor="creator-sort"
className="text-xs font-semibold uppercase tracking-[0.16em] text-white/60"
>
Sort
</label>
<select
id="creator-sort"
value={sortOption}
onChange={e =>
setSortOption(e.target.value as SortOption)
}
className="h-9 rounded-lg border border-white/15 bg-slate-950/80 px-3 text-sm text-white outline-none focus:border-amber-400/60"
>
<option value="featured">Featured</option>
<option value="price-asc">Price: Low to high</option>
<option value="price-desc">Price: High to low</option>
<option value="supply-desc">Supply: High to low</option>
</select>
</div>
</div>
</StickyFilterBar>

<SectionDivider title="Marketplace results" spacing="default" />
Expand All @@ -221,10 +301,25 @@ function LandingPage() {
{isLoading ? (
<CreatorGridSkeleton count={6} />
) : filteredCreators.length > 0 ? (
<div className="grid grid-cols-1 gap-8 sm:grid-cols-2 lg:grid-cols-3">
{filteredCreators.map(creator => (
<CreatorCard key={creator.id} creator={creator} />
))}
<div className="space-y-4">
{showRetryBanner && (
<TransactionRetryNotice
title="Loading live creators"
message={`Fetch failed, retrying with capped backoff (attempt ${fetchRetryAttempt + 1} of ${MAX_CREATOR_FETCH_RETRIES + 1}).`}
retryLabel="Retry now"
onRetry={() => setFetchRetryAttempt(0)}
/>
)}
{finalFetchError && (
<div className="rounded-xl border border-amber-500/25 bg-amber-500/10 px-4 py-3 text-sm text-amber-200">
{finalFetchError}
</div>
)}
<div className="grid grid-cols-1 gap-8 sm:grid-cols-2 lg:grid-cols-3">
{filteredCreators.map(creator => (
<CreatorCard key={creator.id} creator={creator} />
))}
</div>
</div>
) : (
<div className="flex justify-center py-12">
Expand Down Expand Up @@ -294,6 +389,11 @@ function LandingPage() {
/>
</div>
</MarketplaceSection>

<SectionDivider title="Transaction timeline pattern" spacing="relaxed" />
<MarketplaceSection spacing="relaxed">
<EmptyTransactionTimelineState />
</MarketplaceSection>
</div>
</main>
);
Expand Down
Loading