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
31 changes: 28 additions & 3 deletions src/components/ErrorBoundary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,21 +12,26 @@ interface Props {
onError?: (error: Error, info: ErrorInfo) => void;
/** Render fallback content as an in-page scoped panel instead of a full-page state. */
isolate?: boolean;
/** Optional support URL shown in the default fallback. */
supportUrl?: string;
}

interface State {
error: Error | null;
resetKey: number;
componentStack: string | null;
}

export class ErrorBoundary extends Component<Props, State> {
state: State = { error: null, resetKey: 0 };
state: State = { error: null, resetKey: 0, componentStack: null };

static getDerivedStateFromError(error: Error): Partial<State> {
return { error };
}

componentDidCatch(error: Error, info: ErrorInfo) {
this.setState({ error, componentStack: info.componentStack ?? null });

if (this.props.onError) {
this.props.onError(error, info);
return;
Expand All @@ -44,8 +49,8 @@ export class ErrorBoundary extends Component<Props, State> {
}));

render() {
const { error, resetKey } = this.state;
const { children, fallback, isolate } = this.props;
const { error, resetKey, componentStack } = this.state;
const { children, fallback, isolate, supportUrl } = this.props;

if (!error) return <div key={resetKey}>{children}</div>;

Expand Down Expand Up @@ -101,6 +106,26 @@ export class ErrorBoundary extends Component<Props, State> {
: "Details are hidden in production."}
</p>
</div>
{supportUrl && (
<a
href={supportUrl}
target="_blank"
rel="noopener noreferrer"
className="text-[12px] text-brand hover:underline"
>
Report this issue
</a>
)}
{import.meta.env.DEV && componentStack && (
<details className="w-full rounded-xl border border-line bg-surface-2 px-4 py-3 text-left">
<summary className="cursor-pointer text-[11px] font-medium text-ink-3">
Show component stack
</summary>
<pre className="mt-2 whitespace-pre-wrap break-all text-[11px] text-ink-3">
{componentStack}
</pre>
</details>
)}
<div className="flex items-center gap-3">
<button
onClick={this.reset}
Expand Down
55 changes: 35 additions & 20 deletions src/components/FeeEstimator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { Badge } from "@/components/ui/Badge";
import { getClient } from "@/lib/client";
import { cn } from "@/lib/utils";

interface FeeData {
export interface FeeData {
baseFee: string;
recommended: string;
}
Expand All @@ -15,11 +15,15 @@ interface FeeEstimatorProps {
className?: string;
/** Auto-refresh interval in ms. 0 = no refresh. */
refreshInterval?: number;
variant?: "default" | "compact";
onFeeLoad?: (fee: FeeData) => void;
}

export function FeeEstimator({
className,
refreshInterval = 0,
variant = "default",
onFeeLoad,
}: FeeEstimatorProps) {
const [fee, setFee] = useState<FeeData | null>(null);
const [loading, setLoading] = useState(true);
Expand All @@ -35,10 +39,13 @@ export function FeeEstimator({
}
setFee(data);
setError(null);
if (data) {
onFeeLoad?.(data);
}
} finally {
setLoading(false);
}
}, []);
}, [onFeeLoad]);

useEffect(() => {
const timerId = window.setTimeout(() => {
Expand Down Expand Up @@ -93,33 +100,41 @@ export function FeeEstimator({

<div className="px-5 py-4" aria-live="polite" aria-atomic="true">
{loading && !fee ? (
<div className="flex gap-4">
<div className="h-8 w-24 rounded-lg bg-surface-2 animate-pulse" />
<div className="h-8 w-24 rounded-lg bg-surface-2 animate-pulse" />
</div>
variant === "compact" ? (
<div className="h-6 w-28 rounded-lg bg-surface-2 animate-pulse" />
) : (
<div className="flex gap-4">
<div className="h-8 w-24 rounded-lg bg-surface-2 animate-pulse" />
<div className="h-8 w-24 rounded-lg bg-surface-2 animate-pulse" />
</div>
)
) : error ? (
<p className="text-[12px] text-red">{error}</p>
) : fee ? (
<div className="flex items-center gap-4">
<FeeCell label="Base Fee" value={fee.baseFee} unit="stroops" />
<div className="w-px h-8 bg-line" />
<FeeCell
label="Recommended"
value={fee.recommended}
unit="stroops"
highlight
highFee={
parseInt(fee.recommended, 10) > parseInt(fee.baseFee, 10) * 2
}
/>
</div>
variant === "compact" ? (
<p className="text-[13px] text-ink">Fee: ~{fee.recommended} stroops</p>
) : (
<div className="flex items-center gap-4">
<FeeCell label="Base Fee" value={fee.baseFee} unit="stroops" />
<div className="w-px h-8 bg-line" />
<FeeCell
label="Recommended"
value={fee.recommended}
unit="stroops"
highlight
highFee={
parseInt(fee.recommended, 10) > parseInt(fee.baseFee, 10) * 2
}
/>
</div>
)
) : null}
</div>
</div>
);
}

function FeeCell({
export function FeeCell({
label,
value,
unit,
Expand Down
2 changes: 1 addition & 1 deletion src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ export { AllowanceManager } from "./AllowanceManager";
// Transactions
export { ActivityTimeline } from "./ActivityTimeline";
export { ClaimableBalanceCard } from "./ClaimableBalanceCard";
export { FeeEstimator } from "./FeeEstimator";
export { FeeEstimator, FeeCell } from "./FeeEstimator";
export { GasOptimizer } from "./GasOptimizer";
export type {
TransactionConfirmModalProps,
Expand Down
43 changes: 32 additions & 11 deletions src/components/ui/Skeleton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { cn } from "@/lib/utils";
interface SkeletonProps extends React.HTMLAttributes<HTMLDivElement> {
/** Render as a circle (for avatars/icons) */
circle?: boolean;
/** Shape variant for non-circular placeholders. */
shape?: "rounded" | "circle" | "square";
/**
* Animation variant.
* - "pulse" — opacity pulsing via Tailwind's animate-pulse (default)
Expand All @@ -13,13 +15,18 @@ interface SkeletonProps extends React.HTMLAttributes<HTMLDivElement> {
variant?: "pulse" | "shimmer";
}

export function Skeleton({ circle, variant = "pulse", className, ...props }: SkeletonProps) {
export function Skeleton({ circle, shape = "rounded", variant = "pulse", className, ...props }: SkeletonProps) {
const resolvedShape = circle ? "circle" : shape;
return (
<div
role="presentation"
className={cn(
"bg-surface-2 shrink-0",
circle ? "rounded-full" : "rounded-lg",
resolvedShape === "circle"
? "rounded-full"
: resolvedShape === "square"
? "rounded-none"
: "rounded-lg",
variant === "pulse" ? "animate-pulse" : "skeleton-shimmer",
className,
)}
Expand All @@ -28,19 +35,27 @@ export function Skeleton({ circle, variant = "pulse", className, ...props }: Ske
);
}

interface SkeletonRowProps extends React.HTMLAttributes<HTMLDivElement> {
count?: number;
}

/** Pre-composed row skeleton: icon + two lines of text */
export function SkeletonRow({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
export function SkeletonRow({ className, count = 1, ...props }: SkeletonRowProps) {
return (
<div
role="presentation"
className={cn("flex items-center gap-3", className)}
className={cn(count > 1 ? "flex flex-col gap-3" : undefined, className)}
{...props}
>
<Skeleton circle className="w-9 h-9" />
<div className="flex-1 flex flex-col gap-2">
<Skeleton className="h-3.5 w-28" />
<Skeleton className="h-3 w-20" />
</div>
{Array.from({ length: count }).map((_, index) => (
<div key={`skeleton-row-${index}`} className="flex items-center gap-3">
<Skeleton circle className="w-9 h-9" />
<div className="flex-1 flex flex-col gap-2">
<Skeleton className="h-3.5 w-28" />
<Skeleton className="h-3 w-20" />
</div>
</div>
))}
</div>
);
}
Expand All @@ -50,6 +65,7 @@ interface SkeletonCardProps extends React.HTMLAttributes<HTMLDivElement> {
rows?: number;
structure?: React.ReactNode;
children?: React.ReactNode;
header?: React.ReactNode;
}

/**
Expand Down Expand Up @@ -82,6 +98,7 @@ export function SkeletonCard({
rows = 3,
structure,
children,
header,
className,
...props
}: SkeletonCardProps) {
Expand All @@ -98,8 +115,12 @@ export function SkeletonCard({
) : (
<>
<div className="px-5 py-4 border-b border-line flex flex-col gap-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-3 w-48" />
{header ?? (
<>
<Skeleton className="h-4 w-32" />
<Skeleton className="h-3 w-48" />
</>
)}
</div>
<div className="px-5 py-5 flex flex-col gap-4">
{Array.from({ length: rows }).map((_, i) => (
Expand Down
9 changes: 7 additions & 2 deletions src/screens/SorobanScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useEffect, useState } from "react";

import { ContractEventFeed } from "@/components/ContractEventFeed";
import { ErrorBoundary } from "@/components/ErrorBoundary";
import { SorobanPanel } from "@/components/SorobanPanel";
import { useSorokit } from "@/context/useSorokit";
import { SCREEN_LABELS } from "@/lib/nav-labels";
Expand Down Expand Up @@ -94,9 +95,13 @@ export function SorobanScreen() {
</div>
</div>
)}
<SorobanPanel contractId={contractId} onContractIdChange={setContractId} />
<ErrorBoundary isolate>
<SorobanPanel contractId={contractId} onContractIdChange={setContractId} />
</ErrorBoundary>
{contractId.trim() !== "" && (
<ContractEventFeed contractId={contractId} />
<ErrorBoundary isolate>
<ContractEventFeed contractId={contractId} />
</ErrorBoundary>
)}
</div>
);
Expand Down
5 changes: 4 additions & 1 deletion src/screens/TransactionsScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { ActivityTimeline } from "@/components/ActivityTimeline";
import { ErrorBoundary } from "@/components/ErrorBoundary";
import { FeeEstimator } from "@/components/FeeEstimator";
import { GasOptimizer } from "@/components/GasOptimizer";
import { MultiSigTransactionBuilder } from "@/components/MultiSigTransactionBuilder";
Expand All @@ -19,7 +20,9 @@ export function TransactionsScreen() {
<GasOptimizer />
<FeeEstimator />
<MultiSigTransactionBuilder />
<TransactionPanel />
<ErrorBoundary isolate>
<TransactionPanel />
</ErrorBoundary>
<ActivityTimeline />
</div>
);
Expand Down
Loading