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
6 changes: 5 additions & 1 deletion src/components/common/CreatorCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -135,7 +136,10 @@ const CreatorCard: React.FC<CreatorCardProps> = ({
)}
>
<div
className="relative mb-4 aspect-square overflow-hidden rounded-xl"
className={cn(
'relative mb-4 aspect-square overflow-hidden',
CREATOR_CARD_MEDIA_RADIUS_CLASS
)}
role="img"
aria-labelledby={`creator-name-${creator.id}`}
>
Expand Down
6 changes: 5 additions & 1 deletion src/components/common/CreatorProfileHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -80,7 +81,10 @@ const CreatorProfileHeader: React.FC<CreatorProfileHeaderProps> = ({
>
<div className="flex flex-col gap-4 md:flex-row md:items-center md:gap-6">
<div
className="size-24 overflow-hidden rounded-2xl border-4 border-white/10 shadow-xl md:size-32"
className={cn(
'size-24 overflow-hidden border-4 border-white/10 shadow-xl md:size-32',
CREATOR_CARD_MEDIA_RADIUS_CLASS
)}
role="img"
aria-labelledby="creator-profile-name"
>
Expand Down
4 changes: 3 additions & 1 deletion src/components/common/CreatorProfileInfoGrid.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -20,7 +21,8 @@ const CreatorProfileInfoGrid: React.FC<CreatorProfileInfoGridProps> = ({
return (
<div
className={cn(
'grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4',
'grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4',
CREATOR_CARD_STAT_GRID_GAP_CLASS,
className
)}
>
Expand Down
9 changes: 6 additions & 3 deletions src/components/common/CreatorProfileStatRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -24,7 +25,8 @@ const CreatorProfileStatRowSkeleton: React.FC<{
return (
<div
className={cn(
'grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4',
'grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4',
CREATOR_CARD_STAT_GRID_GAP_CLASS,
className
)}
>
Expand Down Expand Up @@ -66,7 +68,8 @@ const CreatorProfileStatRow: React.FC<CreatorProfileStatRowProps> = ({
return (
<div
className={cn(
'grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4',
'grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4',
CREATOR_CARD_STAT_GRID_GAP_CLASS,
className
)}
>
Expand All @@ -82,4 +85,4 @@ const CreatorProfileStatRow: React.FC<CreatorProfileStatRowProps> = ({
);
};

export default CreatorProfileStatRow;
export default CreatorProfileStatRow;
4 changes: 3 additions & 1 deletion src/components/common/CreatorSkeleton.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { cn } from '@/lib/utils';
import { CREATOR_CARD_MEDIA_RADIUS_CLASS } from '@/utils/creatorCardTokens';

interface CreatorSkeletonProps {
className?: string;
Expand All @@ -25,7 +26,8 @@ const CreatorSkeleton: React.FC<CreatorSkeletonProps> = ({
>
<div
className={cn(
'mb-4 aspect-square w-full rounded-xl',
'mb-4 aspect-square w-full',
CREATOR_CARD_MEDIA_RADIUS_CLASS,
blockClass
)}
/>
Expand Down
68 changes: 68 additions & 0 deletions src/components/common/StellarConnectionQualityBadge.tsx
Original file line number Diff line number Diff line change
@@ -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: <Wifi className="size-3.5" />,
},
good: {
className: 'border-emerald-300/20 bg-emerald-400/8 text-emerald-100',
icon: <SignalHigh className="size-3.5" />,
},
degraded: {
className: 'border-amber-300/25 bg-amber-400/10 text-amber-100',
icon: <SignalLow className="size-3.5" />,
},
offline: {
className: 'border-red-300/25 bg-red-400/10 text-red-100',
icon: <WifiOff className="size-3.5" />,
},
};

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 (
<div
role="status"
aria-live="polite"
aria-label={label}
title={label}
className={cn(
'inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-xs font-semibold backdrop-blur-md',
style.className,
className
)}
>
<span aria-hidden="true" className="shrink-0">
{style.icon}
</span>
<span className="whitespace-nowrap">
{isChecking ? 'Checking RPC' : `RPC ${quality.label}`}
</span>
</div>
);
};

export default StellarConnectionQualityBadge;
98 changes: 98 additions & 0 deletions src/hooks/useStellarConnectionQuality.ts
Original file line number Diff line number Diff line change
@@ -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<number[]>([]);
const [latencyMs, setLatencyMs] = useState<number | null>(null);
const [lastCheckedAt, setLastCheckedAt] = useState<number | null>(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,
};
}
Loading
Loading