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
28 changes: 12 additions & 16 deletions src/components/common/CreatorCard.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useRef, useState } from 'react';
import { useAccount } from 'wagmi';
import { Button } from '@/components/ui/button';
import { AsyncButton } from '@/components/ui/async-button';
import type { Course } from '@/services/course.service';
import { cn } from '@/lib/utils';
import { ShoppingCart, Link as LinkIcon, TrendingUp } from 'lucide-react';
Expand Down Expand Up @@ -101,7 +101,7 @@ const CreatorCard: React.FC<CreatorCardProps> = ({ creator, className }) => {
return (
<div
className={cn(
'group relative overflow-hidden rounded-2xl border cursor-pointer border-white/10 bg-white/5 p-4 transition-all duration-300 md:hover:-translate-y-0.5 md:hover:border-amber-500/25 md:hover:bg-white/[0.08] md:hover:shadow-[0_12px_32px_-20px_rgba(251,191,36,0.5)]',
'group relative overflow-hidden rounded-2xl border border-white/10 bg-white/5 p-4 transition-all duration-300 focus-within:ring-2 focus-within:ring-amber-400/40 focus-within:ring-offset-2 focus-within:ring-offset-slate-950 md:hover:-translate-y-0.5 md:hover:border-amber-500/25 md:hover:bg-white/[0.08] md:hover:shadow-[0_12px_32px_-20px_rgba(251,191,36,0.5)]',
className
)}
>
Expand Down Expand Up @@ -215,34 +215,30 @@ const CreatorCard: React.FC<CreatorCardProps> = ({ creator, className }) => {
</div>

<div className="flex items-center justify-end gap-4">
<Button
<AsyncButton
onClick={handleBuy}
variant={isConnected ? 'default' : 'outline'}
size="sm"
disabled={transactionState === 'submitting'}
isPending={transactionState === 'submitting'}
pendingText="Processing..."
className={cn(
'rounded-xl font-bold cursor-pointer ',
'rounded-xl font-bold',
!isConnected && 'border-white/10 hover:bg-white/5'
)}
>
{transactionState === 'success' && (
<TransactionStatusIcon status="success" className="mr-2" />
)}
{transactionState === 'submitting' && (
<TransactionStatusIcon status="pending" className="mr-2" />
)}
{transactionState === 'failed' && (
<TransactionStatusIcon status="failed" className="mr-2" />
)}
<ShoppingCart className="mr-2 size-4" />
{transactionState === 'submitting'
? 'Processing...'
: transactionState === 'success'
? 'Completed'
: transactionState === 'failed'
? 'Retry Purchase'
: 'Buy Key'}
</Button>
{transactionState === 'success'
? 'Completed'
: transactionState === 'failed'
? 'Retry Purchase'
: 'Buy Key'}
</AsyncButton>
</div>

<BuyActionHelperText state={transactionState} className="mt-4" />
Expand Down
22 changes: 22 additions & 0 deletions src/components/common/TransactionFailureDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
import { Button } from '@/components/ui/button';
import { AlertCircle, Copy } from 'lucide-react';
import toast from 'react-hot-toast';
import { formatAbsoluteDateTime, formatRelativeTime } from '@/utils/time.utils';

export interface TransactionFailureDetails {
txHash?: string;
Expand Down Expand Up @@ -43,6 +44,9 @@ const TransactionFailureDrawer: React.FC<TransactionFailureDrawerProps> = ({
onOpenChange?.(false);
};

const absoluteTimestamp = formatAbsoluteDateTime(failureDetails.timestamp);
const relativeTimestamp = formatRelativeTime(failureDetails.timestamp);

return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
Expand All @@ -58,6 +62,24 @@ const TransactionFailureDrawer: React.FC<TransactionFailureDrawerProps> = ({
</DialogHeader>

<div className="space-y-4 border-t border-white/5 pt-4">
{failureDetails.timestamp && (
<div>
<p className="text-sm font-medium text-white/70 mb-2">
Time
</p>
<p
className="text-sm text-white/80 rounded-lg bg-white/5 p-3"
title={absoluteTimestamp ?? undefined}
>
{relativeTimestamp}
{absoluteTimestamp ? (
<span className="ml-2 text-white/45">
({absoluteTimestamp})
</span>
) : null}
</p>
</div>
)}
<div>
<p className="text-sm font-medium text-white/70 mb-2">
Error Message
Expand Down
37 changes: 37 additions & 0 deletions src/components/ui/async-button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import * as React from 'react';
import { Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';

export interface AsyncButtonProps extends React.ComponentProps<typeof Button> {
isPending?: boolean;
pendingText?: string;
spinnerClassName?: string;
}

export function AsyncButton({
isPending = false,
pendingText,
disabled,
className,
children,
spinnerClassName,
...props
}: AsyncButtonProps) {
return (
<Button
{...props}
disabled={disabled || isPending}
aria-busy={isPending || undefined}
className={cn(className)}
>
{isPending && (
<Loader2
className={cn('mr-2 size-4 animate-spin', spinnerClassName)}
aria-hidden="true"
/>
)}
{isPending && pendingText ? pendingText : children}
</Button>
);
}
89 changes: 87 additions & 2 deletions src/pages/LandingPage.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState, useEffect, useMemo } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { courseService, type Course } from '@/services/course.service';
import SearchBar from '@/components/common/SearchBar';
import StickyFilterBar from '@/components/common/StickyFilterBar';
Expand Down Expand Up @@ -114,8 +114,11 @@ const DEMO_CREATORS: Course[] = [
];

const CREATOR_SORT_KEY = 'accesslayer.creator-sort';
const CREATOR_PAGE_KEY = 'accesslayer.creator-page';
const CREATOR_SCROLL_KEY = 'accesslayer.creator-scrollY';
const MAX_CREATOR_FETCH_RETRIES = 3;
const BASE_RETRY_DELAY_MS = 800;
const PAGE_SIZE = 6;

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

Expand All @@ -137,6 +140,13 @@ function LandingPage() {
const [fetchRetryAttempt, setFetchRetryAttempt] = useState(0);
const [showRetryBanner, setShowRetryBanner] = useState(false);
const [finalFetchError, setFinalFetchError] = useState('');
const [page, setPage] = useState(() => {
if (typeof window === 'undefined') return 0;
const saved = window.sessionStorage.getItem(CREATOR_PAGE_KEY);
const parsed = saved ? Number(saved) : 0;
return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0;
});
const pendingScrollRestoreRef = useRef<number | null>(null);

const trimmedSearchQuery = searchQuery.trim();
const hasInvalidSearchInput = /[^a-zA-Z0-9_\s-]/.test(trimmedSearchQuery);
Expand All @@ -150,6 +160,29 @@ function LandingPage() {
}
}, [sortOption]);

useEffect(() => {
if (typeof window === 'undefined') return;
window.sessionStorage.setItem(CREATOR_PAGE_KEY, String(page));
}, [page]);

useEffect(() => {
if (typeof window === 'undefined') return;
const handleScroll = () => {
window.sessionStorage.setItem(CREATOR_SCROLL_KEY, String(window.scrollY));
};
window.addEventListener('scroll', handleScroll, { passive: true });
return () => window.removeEventListener('scroll', handleScroll);
}, []);

useEffect(() => {
if (typeof window === 'undefined') return;
const savedScroll = window.sessionStorage.getItem(CREATOR_SCROLL_KEY);
if (!savedScroll) return;
const parsed = Number(savedScroll);
if (!Number.isFinite(parsed)) return;
window.scrollTo({ top: parsed });
}, []);

useEffect(() => {
const fetchCreators = async () => {
setIsLoading(true);
Expand Down Expand Up @@ -222,6 +255,31 @@ function LandingPage() {
return sorted;
}, [creators, trimmedSearchQuery, hasInvalidSearchInput, sortOption]);

useEffect(() => {
setPage(0);
}, [trimmedSearchQuery, sortOption]);

const totalPages = Math.max(1, Math.ceil(filteredCreators.length / PAGE_SIZE));
const safePage = Math.min(page, totalPages - 1);
const pagedCreators = useMemo(() => {
const start = safePage * PAGE_SIZE;
return filteredCreators.slice(start, start + PAGE_SIZE);
}, [filteredCreators, safePage]);

useEffect(() => {
if (pendingScrollRestoreRef.current == null) return;
const target = pendingScrollRestoreRef.current;
pendingScrollRestoreRef.current = null;
requestAnimationFrame(() => {
window.scrollTo({ top: target });
});
}, [safePage, pagedCreators.length]);

const handlePageChange = (nextPage: number) => {
pendingScrollRestoreRef.current = window.scrollY;
setPage(nextPage);
};

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

const openTradeDialog = (side: TradeSide) => {
Expand Down Expand Up @@ -366,10 +424,37 @@ function LandingPage() {
</div>
)}
<div className="grid grid-cols-1 gap-8 sm:grid-cols-2 lg:grid-cols-3">
{filteredCreators.map(creator => (
{pagedCreators.map(creator => (
<CreatorCard key={creator.id} creator={creator} />
))}
</div>
<div className="mt-8 flex items-center justify-center gap-3">
<Button
type="button"
variant="outline"
size="sm"
disabled={safePage === 0}
onClick={() => handlePageChange(Math.max(0, safePage - 1))}
>
Previous
</Button>
<span className="text-xs text-white/60">
Page {safePage + 1} of {totalPages}
</span>
<Button
type="button"
variant="outline"
size="sm"
disabled={safePage >= totalPages - 1}
onClick={() =>
handlePageChange(
Math.min(totalPages - 1, safePage + 1)
)
}
>
Next
</Button>
</div>
</div>
) : (
<div className="flex justify-center py-12">
Expand Down
22 changes: 4 additions & 18 deletions src/utils/keyPrice.utils.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,12 @@
import { formatRelativeTime as sharedFormatRelativeTime } from '@/utils/time.utils';

export interface TooltipContent {
lastUpdated?: string | null;
quoteSource?: string | null;
}

export function formatRelativeTime(iso: string | null | undefined): string {
if (iso == null) return 'Last updated: N/A';

const date = new Date(iso);
if (isNaN(date.getTime())) return 'Last updated: N/A';

const diffMs = Date.now() - date.getTime();
const diffSec = Math.floor(diffMs / 1000);

// Future timestamps or < 60s → "just now"
if (diffSec < 60) return 'just now';

const diffMin = Math.floor(diffSec / 60);
if (diffMin < 60) return `Updated ${diffMin} min ago`;

const diffHr = Math.floor(diffMin / 60);
if (diffHr < 24) return `Updated ${diffHr} hr ago`;

const diffDay = Math.floor(diffHr / 24);
return `Updated ${diffDay} day${diffDay === 1 ? '' : 's'} ago`;
const relative = sharedFormatRelativeTime(iso, { prefix: 'Updated' });
return relative === 'N/A' ? 'Last updated: N/A' : relative;
}
65 changes: 65 additions & 0 deletions src/utils/time.utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
export interface RelativeTimeOptions {
/**
* Optional prefix included before the relative value.
* Example: prefix="Updated" -> "Updated 2 min ago"
*/
prefix?: string;
/**
* Controls whether to include "ago" wording for past times.
* Defaults to true.
*/
includeAgo?: boolean;
}

export function formatAbsoluteDateTime(
input: string | number | Date | null | undefined
): string | null {
if (input == null) return null;
const date = input instanceof Date ? input : new Date(input);
if (Number.isNaN(date.getTime())) return null;

return new Intl.DateTimeFormat(undefined, {
year: 'numeric',
month: 'short',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
}).format(date);
}

export function formatRelativeTime(
input: string | number | Date | null | undefined,
options: RelativeTimeOptions = {}
): string {
const { prefix, includeAgo = true } = options;
if (input == null) return prefix ? `${prefix}: N/A` : 'N/A';

const date = input instanceof Date ? input : new Date(input);
if (Number.isNaN(date.getTime())) return prefix ? `${prefix}: N/A` : 'N/A';

const diffMs = Date.now() - date.getTime();
const diffSec = Math.floor(Math.abs(diffMs) / 1000);

const isFuture = diffMs < 0;
if (diffSec < 60) return prefix ? `${prefix} just now` : 'just now';

const diffMin = Math.floor(diffSec / 60);
if (diffMin < 60) {
const core = `${diffMin} min`;
const suffix = isFuture ? 'from now' : includeAgo ? 'ago' : '';
return [prefix, core, suffix].filter(Boolean).join(' ');
}

const diffHr = Math.floor(diffMin / 60);
if (diffHr < 24) {
const core = `${diffHr} hr`;
const suffix = isFuture ? 'from now' : includeAgo ? 'ago' : '';
return [prefix, core, suffix].filter(Boolean).join(' ');
}

const diffDay = Math.floor(diffHr / 24);
const core = `${diffDay} day${diffDay === 1 ? '' : 's'}`;
const suffix = isFuture ? 'from now' : includeAgo ? 'ago' : '';
return [prefix, core, suffix].filter(Boolean).join(' ');
}

Loading