From 88eeead36c49e0101645a1a315eee317bd280924 Mon Sep 17 00:00:00 2001 From: Adebanjo Date: Tue, 9 Jun 2026 15:18:31 +0100 Subject: [PATCH] feat: profile completion flow for incomplete profiles; serialize on-chain submits; instant profile reactivity --- agent-node/src/chainSubmitter.js | 16 +++++++++- frontend/src/hooks/useProfile.js | 17 +++++++++- frontend/src/pages/Connect.jsx | 6 ++-- frontend/src/pages/Landing.jsx | 6 ++-- frontend/src/pages/app/Setup.jsx | 55 +++++++++++++++++++++++++++----- 5 files changed, 84 insertions(+), 16 deletions(-) diff --git a/agent-node/src/chainSubmitter.js b/agent-node/src/chainSubmitter.js index 7c7c45c..61234ca 100644 --- a/agent-node/src/chainSubmitter.js +++ b/agent-node/src/chainSubmitter.js @@ -69,6 +69,12 @@ function getConnectedWallet(chainId) { // ─── Public API ────────────────────────────────────────────────────────────── +// Serializes all on-chain submissions from the agent signer. Concurrent +// extensions (e.g. one PR fanning out to multiple streams on the same repo) +// otherwise grab the same account nonce and one tx fails with "nonce too low". +// Submissions are infrequent, so a single global queue is simplest and safe. +let _submitChain = Promise.resolve(); + /** * Submit an ExtensionVoucher on-chain. * @@ -82,7 +88,15 @@ function getConnectedWallet(chainId) { * * @returns {Promise<{ txHash: string, blockNumber: number, gasUsed: string, chainId: number }>} */ -export async function submitExtension({ +export async function submitExtension(params) { + // Queue behind any in-flight submission so account nonces never collide. + const run = _submitChain.then(() => _submitExtension(params)); + // Keep the queue alive whether this one succeeds or fails. + _submitChain = run.then(() => {}, () => {}); + return run; +} + +async function _submitExtension({ streamId, extensionDurationSeconds, nonce, diff --git a/frontend/src/hooks/useProfile.js b/frontend/src/hooks/useProfile.js index 9778db6..86cf4ce 100644 --- a/frontend/src/hooks/useProfile.js +++ b/frontend/src/hooks/useProfile.js @@ -3,6 +3,18 @@ import { useState, useEffect, useRef, useCallback } from 'react'; const AGENT_URL = import.meta.env.VITE_AGENT_URL ?? 'http://localhost:3000'; const CACHE_KEY = addr => `cronstream_profile_${addr?.toLowerCase()}`; +/** + * A profile is "complete" only when every required field is present. A partial + * save (e.g. the onboarding 401 bug) can leave a profile with a role but no + * username, which has no Settings field to fix — so we route such profiles back + * through Setup to collect what's missing. + */ +export function isProfileComplete(p) { + if (!p || !p.role || !p.username || !p.name) return false; + if (p.role === 'contractor' && !p.github) return false; + return true; +} + // ─── Module-level deduplication ────────────────────────────────────────────── // Multiple components (AppShell, Dashboard, CreateStreamModal, etc.) all call // useProfile(address) independently. Without deduplication each mount fires its @@ -170,6 +182,9 @@ export function useProfile(address) { }; setProfile(optimistic); localStorage.setItem(CACHE_KEY(address), JSON.stringify(optimistic)); + // Notify other mounted components (AppShell, dashboard) immediately so the + // role/username reflect without waiting for the server round-trip or a refresh. + notifyListeners(address, optimistic); // Bust the memory cache so next mount re-fetches fresh data invalidateCache(address); @@ -205,7 +220,7 @@ export function useProfile(address) { if (address) { invalidateCache(address); fetchProfile(address); } } - return { profile, saveProfile, loading, synced, hasProfile: !!profile, refreshProfile }; + return { profile, saveProfile, loading, synced, hasProfile: !!profile, profileComplete: isProfileComplete(profile), refreshProfile }; } function _shortAddr(addr) { diff --git a/frontend/src/pages/Connect.jsx b/frontend/src/pages/Connect.jsx index 0425169..c77a0a9 100644 --- a/frontend/src/pages/Connect.jsx +++ b/frontend/src/pages/Connect.jsx @@ -7,13 +7,13 @@ import { useProfile } from '../hooks/useProfile'; export default function Connect() { const navigate = useNavigate(); const { address, isConnected } = useAccount(); - const { hasProfile } = useProfile(address); + const { profileComplete } = useProfile(address); useEffect(() => { if (isConnected) { - navigate(hasProfile ? '/app/dashboard' : '/app/setup', { replace: true }); + navigate(profileComplete ? '/app/dashboard' : '/app/setup', { replace: true }); } - }, [isConnected, hasProfile, navigate]); + }, [isConnected, profileComplete, navigate]); return (
diff --git a/frontend/src/pages/Landing.jsx b/frontend/src/pages/Landing.jsx index e01a077..9b0a766 100644 --- a/frontend/src/pages/Landing.jsx +++ b/frontend/src/pages/Landing.jsx @@ -28,13 +28,13 @@ export default function Landing() { const navigate = useNavigate(); const { address, isConnected } = useAccount(); const { openConnectModal } = useConnectModal(); - const { hasProfile } = useProfile(address); + const { profileComplete } = useProfile(address); useEffect(() => { if (isConnected) { - navigate(hasProfile ? '/app/dashboard' : '/app/setup', { replace: true }); + navigate(profileComplete ? '/app/dashboard' : '/app/setup', { replace: true }); } - }, [isConnected, hasProfile, navigate]); + }, [isConnected, profileComplete, navigate]); function handleLaunch() { if (isConnected) { diff --git a/frontend/src/pages/app/Setup.jsx b/frontend/src/pages/app/Setup.jsx index bf212bd..35900c1 100644 --- a/frontend/src/pages/app/Setup.jsx +++ b/frontend/src/pages/app/Setup.jsx @@ -1,15 +1,28 @@ import { useState, useEffect, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; import { useAccount } from 'wagmi'; -import { useProfile } from '../../hooks/useProfile'; +import { useProfile, isProfileComplete } from '../../hooks/useProfile'; import { useAuth } from '../../context/AuthContext'; const AGENT_URL = import.meta.env.VITE_AGENT_URL ?? 'http://localhost:3000'; -function UsernameField({ value, address, onChange }) { +function UsernameField({ value, address, onChange, locked }) { const [status, setStatus] = useState('idle'); // idle | checking | available | taken | invalid const debounce = useRef(null); + // Already set and immutable — show it read-only. + if (locked) { + return ( +
+ +
+ @ + +
+
+ ); + } + useEffect(() => { if (!value || value.length < 3) { setStatus('idle'); return; } if (!/^[a-z0-9_-]+$/.test(value)) { setStatus('invalid'); return; } @@ -71,10 +84,31 @@ export default function Setup() { const [form, setForm] = useState({ username: '', name: '', github: '', website: '', }); + const [prefilled, setPrefilled] = useState(false); - // If profile already exists redirect immediately - don't show the form + // A profile that exists but is incomplete (e.g. a missing immutable username + // from a partial save) lands here to be completed rather than dumped on a + // broken dashboard. Role is fixed once set; username is fixed once set. + const completing = synced && !!profile?.role && !isProfileComplete(profile); + const usernameLocked = !!profile?.username; + + // Prefill the form once with whatever the existing profile already has. useEffect(() => { - if (synced && profile?.role) { + if (synced && profile && !prefilled) { + setRole(profile.role ?? null); + setForm({ + username: profile.username ?? '', + name: profile.name ?? '', + github: profile.github ?? '', + website: profile.website ?? '', + }); + setPrefilled(true); + } + }, [synced, profile, prefilled]); + + // Only leave Setup once the profile is genuinely complete. + useEffect(() => { + if (synced && isProfileComplete(profile)) { navigate('/app/dashboard', { replace: true }); } }, [synced, profile, navigate]); @@ -155,11 +189,14 @@ export default function Setup() {
CronStream -

Set up your profile

-

Tell us how you'll use CronStream.

+

{completing ? 'Complete your profile' : 'Set up your profile'}

+

+ {completing ? 'A required detail is missing. Add it to finish.' : "Tell us how you'll use CronStream."} +

- {/* Role picker */} + {/* Role picker — hidden once a role exists (role is immutable) */} + {!completing && (
{[ { value: 'company', icon: '🏢', title: 'Company', desc: 'Create streams, pay contractors' }, @@ -178,6 +215,7 @@ export default function Setup() { ))}
+ )} {/* Form */} {role && ( @@ -186,6 +224,7 @@ export default function Setup() { setForm(f => ({ ...f, username: val }))} /> @@ -251,7 +290,7 @@ export default function Setup() { disabled={loading || !form.name || !form.username || (role === 'contractor' && !form.github.trim())} className="btn-primary w-full disabled:opacity-40 disabled:cursor-not-allowed mt-1" > - {loading ? 'Setting up…' : 'Continue to Dashboard →'} + {loading ? 'Saving…' : completing ? 'Save and continue →' : 'Continue to Dashboard →'} )}