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
29 changes: 29 additions & 0 deletions frontend/src/__tests__/setup.ts
Original file line number Diff line number Diff line change
@@ -1 +1,30 @@
import '@testing-library/jest-dom';
import { vi } from 'vitest';

const localStorageMock = (() => {
let store: Record<string, string> = {};
return {
getItem: vi.fn((key: string) => store[key] || null),
setItem: vi.fn((key: string, value: string) => {
store[key] = value.toString();
}),
clear: vi.fn(() => {
store = {};
}),
removeItem: vi.fn((key: string) => {
delete store[key];
}),
key: vi.fn((index: number) => Object.keys(store)[index] || null),
length: 0,
};
})();

vi.stubGlobal('localStorage', localStorageMock);
if (typeof window !== 'undefined') {
Object.defineProperty(window, 'localStorage', {
value: localStorageMock,
configurable: true,
writable: true,
});
}

259 changes: 0 additions & 259 deletions frontend/src/app/streams/create/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,263 +8,4 @@ export const metadata: Metadata = {

export default function CreateStreamPage() {
return <CreateStreamContent />;
import React, { useState } from "react";
import {
createStream,
toBaseUnits,
toDurationSeconds,
getTokenAddress,
toSorobanErrorMessage,
TOKEN_ADDRESSES
} from "@/lib/soroban";
import { hasValidPrecision, validateAmountInput } from "@/utils/amount";
import { isValidStellarPublicKey } from "@/lib/stellar";
import { toast } from "react-hot-toast";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { ArrowLeft } from "lucide-react";
import { useWallet } from "@/context/wallet-context";

const TOKEN_DECIMALS = 7;

export default function CreateStreamPage() {
const { status, session } = useWallet();
const router = useRouter();
const [nowTimestamp] = useState(() => Date.now());
const [loading, setLoading] = useState(false);
const [txState, setTxState] = useState<"idle" | "signing" | "submitted" | "confirming">("idle");
const [formData, setFormData] = useState({
recipient: "",
token: "XLM",
amount: "",
duration: "30", // days
});

const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (status !== "connected" || !session) {
toast.error("Please connect your wallet first.");
return;
}

// Validate recipient
if (!formData.recipient.trim()) {
toast.error("Recipient address is required");
return;
}
if (!isValidStellarPublicKey(formData.recipient)) {
toast.error("Invalid Stellar public key format");
return;
}

// Validate amount
const validationError = validateAmountInput(formData.amount, TOKEN_DECIMALS);
if (validationError) {
toast.error(validationError);
return;
}

// Validate duration
const durationNum = parseFloat(formData.duration);
if (isNaN(durationNum) || durationNum <= 0) {
toast.error("Duration must be a positive number");
return;
}

setLoading(true);
setTxState("signing");

try {
const amountBigInt = toBaseUnits(formData.amount);
const durationBigInt = toDurationSeconds(formData.duration, "days");
const tokenAddress = getTokenAddress(formData.token);

const result = await createStream(session, {
recipient: formData.recipient,
tokenAddress,
amount: amountBigInt,
durationSeconds: durationBigInt,
});

if (result.success) {
setTxState("confirming");
toast.success("Stream created successfully!");
// Small delay to allow indexer to catch up
setTimeout(() => {
router.push("/dashboard");
}, 2000);
}
} catch (error) {
console.error("Stream creation failed:", error);
toast.error(toSorobanErrorMessage(error));
} finally {
setLoading(false);
setTxState("idle");
}
};

const getButtonText = () => {
if (!loading) return "Start Streaming";
switch (txState) {
case "signing": return "Confirm in Wallet...";
case "submitted": return "Submitting to Network...";
case "confirming": return "Finalizing Stream...";
default: return "Processing...";
}
};

// Inline validation feedback for the amount field. validateAmountInput
// returns an error message when invalid and null when valid. Only show it
// once the user has typed something — the empty case is handled on submit.
const amountError = formData.amount
? validateAmountInput(formData.amount, TOKEN_DECIMALS)
: null;

const recipientError = formData.recipient
? (!isValidStellarPublicKey(formData.recipient) ? "Invalid Stellar public key format" : null)
: null;

const durationError = formData.duration
? (isNaN(Number(formData.duration)) || Number(formData.duration) <= 0
? "Duration must be a positive number"
: null)
: null;

return (
<div className="container mx-auto max-w-2xl px-4 py-12">
<Link
href="/dashboard"
className="mb-8 inline-flex items-center text-sm font-medium text-slate-400 hover:text-white transition-colors"
>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Dashboard
</Link>

<div className="glass-card rounded-3xl border-slate-800 p-8">
<h1 className="mb-2 text-3xl font-bold">Create New Stream</h1>
<p className="mb-8 text-slate-400">
Set up a real-time payment stream to any Stellar address.
</p>

<form onSubmit={handleSubmit} className="space-y-6">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-300">
Recipient Address
</label>
<input
type="text"
placeholder="G..."
className={`w-full rounded-xl border ${
recipientError ? "border-red-500 focus:border-red-500" : "border-slate-800 focus:border-accent"
} bg-slate-900/50 p-4 outline-none transition-colors`}
value={formData.recipient}
onChange={(e) => setFormData({ ...formData, recipient: e.target.value })}
required
/>
{recipientError && (
<p className="text-xs text-red-400 mt-1">{recipientError}</p>
)}
</div>

<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-300">
Token
</label>
<select
className="w-full rounded-xl border border-slate-800 bg-slate-900/50 p-4 outline-none focus:border-accent transition-colors appearance-none"
value={formData.token}
onChange={(e) => setFormData({ ...formData, token: e.target.value })}
>
{Object.keys(TOKEN_ADDRESSES).map((symbol) => (
<option key={symbol} value={symbol}>
{symbol}
</option>
))}
</select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-300">
Total Amount
</label>
<input
type="text"
inputMode="decimal"
placeholder="0.00"
className={`w-full rounded-xl border ${
amountError ? "border-red-500 focus:border-red-500" : "border-slate-800 focus:border-accent"
} bg-slate-900/50 p-4 outline-none transition-colors`}
value={formData.amount}
onChange={(e) => {
const newValue = e.target.value;
// Only allow valid number characters and check precision
if (newValue === '' || /^\d*\.?\d*$/.test(newValue)) {
if (hasValidPrecision(newValue, TOKEN_DECIMALS)) {
setFormData({ ...formData, amount: newValue });
}
}
}}
required
/>
{amountError && (
<p className="text-xs text-red-400 mt-1">{amountError}</p>
)}
</div>
</div>

<div className="space-y-2">
<label className="text-sm font-medium text-slate-300">
Duration (Days)
</label>
<input
type="number"
placeholder="30"
className={`w-full rounded-xl border ${
durationError ? "border-red-500 focus:border-red-500" : "border-slate-800 focus:border-accent"
} bg-slate-900/50 p-4 outline-none transition-colors`}
value={formData.duration}
onChange={(e) => setFormData({ ...formData, duration: e.target.value })}
required
/>
{durationError && (
<p className="text-xs text-red-400 mt-1">{durationError}</p>
)}
</div>

<div className="rounded-2xl bg-accent/5 p-6 space-y-4">
<div className="flex justify-between items-center text-sm">
<span className="text-slate-400">Streaming Rate</span>
<span className="font-mono font-medium text-accent">
{formData.amount && formData.duration && Number(formData.duration) > 0
? (Number(formData.amount) / (Number(formData.duration) * 86400)).toFixed(8)
: "0.00000000"} {formData.token}/sec
</span>
</div>
<div className="flex justify-between items-center text-sm">
<span className="text-slate-400">Estimated End Date</span>
<span className="font-medium">
{formData.duration && Number(formData.duration) > 0
? new Date(nowTimestamp + Number(formData.duration) * 86400000).toLocaleDateString()
: "—"}
</span>
</div>
</div>

<button
type="submit"
disabled={loading || status !== "connected" || !!amountError || !!recipientError || !!durationError}
className="w-full rounded-xl bg-accent py-4 text-lg font-bold text-background transition-all hover:opacity-90 disabled:opacity-50 active:scale-[0.98]"
>
{getButtonText()}
</button>

{status !== "connected" && (
<p className="text-center text-sm text-red-400">
Please connect your wallet to create a stream.
</p>
)}
</form>
</div>
</div>
);
}
22 changes: 11 additions & 11 deletions frontend/src/components/dashboard/StreamDetailsModal.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
"use client";

import React, { useEffect } from "react";
import React from "react";
import { Button } from "@/components/ui/Button";
import { useModalDialog } from "@/hooks/useModalDialog";
import type { Stream } from "@/lib/dashboard";

interface StreamDetailsModalProps {
Expand All @@ -17,30 +18,29 @@ export const StreamDetailsModal: React.FC<StreamDetailsModalProps> = ({
onCancelClick,
onTopUpClick,
}) => {
// Escape key support
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", handleEscape);
return () => window.removeEventListener("keydown", handleEscape);
}, [onClose]);
const dialogRef = useModalDialog({ onClose });

const progress = stream.deposited > 0 ? Math.min(100, Math.max(0, (stream.withdrawn / stream.deposited) * 100)) : 0;
const remaining = stream.deposited - stream.withdrawn;

return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md"
role="dialog"
aria-modal="true"
aria-labelledby="stream-details-modal-title"
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div className="glass-card relative w-full max-w-2xl mx-4 rounded-3xl border border-glass-border p-8 shadow-2xl animate-in fade-in zoom-in-95">
<div
ref={dialogRef}
className="glass-card relative w-full max-w-2xl mx-4 rounded-3xl border border-glass-border p-8 shadow-2xl animate-in fade-in zoom-in-95"
>
{/* Header */}
<div className="flex items-center justify-between mb-8">
<div>
<h2 className="text-2xl font-black tracking-tight">Stream Details</h2>
<h2 id="stream-details-modal-title" className="text-2xl font-black tracking-tight">Stream Details</h2>
<p className="text-sm text-slate-400 font-mono">ID: {stream.id}</p>
</div>
<button
Expand Down
7 changes: 2 additions & 5 deletions frontend/src/components/dashboard/dashboard-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,11 +108,8 @@ const SIDEBAR_ITEMS: SidebarItem[] = [
/** Shimmer card used as a placeholder while data loads */
function SkeletonCard({ className = "" }: { className?: string }) {
return (
<Skeleton
className={`rounded-2xl ${className}`}
aria-hidden="true"
/>
>
<div className={`relative overflow-hidden rounded-2xl ${className}`} aria-hidden="true">
<Skeleton className="w-full h-full" />
{/* shimmer sweep */}
<div className="absolute inset-0 -translate-x-full animate-shimmer bg-gradient-to-r from-transparent via-white/10 to-transparent" />
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -321,7 +321,7 @@ export const StreamCreationWizard: React.FC<StreamCreationWizardProps> = ({
setCurrentStep(currentStep + 1);
// Scroll to top when moving to next step
const modal = document.querySelector('.glass-card');
if (modal) {
if (modal && typeof modal.scrollTo === 'function') {
modal.scrollTo({ top: 0, behavior: 'smooth' });
}
}
Expand All @@ -338,7 +338,9 @@ export const StreamCreationWizard: React.FC<StreamCreationWizardProps> = ({
if (currentStep > 1) {
setCurrentStep(currentStep - 1);
// Scroll to top when going back
window.scrollTo({ top: 0, behavior: 'smooth' });
if (typeof window.scrollTo === 'function') {
window.scrollTo({ top: 0, behavior: 'smooth' });
}
}
};

Expand Down
Loading
Loading