diff --git a/src/components/common/CreatorCard.tsx b/src/components/common/CreatorCard.tsx index 1a6a4802..69d2c451 100644 --- a/src/components/common/CreatorCard.tsx +++ b/src/components/common/CreatorCard.tsx @@ -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'; @@ -101,7 +101,7 @@ const CreatorCard: React.FC = ({ creator, className }) => { return (
@@ -215,34 +215,30 @@ const CreatorCard: React.FC = ({ creator, className }) => {
- + {transactionState === 'success' + ? 'Completed' + : transactionState === 'failed' + ? 'Retry Purchase' + : 'Buy Key'} +
diff --git a/src/components/common/TransactionFailureDrawer.tsx b/src/components/common/TransactionFailureDrawer.tsx index 8559c218..80dff8e8 100644 --- a/src/components/common/TransactionFailureDrawer.tsx +++ b/src/components/common/TransactionFailureDrawer.tsx @@ -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; @@ -43,6 +44,9 @@ const TransactionFailureDrawer: React.FC = ({ onOpenChange?.(false); }; + const absoluteTimestamp = formatAbsoluteDateTime(failureDetails.timestamp); + const relativeTimestamp = formatRelativeTime(failureDetails.timestamp); + return ( @@ -58,6 +62,24 @@ const TransactionFailureDrawer: React.FC = ({
+ {failureDetails.timestamp && ( +
+

+ Time +

+

+ {relativeTimestamp} + {absoluteTimestamp ? ( + + ({absoluteTimestamp}) + + ) : null} +

+
+ )}

Error Message diff --git a/src/components/ui/async-button.tsx b/src/components/ui/async-button.tsx new file mode 100644 index 00000000..16d8b5c7 --- /dev/null +++ b/src/components/ui/async-button.tsx @@ -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 { + isPending?: boolean; + pendingText?: string; + spinnerClassName?: string; +} + +export function AsyncButton({ + isPending = false, + pendingText, + disabled, + className, + children, + spinnerClassName, + ...props +}: AsyncButtonProps) { + return ( + + ); +} diff --git a/src/pages/LandingPage.tsx b/src/pages/LandingPage.tsx index 4f76e285..128f5859 100644 --- a/src/pages/LandingPage.tsx +++ b/src/pages/LandingPage.tsx @@ -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'; @@ -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'; @@ -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(null); const trimmedSearchQuery = searchQuery.trim(); const hasInvalidSearchInput = /[^a-zA-Z0-9_\s-]/.test(trimmedSearchQuery); @@ -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); @@ -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) => { @@ -366,10 +424,37 @@ function LandingPage() {

)}
- {filteredCreators.map(creator => ( + {pagedCreators.map(creator => ( ))}
+
+ + + Page {safePage + 1} of {totalPages} + + +
) : (
diff --git a/src/utils/keyPrice.utils.ts b/src/utils/keyPrice.utils.ts index 8d054f7a..2ae20b7c 100644 --- a/src/utils/keyPrice.utils.ts +++ b/src/utils/keyPrice.utils.ts @@ -1,3 +1,5 @@ +import { formatRelativeTime as sharedFormatRelativeTime } from '@/utils/time.utils'; + export interface TooltipContent { lastUpdated?: string | null; quoteSource?: string | null; @@ -5,22 +7,6 @@ export interface TooltipContent { 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; } diff --git a/src/utils/time.utils.ts b/src/utils/time.utils.ts new file mode 100644 index 00000000..813690d2 --- /dev/null +++ b/src/utils/time.utils.ts @@ -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(' '); +} +