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) => (
-
- {stream.date}
-
- {stream.recipient}
-
-
- {stream.deposited} {stream.token}
-
-
- {stream.withdrawn} {stream.token}
-
-
- onTopUp(stream.id)}
- >
- Add Funds
-
-
-
- ))}
+ {snapshot.outgoingStreams
+ .filter((s) => s.status === "Active")
+ .map((stream) => (
+
+ {stream.date}
+
+ {stream.recipient}
+
+
+ {stream.deposited} {stream.token}
+
+
+ {stream.withdrawn} {stream.token}
+
+
+
+ onTopUp(stream)}
+ >
+ Add Funds
+
+ onCancel(stream)}
+ >
+ Cancel
+
+
+
+
+ ))}
@@ -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
+
+
+ setShowWizard(true)} glow>
+ Create Your First Stream
+
+
+
+ );
+ }
+
+ 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) {
)}
-
- {showWizard && (
- setShowWizard(false)}
- onSubmit={handleCreateStream}
- />
- )}
+ {/* Create Stream Wizard */}
+ {
+ showWizard && (
+ setShowWizard(false)}
+ onSubmit={handleCreateStream}
+ />
+ )
+ }
+
+ {/* Top Up Modal */}
+ {
+ modal?.type === "topup" && (
+ setModal(null)}
+ />
+ )
+ }
+
+ {/* Cancel Confirmation Modal */}
+ {
+ modal?.type === "cancel" && (
+ setModal(null)}
+ />
+ )
+ }
);
}
diff --git a/frontend/components/stream-creation/CancelConfirmModal.tsx b/frontend/components/stream-creation/CancelConfirmModal.tsx
new file mode 100644
index 0000000..7b53cf3
--- /dev/null
+++ b/frontend/components/stream-creation/CancelConfirmModal.tsx
@@ -0,0 +1,147 @@
+"use client";
+
+/**
+ * CancelConfirmModal.tsx
+ *
+ * Confirmation dialog before cancelling an active stream.
+ * Clearly communicates the consequences (stream stops, no refund of
+ * already-withdrawn funds) before the user commits.
+ */
+
+import React, { useEffect, useState } from "react";
+import { Button } from "@/components/ui/Button";
+
+interface CancelConfirmModalProps {
+ streamId: string;
+ recipient: string;
+ token: string;
+ deposited: number;
+ withdrawn: number;
+ onConfirm: (streamId: string) => Promise;
+ onClose: () => void;
+}
+
+export const CancelConfirmModal: React.FC = ({
+ streamId,
+ recipient,
+ token,
+ deposited,
+ withdrawn,
+ onConfirm,
+ onClose,
+}) => {
+ const [isSubmitting, setIsSubmitting] = useState(false);
+
+ // Escape key support
+ useEffect(() => {
+ const handleEscape = (e: KeyboardEvent) => {
+ if (e.key === "Escape" && !isSubmitting) onClose();
+ };
+ window.addEventListener("keydown", handleEscape);
+ return () => window.removeEventListener("keydown", handleEscape);
+ }, [onClose, isSubmitting]);
+
+ const remaining = deposited - withdrawn;
+
+ const handleConfirm = async () => {
+ setIsSubmitting(true);
+ try {
+ await onConfirm(streamId);
+ } catch {
+ // Errors are handled upstream (toast in dashboard-view)
+ setIsSubmitting(false);
+ }
+ };
+
+ return (
+ {
+ if (e.target === e.currentTarget && !isSubmitting) onClose();
+ }}
+ >
+
+ {/* Header */}
+
+ {/* Warning icon */}
+
+
+
Cancel Stream?
+
+ This action is permanent and cannot be undone.
+
+
+
+
+
+
+
+
+
+ {/* Stream summary */}
+
+
+ Stream
+ {streamId}
+
+
+ Recipient
+ {recipient}
+
+
+ Already withdrawn
+ {withdrawn} {token}
+
+
+ Remaining in stream
+ {remaining} {token}
+
+
+
+ {/* Consequence note */}
+
+ What happens: The stream stops immediately.
+ The recipient keeps any already-withdrawn funds. Remaining funds
+ ({remaining} {token}) stay locked in the contract until the recipient withdraws
+ or the admin resolves them.
+
+
+ {/* Actions */}
+
+
+ Keep Stream
+
+
void handleConfirm()}
+ disabled={isSubmitting}
+ className="inline-flex items-center justify-center rounded-full px-6 py-2.5 text-sm font-semibold bg-red-600 hover:bg-red-500 text-white transition-all active:scale-95 disabled:opacity-50 disabled:pointer-events-none"
+ >
+ {isSubmitting ? (
+ <>
+
+
+
+
+ Cancelling…
+ >
+ ) : (
+ "Yes, Cancel Stream"
+ )}
+
+
+
+
+ );
+};
diff --git a/frontend/components/stream-creation/TopUpModal.tsx b/frontend/components/stream-creation/TopUpModal.tsx
new file mode 100644
index 0000000..3065cd5
--- /dev/null
+++ b/frontend/components/stream-creation/TopUpModal.tsx
@@ -0,0 +1,184 @@
+"use client";
+
+/**
+ * TopUpModal.tsx
+ *
+ * Replaces the prompt() / alert() in dashboard-view.tsx handleTopUp.
+ * Collects an amount, shows a confirmation summary, and calls onConfirm.
+ */
+
+import React, { useRef, useEffect, useState } from "react";
+import { Button } from "@/components/ui/Button";
+
+interface TopUpModalProps {
+ streamId: string;
+ token: string;
+ currentDeposited: number;
+ onConfirm: (streamId: string, amount: string) => Promise;
+ onClose: () => void;
+}
+
+export const TopUpModal: React.FC = ({
+ streamId,
+ token,
+ currentDeposited,
+ onConfirm,
+ onClose,
+}) => {
+ const [amount, setAmount] = useState("");
+ const [error, setError] = useState(null);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const inputRef = useRef(null);
+
+ // Auto-focus and Escape key support
+ useEffect(() => {
+ inputRef.current?.focus();
+
+ const handleEscape = (e: KeyboardEvent) => {
+ if (e.key === "Escape" && !isSubmitting) onClose();
+ };
+ window.addEventListener("keydown", handleEscape);
+ return () => window.removeEventListener("keydown", handleEscape);
+ }, [onClose, isSubmitting]);
+
+ const validate = (): boolean => {
+ const parsed = parseFloat(amount);
+ if (!amount.trim() || isNaN(parsed) || parsed <= 0) {
+ setError("Please enter a valid positive amount.");
+ return false;
+ }
+ setError(null);
+ return true;
+ };
+
+ const handleConfirm = async () => {
+ if (!validate()) return;
+ setIsSubmitting(true);
+ try {
+ await onConfirm(streamId, amount);
+ // onConfirm is responsible for closing + toasting on success
+ } catch {
+ // Errors are handled upstream (toast in dashboard-view)
+ setIsSubmitting(false);
+ }
+ };
+
+ const parsedAmount = parseFloat(amount);
+ const newTotal = !isNaN(parsedAmount) && parsedAmount > 0
+ ? currentDeposited + parsedAmount
+ : null;
+
+ return (
+ {
+ if (e.target === e.currentTarget && !isSubmitting) onClose();
+ }}
+ >
+
+ {/* Header */}
+
+
Top Up Stream
+
+
+
+
+
+
+
+ {/* Stream info */}
+
+
+ Stream {streamId}
+
+
+ Current balance: {currentDeposited} {token}
+
+
+
+ {/* Amount input */}
+
+
+ Amount to add ({token})
+
+
+ {
+ setAmount(e.target.value);
+ if (error) setError(null);
+ }}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") void handleConfirm();
+ }}
+ placeholder="0.00"
+ disabled={isSubmitting}
+ className={`w-full px-4 py-3 rounded-lg bg-glass border ${
+ error
+ ? "border-red-500 focus:border-red-500"
+ : "border-glass-border focus:border-accent"
+ } focus:outline-none focus:ring-2 focus:ring-accent/50 transition-colors text-foreground placeholder-slate-500 disabled:opacity-50`}
+ aria-invalid={!!error}
+ aria-describedby={error ? "topup-error" : undefined}
+ />
+
+ {token}
+
+
+
+ {error && (
+
+
+
+
+ {error}
+
+ )}
+
+
+ {/* Preview */}
+ {newTotal !== null && (
+
+
+ New total:{" "}
+
+ {newTotal.toFixed(2)} {token}
+
+
+
+ )}
+
+ {/* Actions */}
+
+
+ Cancel
+
+
void handleConfirm()} disabled={isSubmitting} glow>
+ {isSubmitting ? (
+ <>
+
+
+
+
+ Topping up…
+ >
+ ) : (
+ "Confirm Top Up"
+ )}
+
+
+
+
+ );
+};
diff --git a/frontend/lib/soroban.ts b/frontend/lib/soroban.ts
new file mode 100644
index 0000000..4deb323
--- /dev/null
+++ b/frontend/lib/soroban.ts
@@ -0,0 +1,260 @@
+import type { WalletSession } from "@/lib/wallet";
+
+const CONTRACT_ID =
+ process.env.NEXT_PUBLIC_STREAM_CONTRACT_ID ?? "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4";
+
+const SOROBAN_RPC_URL =
+ process.env.NEXT_PUBLIC_SOROBAN_RPC_URL ?? "https://soroban-testnet.stellar.org";
+
+const NETWORK_PASSPHRASE =
+ process.env.NEXT_PUBLIC_NETWORK_PASSPHRASE ?? "Test SDF Network ; September 2015";
+
+const MOCK_DELAY_MS = 1400;
+
+export interface CreateStreamParams {
+ recipient: string;
+ tokenAddress: string;
+ amount: bigint;
+ durationSeconds: bigint;
+}
+
+export interface TopUpParams {
+ streamId: bigint;
+ amount: bigint;
+}
+
+export interface CancelParams {
+ streamId: bigint;
+}
+
+export interface WithdrawParams {
+ streamId: bigint;
+}
+
+export interface SorobanResult {
+ success: true;
+ txHash: string;
+}
+
+export class SorobanCallError extends Error {
+ constructor(
+ message: string,
+ public readonly code?:
+ | "InvalidAmount"
+ | "StreamNotFound"
+ | "Unauthorized"
+ | "StreamInactive"
+ | "AlreadyInitialized"
+ | "NotAdmin"
+ | "InvalidFeeRate"
+ | "NotInitialized"
+ | "WalletRejected"
+ | "NetworkError"
+ | "Unknown",
+ ) {
+ super(message);
+ this.name = "SorobanCallError";
+ }
+}
+
+type DurationUnit = "seconds" | "minutes" | "hours" | "days" | "weeks" | "months";
+
+const SECONDS_PER_UNIT: Record = {
+ seconds: BigInt(1),
+ minutes: BigInt(60),
+ hours: BigInt(3600),
+ days: BigInt(86400),
+ weeks: BigInt(604800),
+ months: BigInt(2592000),
+};
+
+export function toDurationSeconds(value: string, unit: DurationUnit): bigint {
+ const parsed = parseFloat(value);
+ if (isNaN(parsed) || parsed <= 0) {
+ throw new SorobanCallError("Duration must be a positive number.", "InvalidAmount");
+ }
+ return BigInt(Math.round(parsed)) * SECONDS_PER_UNIT[unit];
+}
+
+export function toBaseUnits(value: string, decimals = 7): bigint {
+ const parsed = parseFloat(value);
+ if (isNaN(parsed) || parsed <= 0) {
+ throw new SorobanCallError("Amount must be a positive number.", "InvalidAmount");
+ }
+ return BigInt(Math.round(parsed * 10 ** decimals));
+}
+
+export const TOKEN_ADDRESSES: Record = {
+ USDC: process.env.NEXT_PUBLIC_USDC_ADDRESS ?? "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA",
+ XLM: process.env.NEXT_PUBLIC_XLM_ADDRESS ?? "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCN",
+ EURC: process.env.NEXT_PUBLIC_EURC_ADDRESS ?? "CCWAMYJME4YOIUNAKVYEBYOG5I65QMKEX2NMN4OJAPXRPIF24ONPSHY",
+};
+
+export function getTokenAddress(symbol: string): string {
+ const address = TOKEN_ADDRESSES[symbol.toUpperCase()];
+ if (!address) {
+ throw new SorobanCallError(`Unsupported token: ${symbol}`, "Unknown");
+ }
+ return address;
+}
+
+function wait(ms: number): Promise {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+function mockTxHash(): string {
+ return Array.from({ length: 64 }, () =>
+ Math.floor(Math.random() * 16).toString(16),
+ ).join("");
+}
+
+async function mockCall(label: string): Promise {
+ console.info(`[soroban:mock] ${label}`);
+ await wait(MOCK_DELAY_MS);
+ return { success: true, txHash: mockTxHash() };
+}
+
+async function freighterCall(
+ publicKey: string,
+ method: string,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ args: any[],
+): Promise {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const sdk: any = await import("@stellar/stellar-sdk");
+ const { Contract, TransactionBuilder, BASE_FEE } = sdk;
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const rpc: any = sdk.rpc ?? sdk.SorobanRpc;
+
+ const { signTransaction } = await import("@stellar/freighter-api");
+
+ const server = new rpc.Server(SOROBAN_RPC_URL, { allowHttp: false });
+ const account = await server.getAccount(publicKey);
+ const contract = new Contract(CONTRACT_ID);
+
+ const tx = new TransactionBuilder(account, {
+ fee: BASE_FEE,
+ networkPassphrase: NETWORK_PASSPHRASE,
+ })
+ .addOperation(contract.call(method, ...args))
+ .setTimeout(30)
+ .build();
+
+ const simResult = await server.simulateTransaction(tx);
+ if (rpc.Api?.isSimulationError?.(simResult) ?? simResult?.error) {
+ throw new SorobanCallError(`Simulation failed: ${simResult.error}`, "NetworkError");
+ }
+
+ const preparedTx = (rpc.assembleTransaction ?? sdk.assembleTransaction)(tx, simResult).build();
+
+ const { signedTxXdr, error: signError } = await signTransaction(
+ preparedTx.toXDR(),
+ { networkPassphrase: NETWORK_PASSPHRASE },
+ );
+
+ if (signError) {
+ const msg = typeof signError === "string" ? signError : (signError as Error).message;
+ if (/reject|cancel|denied/i.test(msg)) {
+ throw new SorobanCallError("Transaction was rejected in wallet.", "WalletRejected");
+ }
+ throw new SorobanCallError(msg, "Unknown");
+ }
+
+ const signedTx = TransactionBuilder.fromXDR(signedTxXdr, NETWORK_PASSPHRASE);
+ const sendResult = await server.sendTransaction(signedTx);
+
+ if (sendResult.status === "ERROR") {
+ throw new SorobanCallError(
+ `Transaction failed: ${sendResult.errorResult?.toXDR?.("base64") ?? "unknown error"}`,
+ "NetworkError",
+ );
+ }
+
+ const txHash = sendResult.hash;
+ const SUCCESS = rpc.Api?.GetTransactionStatus?.SUCCESS ?? "SUCCESS";
+ const FAILED = rpc.Api?.GetTransactionStatus?.FAILED ?? "FAILED";
+
+ for (let i = 0; i < 20; i++) {
+ await wait(1000);
+ const status = await server.getTransaction(txHash);
+ if (status.status === SUCCESS) return { success: true, txHash };
+ if (status.status === FAILED) {
+ throw new SorobanCallError("Transaction failed on-chain.", "NetworkError");
+ }
+ }
+
+ throw new SorobanCallError("Transaction confirmation timed out.", "NetworkError");
+}
+
+export function toSorobanErrorMessage(error: unknown): string {
+ if (error instanceof SorobanCallError) return error.message;
+ if (error instanceof Error) {
+ const msg = error.message;
+ if (/reject|cancel|denied/i.test(msg)) return "Transaction was rejected in your wallet.";
+ if (/timeout/i.test(msg)) return "Transaction timed out. The network may be congested — please try again.";
+ if (/insufficient/i.test(msg)) return "Insufficient balance to complete this transaction.";
+ if (/simulation/i.test(msg)) return "Contract simulation failed. Check your inputs and try again.";
+ return msg;
+ }
+ return "An unexpected error occurred. Please try again.";
+}
+
+export async function createStream(
+ session: WalletSession,
+ params: CreateStreamParams,
+): Promise {
+ if (session.mocked) {
+ return mockCall(`create_stream recipient=${params.recipient} amount=${params.amount} duration=${params.durationSeconds}s`);
+ }
+ const { Address, nativeToScVal } = await import("@stellar/stellar-sdk");
+ return freighterCall(session.publicKey, "create_stream", [
+ new Address(session.publicKey).toScVal(),
+ new Address(params.recipient).toScVal(),
+ new Address(params.tokenAddress).toScVal(),
+ nativeToScVal(params.amount, { type: "i128" }),
+ nativeToScVal(params.durationSeconds, { type: "u64" }),
+ ]);
+}
+
+export async function topUpStream(
+ session: WalletSession,
+ params: TopUpParams,
+): Promise {
+ if (session.mocked) {
+ return mockCall(`top_up_stream stream_id=${params.streamId} amount=${params.amount}`);
+ }
+ const { Address, nativeToScVal } = await import("@stellar/stellar-sdk");
+ return freighterCall(session.publicKey, "top_up_stream", [
+ new Address(session.publicKey).toScVal(),
+ nativeToScVal(params.streamId, { type: "u64" }),
+ nativeToScVal(params.amount, { type: "i128" }),
+ ]);
+}
+
+export async function cancelStream(
+ session: WalletSession,
+ params: CancelParams,
+): Promise {
+ if (session.mocked) {
+ return mockCall(`cancel_stream stream_id=${params.streamId}`);
+ }
+ const { Address, nativeToScVal } = await import("@stellar/stellar-sdk");
+ return freighterCall(session.publicKey, "cancel_stream", [
+ new Address(session.publicKey).toScVal(),
+ nativeToScVal(params.streamId, { type: "u64" }),
+ ]);
+}
+
+export async function withdrawFromStream(
+ session: WalletSession,
+ params: WithdrawParams,
+): Promise {
+ if (session.mocked) {
+ return mockCall(`withdraw stream_id=${params.streamId}`);
+ }
+ const { Address, nativeToScVal } = await import("@stellar/stellar-sdk");
+ return freighterCall(session.publicKey, "withdraw", [
+ new Address(session.publicKey).toScVal(),
+ nativeToScVal(params.streamId, { type: "u64" }),
+ ]);
+}
diff --git a/frontend/package.json b/frontend/package.json
index 54459b0..0f498a0 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -9,13 +9,13 @@
"lint": "eslint"
},
"dependencies": {
- "@stellar/freighter-api": "^6.0.0",
+ "@stellar/freighter-api": "^6.0.1",
+ "@stellar/stellar-sdk": "^14.5.0",
"lucide-react": "^0.575.0",
"next": "16.1.6",
"next-themes": "^0.4.6",
"react": "19.2.4",
- "react-dom": "19.2.4",
- "lucide-react": "^0.575.0"
+ "react-dom": "19.2.4"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json
index 3a13f90..15c7b97 100644
--- a/frontend/tsconfig.json
+++ b/frontend/tsconfig.json
@@ -1,6 +1,6 @@
{
"compilerOptions": {
- "target": "ES2017",
+ "target": "ES2020",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,