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}
-
- )}
-
-
-
-
-
- Already have an account?{" "}
-
- Log in here
-
-
-
-
- );
+ return ;
}