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
261 changes: 176 additions & 85 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,88 +1,95 @@
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useState, type RefObject } from "react";
import { CreateStreamForm } from "./components/CreateStreamForm";
import { EditStartTimeModal } from "./components/EditStartTimeModal";
import { IssueBacklog } from "./components/IssueBacklog";
import { RecipientDashboard } from "./components/RecipientDashboard";
import { StreamsTable } from "./components/StreamsTable";

import { SenderDashboard } from "./components/SenderDashboard";
import { StreamDetailDrawer } from "./components/StreamDetailDrawer";
import { StreamMetricsChart } from "./components/StreamMetricsChart";
import { WalletButton } from "./components/WalletButton";
import { StreamTimeline } from "./components/StreamTimeline";
import { StreamsTable } from "./components/StreamsTable";
import { WalletButton } from "./components/WalletButton";
import { useFreighter } from "./hooks/useFreighter";
import { useMetricsHistory } from "./hooks/useMetricsHistory";
import { defaultStreamFilters, useStreamFilter } from "./hooks/useStreamFilter";
import { useToast } from "./hooks/useToast";
import { useWebSocket } from "./hooks/useWebSocket";
import {
ApiError,
cancelStream,
createStream,
listOpenIssues,
listStreams,
updateStreamStartAt,
} from "./services/api";
import { ListStreamsFilters } from "./services/api";
import { OpenIssue, Stream } from "./types/stream";
import { useMetricsHistory } from "./hooks/useMetricsHistory";
import { useUrlFilters } from "./hooks/useUrlFilters";

type ViewMode = "dashboard" | "recipient" | "sender";

// Derive a user-friendly hint string for global (non-form) errors.
function describeGlobalError(raw: string): string {
const lower = raw.toLowerCase();
if (
lower.includes("network") ||
lower.includes("fetch") ||
lower.includes("failed to fetch")
) {
return "Network error — check that the StellarStream backend is running and reachable.";
}
if (lower.includes("not found")) {
return "The requested stream could not be found. It may have already been cancelled.";
}
if (lower.includes("cancel")) {
return `Unable to cancel stream: ${raw}`;
}
return raw;
}

function App() {
const wallet = useFreighter();
const {
view: viewMode,
filters,
streamId: detailStreamId,
setView: setViewMode,
setFilters,
openStream,
closeStream,
} = useUrlFilters();
const { showToast } = useToast();
const [viewMode, setViewMode] = useState<ViewMode>("dashboard");
const [detailStreamId, setDetailStreamId] = useState<string | null>(null);
const [streams, setStreams] = useState<Stream[]>([]);
const [issues, setIssues] = useState<OpenIssue[]>([]);
const [globalError, setGlobalError] = useState<string | null>(null);
const [formError, setFormError] = useState<string | null>(null);
const [editingStream, setEditingStream] = useState<{
stream: Stream;
triggerRef: React.RefObject<HTMLButtonElement | null>;
triggerRef: RefObject<HTMLButtonElement | null>;
} | null>(null);
const [loadingDashboard, setLoadingDashboard] = useState(true);
const [initialLoading, setInitialLoading] = useState(true);

const { filters, filteredStreams, setFilter } = useStreamFilter(streams);
const wsUrl = import.meta.env.VITE_WS_URL ?? "";
const { lastMessage } = useWebSocket<{
eventType?: string;
type?: string;
status?: string;
streamId?: string;
}>(wsUrl);

const apiFilters: ListStreamsFilters = useMemo(
() => ({
status: filters.status,
sender: filters.sender,
recipient: filters.recipient,
asset: filters.assetCode,
}),
[filters],
);

const tableFilters: ListStreamsFilters = useMemo(
() => ({
status: filters.status,
sender: filters.sender,
recipient: filters.recipient,
asset: filters.assetCode,
q: "",
}),
[filters],
);

const metrics = useMemo(() => {
const activeCount = streams.filter(
const activeCount = filteredStreams.filter(
(s) => s.progress.status === "active",
).length;
const completedCount = streams.filter(
const completedCount = filteredStreams.filter(
(s) => s.progress.status === "completed",
).length;
const totalVested = streams.reduce(
const totalVested = filteredStreams.reduce(
(sum, s) => sum + s.progress.vestedAmount,
0,
);

return {
total: streams.length,
total: filteredStreams.length,
active: activeCount,
completed: completedCount,
vested: Number(totalVested.toFixed(2)),
};
}, [streams]);
}, [filteredStreams]);

const metricsHistory = useMetricsHistory(
metrics.active,
Expand All @@ -91,39 +98,132 @@ function App() {
5000,
);

useEffect(() => {
const params = new URLSearchParams(window.location.search);
const rawView = params.get("view");
const rawStreamId = params.get("streamId");
if (rawView === "dashboard" || rawView === "sender" || rawView === "recipient") {
setViewMode(rawView);
}
setDetailStreamId(rawStreamId);
}, []);

useEffect(() => {
const params = new URLSearchParams(window.location.search);
if (viewMode === "dashboard") {
params.delete("view");
} else {
params.set("view", viewMode);
}
if (detailStreamId) {
params.set("streamId", detailStreamId);
} else {
params.delete("streamId");
}
const next = params.toString();
window.history.replaceState(
null,
"",
next ? `${window.location.pathname}?${next}` : window.location.pathname,
);
}, [detailStreamId, viewMode]);

async function refreshStreams(currentFilters: ListStreamsFilters): Promise<void> {
const data = await listStreams(currentFilters);
setStreams(data);
}

useEffect(() => {
setLoadingDashboard(true);
refreshStreams(apiFilters)
.catch((err: unknown) => {
const message =
err instanceof ApiError
? `Failed loading streams (${err.statusCode})`
: "Failed loading streams";
showToast(message, "error");
})
.finally(() => {
setInitialLoading(false);
setLoadingDashboard(false);
});
}, [apiFilters, showToast]);

useEffect(() => {
listOpenIssues()
.then(setIssues)
.catch(() => undefined);
}, []);

useEffect(() => {
if (!lastMessage) return;
const eventKind = lastMessage.eventType ?? lastMessage.type ?? "";
if (!eventKind) return;

if (eventKind.includes("created")) {
showToast("Stream created", "success");
} else if (eventKind.includes("cancel")) {
showToast("Stream canceled", "info");
} else if (eventKind.includes("complete")) {
showToast("Stream completed", "success");
}
refreshStreams(apiFilters).catch(() => undefined);
}, [apiFilters, lastMessage, showToast]);

async function handleCreate(
payload: Parameters<typeof createStream>[0],
): Promise<void> {
setFormError(null);
setGlobalError(null);
try {
await createStream(payload);
const data = await listStreams(filters);
setStreams(data);
await refreshStreams(apiFilters);
showToast("Stream created successfully", "success");
} catch (err) {
setFormError(
err instanceof Error ? err.message : "Failed to create stream.",
);
if (err instanceof ApiError) {
setFormError(err.message);
showToast(`Create failed (${err.statusCode}): ${err.message}`, "error");
return;
}
const fallback = err instanceof Error ? err.message : "Failed to create stream.";
setFormError(fallback);
showToast(fallback, "error");
}
}

async function handleCancel(streamId: string): Promise<void> {
setGlobalError(null);
setFormError(null);
try {
await cancelStream(streamId);
const data = await listStreams(filters);
setStreams(data);
await refreshStreams(apiFilters);
showToast("Stream canceled", "info");
} catch (err) {
setGlobalError(
err instanceof Error
? describeGlobalError(err.message)
: "Failed to cancel the stream. Please try again.",
if (err instanceof ApiError) {
showToast(`Cancel failed (${err.statusCode}): ${err.message}`, "error");
return;
}
showToast(
err instanceof Error ? err.message : "Failed to cancel the stream.",
"error",
);
}
}

async function handleUpdateStartTime(streamId: string, nextStartAt: number) {
try {
await updateStreamStartAt(streamId, nextStartAt);
await refreshStreams(apiFilters);
showToast("Start time updated", "success");
} catch (err) {
if (err instanceof ApiError) {
showToast(`Update failed (${err.statusCode}): ${err.message}`, "error");
return;
}
showToast("Failed to update stream start time", "error");
}
}

if (initialLoading && viewMode === "dashboard") {
return <div className="app-shell">Loading dashboard…</div>;
}

return (
<div className="app-shell">
Expand Down Expand Up @@ -166,9 +266,11 @@ function App() {
</nav>

{viewMode === "sender" ? (
<SenderDashboard
senderAddress={wallet.address}
onEditStartTime={(stream) => setEditingStream(stream)}
<SenderDashboard
senderAddress={wallet.address}
onEditStartTime={(stream) =>
setEditingStream({ stream, triggerRef: { current: null } })
}
/>
) : viewMode === "recipient" ? (
<RecipientDashboard recipientAddress={wallet.address} />
Expand Down Expand Up @@ -198,35 +300,26 @@ function App() {
<StreamMetricsChart data={metricsHistory} />
</section>

{/* Global (cancel / bootstrap) errors shown as a dismissible banner */}
{globalError && (
<div className="error-banner" role="alert" aria-live="assertive">
<span className="error-banner__icon" aria-hidden>
</span>
<span>{globalError}</span>
<button
className="error-banner__dismiss"
type="button"
aria-label="Dismiss error"
onClick={() => setGlobalError(null)}
>
×
</button>
</div>
)}

<section className="layout-grid">
{/* formError is passed into the form so the create-stream card can show it inline */}
<CreateStreamForm onCreate={handleCreate} apiError={formError} />
<CreateStreamForm
onCreate={handleCreate}
apiError={formError}
walletAddress={wallet.address}
/>
<StreamsTable
streams={streams}
filters={filters}
onFiltersChange={setFilters}
streams={filteredStreams}
filters={tableFilters}
onFiltersChange={(next) => {
setFilter("status", next.status ?? defaultStreamFilters.status);
setFilter("sender", next.sender ?? defaultStreamFilters.sender);
setFilter("recipient", next.recipient ?? defaultStreamFilters.recipient);
setFilter("assetCode", next.asset ?? defaultStreamFilters.assetCode);
}}
onCancel={handleCancel}
onEditStartTime={(stream, triggerRef) =>
setEditingStream({ stream, triggerRef })
}
onOpenStream={setDetailStreamId}
/>
</section>

Expand All @@ -237,7 +330,6 @@ function App() {
<StreamTimeline />
</section>

{/* Edit start-time modal — only rendered when a stream is being edited */}
{editingStream && (
<EditStartTimeModal
stream={editingStream.stream}
Expand All @@ -247,11 +339,10 @@ function App() {
/>
)}

{/* Stream detail drawer — URL-driven via ?streamId= */}
{detailStreamId && (
<StreamDetailDrawer
streamId={detailStreamId}
onClose={closeStream}
onClose={() => setDetailStreamId(null)}
onCancel={handleCancel}
/>
)}
Expand Down
Loading
Loading