diff --git a/src/components/common/CreatorCard.tsx b/src/components/common/CreatorCard.tsx index 562e48a..a7ffb3b 100644 --- a/src/components/common/CreatorCard.tsx +++ b/src/components/common/CreatorCard.tsx @@ -27,6 +27,7 @@ import CreatorListRowDivider from '@/components/common/CreatorListRowDivider'; import BuyActionHelperText from '@/components/common/BuyActionHelperText'; import NetworkFeeHint from '@/components/common/NetworkFeeHint'; import CreatorBio from '@/components/common/CreatorBio'; +import { CREATOR_CARD_MEDIA_RADIUS_CLASS } from '@/utils/creatorCardTokens'; interface CreatorCardProps { creator: Course; @@ -135,7 +136,10 @@ const CreatorCard: React.FC = ({ )} >
diff --git a/src/components/common/CreatorProfileHeader.tsx b/src/components/common/CreatorProfileHeader.tsx index a80aaca..886795c 100644 --- a/src/components/common/CreatorProfileHeader.tsx +++ b/src/components/common/CreatorProfileHeader.tsx @@ -8,6 +8,7 @@ import VerifiedBadge from '@/components/common/VerifiedBadge'; import CreatorInitialsAvatar from '@/components/common/CreatorInitialsAvatar'; import CreatorBio from '@/components/common/CreatorBio'; import { formatCreatorHandle } from '@/utils/handleDisplay.utils'; +import { CREATOR_CARD_MEDIA_RADIUS_CLASS } from '@/utils/creatorCardTokens'; interface CreatorProfileHeaderProps { name: string; @@ -80,7 +81,10 @@ const CreatorProfileHeader: React.FC = ({ >
diff --git a/src/components/common/CreatorProfileInfoGrid.tsx b/src/components/common/CreatorProfileInfoGrid.tsx index 28924b6..8a735ea 100644 --- a/src/components/common/CreatorProfileInfoGrid.tsx +++ b/src/components/common/CreatorProfileInfoGrid.tsx @@ -1,6 +1,7 @@ import type { ReactNode } from 'react'; import { cn } from '@/lib/utils'; import CreatorProfileStatItem from './CreatorProfileStatItem'; +import { CREATOR_CARD_STAT_GRID_GAP_CLASS } from '@/utils/creatorCardTokens'; interface CreatorProfileInfoItem { label: string; @@ -20,7 +21,8 @@ const CreatorProfileInfoGrid: React.FC = ({ return (
diff --git a/src/components/common/CreatorProfileStatRow.tsx b/src/components/common/CreatorProfileStatRow.tsx index 4eb8f6f..0a25362 100644 --- a/src/components/common/CreatorProfileStatRow.tsx +++ b/src/components/common/CreatorProfileStatRow.tsx @@ -2,6 +2,7 @@ import { cn } from '@/lib/utils'; import CreatorProfileStatItem from './CreatorProfileStatItem'; import Skeleton from '@/components/ui/skeleton'; import type { ReactNode } from 'react'; +import { CREATOR_CARD_STAT_GRID_GAP_CLASS } from '@/utils/creatorCardTokens'; interface CreatorProfileStatItemData { label: string; @@ -24,7 +25,8 @@ const CreatorProfileStatRowSkeleton: React.FC<{ return (
@@ -66,7 +68,8 @@ const CreatorProfileStatRow: React.FC = ({ return (
@@ -82,4 +85,4 @@ const CreatorProfileStatRow: React.FC = ({ ); }; -export default CreatorProfileStatRow; \ No newline at end of file +export default CreatorProfileStatRow; diff --git a/src/components/common/CreatorSkeleton.tsx b/src/components/common/CreatorSkeleton.tsx index a16fcf8..3f5aa3c 100644 --- a/src/components/common/CreatorSkeleton.tsx +++ b/src/components/common/CreatorSkeleton.tsx @@ -1,4 +1,5 @@ import { cn } from '@/lib/utils'; +import { CREATOR_CARD_MEDIA_RADIUS_CLASS } from '@/utils/creatorCardTokens'; interface CreatorSkeletonProps { className?: string; @@ -25,7 +26,8 @@ const CreatorSkeleton: React.FC = ({ >
diff --git a/src/components/common/StellarConnectionQualityBadge.tsx b/src/components/common/StellarConnectionQualityBadge.tsx new file mode 100644 index 0000000..88c5c35 --- /dev/null +++ b/src/components/common/StellarConnectionQualityBadge.tsx @@ -0,0 +1,68 @@ +import { type ReactNode } from 'react'; +import { Wifi, WifiOff, SignalHigh, SignalLow } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { useStellarConnectionQuality } from '@/hooks/useStellarConnectionQuality'; +import { + formatStellarConnectionQualityLabel, + type StellarConnectionQualitySnapshot, +} from '@/utils/stellarConnectionQuality.utils'; + +interface StellarConnectionQualityBadgeProps { + className?: string; +} + +const qualityStyles: Record< + StellarConnectionQualitySnapshot['quality'], + { className: string; icon: ReactNode } +> = { + excellent: { + className: + 'border-emerald-300/25 bg-emerald-400/10 text-emerald-100', + icon: , + }, + good: { + className: 'border-emerald-300/20 bg-emerald-400/8 text-emerald-100', + icon: , + }, + degraded: { + className: 'border-amber-300/25 bg-amber-400/10 text-amber-100', + icon: , + }, + offline: { + className: 'border-red-300/25 bg-red-400/10 text-red-100', + icon: , + }, +}; + +const StellarConnectionQualityBadge: React.FC< + StellarConnectionQualityBadgeProps +> = ({ className }) => { + const { quality, latencyMs, isChecking } = useStellarConnectionQuality(); + const style = qualityStyles[quality.quality]; + const label = isChecking + ? 'Checking Stellar RPC...' + : formatStellarConnectionQualityLabel(quality, latencyMs); + + return ( +
+ + + {isChecking ? 'Checking RPC' : `RPC ${quality.label}`} + +
+ ); +}; + +export default StellarConnectionQualityBadge; diff --git a/src/hooks/useStellarConnectionQuality.ts b/src/hooks/useStellarConnectionQuality.ts new file mode 100644 index 0000000..fafad1e --- /dev/null +++ b/src/hooks/useStellarConnectionQuality.ts @@ -0,0 +1,98 @@ +import { useEffect, useMemo, useState } from 'react'; +import { defaultChain } from '@/lib/web3/chains'; +import { useEthersProvider } from '@/hooks/useEthersProvider'; +import { + classifyStellarConnectionQuality, + type StellarConnectionQualitySnapshot, +} from '@/utils/stellarConnectionQuality.utils'; + +const DEFAULT_POLL_INTERVAL_MS = 20_000; +const MAX_SAMPLES = 4; + +export interface StellarConnectionQualityState { + quality: StellarConnectionQualitySnapshot; + latencyMs: number | null; + lastCheckedAt: number | null; + isChecking: boolean; +} + +export function useStellarConnectionQuality( + pollIntervalMs: number = DEFAULT_POLL_INTERVAL_MS +): StellarConnectionQualityState { + const provider = useEthersProvider({ chainId: defaultChain.id }); + const [latencySamples, setLatencySamples] = useState([]); + const [latencyMs, setLatencyMs] = useState(null); + const [lastCheckedAt, setLastCheckedAt] = useState(null); + const [hasError, setHasError] = useState(false); + const [isChecking, setIsChecking] = useState(true); + + useEffect(() => { + if (!provider) { + setLatencySamples([]); + setLatencyMs(null); + setHasError(true); + setIsChecking(false); + return; + } + + setLatencySamples([]); + setLatencyMs(null); + setLastCheckedAt(null); + setHasError(false); + setIsChecking(true); + + let isActive = true; + + const sampleConnection = async () => { + setIsChecking(true); + const startedAt = performance.now(); + + try { + await provider.getBlockNumber(); + const sampleLatency = Math.round(performance.now() - startedAt); + if (!isActive) return; + + setHasError(false); + setLatencyMs(sampleLatency); + setLastCheckedAt(Date.now()); + setLatencySamples(previous => [ + sampleLatency, + ...previous, + ].slice(0, MAX_SAMPLES)); + } catch { + if (!isActive) return; + setHasError(true); + setLatencyMs(null); + setLastCheckedAt(Date.now()); + } finally { + if (isActive) { + setIsChecking(false); + } + } + }; + + void sampleConnection(); + const intervalId = window.setInterval(sampleConnection, pollIntervalMs); + + return () => { + isActive = false; + window.clearInterval(intervalId); + }; + }, [pollIntervalMs, provider]); + + const quality = useMemo(() => { + const snapshot = classifyStellarConnectionQuality( + latencySamples, + hasError + ); + + return snapshot; + }, [hasError, latencySamples]); + + return { + quality, + latencyMs, + lastCheckedAt, + isChecking, + }; +} diff --git a/src/pages/LandingPage.tsx b/src/pages/LandingPage.tsx index 6311093..285faa6 100644 --- a/src/pages/LandingPage.tsx +++ b/src/pages/LandingPage.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { courseService, type Course } from '@/services/course.service'; +import { cn } from '@/lib/utils'; import SearchBar from '@/components/common/SearchBar'; import StickyFilterBar from '@/components/common/StickyFilterBar'; import CreatorCard from '@/components/common/CreatorCard'; @@ -24,8 +25,8 @@ import CreatorProfileHeader from '@/components/common/CreatorProfileHeader'; import TransactionRetryNotice from '@/components/common/TransactionRetryNotice'; import EmptyTransactionTimelineState from '@/components/common/EmptyTransactionTimelineState'; import TradeDialog, { type TradeSide } from '@/components/common/TradeDialog'; -import PendingTxModal from '@/components/common/PendingTxModal'; import NetworkMismatchBanner from '@/components/common/NetworkMismatchBanner'; +import StellarConnectionQualityBadge from '@/components/common/StellarConnectionQualityBadge'; import { useNetworkMismatch } from '@/hooks/useNetworkMismatch'; import showToast from '@/utils/toast.util'; import { formatCompactNumber, formatNumber } from '@/utils/numberFormat.utils'; @@ -210,7 +211,6 @@ function LandingPage() { const [tradeSide, setTradeSide] = useState('buy'); const [tradeDialogOpen, setTradeDialogOpen] = useState(false); const [tradeSubmitting, setTradeSubmitting] = useState(false); - const [pendingTxOpen, setPendingTxOpen] = useState(false); const [sortOption, setSortOption] = useState(() => { if (typeof window === 'undefined') return 'featured'; const saved = window.localStorage.getItem( @@ -451,7 +451,6 @@ function LandingPage() { const handleConfirmTrade = async (amount: number) => { const previousHoldings = featuredHoldings; setTradeSubmitting(true); - setPendingTxOpen(true); try { showToast.loading( @@ -482,7 +481,6 @@ function LandingPage() { showToast.error('Trade failed. Holdings have been restored.'); } finally { setTradeSubmitting(false); - setPendingTxOpen(false); } }; @@ -520,6 +518,9 @@ function LandingPage() {
+
+ +
{isNetworkMismatch && } -
- - + + +
+ {tradeSubmitting && ( +
+
+
+ Submitting trade +
+
+ )}
@@ -862,23 +879,41 @@ function LandingPage() {
- - +
+
+ + +
+ {tradeSubmitting && ( +
+
+
+ Submitting trade +
+
+ )} +
@@ -903,14 +938,6 @@ function LandingPage() { onOpenChange={setTradeDialogOpen} onConfirm={handleConfirmTrade} /> -
); diff --git a/src/utils/creatorCardTokens.ts b/src/utils/creatorCardTokens.ts new file mode 100644 index 0000000..96c78a9 --- /dev/null +++ b/src/utils/creatorCardTokens.ts @@ -0,0 +1,3 @@ +export const CREATOR_CARD_MEDIA_RADIUS_CLASS = 'rounded-2xl'; + +export const CREATOR_CARD_STAT_GRID_GAP_CLASS = 'gap-4'; diff --git a/src/utils/stellarConnectionQuality.utils.ts b/src/utils/stellarConnectionQuality.utils.ts new file mode 100644 index 0000000..cf2aa2c --- /dev/null +++ b/src/utils/stellarConnectionQuality.utils.ts @@ -0,0 +1,80 @@ +export type StellarConnectionQuality = + | 'excellent' + | 'good' + | 'degraded' + | 'offline'; + +export interface StellarConnectionQualitySnapshot { + quality: StellarConnectionQuality; + label: string; + description: string; + tone: 'success' | 'warning' | 'destructive' | 'muted'; +} + +const STELLAR_CONNECTION_QUALITY_MAP: Record< + StellarConnectionQuality, + StellarConnectionQualitySnapshot +> = { + excellent: { + quality: 'excellent', + label: 'Excellent', + description: 'RPC responses are fast and stable.', + tone: 'success', + }, + good: { + quality: 'good', + label: 'Good', + description: 'RPC responses are healthy.', + tone: 'success', + }, + degraded: { + quality: 'degraded', + label: 'Slow', + description: 'RPC responses are slower than expected.', + tone: 'warning', + }, + offline: { + quality: 'offline', + label: 'Offline', + description: 'RPC requests are failing right now.', + tone: 'destructive', + }, +}; + +export const classifyStellarConnectionQuality = ( + latencySamplesMs: number[], + hasError = false +): StellarConnectionQualitySnapshot => { + if (hasError || latencySamplesMs.length === 0) { + return STELLAR_CONNECTION_QUALITY_MAP.offline; + } + + const averageLatencyMs = + latencySamplesMs.reduce((sum, sample) => sum + sample, 0) / + latencySamplesMs.length; + + if (averageLatencyMs <= 600) { + return STELLAR_CONNECTION_QUALITY_MAP.excellent; + } + + if (averageLatencyMs <= 1500) { + return STELLAR_CONNECTION_QUALITY_MAP.good; + } + + return STELLAR_CONNECTION_QUALITY_MAP.degraded; +}; + +export const formatStellarConnectionQualityLabel = ( + snapshot: StellarConnectionQualitySnapshot, + latencyMs?: number | null +) => { + if (snapshot.quality === 'offline') { + return 'Stellar RPC offline'; + } + + if (latencyMs == null) { + return `Stellar RPC ${snapshot.label.toLowerCase()}`; + } + + return `Stellar RPC ${snapshot.label.toLowerCase()} at ${latencyMs} ms`; +};