Skip to content

Commit edf6085

Browse files
authored
Merge pull request #1421 from emarkees/fix/unify-stream-creation-validation
refactor: consolidate stream validation logic into a shared utility w…
2 parents 1e7f803 + 1766605 commit edf6085

7 files changed

Lines changed: 1180 additions & 429 deletions

File tree

frontend/src/app/streams/create/create-stream-content.tsx

Lines changed: 55 additions & 128 deletions
Original file line numberDiff line numberDiff line change
@@ -10,86 +10,15 @@ import {
1010
toSorobanErrorMessage,
1111
TOKEN_ADDRESSES
1212
} from "@/lib/soroban";
13-
import { hasValidPrecision, validateAmountInput } from "@/utils/amount";
13+
import { hasValidPrecision } from "@/utils/amount";
1414
import { toast } from "react-hot-toast";
1515
import { useRouter, useSearchParams } from "next/navigation";
1616
import Link from "next/link";
1717
import { ArrowLeft, FileText, X } from "lucide-react";
1818
import { useWallet } from "@/context/wallet-context";
19+
import { useStreamForm } from "@/hooks/useStreamForm";
1920

2021
const TOKEN_DECIMALS = 7;
21-
const DRAFT_STORAGE_KEY = "flowfi.create-stream.draft.v1";
22-
23-
interface StreamDraft {
24-
recipient: string;
25-
token: string;
26-
amount: string;
27-
duration: string;
28-
savedAt: number;
29-
}
30-
31-
interface FormFields {
32-
recipient: string;
33-
token: string;
34-
amount: string;
35-
duration: string;
36-
}
37-
38-
const DEFAULT_FORM: FormFields = {
39-
recipient: "",
40-
token: "XLM",
41-
amount: "",
42-
duration: "30",
43-
};
44-
45-
function isPristineForm(form: FormFields): boolean {
46-
return (
47-
form.recipient === "" &&
48-
form.token === DEFAULT_FORM.token &&
49-
form.amount === "" &&
50-
form.duration === DEFAULT_FORM.duration
51-
);
52-
}
53-
54-
function saveDraftToSession(data: StreamDraft): void {
55-
try {
56-
if (typeof window === "undefined") return;
57-
sessionStorage.setItem(DRAFT_STORAGE_KEY, JSON.stringify(data));
58-
} catch {
59-
// sessionStorage may be full or unavailable
60-
}
61-
}
62-
63-
function loadDraftFromSession(): StreamDraft | null {
64-
try {
65-
if (typeof window === "undefined") return null;
66-
const raw = sessionStorage.getItem(DRAFT_STORAGE_KEY);
67-
if (!raw) return null;
68-
const parsed = JSON.parse(raw) as StreamDraft;
69-
// Reject empty drafts so a bogus "resumed draft" banner is never shown
70-
// over a blank form.
71-
if (
72-
parsed &&
73-
typeof parsed.recipient === "string" &&
74-
typeof parsed.amount === "string" &&
75-
(parsed.recipient.trim() !== "" || parsed.amount.trim() !== "")
76-
) {
77-
return parsed;
78-
}
79-
return null;
80-
} catch {
81-
return null;
82-
}
83-
}
84-
85-
function clearDraft(): void {
86-
try {
87-
if (typeof window === "undefined") return;
88-
sessionStorage.removeItem(DRAFT_STORAGE_KEY);
89-
} catch {
90-
// ignore
91-
}
92-
}
9322

9423
export default function CreateStreamContent() {
9524
const { status, session } = useWallet();
@@ -98,55 +27,40 @@ export default function CreateStreamContent() {
9827
const [nowTimestamp] = useState(() => Date.now());
9928
const [loading, setLoading] = useState(false);
10029
const [txState, setTxState] = useState<"idle" | "signing" | "submitted" | "confirming">("idle");
101-
// Read the draft once at mount so the banner has a stable savedAt value
102-
// instead of re-reading sessionStorage on every render.
103-
const [restoredDraft, setRestoredDraft] = useState<StreamDraft | null>(
104-
() => loadDraftFromSession()
105-
);
10630
const [dismissedDraftBanner, setDismissedDraftBanner] = useState(false);
107-
const draftRestored = restoredDraft !== null;
10831

109-
const [formData, setFormData] = useState<FormFields>(() => {
110-
if (restoredDraft) {
111-
return {
112-
recipient: restoredDraft.recipient,
113-
token: restoredDraft.token,
114-
amount: restoredDraft.amount,
115-
duration: restoredDraft.duration,
116-
};
117-
}
118-
return { ...DEFAULT_FORM };
32+
// ── Shared form hook ────────────────────────────────────────────────────
33+
const {
34+
formData,
35+
errors,
36+
updateFormData,
37+
resetForm: _resetForm,
38+
validateAll,
39+
walletBalance,
40+
walletBalanceLoading,
41+
walletBalanceError,
42+
hasDraft,
43+
discardDraft,
44+
draftSavedAt,
45+
} = useStreamForm({
46+
walletPublicKey: session?.publicKey,
47+
enableDraftPersistence: true,
48+
initialData: { token: "XLM", duration: "30" },
11949
});
12050

121-
// Persist form data to sessionStorage whenever it changes — but never
122-
// write an empty draft for a pristine form on first mount.
123-
useEffect(() => {
124-
const timer = setTimeout(() => {
125-
if (isPristineForm(formData)) return;
126-
saveDraftToSession({
127-
recipient: formData.recipient,
128-
token: formData.token,
129-
amount: formData.amount,
130-
duration: formData.duration,
131-
savedAt: Date.now(),
132-
});
133-
}, 500); // debounce writes
134-
return () => clearTimeout(timer);
135-
}, [formData]);
136-
13751
// Handle recipient prefill from search params — but only if no draft is restored
13852
useEffect(() => {
13953
const recipientParam = searchParams.get("recipient");
140-
if (!recipientParam || draftRestored) return;
54+
if (!recipientParam || hasDraft) return;
14155

14256
import("@stellar/stellar-sdk").then(({ StrKey }) => {
14357
if (StrKey.isValidEd25519PublicKey(recipientParam)) {
144-
setFormData((prev) => ({ ...prev, recipient: recipientParam }));
58+
updateFormData({ recipient: recipientParam });
14559
} else {
14660
logger.warn("Ignoring malformed recipient query param", { recipientParam });
14761
}
14862
});
149-
}, [searchParams, draftRestored]);
63+
}, [searchParams, hasDraft, updateFormData]);
15064

15165
const handleSubmit = async (e: React.FormEvent) => {
15266
e.preventDefault();
@@ -155,9 +69,11 @@ export default function CreateStreamContent() {
15569
return;
15670
}
15771

158-
const validationError = validateAmountInput(formData.amount, TOKEN_DECIMALS);
159-
if (validationError) {
160-
toast.error(validationError);
72+
// Use the shared validation (checks recipient format, amount, precision, balance)
73+
if (!validateAll()) {
74+
// Show the first error as a toast for flat-form UX
75+
const firstError = Object.values(errors)[0];
76+
if (firstError) toast.error(firstError);
16177
return;
16278
}
16379

@@ -178,7 +94,7 @@ export default function CreateStreamContent() {
17894

17995
if (result.success) {
18096
setTxState("confirming");
181-
clearDraft();
97+
discardDraft();
18298
toast.success("Stream created successfully!");
18399
setTimeout(() => {
184100
setLoading(false);
@@ -204,16 +120,10 @@ export default function CreateStreamContent() {
204120
}
205121
};
206122

207-
const amountError = formData.amount
208-
? validateAmountInput(formData.amount, TOKEN_DECIMALS)
209-
: null;
210-
211123
const handleDismissDraft = useCallback(() => {
212-
clearDraft();
213-
setRestoredDraft(null);
124+
discardDraft();
214125
setDismissedDraftBanner(true);
215-
setFormData({ ...DEFAULT_FORM });
216-
}, []);
126+
}, [discardDraft]);
217127

218128
return (
219129
<div className="container mx-auto max-w-2xl px-4 py-12">
@@ -226,12 +136,12 @@ export default function CreateStreamContent() {
226136
</Link>
227137

228138
{/* Resume draft banner */}
229-
{restoredDraft && !dismissedDraftBanner && (
139+
{hasDraft && !dismissedDraftBanner && draftSavedAt && (
230140
<div className="mb-6 flex items-center gap-3 rounded-2xl border border-accent/30 bg-accent/10 px-5 py-4 text-sm">
231141
<FileText className="h-5 w-5 text-accent flex-shrink-0" />
232142
<span className="flex-1">
233143
Resumed a saved draft from{" "}
234-
{new Date(restoredDraft.savedAt).toLocaleTimeString()}
144+
{new Date(draftSavedAt).toLocaleTimeString()}
235145
. You can continue editing or start fresh.
236146
</span>
237147
<button
@@ -261,9 +171,12 @@ export default function CreateStreamContent() {
261171
placeholder="G..."
262172
className="w-full rounded-xl border border-slate-800 bg-slate-900/50 p-4 outline-none focus:border-accent transition-colors"
263173
value={formData.recipient}
264-
onChange={(e) => setFormData({ ...formData, recipient: e.target.value })}
174+
onChange={(e) => updateFormData({ recipient: e.target.value })}
265175
required
266176
/>
177+
{errors.recipient && (
178+
<p className="text-xs text-red-400 mt-1" role="alert">{errors.recipient}</p>
179+
)}
267180
</div>
268181

269182
<div className="grid grid-cols-2 gap-4">
@@ -275,7 +188,7 @@ export default function CreateStreamContent() {
275188
id="create-stream-token"
276189
className="w-full rounded-xl border border-slate-800 bg-slate-900/50 p-4 outline-none focus:border-accent transition-colors appearance-none"
277190
value={formData.token}
278-
onChange={(e) => setFormData({ ...formData, token: e.target.value })}
191+
onChange={(e) => updateFormData({ token: e.target.value })}
279192
>
280193
{Object.keys(TOKEN_ADDRESSES).map((symbol) => (
281194
<option key={symbol} value={symbol}>
@@ -299,14 +212,25 @@ export default function CreateStreamContent() {
299212
const newValue = e.target.value;
300213
if (newValue === '' || /^\d*\.?\d*$/.test(newValue)) {
301214
if (hasValidPrecision(newValue, TOKEN_DECIMALS)) {
302-
setFormData({ ...formData, amount: newValue });
215+
updateFormData({ amount: newValue });
303216
}
304217
}
305218
}}
306219
required
307220
/>
308-
{amountError && (
309-
<p className="text-xs text-red-400 mt-1">{amountError}</p>
221+
{errors.amount && (
222+
<p className="text-xs text-red-400 mt-1" role="alert">{errors.amount}</p>
223+
)}
224+
{walletBalance && !errors.amount && (
225+
<p className="text-xs text-slate-500 mt-1">
226+
Available: {walletBalance} {formData.token}
227+
</p>
228+
)}
229+
{walletBalanceLoading && (
230+
<p className="text-xs text-slate-500 mt-1">Loading balance…</p>
231+
)}
232+
{walletBalanceError && (
233+
<p className="text-xs text-yellow-500 mt-1">{walletBalanceError}</p>
310234
)}
311235
</div>
312236
</div>
@@ -321,9 +245,12 @@ export default function CreateStreamContent() {
321245
placeholder="30"
322246
className="w-full rounded-xl border border-slate-800 bg-slate-900/50 p-4 outline-none focus:border-accent transition-colors"
323247
value={formData.duration}
324-
onChange={(e) => setFormData({ ...formData, duration: e.target.value })}
248+
onChange={(e) => updateFormData({ duration: e.target.value })}
325249
required
326250
/>
251+
{errors.duration && (
252+
<p className="text-xs text-red-400 mt-1" role="alert">{errors.duration}</p>
253+
)}
327254
</div>
328255

329256
<div className="rounded-2xl bg-accent/5 p-6 space-y-4">

frontend/src/components/dashboard/dashboard-view.tsx

Lines changed: 33 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,8 @@ import {
3838
getTokenAddress,
3939
toSorobanErrorMessage,
4040
} from "@/lib/soroban";
41-
import { isValidStellarPublicKey } from "@/lib/stellar";
41+
import { validateStreamForm, type StreamFormData as SharedStreamFormData } from "@/lib/stream-validation";
42+
import { useStreamForm } from "@/hooks/useStreamForm";
4243
import IncomingStreams from "../IncomingStreams";
4344
import { useStreamEvents } from "@/hooks/useStreamEvents";
4445
import { SSEStatusIndicator } from "./SSEStatusIndicator";
@@ -565,6 +566,10 @@ export function DashboardView({ session, onDisconnect }: DashboardViewProps) {
565566

566567
const [streamForm, setStreamForm] =
567568
React.useState<StreamFormValues>(EMPTY_STREAM_FORM);
569+
const streamFormHook = useStreamForm({
570+
walletPublicKey: session.publicKey,
571+
initialData: { token: streamForm.token },
572+
});
568573
const [templates, setTemplates] = React.useState<StreamTemplate[]>([]);
569574
const [templatesHydrated, setTemplatesHydrated] = React.useState(false);
570575
const [templateNameInput, setTemplateNameInput] = React.useState("");
@@ -694,6 +699,9 @@ export function DashboardView({ session, onDisconnect }: DashboardViewProps) {
694699
const updateStreamForm = (field: keyof StreamFormValues, value: string) => {
695700
setStreamForm((prev) => ({ ...prev, [field]: value }));
696701
setStreamFormMessage(null);
702+
if (field === "token") {
703+
streamFormHook.updateFormData({ token: value });
704+
}
697705
};
698706

699707
const handleApplyTemplate = (templateId: string) => {
@@ -925,14 +933,8 @@ export function DashboardView({ session, onDisconnect }: DashboardViewProps) {
925933
});
926934
return;
927935
}
928-
const recipient = streamForm.recipient.trim();
929-
if (!isValidStellarPublicKey(recipient)) {
930-
setStreamFormMessage({
931-
text: "Recipient must be a valid Stellar public key.",
932-
tone: "error",
933-
});
934-
return;
935-
}
936+
937+
// ── Date-specific validation (unique to this form layout) ────────────
936938
const startDate = new Date(streamForm.startsAt);
937939
const endDate = new Date(streamForm.endsAt);
938940
if (Number.isNaN(startDate.getTime()) || Number.isNaN(endDate.getTime())) {
@@ -952,15 +954,30 @@ export function DashboardView({ session, onDisconnect }: DashboardViewProps) {
952954
});
953955
return;
954956
}
957+
958+
// ── Shared validation (recipient format, amount precision, balance) ──
959+
const canonicalData: SharedStreamFormData = {
960+
recipient: streamForm.recipient.trim(),
961+
token: streamForm.token.trim(),
962+
amount: streamForm.totalAmount.trim(),
963+
duration: String(durationSeconds),
964+
durationUnit: "seconds",
965+
};
966+
const sharedErrors = validateStreamForm(canonicalData, {
967+
walletBalance: streamFormHook.walletBalance,
968+
});
969+
if (Object.keys(sharedErrors).length > 0) {
970+
const firstError = Object.values(sharedErrors)[0];
971+
setStreamFormMessage({
972+
text: firstError ?? "Validation failed.",
973+
tone: "error",
974+
});
975+
return;
976+
}
977+
955978
setIsFormSubmitting(true);
956979
try {
957-
await handleCreateStream({
958-
recipient,
959-
token: streamForm.token.trim(),
960-
amount: streamForm.totalAmount.trim(),
961-
duration: String(durationSeconds),
962-
durationUnit: "seconds",
963-
});
980+
await handleCreateStream(canonicalData);
964981
handleResetStreamForm();
965982
setStreamFormMessage({
966983
text: "Stream submitted to wallet and confirmed on-chain.",

0 commit comments

Comments
 (0)