Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2bc06669e9 | ||
|
|
a2d1ff9d76 | ||
|
|
dcbf0756d2 |
@@ -86,6 +86,8 @@ func main() {
|
|||||||
authenticate(http.HandlerFunc(stripeHandler.CreateCheckoutSession)))
|
authenticate(http.HandlerFunc(stripeHandler.CreateCheckoutSession)))
|
||||||
mux.Handle("GET "+b+"/stripe/subscription-status",
|
mux.Handle("GET "+b+"/stripe/subscription-status",
|
||||||
authenticate(http.HandlerFunc(stripeHandler.GetSubscriptionStatus)))
|
authenticate(http.HandlerFunc(stripeHandler.GetSubscriptionStatus)))
|
||||||
|
mux.Handle("POST "+b+"/stripe/verify-checkout",
|
||||||
|
authenticate(http.HandlerFunc(stripeHandler.VerifyCheckoutSession)))
|
||||||
|
|
||||||
// Authenticated + subscribed routes — addresses
|
// Authenticated + subscribed routes — addresses
|
||||||
mux.Handle("POST "+b+"/addresses",
|
mux.Handle("POST "+b+"/addresses",
|
||||||
|
|||||||
@@ -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.
|
// HandleWebhook processes incoming Stripe webhook events.
|
||||||
// This endpoint must NOT require authentication (Stripe calls it directly).
|
// This endpoint must NOT require authentication (Stripe calls it directly).
|
||||||
func (h *StripeHandler) HandleWebhook(w http.ResponseWriter, r *http.Request) {
|
func (h *StripeHandler) HandleWebhook(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -1,38 +1,38 @@
|
|||||||
import { Routes, Route, Navigate } from "react-router-dom";
|
import { Routes, Route, Navigate } from "react-router-dom";
|
||||||
import { useAuth } from "./contexts/AuthContext";
|
import { useAuth } from "./contexts/AuthContext";
|
||||||
import Navbar from "./components/Navbar";
|
import Navbar from "./components/Navbar";
|
||||||
import Login from "./pages/Login";
|
import Login from "./pages/login/Login";
|
||||||
import Signup from "./pages/Signup";
|
import Signup from "./pages/Signup";
|
||||||
import Onboarding from "./pages/Onboarding";
|
import Subscribe from "./pages/subscribe/subscribe";
|
||||||
import Addresses from "./pages/Addresses";
|
import Addresses from "./pages/addresses/Addresses";
|
||||||
import Alerts from "./pages/Alerts";
|
import Alerts from "./pages/alerts/Alerts";
|
||||||
import AlertHistory from "./pages/AlertHistory";
|
import AlertHistory from "./pages/alertHistory/AlertHistory";
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { currentUser } = useAuth();
|
const { currentUser } = useAuth();
|
||||||
|
|
||||||
if (!currentUser) {
|
|
||||||
return (
|
|
||||||
<Routes>
|
|
||||||
<Route path="/login" element={<Login />} />
|
|
||||||
<Route path="/signup" element={<Signup />} />
|
|
||||||
<Route path="/subscribe" element={<Onboarding />} />
|
|
||||||
<Route path="*" element={<Navigate to="/login" />} />
|
|
||||||
</Routes>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if (!currentUser) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<Routes>
|
||||||
<Navbar />
|
<Route path="/login" element={<Login />} />
|
||||||
<Routes>
|
<Route path="/signup" element={<Signup />} />
|
||||||
<Route path="/" element={<Addresses />} />
|
<Route path="/subscribe" element={<Subscribe />} />
|
||||||
<Route path="/addresses" element={<Addresses />} />
|
<Route path="*" element={<Navigate to="/login" />} />
|
||||||
<Route path="/alerts" element={<Alerts />} />
|
</Routes>
|
||||||
<Route path="/alertevents" element={<AlertHistory />} />
|
|
||||||
<Route path="/subscribe" element={<Onboarding />} />
|
|
||||||
<Route path="*" element={<Navigate to="/addresses" />} />
|
|
||||||
</Routes>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Navbar />
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Addresses />} />
|
||||||
|
<Route path="/addresses" element={<Addresses />} />
|
||||||
|
<Route path="/alerts" element={<Alerts />} />
|
||||||
|
<Route path="/alertevents" element={<AlertHistory />} />
|
||||||
|
<Route path="/subscribe" element={<Subscribe />} />
|
||||||
|
<Route path="*" element={<Navigate to="/addresses" />} />
|
||||||
|
</Routes>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,20 @@ export async function createCheckoutSession() {
|
|||||||
return res.json();
|
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() {
|
export async function getSubscriptionStatus() {
|
||||||
const headers = await getAuthHeaders();
|
const headers = await getAuthHeaders();
|
||||||
const res = await fetch(`${API_BASE}/stripe/subscription-status`, {
|
const res = await fetch(`${API_BASE}/stripe/subscription-status`, {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import "./Navbar.css";
|
|||||||
const navLinks = [
|
const navLinks = [
|
||||||
{ to: "/addresses", label: "Addresses" },
|
{ to: "/addresses", label: "Addresses" },
|
||||||
{ to: "/alerts", label: "Configure Alerts" },
|
{ to: "/alerts", label: "Configure Alerts" },
|
||||||
{ to: "/alertevents", label: "Alert Events" },
|
{ to: "/alertevents", label: "Alert Event History" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function Navbar() {
|
export default function Navbar() {
|
||||||
|
|||||||
@@ -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 (
|
|
||||||
<div className="page">
|
|
||||||
<h1>Add Addresses to Track</h1>
|
|
||||||
|
|
||||||
<div className="mb-xl">
|
|
||||||
<AddressForm onSubmit={handleAddressSubmit} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h2>Existing Tracked Addresses</h2>
|
|
||||||
{loading && <p>Loading addresses...</p>}
|
|
||||||
{error && <p className="text-error">Error: {error}</p>}
|
|
||||||
{!loading && !error && addresses.length === 0 && (
|
|
||||||
<p className="text-dimmed">
|
|
||||||
No addresses tracked yet. Add one above to get started.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{addresses.length > 0 && (
|
|
||||||
<ul className="list-unstyled">
|
|
||||||
{addresses.map((addr, index) => (
|
|
||||||
<li key={addr.id || index} className="list-item--card">
|
|
||||||
<div className="flex flex--between flex--center">
|
|
||||||
<div>
|
|
||||||
{editingId === addr.id ? (
|
|
||||||
<div className="flex flex--center gap-sm mb-sm">
|
|
||||||
<input
|
|
||||||
value={editLabel}
|
|
||||||
onChange={(e) => 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
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={() => handleEditSave(addr.id)}
|
|
||||||
className="btn btn--primary btn--sm"
|
|
||||||
>
|
|
||||||
Save
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleEditCancel}
|
|
||||||
className="btn btn--ghost btn--sm"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex--center gap-sm mb-sm">
|
|
||||||
<span className="text-bold">
|
|
||||||
{addr.label || "Unlabeled"}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
onClick={() => handleEditStart(addr)}
|
|
||||||
className="address__edit-link"
|
|
||||||
>
|
|
||||||
Edit
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="text-mono text-sm text-muted">
|
|
||||||
{addr.address}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => handleDelete(addr.id, addr.label)}
|
|
||||||
className="address__remove"
|
|
||||||
>
|
|
||||||
Remove
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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 <div className="page">Loading...</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<div className="page text-error">Error: {error}</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="page">
|
|
||||||
<h1>Recent Alert Events</h1>
|
|
||||||
|
|
||||||
{alertEvents.length === 0 ? (
|
|
||||||
<p className="text-dimmed">No alerts yet</p>
|
|
||||||
) : (
|
|
||||||
<ul className="list-unstyled">
|
|
||||||
{alertEvents.map((event) => (
|
|
||||||
<li key={event.id} className="alert-history__item">
|
|
||||||
<div className="mb-sm">{event.message}</div>
|
|
||||||
{event.address_label && (
|
|
||||||
<div className="text-sm text-dimmed">
|
|
||||||
Address: {event.address_label}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<small className="text-muted">
|
|
||||||
{formatTimestamp(event.timestamp)}
|
|
||||||
</small>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTimestamp(timestamp) {
|
|
||||||
const date = new Date(timestamp);
|
|
||||||
return date.toLocaleString();
|
|
||||||
}
|
|
||||||
@@ -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 <div className="page">Loading addresses...</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (addresses.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="page">
|
|
||||||
<p>No addresses tracked yet. Add an address first to create alerts.</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="page page--wide">
|
|
||||||
<h1 className="mb-xl">Alert Rules & Notifications</h1>
|
|
||||||
|
|
||||||
<div className="alerts-grid">
|
|
||||||
{/* LEFT COLUMN: Alert Rules */}
|
|
||||||
<div>
|
|
||||||
<h2 className="mt-0">Alert Rules</h2>
|
|
||||||
|
|
||||||
<div className="mb-xl">
|
|
||||||
<label className="form-label mb-sm">
|
|
||||||
<strong>Select Address:</strong>
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={selectedAddressId || ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
setSelectedAddressId(Number(e.target.value))
|
|
||||||
}
|
|
||||||
className="form-select"
|
|
||||||
>
|
|
||||||
{addresses.map((addr) => (
|
|
||||||
<option key={addr.id} value={addr.id}>
|
|
||||||
{addr.label || "Unlabeled"} - {addr.address}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{selectedAddress && (
|
|
||||||
<>
|
|
||||||
<div className="alerts__address-info">
|
|
||||||
<div className="text-sm text-muted">
|
|
||||||
Managing alerts for:
|
|
||||||
</div>
|
|
||||||
<div className="text-bold">
|
|
||||||
{selectedAddress.label || "Unlabeled"}
|
|
||||||
</div>
|
|
||||||
<div className="text-mono text-sm text-muted">
|
|
||||||
{selectedAddress.address}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-xl">
|
|
||||||
<h3>Create New Alert</h3>
|
|
||||||
<AlertForm onSubmit={handleAlertSubmit} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h3>Active Alert Rules</h3>
|
|
||||||
{error && (
|
|
||||||
<p className="text-error">Error: {error}</p>
|
|
||||||
)}
|
|
||||||
{alerts.length === 0 ? (
|
|
||||||
<p className="text-dimmed">
|
|
||||||
No alert rules defined yet. Create one above.
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<ul className="list-unstyled">
|
|
||||||
{alerts.map((alert) => (
|
|
||||||
<li
|
|
||||||
key={alert.id}
|
|
||||||
className={`alerts__rule ${!alert.enabled ? "alerts__rule--disabled" : ""}`}
|
|
||||||
>
|
|
||||||
<div className="flex flex--between">
|
|
||||||
<div>
|
|
||||||
<div className="text-bold mb-sm">
|
|
||||||
{formatAlertType(alert.type)}
|
|
||||||
</div>
|
|
||||||
{alert.threshold && (
|
|
||||||
<div className="text-sm text-muted">
|
|
||||||
Threshold: {alert.threshold} ETH
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="text-xs text-dimmed">
|
|
||||||
Status: {alert.enabled ? "Enabled" : "Disabled"}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex--center gap-sm">
|
|
||||||
<Button
|
|
||||||
onClick={() =>
|
|
||||||
handleToggleAlert(alert.id, alert.enabled)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{alert.enabled ? "Disable" : "Enable"}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => handleDeleteAlert(alert.id)}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* RIGHT COLUMN: Notification Settings */}
|
|
||||||
<div>
|
|
||||||
<h2 className="mt-0">Notification Settings</h2>
|
|
||||||
|
|
||||||
{notificationSuccess && (
|
|
||||||
<div className="alert alert--success">{notificationSuccess}</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{notificationError && (
|
|
||||||
<div className="alert alert--error">{notificationError}</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Master toggle */}
|
|
||||||
<div className="alerts__toggle-panel">
|
|
||||||
<label className="alerts__toggle-label">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={notificationEnabled}
|
|
||||||
onChange={(e) =>
|
|
||||||
setNotificationEnabled(e.target.checked)
|
|
||||||
}
|
|
||||||
className="alerts__toggle-checkbox"
|
|
||||||
/>
|
|
||||||
<span className="text-bold">Enable Notifications</span>
|
|
||||||
</label>
|
|
||||||
<div className="alerts__toggle-hint">
|
|
||||||
Master switch for all notification channels
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* All channel settings -- hidden when master toggle is off */}
|
|
||||||
{notificationEnabled && (
|
|
||||||
<>
|
|
||||||
{/* Telegram */}
|
|
||||||
<div className="section">
|
|
||||||
<h3 className="mt-0 mb-md">Telegram</h3>
|
|
||||||
<Input
|
|
||||||
label="Bot Token"
|
|
||||||
value={telegramBotToken}
|
|
||||||
onChange={setTelegramBotToken}
|
|
||||||
placeholder="123456789:ABCdefGHIjklMNOpqrSTUvwxYZ"
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label="Chat ID"
|
|
||||||
value={telegramChatId}
|
|
||||||
onChange={setTelegramChatId}
|
|
||||||
placeholder="-1001234567890"
|
|
||||||
/>
|
|
||||||
<a
|
|
||||||
href="https://core.telegram.org/bots#how-do-i-create-a-bot"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="help-link"
|
|
||||||
>
|
|
||||||
How to create a Telegram bot & get your Chat ID
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Email */}
|
|
||||||
<div className="section">
|
|
||||||
<h3 className="mt-0 mb-md">Email</h3>
|
|
||||||
<Input
|
|
||||||
label="Email Address"
|
|
||||||
type="email"
|
|
||||||
value={email}
|
|
||||||
onChange={setEmail}
|
|
||||||
placeholder="you@example.com"
|
|
||||||
/>
|
|
||||||
<div className="help-link text-dimmed mb-md">
|
|
||||||
Alert notifications and digests will be sent to this address
|
|
||||||
</div>
|
|
||||||
<div className="alerts__email-buttons">
|
|
||||||
<Button
|
|
||||||
onClick={handleSetupEmail}
|
|
||||||
disabled={settingUpEmail || !email}
|
|
||||||
className="btn--sm"
|
|
||||||
>
|
|
||||||
{settingUpEmail ? "Setting up..." : "Verify Email"}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={handleSendDigest}
|
|
||||||
disabled={sendingDigest || !email}
|
|
||||||
variant="secondary"
|
|
||||||
className="btn--sm"
|
|
||||||
>
|
|
||||||
{sendingDigest ? "Sending..." : "Send Digest Now"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Discord */}
|
|
||||||
<div className="section">
|
|
||||||
<h3 className="mt-0 mb-md">Discord</h3>
|
|
||||||
<Input
|
|
||||||
label="Discord Webhook URL"
|
|
||||||
value={discordWebhookUrl}
|
|
||||||
onChange={setDiscordWebhookUrl}
|
|
||||||
placeholder="https://discord.com/api/webhooks/..."
|
|
||||||
/>
|
|
||||||
<a
|
|
||||||
href="https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="help-link"
|
|
||||||
>
|
|
||||||
How to get a Discord webhook URL
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Slack */}
|
|
||||||
<div className="section">
|
|
||||||
<h3 className="mt-0 mb-md">Slack</h3>
|
|
||||||
<Input
|
|
||||||
label="Slack Webhook URL"
|
|
||||||
value={slackWebhookUrl}
|
|
||||||
onChange={setSlackWebhookUrl}
|
|
||||||
placeholder="https://hooks.slack.com/services/..."
|
|
||||||
/>
|
|
||||||
<a
|
|
||||||
href="https://api.slack.com/messaging/webhooks"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="help-link"
|
|
||||||
>
|
|
||||||
How to set up Slack Incoming Webhooks
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Save & Test buttons */}
|
|
||||||
<div className="alerts__save-test">
|
|
||||||
<Button
|
|
||||||
onClick={handleSaveNotificationConfig}
|
|
||||||
disabled={notificationLoading}
|
|
||||||
className="btn--lg"
|
|
||||||
>
|
|
||||||
{notificationLoading ? "Saving..." : "Save Settings"}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={handleTestChannels}
|
|
||||||
disabled={testingChannels}
|
|
||||||
variant="success"
|
|
||||||
className="btn--lg"
|
|
||||||
>
|
|
||||||
{testingChannels ? "Testing..." : "Test All Channels"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
@@ -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 (
|
|
||||||
<div className="progress-bar">
|
|
||||||
{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 (
|
|
||||||
<div key={label} className="progress-bar__step">
|
|
||||||
{i > 0 && (
|
|
||||||
<div
|
|
||||||
className={`progress-bar__connector ${
|
|
||||||
done || active
|
|
||||||
? "progress-bar__connector--active"
|
|
||||||
: "progress-bar__connector--inactive"
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div>
|
|
||||||
<div className={`progress-bar__dot ${dotClass}`}>
|
|
||||||
{done ? "\u2713" : stepNum}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={`progress-bar__label ${
|
|
||||||
active
|
|
||||||
? "progress-bar__label--active"
|
|
||||||
: "progress-bar__label--inactive"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Step content ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function Step1() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<h2 className="mb-lg">Create your account</h2>
|
|
||||||
<Input
|
|
||||||
label="Email"
|
|
||||||
type="email"
|
|
||||||
value={data.email}
|
|
||||||
onChange={(v) => set("email", v)}
|
|
||||||
disabled={loading}
|
|
||||||
placeholder="you@example.com"
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label="Password"
|
|
||||||
type="password"
|
|
||||||
value={data.password}
|
|
||||||
onChange={(v) => set("password", v)}
|
|
||||||
disabled={loading}
|
|
||||||
placeholder="At least 6 characters"
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label="Confirm Password"
|
|
||||||
type="password"
|
|
||||||
value={data.confirmPassword}
|
|
||||||
onChange={(v) => set("confirmPassword", v)}
|
|
||||||
disabled={loading}
|
|
||||||
placeholder="Repeat your password"
|
|
||||||
className="form-field--last"
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Step2() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<h2 className="mb-sm">Add a wallet address</h2>
|
|
||||||
<p className="onboarding__subtitle">
|
|
||||||
Enter the Ethereum address you want to monitor.
|
|
||||||
</p>
|
|
||||||
<Input
|
|
||||||
label="ETH Address"
|
|
||||||
value={data.walletAddress}
|
|
||||||
onChange={(v) => set("walletAddress", v)}
|
|
||||||
disabled={loading}
|
|
||||||
placeholder="0x..."
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label="Label (optional)"
|
|
||||||
value={data.walletLabel}
|
|
||||||
onChange={(v) => set("walletLabel", v)}
|
|
||||||
disabled={loading}
|
|
||||||
placeholder="e.g. My main wallet"
|
|
||||||
className="form-field--last"
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Step3() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<h2 className="mb-sm">Configure alert rules</h2>
|
|
||||||
<p className="onboarding__subtitle">
|
|
||||||
Choose which events trigger notifications. You can change these later.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<CheckboxRow
|
|
||||||
checked={data.alertIncomingTx}
|
|
||||||
onChange={(v) => set("alertIncomingTx", v)}
|
|
||||||
label="Incoming transaction"
|
|
||||||
/>
|
|
||||||
<CheckboxRow
|
|
||||||
checked={data.alertOutgoingTx}
|
|
||||||
onChange={(v) => set("alertOutgoingTx", v)}
|
|
||||||
label="Outgoing transaction"
|
|
||||||
/>
|
|
||||||
<CheckboxRow
|
|
||||||
checked={data.alertLargeTransfer}
|
|
||||||
onChange={(v) => set("alertLargeTransfer", v)}
|
|
||||||
label="Large transfer"
|
|
||||||
>
|
|
||||||
{data.alertLargeTransfer && (
|
|
||||||
<div className="checkbox-row__nested">
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
label=""
|
|
||||||
value={data.largeTransferThreshold}
|
|
||||||
onChange={(v) => set("largeTransferThreshold", v)}
|
|
||||||
placeholder="Threshold (ETH)"
|
|
||||||
min="0"
|
|
||||||
step="0.01"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CheckboxRow>
|
|
||||||
<CheckboxRow
|
|
||||||
checked={data.alertBalanceBelow}
|
|
||||||
onChange={(v) => set("alertBalanceBelow", v)}
|
|
||||||
label="Balance below"
|
|
||||||
>
|
|
||||||
{data.alertBalanceBelow && (
|
|
||||||
<div className="checkbox-row__nested">
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
label=""
|
|
||||||
value={data.balanceBelowThreshold}
|
|
||||||
onChange={(v) => set("balanceBelowThreshold", v)}
|
|
||||||
placeholder="Threshold (ETH)"
|
|
||||||
min="0"
|
|
||||||
step="0.01"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CheckboxRow>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Step4() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<h2 className="mb-sm">Set up notifications</h2>
|
|
||||||
<p className="onboarding__subtitle">
|
|
||||||
Add at least one channel so you receive alerts. All fields are optional.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="mb-md">
|
|
||||||
<label className="form-label">
|
|
||||||
Discord Webhook URL{" "}
|
|
||||||
<a
|
|
||||||
href="https://support.discord.com/hc/en-us/articles/228383668"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="help-link"
|
|
||||||
>
|
|
||||||
(how to get one)
|
|
||||||
</a>
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
label=""
|
|
||||||
type="url"
|
|
||||||
value={data.discordWebhookUrl}
|
|
||||||
onChange={(v) => set("discordWebhookUrl", v)}
|
|
||||||
disabled={loading}
|
|
||||||
placeholder="https://discord.com/api/webhooks/..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-md">
|
|
||||||
<label className="form-label">
|
|
||||||
Slack Webhook URL{" "}
|
|
||||||
<a
|
|
||||||
href="https://api.slack.com/messaging/webhooks"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="help-link"
|
|
||||||
>
|
|
||||||
(how to get one)
|
|
||||||
</a>
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
label=""
|
|
||||||
type="url"
|
|
||||||
value={data.slackWebhookUrl}
|
|
||||||
onChange={(v) => set("slackWebhookUrl", v)}
|
|
||||||
disabled={loading}
|
|
||||||
placeholder="https://hooks.slack.com/services/..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
label="Email address for alerts"
|
|
||||||
type="email"
|
|
||||||
value={data.notificationEmail}
|
|
||||||
onChange={(v) => 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 (
|
|
||||||
<>
|
|
||||||
<h2 className="mb-md">You're all set!</h2>
|
|
||||||
|
|
||||||
<div className="onboarding__summary">
|
|
||||||
<p className="onboarding__summary-title">Summary</p>
|
|
||||||
<ul className="onboarding__summary-list">
|
|
||||||
<li>
|
|
||||||
Wallet address added:{" "}
|
|
||||||
<span className="text-mono text-white-sm">
|
|
||||||
{data.walletAddress}
|
|
||||||
</span>
|
|
||||||
{data.walletLabel && ` (${data.walletLabel})`}
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Alert rules configured:{" "}
|
|
||||||
<span className="text-white">
|
|
||||||
{alertCount > 0 ? `${alertCount} rule${alertCount !== 1 ? "s" : ""}` : "None (skipped)"}
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Notification channels:{" "}
|
|
||||||
<span className="text-white">
|
|
||||||
{hasNotif ? "Configured" : "Not set up (skipped)"}
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{hasNotif && (
|
|
||||||
<div className="mb-lg">
|
|
||||||
<Button
|
|
||||||
onClick={handleTestChannels}
|
|
||||||
disabled={testLoading}
|
|
||||||
variant="ghost"
|
|
||||||
>
|
|
||||||
{testLoading ? "Testing..." : "Test All Channels"}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{testResults && (
|
|
||||||
<div className="mt-md">
|
|
||||||
{testResults.error ? (
|
|
||||||
<p className="text-error">{testResults.error}</p>
|
|
||||||
) : (
|
|
||||||
<ul className="list-unstyled">
|
|
||||||
{Object.entries(testResults).map(([channel, result]) => (
|
|
||||||
<li
|
|
||||||
key={channel}
|
|
||||||
className={`test-result ${result.success ? "test-result--success" : "test-result--failure"}`}
|
|
||||||
>
|
|
||||||
{result.success ? "\u2713" : "\u2717"} {channel}:{" "}
|
|
||||||
{result.message || (result.success ? "OK" : "Failed")}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button onClick={() => navigate("/addresses")} className="btn--lg text-bold">
|
|
||||||
Go to Dashboard →
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Shared helpers ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function CheckboxRow({ checked, onChange, label, children }) {
|
|
||||||
return (
|
|
||||||
<div className="checkbox-row">
|
|
||||||
<label className="checkbox-row__label">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={checked}
|
|
||||||
onChange={(e) => onChange(e.target.checked)}
|
|
||||||
className="checkbox-row__input"
|
|
||||||
/>
|
|
||||||
{label}
|
|
||||||
</label>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 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 (
|
|
||||||
<div className="onboarding__footer">
|
|
||||||
<div>
|
|
||||||
{canBack && (
|
|
||||||
<Button
|
|
||||||
onClick={handleBack}
|
|
||||||
disabled={loading}
|
|
||||||
variant="ghost"
|
|
||||||
>
|
|
||||||
← Back
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-md">
|
|
||||||
{canSkip && (
|
|
||||||
<Button
|
|
||||||
onClick={handleSkip}
|
|
||||||
disabled={loading}
|
|
||||||
variant="ghost"
|
|
||||||
>
|
|
||||||
Skip for now
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
onClick={handleNext}
|
|
||||||
disabled={loading}
|
|
||||||
className="text-bold"
|
|
||||||
>
|
|
||||||
{loading ? "Please wait..." : nextLabel}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Render ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const stepContent = {
|
|
||||||
1: Step1(),
|
|
||||||
2: Step2(),
|
|
||||||
3: Step3(),
|
|
||||||
4: Step4(),
|
|
||||||
5: Step5(),
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="onboarding">
|
|
||||||
<div className="onboarding__container">
|
|
||||||
<h1 className="onboarding__title">Koin Ping</h1>
|
|
||||||
|
|
||||||
{ProgressBar()}
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="alert alert--error">{error}</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{skipWarning && (
|
|
||||||
<div className="alert alert--warning">{skipWarning}</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="onboarding__card">
|
|
||||||
{stepContent[step]}
|
|
||||||
{Footer()}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{step === 1 && (
|
|
||||||
<p className="onboarding__login-link">
|
|
||||||
Already have an account?{" "}
|
|
||||||
<a href="/login">Log in here</a>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
159
frontend/src/pages/addresses/Addresses.jsx
Normal file
159
frontend/src/pages/addresses/Addresses.jsx
Normal file
@@ -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 (
|
||||||
|
<div className="page">
|
||||||
|
<h1>Add Addresses to Track</h1>
|
||||||
|
|
||||||
|
<div className="mb-xl">
|
||||||
|
<AddressForm onSubmit={handleAddressSubmit} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2>Existing Tracked Addresses</h2>
|
||||||
|
{loading && <p>Loading addresses...</p>}
|
||||||
|
{error && <p className="text-error">Error: {error}</p>}
|
||||||
|
{!loading && !error && addresses.length === 0 && (
|
||||||
|
<p className="text-dimmed">
|
||||||
|
No addresses tracked yet. Add one above to get started.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{addresses.length > 0 && (
|
||||||
|
<ul className="list-unstyled">
|
||||||
|
{addresses.map((addr, index) => (
|
||||||
|
<li key={addr.id || index} className="list-item--card">
|
||||||
|
<div className="flex flex--between flex--center">
|
||||||
|
<div>
|
||||||
|
{editingId === addr.id ? (
|
||||||
|
<div className="flex flex--center gap-sm mb-sm">
|
||||||
|
<input
|
||||||
|
value={editLabel}
|
||||||
|
onChange={(e) => 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
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => handleEditSave(addr.id)}
|
||||||
|
className="btn btn--primary btn--sm"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleEditCancel}
|
||||||
|
className="btn btn--ghost btn--sm"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex--center gap-sm mb-sm">
|
||||||
|
<span className="text-bold">
|
||||||
|
{addr.label || "Unlabeled"}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => handleEditStart(addr)}
|
||||||
|
className="address__edit-link"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="text-mono text-sm text-muted">
|
||||||
|
{addr.address}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(addr.id, addr.label)}
|
||||||
|
className="address__remove"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
67
frontend/src/pages/alertHistory/AlertHistory.jsx
Normal file
67
frontend/src/pages/alertHistory/AlertHistory.jsx
Normal file
@@ -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 <div className="page">Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="page text-error">Error: {error}</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<h1>Recent Alert Events</h1>
|
||||||
|
|
||||||
|
{alertEvents.length === 0 ? (
|
||||||
|
<p className="text-dimmed">No alerts yet</p>
|
||||||
|
) : (
|
||||||
|
<ul className="list-unstyled">
|
||||||
|
{alertEvents.map((event) => (
|
||||||
|
<li key={event.id} className="alert-history__item">
|
||||||
|
<div className="mb-sm">{event.message}</div>
|
||||||
|
{event.address_label && (
|
||||||
|
<div className="text-sm text-dimmed">
|
||||||
|
Address: {event.address_label}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<small className="text-muted">
|
||||||
|
{formatTimestamp(event.timestamp)}
|
||||||
|
</small>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimestamp(timestamp) {
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
return date.toLocaleString();
|
||||||
|
}
|
||||||
522
frontend/src/pages/alerts/Alerts.jsx
Normal file
522
frontend/src/pages/alerts/Alerts.jsx
Normal file
@@ -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 <div className="page">Loading addresses...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (addresses.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<p>No addresses tracked yet. Add an address first to create alerts.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page page--wide">
|
||||||
|
<h1 className="mb-xl">Alert Rules & Notifications</h1>
|
||||||
|
|
||||||
|
<div className="alerts-grid">
|
||||||
|
{/* LEFT COLUMN: Alert Rules */}
|
||||||
|
<div>
|
||||||
|
<h2 className="mt-0">Alert Rules</h2>
|
||||||
|
|
||||||
|
<div className="mb-xl">
|
||||||
|
<label className="form-label mb-sm">
|
||||||
|
<strong>Select Address:</strong>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={selectedAddressId || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSelectedAddressId(Number(e.target.value))
|
||||||
|
}
|
||||||
|
className="form-select"
|
||||||
|
>
|
||||||
|
{addresses.map((addr) => (
|
||||||
|
<option key={addr.id} value={addr.id}>
|
||||||
|
{addr.label || "Unlabeled"} - {addr.address}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedAddress && (
|
||||||
|
<>
|
||||||
|
<div className="alerts__address-info">
|
||||||
|
<div className="text-sm text-muted">
|
||||||
|
Managing alerts for:
|
||||||
|
</div>
|
||||||
|
<div className="text-bold">
|
||||||
|
{selectedAddress.label || "Unlabeled"}
|
||||||
|
</div>
|
||||||
|
<div className="text-mono text-sm text-muted">
|
||||||
|
{selectedAddress.address}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-xl">
|
||||||
|
<h3>Create New Alert</h3>
|
||||||
|
<AlertForm onSubmit={handleAlertSubmit} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3>Active Alert Rules</h3>
|
||||||
|
{error && (
|
||||||
|
<p className="text-error">Error: {error}</p>
|
||||||
|
)}
|
||||||
|
{alerts.length === 0 ? (
|
||||||
|
<p className="text-dimmed">
|
||||||
|
No alert rules defined yet. Create one above.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="list-unstyled">
|
||||||
|
{alerts.map((alert) => (
|
||||||
|
<li
|
||||||
|
key={alert.id}
|
||||||
|
className={`alerts__rule ${!alert.enabled ? "alerts__rule--disabled" : ""}`}
|
||||||
|
>
|
||||||
|
<div className="flex flex--between">
|
||||||
|
<div>
|
||||||
|
<div className="text-bold mb-sm">
|
||||||
|
{formatAlertType(alert.type)}
|
||||||
|
</div>
|
||||||
|
{alert.threshold && (
|
||||||
|
<div className="text-sm text-muted">
|
||||||
|
Threshold: {alert.threshold} ETH
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="text-xs text-dimmed">
|
||||||
|
Status: {alert.enabled ? "Enabled" : "Disabled"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex--center gap-sm">
|
||||||
|
<Button
|
||||||
|
onClick={() =>
|
||||||
|
handleToggleAlert(alert.id, alert.enabled)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{alert.enabled ? "Disable" : "Enable"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => handleDeleteAlert(alert.id)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* RIGHT COLUMN: Notification Settings */}
|
||||||
|
<div>
|
||||||
|
<h2 className="mt-0">Notification Settings</h2>
|
||||||
|
|
||||||
|
{notificationSuccess && (
|
||||||
|
<div className="alert alert--success">{notificationSuccess}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{notificationError && (
|
||||||
|
<div className="alert alert--error">{notificationError}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Master toggle */}
|
||||||
|
<div className="alerts__toggle-panel">
|
||||||
|
<label className="alerts__toggle-label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={notificationEnabled}
|
||||||
|
onChange={(e) =>
|
||||||
|
setNotificationEnabled(e.target.checked)
|
||||||
|
}
|
||||||
|
className="alerts__toggle-checkbox"
|
||||||
|
/>
|
||||||
|
<span className="text-bold">Enable Notifications</span>
|
||||||
|
</label>
|
||||||
|
<div className="alerts__toggle-hint">
|
||||||
|
Master switch for all notification channels
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* All channel settings -- hidden when master toggle is off */}
|
||||||
|
{notificationEnabled && (
|
||||||
|
<>
|
||||||
|
{/* Telegram */}
|
||||||
|
<div className="section">
|
||||||
|
<h3 className="mt-0 mb-md">Telegram</h3>
|
||||||
|
<Input
|
||||||
|
label="Bot Token"
|
||||||
|
value={telegramBotToken}
|
||||||
|
onChange={setTelegramBotToken}
|
||||||
|
placeholder="123456789:ABCdefGHIjklMNOpqrSTUvwxYZ"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Chat ID"
|
||||||
|
value={telegramChatId}
|
||||||
|
onChange={setTelegramChatId}
|
||||||
|
placeholder="-1001234567890"
|
||||||
|
/>
|
||||||
|
<a
|
||||||
|
href="https://core.telegram.org/bots#how-do-i-create-a-bot"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="help-link"
|
||||||
|
>
|
||||||
|
How to create a Telegram bot & get your Chat ID
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Email */}
|
||||||
|
<div className="section">
|
||||||
|
<h3 className="mt-0 mb-md">Email</h3>
|
||||||
|
<Input
|
||||||
|
label="Email Address"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={setEmail}
|
||||||
|
placeholder="you@example.com"
|
||||||
|
/>
|
||||||
|
<div className="help-link text-dimmed mb-md">
|
||||||
|
Alert notifications and digests will be sent to this address
|
||||||
|
</div>
|
||||||
|
<div className="alerts__email-buttons">
|
||||||
|
<Button
|
||||||
|
onClick={handleSetupEmail}
|
||||||
|
disabled={settingUpEmail || !email}
|
||||||
|
className="btn--sm"
|
||||||
|
>
|
||||||
|
{settingUpEmail ? "Setting up..." : "Verify Email"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSendDigest}
|
||||||
|
disabled={sendingDigest || !email}
|
||||||
|
variant="secondary"
|
||||||
|
className="btn--sm"
|
||||||
|
>
|
||||||
|
{sendingDigest ? "Sending..." : "Send Digest Now"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Discord */}
|
||||||
|
<div className="section">
|
||||||
|
<h3 className="mt-0 mb-md">Discord</h3>
|
||||||
|
<Input
|
||||||
|
label="Discord Webhook URL"
|
||||||
|
value={discordWebhookUrl}
|
||||||
|
onChange={setDiscordWebhookUrl}
|
||||||
|
placeholder="https://discord.com/api/webhooks/..."
|
||||||
|
/>
|
||||||
|
<a
|
||||||
|
href="https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="help-link"
|
||||||
|
>
|
||||||
|
How to get a Discord webhook URL
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Slack */}
|
||||||
|
<div className="section">
|
||||||
|
<h3 className="mt-0 mb-md">Slack</h3>
|
||||||
|
<Input
|
||||||
|
label="Slack Webhook URL"
|
||||||
|
value={slackWebhookUrl}
|
||||||
|
onChange={setSlackWebhookUrl}
|
||||||
|
placeholder="https://hooks.slack.com/services/..."
|
||||||
|
/>
|
||||||
|
<a
|
||||||
|
href="https://api.slack.com/messaging/webhooks"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="help-link"
|
||||||
|
>
|
||||||
|
How to set up Slack Incoming Webhooks
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Save & Test buttons */}
|
||||||
|
<div className="alerts__save-test">
|
||||||
|
<Button
|
||||||
|
onClick={handleSaveNotificationConfig}
|
||||||
|
disabled={notificationLoading}
|
||||||
|
className="btn--lg"
|
||||||
|
>
|
||||||
|
{notificationLoading ? "Saving..." : "Save Settings"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleTestChannels}
|
||||||
|
disabled={testingChannels}
|
||||||
|
variant="success"
|
||||||
|
className="btn--lg"
|
||||||
|
>
|
||||||
|
{testingChannels ? "Testing..." : "Test All Channels"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
import { useAuth } from "../contexts/AuthContext";
|
import { useAuth } from "../../contexts/AuthContext";
|
||||||
import Input from "../components/Input";
|
import Input from "../../components/Input";
|
||||||
import "./Login.css";
|
import "./Login.css";
|
||||||
|
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
.onboarding {
|
.subscribe {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background-color: var(--color-bg);
|
background-color: var(--color-bg);
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -9,34 +9,34 @@
|
|||||||
padding-bottom: 3rem;
|
padding-bottom: 3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.onboarding__container {
|
.subscribe__container {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 540px;
|
max-width: 540px;
|
||||||
padding: 0 1rem;
|
padding: 0 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.onboarding__title {
|
.subscribe__title {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
color: var(--color-primary);
|
color: var(--color-primary);
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.onboarding__card {
|
.subscribe__card {
|
||||||
background-color: var(--color-bg-elevated);
|
background-color: var(--color-bg-elevated);
|
||||||
border: 1px solid var(--color-border-light);
|
border: 1px solid var(--color-border-light);
|
||||||
border-radius: var(--radius-xl);
|
border-radius: var(--radius-xl);
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.onboarding__login-link {
|
.subscribe__login-link {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin-top: 1.25rem;
|
margin-top: 1.25rem;
|
||||||
color: #888;
|
color: #888;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.onboarding__login-link a {
|
.subscribe__login-link a {
|
||||||
color: var(--color-primary);
|
color: var(--color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,7 +108,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Step 5 summary */
|
/* Step 5 summary */
|
||||||
.onboarding__summary {
|
.subscribe__summary {
|
||||||
background-color: #1e2e1e;
|
background-color: #1e2e1e;
|
||||||
border: 1px solid #2d5a2d;
|
border: 1px solid #2d5a2d;
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
@@ -116,13 +116,13 @@
|
|||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.onboarding__summary-title {
|
.subscribe__summary-title {
|
||||||
margin: 0 0 0.5rem;
|
margin: 0 0 0.5rem;
|
||||||
color: #90ee90;
|
color: #90ee90;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
.onboarding__summary-list {
|
.subscribe__summary-list {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding-left: 1.25rem;
|
padding-left: 1.25rem;
|
||||||
color: var(--color-text-label);
|
color: var(--color-text-label);
|
||||||
@@ -154,7 +154,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Footer navigation */
|
/* Footer navigation */
|
||||||
.onboarding__footer {
|
.subscribe__footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -178,7 +178,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Step subtitle */
|
/* Step subtitle */
|
||||||
.onboarding__subtitle {
|
.subscribe__subtitle {
|
||||||
color: #aaa;
|
color: #aaa;
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
679
frontend/src/pages/subscribe/subscribe.jsx
Normal file
679
frontend/src/pages/subscribe/subscribe.jsx
Normal file
@@ -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 (
|
||||||
|
<div className="progress-bar">
|
||||||
|
{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 (
|
||||||
|
<div key={label} className="progress-bar__step">
|
||||||
|
{i > 0 && (
|
||||||
|
<div
|
||||||
|
className={`progress-bar__connector ${done || active
|
||||||
|
? "progress-bar__connector--active"
|
||||||
|
: "progress-bar__connector--inactive"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<div className={`progress-bar__dot ${dotClass}`}>
|
||||||
|
{done ? "\u2713" : stepNum}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`progress-bar__label ${active
|
||||||
|
? "progress-bar__label--active"
|
||||||
|
: "progress-bar__label--inactive"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step content ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function Step1() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h2 className="mb-lg">Create your account</h2>
|
||||||
|
<Input
|
||||||
|
label="Email"
|
||||||
|
type="email"
|
||||||
|
value={data.email}
|
||||||
|
onChange={(v) => set("email", v)}
|
||||||
|
disabled={loading}
|
||||||
|
placeholder="you@example.com"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Password"
|
||||||
|
type="password"
|
||||||
|
value={data.password}
|
||||||
|
onChange={(v) => set("password", v)}
|
||||||
|
disabled={loading}
|
||||||
|
placeholder="At least 6 characters"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Confirm Password"
|
||||||
|
type="password"
|
||||||
|
value={data.confirmPassword}
|
||||||
|
onChange={(v) => set("confirmPassword", v)}
|
||||||
|
disabled={loading}
|
||||||
|
placeholder="Repeat your password"
|
||||||
|
className="form-field--last"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Step2() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h2 className="mb-sm">Add a wallet address</h2>
|
||||||
|
<p className="subscribe__subtitle">
|
||||||
|
Enter the Ethereum address you want to monitor.
|
||||||
|
</p>
|
||||||
|
<Input
|
||||||
|
label="ETH Address"
|
||||||
|
value={data.walletAddress}
|
||||||
|
onChange={(v) => set("walletAddress", v)}
|
||||||
|
disabled={loading}
|
||||||
|
placeholder="0x..."
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Label (optional)"
|
||||||
|
value={data.walletLabel}
|
||||||
|
onChange={(v) => set("walletLabel", v)}
|
||||||
|
disabled={loading}
|
||||||
|
placeholder="e.g. My main wallet"
|
||||||
|
className="form-field--last"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Step3() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h2 className="mb-sm">Configure alert rules</h2>
|
||||||
|
<p className="subscribe__subtitle">
|
||||||
|
Choose which events trigger notifications. You can change these later.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<CheckboxRow
|
||||||
|
checked={data.alertIncomingTx}
|
||||||
|
onChange={(v) => set("alertIncomingTx", v)}
|
||||||
|
label="Incoming transaction"
|
||||||
|
/>
|
||||||
|
<CheckboxRow
|
||||||
|
checked={data.alertOutgoingTx}
|
||||||
|
onChange={(v) => set("alertOutgoingTx", v)}
|
||||||
|
label="Outgoing transaction"
|
||||||
|
/>
|
||||||
|
<CheckboxRow
|
||||||
|
checked={data.alertLargeTransfer}
|
||||||
|
onChange={(v) => set("alertLargeTransfer", v)}
|
||||||
|
label="Large transfer"
|
||||||
|
>
|
||||||
|
{data.alertLargeTransfer && (
|
||||||
|
<div className="checkbox-row__nested">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
label=""
|
||||||
|
value={data.largeTransferThreshold}
|
||||||
|
onChange={(v) => set("largeTransferThreshold", v)}
|
||||||
|
placeholder="Threshold (ETH)"
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CheckboxRow>
|
||||||
|
<CheckboxRow
|
||||||
|
checked={data.alertBalanceBelow}
|
||||||
|
onChange={(v) => set("alertBalanceBelow", v)}
|
||||||
|
label="Balance below"
|
||||||
|
>
|
||||||
|
{data.alertBalanceBelow && (
|
||||||
|
<div className="checkbox-row__nested">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
label=""
|
||||||
|
value={data.balanceBelowThreshold}
|
||||||
|
onChange={(v) => set("balanceBelowThreshold", v)}
|
||||||
|
placeholder="Threshold (ETH)"
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CheckboxRow>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Step4() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h2 className="mb-sm">Set up notifications</h2>
|
||||||
|
<p className="subscribe__subtitle">
|
||||||
|
Add at least one channel so you receive alerts. All fields are optional.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mb-md">
|
||||||
|
<label className="form-label">
|
||||||
|
Discord Webhook URL{" "}
|
||||||
|
<a
|
||||||
|
href="https://support.discord.com/hc/en-us/articles/228383668"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="help-link"
|
||||||
|
>
|
||||||
|
(how to get one)
|
||||||
|
</a>
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
label=""
|
||||||
|
type="url"
|
||||||
|
value={data.discordWebhookUrl}
|
||||||
|
onChange={(v) => set("discordWebhookUrl", v)}
|
||||||
|
disabled={loading}
|
||||||
|
placeholder="https://discord.com/api/webhooks/..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-md">
|
||||||
|
<label className="form-label">
|
||||||
|
Slack Webhook URL{" "}
|
||||||
|
<a
|
||||||
|
href="https://api.slack.com/messaging/webhooks"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="help-link"
|
||||||
|
>
|
||||||
|
(how to get one)
|
||||||
|
</a>
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
label=""
|
||||||
|
type="url"
|
||||||
|
value={data.slackWebhookUrl}
|
||||||
|
onChange={(v) => set("slackWebhookUrl", v)}
|
||||||
|
disabled={loading}
|
||||||
|
placeholder="https://hooks.slack.com/services/..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Email address for alerts"
|
||||||
|
type="email"
|
||||||
|
value={data.notificationEmail}
|
||||||
|
onChange={(v) => 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 (
|
||||||
|
<>
|
||||||
|
<h2 className="mb-md">You're all set!</h2>
|
||||||
|
|
||||||
|
<div className="subscribe__summary">
|
||||||
|
<p className="subscribe__summary-title">Summary</p>
|
||||||
|
<ul className="subscribe__summary-list">
|
||||||
|
<li>
|
||||||
|
Wallet address added:{" "}
|
||||||
|
<span className="text-mono text-white-sm">
|
||||||
|
{data.walletAddress}
|
||||||
|
</span>
|
||||||
|
{data.walletLabel && ` (${data.walletLabel})`}
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Alert rules configured:{" "}
|
||||||
|
<span className="text-white">
|
||||||
|
{alertCount > 0 ? `${alertCount} rule${alertCount !== 1 ? "s" : ""}` : "None (skipped)"}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Notification channels:{" "}
|
||||||
|
<span className="text-white">
|
||||||
|
{hasNotif ? "Configured" : "Not set up (skipped)"}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasNotif && (
|
||||||
|
<div className="mb-lg">
|
||||||
|
<Button
|
||||||
|
onClick={handleTestChannels}
|
||||||
|
disabled={testLoading}
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
{testLoading ? "Testing..." : "Test All Channels"}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{testResults && (
|
||||||
|
<div className="mt-md">
|
||||||
|
{testResults.error ? (
|
||||||
|
<p className="text-error">{testResults.error}</p>
|
||||||
|
) : (
|
||||||
|
<ul className="list-unstyled">
|
||||||
|
{Object.entries(testResults).map(([channel, result]) => (
|
||||||
|
<li
|
||||||
|
key={channel}
|
||||||
|
className={`test-result ${result.success ? "test-result--success" : "test-result--failure"}`}
|
||||||
|
>
|
||||||
|
{result.success ? "\u2713" : "\u2717"} {channel}:{" "}
|
||||||
|
{result.message || (result.success ? "OK" : "Failed")}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button onClick={() => navigate("/addresses")} className="btn--lg text-bold">
|
||||||
|
Go to Dashboard →
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Shared helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function CheckboxRow({ checked, onChange, label, children }) {
|
||||||
|
return (
|
||||||
|
<div className="checkbox-row">
|
||||||
|
<label className="checkbox-row__label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
onChange={(e) => onChange(e.target.checked)}
|
||||||
|
className="checkbox-row__input"
|
||||||
|
/>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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 (
|
||||||
|
<div className="subscribe__footer">
|
||||||
|
<div>
|
||||||
|
{canBack && (
|
||||||
|
<Button
|
||||||
|
onClick={handleBack}
|
||||||
|
disabled={loading}
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
← Back
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-md">
|
||||||
|
{canSkip && (
|
||||||
|
<Button
|
||||||
|
onClick={handleSkip}
|
||||||
|
disabled={loading}
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
Skip for now
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
onClick={handleNext}
|
||||||
|
disabled={loading}
|
||||||
|
className="text-bold"
|
||||||
|
>
|
||||||
|
{loading ? "Please wait..." : nextLabel}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Render ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const stepContent = {
|
||||||
|
1: Step1(),
|
||||||
|
2: Step2(),
|
||||||
|
3: Step3(),
|
||||||
|
4: Step4(),
|
||||||
|
5: Step5(),
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="subscribe">
|
||||||
|
<div className="subscribe__container">
|
||||||
|
<h1 className="subscribe__title">Koin Ping</h1>
|
||||||
|
|
||||||
|
{ProgressBar()}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="alert alert--error">{error}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{skipWarning && (
|
||||||
|
<div className="alert alert--warning">{skipWarning}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="subscribe__card">
|
||||||
|
{stepContent[step]}
|
||||||
|
{Footer()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{step === 1 && (
|
||||||
|
<p className="subscribe__login-link">
|
||||||
|
Already have an account?{" "}
|
||||||
|
<a href="/login">Log in here</a>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,24 +1,28 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2020",
|
"target": "ES2020",
|
||||||
"useDefineForClassFields": true,
|
"useDefineForClassFields": true,
|
||||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
"lib": [
|
||||||
"module": "ESNext",
|
"ES2020",
|
||||||
"skipLibCheck": true,
|
"DOM",
|
||||||
|
"DOM.Iterable"
|
||||||
/* Bundler mode */
|
],
|
||||||
"moduleResolution": "bundler",
|
"module": "ESNext",
|
||||||
"allowImportingTsExtensions": true,
|
"skipLibCheck": true,
|
||||||
"resolveJsonModule": true,
|
/* Bundler mode */
|
||||||
"isolatedModules": true,
|
"moduleResolution": "bundler",
|
||||||
"noEmit": true,
|
"allowImportingTsExtensions": true,
|
||||||
"jsx": "react-jsx",
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
/* Linting */
|
"noEmit": true,
|
||||||
"strict": true,
|
"jsx": "react-jsx",
|
||||||
"noUnusedLocals": true,
|
/* Linting */
|
||||||
"noUnusedParameters": true,
|
"strict": true,
|
||||||
"noFallthroughCasesInSwitch": true
|
"noUnusedLocals": true,
|
||||||
},
|
"noUnusedParameters": true,
|
||||||
"include": ["src"]
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user