Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 114 additions & 1 deletion app/profile/page.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,21 @@
"use client";

import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useWallet } from "@/contexts/WalletContext";
import { useSelectedNetwork } from "@/hooks/useSelectedNetwork";
import { CopyHashButton } from "@/components/ui/CopyHashButton";
import { ProfileSkeleton } from "@/components/ProfileSkeleton";
import { isValidStellarPublicKey } from "@/lib/stellar-address";
import { CashflowChart } from "@/components/profile/CashflowChart";
import {
computeCashflow,
CASHFLOW_WINDOW_DAYS,
type CashflowStream,
} from "@/lib/profile-analytics";
import { streamsBySender, streamsByRecipient } from "@/lib/factory";
import { getStreamAddress, getStreamInfo } from "@/lib/stream";
import { fromStroops } from "@/lib/format";

type ConnectionState =
| { status: "loading" }
Expand All @@ -24,6 +33,108 @@ function useConnectionState(): ConnectionState {
return { status: "disconnected" };
}

/**
* Aggregated incoming vs outgoing streaming volume over the trailing 30 days
* (#690). Resolves the user's stream IDs in both directions, estimates
* per-day volume as rate × overlap, and renders totals + a stacked bar chart.
*/
function CashflowSection({ publicKey }: { publicKey: string }) {
const [streams, setStreams] = useState<CashflowStream[]>([]);
const [loading, setLoading] = useState(true);

useEffect(() => {
let active = true;
setLoading(true);
(async () => {
try {
const [senderIds, recipientIds] = await Promise.all([
streamsBySender(publicKey, publicKey, 0, 50).catch(() => [] as bigint[]),
streamsByRecipient(publicKey, publicKey, 0, 50).catch(() => [] as bigint[]),
]);
const jobs: { id: bigint; direction: "incoming" | "outgoing" }[] = [
...senderIds.map((id) => ({ id, direction: "outgoing" as const })),
...recipientIds.map((id) => ({ id, direction: "incoming" as const })),
];
const settled = await Promise.allSettled(
jobs.map(async (job) => {
const addr = await getStreamAddress(publicKey, job.id);
if (!addr) return null;
const info = await getStreamInfo(publicKey, addr);
return { info, direction: job.direction } as CashflowStream;
}),
);
if (!active) return;
setStreams(
settled.flatMap((r) =>
r.status === "fulfilled" && r.value ? [r.value] : [],
),
);
} catch {
if (active) setStreams([]);
} finally {
if (active) setLoading(false);
}
})();
return () => {
active = false;
};
}, [publicKey]);

const summary = useMemo(
() =>
computeCashflow(
streams,
Math.floor(Date.now() / 1000),
CASHFLOW_WINDOW_DAYS,
),
[streams],
);

return (
<div className="card mb-6">
<h2 className="text-sm font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-1">
Cash Flow — past {CASHFLOW_WINDOW_DAYS} days
</h2>
<p className="text-xs text-gray-400 dark:text-gray-500 mb-4">
Estimated streaming income vs expenditure from active stream rates.
</p>
{loading ? (
<div className="py-10 text-center text-sm text-gray-400" aria-busy="true">
Loading cash flow…
</div>
) : (
<>
<div className="grid grid-cols-3 gap-3 mb-5">
<div>
<p className="text-[11px] text-gray-400 dark:text-gray-500">Income</p>
<p className="text-lg font-black font-mono" data-testid="cashflow-incoming">
{fromStroops(summary.totalIncoming)}
</p>
</div>
<div>
<p className="text-[11px] text-gray-400 dark:text-gray-500">Spent</p>
<p className="text-lg font-black font-mono" data-testid="cashflow-outgoing">
{fromStroops(summary.totalOutgoing)}
</p>
</div>
<div>
<p className="text-[11px] text-gray-400 dark:text-gray-500">Net</p>
<p
className={`text-lg font-black font-mono ${summary.net >= 0n ? "text-green-600 dark:text-green-400" : "text-red-600 dark:text-red-400"}`}
data-testid="cashflow-net"
>
{summary.net >= 0n ? "+" : "−"}
{fromStroops(summary.net >= 0n ? summary.net : -summary.net)}
</p>
</div>
</div>
<CashflowChart daily={summary.daily} />
</>
)}
</div>
);
}

export default function ProfilePage() {
const connection = useConnectionState();
const { disconnect } = useWallet();
Expand Down Expand Up @@ -125,6 +236,8 @@ export default function ProfilePage() {
</div>
</div>

<CashflowSection publicKey={publicKey} />

<div className="card">
<h2 className="text-sm font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-4">
Quick Links
Expand Down
146 changes: 146 additions & 0 deletions app/settings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,15 @@ import { useTheme } from "next-themes";
import { NetworkName, NETWORKS } from "@/lib/network-config";
import { saveSelectedNetwork } from "@/lib/network-storage";
import { useOnboarding } from "@/hooks/useOnboarding";
import {
clearCustomRpcUrl,
getCustomRpcUrl,
isValidRpcUrl,
pingRpcEndpoint,
saveCustomRpcUrl,
type RpcPingResult,
} from "@/lib/custom-rpc";
import { resetServer } from "@/lib/soroban";
import {
type TimeFormat,
type RefreshIntervalSeconds,
Expand Down Expand Up @@ -73,6 +82,18 @@ export default function SettingsPage() {
const [saved, setSaved] = useState(false);
const didMount = useRef(false);

// #691 — custom RPC endpoint override (local sandbox / private node).
const [customRpc, setCustomRpc] = useState("");
const [savedCustomRpc, setSavedCustomRpc] = useState<string | null>(null);
const [ping, setPing] = useState<RpcPingResult | null>(null);
const [pinging, setPinging] = useState(false);
const [rpcMessage, setRpcMessage] = useState<string | null>(null);

useEffect(() => {
setSavedCustomRpc(getCustomRpcUrl());
setCustomRpc(getCustomRpcUrl() ?? "");
}, []);

useEffect(() => {
if (!didMount.current) {
didMount.current = true;
Expand Down Expand Up @@ -113,6 +134,59 @@ export default function SettingsPage() {
setTimeout(() => setSaved(false), 2000);
}, [setTheme]);

const handleTestPing = useCallback(async () => {
setRpcMessage(null);
setPing(null);
if (!isValidRpcUrl(customRpc)) {
setPing({ ok: false, latencyMs: null, error: "Enter a valid http(s) RPC URL." });
return;
}
setPinging(true);
try {
const result = await pingRpcEndpoint(customRpc.trim());
setPing(result);
} finally {
setPinging(false);
}
}, [customRpc]);

const handleSaveCustomRpc = useCallback(async () => {
setRpcMessage(null);
const url = customRpc.trim();
if (!isValidRpcUrl(url)) {
setPing({ ok: false, latencyMs: null, error: "Enter a valid http(s) RPC URL." });
return;
}
// Validate latency before saving — ping first, save only on success.
setPinging(true);
try {
const result = await pingRpcEndpoint(url);
setPing(result);
if (!result.ok) return;
saveCustomRpcUrl(url);
resetServer();
setSavedCustomRpc(url);
setRpcMessage(`Custom RPC saved (${result.latencyMs} ms).`);
} catch (e) {
setPing({
ok: false,
latencyMs: null,
error: e instanceof Error ? e.message : "Could not save the RPC endpoint.",
});
} finally {
setPinging(false);
}
}, [customRpc]);

const handleClearCustomRpc = useCallback(() => {
clearCustomRpcUrl();
resetServer();
setSavedCustomRpc(null);
setCustomRpc("");
setPing(null);
setRpcMessage("Custom RPC cleared — using the preset network endpoint.");
}, []);

return (
<div className="max-w-2xl mx-auto px-4 py-10 space-y-8">
<div className="flex items-center justify-between">
Expand Down Expand Up @@ -173,6 +247,78 @@ export default function SettingsPage() {
))}
</select>
</div>

{/* Custom RPC endpoint override (#691) */}
<div className="mt-5 border-t border-gray-100 dark:border-gray-800 pt-4">
<span className="text-sm font-medium">Custom RPC endpoint</span>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-0.5 mb-3">
Override the preset endpoint with a local sandbox or private node
(e.g. QuickNode, Triton). Ping it first — saving requires a
successful response.
</p>
<div className="flex flex-col gap-2">
<input
type="url"
value={customRpc}
onChange={(e) => {
setCustomRpc(e.target.value);
setPing(null);
setRpcMessage(null);
}}
placeholder="https://my-private-rpc.example.com"
aria-label="Custom RPC endpoint"
className="w-full bg-gray-100 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded px-3 py-1.5 text-sm font-mono"
/>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={handleTestPing}
disabled={pinging || !customRpc.trim()}
className="px-4 py-1.5 rounded text-sm font-medium border border-gray-300 dark:border-gray-700 text-black dark:text-white hover:bg-gray-50 dark:hover:bg-gray-900 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{pinging ? "Pinging…" : "Test Ping"}
</button>
<button
type="button"
onClick={handleSaveCustomRpc}
disabled={pinging || !isValidRpcUrl(customRpc)}
className="px-4 py-1.5 rounded text-sm font-medium bg-black text-white dark:bg-white dark:text-black disabled:opacity-50 disabled:cursor-not-allowed"
>
Save endpoint
</button>
{savedCustomRpc && (
<button
type="button"
onClick={handleClearCustomRpc}
className="px-4 py-1.5 rounded text-sm font-medium text-gray-500 hover:text-black dark:hover:text-white underline"
>
Clear override
</button>
)}
</div>
{ping && (
<p
role="status"
data-testid="rpc-ping-result"
className={`text-xs font-mono ${ping.ok ? "text-green-600 dark:text-green-400" : "text-red-600 dark:text-red-400"}`}
>
{ping.ok
? `Reachable — ${ping.latencyMs} ms.`
: `Unreachable${ping.latencyMs !== null ? ` (${ping.latencyMs} ms)` : ""}: ${ping.error ?? "no response"}`}
</p>
)}
{rpcMessage && (
<p role="status" className="text-xs text-gray-500 dark:text-gray-400">
{rpcMessage}
</p>
)}
{savedCustomRpc && (
<p className="text-xs text-gray-400 dark:text-gray-500 break-all">
Active override: <span className="font-mono">{savedCustomRpc}</span>
</p>
)}
</div>
</div>
</section>

{/* Currency & Slippage */}
Expand Down
32 changes: 31 additions & 1 deletion app/stream/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useParams } from "next/navigation";
import Link from "next/link";
import { ArrowLeft, FileText } from "lucide-react";
import { ArrowLeft, FileText, Share2 } from "lucide-react";

import { Badge } from "@/components/ui/Badge";
import { Card } from "@/components/ui/Card";
import { CopyAddress } from "@/components/ui/CopyAddress";
import { QrCodeModal } from "@/components/ui/QrCodeModal";
import { RateTicker } from "@/components/stream/RateTicker";
import { StreamTimeline } from "@/components/stream/StreamTimeline";
import { StreamFlowChart } from "@/components/stream/StreamFlowChart";
Expand Down Expand Up @@ -68,6 +69,8 @@ export default function StreamPage() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [status, setStatus] = useState<StreamStatus>("active");
// #688 — shareable claim-link QR modal state.
const [shareOpen, setShareOpen] = useState(false);

// Use stream note hook — only available after streamAddress is resolved
const noteHook = useStreamNote(streamAddress || "");
Expand Down Expand Up @@ -255,6 +258,14 @@ export default function StreamPage() {
tokenByAddress(info.token, "testnet")?.symbol ??
truncateAddress(info.token);

// #688 — direct stream claim URL for QR sharing. Built from the current
// origin on the client so a scanned code opens this exact stream page
// (claim/monitor) on the recipient's device.
const claimUrl =
typeof window !== "undefined"
? `${window.location.origin}/stream/${id}`
: `/stream/${id}`;

return (
<div
className="max-w-2xl mx-auto px-4 py-10 print-receipt"
Expand Down Expand Up @@ -284,6 +295,16 @@ export default function StreamPage() {
</div>
<div className="flex items-center gap-2">
<Badge status={status} />
{/* Share stream claim link via QR code (#688) */}
<button
type="button"
onClick={() => setShareOpen(true)}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded text-xs font-medium border border-gray-300 dark:border-gray-700 text-black dark:text-white hover:bg-gray-50 dark:hover:bg-gray-900 transition-colors print:hidden"
aria-label="Share stream"
>
<Share2 className="w-3.5 h-3.5" aria-hidden="true" />
Share
</button>
{/* Add stream end date to an external calendar (#566) */}
{info.endTime > 0 && (
<AddToCalendarButton
Expand Down Expand Up @@ -524,6 +545,15 @@ export default function StreamPage() {
tokens at any time.
</p>
)}

{/* Share stream claim-link QR modal (#688) */}
{shareOpen && (
<QrCodeModal
value={claimUrl}
title={`Share Stream #${id}`}
onClose={() => setShareOpen(false)}
/>
)}
</div>
);
}
Loading