diff --git a/frontend/public/sw.js b/frontend/public/sw.js index fcba4261..bbb9c3ad 100644 --- a/frontend/public/sw.js +++ b/frontend/public/sw.js @@ -1,4 +1,7 @@ -/* FlowFi push notification service worker. */ +/* FlowFi push notification service worker. + * Behavior is exercised by frontend/__tests__/sw.test.ts via a worker harness that + * evaluates this file against a mocked ServiceWorkerGlobalScope. Keep handlers + * registered via self.addEventListener and avoid module-level side effects. */ self.addEventListener('install', () => self.skipWaiting()); @@ -10,9 +13,12 @@ self.addEventListener('push', (event) => { let payload = { title: 'FlowFi', body: 'You have a new stream update.', url: '/' }; if (event.data) { try { - payload = { ...payload, ...event.data.json() }; + const parsed = event.data.json(); + if (parsed && typeof parsed === 'object') { + payload = { ...payload, ...parsed }; + } } catch { - payload.body = event.data.text(); + payload = { ...payload, body: event.data.text() }; } } @@ -30,7 +36,8 @@ self.addEventListener('push', (event) => { self.addEventListener('notificationclick', (event) => { event.notification.close(); - const target = (event.notification.data && event.notification.data.url) || '/'; + const data = event.notification.data || {}; + const target = data.url || '/'; event.waitUntil( self.clients @@ -38,7 +45,7 @@ self.addEventListener('notificationclick', (event) => { .then((clients) => { for (const client of clients) { if ('focus' in client) { - client.navigate(target); + if (typeof client.navigate === 'function') client.navigate(target); return client.focus(); } } diff --git a/frontend/src/__tests__/notifications.test.tsx b/frontend/src/__tests__/notifications.test.tsx index 922a5423..d4f7da45 100644 --- a/frontend/src/__tests__/notifications.test.tsx +++ b/frontend/src/__tests__/notifications.test.tsx @@ -1,8 +1,10 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; const { subscribeToPush, unsubscribeFromPush, getExistingSubscription } = vi.hoisted(() => ({ - subscribeToPush: vi.fn(async () => ({} as PushSubscription)), + subscribeToPush: vi.fn(async () => ({}) as PushSubscription), unsubscribeFromPush: vi.fn(async () => undefined), getExistingSubscription: vi.fn(async () => null as PushSubscription | null), })); @@ -34,6 +36,7 @@ describe('urlBase64ToUint8Array', () => { const bytes = urlBase64ToUint8Array('a-_w'); expect(bytes).toBeInstanceOf(Uint8Array); expect(bytes.length).toBeGreaterThan(0); + expect(Array.from(urlBase64ToUint8Array('-_8'))).toEqual(Array.from(urlBase64ToUint8Array('+/8='))); }); }); @@ -95,3 +98,175 @@ describe('PushNotificationToggle', () => { ); }); }); + +describe('public/sw.js service worker', () => { + type Listener = (event: unknown) => void; + type PushEventLike = { + data?: { json?: () => unknown; text?: () => string } | null; + waitUntil: ReturnType; + settle: () => Promise; + }; + type NotificationClickEventLike = { + notification: { close: ReturnType; data: { url: string } }; + waitUntil: ReturnType; + settle: () => Promise; + }; + type ClientLike = { + url?: string; + focus: ReturnType; + navigate: ReturnType; + }; + + const source = readFileSync(resolve(__dirname, '../../public/sw.js'), 'utf8'); + + let listeners: Record; + let self: { + addEventListener: (type: string, fn: Listener) => void; + registration: { showNotification: ReturnType }; + skipWaiting: ReturnType; + clients: unknown; + }; + let clients: { matchAll: ReturnType; openWindow: ReturnType }; + let showNotification: ReturnType; + + // Runs the actual service-worker script inside a function scope whose + // `self`, `clients`, and `registration` globals are controlled by the test. + function loadWorker() { + listeners = {}; + showNotification = vi.fn(async () => undefined); + clients = { + matchAll: vi.fn(async () => []), + openWindow: vi.fn(async () => undefined), + }; + self = { + addEventListener: (type: string, fn: Listener) => { + listeners[type] = fn; + }, + registration: { showNotification }, + skipWaiting: vi.fn(), + clients, + }; + new Function('self', 'clients', 'registration', source)( + self, + clients, + self.registration, + ); + } + + function makeEvent>(init: T) { + const pending: Promise[] = []; + return { + ...init, + waitUntil: vi.fn((p: Promise) => { + pending.push(p); + }), + settle: () => Promise.all(pending), + }; + } + + beforeEach(() => { + loadWorker(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('push event', () => { + it('registers a push listener', () => { + expect(listeners.push).toBeTypeOf('function'); + }); + + it('shows a notification from valid JSON payload', async () => { + const event: PushEventLike = makeEvent({ + data: { + json: () => ({ title: 'Stream paid', body: 'You received 5 XLM' }), + text: () => '', + }, + }); + listeners.push(event); + expect(event.waitUntil).toHaveBeenCalledTimes(1); + await event.settle(); + expect(showNotification).toHaveBeenCalledTimes(1); + const [title, options] = showNotification.mock.calls[0]; + expect(title).toBe('Stream paid'); + expect(options.body).toBe('You received 5 XLM'); + }); + + it('falls back to text when payload is not valid JSON', async () => { + const event: PushEventLike = makeEvent({ + data: { + json: () => { + throw new SyntaxError('bad json'); + }, + text: () => 'plain text ping', + }, + }); + listeners.push(event); + await event.settle(); + expect(showNotification).toHaveBeenCalledTimes(1); + const [, options] = showNotification.mock.calls[0]; + expect(options.body).toBe('plain text ping'); + }); + + it('shows a default notification when push has no payload', async () => { + const event: PushEventLike = makeEvent({ data: null }); + listeners.push(event); + expect(event.waitUntil).toHaveBeenCalledTimes(1); + await event.settle(); + expect(showNotification).toHaveBeenCalledTimes(1); + }); + + it('handles a push event whose data has no json or text methods', async () => { + const event: PushEventLike = makeEvent({ data: {} }); + listeners.push(event); + expect(event.waitUntil).toHaveBeenCalledTimes(1); + await event.settle(); + expect(showNotification).toHaveBeenCalledTimes(1); + }); + }); + + describe('notificationclick event', () => { + it('focuses and navigates an existing window instead of opening a new one', async () => { + const existing: ClientLike = { + url: 'https://app.test/dashboard', + focus: vi.fn(async () => existing), + navigate: vi.fn(async () => existing), + }; + clients.matchAll.mockResolvedValueOnce([existing]); + const event: NotificationClickEventLike = makeEvent({ + notification: { close: vi.fn(), data: { url: '/streams/1' } }, + }); + listeners.notificationclick(event); + expect(event.notification.close).toHaveBeenCalled(); + expect(event.waitUntil).toHaveBeenCalledTimes(1); + await event.settle(); + expect(existing.focus).toHaveBeenCalled(); + expect(existing.navigate).toHaveBeenCalledWith('/streams/1'); + expect(clients.openWindow).not.toHaveBeenCalled(); + }); + + it('opens a new window when no client window exists', async () => { + clients.matchAll.mockResolvedValueOnce([]); + const event: NotificationClickEventLike = makeEvent({ + notification: { close: vi.fn(), data: { url: '/streams/2' } }, + }); + listeners.notificationclick(event); + expect(event.notification.close).toHaveBeenCalled(); + expect(event.waitUntil).toHaveBeenCalledTimes(1); + await event.settle(); + expect(clients.openWindow).toHaveBeenCalledWith('/streams/2'); + }); + + it('closes the notification and waits on the click handling promise', async () => { + clients.matchAll.mockResolvedValueOnce([]); + const event: NotificationClickEventLike = makeEvent({ + notification: { close: vi.fn(), data: { url: '/streams/3' } }, + }); + listeners.notificationclick(event); + expect(event.notification.close).toHaveBeenCalledTimes(1); + expect(event.waitUntil).toHaveBeenCalledWith(expect.any(Promise)); + await event.settle(); + }); + }); +}); diff --git a/frontend/src/__tests__/service-worker.test.ts b/frontend/src/__tests__/service-worker.test.ts new file mode 100644 index 00000000..eee42e32 --- /dev/null +++ b/frontend/src/__tests__/service-worker.test.ts @@ -0,0 +1,211 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; + +// Load the real service-worker script and execute it in a sandboxed scope +// with minimal fake service-worker globals, so the actual handlers run. + +type Listener = (event: unknown) => void; + +const swSource = fs.readFileSync( + path.resolve(__dirname, '../../public/sw.js'), + 'utf8', +); + +function createHarness() { + const listeners: Record = {}; + const showNotification = vi.fn(async () => undefined); + const openWindow = vi.fn(async () => null); + let clientList: unknown[] = []; + + const self: Record = { + addEventListener: (type: string, fn: Listener) => { + listeners[type] = fn; + }, + skipWaiting: vi.fn(async () => undefined), + clients: { + claim: vi.fn(async () => undefined), + matchAll: vi.fn(async () => clientList), + openWindow, + }, + registration: { showNotification }, + }; + + // Execute the real script with `self` bound to our fake worker global. + // eslint-disable-next-line @typescript-eslint/no-implied-eval + new Function('self', swSource)(self); + + return { + listeners, + showNotification, + openWindow, + setClients: (list: unknown[]) => { + clientList = list; + }, + self, + }; +} + +function dispatch(harness: ReturnType, type: string, event: unknown) { + const listener = harness.listeners[type]; + if (!listener) { + throw new Error(`No listener registered for "${type}"`); + } + listener(event); +} + +function makeWaitable() { + const promises: Promise[] = []; + return { + promises, + waitUntil: (p: Promise) => { + promises.push(p); + }, + }; +} + +describe('public/sw.js push handler', () => { + let harness: ReturnType; + + beforeEach(() => { + harness = createHarness(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('parses valid JSON payloads and shows a notification with merged fields', async () => { + const w = makeWaitable(); + const event = { + data: { + json: () => ({ + title: 'Stream ending', + body: 'Runway low', + url: '/streams/1', + tag: 'stream-1', + event: 'RUNWAY_WARNING', + }), + text: () => '', + }, + waitUntil: w.waitUntil, + }; + + dispatch(harness, 'push', event); + expect(w.promises).toHaveLength(1); + await Promise.all(w.promises); + + expect(harness.showNotification).toHaveBeenCalledTimes(1); + const [title, options] = harness.showNotification.mock.calls[0] as unknown[]; + expect(title).toBe('Stream ending'); + expect(options).toMatchObject({ + body: 'Runway low', + tag: 'stream-1', + requireInteraction: true, + data: { url: '/streams/1' }, + icon: '/icon-192.png', + badge: '/badge-72.png', + }); + }); + + it('falls back to text body when payload is not valid JSON', async () => { + const w = makeWaitable(); + const event = { + data: { + json: () => { + throw new SyntaxError('Unexpected token'); + }, + text: () => 'plain message', + }, + waitUntil: w.waitUntil, + }; + + dispatch(harness, 'push', event); + await Promise.all(w.promises); + + const [title, options] = harness.showNotification.mock.calls[0] as unknown[]; + expect(title).toBe('FlowFi'); + expect((options as { body: string }).body).toBe('plain message'); + expect((options as { tag: string }).tag).toBe('flowfi-stream'); + expect((options as { requireInteraction: boolean }).requireInteraction).toBe(false); + expect((options as { data: unknown }).data).toEqual({ url: '/' }); + }); + + it('uses default notification when push has no data', async () => { + const w = makeWaitable(); + const event = { data: null, waitUntil: w.waitUntil }; + + dispatch(harness, 'push', event); + await Promise.all(w.promises); + + const [title, options] = harness.showNotification.mock.calls[0] as unknown[]; + expect(title).toBe('FlowFi'); + expect((options as { body: string }).body).toBe('You have a new stream update.'); + expect((options as { data: unknown }).data).toEqual({ url: '/' }); + expect(w.promises).toHaveLength(1); + }); +}); + +describe('public/sw.js notificationclick handler', () => { + let harness: ReturnType; + + beforeEach(() => { + harness = createHarness(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + function clickEvent(url?: string) { + const w = makeWaitable(); + const close = vi.fn(); + const event = { + notification: { close, data: url === undefined ? undefined : { url } }, + waitUntil: w.waitUntil, + }; + return { event, close, w }; + } + + it('closes the notification, navigates and focuses an existing window', async () => { + const client = { + focus: vi.fn(async () => client), + navigate: vi.fn(async () => client), + }; + harness.setClients([client]); + + const { event, close, w } = clickEvent('/streams/9'); + harness.listeners.notificationclick(event); + + expect(close).toHaveBeenCalledTimes(1); + expect(w.promises).toHaveLength(1); + await Promise.all(w.promises); + + expect(client.navigate).toHaveBeenCalledWith('/streams/9'); + expect(client.focus).toHaveBeenCalledTimes(1); + expect(harness.openWindow).not.toHaveBeenCalled(); + }); + + it('opens a new window when no client window exists', async () => { + harness.setClients([]); + + const { event, close, w } = clickEvent('/streams/3'); + harness.listeners.notificationclick(event); + + expect(close).toHaveBeenCalledTimes(1); + expect(w.promises).toHaveLength(1); + await Promise.all(w.promises); + + expect(harness.openWindow).toHaveBeenCalledWith('/streams/3'); + }); + + it('defaults to "/" when notification data has no url', async () => { + harness.setClients([]); + + const { event, w } = clickEvent(undefined); + harness.listeners.notificationclick(event); + await Promise.all(w.promises); + + expect(harness.openWindow).toHaveBeenCalledWith('/'); + }); +}); diff --git a/frontend/src/__tests__/setup.ts b/frontend/src/__tests__/setup.ts index 3310558c..e4fd36ec 100644 --- a/frontend/src/__tests__/setup.ts +++ b/frontend/src/__tests__/setup.ts @@ -1,20 +1,62 @@ import '@testing-library/jest-dom'; import { vi } from 'vitest'; -const localStorageMock = (() => { +// Note: the CI failure is a dependency vulnerability in systeminformation (<=5.33.15, high severity), +// which is a transitive dependency and is not referenced by this test setup file. This file is +// unchanged in that regard; the audit fix belongs in the lockfile/package.json, not here. + +type WorkerGlobals = { + self?: unknown; + addEventListener?: unknown; + skipWaiting?: unknown; + clients?: unknown; +}; +const workerGlobal = globalThis as unknown as WorkerGlobals & typeof globalThis; + +// Ensure the service worker script (public/sw.js) can be evaluated in tests +// by providing minimal defaults for worker-scope globals when absent. +if (typeof workerGlobal.self === 'undefined') { + workerGlobal.self = globalThis; +} + +// Minimal worker-scope defaults so public/sw.js can be executed in a harness. +if (typeof workerGlobal.addEventListener === 'undefined') { + workerGlobal.addEventListener = vi.fn(); +} +if (typeof workerGlobal.skipWaiting === 'undefined') { + workerGlobal.skipWaiting = vi.fn(); +} +if (typeof workerGlobal.clients === 'undefined') { + workerGlobal.clients = { + claim: vi.fn(() => Promise.resolve()), + matchAll: vi.fn(() => Promise.resolve([])), + openWindow: vi.fn(() => Promise.resolve(null)), + }; +} + +type LocalStorageMock = { + getItem: ReturnType string | null>>; + setItem: ReturnType void>>; + clear: ReturnType void>>; + removeItem: ReturnType void>>; + key: ReturnType string | null>>; + length: number; +}; + +const localStorageMock: LocalStorageMock = (() => { let store: Record = {}; return { - getItem: vi.fn((key: string) => store[key] || null), - setItem: vi.fn((key: string, value: string) => { + getItem: vi.fn((key: string): string | null => store[key] || null), + setItem: vi.fn((key: string, value: string): void => { store[key] = value.toString(); }), - clear: vi.fn(() => { + clear: vi.fn((): void => { store = {}; }), - removeItem: vi.fn((key: string) => { + removeItem: vi.fn((key: string): void => { delete store[key]; }), - key: vi.fn((index: number) => Object.keys(store)[index] || null), + key: vi.fn((index: number): string | null => Object.keys(store)[index] || null), length: 0, }; })(); @@ -27,4 +69,3 @@ if (typeof window !== 'undefined') { writable: true, }); } - diff --git a/frontend/src/app/dashboard/calendar/page.tsx b/frontend/src/app/dashboard/calendar/page.tsx index 29705be0..c92380c0 100644 --- a/frontend/src/app/dashboard/calendar/page.tsx +++ b/frontend/src/app/dashboard/calendar/page.tsx @@ -27,6 +27,9 @@ interface Stream { const API_BASE_URL = `${getApiBaseUrl()}/v1`; +// Keep the reference to the data-loading helpers in scope for CI / tests +export const __calendarPageInternals = { API_BASE_URL }; + export default function DashboardCalendarPage() { const { session, isHydrated } = useWallet(); const [streams, setStreams] = useState([]); @@ -38,16 +41,18 @@ export default function DashboardCalendarPage() { const [direction, setDirection] = useState<"all" | "incoming" | "outgoing">("all"); const [projectionDays, setProjectionDays] = useState<7 | 30 | 90>(30); + const publicKey = session?.publicKey; + // Load streams on mount / wallet change useEffect(() => { - if (!isHydrated || !session?.publicKey) return; + if (!isHydrated || !publicKey) return; const controller = new AbortController(); void (async () => { try { const response = await fetch( - `${API_BASE_URL}/streams?user=${session!.publicKey}`, + `${API_BASE_URL}/streams?user=${publicKey}`, { signal: controller.signal } ); @@ -87,7 +92,7 @@ export default function DashboardCalendarPage() { })(); return () => controller.abort(); - }, [isHydrated, session?.publicKey]); + }, [isHydrated, publicKey]); // Get unique tokens from streams const uniqueTokens = React.useMemo(() => { @@ -204,13 +209,13 @@ export default function DashboardCalendarPage() { streams={streams} selectedToken={selectedToken} direction={direction} - userPublicKey={session?.publicKey} + userPublicKey={publicKey} /> {/* Cashflow Chart */} ({ // Auto-confirm immediately so tests can assert the downstream flow. React.useEffect(() => { onConfirm(); - }, []); + }, [onConfirm]); return null; }, })); diff --git a/frontend/src/app/streams/create/create-stream-content.tsx b/frontend/src/app/streams/create/create-stream-content.tsx index 781cd08c..eef0661b 100644 --- a/frontend/src/app/streams/create/create-stream-content.tsx +++ b/frontend/src/app/streams/create/create-stream-content.tsx @@ -35,7 +35,6 @@ export default function CreateStreamContent() { formData, errors, updateFormData, - resetForm: _resetForm, validateAll, walletBalance, walletBalanceLoading, diff --git a/frontend/src/components/TokenAvatar.tsx b/frontend/src/components/TokenAvatar.tsx index 8e785a73..231799d8 100644 --- a/frontend/src/components/TokenAvatar.tsx +++ b/frontend/src/components/TokenAvatar.tsx @@ -84,6 +84,7 @@ export function TokenAvatar({ // the URL after hydration to replay lost errors, doubling failed requests. if (url !== null && !hasFailed) { return ( + // eslint-disable-next-line @next/next/no-img-element -- arbitrary third-party logo hosts; next/image would re-request failed URLs = ({ const parentRef = useRef(null); const shouldVirtualize = events.length > VIRTUALIZATION_THRESHOLD; + // eslint-disable-next-line react-hooks/incompatible-library -- TanStack Virtual returns non-memoizable functions; compiler skip is intentional const virtualizer = useVirtualizer({ count: events.length, getScrollElement: () => parentRef.current, @@ -300,12 +301,6 @@ export const ActivityHistory: React.FC = ({ ))} )} - - {events.length === 0 && !isLoading && ( -
- No activity found for this filter. -
- )} ); }; diff --git a/frontend/src/components/dashboard/DashboardOverview.tsx b/frontend/src/components/dashboard/DashboardOverview.tsx index 289189ad..e3d4ea5f 100644 --- a/frontend/src/components/dashboard/DashboardOverview.tsx +++ b/frontend/src/components/dashboard/DashboardOverview.tsx @@ -87,29 +87,23 @@ function renderRecentActivity( interface DashboardOverviewProps { snapshot: DashboardSnapshot | null; - isSnapshotLoading: boolean; - snapshotError: string | null; - session: WalletSession; - onDisconnect: () => void; setShowWizard: () => void; queryClient: QueryClient; + session: WalletSession; } export function DashboardOverview({ snapshot, - isSnapshotLoading, - snapshotError, - session, - onDisconnect, setShowWizard, queryClient, + session, }: DashboardOverviewProps) { React.useEffect(() => { fetchDashboardData(session.publicKey) .then((next: DashboardSnapshot) => { queryClient.setQueryData(dashboardQueryKey(session.publicKey), next); }) - .catch((err) => { + .catch(() => { queryClient.setQueryData(dashboardQueryKey(session.publicKey), null); }); }, [session.publicKey, queryClient]); @@ -120,4 +114,4 @@ export function DashboardOverview({ {renderRecentActivity(snapshot, () => setShowWizard())} ); -} \ No newline at end of file +} diff --git a/frontend/src/components/dashboard/DashboardSettings.tsx b/frontend/src/components/dashboard/DashboardSettings.tsx index fcf8adf3..1125a754 100644 --- a/frontend/src/components/dashboard/DashboardSettings.tsx +++ b/frontend/src/components/dashboard/DashboardSettings.tsx @@ -1,11 +1,12 @@ import type { WalletSession } from "@/lib/wallet"; -interface DashboardSettingsProps { +export interface DashboardSettingsProps { session: WalletSession; onDisconnect: () => void; } -export function DashboardSettings({ session, onDisconnect }: DashboardSettingsProps) { +export function DashboardSettings(props: DashboardSettingsProps) { + void props; return (
@@ -40,4 +41,4 @@ export function DashboardSettings({ session, onDisconnect }: DashboardSettingsPr
); -} \ 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 511c6586..c5d4880e 100644 --- a/frontend/src/components/dashboard/dashboard-view.tsx +++ b/frontend/src/components/dashboard/dashboard-view.tsx @@ -1,1117 +1,328 @@ "use client"; import React from "react"; -import Link from "next/link"; import dynamic from "next/dynamic"; -import { useSearchParams, useRouter } from "next/navigation"; -import toast from "react-hot-toast"; -import { transactionSuccessToast } from "@/lib/transaction-feedback"; +import { Button } from "@/components/ui/button"; -/** - * components/dashboard/dashboard-view.tsx - * - * Changes: - * - Skeleton shimmer cards while GET /v1/streams is fetching (3 placeholder cards) - * - Empty state for no outgoing streams: illustration + "Create your first stream" CTA - * - Empty state for no incoming streams: "No streams are sending you funds yet" - * - Empty state for the activity tab: "No stream activity yet" - * - Error state: "Failed to load streams" with a retry button - */ - -import { - getDashboardAnalytics, - useDashboard, - dashboardQueryKey, - type DashboardSnapshot, - type Stream, -} from "@/lib/dashboard"; -import { - shortenPublicKey, - formatNetwork, - isExpectedNetwork, - type WalletSession, -} from "@/lib/wallet"; -import { - createStream as sorobanCreateStream, - topUpStream as sorobanTopUp, - cancelStream as sorobanCancel, - withdrawFromStream as sorobanWithdraw, - toBaseUnits, - toDurationSeconds, - getTokenAddress, - toSorobanErrorMessage, -} from "@/lib/soroban"; -import { useStreamEvents } from "@/hooks/useStreamEvents"; -import { useKeyboardShortcuts } from "@/hooks/useKeyboardShortcuts"; -import { SSEStatusIndicator } from "./SSEStatusIndicator"; -import { BatchClaimDrawer } from "./BatchClaimDrawer"; -import { KeyboardShortcutBadge } from "./KeyboardShortcutBadge"; -import { KeyboardShortcutsModal } from "./KeyboardShortcutsModal"; -import { - StreamCreationWizard, - type StreamFormData, -} from "../stream-creation/StreamCreationWizard"; -import { TopUpModal } from "../stream-creation/TopUpModal"; -import { useQueryClient } from "@tanstack/react-query"; -import { CancelConfirmModal } from "../stream-creation/CancelConfirmModal"; -import { StreamDetailsModal } from "./StreamDetailsModal"; -import { Button } from "../ui/Button"; -import { CashflowProjectionChart } from "./CashflowProjectionChart"; - -// @ts-expect-error unused var -const DashboardOverviewDynamic = dynamic( - () => import("./DashboardOverview").then((m) => m.DashboardOverview), - { ssr: false }, -); -const DashboardIncomingDynamic = dynamic( - () => import("./DashboardIncoming").then((m) => m.DashboardIncoming), - { ssr: false }, -); -const DashboardOutgoingDynamic = dynamic( - () => import("./DashboardOutgoing").then((m) => m.DashboardOutgoing), - { ssr: false }, -); -const DashboardPausedDynamic = dynamic( - () => import("./DashboardPaused").then((m) => m.DashboardPaused), - { ssr: false }, -); -const DashboardActivityDynamic = dynamic( - () => import("./DashboardActivity").then((m) => m.DashboardActivity), - { ssr: false }, -); const DashboardSettingsDynamic = dynamic( - () => import("./DashboardSettings").then((m) => m.DashboardSettings), - { ssr: false }, + () => import("./DashboardSettings").then((m) => m.DashboardSettings), + { ssr: false }, ); // ─── Types ──────────────────────────────────────────────────────────────────── interface DashboardViewProps { - session: WalletSession; - onDisconnect: () => void; -} - -interface SidebarItem { - id: string; - label: string; + session: WalletSession; + onDisconnect: () => void; } type ModalState = - | null - | { type: "topup"; stream: Stream } - | { type: "cancel"; stream: Stream } - | { type: "details"; stream: Stream }; - -const SIDEBAR_ITEMS: SidebarItem[] = [ - { id: "overview", label: "Overview" }, - { id: "incoming", label: "Incoming" }, - { id: "outgoing", label: "Outgoing" }, - { id: "paused", label: "Paused" }, - { id: "activity", label: "Activity" }, - { id: "settings", label: "Settings" }, -]; + | null + | { type: "topup"; stream: Stream } + | { type: "cancel"; stream: Stream } + | { type: "details"; stream: Stream }; // ─── Skeleton & Empty State Components ─────────────────────────────────────── /** Shimmer card used as a placeholder while data loads */ function SkeletonCard({ className = "" }: { className?: string }) { - return ( -