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/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 && ( - - )} -
-
- ); -} 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} -
-
- -
-

Create New Alert

- -
- -
-

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 */} -
-

Telegram

- - - - How to create a Telegram bot & get your Chat ID - -
- - {/* Email */} -
-

Email

- -
- Alert notifications and digests will be sent to this address -
-
- - -
-
- - {/* Discord */} -
-

Discord

- - - How to get a Discord webhook URL - -
- - {/* Slack */} -
-

Slack

- - - How to set up Slack Incoming Webhooks - -
- - {/* 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

+ ) : ( +
    + {alertEvents.map((event) => ( +
  • +
    {event.message}
    + {event.address_label && ( +
    + Address: {event.address_label} +
    + )} + + {formatTimestamp(event.timestamp)} + +
  • + ))} +
+ )} +
+ ); +} + +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} +
+
+ +
+

Create New Alert

+ +
+ +
+

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