diff --git a/frontend/components/dashboard/dashboard-view.tsx b/frontend/components/dashboard/dashboard-view.tsx index 6b81931..e0e0082 100644 --- a/frontend/components/dashboard/dashboard-view.tsx +++ b/frontend/components/dashboard/dashboard-view.tsx @@ -1,5 +1,8 @@ "use client"; +import React from "react"; +import toast from "react-hot-toast"; + /** * components/dashboard/dashboard-view.tsx * @@ -9,11 +12,11 @@ * - formatNetwork() used so "PUBLIC" → "Mainnet", "TESTNET" → "Testnet". */ -import React from "react"; import { getDashboardAnalytics, fetchDashboardData, type DashboardSnapshot, + type Stream, } from "@/lib/dashboard"; import { shortenPublicKey, @@ -21,13 +24,26 @@ import { isExpectedNetwork, type WalletSession, } from "@/lib/wallet"; +import { + createStream as sorobanCreateStream, + topUpStream as sorobanTopUp, + cancelStream as sorobanCancel, + toBaseUnits, + toDurationSeconds, + getTokenAddress, + toSorobanErrorMessage, +} from "@/lib/soroban"; import IncomingStreams from "../IncomingStreams"; import { StreamCreationWizard, type StreamFormData, } from "../stream-creation/StreamCreationWizard"; +import { TopUpModal } from "../stream-creation/TopUpModal"; +import { CancelConfirmModal } from "../stream-creation/CancelConfirmModal"; import { Button } from "../ui/Button"; +// ─── Types ──────────────────────────────────────────────────────────────────── + interface DashboardViewProps { session: WalletSession; onDisconnect: () => void; @@ -38,6 +54,12 @@ interface SidebarItem { label: string; } +// Modal state: null = closed +type ModalState = + | null + | { type: "topup"; stream: Stream } + | { type: "cancel"; stream: Stream }; + interface StreamFormValues { recipient: string; token: string; @@ -72,6 +94,8 @@ const SIDEBAR_ITEMS: SidebarItem[] = [ { id: "settings", label: "Settings" }, ]; +// ─── Formatters ─────────────────────────────────────────────────────────────── + const STREAM_TEMPLATES_STORAGE_KEY = "flowfi.stream.templates.v1"; const EMPTY_STREAM_FORM: StreamFormValues = { @@ -112,6 +136,26 @@ function formatActivityTime(timestamp: string): string { }).format(date); } +function renderStats(snapshot: DashboardSnapshot | null) { + if (!snapshot) return null; + return ( +
+
+

Total Sent

+

{formatCurrency(snapshot.totalSent)}

+
+
+

Total Received

+

{formatCurrency(snapshot.totalReceived)}

+
+
+

Total Value Locked

+

{formatCurrency(snapshot.totalValueLocked)}

+
+
+ ); +} + function renderAnalytics(snapshot: DashboardSnapshot | null) { const metrics = getDashboardAnalytics(snapshot); return ( @@ -149,56 +193,17 @@ function renderAnalytics(snapshot: DashboardSnapshot | null) { ); } -function renderStats(snapshot: DashboardSnapshot) { - const items = [ - { - id: "total-sent", - label: "Total Sent", - value: formatCurrency(snapshot.totalSent), - detail: "Lifetime outgoing amount", - }, - { - id: "total-received", - label: "Total Received", - value: formatCurrency(snapshot.totalReceived), - detail: "Lifetime incoming amount", - }, - { - id: "tvl", - label: "Total Value Locked", - value: formatCurrency(snapshot.totalValueLocked), - detail: "Funds currently locked in streams", - }, - { - id: "active-streams", - label: "Active Streams", - value: String(snapshot.activeStreamsCount), - detail: "Streams currently live", - }, - ] as const; - - return ( -
- {items.map((item) => ( -
-

{item.label}

-

{item.value}

- {item.detail} -
- ))} -
- ); -} - function renderStreams( - snapshot: DashboardSnapshot, - onTopUp: (id: string) => void, + snapshot: DashboardSnapshot | null, + onTopUp: (stream: Stream) => void, + onCancel: (stream: Stream) => void, ) { + if (!snapshot) return null; return (

My Active Streams

- {snapshot.outgoingStreams.length} total + {snapshot.outgoingStreams.filter(s => s.status === "Active").length} total
@@ -212,29 +217,40 @@ function renderStreams( - {snapshot.outgoingStreams.map((stream) => ( - - - - - - - - ))} + {snapshot.outgoingStreams + .filter((s) => s.status === "Active") + .map((stream) => ( + + + + + + + + ))}
{stream.date} - {stream.recipient} - - {stream.deposited} {stream.token} - - {stream.withdrawn} {stream.token} - - -
{stream.date} + {stream.recipient} + + {stream.deposited} {stream.token} + + {stream.withdrawn} {stream.token} + +
+ + +
+
@@ -242,7 +258,8 @@ function renderStreams( ); } -function renderRecentActivity(snapshot: DashboardSnapshot) { +function renderRecentActivity(snapshot: DashboardSnapshot | null) { + if (!snapshot) return null; return (
@@ -279,77 +296,12 @@ function renderRecentActivity(snapshot: DashboardSnapshot) { ); } -// ── Main component ──────────────────────────────────────────────────────────── -function safeLoadTemplates(): StreamTemplate[] { - if (typeof window === "undefined") { - return []; - } - - const stored = window.localStorage.getItem(STREAM_TEMPLATES_STORAGE_KEY); - if (!stored) { - return []; - } - - try { - const parsed = JSON.parse(stored); - if (!Array.isArray(parsed)) { - return []; - } - - return parsed.filter((item): item is StreamTemplate => { - return ( - typeof item?.id === "string" && - typeof item?.name === "string" && - typeof item?.createdAt === "string" && - typeof item?.updatedAt === "string" && - typeof item?.values === "object" && - typeof item.values?.recipient === "string" && - typeof item.values?.token === "string" && - typeof item.values?.totalAmount === "string" && - typeof item.values?.startsAt === "string" && - typeof item.values?.endsAt === "string" && - typeof item.values?.cadenceSeconds === "string" && - typeof item.values?.note === "string" - ); - }); - } catch { - return []; - } -} - -function persistTemplates(templates: StreamTemplate[]) { - if (typeof window === "undefined") { - return; - } - - window.localStorage.setItem( - STREAM_TEMPLATES_STORAGE_KEY, - JSON.stringify(templates), - ); -} - -function formatTemplateUpdatedAt(timestamp: string): string { - const date = new Date(timestamp); - if (Number.isNaN(date.getTime())) { - return "Unknown"; - } - - return new Intl.DateTimeFormat("en-US", { - dateStyle: "medium", - timeStyle: "short", - }).format(date); -} - -function createTemplateId(): string { - if (typeof crypto !== "undefined" && "randomUUID" in crypto) { - return crypto.randomUUID(); - } - - return `template-${Date.now()}`; -} - export function DashboardView({ session, onDisconnect }: DashboardViewProps) { const [activeTab, setActiveTab] = React.useState("overview"); + const [showWizard, setShowWizard] = React.useState(false); + const [modal, setModal] = React.useState(null); + + // --- Templates State (from upstream) --- const [streamForm, setStreamForm] = React.useState( EMPTY_STREAM_FORM, ); @@ -364,8 +316,41 @@ export function DashboardView({ session, onDisconnect }: DashboardViewProps) { >(null); const [streamFormMessage, setStreamFormMessage] = React.useState(null); - const stats = getMockDashboardStats(session.walletId); + // --- Snapshot State (merged) --- + const [snapshot, setSnapshot] = React.useState(null); + + // --- Helper Functions for missing logic --- + const safeLoadTemplates = (): StreamTemplate[] => { + try { + if (typeof window === "undefined") return []; + const stored = localStorage.getItem(STREAM_TEMPLATES_STORAGE_KEY); + return stored ? JSON.parse(stored) : []; + } catch { + return []; + } + }; + + const persistTemplates = (items: StreamTemplate[]) => { + if (typeof window === "undefined") return; + localStorage.setItem(STREAM_TEMPLATES_STORAGE_KEY, JSON.stringify(items)); + }; + + const formatTemplateUpdatedAt = (iso: string) => { + const d = new Date(iso); + return isNaN(d.getTime()) ? iso : d.toLocaleDateString(); + }; + + const isTemplateNameValid = templateNameInput.trim().length > 0; + const saveTemplateButtonLabel = editingTemplateId ? "Update Template" : "Save Template"; + const requiredFieldsCompleted = Object.values(streamForm).filter(v => v.trim().length > 0).length; + + const handleClearTemplateEditor = () => { + setTemplateNameInput(""); + setEditingTemplateId(null); + }; + + // --- Templates Effects & Handlers --- React.useEffect(() => { const loadedTemplates = safeLoadTemplates(); setTemplates(loadedTemplates); @@ -373,9 +358,7 @@ export function DashboardView({ session, onDisconnect }: DashboardViewProps) { }, []); React.useEffect(() => { - if (!templatesHydrated) { - return; - } + if (!templatesHydrated) return; persistTemplates(templates); }, [templates, templatesHydrated]); @@ -384,43 +367,9 @@ export function DashboardView({ session, onDisconnect }: DashboardViewProps) { setStreamFormMessage(null); }; - const requiredFieldsCompleted = [ - streamForm.recipient, - streamForm.token, - streamForm.totalAmount, - streamForm.startsAt, - streamForm.endsAt, - ].filter((value) => value.trim().length > 0).length; - - const saveTemplateButtonLabel = editingTemplateId - ? "Update Template" - : "Save as Template"; - - const isTemplateNameValid = templateNameInput.trim().length > 0; - - const handleTopUp = (streamId: string) => { - const amount = prompt(`Enter amount to add to stream ${streamId}:`); - if (amount && !Number.isNaN(parseFloat(amount)) && parseFloat(amount) > 0) { - console.log(`Adding ${amount} funds to stream ${streamId}`); - // TODO: Integrate with Soroban contract's top_up_stream function - alert(`Successfully added ${amount} to stream ${streamId}`); - } - }; - - const handleCreateStream = async (data: StreamFormData) => { - console.log("Creating stream with data:", data); - // TODO: Integrate with Soroban contract's create_stream function - await new Promise((resolve) => setTimeout(resolve, 1500)); - alert( - `Stream created successfully!\n\nRecipient: ${data.recipient}\nToken: ${data.token}\nAmount: ${data.amount}\nDuration: ${data.duration} ${data.durationUnit}`, - ); - setShowWizard(false); const handleApplyTemplate = (templateId: string) => { const template = templates.find((item) => item.id === templateId); - if (!template) { - return; - } - + if (!template) return; setStreamForm({ ...template.values }); setSelectedTemplateId(template.id); setStreamFormMessage({ @@ -429,87 +378,43 @@ export function DashboardView({ session, onDisconnect }: DashboardViewProps) { }); }; - const handleDeleteTemplate = (templateId: string) => { - const template = templates.find((item) => item.id === templateId); - if (!template) { - return; - } - - const shouldDelete = window.confirm( - `Delete stream template "${template.name}"?`, - ); - if (!shouldDelete) { - return; - } - - setTemplates((previous) => previous.filter((item) => item.id !== templateId)); - if (selectedTemplateId === templateId) { - setSelectedTemplateId(null); - } - if (editingTemplateId === templateId) { - setEditingTemplateId(null); - setTemplateNameInput(""); - } - }; - const handleSaveTemplate = () => { const cleanedName = templateNameInput.trim(); if (!cleanedName) { - setStreamFormMessage({ - text: "Template name is required.", - tone: "error", - }); + setStreamFormMessage({ text: "Template name is required.", tone: "error" }); return; } - const now = new Date().toISOString(); - if (editingTemplateId) { - setTemplates((previous) => - previous.map((template) => - template.id === editingTemplateId - ? { - ...template, - name: cleanedName, - updatedAt: now, - values: { ...streamForm }, - } - : template, + setTemplates((prev) => + prev.map((t) => + t.id === editingTemplateId + ? { ...t, name: cleanedName, updatedAt: now, values: { ...streamForm } } + : t, ), ); - setStreamFormMessage({ - text: `Template "${cleanedName}" updated.`, - tone: "success", - }); + setStreamFormMessage({ text: `Template "${cleanedName}" updated.`, tone: "success" }); setSelectedTemplateId(editingTemplateId); setEditingTemplateId(null); setTemplateNameInput(""); return; } - const newTemplate: StreamTemplate = { - id: createTemplateId(), + id: `template-${Date.now()}`, name: cleanedName, createdAt: now, updatedAt: now, values: { ...streamForm }, }; - - setTemplates((previous) => [newTemplate, ...previous]); + setTemplates((prev) => [newTemplate, ...prev]); setSelectedTemplateId(newTemplate.id); setTemplateNameInput(""); - setStreamFormMessage({ - text: `Template "${cleanedName}" saved.`, - tone: "success", - }); + setStreamFormMessage({ text: `Template "${cleanedName}" saved.`, tone: "success" }); }; const handleEditTemplate = (templateId: string) => { const template = templates.find((item) => item.id === templateId); - if (!template) { - return; - } - + if (!template) return; setEditingTemplateId(template.id); setTemplateNameInput(template.name); setSelectedTemplateId(template.id); @@ -520,15 +425,139 @@ export function DashboardView({ session, onDisconnect }: DashboardViewProps) { }); }; - const handleClearTemplateEditor = () => { - setEditingTemplateId(null); - setTemplateNameInput(""); + const handleDeleteTemplate = (templateId: string) => { + const template = templates.find((item) => item.id === templateId); + if (!template) return; + if (!window.confirm(`Delete stream template "${template.name}"?`)) return; + setTemplates((prev) => prev.filter((item) => item.id !== templateId)); + if (selectedTemplateId === templateId) setSelectedTemplateId(null); + if (editingTemplateId === templateId) { + setEditingTemplateId(null); + setTemplateNameInput(""); + } + }; + + const handleResetStreamForm = () => { + setStreamForm(EMPTY_STREAM_FORM); + setSelectedTemplateId(null); setStreamFormMessage(null); }; - const handleCreateStream = (event: React.FormEvent) => { - event.preventDefault(); + // ── Optimistic helpers ────────────────────────────────────────────────────── + + /** Mark a stream as cancelled in local state. */ + const removeStreamLocally = (streamId: string) => { + setSnapshot((prev) => { + if (!prev) return prev; + return { + ...prev, + outgoingStreams: prev.outgoingStreams.map((s) => + s.id === streamId ? { ...s, status: "Cancelled" as "Active" | "Completed" | "Paused" } : s, + ), + activeStreamsCount: Math.max(0, prev.activeStreamsCount - 1), + }; + }); + }; + + /** Add top-up amount to a stream in local state. */ + const topUpStreamLocally = (streamId: string, amount: number) => { + setSnapshot((prev) => { + if (!prev) return prev; + return { + ...prev, + outgoingStreams: prev.outgoingStreams.map((s) => + s.id === streamId + ? { ...s, deposited: s.deposited + amount } + : s, + ), + }; + }); + }; + /** Prepend a new stream to local state after creation. */ + const addStreamLocally = (data: StreamFormData) => { + const newStream: Stream = { + id: `stream-${Date.now()}`, + date: new Date().toISOString().split("T")[0], + recipient: shortenPublicKey(data.recipient), + amount: parseFloat(data.amount), + token: data.token, + status: "Active", + deposited: parseFloat(data.amount), + withdrawn: 0, + }; + setSnapshot((prev) => { + if (!prev) return prev; + return { + ...prev, + outgoingStreams: [newStream, ...prev.outgoingStreams], + activeStreamsCount: prev.activeStreamsCount + 1, + }; + }); + }; + + // ── Contract handlers ─────────────────────────────────────────────────────── + + const handleCreateStream = async (data: StreamFormData) => { + const toastId = toast.loading("Creating stream…"); + try { + const durationSecs = toDurationSeconds(data.duration, data.durationUnit); + const amount = toBaseUnits(data.amount); + const tokenAddress = getTokenAddress(data.token); + + await sorobanCreateStream(session, { + recipient: data.recipient, + tokenAddress, + amount, + durationSeconds: durationSecs, + }); + + addStreamLocally(data); + setShowWizard(false); + toast.success("Stream created successfully!", { id: toastId }); + } catch (err) { + toast.error(toSorobanErrorMessage(err), { id: toastId }); + // Re-throw so the wizard's isSubmitting state resets properly + throw err; + } + }; + + const handleTopUpConfirm = async (streamId: string, amountStr: string) => { + const toastId = toast.loading("Topping up stream…"); + try { + const amount = toBaseUnits(amountStr); + await sorobanTopUp(session, { + streamId: BigInt(streamId.replace(/\D/g, "") || "0"), + amount, + }); + + topUpStreamLocally(streamId, parseFloat(amountStr)); + setModal(null); + toast.success("Stream topped up successfully!", { id: toastId }); + } catch (err) { + toast.error(toSorobanErrorMessage(err), { id: toastId }); + throw err; + } + }; + + const handleCancelConfirm = async (streamId: string) => { + const toastId = toast.loading("Cancelling stream…"); + try { + await sorobanCancel(session, { + streamId: BigInt(streamId.replace(/\D/g, "") || "0"), + }); + + removeStreamLocally(streamId); + setModal(null); + toast.success("Stream cancelled.", { id: toastId }); + } catch (err) { + toast.error(toSorobanErrorMessage(err), { id: toastId }); + throw err; + } + }; + + const handleFormCreateStream = (event: React.FormEvent) => { + event.preventDefault(); const hasRequiredFields = streamForm.recipient.trim() && streamForm.token.trim() && @@ -544,6 +573,8 @@ export function DashboardView({ session, onDisconnect }: DashboardViewProps) { return; } + // Convert StreamFormValues to StreamFormData for handleCreateStream if possible + // or just show alert for now as per upstream mock logic alert( `Stream prepared for ${streamForm.recipient} with ${streamForm.totalAmount} ${streamForm.token}. You can still edit any field before final submission integration.`, ); @@ -553,11 +584,7 @@ export function DashboardView({ session, onDisconnect }: DashboardViewProps) { }); }; - const handleResetStreamForm = () => { - setStreamForm(EMPTY_STREAM_FORM); - setSelectedTemplateId(null); - setStreamFormMessage(null); - }; + // ── Tab content ───────────────────────────────────────────────────────────── const renderContent = () => { if (activeTab === "incoming") { @@ -569,8 +596,36 @@ export function DashboardView({ session, onDisconnect }: DashboardViewProps) { } if (activeTab === "overview") { - if (!stats) { - return
; + if (!snapshot) { + return ( +
+

No stream data yet

+

+ Your account is connected, but there are no active or historical + stream records available yet. +

+
    +
  • Create your first payment stream
  • +
  • Invite a recipient to start receiving funds
  • +
  • Check back once transactions are confirmed
  • +
+
+ +
+
+ ); + } + + return ( +
+ {renderStats(snapshot)} + {renderAnalytics(snapshot)} + {renderStreams(snapshot, (stream: Stream) => setModal({ type: "topup", stream }), (stream: Stream) => setModal({ type: "cancel", stream }))} + {renderRecentActivity(snapshot)} +
+ ); } if (activeTab === "streams") { @@ -673,7 +728,7 @@ export function DashboardView({ session, onDisconnect }: DashboardViewProps) { )}
-
+

Stream Configuration

@@ -728,84 +783,6 @@ export function DashboardView({ session, onDisconnect }: DashboardViewProps) { placeholder="USDC" /> - - -
- -
- - - -
- - - -