diff --git a/backend-go/cmd/api/main.go b/backend-go/cmd/api/main.go
index 1fd7d98..7bbdb1a 100644
--- a/backend-go/cmd/api/main.go
+++ b/backend-go/cmd/api/main.go
@@ -86,6 +86,8 @@ func main() {
authenticate(http.HandlerFunc(stripeHandler.CreateCheckoutSession)))
mux.Handle("GET "+b+"/stripe/subscription-status",
authenticate(http.HandlerFunc(stripeHandler.GetSubscriptionStatus)))
+ mux.Handle("POST "+b+"/stripe/verify-checkout",
+ authenticate(http.HandlerFunc(stripeHandler.VerifyCheckoutSession)))
// Authenticated + subscribed routes — addresses
mux.Handle("POST "+b+"/addresses",
diff --git a/backend-go/internal/handlers/stripe.go b/backend-go/internal/handlers/stripe.go
index 666b204..55b50cd 100644
--- a/backend-go/internal/handlers/stripe.go
+++ b/backend-go/internal/handlers/stripe.go
@@ -84,6 +84,61 @@ func (h *StripeHandler) GetSubscriptionStatus(w http.ResponseWriter, r *http.Req
})
}
+// VerifyCheckoutSession retrieves a completed checkout session from Stripe,
+// confirms payment, and activates the user's subscription in the database.
+// This is the primary activation path; webhooks serve as a backup.
+func (h *StripeHandler) VerifyCheckoutSession(w http.ResponseWriter, r *http.Request) {
+ userID := middleware.GetUserID(r.Context())
+
+ var body struct {
+ SessionID string `json:"session_id"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.SessionID == "" {
+ writeError(w, http.StatusBadRequest, "BAD_REQUEST", "Missing session_id")
+ return
+ }
+
+ s, err := checkoutsession.Get(body.SessionID, nil)
+ if err != nil {
+ log.Printf("Failed to retrieve checkout session %s: %v", body.SessionID, err)
+ writeError(w, http.StatusBadRequest, "STRIPE_ERROR", "Invalid checkout session")
+ return
+ }
+
+ if s.ClientReferenceID != userID {
+ writeError(w, http.StatusForbidden, "FORBIDDEN", "Session does not belong to this user")
+ return
+ }
+
+ if s.PaymentStatus != stripe.CheckoutSessionPaymentStatusPaid {
+ writeError(w, http.StatusBadRequest, "PAYMENT_INCOMPLETE", "Payment has not been completed")
+ return
+ }
+
+ customerID := ""
+ if s.Customer != nil {
+ customerID = s.Customer.ID
+ }
+ subscriptionID := ""
+ if s.Subscription != nil {
+ subscriptionID = s.Subscription.ID
+ }
+
+ if customerID != "" {
+ if err := h.users.UpdateStripeCustomer(r.Context(), userID, customerID); err != nil {
+ log.Printf("VerifyCheckout: failed to save customer ID: %v", err)
+ }
+ }
+ if subscriptionID != "" && customerID != "" {
+ if err := h.users.ActivateSubscription(r.Context(), customerID, subscriptionID, "active"); err != nil {
+ log.Printf("VerifyCheckout: failed to activate subscription: %v", err)
+ }
+ }
+
+ log.Printf("Checkout verified for user %s, customer %s, subscription %s", userID, customerID, subscriptionID)
+ writeJSON(w, http.StatusOK, map[string]string{"subscription_status": "active"})
+}
+
// HandleWebhook processes incoming Stripe webhook events.
// This endpoint must NOT require authentication (Stripe calls it directly).
func (h *StripeHandler) HandleWebhook(w http.ResponseWriter, r *http.Request) {
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 60dd37a..99d7b54 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -1,38 +1,38 @@
import { Routes, Route, Navigate } from "react-router-dom";
import { useAuth } from "./contexts/AuthContext";
import Navbar from "./components/Navbar";
-import Login from "./pages/Login";
+import Login from "./pages/login/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";
+import Subscribe from "./pages/subscribe/subscribe";
+import Addresses from "./pages/addresses/Addresses";
+import Alerts from "./pages/alerts/Alerts";
+import AlertHistory from "./pages/alertHistory/AlertHistory";
export default function App() {
- const { currentUser } = useAuth();
-
- if (!currentUser) {
- return (
-
- } />
- } />
- } />
- } />
-
- );
- }
+ const { currentUser } = useAuth();
+ if (!currentUser) {
return (
-
-
-
- } />
- } />
- } />
- } />
- } />
- } />
-
-
+
+ } />
+ } />
+ } />
+ } />
+
);
+ }
+
+ return (
+
+
+
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+ );
}
diff --git a/frontend/src/api/stripe.jsx b/frontend/src/api/stripe.jsx
index 9eb38e2..701ad2e 100644
--- a/frontend/src/api/stripe.jsx
+++ b/frontend/src/api/stripe.jsx
@@ -14,6 +14,20 @@ export async function createCheckoutSession() {
return res.json();
}
+export async function verifyCheckoutSession(sessionId) {
+ const headers = await getAuthHeaders();
+ const res = await fetch(`${API_BASE}/stripe/verify-checkout`, {
+ method: "POST",
+ headers: { ...headers, "Content-Type": "application/json" },
+ body: JSON.stringify({ session_id: sessionId }),
+ });
+ if (!res.ok) {
+ const data = await res.json();
+ throw new Error(data.message || "Failed to verify checkout session");
+ }
+ return res.json();
+}
+
export async function getSubscriptionStatus() {
const headers = await getAuthHeaders();
const res = await fetch(`${API_BASE}/stripe/subscription-status`, {
diff --git a/frontend/src/components/Navbar.jsx b/frontend/src/components/Navbar.jsx
index 7dcfd27..bc4a110 100644
--- a/frontend/src/components/Navbar.jsx
+++ b/frontend/src/components/Navbar.jsx
@@ -5,7 +5,7 @@ import "./Navbar.css";
const navLinks = [
{ to: "/addresses", label: "Addresses" },
{ to: "/alerts", label: "Configure Alerts" },
- { to: "/alertevents", label: "Alert Events" },
+ { to: "/alertevents", label: "Alert Event History" },
];
export default function Navbar() {
diff --git a/frontend/src/pages/Addresses.jsx b/frontend/src/pages/Addresses.jsx
deleted file mode 100644
index 754c8c3..0000000
--- a/frontend/src/pages/Addresses.jsx
+++ /dev/null
@@ -1,159 +0,0 @@
-import { useState, useEffect } from "react";
-import AddressForm from "../components/AddressForm";
-import { getAddresses, createAddress, deleteAddress, updateAddress } from "../api/addresses";
-import "./Addresses.css";
-
-export default function Addresses() {
- const [addresses, setAddresses] = useState([]);
- const [loading, setLoading] = useState(true);
- const [error, setError] = useState(null);
- const [editingId, setEditingId] = useState(null);
- const [editLabel, setEditLabel] = useState("");
-
- useEffect(() => {
- async function fetchAddresses() {
- try {
- setLoading(true);
- const data = await getAddresses();
- setAddresses(data);
- } catch (err) {
- setError(err.message);
- console.error("Failed to fetch addresses:", err);
- } finally {
- setLoading(false);
- }
- }
-
- fetchAddresses();
- }, []);
-
- async function handleAddressSubmit(data) {
- try {
- const newAddress = await createAddress(data);
- setAddresses((prev) => [...prev, newAddress]);
- setError(null);
- } catch (err) {
- setError(err.message);
- console.error("Failed to create address:", err);
- }
- }
-
- async function handleDelete(id, label) {
- const displayName = label || "this address";
- if (!window.confirm(`Remove "${displayName}"? This will also delete all associated alert rules.`)) {
- return;
- }
- try {
- await deleteAddress(id);
- setAddresses((prev) => prev.filter((a) => a.id !== id));
- setError(null);
- } catch (err) {
- setError(err.message);
- console.error("Failed to delete address:", err);
- }
- }
-
- function handleEditStart(addr) {
- setEditingId(addr.id);
- setEditLabel(addr.label ?? "");
- }
-
- async function handleEditSave(id) {
- try {
- const updated = await updateAddress(id, { label: editLabel || null });
- setAddresses((prev) => prev.map((a) => (a.id === id ? updated : a)));
- setEditingId(null);
- setEditLabel("");
- setError(null);
- } catch (err) {
- setError(err.message);
- console.error("Failed to update address:", err);
- }
- }
-
- function handleEditCancel() {
- setEditingId(null);
- setEditLabel("");
- }
-
- return (
-
-
Add Addresses to Track
-
-
-
-
-
Existing Tracked Addresses
- {loading &&
Loading addresses...
}
- {error &&
Error: {error}
}
- {!loading && !error && addresses.length === 0 && (
-
- No addresses tracked yet. Add one above to get started.
-
- )}
- {addresses.length > 0 && (
-
- {addresses.map((addr, index) => (
- -
-
-
- {editingId === addr.id ? (
-
- setEditLabel(e.target.value)}
- placeholder="Label (optional)"
- className="address__edit-input"
- onKeyDown={(e) => {
- if (e.key === "Enter") handleEditSave(addr.id);
- if (e.key === "Escape") handleEditCancel();
- }}
- autoFocus
- />
-
-
-
- ) : (
-
-
- {addr.label || "Unlabeled"}
-
-
-
- )}
-
- {addr.address}
-
-
-
-
-
- ))}
-
- )}
-
-
- );
-}
diff --git a/frontend/src/pages/AlertHistory.jsx b/frontend/src/pages/AlertHistory.jsx
deleted file mode 100644
index 90d6e03..0000000
--- a/frontend/src/pages/AlertHistory.jsx
+++ /dev/null
@@ -1,67 +0,0 @@
-import { useState, useEffect } from "react";
-import { getAlertEvents } from "../api/alertEvents";
-import "./AlertHistory.css";
-
-export default function AlertHistory() {
- const [alertEvents, setAlertEvents] = useState([]);
- const [loading, setLoading] = useState(true);
- const [error, setError] = useState(null);
-
- useEffect(() => {
- async function fetchAlertEvents() {
- try {
- setLoading(true);
- const data = await getAlertEvents();
- setAlertEvents(data);
- } catch (err) {
- setError(err.message);
- console.error("Failed to fetch alert events:", err);
- } finally {
- setLoading(false);
- }
- }
-
- fetchAlertEvents();
- }, []);
-
- if (loading) {
- return Loading...
;
- }
-
- if (error) {
- return (
- Error: {error}
- );
- }
-
- return (
-
-
Recent Alert Events
-
- {alertEvents.length === 0 ? (
-
No alerts yet
- ) : (
-
- )}
-
- );
-}
-
-function formatTimestamp(timestamp) {
- const date = new Date(timestamp);
- return date.toLocaleString();
-}
diff --git a/frontend/src/pages/Alerts.jsx b/frontend/src/pages/Alerts.jsx
deleted file mode 100644
index cf6f2a9..0000000
--- a/frontend/src/pages/Alerts.jsx
+++ /dev/null
@@ -1,522 +0,0 @@
-import { useState, useEffect } from "react";
-import AlertForm from "../components/AlertForm";
-import Button from "../components/Button";
-import Input from "../components/Input";
-import { getAddresses } from "../api/addresses";
-import {
- getAlerts,
- createAlert,
- updateAlertStatus,
- deleteAlert,
-} from "../api/alerts";
-import {
- getNotificationConfig,
- updateNotificationConfig,
- testNotificationChannels,
- setupEmail,
- sendEmailDigest,
-} from "../api/notificationConfig";
-import "./Alerts.css";
-
-export default function Alerts() {
- const [addresses, setAddresses] = useState([]);
- const [selectedAddressId, setSelectedAddressId] = useState(null);
- const [alerts, setAlerts] = useState([]);
- const [loading, setLoading] = useState(true);
- const [error, setError] = useState(null);
-
- const [notificationEnabled, setNotificationEnabled] = useState(false);
- const [discordWebhookUrl, setDiscordWebhookUrl] = useState("");
- const [telegramBotToken, setTelegramBotToken] = useState("");
- const [telegramChatId, setTelegramChatId] = useState("");
- const [email, setEmail] = useState("");
- const [slackWebhookUrl, setSlackWebhookUrl] = useState("");
- const [notificationLoading, setNotificationLoading] = useState(false);
- const [notificationError, setNotificationError] = useState(null);
- const [notificationSuccess, setNotificationSuccess] = useState(null);
- const [testingChannels, setTestingChannels] = useState(false);
- const [settingUpEmail, setSettingUpEmail] = useState(false);
- const [sendingDigest, setSendingDigest] = useState(false);
- const [hasExistingConfig, setHasExistingConfig] = useState(false);
-
- useEffect(() => {
- async function fetchData() {
- try {
- setLoading(true);
-
- const addressData = await getAddresses();
- setAddresses(addressData);
- if (addressData.length > 0) {
- setSelectedAddressId(addressData[0].id);
- }
-
- const configData = await getNotificationConfig();
- setNotificationEnabled(
- configData.notification_enabled !== false,
- );
- setDiscordWebhookUrl(configData.discord_webhook_url || "");
- setTelegramBotToken(configData.telegram_bot_token || "");
- setTelegramChatId(configData.telegram_chat_id || "");
- setEmail(configData.email || "");
- setSlackWebhookUrl(configData.slack_webhook_url || "");
-
- const hasSaved =
- !!configData.discord_webhook_url ||
- !!configData.telegram_bot_token ||
- !!configData.telegram_chat_id ||
- !!configData.email ||
- !!configData.slack_webhook_url;
- setHasExistingConfig(hasSaved);
- } catch (err) {
- setError(err.message);
- console.error("Failed to fetch data:", err);
- } finally {
- setLoading(false);
- }
- }
-
- fetchData();
- }, []);
-
- useEffect(() => {
- if (!selectedAddressId) {
- setAlerts([]);
- return;
- }
-
- async function fetchAlerts() {
- try {
- const data = await getAlerts(selectedAddressId);
- setAlerts(data);
- setError(null);
- } catch (err) {
- setError(err.message);
- console.error("Failed to fetch alerts:", err);
- }
- }
-
- fetchAlerts();
- }, [selectedAddressId]);
-
- async function handleAlertSubmit(data) {
- if (!selectedAddressId) return;
-
- try {
- const newAlert = await createAlert(selectedAddressId, data);
- setAlerts((prev) => [...prev, newAlert]);
- setError(null);
- } catch (err) {
- setError(err.message);
- console.error("Failed to create alert:", err);
- }
- }
-
- async function handleToggleAlert(alertId, currentStatus) {
- try {
- const updated = await updateAlertStatus(alertId, !currentStatus);
- setAlerts((prev) =>
- prev.map((alert) => (alert.id === alertId ? updated : alert)),
- );
- setError(null);
- } catch (err) {
- setError(err.message);
- console.error("Failed to update alert:", err);
- }
- }
-
- async function handleDeleteAlert(alertId) {
- try {
- await deleteAlert(alertId);
- setAlerts((prev) => prev.filter((alert) => alert.id !== alertId));
- setError(null);
- } catch (err) {
- setError(err.message);
- console.error("Failed to delete alert:", err);
- }
- }
-
- async function handleSaveNotificationConfig() {
- if (hasExistingConfig) {
- const confirmed = window.confirm(
- "This will overwrite your previously saved notification settings. Continue?",
- );
- if (!confirmed) return;
- }
-
- try {
- setNotificationLoading(true);
- setNotificationError(null);
- setNotificationSuccess(null);
-
- const config = {
- notification_enabled: notificationEnabled,
- discord_webhook_url: discordWebhookUrl || null,
- telegram_bot_token: telegramBotToken || null,
- telegram_chat_id: telegramChatId || null,
- email: email || null,
- slack_webhook_url: slackWebhookUrl || null,
- };
-
- await updateNotificationConfig(config);
- setHasExistingConfig(true);
- setNotificationSuccess("Notification settings saved!");
- setTimeout(() => setNotificationSuccess(null), 3000);
- } catch (err) {
- setNotificationError(err.message);
- console.error("Failed to save notification config:", err);
- } finally {
- setNotificationLoading(false);
- }
- }
-
- async function handleTestChannels() {
- try {
- setTestingChannels(true);
- setNotificationError(null);
- setNotificationSuccess(null);
-
- const data = await testNotificationChannels();
- const results = data.results || [];
-
- const failed = results.filter((r) => !r.success);
- const succeeded = results.filter((r) => r.success);
-
- if (failed.length === 0 && succeeded.length > 0) {
- setNotificationSuccess(
- `Test sent to: ${succeeded.map((r) => r.channel).join(", ")}`,
- );
- } else if (failed.length > 0 && succeeded.length > 0) {
- setNotificationSuccess(
- `Sent: ${succeeded.map((r) => r.channel).join(", ")}. Failed: ${failed.map((r) => `${r.channel} (${r.error})`).join(", ")}`,
- );
- } else if (failed.length > 0) {
- setNotificationError(
- `Test failed: ${failed.map((r) => `${r.channel} (${r.error})`).join(", ")}`,
- );
- }
-
- setTimeout(() => {
- setNotificationSuccess(null);
- setNotificationError(null);
- }, 6000);
- } catch (err) {
- setNotificationError(err.message);
- } finally {
- setTestingChannels(false);
- }
- }
-
- async function handleSetupEmail() {
- try {
- setSettingUpEmail(true);
- setNotificationError(null);
- setNotificationSuccess(null);
-
- await handleSaveNotificationConfig();
-
- const result = await setupEmail();
- setNotificationSuccess(
- result.message || "Confirmation email sent!",
- );
- setTimeout(() => setNotificationSuccess(null), 5000);
- } catch (err) {
- setNotificationError(err.message);
- } finally {
- setSettingUpEmail(false);
- }
- }
-
- async function handleSendDigest() {
- try {
- setSendingDigest(true);
- setNotificationError(null);
- setNotificationSuccess(null);
-
- const result = await sendEmailDigest();
- setNotificationSuccess(result.message || "Digest email sent!");
- setTimeout(() => setNotificationSuccess(null), 5000);
- } catch (err) {
- setNotificationError(err.message);
- } finally {
- setSendingDigest(false);
- }
- }
-
- const selectedAddress = addresses.find((a) => a.id === selectedAddressId);
-
- if (loading) {
- return Loading addresses...
;
- }
-
- if (addresses.length === 0) {
- return (
-
-
No addresses tracked yet. Add an address first to create alerts.
-
- );
- }
-
- return (
-
-
Alert Rules & Notifications
-
-
- {/* LEFT COLUMN: Alert Rules */}
-
-
Alert Rules
-
-
-
-
-
-
- {selectedAddress && (
- <>
-
-
- Managing alerts for:
-
-
- {selectedAddress.label || "Unlabeled"}
-
-
- {selectedAddress.address}
-
-
-
-
-
-
-
Active Alert Rules
- {error && (
-
Error: {error}
- )}
- {alerts.length === 0 ? (
-
- No alert rules defined yet. Create one above.
-
- ) : (
-
- {alerts.map((alert) => (
- -
-
-
-
- {formatAlertType(alert.type)}
-
- {alert.threshold && (
-
- Threshold: {alert.threshold} ETH
-
- )}
-
- Status: {alert.enabled ? "Enabled" : "Disabled"}
-
-
-
-
-
-
-
-
- ))}
-
- )}
-
- >
- )}
-
-
- {/* RIGHT COLUMN: Notification Settings */}
-
-
Notification Settings
-
- {notificationSuccess && (
-
{notificationSuccess}
- )}
-
- {notificationError && (
-
{notificationError}
- )}
-
- {/* Master toggle */}
-
-
-
- Master switch for all notification channels
-
-
-
- {/* All channel settings -- hidden when master toggle is off */}
- {notificationEnabled && (
- <>
- {/* Telegram */}
-
-
- {/* Email */}
-
-
Email
-
-
- Alert notifications and digests will be sent to this address
-
-
-
-
-
-
-
- {/* Discord */}
-
-
- {/* Slack */}
-
-
- {/* Save & Test buttons */}
-
-
-
-
- >
- )}
-
-
-
- );
-}
-
-function formatAlertType(type) {
- const labels = {
- incoming_tx: "Incoming transaction",
- outgoing_tx: "Outgoing transaction",
- large_transfer: "Large transfer",
- balance_below: "Balance below threshold",
- };
- return labels[type] || type;
-}
diff --git a/frontend/src/pages/Onboarding.jsx b/frontend/src/pages/Onboarding.jsx
deleted file mode 100644
index 022e2b0..0000000
--- a/frontend/src/pages/Onboarding.jsx
+++ /dev/null
@@ -1,671 +0,0 @@
-/**
- * Subscribe / Onboarding Wizard
- *
- * 5-step guided flow: Create Account -> Add Wallet -> Alert Rules -> Notifications -> Done
- * After account creation, user is redirected to Stripe Checkout for payment.
- * On successful payment, they return here at step 2 (Add Wallet).
- */
-
-import { useState, useEffect } from "react";
-import { useNavigate, useSearchParams } 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";
-import { createCheckoutSession, getSubscriptionStatus } from "../api/stripe";
-import Input from "../components/Input";
-import Button from "../components/Button";
-import "./Onboarding.css";
-
-const STEPS = [
- "Create Account",
- "Add Wallet",
- "Alert Rules",
- "Notifications",
- "Done",
-];
-
-export default function Onboarding() {
- const { currentUser, signup } = useAuth();
- const navigate = useNavigate();
- const [searchParams, setSearchParams] = useSearchParams();
-
- 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);
-
- const [data, setData] = useState({
- email: "",
- password: "",
- confirmPassword: "",
- walletAddress: "",
- walletLabel: "",
- createdAddressId: null,
- alertIncomingTx: false,
- alertOutgoingTx: false,
- alertLargeTransfer: false,
- largeTransferThreshold: "",
- alertBalanceBelow: false,
- balanceBelowThreshold: "",
- discordWebhookUrl: "",
- slackWebhookUrl: "",
- notificationEmail: "",
- alertsCreated: [],
- notificationConfigured: false,
- });
-
- function set(field, value) {
- setData((prev) => ({ ...prev, [field]: value }));
- }
-
- useEffect(() => {
- if (!currentUser) return;
- getAddresses()
- .then((addresses) => {
- if (addresses.length > 0) {
- navigate("/addresses", { replace: true });
- }
- })
- .catch(() => {});
- }, [currentUser, navigate]);
-
- // Handle Stripe redirect back from checkout
- useEffect(() => {
- if (!currentUser) return;
- const payment = searchParams.get("payment");
- if (payment === "success") {
- setSearchParams({}, { replace: true });
- setStep(2);
- } else if (payment === "cancelled") {
- setSearchParams({}, { replace: true });
- setStep(1);
- setError("Payment was cancelled. Please try again.");
- }
- }, [currentUser, searchParams, setSearchParams]);
-
- // ── 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;
- }
- try {
- setLoading(true);
- if (!currentUser) {
- await signup(data.email, data.password);
- }
- const status = await getSubscriptionStatus();
- if (status.subscription_status === "active" || status.subscription_status === "trialing") {
- setStep(2);
- return;
- }
- const { url } = await createCheckoutSession();
- window.location.href = url;
- } 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);
- }
- }
-
- 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;
- const dotClass = done
- ? "progress-bar__dot--done"
- : active
- ? "progress-bar__dot--active"
- : "progress-bar__dot--pending";
-
- return (
-
- {i > 0 && (
-
- )}
-
-
- {done ? "\u2713" : stepNum}
-
-
- {label}
-
-
-
- );
- })}
-
- );
- }
-
- // ── Step content ──────────────────────────────────────────────────────────
-
- function Step1() {
- return (
- <>
- Create your account
- set("email", v)}
- disabled={loading}
- placeholder="you@example.com"
- />
- set("password", v)}
- disabled={loading}
- placeholder="At least 6 characters"
- />
- set("confirmPassword", v)}
- disabled={loading}
- placeholder="Repeat your password"
- className="form-field--last"
- />
- >
- );
- }
-
- function Step2() {
- return (
- <>
- Add a wallet address
-
- Enter the Ethereum address you want to monitor.
-
- set("walletAddress", v)}
- disabled={loading}
- placeholder="0x..."
- />
- set("walletLabel", v)}
- disabled={loading}
- placeholder="e.g. My main wallet"
- className="form-field--last"
- />
- >
- );
- }
-
- 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", v)}
- placeholder="Threshold (ETH)"
- min="0"
- step="0.01"
- />
-
- )}
-
- set("alertBalanceBelow", v)}
- label="Balance below"
- >
- {data.alertBalanceBelow && (
-
- set("balanceBelowThreshold", v)}
- 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", v)}
- disabled={loading}
- placeholder="https://discord.com/api/webhooks/..."
- />
-
-
-
-
-
set("slackWebhookUrl", v)}
- disabled={loading}
- placeholder="https://hooks.slack.com/services/..."
- />
-
-
- set("notificationEmail", v)}
- disabled={loading}
- placeholder="you@example.com"
- className="form-field--last"
- />
- >
- );
- }
-
- 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 ? "\u2713" : "\u2717"} {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 > 2;
-
- 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);
- }
-
- const nextLabel = step === 1
- ? "Create Account & Subscribe"
- : step === 4
- ? "Finish"
- : "Next →";
-
- return (
-
-
- {canBack && (
-
- )}
-
-
-
- {canSkip && (
-
- )}
-
-
-
- );
- }
-
- // ── Render ────────────────────────────────────────────────────────────────
-
- const stepContent = {
- 1: Step1(),
- 2: Step2(),
- 3: Step3(),
- 4: Step4(),
- 5: Step5(),
- };
-
- return (
-
-
-
Koin Ping
-
- {ProgressBar()}
-
- {error && (
-
{error}
- )}
-
- {skipWarning && (
-
{skipWarning}
- )}
-
-
- {stepContent[step]}
- {Footer()}
-
-
- {step === 1 && (
-
- Already have an account?{" "}
- Log in here
-
- )}
-
-
- );
-}
diff --git a/frontend/src/pages/Addresses.css b/frontend/src/pages/addresses/Addresses.css
similarity index 100%
rename from frontend/src/pages/Addresses.css
rename to frontend/src/pages/addresses/Addresses.css
diff --git a/frontend/src/pages/addresses/Addresses.jsx b/frontend/src/pages/addresses/Addresses.jsx
new file mode 100644
index 0000000..b0255ac
--- /dev/null
+++ b/frontend/src/pages/addresses/Addresses.jsx
@@ -0,0 +1,159 @@
+import { useState, useEffect } from "react";
+import AddressForm from "../../components/AddressForm";
+import { getAddresses, createAddress, deleteAddress, updateAddress } from "../../api/addresses";
+import "./Addresses.css";
+
+export default function Addresses() {
+ const [addresses, setAddresses] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [editingId, setEditingId] = useState(null);
+ const [editLabel, setEditLabel] = useState("");
+
+ useEffect(() => {
+ async function fetchAddresses() {
+ try {
+ setLoading(true);
+ const data = await getAddresses();
+ setAddresses(data);
+ } catch (err) {
+ setError(err.message);
+ console.error("Failed to fetch addresses:", err);
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ fetchAddresses();
+ }, []);
+
+ async function handleAddressSubmit(data) {
+ try {
+ const newAddress = await createAddress(data);
+ setAddresses((prev) => [...prev, newAddress]);
+ setError(null);
+ } catch (err) {
+ setError(err.message);
+ console.error("Failed to create address:", err);
+ }
+ }
+
+ async function handleDelete(id, label) {
+ const displayName = label || "this address";
+ if (!window.confirm(`Remove "${displayName}"? This will also delete all associated alert rules.`)) {
+ return;
+ }
+ try {
+ await deleteAddress(id);
+ setAddresses((prev) => prev.filter((a) => a.id !== id));
+ setError(null);
+ } catch (err) {
+ setError(err.message);
+ console.error("Failed to delete address:", err);
+ }
+ }
+
+ function handleEditStart(addr) {
+ setEditingId(addr.id);
+ setEditLabel(addr.label ?? "");
+ }
+
+ async function handleEditSave(id) {
+ try {
+ const updated = await updateAddress(id, { label: editLabel || null });
+ setAddresses((prev) => prev.map((a) => (a.id === id ? updated : a)));
+ setEditingId(null);
+ setEditLabel("");
+ setError(null);
+ } catch (err) {
+ setError(err.message);
+ console.error("Failed to update address:", err);
+ }
+ }
+
+ function handleEditCancel() {
+ setEditingId(null);
+ setEditLabel("");
+ }
+
+ return (
+
+
Add Addresses to Track
+
+
+
+
+
Existing Tracked Addresses
+ {loading &&
Loading addresses...
}
+ {error &&
Error: {error}
}
+ {!loading && !error && addresses.length === 0 && (
+
+ No addresses tracked yet. Add one above to get started.
+
+ )}
+ {addresses.length > 0 && (
+
+ {addresses.map((addr, index) => (
+ -
+
+
+ {editingId === addr.id ? (
+
+ setEditLabel(e.target.value)}
+ placeholder="Label (optional)"
+ className="address__edit-input"
+ onKeyDown={(e) => {
+ if (e.key === "Enter") handleEditSave(addr.id);
+ if (e.key === "Escape") handleEditCancel();
+ }}
+ autoFocus
+ />
+
+
+
+ ) : (
+
+
+ {addr.label || "Unlabeled"}
+
+
+
+ )}
+
+ {addr.address}
+
+
+
+
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/frontend/src/pages/AlertHistory.css b/frontend/src/pages/alertHistory/AlertHistory.css
similarity index 98%
rename from frontend/src/pages/AlertHistory.css
rename to frontend/src/pages/alertHistory/AlertHistory.css
index b416758..5ffa258 100644
--- a/frontend/src/pages/AlertHistory.css
+++ b/frontend/src/pages/alertHistory/AlertHistory.css
@@ -4,4 +4,4 @@
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background-color: var(--color-bg-card);
-}
+}
\ No newline at end of file
diff --git a/frontend/src/pages/alertHistory/AlertHistory.jsx b/frontend/src/pages/alertHistory/AlertHistory.jsx
new file mode 100644
index 0000000..780be73
--- /dev/null
+++ b/frontend/src/pages/alertHistory/AlertHistory.jsx
@@ -0,0 +1,67 @@
+import { useState, useEffect } from "react";
+import { getAlertEvents } from "../../api/alertEvents";
+import "./AlertHistory.css";
+
+export default function AlertHistory() {
+ const [alertEvents, setAlertEvents] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ async function fetchAlertEvents() {
+ try {
+ setLoading(true);
+ const data = await getAlertEvents();
+ setAlertEvents(data);
+ } catch (err) {
+ setError(err.message);
+ console.error("Failed to fetch alert events:", err);
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ fetchAlertEvents();
+ }, []);
+
+ if (loading) {
+ return Loading...
;
+ }
+
+ if (error) {
+ return (
+ Error: {error}
+ );
+ }
+
+ return (
+
+
Recent Alert Events
+
+ {alertEvents.length === 0 ? (
+
No alerts yet
+ ) : (
+
+ )}
+
+ );
+}
+
+function formatTimestamp(timestamp) {
+ const date = new Date(timestamp);
+ return date.toLocaleString();
+}
diff --git a/frontend/src/pages/Alerts.css b/frontend/src/pages/alerts/Alerts.css
similarity index 100%
rename from frontend/src/pages/Alerts.css
rename to frontend/src/pages/alerts/Alerts.css
diff --git a/frontend/src/pages/alerts/Alerts.jsx b/frontend/src/pages/alerts/Alerts.jsx
new file mode 100644
index 0000000..2317f80
--- /dev/null
+++ b/frontend/src/pages/alerts/Alerts.jsx
@@ -0,0 +1,522 @@
+import { useState, useEffect } from "react";
+import AlertForm from "../../components/AlertForm";
+import Button from "../../components/Button";
+import Input from "../../components/Input";
+import { getAddresses } from "../../api/addresses";
+import {
+ getAlerts,
+ createAlert,
+ updateAlertStatus,
+ deleteAlert,
+} from "../../api/alerts";
+import {
+ getNotificationConfig,
+ updateNotificationConfig,
+ testNotificationChannels,
+ setupEmail,
+ sendEmailDigest,
+} from "../../api/notificationConfig";
+import "./Alerts.css";
+
+export default function Alerts() {
+ const [addresses, setAddresses] = useState([]);
+ const [selectedAddressId, setSelectedAddressId] = useState(null);
+ const [alerts, setAlerts] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ const [notificationEnabled, setNotificationEnabled] = useState(false);
+ const [discordWebhookUrl, setDiscordWebhookUrl] = useState("");
+ const [telegramBotToken, setTelegramBotToken] = useState("");
+ const [telegramChatId, setTelegramChatId] = useState("");
+ const [email, setEmail] = useState("");
+ const [slackWebhookUrl, setSlackWebhookUrl] = useState("");
+ const [notificationLoading, setNotificationLoading] = useState(false);
+ const [notificationError, setNotificationError] = useState(null);
+ const [notificationSuccess, setNotificationSuccess] = useState(null);
+ const [testingChannels, setTestingChannels] = useState(false);
+ const [settingUpEmail, setSettingUpEmail] = useState(false);
+ const [sendingDigest, setSendingDigest] = useState(false);
+ const [hasExistingConfig, setHasExistingConfig] = useState(false);
+
+ useEffect(() => {
+ async function fetchData() {
+ try {
+ setLoading(true);
+
+ const addressData = await getAddresses();
+ setAddresses(addressData);
+ if (addressData.length > 0) {
+ setSelectedAddressId(addressData[0].id);
+ }
+
+ const configData = await getNotificationConfig();
+ setNotificationEnabled(
+ configData.notification_enabled !== false,
+ );
+ setDiscordWebhookUrl(configData.discord_webhook_url || "");
+ setTelegramBotToken(configData.telegram_bot_token || "");
+ setTelegramChatId(configData.telegram_chat_id || "");
+ setEmail(configData.email || "");
+ setSlackWebhookUrl(configData.slack_webhook_url || "");
+
+ const hasSaved =
+ !!configData.discord_webhook_url ||
+ !!configData.telegram_bot_token ||
+ !!configData.telegram_chat_id ||
+ !!configData.email ||
+ !!configData.slack_webhook_url;
+ setHasExistingConfig(hasSaved);
+ } catch (err) {
+ setError(err.message);
+ console.error("Failed to fetch data:", err);
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ fetchData();
+ }, []);
+
+ useEffect(() => {
+ if (!selectedAddressId) {
+ setAlerts([]);
+ return;
+ }
+
+ async function fetchAlerts() {
+ try {
+ const data = await getAlerts(selectedAddressId);
+ setAlerts(data);
+ setError(null);
+ } catch (err) {
+ setError(err.message);
+ console.error("Failed to fetch alerts:", err);
+ }
+ }
+
+ fetchAlerts();
+ }, [selectedAddressId]);
+
+ async function handleAlertSubmit(data) {
+ if (!selectedAddressId) return;
+
+ try {
+ const newAlert = await createAlert(selectedAddressId, data);
+ setAlerts((prev) => [...prev, newAlert]);
+ setError(null);
+ } catch (err) {
+ setError(err.message);
+ console.error("Failed to create alert:", err);
+ }
+ }
+
+ async function handleToggleAlert(alertId, currentStatus) {
+ try {
+ const updated = await updateAlertStatus(alertId, !currentStatus);
+ setAlerts((prev) =>
+ prev.map((alert) => (alert.id === alertId ? updated : alert)),
+ );
+ setError(null);
+ } catch (err) {
+ setError(err.message);
+ console.error("Failed to update alert:", err);
+ }
+ }
+
+ async function handleDeleteAlert(alertId) {
+ try {
+ await deleteAlert(alertId);
+ setAlerts((prev) => prev.filter((alert) => alert.id !== alertId));
+ setError(null);
+ } catch (err) {
+ setError(err.message);
+ console.error("Failed to delete alert:", err);
+ }
+ }
+
+ async function handleSaveNotificationConfig() {
+ if (hasExistingConfig) {
+ const confirmed = window.confirm(
+ "This will overwrite your previously saved notification settings. Continue?",
+ );
+ if (!confirmed) return;
+ }
+
+ try {
+ setNotificationLoading(true);
+ setNotificationError(null);
+ setNotificationSuccess(null);
+
+ const config = {
+ notification_enabled: notificationEnabled,
+ discord_webhook_url: discordWebhookUrl || null,
+ telegram_bot_token: telegramBotToken || null,
+ telegram_chat_id: telegramChatId || null,
+ email: email || null,
+ slack_webhook_url: slackWebhookUrl || null,
+ };
+
+ await updateNotificationConfig(config);
+ setHasExistingConfig(true);
+ setNotificationSuccess("Notification settings saved!");
+ setTimeout(() => setNotificationSuccess(null), 3000);
+ } catch (err) {
+ setNotificationError(err.message);
+ console.error("Failed to save notification config:", err);
+ } finally {
+ setNotificationLoading(false);
+ }
+ }
+
+ async function handleTestChannels() {
+ try {
+ setTestingChannels(true);
+ setNotificationError(null);
+ setNotificationSuccess(null);
+
+ const data = await testNotificationChannels();
+ const results = data.results || [];
+
+ const failed = results.filter((r) => !r.success);
+ const succeeded = results.filter((r) => r.success);
+
+ if (failed.length === 0 && succeeded.length > 0) {
+ setNotificationSuccess(
+ `Test sent to: ${succeeded.map((r) => r.channel).join(", ")}`,
+ );
+ } else if (failed.length > 0 && succeeded.length > 0) {
+ setNotificationSuccess(
+ `Sent: ${succeeded.map((r) => r.channel).join(", ")}. Failed: ${failed.map((r) => `${r.channel} (${r.error})`).join(", ")}`,
+ );
+ } else if (failed.length > 0) {
+ setNotificationError(
+ `Test failed: ${failed.map((r) => `${r.channel} (${r.error})`).join(", ")}`,
+ );
+ }
+
+ setTimeout(() => {
+ setNotificationSuccess(null);
+ setNotificationError(null);
+ }, 6000);
+ } catch (err) {
+ setNotificationError(err.message);
+ } finally {
+ setTestingChannels(false);
+ }
+ }
+
+ async function handleSetupEmail() {
+ try {
+ setSettingUpEmail(true);
+ setNotificationError(null);
+ setNotificationSuccess(null);
+
+ await handleSaveNotificationConfig();
+
+ const result = await setupEmail();
+ setNotificationSuccess(
+ result.message || "Confirmation email sent!",
+ );
+ setTimeout(() => setNotificationSuccess(null), 5000);
+ } catch (err) {
+ setNotificationError(err.message);
+ } finally {
+ setSettingUpEmail(false);
+ }
+ }
+
+ async function handleSendDigest() {
+ try {
+ setSendingDigest(true);
+ setNotificationError(null);
+ setNotificationSuccess(null);
+
+ const result = await sendEmailDigest();
+ setNotificationSuccess(result.message || "Digest email sent!");
+ setTimeout(() => setNotificationSuccess(null), 5000);
+ } catch (err) {
+ setNotificationError(err.message);
+ } finally {
+ setSendingDigest(false);
+ }
+ }
+
+ const selectedAddress = addresses.find((a) => a.id === selectedAddressId);
+
+ if (loading) {
+ return Loading addresses...
;
+ }
+
+ if (addresses.length === 0) {
+ return (
+
+
No addresses tracked yet. Add an address first to create alerts.
+
+ );
+ }
+
+ return (
+
+
Alert Rules & Notifications
+
+
+ {/* LEFT COLUMN: Alert Rules */}
+
+
Alert Rules
+
+
+
+
+
+
+ {selectedAddress && (
+ <>
+
+
+ Managing alerts for:
+
+
+ {selectedAddress.label || "Unlabeled"}
+
+
+ {selectedAddress.address}
+
+
+
+
+
+
+
Active Alert Rules
+ {error && (
+
Error: {error}
+ )}
+ {alerts.length === 0 ? (
+
+ No alert rules defined yet. Create one above.
+
+ ) : (
+
+ {alerts.map((alert) => (
+ -
+
+
+
+ {formatAlertType(alert.type)}
+
+ {alert.threshold && (
+
+ Threshold: {alert.threshold} ETH
+
+ )}
+
+ Status: {alert.enabled ? "Enabled" : "Disabled"}
+
+
+
+
+
+
+
+
+ ))}
+
+ )}
+
+ >
+ )}
+
+
+ {/* RIGHT COLUMN: Notification Settings */}
+
+
Notification Settings
+
+ {notificationSuccess && (
+
{notificationSuccess}
+ )}
+
+ {notificationError && (
+
{notificationError}
+ )}
+
+ {/* Master toggle */}
+
+
+
+ Master switch for all notification channels
+
+
+
+ {/* All channel settings -- hidden when master toggle is off */}
+ {notificationEnabled && (
+ <>
+ {/* Telegram */}
+
+
+ {/* Email */}
+
+
Email
+
+
+ Alert notifications and digests will be sent to this address
+
+
+
+
+
+
+
+ {/* Discord */}
+
+
+ {/* Slack */}
+
+
+ {/* Save & Test buttons */}
+
+
+
+
+ >
+ )}
+
+
+
+ );
+}
+
+function formatAlertType(type) {
+ const labels = {
+ incoming_tx: "Incoming transaction",
+ outgoing_tx: "Outgoing transaction",
+ large_transfer: "Large transfer",
+ balance_below: "Balance below threshold",
+ };
+ return labels[type] || type;
+}
diff --git a/frontend/src/pages/Login.css b/frontend/src/pages/login/Login.css
similarity index 100%
rename from frontend/src/pages/Login.css
rename to frontend/src/pages/login/Login.css
diff --git a/frontend/src/pages/Login.jsx b/frontend/src/pages/login/Login.jsx
similarity index 95%
rename from frontend/src/pages/Login.jsx
rename to frontend/src/pages/login/Login.jsx
index 24d1e0d..9111861 100644
--- a/frontend/src/pages/Login.jsx
+++ b/frontend/src/pages/login/Login.jsx
@@ -1,7 +1,7 @@
import { useState } from "react";
import { Link, useNavigate } from "react-router-dom";
-import { useAuth } from "../contexts/AuthContext";
-import Input from "../components/Input";
+import { useAuth } from "../../contexts/AuthContext";
+import Input from "../../components/Input";
import "./Login.css";
export default function Login() {
diff --git a/frontend/src/pages/Onboarding.css b/frontend/src/pages/subscribe/subscribe.css
similarity index 93%
rename from frontend/src/pages/Onboarding.css
rename to frontend/src/pages/subscribe/subscribe.css
index b15e8a8..d8e4e81 100644
--- a/frontend/src/pages/Onboarding.css
+++ b/frontend/src/pages/subscribe/subscribe.css
@@ -1,4 +1,4 @@
-.onboarding {
+.subscribe {
min-height: 100vh;
background-color: var(--color-bg);
display: flex;
@@ -9,34 +9,34 @@
padding-bottom: 3rem;
}
-.onboarding__container {
+.subscribe__container {
width: 100%;
max-width: 540px;
padding: 0 1rem;
}
-.onboarding__title {
+.subscribe__title {
text-align: center;
margin-bottom: 2rem;
color: var(--color-primary);
letter-spacing: 0.5px;
}
-.onboarding__card {
+.subscribe__card {
background-color: var(--color-bg-elevated);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-xl);
padding: 2rem;
}
-.onboarding__login-link {
+.subscribe__login-link {
text-align: center;
margin-top: 1.25rem;
color: #888;
font-size: 0.9rem;
}
-.onboarding__login-link a {
+.subscribe__login-link a {
color: var(--color-primary);
}
@@ -108,7 +108,7 @@
}
/* Step 5 summary */
-.onboarding__summary {
+.subscribe__summary {
background-color: #1e2e1e;
border: 1px solid #2d5a2d;
border-radius: var(--radius-lg);
@@ -116,13 +116,13 @@
margin-bottom: 1.5rem;
}
-.onboarding__summary-title {
+.subscribe__summary-title {
margin: 0 0 0.5rem;
color: #90ee90;
font-weight: bold;
}
-.onboarding__summary-list {
+.subscribe__summary-list {
margin: 0;
padding-left: 1.25rem;
color: var(--color-text-label);
@@ -154,7 +154,7 @@
}
/* Footer navigation */
-.onboarding__footer {
+.subscribe__footer {
display: flex;
justify-content: space-between;
align-items: center;
@@ -178,7 +178,7 @@
}
/* Step subtitle */
-.onboarding__subtitle {
+.subscribe__subtitle {
color: #aaa;
margin-bottom: 1.5rem;
font-size: 0.9rem;
@@ -235,4 +235,4 @@
font-size: 0.8rem;
margin: 0;
font-style: italic;
-}
+}
\ No newline at end of file
diff --git a/frontend/src/pages/subscribe/subscribe.jsx b/frontend/src/pages/subscribe/subscribe.jsx
new file mode 100644
index 0000000..3fdd3a7
--- /dev/null
+++ b/frontend/src/pages/subscribe/subscribe.jsx
@@ -0,0 +1,679 @@
+/**
+ * Subscribe / Subscribe Wizard
+ *
+ * 5-step guided flow: Create Account -> Add Wallet -> Alert Rules -> Notifications -> Done
+ * After account creation, user is redirected to Stripe Checkout for payment.
+ * On successful payment, they return here at step 2 (Add Wallet).
+ */
+
+import { useState, useEffect } from "react";
+import { useNavigate, useSearchParams } 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";
+import { createCheckoutSession, getSubscriptionStatus, verifyCheckoutSession } from "../../api/stripe";
+import Input from "../../components/Input";
+import Button from "../../components/Button";
+import "./Subscribe.css";
+
+const STEPS = [
+ "Create Account",
+ "Add Wallet",
+ "Alert Rules",
+ "Notifications",
+ "Done",
+];
+
+export default function Subscribe() {
+ const { currentUser, signup } = useAuth();
+ const navigate = useNavigate();
+ const [searchParams, setSearchParams] = useSearchParams();
+
+ 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);
+
+ const [data, setData] = useState({
+ email: "",
+ password: "",
+ confirmPassword: "",
+ walletAddress: "",
+ walletLabel: "",
+ createdAddressId: null,
+ alertIncomingTx: false,
+ alertOutgoingTx: false,
+ alertLargeTransfer: false,
+ largeTransferThreshold: "",
+ alertBalanceBelow: false,
+ balanceBelowThreshold: "",
+ discordWebhookUrl: "",
+ slackWebhookUrl: "",
+ notificationEmail: "",
+ alertsCreated: [],
+ notificationConfigured: false,
+ });
+
+ function set(field, value) {
+ setData((prev) => ({ ...prev, [field]: value }));
+ }
+
+ useEffect(() => {
+ if (!currentUser) return;
+ getAddresses()
+ .then((addresses) => {
+ if (addresses.length > 0) {
+ navigate("/addresses", { replace: true });
+ }
+ })
+ .catch(() => { });
+ }, [currentUser, navigate]);
+
+ // Handle Stripe redirect back from checkout
+ useEffect(() => {
+ if (!currentUser) return;
+ const payment = searchParams.get("payment");
+ const sessionId = searchParams.get("session_id");
+ if (payment === "success" && sessionId) {
+ setSearchParams({}, { replace: true });
+ setLoading(true);
+ verifyCheckoutSession(sessionId)
+ .then(() => {
+ setStep(2);
+ })
+ .catch((err) => {
+ setError("Payment verification failed: " + err.message);
+ setStep(1);
+ })
+ .finally(() => setLoading(false));
+ } else if (payment === "cancelled") {
+ setSearchParams({}, { replace: true });
+ setStep(1);
+ setError("Payment was cancelled. Please try again.");
+ }
+ }, [currentUser, searchParams, setSearchParams]);
+
+ // ── 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;
+ }
+ try {
+ setLoading(true);
+ if (!currentUser) {
+ await signup(data.email, data.password);
+ }
+ const status = await getSubscriptionStatus();
+ if (status.subscription_status === "active" || status.subscription_status === "trialing") {
+ setStep(2);
+ return;
+ }
+ const { url } = await createCheckoutSession();
+ window.location.href = url;
+ } 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);
+ }
+ }
+
+ 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;
+ const dotClass = done
+ ? "progress-bar__dot--done"
+ : active
+ ? "progress-bar__dot--active"
+ : "progress-bar__dot--pending";
+
+ return (
+
+ {i > 0 && (
+
+ )}
+
+
+ {done ? "\u2713" : stepNum}
+
+
+ {label}
+
+
+
+ );
+ })}
+
+ );
+ }
+
+ // ── Step content ──────────────────────────────────────────────────────────
+
+ function Step1() {
+ return (
+ <>
+ Create your account
+ set("email", v)}
+ disabled={loading}
+ placeholder="you@example.com"
+ />
+ set("password", v)}
+ disabled={loading}
+ placeholder="At least 6 characters"
+ />
+ set("confirmPassword", v)}
+ disabled={loading}
+ placeholder="Repeat your password"
+ className="form-field--last"
+ />
+ >
+ );
+ }
+
+ function Step2() {
+ return (
+ <>
+ Add a wallet address
+
+ Enter the Ethereum address you want to monitor.
+
+ set("walletAddress", v)}
+ disabled={loading}
+ placeholder="0x..."
+ />
+ set("walletLabel", v)}
+ disabled={loading}
+ placeholder="e.g. My main wallet"
+ className="form-field--last"
+ />
+ >
+ );
+ }
+
+ 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", v)}
+ placeholder="Threshold (ETH)"
+ min="0"
+ step="0.01"
+ />
+
+ )}
+
+ set("alertBalanceBelow", v)}
+ label="Balance below"
+ >
+ {data.alertBalanceBelow && (
+
+ set("balanceBelowThreshold", v)}
+ 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", v)}
+ disabled={loading}
+ placeholder="https://discord.com/api/webhooks/..."
+ />
+
+
+
+
+
set("slackWebhookUrl", v)}
+ disabled={loading}
+ placeholder="https://hooks.slack.com/services/..."
+ />
+
+
+ set("notificationEmail", v)}
+ disabled={loading}
+ placeholder="you@example.com"
+ className="form-field--last"
+ />
+ >
+ );
+ }
+
+ 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 ? "\u2713" : "\u2717"} {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 > 2;
+
+ 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);
+ }
+
+ const nextLabel = step === 1
+ ? "Create Account & Subscribe"
+ : step === 4
+ ? "Finish"
+ : "Next →";
+
+ return (
+
+
+ {canBack && (
+
+ )}
+
+
+
+ {canSkip && (
+
+ )}
+
+
+
+ );
+ }
+
+ // ── Render ────────────────────────────────────────────────────────────────
+
+ const stepContent = {
+ 1: Step1(),
+ 2: Step2(),
+ 3: Step3(),
+ 4: Step4(),
+ 5: Step5(),
+ };
+
+ return (
+
+
+
Koin Ping
+
+ {ProgressBar()}
+
+ {error && (
+
{error}
+ )}
+
+ {skipWarning && (
+
{skipWarning}
+ )}
+
+
+ {stepContent[step]}
+ {Footer()}
+
+
+ {step === 1 && (
+
+ Already have an account?{" "}
+ Log in here
+
+ )}
+
+
+ );
+}
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json
index df8c471..00196df 100644
--- a/frontend/tsconfig.json
+++ b/frontend/tsconfig.json
@@ -1,24 +1,28 @@
{
- "compilerOptions": {
- "target": "ES2020",
- "useDefineForClassFields": true,
- "lib": ["ES2020", "DOM", "DOM.Iterable"],
- "module": "ESNext",
- "skipLibCheck": true,
-
- /* Bundler mode */
- "moduleResolution": "bundler",
- "allowImportingTsExtensions": true,
- "resolveJsonModule": true,
- "isolatedModules": true,
- "noEmit": true,
- "jsx": "react-jsx",
-
- /* Linting */
- "strict": true,
- "noUnusedLocals": true,
- "noUnusedParameters": true,
- "noFallthroughCasesInSwitch": true
- },
- "include": ["src"]
-}
+ "compilerOptions": {
+ "target": "ES2020",
+ "useDefineForClassFields": true,
+ "lib": [
+ "ES2020",
+ "DOM",
+ "DOM.Iterable"
+ ],
+ "module": "ESNext",
+ "skipLibCheck": true,
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx",
+ /* Linting */
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true
+ },
+ "include": [
+ "src"
+ ]
+}
\ No newline at end of file