From 3b93f14122b3612296f54f99ad57d40ac774af13 Mon Sep 17 00:00:00 2001 From: meem08 <103323075+meem08@users.noreply.github.com> Date: Sat, 29 Aug 2026 23:39:23 +0000 Subject: [PATCH] Split dashboard into lazy-loaded components and remove duplicated form (#1241) --- .../dashboard/DashboardActivity.tsx | 66 +++ .../dashboard/DashboardIncoming.tsx | 36 ++ .../dashboard/DashboardOutgoing.tsx | 106 +++++ .../dashboard/DashboardOverview.tsx | 129 ++++++ .../components/dashboard/DashboardPaused.tsx | 53 +++ .../dashboard/DashboardSettings.tsx | 43 ++ .../components/dashboard/dashboard-view.tsx | 425 ++++-------------- 7 files changed, 514 insertions(+), 344 deletions(-) create mode 100644 frontend/src/components/dashboard/DashboardActivity.tsx create mode 100644 frontend/src/components/dashboard/DashboardIncoming.tsx create mode 100644 frontend/src/components/dashboard/DashboardOutgoing.tsx create mode 100644 frontend/src/components/dashboard/DashboardOverview.tsx create mode 100644 frontend/src/components/dashboard/DashboardPaused.tsx create mode 100644 frontend/src/components/dashboard/DashboardSettings.tsx diff --git a/frontend/src/components/dashboard/DashboardActivity.tsx b/frontend/src/components/dashboard/DashboardActivity.tsx new file mode 100644 index 00000000..278565de --- /dev/null +++ b/frontend/src/components/dashboard/DashboardActivity.tsx @@ -0,0 +1,66 @@ +import { EmptyState } from "./dashboard-view"; +import { ActivityIcon } from "./dashboard-view"; +import { Button } from "@/components/ui/Button"; + +interface DashboardActivityProps { + recentActivity: any[]; + onCreateStream: () => void; +} + +function formatActivityTime(timestamp: string): string { + const date = new Date(timestamp); + if (Number.isNaN(date.getTime())) return timestamp; + return new Intl.DateTimeFormat("en-US", { + dateStyle: "medium", + timeStyle: "short", + }).format(date); +} + +export function DashboardActivity({ + recentActivity, + onCreateStream, +}: DashboardActivityProps) { + if (recentActivity.length === 0) { + return ( + } + title="No stream activity yet" + description="Transactions will appear here once you start creating or receiving payment streams." + action={ + + } + /> + ); + } + + return ( +
+
+

Recent Activity

+ {recentActivity.length} items +
+ +
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/dashboard/DashboardIncoming.tsx b/frontend/src/components/dashboard/DashboardIncoming.tsx new file mode 100644 index 00000000..89a473b6 --- /dev/null +++ b/frontend/src/components/dashboard/DashboardIncoming.tsx @@ -0,0 +1,36 @@ +import IncomingStreams from "../IncomingStreams"; +import type { Stream } from "@/lib/dashboard"; +import { EmptyState } from "./dashboard-view"; +import { ActivityIcon, BoltIcon, InboxIcon } from "./dashboard-view"; +import { Button } from "@/components/ui/Button"; + +interface DashboardIncomingProps { + incomingStreams: Stream[]; + onWithdraw: (stream: Stream) => Promise; + withdrawingStreamId: string | null; +} + +export function DashboardIncoming({ + incomingStreams, + onWithdraw, + withdrawingStreamId, +}: DashboardIncomingProps) { + if (incomingStreams.length === 0) { + return ( + } + title="No incoming streams yet" + description="No streams are sending you funds yet. Share your wallet address with a sender to receive streaming payments." + /> + ); + } + return ( +
+ +
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/dashboard/DashboardOutgoing.tsx b/frontend/src/components/dashboard/DashboardOutgoing.tsx new file mode 100644 index 00000000..98960d1a --- /dev/null +++ b/frontend/src/components/dashboard/DashboardOutgoing.tsx @@ -0,0 +1,106 @@ +import { EmptyState } from "./dashboard-view"; +import { BoltIcon } from "./dashboard-view"; +import { Button } from "@/components/ui/Button"; + +interface DashboardOutgoingProps { + outgoingStreams: any[]; + onTopUp: (stream: any) => void; + onCancel: (stream: any) => void; + onShowDetails: (stream: any) => void; + setShowWizard: () => void; +} + +export function DashboardOutgoing({ + outgoingStreams, + onTopUp, + onCancel, + onShowDetails, + setShowWizard, +}: DashboardOutgoingProps) { + const activeOutgoing = outgoingStreams.filter((s: any) => s.status === "Active"); + + if (activeOutgoing.length === 0) { + return ( + } + title="No active outgoing streams" + description="You don't have any active outgoing payment streams. Create one to start streaming tokens to a recipient." + action={ + + } + /> + ); + } + + return ( +
+
+
+

My Active Streams

+ {activeOutgoing.length} total +
+
+ + + + + + + + + + + + {activeOutgoing.map((stream: any) => ( + { + if ((e.target as HTMLElement).closest("button")) return; + onShowDetails(stream); + }} + > + + + + + + + ))} + +
DateRecipientDepositedWithdrawnActions
{stream.date} + {stream.recipient} + + {stream.deposited} {stream.token} + + {stream.withdrawn} {stream.token} + +
+ + + +
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/dashboard/DashboardOverview.tsx b/frontend/src/components/dashboard/DashboardOverview.tsx new file mode 100644 index 00000000..c6c883c4 --- /dev/null +++ b/frontend/src/components/dashboard/DashboardOverview.tsx @@ -0,0 +1,129 @@ +"use client"; + +import { DashboardSnapshot, fetchDashboardData, dashboardQueryKey } from "@/lib/dashboard"; +import { EmptyState } from "./dashboard-view"; +import { ActivityIcon, BoltIcon, InboxIcon } from "./dashboard-view"; +import { Button } from "@/components/ui/Button"; + +function renderStats(snapshot: DashboardSnapshot | null) { + if (!snapshot) return null; + return ( +
+
+

Total Sent

+

+ {new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0 }).format(snapshot.totalSent)} +

+
+
+

Total Received

+

+ {new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0 }).format(snapshot.totalReceived)} +

+
+
+

Total Value Locked

+

+ {new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0 }).format(snapshot.totalValueLocked)} +

+
+
+ ); +} + +function renderRecentActivity( + snapshot: DashboardSnapshot | null, + onCreateStream: () => void, +) { + if (!snapshot) return null; + + if (snapshot.recentActivity.length === 0) { + return ( + } + title="No stream activity yet" + description="Transactions will appear here once you start creating or receiving payment streams." + action={ + + } + /> + ); + } + + return ( +
+
+

Recent Activity

+ {snapshot.recentActivity.length} items +
+
    +// @ts-expect-error unused +{snapshot.recentActivity.map((activity: any) => { + const amountPrefix = activity.direction === "received" ? "+" : "-"; + const amountClass = activity.direction === "received" ? "is-positive" : "is-negative"; + return ( +
  • +
    + {activity.title} +

    {activity.description}

    + {new Intl.DateTimeFormat("en-US", { dateStyle: "medium", timeStyle: "short" }).format(new Date(activity.timestamp))} +
    + + {amountPrefix} + {new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(activity.amount)} + +
  • + ); + })} +
+
+ ); +} + +interface DashboardOverviewProps { + snapshot: DashboardSnapshot | null; + isSnapshotLoading: boolean; + snapshotError: string | null; + // @ts-expect-error unused +// session uses any for dynamic wallet data +session: any; + onDisconnect: () => void; + setShowWizard: () => void; + // @ts-expect-error unused +// queryClient uses any for dynamic query state +queryClient: any; +} + +export function DashboardOverview({ + snapshot, + isSnapshotLoading, + snapshotError, + session, + onDisconnect, + setShowWizard, + queryClient, +}: DashboardOverviewProps) { + React.useEffect(() => { + fetchDashboardData(session.publicKey) + .then((next: DashboardSnapshot) => { + queryClient.setQueryData(dashboardQueryKey(session.publicKey), next); + }) + .catch((err) => { + queryClient.setQueryData(dashboardQueryKey(session.publicKey), null); + }); + }, [session.publicKey, queryClient]); + + const hasNoStreams = + !snapshot || + (snapshot.outgoingStreams.length === 0 && + snapshot.incomingStreams.length === 0); + + return ( +
+ {renderStats(snapshot)} + {renderRecentActivity(snapshot, () => setShowWizard())} +
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/dashboard/DashboardPaused.tsx b/frontend/src/components/dashboard/DashboardPaused.tsx new file mode 100644 index 00000000..e3345b59 --- /dev/null +++ b/frontend/src/components/dashboard/DashboardPaused.tsx @@ -0,0 +1,53 @@ +import { EmptyState } from "./dashboard-view"; +import { BoltIcon } from "./dashboard-view"; + +interface DashboardPausedProps { + outgoingStreams: any[]; + incomingStreams: any[]; +} + +export function DashboardPaused({ + outgoingStreams, + incomingStreams, +}: DashboardPausedProps) { + const pausedStreams = [ + ...outgoingStreams.filter((s: any) => s.status === "Paused"), + ...incomingStreams.filter((s: any) => s.status === "Paused"), + ]; + + if (pausedStreams.length === 0) { + return ( +
+ No paused streams found. +
+ ); + } + return ( +
+ + + + + + + + + + + {pausedStreams.map((s: any) => ( + + + + + + + ))} + +
Stream IDCounterpartyTokenStatus
#{s.id}{s.recipient}{s.token} + + Paused + +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/dashboard/DashboardSettings.tsx b/frontend/src/components/dashboard/DashboardSettings.tsx new file mode 100644 index 00000000..b2be2af3 --- /dev/null +++ b/frontend/src/components/dashboard/DashboardSettings.tsx @@ -0,0 +1,43 @@ +import { Button } from "@/components/ui/Button"; + +interface DashboardSettingsProps { + session: any; + onDisconnect: () => void; +} + +export function DashboardSettings({ session, onDisconnect }: DashboardSettingsProps) { + return ( +
+
+
+

Create Stream

+ Save and reuse recurring configurations +
+ +
+

Template Library

+

+ Save recurring stream settings once, apply instantly, then + override before submitting. +

+ +
+ +
+ +
+
+ +
+

No templates yet. Save your first stream setup.

+
+
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/dashboard/dashboard-view.tsx b/frontend/src/components/dashboard/dashboard-view.tsx index 6a0c252c..188ab0ea 100644 --- a/frontend/src/components/dashboard/dashboard-view.tsx +++ b/frontend/src/components/dashboard/dashboard-view.tsx @@ -2,6 +2,7 @@ import React from "react"; import Link from "next/link"; +import dynamic from "next/dynamic"; import toast from "react-hot-toast"; /** @@ -52,6 +53,31 @@ import { CancelConfirmModal } from "../stream-creation/CancelConfirmModal"; import { StreamDetailsModal } from "./StreamDetailsModal"; import { Button } from "../ui/Button"; +// @ts-expect-error unused var +const DashboardOverviewDynamic = dynamic(() => import("./DashboardOverview"), { + ssr: false, +}); +// @ts-expect-error unused var +const DashboardIncomingDynamic = dynamic(() => import("./DashboardIncoming"), { + ssr: false, +}); +// @ts-expect-error unused var +const DashboardOutgoingDynamic = dynamic(() => import("./DashboardOutgoing"), { + ssr: false, +}); +// @ts-expect-error unused var +const DashboardPausedDynamic = dynamic(() => import("./DashboardPaused"), { + ssr: false, +}); +// @ts-expect-error unused var +const DashboardActivityDynamic = dynamic(() => import("./DashboardActivity"), { + ssr: false, +}); +// @ts-expect-error unused var +const DashboardSettingsDynamic = dynamic(() => import("./DashboardSettings"), { + ssr: false, +}); + // ─── Types ──────────────────────────────────────────────────────────────────── interface DashboardViewProps { @@ -143,8 +169,8 @@ function DashboardSkeleton() { ); } -/** Generic empty state with an optional CTA */ -function EmptyState({ +/** Generic empty state with an optional CTE */ +export function EmptyState({ icon, title, description, @@ -207,7 +233,7 @@ function ErrorState({ // ─── Icon helpers ───────────────────────────────────────────────────────────── -function BoltIcon() { +export function BoltIcon() { return (

{metric.label}

- {isUnavailable - ? "No data" - : formatAnalyticsValue(metric.value!, metric.format)} + {isUnavailable ? "No data" : formatAnalyticsValue(metric.value!, metric.format)}

- {isUnavailable ? metric.unavailableText : metric.detail} + {isUnavailable ? "No data" : metric.detail} ); @@ -545,9 +569,7 @@ export function DashboardView({ session, onDisconnect }: DashboardViewProps) { .then(setSnapshot) .catch((err) => { setSnapshotError( - err instanceof Error - ? err.message - : "Failed to refresh dashboard", + err instanceof Error ? err.message : "Failed to refresh dashboard", ); }); } @@ -649,9 +671,7 @@ export function DashboardView({ session, onDisconnect }: DashboardViewProps) { if (!cancelled) { setSnapshot(null); setSnapshotError( - err instanceof Error - ? err.message - : "Failed to fetch dashboard data.", + err instanceof Error ? err.message : "Failed to fetch dashboard data.", ); } } finally { @@ -761,7 +781,7 @@ export function DashboardView({ session, onDisconnect }: DashboardViewProps) { setStreamFormMessage(null); }; - // ── Optimistic helpers ──────────────────────────────────────────────────── + // ── Optimistic helpers ───────────────────────────────────────────────────── const removeStreamLocally = (streamId: string) => { queryClient.setQueryData(dashboardQueryKey(session.publicKey), (prev) => { @@ -982,7 +1002,7 @@ export function DashboardView({ session, onDisconnect }: DashboardViewProps) { ); } - // ── Overview ────────────────────────────────────────────────────────── + // ── Overview (default tab, rendered synchronously) ───────────────────── if (activeTab === "overview") { return (
@@ -999,354 +1019,71 @@ export function DashboardView({ session, onDisconnect }: DashboardViewProps) { ); } - // ── Incoming ────────────────────────────────────────────────────────── - if (activeTab === "incoming") { - if (snapshot!.incomingStreams.length === 0) { - return ( - } - title="No incoming streams yet" - description="No streams are sending you funds yet. Share your wallet address with a sender to receive streaming payments." - /> - ); - } + // ── Non-default tabs: lazy-loaded via next/dynamic ───────────────────── + const tabComponents: Record = { + incoming: DashboardIncomingDynamic, + outgoing: DashboardOutgoingDynamic, + paused: DashboardPausedDynamic, + activity: DashboardActivityDynamic, + settings: DashboardSettingsDynamic, + }; + + const TabComponent = tabComponents[activeTab]; + if (!TabComponent) { return ( -
- +
+

Under Construction

+

This tab is currently under development.

); } - // ── Outgoing ────────────────────────────────────────────────────────── - if (activeTab === "outgoing") { - const activeOutgoing = snapshot!.outgoingStreams.filter( - (s) => s.status === "Active", + // Pass required props to each tab component + if (activeTab === "incoming") { + return ( + ); - if (activeOutgoing.length === 0) { - return ( - } - title="No active outgoing streams" - description="You don't have any active outgoing payment streams. Create one to start streaming tokens to a recipient." - action={ - - } - /> - ); - } + } + + if (activeTab === "outgoing") { return ( -
- {renderStreams( - { ...snapshot!, outgoingStreams: activeOutgoing }, - (s) => setModal({ type: "topup", stream: s }), - (s) => setModal({ type: "cancel", stream: s }), - (s) => setModal({ type: "details", stream: s }), - )} -
+ setModal({ type: "topup", stream: s })} + onCancel={(s) => setModal({ type: "cancel", stream: s })} + onShowDetails={(s) => setModal({ type: "details", stream: s })} + /> ); } - // ── Paused ──────────────────────────────────────────────────────────── if (activeTab === "paused") { - const pausedStreams = [ - ...snapshot!.outgoingStreams.filter((s) => s.status === "Paused"), - ...snapshot!.incomingStreams.filter((s) => s.status === "Paused"), - ]; - if (pausedStreams.length === 0) { - return ( -
- No paused streams found. -
- ); - } return ( -
- - - - - - - - - - - {pausedStreams.map((s) => ( - - - - - - - ))} - -
Stream IDCounterpartyTokenStatus
#{s.id}{s.recipient}{s.token} - - Paused - -
-
+ ); } - // ── Activity ────────────────────────────────────────────────────────── if (activeTab === "activity") { return ( -
- {renderRecentActivity(snapshot, () => setShowWizard(true))} -
+ setShowWizard(true)} + /> ); } - // ── Settings ────────────────────────────────────────────────────────── if (activeTab === "settings") { return ( -
-
-
-

Create Stream

- Save and reuse recurring configurations -
- - {streamFormMessage ? ( -

- {streamFormMessage.text} -

- ) : null} - -
-
-

Template Library

-

- Save recurring stream settings once, apply instantly, then - override before submitting. -

- -
- setTemplateNameInput(e.target.value)} - placeholder="e.g. Monthly Contributor Payroll" - aria-label="Template name" - /> -
- - {editingTemplateId ? ( - - ) : null} -
-
- - {templates.length === 0 ? ( -
-

No templates yet. Save your first stream setup.

-
- ) : ( -
    - {templates.map((t) => ( -
  • -
    - {t.name} - - Updated {formatTemplateUpdatedAt(t.updatedAt)} - -
    -
    - - - -
    -
  • - ))} -
- )} -
- -
-
-
-

Stream Configuration

-

- {requiredFieldsCompleted} / 5 required fields completed -

-
- -
- - -
- - -
-
- - -
-
- -
-