From 42a64fc043aa87830a944bff38cc7314299323a1 Mon Sep 17 00:00:00 2001 From: KS Jannette Date: Sun, 1 Mar 2026 12:54:55 -0500 Subject: [PATCH] Add multi-step onboarding wizard to replace bare signup form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a 5-step guided flow (Create Account → Add Wallet → Alert Rules → Notifications → Done) that takes new users from zero to fully configured alerts in one linear session. /signup now redirects to /onboarding; already-onboarded users who revisit /onboarding are redirected to /addresses. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/App.jsx | 3 + frontend/src/pages/Onboarding.jsx | 834 ++++++++++++++++++++++++++++++ frontend/src/pages/Signup.jsx | 187 +------ 3 files changed, 839 insertions(+), 185 deletions(-) create mode 100644 frontend/src/pages/Onboarding.jsx diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 4223436..3f16436 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -3,6 +3,7 @@ import { useAuth } from "./contexts/AuthContext"; import Navbar from "./components/Navbar"; import Login from "./pages/Login"; import Signup from "./pages/Signup"; +import Onboarding from "./pages/Onboarding"; import Addresses from "./pages/Addresses"; import Alerts from "./pages/Alerts"; import AlertHistory from "./pages/AlertHistory"; @@ -15,6 +16,7 @@ export default function App() { } /> } /> + } /> } /> ); @@ -28,6 +30,7 @@ export default function App() { } /> } /> } /> + } /> } /> diff --git a/frontend/src/pages/Onboarding.jsx b/frontend/src/pages/Onboarding.jsx new file mode 100644 index 0000000..fb09220 --- /dev/null +++ b/frontend/src/pages/Onboarding.jsx @@ -0,0 +1,834 @@ +/** + * Onboarding Wizard + * + * 5-step guided flow: Create Account → Add Wallet → Alert Rules → Notifications → Done + */ + +import { useState, useEffect } from "react"; +import { useNavigate } from "react-router-dom"; +import { useAuth } from "../contexts/AuthContext"; +import { createAddress, getAddresses } from "../api/addresses"; +import { createAlert } from "../api/alerts"; +import { + updateNotificationConfig, + testNotificationChannels, +} from "../api/notificationConfig"; + +const STEPS = [ + "Create Account", + "Add Wallet", + "Alert Rules", + "Notifications", + "Done", +]; + +const inputStyle = { + width: "100%", + padding: "0.5rem", + fontSize: "1rem", + backgroundColor: "#2a2a2a", + border: "1px solid #444", + borderRadius: "4px", + color: "white", + boxSizing: "border-box", +}; + +const labelStyle = { + display: "block", + marginBottom: "0.4rem", + color: "#ccc", + fontSize: "0.9rem", +}; + +export default function Onboarding() { + const { currentUser, signup } = useAuth(); + const navigate = useNavigate(); + + const [step, setStep] = useState(1); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + const [skipWarning, setSkipWarning] = useState(""); + const [testResults, setTestResults] = useState(null); + const [testLoading, setTestLoading] = useState(false); + + // Wizard state + const [data, setData] = useState({ + email: "", + password: "", + confirmPassword: "", + walletAddress: "", + walletLabel: "", + createdAddressId: null, + alertIncomingTx: false, + alertOutgoingTx: false, + alertLargeTransfer: false, + largeTransferThreshold: "", + alertBalanceBelow: false, + balanceBelowThreshold: "", + discordWebhookUrl: "", + slackWebhookUrl: "", + notificationEmail: "", + // summary + alertsCreated: [], + notificationConfigured: false, + }); + + function set(field, value) { + setData((prev) => ({ ...prev, [field]: value })); + } + + // On mount: if already fully onboarded, redirect away + useEffect(() => { + if (!currentUser) return; + getAddresses() + .then((addresses) => { + if (addresses.length > 0) { + navigate("/addresses", { replace: true }); + } + }) + .catch(() => {}); // ignore errors (e.g. mid-signup) + }, [currentUser, navigate]); + + // ── Step handlers ───────────────────────────────────────────────────────── + + async function handleStep1() { + setError(""); + if (!data.email || !data.password || !data.confirmPassword) { + setError("Please fill in all fields"); + return; + } + if (data.password !== data.confirmPassword) { + setError("Passwords do not match"); + return; + } + if (data.password.length < 6) { + setError("Password must be at least 6 characters"); + return; + } + // If user already exists (browser-close-mid-wizard), skip signup + if (!currentUser) { + try { + setLoading(true); + await signup(data.email, data.password); + } catch (err) { + if (err.code === "auth/email-already-in-use") { + setError("Email already in use. Try logging in instead."); + } else if (err.code === "auth/invalid-email") { + setError("Invalid email address"); + } else if (err.code === "auth/weak-password") { + setError("Password is too weak"); + } else { + setError("Failed to create account: " + err.message); + } + setLoading(false); + return; + } finally { + setLoading(false); + } + } + setStep(2); + } + + async function handleStep2() { + setError(""); + if (!data.walletAddress) { + setError("Please enter a wallet address"); + return; + } + if (!/^0x[0-9a-fA-F]{40}$/.test(data.walletAddress)) { + setError("Invalid ETH address (must be 0x followed by 40 hex characters)"); + return; + } + try { + setLoading(true); + const created = await createAddress({ + address: data.walletAddress, + label: data.walletLabel || undefined, + }); + set("createdAddressId", created.id); + setStep(3); + } catch (err) { + setError(err.message); + } finally { + setLoading(false); + } + } + + async function handleStep3() { + setError(""); + const rules = []; + if (data.alertIncomingTx) rules.push({ type: "incoming_tx" }); + if (data.alertOutgoingTx) rules.push({ type: "outgoing_tx" }); + if (data.alertLargeTransfer) { + if (!data.largeTransferThreshold) { + setError("Please enter a threshold for large transfers"); + return; + } + rules.push({ type: "large_transfer", threshold: data.largeTransferThreshold }); + } + if (data.alertBalanceBelow) { + if (!data.balanceBelowThreshold) { + setError("Please enter a threshold for balance below"); + return; + } + rules.push({ type: "balance_below", threshold: data.balanceBelowThreshold }); + } + + if (rules.length === 0) { + setStep(4); + return; + } + + try { + setLoading(true); + const created = []; + for (const rule of rules) { + const result = await createAlert(data.createdAddressId, rule); + created.push(result); + } + set("alertsCreated", created); + setStep(4); + } catch (err) { + setError(err.message); + } finally { + setLoading(false); + } + } + + async function handleStep4() { + setError(""); + const hasAny = + data.discordWebhookUrl || data.slackWebhookUrl || data.notificationEmail; + if (!hasAny) { + setStep(5); + return; + } + try { + setLoading(true); + await updateNotificationConfig({ + notification_enabled: true, + discord_webhook_url: data.discordWebhookUrl || undefined, + slack_webhook_url: data.slackWebhookUrl || undefined, + email: data.notificationEmail || undefined, + }); + set("notificationConfigured", true); + setStep(5); + } catch (err) { + setError(err.message); + } finally { + setLoading(false); + } + } + + async function handleTestChannels() { + setTestLoading(true); + setTestResults(null); + try { + const results = await testNotificationChannels(); + setTestResults(results); + } catch (err) { + setTestResults({ error: err.message }); + } finally { + setTestLoading(false); + } + } + + // ── Progress bar ────────────────────────────────────────────────────────── + + function ProgressBar() { + return ( +
+ {STEPS.map((label, i) => { + const stepNum = i + 1; + const done = step > stepNum; + const active = step === stepNum; + return ( +
+ {i > 0 && ( +
+ )} +
+
+ {done ? "✓" : stepNum} +
+
+ {label} +
+
+
+ ); + })} +
+ ); + } + + // ── Step content ────────────────────────────────────────────────────────── + + function Step1() { + return ( + <> +

Create your account

+
+ + set("email", e.target.value)} + disabled={loading} + style={inputStyle} + placeholder="you@example.com" + /> +
+
+ + set("password", e.target.value)} + disabled={loading} + style={inputStyle} + placeholder="At least 6 characters" + /> +
+
+ + set("confirmPassword", e.target.value)} + disabled={loading} + style={inputStyle} + placeholder="Repeat your password" + /> +
+ + ); + } + + function Step2() { + return ( + <> +

Add a wallet address

+

+ Enter the Ethereum address you want to monitor. +

+
+ + set("walletAddress", e.target.value)} + disabled={loading} + style={inputStyle} + placeholder="0x..." + /> +
+
+ + set("walletLabel", e.target.value)} + disabled={loading} + style={inputStyle} + placeholder="e.g. My main wallet" + /> +
+ + ); + } + + function Step3() { + return ( + <> +

Configure alert rules

+

+ Choose which events trigger notifications. You can change these later. +

+ + set("alertIncomingTx", v)} + label="Incoming transaction" + /> + set("alertOutgoingTx", v)} + label="Outgoing transaction" + /> + set("alertLargeTransfer", v)} + label="Large transfer" + > + {data.alertLargeTransfer && ( +
+ set("largeTransferThreshold", e.target.value)} + style={{ ...inputStyle, width: "160px" }} + placeholder="Threshold (ETH)" + min="0" + step="0.01" + /> +
+ )} +
+ set("alertBalanceBelow", v)} + label="Balance below" + > + {data.alertBalanceBelow && ( +
+ set("balanceBelowThreshold", e.target.value)} + style={{ ...inputStyle, width: "160px" }} + placeholder="Threshold (ETH)" + min="0" + step="0.01" + /> +
+ )} +
+ + ); + } + + function Step4() { + return ( + <> +

Set up notifications

+

+ Add at least one channel so you receive alerts. All fields are optional. +

+ +
+ + set("discordWebhookUrl", e.target.value)} + disabled={loading} + style={inputStyle} + placeholder="https://discord.com/api/webhooks/..." + /> +
+ +
+ + set("slackWebhookUrl", e.target.value)} + disabled={loading} + style={inputStyle} + placeholder="https://hooks.slack.com/services/..." + /> +
+ +
+ + set("notificationEmail", e.target.value)} + disabled={loading} + style={inputStyle} + placeholder="you@example.com" + /> +
+ + ); + } + + function Step5() { + const alertCount = data.alertsCreated.length; + const hasNotif = data.notificationConfigured; + + return ( + <> +

You're all set!

+ +
+

+ Summary +

+
    +
  • + Wallet address added:{" "} + + {data.walletAddress} + + {data.walletLabel && ` (${data.walletLabel})`} +
  • +
  • + Alert rules configured:{" "} + + {alertCount > 0 ? `${alertCount} rule${alertCount !== 1 ? "s" : ""}` : "None (skipped)"} + +
  • +
  • + Notification channels:{" "} + + {hasNotif ? "Configured" : "Not set up (skipped)"} + +
  • +
+
+ + {hasNotif && ( +
+ + + {testResults && ( +
+ {testResults.error ? ( +

{testResults.error}

+ ) : ( +
    + {Object.entries(testResults).map(([channel, result]) => ( +
  • + {result.success ? "✓" : "✗"} {channel}:{" "} + {result.message || (result.success ? "OK" : "Failed")} +
  • + ))} +
+ )} +
+ )} +
+ )} + + + + ); + } + + // ── Shared helpers ──────────────────────────────────────────────────────── + + function CheckboxRow({ checked, onChange, label, children }) { + return ( +
+ + {children} +
+ ); + } + + // ── Footer navigation ───────────────────────────────────────────────────── + + function Footer() { + if (step === 5) return null; + + const canSkip = step === 3 || step === 4; + const canBack = step > 1; + + async function handleNext() { + setSkipWarning(""); + if (step === 1) await handleStep1(); + else if (step === 2) await handleStep2(); + else if (step === 3) await handleStep3(); + else if (step === 4) await handleStep4(); + } + + function handleSkip() { + setError(""); + setSkipWarning(""); + setStep((s) => s + 1); + } + + function handleBack() { + setError(""); + setSkipWarning(""); + setStep((s) => s - 1); + } + + return ( +
+
+ {canBack && ( + + )} +
+ +
+ {canSkip && ( + + )} + +
+
+ ); + } + + // ── Render ──────────────────────────────────────────────────────────────── + + const stepContent = { + 1: , + 2: , + 3: , + 4: , + 5: , + }; + + return ( +
+
+

+ Koin Ping +

+ + + + {error && ( +
+ {error} +
+ )} + + {skipWarning && ( +
+ {skipWarning} +
+ )} + +
+ {stepContent[step]} +
+
+ + {step === 1 && ( +

+ Already have an account?{" "} + + Log in here + +

+ )} +
+
+ ); +} diff --git a/frontend/src/pages/Signup.jsx b/frontend/src/pages/Signup.jsx index f854af2..bbdd78e 100644 --- a/frontend/src/pages/Signup.jsx +++ b/frontend/src/pages/Signup.jsx @@ -1,188 +1,5 @@ -/** - * Signup Page - * - * Allows new users to create an account with email and password - */ - -import { useState } from "react"; -import { Link, useNavigate } from "react-router-dom"; -import { useAuth } from "../contexts/AuthContext"; +import { Navigate } from "react-router-dom"; export default function Signup() { - const [email, setEmail] = useState(""); - const [password, setPassword] = useState(""); - const [confirmPassword, setConfirmPassword] = useState(""); - const [error, setError] = useState(""); - const [loading, setLoading] = useState(false); - - const { signup } = useAuth(); - const navigate = useNavigate(); - - async function handleSubmit(e) { - e.preventDefault(); - - // Validation - if (!email || !password || !confirmPassword) { - setError("Please fill in all fields"); - return; - } - - if (password !== confirmPassword) { - setError("Passwords do not match"); - return; - } - - if (password.length < 6) { - setError("Password must be at least 6 characters"); - return; - } - - try { - setError(""); - setLoading(true); - await signup(email, password); - navigate("/addresses"); // Auto-login and redirect - } catch (err) { - // Firebase-specific error messages - if (err.code === "auth/email-already-in-use") { - setError("Email already in use. Try logging in instead."); - } else if (err.code === "auth/invalid-email") { - setError("Invalid email address"); - } else if (err.code === "auth/weak-password") { - setError("Password is too weak"); - } else { - setError("Failed to create account: " + err.message); - } - } finally { - setLoading(false); - } - } - - return ( -
-

- Koin Ping - Sign Up -

- - {error && ( -
- {error} -
- )} - -
-
- - setEmail(e.target.value)} - disabled={loading} - style={{ - width: "100%", - padding: "0.5rem", - fontSize: "1rem", - backgroundColor: "#242424", - border: "1px solid #444", - borderRadius: "4px", - color: "white", - }} - required - /> -
- -
- - setPassword(e.target.value)} - disabled={loading} - style={{ - width: "100%", - padding: "0.5rem", - fontSize: "1rem", - backgroundColor: "#242424", - border: "1px solid #444", - borderRadius: "4px", - color: "white", - }} - required - /> -
- -
- - setConfirmPassword(e.target.value)} - disabled={loading} - style={{ - width: "100%", - padding: "0.5rem", - fontSize: "1rem", - backgroundColor: "#242424", - border: "1px solid #444", - borderRadius: "4px", - color: "white", - }} - required - /> -
- - -
- -
-

- Already have an account?{" "} - - Log in here - -

-
-
- ); + return ; }