Compare commits

..

13 Commits

Author SHA1 Message Date
KS Jannette
91336130b9 discrete user accounts
Some checks are pending
check / check (push) Waiting to run
2026-03-03 19:10:24 -05:00
KS Jannette
e4e859d051 style 2026-03-03 18:39:20 -05:00
S Jannette
6f6a2c6d18 Merge pull request #13 from kjannette/refactor-css
Refactor css
2026-03-02 03:43:34 -05:00
KS Jannette
63eecaaf72 m
Some checks are pending
check / check (push) Waiting to run
2026-03-02 03:42:58 -05:00
KS Jannette
8a5f5db903 refact css sytem 2026-03-02 03:36:21 -05:00
KS Jannette
c2515472b2 more css hottie 2026-03-02 00:34:34 -05:00
S Jannette
5ad51271af Merge pull request #12 from kjannette/migrate-inline-css
Migrate inline css
2026-03-02 00:28:46 -05:00
KS Jannette
b3a08e8f25 css trick
Some checks are pending
check / check (push) Waiting to run
2026-03-02 00:28:13 -05:00
KS Jannette
8716be0201 fudge 2026-03-02 00:09:24 -05:00
KS Jannette
647be874e5 m 2026-03-02 00:09:01 -05:00
KS Jannette
c12c4fe742 add image 2026-03-01 23:14:25 -05:00
S Jannette
5f5253d495 Revise README for clarity and feature description
Updated the README to clarify features and improve readability.
2026-03-01 21:48:53 -05:00
S Jannette
bbe2374703 Merge pull request #11 from kjannette/history-reconfig-possible-bugfix
History reconfig possible bugfix
2026-03-01 20:38:56 -05:00
30 changed files with 1432 additions and 1149 deletions

View File

@@ -5,7 +5,6 @@ A lightweight on-chain monitoring and alerting system designed to give users sit
Koin Ping observes on-chain activity and notifies users when predefined conditions are met. It does not execute transactions, manage wallets, or speculate on prices. Koin Ping observes on-chain activity and notifies users when predefined conditions are met. It does not execute transactions, manage wallets, or speculate on prices.
## Getting Started ## Getting Started
### Prerequisites ### Prerequisites
@@ -46,16 +45,16 @@ cd frontend && npm run dev
``` ```
The API listens on `http://localhost:3001` and the frontend on The API listens on `http://localhost:3001` and the frontend on
`http://localhost:3000` by default. `http://localhost:3000` by default for development.
## Rationale ## Rationale
Crypto users who hold or actively monitor addresses need a lightweight, reliable Crypto users may actively monitor addresses with webhooks integrating popular
way to know when on-chain activity occurs without polling block explorers messaging platforms: Discord, Slack, Telegram.
manually. Koin Ping fills that gap: it watches a set of Ethereum addresses, This lightweight, reliable framework makes instant awareness of on-chain
evaluates configurable alert rules (incoming transactions, outgoing activity trivial, without polling block explorers manually. Koin Ping watches
transactions, large transfers, balance thresholds), and notifies the user addresses, evaluates configurable alert rules (incoming transactions, outgoing
through Discord webhooks. transactions, "large" transfers, balance thresholds), and sends notifications.
## Design ## Design
@@ -132,7 +131,7 @@ To receive alerts via Telegram, you need to create a bot and get your chat ID.
4. In the JSON response, find the `"chat"` object — the `"id"` field is your **Chat ID** (a numeric value). 4. In the JSON response, find the `"chat"` object — the `"id"` field is your **Chat ID** (a numeric value).
> **Tip:** If the `"result"` array is empty, make sure you sent a message to your bot first, then refresh the page. > **Tip:** If the `'result"` array is empty, make sure you sent a message to your bot first, then refresh the page.
#### 3. Save in Koin Ping #### 3. Save in Koin Ping
@@ -153,3 +152,5 @@ MIT. See [LICENSE](LICENSE).
## Author ## Author
Steven Jannette Steven Jannette

View File

@@ -13,3 +13,9 @@ Terminal 1 (API server):
cd /Users/kjannette/workspace/koin_ping_0.2.0/backend-gogo run ./cmd/api cd /Users/kjannette/workspace/koin_ping_0.2.0/backend-gogo run ./cmd/api
Terminal 2 (Poller): Terminal 2 (Poller):
cd /Users/kjannette/workspace/koin_ping_0.2.0/backend-gogo run ./cmd/poller cd /Users/kjannette/workspace/koin_ping_0.2.0/backend-gogo run ./cmd/poller
make run — Builds and runs the API server.
make dev — Runs the API server with auto-reload via air (falls back to go run if air isn't installed).
make poller — Builds and runs the poller.
make poller-dev — Runs the poller with auto-reload.
make dev-all — Runs both the API and poller concurrently.

View File

@@ -44,6 +44,7 @@ func main() {
defer database.Close() defer database.Close()
userModel := models.NewUserModel(pool)
addressModel := models.NewAddressModel(pool) addressModel := models.NewAddressModel(pool)
alertRuleModel := models.NewAlertRuleModel(pool) alertRuleModel := models.NewAlertRuleModel(pool)
alertEventModel := models.NewAlertEventModel(pool) alertEventModel := models.NewAlertEventModel(pool)
@@ -61,6 +62,8 @@ func main() {
emailDigestHandler := handlers.NewEmailDigestHandler(emailDigestSvc, notifConfigModel) emailDigestHandler := handlers.NewEmailDigestHandler(emailDigestSvc, notifConfigModel)
statusHandler := handlers.NewStatusHandler(checkpointModel) statusHandler := handlers.NewStatusHandler(checkpointModel)
authenticate := middleware.Authenticate(userModel)
mux := http.NewServeMux() mux := http.NewServeMux()
b := cfg.APIBasePath // e.g. "/v1" b := cfg.APIBasePath // e.g. "/v1"
@@ -70,43 +73,43 @@ func main() {
// Authenticated routes — addresses // Authenticated routes — addresses
mux.Handle("POST "+b+"/addresses", mux.Handle("POST "+b+"/addresses",
middleware.Authenticate(http.HandlerFunc(addressHandler.Create))) authenticate(http.HandlerFunc(addressHandler.Create)))
mux.Handle("GET "+b+"/addresses", mux.Handle("GET "+b+"/addresses",
middleware.Authenticate(http.HandlerFunc(addressHandler.List))) authenticate(http.HandlerFunc(addressHandler.List)))
mux.Handle("DELETE "+b+"/addresses/{addressId}", mux.Handle("DELETE "+b+"/addresses/{addressId}",
middleware.Authenticate(http.HandlerFunc(addressHandler.Remove))) authenticate(http.HandlerFunc(addressHandler.Remove)))
mux.Handle("PATCH "+b+"/addresses/{addressId}", mux.Handle("PATCH "+b+"/addresses/{addressId}",
middleware.Authenticate(http.HandlerFunc(addressHandler.UpdateLabel))) authenticate(http.HandlerFunc(addressHandler.UpdateLabel)))
// Authenticated routes for alert rules // Authenticated routes for alert rules
mux.Handle("POST "+b+"/addresses/{addressId}/alerts", mux.Handle("POST "+b+"/addresses/{addressId}/alerts",
middleware.Authenticate(http.HandlerFunc(alertRuleHandler.Create))) authenticate(http.HandlerFunc(alertRuleHandler.Create)))
mux.Handle("GET "+b+"/addresses/{addressId}/alerts", mux.Handle("GET "+b+"/addresses/{addressId}/alerts",
middleware.Authenticate(http.HandlerFunc(alertRuleHandler.ListByAddress))) authenticate(http.HandlerFunc(alertRuleHandler.ListByAddress)))
mux.Handle("PATCH "+b+"/alerts/{alertId}", mux.Handle("PATCH "+b+"/alerts/{alertId}",
middleware.Authenticate(http.HandlerFunc(alertRuleHandler.UpdateStatus))) authenticate(http.HandlerFunc(alertRuleHandler.UpdateStatus)))
mux.Handle("DELETE "+b+"/alerts/{alertId}", mux.Handle("DELETE "+b+"/alerts/{alertId}",
middleware.Authenticate(http.HandlerFunc(alertRuleHandler.Remove))) authenticate(http.HandlerFunc(alertRuleHandler.Remove)))
// Authenticated routes — alert events // Authenticated routes — alert events
mux.Handle("GET "+b+"/alert-events", mux.Handle("GET "+b+"/alert-events",
middleware.Authenticate(http.HandlerFunc(alertEventHandler.List))) authenticate(http.HandlerFunc(alertEventHandler.List)))
// Authenticated routes — notification config // Authenticated routes — notification config
mux.Handle("GET "+b+"/notification-config", mux.Handle("GET "+b+"/notification-config",
middleware.Authenticate(http.HandlerFunc(notifConfigHandler.GetConfig))) authenticate(http.HandlerFunc(notifConfigHandler.GetConfig)))
mux.Handle("PUT "+b+"/notification-config", mux.Handle("PUT "+b+"/notification-config",
middleware.Authenticate(http.HandlerFunc(notifConfigHandler.UpdateConfig))) authenticate(http.HandlerFunc(notifConfigHandler.UpdateConfig)))
mux.Handle("DELETE "+b+"/notification-config", mux.Handle("DELETE "+b+"/notification-config",
middleware.Authenticate(http.HandlerFunc(notifConfigHandler.DeleteConfig))) authenticate(http.HandlerFunc(notifConfigHandler.DeleteConfig)))
mux.Handle("POST "+b+"/notification-config/test", mux.Handle("POST "+b+"/notification-config/test",
middleware.Authenticate(http.HandlerFunc(notifConfigHandler.TestChannels))) authenticate(http.HandlerFunc(notifConfigHandler.TestChannels)))
// Authenticated routes — email digest // Authenticated routes — email digest
mux.Handle("POST "+b+"/email/setup", mux.Handle("POST "+b+"/email/setup",
middleware.Authenticate(http.HandlerFunc(emailDigestHandler.SetupEmail))) authenticate(http.HandlerFunc(emailDigestHandler.SetupEmail)))
mux.Handle("POST "+b+"/email/digest", mux.Handle("POST "+b+"/email/digest",
middleware.Authenticate(http.HandlerFunc(emailDigestHandler.SendDigest))) authenticate(http.HandlerFunc(emailDigestHandler.SendDigest)))
handler := corsMiddleware(mux) handler := corsMiddleware(mux)

View File

@@ -0,0 +1,13 @@
-- Migration 005: Create users table with UUID primary key
-- This establishes a local user record for each Firebase-authenticated user.
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
firebase_uid VARCHAR(128) NOT NULL UNIQUE,
email VARCHAR(255) NOT NULL,
display_name VARCHAR(255),
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_users_firebase_uid ON users(firebase_uid);

View File

@@ -0,0 +1,73 @@
-- Migration 006: Migrate user_id columns from Firebase UID strings to user UUIDs
--
-- Prerequisites: migration 005 (users table) must be applied first.
-- This migration backfills the users table from existing data, then swaps the
-- VARCHAR user_id columns for UUID foreign keys referencing users(id).
BEGIN;
-- 1. Backfill users table from existing Firebase UIDs in addresses
INSERT INTO users (firebase_uid, email)
SELECT DISTINCT user_id, ''
FROM addresses
WHERE user_id IS NOT NULL
ON CONFLICT (firebase_uid) DO NOTHING;
-- 2. Backfill from notification configs (catches users with configs but no addresses)
INSERT INTO users (firebase_uid, email)
SELECT DISTINCT user_id, ''
FROM user_notification_configs
WHERE user_id IS NOT NULL
ON CONFLICT (firebase_uid) DO NOTHING;
-- ============================================================
-- 3. Migrate addresses.user_id from VARCHAR to UUID
-- ============================================================
ALTER TABLE addresses ADD COLUMN user_uuid UUID;
UPDATE addresses a
SET user_uuid = u.id
FROM users u
WHERE u.firebase_uid = a.user_id;
-- Drop old constraints and column
ALTER TABLE addresses DROP CONSTRAINT IF EXISTS addresses_user_id_address_key;
DROP INDEX IF EXISTS idx_addresses_user_id;
ALTER TABLE addresses DROP COLUMN user_id;
-- Rename and constrain
ALTER TABLE addresses RENAME COLUMN user_uuid TO user_id;
ALTER TABLE addresses ALTER COLUMN user_id SET NOT NULL;
ALTER TABLE addresses ADD CONSTRAINT fk_addresses_user
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
ALTER TABLE addresses ADD CONSTRAINT addresses_user_id_address_key
UNIQUE (user_id, address);
CREATE INDEX idx_addresses_user_id ON addresses(user_id);
-- ============================================================
-- 4. Migrate user_notification_configs.user_id from VARCHAR to UUID
-- ============================================================
-- Drop the PK first (it's on user_id)
ALTER TABLE user_notification_configs DROP CONSTRAINT IF EXISTS user_notification_configs_pkey;
DROP INDEX IF EXISTS idx_notification_configs_enabled;
ALTER TABLE user_notification_configs ADD COLUMN user_uuid UUID;
UPDATE user_notification_configs nc
SET user_uuid = u.id
FROM users u
WHERE u.firebase_uid = nc.user_id;
ALTER TABLE user_notification_configs DROP COLUMN user_id;
ALTER TABLE user_notification_configs RENAME COLUMN user_uuid TO user_id;
ALTER TABLE user_notification_configs ALTER COLUMN user_id SET NOT NULL;
ALTER TABLE user_notification_configs ADD CONSTRAINT user_notification_configs_pkey
PRIMARY KEY (user_id);
ALTER TABLE user_notification_configs ADD CONSTRAINT fk_notification_configs_user
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
CREATE INDEX idx_notification_configs_enabled
ON user_notification_configs(notification_enabled);
COMMIT;

View File

@@ -4,15 +4,24 @@ DROP TABLE IF EXISTS alert_rules CASCADE;
DROP TABLE IF EXISTS address_checkpoints CASCADE; DROP TABLE IF EXISTS address_checkpoints CASCADE;
DROP TABLE IF EXISTS user_notification_configs CASCADE; DROP TABLE IF EXISTS user_notification_configs CASCADE;
DROP TABLE IF EXISTS addresses CASCADE; DROP TABLE IF EXISTS addresses CASCADE;
DROP TABLE IF EXISTS users CASCADE;
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
firebase_uid VARCHAR(128) NOT NULL UNIQUE,
email VARCHAR(255) NOT NULL,
display_name VARCHAR(255),
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE addresses ( CREATE TABLE addresses (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
user_id VARCHAR(128) NOT NULL, -- Firebase user ID (multi-user support) user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
address VARCHAR(42) NOT NULL, -- Ethereum address format (0x + 40 hex chars) address VARCHAR(42) NOT NULL, -- Ethereum address format (0x + 40 hex chars)
label VARCHAR(255), -- Optional human-readable label label VARCHAR(255), -- Optional human-readable label
created_at TIMESTAMP DEFAULT NOW(), created_at TIMESTAMP DEFAULT NOW(),
-- Unique users can track the same address independently
UNIQUE(user_id, address) UNIQUE(user_id, address)
); );
@@ -51,7 +60,7 @@ CREATE TABLE address_checkpoints (
); );
CREATE TABLE user_notification_configs ( CREATE TABLE user_notification_configs (
user_id VARCHAR(128) PRIMARY KEY, user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
discord_webhook_url TEXT, -- Discord webhook URL (nullable) discord_webhook_url TEXT, -- Discord webhook URL (nullable)
telegram_chat_id VARCHAR(128), -- Telegram chat ID (nullable) telegram_chat_id VARCHAR(128), -- Telegram chat ID (nullable)
telegram_bot_token VARCHAR(255), -- Telegram bot token (nullable) telegram_bot_token VARCHAR(255), -- Telegram bot token (nullable)
@@ -63,6 +72,7 @@ CREATE TABLE user_notification_configs (
); );
-- Create indexes for common queries -- Create indexes for common queries
CREATE INDEX idx_users_firebase_uid ON users(firebase_uid);
CREATE INDEX idx_addresses_user_id ON addresses(user_id); CREATE INDEX idx_addresses_user_id ON addresses(user_id);
CREATE INDEX idx_alert_rules_address_id ON alert_rules(address_id); CREATE INDEX idx_alert_rules_address_id ON alert_rules(address_id);
CREATE INDEX idx_alert_rules_enabled ON alert_rules(enabled); CREATE INDEX idx_alert_rules_enabled ON alert_rules(enabled);

View File

@@ -2,6 +2,15 @@ package domain
import "time" import "time"
type User struct {
ID string `json:"id"`
FirebaseUID string `json:"-"`
Email string `json:"email"`
DisplayName *string `json:"display_name"` //nolint:tagliatelle
CreatedAt time.Time `json:"created_at"` //nolint:tagliatelle
UpdatedAt time.Time `json:"updated_at"` //nolint:tagliatelle
}
type Address struct { type Address struct {
ID int `json:"id"` ID int `json:"id"`
UserID string `json:"user_id"` //nolint:tagliatelle UserID string `json:"user_id"` //nolint:tagliatelle

View File

@@ -9,6 +9,7 @@ import (
"strings" "strings"
fbauth "github.com/kjannette/koin-ping/backend-go/internal/firebase" fbauth "github.com/kjannette/koin-ping/backend-go/internal/firebase"
"github.com/kjannette/koin-ping/backend-go/internal/models"
) )
type contextKey string type contextKey string
@@ -26,10 +27,14 @@ type errorResponse struct {
func writeJSON(w http.ResponseWriter, status int, v interface{}) { func writeJSON(w http.ResponseWriter, status int, v interface{}) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status) w.WriteHeader(status)
json.NewEncoder(w).Encode(v) json.NewEncoder(w).Encode(v) //nolint:errcheck
} }
func Authenticate(next http.Handler) http.Handler { // Authenticate verifies the Firebase ID token and auto-provisions a local user
// record. The local user UUID (not the Firebase UID) is placed into context so
// all downstream handlers use it as the canonical user identifier.
func Authenticate(userModel *models.UserModel) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization") authHeader := r.Header.Get("Authorization")
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") { if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
@@ -72,17 +77,28 @@ func Authenticate(next http.Handler) http.Handler {
return return
} }
userID := decoded.UID firebaseUID := decoded.UID
email, _ := decoded.Claims["email"].(string) email, _ := decoded.Claims["email"].(string)
log.Printf("Token verified! User ID: %s, Email: %s", userID, email) user, err := userModel.FindOrCreateByFirebaseUID(r.Context(), firebaseUID, email)
if err != nil {
log.Printf("Failed to provision local user for Firebase UID %s: %v", firebaseUID, err)
writeJSON(w, http.StatusInternalServerError, errorResponse{
Error: "INTERNAL_ERROR",
Message: "Failed to initialize user account",
})
return
}
ctx := context.WithValue(r.Context(), UserIDKey, userID) log.Printf("Token verified! User UUID: %s, Email: %s", user.ID, email)
ctx := context.WithValue(r.Context(), UserIDKey, user.ID)
ctx = context.WithValue(ctx, UserEmailKey, email) ctx = context.WithValue(ctx, UserEmailKey, email)
next.ServeHTTP(w, r.WithContext(ctx)) next.ServeHTTP(w, r.WithContext(ctx))
}) })
} }
}
func GetUserID(ctx context.Context) string { func GetUserID(ctx context.Context) string {
if v, ok := ctx.Value(UserIDKey).(string); ok { if v, ok := ctx.Value(UserIDKey).(string); ok {

View File

@@ -0,0 +1,53 @@
package models
import (
"context"
"errors"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/kjannette/koin-ping/backend-go/internal/domain"
)
type UserModel struct {
pool *pgxpool.Pool
}
func NewUserModel(pool *pgxpool.Pool) *UserModel {
return &UserModel{pool: pool}
}
// FindOrCreateByFirebaseUID returns the local user for a Firebase UID,
// creating one if it doesn't exist yet. On conflict (returning user) the
// updated_at timestamp is refreshed.
func (m *UserModel) FindOrCreateByFirebaseUID(ctx context.Context, firebaseUID, email string) (*domain.User, error) {
var u domain.User
err := m.pool.QueryRow(ctx,
`INSERT INTO users (firebase_uid, email)
VALUES ($1, $2)
ON CONFLICT (firebase_uid) DO UPDATE SET updated_at = NOW()
RETURNING id, firebase_uid, email, display_name, created_at, updated_at`,
firebaseUID, email,
).Scan(&u.ID, &u.FirebaseUID, &u.Email, &u.DisplayName, &u.CreatedAt, &u.UpdatedAt)
if err != nil {
return nil, err
}
return &u, nil
}
func (m *UserModel) GetByID(ctx context.Context, id string) (*domain.User, error) {
var u domain.User
err := m.pool.QueryRow(ctx,
`SELECT id, firebase_uid, email, display_name, created_at, updated_at
FROM users
WHERE id = $1`,
id,
).Scan(&u.ID, &u.FirebaseUID, &u.Email, &u.DisplayName, &u.CreatedAt, &u.UpdatedAt)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, err
}
return &u, nil
}

BIN
frontend/public/ping.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

View File

@@ -37,7 +37,7 @@ export default function AddressForm({ onSubmit }) {
placeholder="Treasury, Cold Wallet, etc." placeholder="Treasury, Cold Wallet, etc."
/> />
<Button type="submit" disabled={!canSubmit}> <Button type="submit" disabled={!canSubmit} className="mt-md">
Add Address Add Address
</Button> </Button>
</form> </form>

View File

@@ -0,0 +1,7 @@
.alert-form__options {
margin-bottom: 1rem;
}
.alert-form__radio {
display: block;
}

View File

@@ -1,6 +1,7 @@
import { useState } from "react"; import { useState } from "react";
import Input from "./Input"; import Input from "./Input";
import Button from "./Button"; import Button from "./Button";
import "./AlertForm.css";
const ALERT_TYPES = [ const ALERT_TYPES = [
{ value: "incoming_tx", label: "Incoming transaction" }, { value: "incoming_tx", label: "Incoming transaction" },
@@ -36,9 +37,9 @@ export default function AlertForm({ onSubmit }) {
return ( return (
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<div style={{ marginBottom: "1rem" }}> <div className="alert-form__options">
{ALERT_TYPES.map((opt) => ( {ALERT_TYPES.map((opt) => (
<label key={opt.value} style={{ display: "block" }}> <label key={opt.value} className="alert-form__radio">
<input <input
type="radio" type="radio"
name="alertType" name="alertType"

View File

@@ -0,0 +1,14 @@
.btn {
padding: 0.5rem 1rem;
font-size: 1rem;
border: none;
border-radius: var(--radius-md);
cursor: pointer;
color: var(--color-text);
transition: opacity 0.15s ease;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}

View File

@@ -1,20 +1,19 @@
import "./Button.css";
export default function Button({ export default function Button({
children, children,
onClick, onClick,
disabled = false, disabled = false,
type = "button", type = "button",
variant = "primary",
className = "",
}) { }) {
return ( return (
<button <button
type={type} type={type}
onClick={onClick} onClick={onClick}
disabled={disabled} disabled={disabled}
style={{ className={`btn btn--${variant} ${className}`}
padding: "0.5rem 1rem",
fontSize: "1rem",
cursor: disabled ? "not-allowed" : "pointer",
opacity: disabled ? 0.6 : 1,
}}
> >
{children} {children}
</button> </button>

View File

@@ -0,0 +1,21 @@
.input__label {
display: block;
margin-bottom: 0.4rem;
font-size: 0.9rem;
}
.input__control {
width: 100%;
padding: 0.5rem;
font-size: 1rem;
background-color: var(--color-bg-elevated);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
color: var(--color-text);
}
.input__control:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 2px rgba(0, 102, 204, 0.25);
}

View File

@@ -1,3 +1,5 @@
import "./Input.css";
export default function Input({ export default function Input({
label, label,
type = "text", type = "text",
@@ -7,12 +9,12 @@ export default function Input({
step, step,
min, min,
disabled = false, disabled = false,
required = false,
className = "",
}) { }) {
return ( return (
<label style={{ display: "block", marginBottom: "1rem" }}> <label className={`form-field ${className}`}>
<div style={{ marginBottom: "0.25rem", fontSize: "0.9rem" }}> <div className="input__label">{label}</div>
{label}
</div>
<input <input
type={type} type={type}
value={value} value={value}
@@ -20,12 +22,9 @@ export default function Input({
step={step} step={step}
min={min} min={min}
disabled={disabled} disabled={disabled}
required={required}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
style={{ className="input__control"
width: "100%",
padding: "0.5rem",
fontSize: "1rem",
}}
/> />
</label> </label>
); );

View File

@@ -0,0 +1,56 @@
.navbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 2rem;
margin-bottom: 1.5rem;
background-color: var(--color-bg);
border-bottom: 1px solid var(--color-border-light);
}
.navbar__brand {
font-weight: 700;
font-size: 1.4rem;
color: var(--color-brand);
margin-right: 2rem;
letter-spacing: 0.5px;
}
.navbar__links {
display: flex;
gap: 0.25rem;
}
.navbar__link {
padding: 0.5rem 1.25rem;
border-radius: var(--radius-lg);
text-decoration: none;
font-size: 1.425rem;
font-weight: 200;
color: var(--color-text-subtle);
background-color: transparent;
transition: all 0.15s ease;
}
.navbar__link--active {
font-weight: 600;
color: var(--color-text);
background-color: var(--color-bg-card);
}
.navbar__user {
font-size: 1.275rem;
color: #777;
}
.navbar__logout {
padding: 0.4rem 1rem;
font-size: 1.275rem;
font-weight: 400;
background-color: transparent;
color: var(--color-text-subtle);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
cursor: pointer;
transition: all 0.15s ease;
}

View File

@@ -1,5 +1,6 @@
import { Link, useLocation } from "react-router-dom"; import { Link, useLocation } from "react-router-dom";
import { useAuth } from "../contexts/AuthContext"; import { useAuth } from "../contexts/AuthContext";
import "./Navbar.css";
const navLinks = [ const navLinks = [
{ to: "/addresses", label: "Addresses" }, { to: "/addresses", label: "Addresses" },
@@ -14,43 +15,17 @@ export default function Navbar() {
if (!currentUser) return null; if (!currentUser) return null;
return ( return (
<nav style={{ <nav className="navbar">
display: "flex", <div className="flex flex--center gap-sm">
justifyContent: "space-between", <span className="navbar__brand">Koin Ping</span>
alignItems: "center", <div className="navbar__links">
padding: "1rem 2rem",
marginBottom: "1.5rem",
backgroundColor: "#1a1a1a",
borderBottom: "1px solid #333",
}}>
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
<span style={{
fontWeight: 700,
fontSize: "1.1rem",
color: "#fff",
marginRight: "2rem",
letterSpacing: "0.5px",
}}>
Koin Ping
</span>
<div style={{ display: "flex", gap: "0.25rem" }}>
{navLinks.map(({ to, label }) => { {navLinks.map(({ to, label }) => {
const isActive = location.pathname === to; const isActive = location.pathname === to;
return ( return (
<Link <Link
key={to} key={to}
to={to} to={to}
style={{ className={`navbar__link ${isActive ? "navbar__link--active" : ""}`}
padding: "0.5rem 1.25rem",
borderRadius: "6px",
textDecoration: "none",
fontSize: "1.425rem",
fontWeight: isActive ? 600 : 200,
color: isActive ? "#fff" : "#999",
backgroundColor: isActive ? "#333" : "transparent",
transition: "all 0.15s ease",
}}
> >
{label} {label}
</Link> </Link>
@@ -59,24 +34,9 @@ export default function Navbar() {
</div> </div>
</div> </div>
<div style={{ display: "flex", alignItems: "center", gap: "1.25rem" }}> <div className="flex flex--center gap-lg">
<span style={{ fontSize: "1.275rem", color: "#777" }}> <span className="navbar__user">{currentUser.email}</span>
{currentUser.email} <button onClick={logout} className="navbar__logout">
</span>
<button
onClick={logout}
style={{
padding: "0.4rem 1rem",
fontSize: "1.275rem",
fontWeight: 400,
backgroundColor: "transparent",
color: "#999",
border: "1px solid #444",
borderRadius: "6px",
cursor: "pointer",
transition: "all 0.15s ease",
}}
>
Logout Logout
</button> </button>
</div> </div>

View File

@@ -1,5 +1,43 @@
@import url('https://fonts.googleapis.com/css2?family=Nunito:ital,wght@0,200..1000;1,200..1000&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Nunito:ital,wght@0,200..1000;1,200..1000&display=swap');
:root {
--color-primary: #0066cc;
--color-primary-hover: #0077ee;
--color-brand: #e62525;
--color-success: #28a745;
--color-success-text: #00ff00;
--color-success-bg: #00ff0020;
--color-success-border: #00ff00;
--color-error: #ff6666;
--color-error-bg: #ff000020;
--color-error-border: #ff0000;
--color-warning: #ffcc44;
--color-warning-bg: #3a2e00;
--color-warning-border: #aa7700;
--color-danger: #cc6666;
--color-danger-border: #884444;
--color-bg: #1a1a1a;
--color-bg-elevated: #242424;
--color-bg-card: #333;
--color-bg-section: #2a2a2a;
--color-border: #444;
--color-border-light: #333;
--color-border-section: #3a3a3a;
--color-text: white;
--color-text-muted: #b3b3b3;
--color-text-dimmed: #808080;
--color-text-subtle: #999;
--color-text-label: #ccc;
--radius-sm: 3px;
--radius-md: 4px;
--radius-lg: 6px;
--radius-xl: 8px;
}
*, *,
*::before, *::before,
*::after { *::after {
@@ -21,13 +59,300 @@ body {
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
} }
h1, h2, h3, h4, h5, h6 { h1,
h2,
h3,
h4,
h5,
h6 {
font-family: 'Nunito', sans-serif; font-family: 'Nunito', sans-serif;
font-weight: 200; font-weight: 200;
} }
input, select, textarea, button { input,
select,
textarea,
button {
font-family: 'Nunito', sans-serif; font-family: 'Nunito', sans-serif;
font-size: 1rem; font-size: 1rem;
font-weight: 200; font-weight: 200;
} }
/* ── Page Layouts ─────────────────────────────────────────── */
.page {
max-width: 800px;
margin: 0 auto;
padding: 2rem;
}
.page--wide {
max-width: 1400px;
}
/* ── Cards & Sections ─────────────────────────────────────── */
.card {
padding: 1rem;
background-color: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
}
.section {
margin-bottom: 1.5rem;
padding: 1rem;
background-color: var(--color-bg-section);
border-radius: var(--radius-lg);
border: 1px solid var(--color-border-section);
}
/* ── Form Controls ────────────────────────────────────────── */
.form-field {
margin-bottom: 1.25rem;
}
.form-field--last {
margin-bottom: 1.75rem;
}
.form-label {
display: block;
margin-bottom: 0.25rem;
font-size: 0.9rem;
color: var(--color-text-label);
}
.form-control {
width: 100%;
padding: 0.5rem;
font-size: 1rem;
background-color: var(--color-bg-elevated);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
color: var(--color-text);
}
.form-control:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 2px rgba(0, 102, 204, 0.25);
}
.form-control--mono {
font-family: monospace;
}
.form-select {
width: 100%;
padding: 0.5rem;
font-size: 1rem;
background-color: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
color: var(--color-text);
}
/* ── Buttons ──────────────────────────────────────────────── */
.btn {
padding: 0.5rem 1rem;
font-size: 1rem;
border: none;
border-radius: var(--radius-md);
cursor: pointer;
color: var(--color-text);
transition: opacity 0.15s ease;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn--primary {
background-color: var(--color-primary);
}
.btn--success {
background-color: var(--color-success);
}
.btn--secondary {
background-color: #6c757d;
}
.btn--danger {
background-color: transparent;
border: 1px solid var(--color-danger-border);
color: var(--color-danger);
}
.btn--ghost {
background-color: transparent;
border: 1px solid var(--color-border);
color: var(--color-text-subtle);
}
.btn--sm {
padding: 0.25rem 0.6rem;
font-size: 0.85rem;
}
.btn--lg {
padding: 0.75rem 1.5rem;
}
/* ── Feedback Alerts ──────────────────────────────────────── */
.alert {
padding: 0.75rem;
margin-bottom: 1rem;
border-radius: var(--radius-md);
}
.alert--error {
background-color: var(--color-error-bg);
border: 1px solid var(--color-error-border);
color: var(--color-error);
}
.alert--success {
background-color: var(--color-success-bg);
border: 1px solid var(--color-success-border);
color: var(--color-success-text);
}
.alert--warning {
background-color: var(--color-warning-bg);
border: 1px solid var(--color-warning-border);
color: var(--color-warning);
}
/* ── Lists ────────────────────────────────────────────────── */
.list-unstyled {
list-style: none;
padding: 0;
}
.list-item--card {
padding: 1rem;
margin-bottom: 0.5rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background-color: var(--color-bg-card);
}
/* ── Typography Helpers ───────────────────────────────────── */
.text-muted {
color: var(--color-text-muted);
}
.text-dimmed {
color: var(--color-text-dimmed);
}
.text-mono {
font-family: monospace;
}
.text-bold {
font-weight: bold;
}
.text-error {
color: red;
}
.text-sm {
font-size: 1.035rem;
}
.text-xs {
font-size: 0.978rem;
}
.help-link {
color: var(--color-primary);
font-size: 0.85rem;
}
/* ── Flex Utilities ───────────────────────────────────────── */
.flex {
display: flex;
}
.flex--between {
justify-content: space-between;
}
.flex--center {
align-items: center;
}
.flex--column {
flex-direction: column;
}
.flex--wrap {
flex-wrap: wrap;
}
.gap-xs {
gap: 0.25rem;
}
.gap-sm {
gap: 0.5rem;
}
.gap-md {
gap: 0.75rem;
}
.gap-lg {
gap: 1rem;
}
/* ── Spacing Helpers ──────────────────────────────────────── */
.mb-0 {
margin-bottom: 0;
}
.mb-sm {
margin-bottom: 0.5rem;
}
.mb-md {
margin-bottom: 1rem;
}
.mb-lg {
margin-bottom: 1.5rem;
}
.mb-xl {
margin-bottom: 2rem;
}
.mt-0 {
margin-top: 0;
}
.mt-md {
margin-top: 1rem;
}
.text-white {
color: white;
}
.text-white-sm {
color: white;
font-size: 0.85rem;
}

View File

@@ -0,0 +1,30 @@
.address__edit-input {
background: var(--color-border);
border: 1px solid #666;
border-radius: var(--radius-sm);
color: var(--color-text);
padding: 0.25rem 0.5rem;
font-size: 0.9rem;
}
.address__edit-link {
cursor: pointer;
background: transparent;
border: none;
color: #6699cc;
font-size: 0.8rem;
padding: 0;
text-decoration: underline;
}
.address__remove {
cursor: pointer;
background: transparent;
border: 1px solid var(--color-danger-border);
color: var(--color-danger);
border-radius: var(--radius-sm);
padding: 0.3rem 0.7rem;
font-size: 0.85rem;
margin-left: 1rem;
flex-shrink: 0;
}

View File

@@ -1,6 +1,7 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import AddressForm from "../components/AddressForm"; import AddressForm from "../components/AddressForm";
import { getAddresses, createAddress, deleteAddress, updateAddress } from "../api/addresses"; import { getAddresses, createAddress, deleteAddress, updateAddress } from "../api/addresses";
import "./Addresses.css";
export default function Addresses() { export default function Addresses() {
const [addresses, setAddresses] = useState([]); const [addresses, setAddresses] = useState([]);
@@ -9,7 +10,6 @@ export default function Addresses() {
const [editingId, setEditingId] = useState(null); const [editingId, setEditingId] = useState(null);
const [editLabel, setEditLabel] = useState(""); const [editLabel, setEditLabel] = useState("");
// Load addresses on mount
useEffect(() => { useEffect(() => {
async function fetchAddresses() { async function fetchAddresses() {
try { try {
@@ -27,7 +27,6 @@ export default function Addresses() {
fetchAddresses(); fetchAddresses();
}, []); }, []);
// Handle new address submission
async function handleAddressSubmit(data) { async function handleAddressSubmit(data) {
try { try {
const newAddress = await createAddress(data); const newAddress = await createAddress(data);
@@ -78,51 +77,35 @@ export default function Addresses() {
} }
return ( return (
<div style={{ maxWidth: "800px", margin: "0 auto", padding: "2rem" }}> <div className="page">
<h1>Tracked Addresses</h1> <h1>Add Addresses to Track</h1>
<div style={{ marginBottom: "2rem" }}> <div className="mb-xl">
<AddressForm onSubmit={handleAddressSubmit} /> <AddressForm onSubmit={handleAddressSubmit} />
</div> </div>
<div> <div>
<h2>Existing Addresses</h2> <h2>Existing Tracked Addresses</h2>
{loading && <p>Loading addresses...</p>} {loading && <p>Loading addresses...</p>}
{error && <p style={{ color: "red" }}>Error: {error}</p>} {error && <p className="text-error">Error: {error}</p>}
{!loading && !error && addresses.length === 0 && ( {!loading && !error && addresses.length === 0 && (
<p style={{ color: "#808080" }}> <p className="text-dimmed">
No addresses tracked yet. Add one above to get started. No addresses tracked yet. Add one above to get started.
</p> </p>
)} )}
{addresses.length > 0 && ( {addresses.length > 0 && (
<ul style={{ listStyle: "none", padding: 0 }}> <ul className="list-unstyled">
{addresses.map((addr, index) => ( {addresses.map((addr, index) => (
<li <li key={addr.id || index} className="list-item--card">
key={addr.id || index} <div className="flex flex--between flex--center">
style={{ <div>
padding: "1rem",
marginBottom: "0.5rem",
border: "1px solid #444",
borderRadius: "4px",
backgroundColor: "#333",
}}
>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<div style={{ flex: 1 }}>
{editingId === addr.id ? ( {editingId === addr.id ? (
<div style={{ display: "flex", gap: "0.5rem", alignItems: "center", marginBottom: "0.25rem" }}> <div className="flex flex--center gap-sm mb-sm">
<input <input
value={editLabel} value={editLabel}
onChange={(e) => setEditLabel(e.target.value)} onChange={(e) => setEditLabel(e.target.value)}
placeholder="Label (optional)" placeholder="Label (optional)"
style={{ className="address__edit-input"
background: "#444",
border: "1px solid #666",
borderRadius: "3px",
color: "#fff",
padding: "0.25rem 0.5rem",
fontSize: "0.9rem",
}}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter") handleEditSave(addr.id); if (e.key === "Enter") handleEditSave(addr.id);
if (e.key === "Escape") handleEditCancel(); if (e.key === "Escape") handleEditCancel();
@@ -131,61 +114,37 @@ export default function Addresses() {
/> />
<button <button
onClick={() => handleEditSave(addr.id)} onClick={() => handleEditSave(addr.id)}
style={{ cursor: "pointer", padding: "0.25rem 0.6rem", fontSize: "0.85rem" }} className="btn btn--primary btn--sm"
> >
Save Save
</button> </button>
<button <button
onClick={handleEditCancel} onClick={handleEditCancel}
style={{ cursor: "pointer", padding: "0.25rem 0.6rem", fontSize: "0.85rem", background: "transparent", color: "#aaa", border: "1px solid #555" }} className="btn btn--ghost btn--sm"
> >
Cancel Cancel
</button> </button>
</div> </div>
) : ( ) : (
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem", marginBottom: "0.25rem" }}> <div className="flex flex--center gap-sm mb-sm">
<span style={{ fontWeight: "bold" }}> <span className="text-bold">
{addr.label || "Unlabeled"} {addr.label || "Unlabeled"}
</span> </span>
<button <button
onClick={() => handleEditStart(addr)} onClick={() => handleEditStart(addr)}
style={{ className="address__edit-link"
cursor: "pointer",
background: "transparent",
border: "none",
color: "#6699cc",
fontSize: "0.8rem",
padding: "0",
textDecoration: "underline",
}}
> >
Edit Edit
</button> </button>
</div> </div>
)} )}
<div <div className="text-mono text-sm text-muted">
style={{
fontFamily: "monospace",
fontSize: "1.035rem",
color: "#b3b3b3",
}}
>
{addr.address} {addr.address}
</div> </div>
</div> </div>
<button <button
onClick={() => handleDelete(addr.id, addr.label)} onClick={() => handleDelete(addr.id, addr.label)}
style={{ className="address__remove"
cursor: "pointer",
background: "transparent",
border: "1px solid #884444",
color: "#cc6666",
borderRadius: "3px",
padding: "0.3rem 0.7rem",
fontSize: "0.85rem",
marginLeft: "1rem",
flexShrink: 0,
}}
> >
Remove Remove
</button> </button>

View File

@@ -0,0 +1,7 @@
.alert-history__item {
padding: 1rem;
margin-bottom: 0.75rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background-color: var(--color-bg-card);
}

View File

@@ -1,12 +1,12 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { getAlertEvents } from "../api/alertEvents"; import { getAlertEvents } from "../api/alertEvents";
import "./AlertHistory.css";
export default function AlertHistory() { export default function AlertHistory() {
const [alertEvents, setAlertEvents] = useState([]); const [alertEvents, setAlertEvents] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(null); const [error, setError] = useState(null);
// Fetch alert events on mount
useEffect(() => { useEffect(() => {
async function fetchAlertEvents() { async function fetchAlertEvents() {
try { try {
@@ -25,49 +25,32 @@ export default function AlertHistory() {
}, []); }, []);
if (loading) { if (loading) {
return <div style={{ padding: "2rem" }}>Loading...</div>; return <div className="page">Loading...</div>;
} }
if (error) { if (error) {
return ( return (
<div style={{ padding: "2rem", color: "red" }}>Error: {error}</div> <div className="page text-error">Error: {error}</div>
); );
} }
return ( return (
<div style={{ maxWidth: "800px", margin: "0 auto", padding: "2rem" }}> <div className="page">
<h1>Recent Alert Events</h1> <h1>Recent Alert Events</h1>
{alertEvents.length === 0 ? ( {alertEvents.length === 0 ? (
<p style={{ color: "#808080" }}>No alerts yet</p> <p className="text-dimmed">No alerts yet</p>
) : ( ) : (
<ul style={{ listStyle: "none", padding: 0 }}> <ul className="list-unstyled">
{alertEvents.map((event) => ( {alertEvents.map((event) => (
<li <li key={event.id} className="alert-history__item">
key={event.id} <div className="mb-sm">{event.message}</div>
style={{
padding: "1rem",
marginBottom: "0.75rem",
border: "1px solid #444",
borderRadius: "4px",
backgroundColor: "#333",
}}
>
<div style={{ marginBottom: "0.5rem" }}>
{event.message}
</div>
{event.address_label && ( {event.address_label && (
<div <div className="text-sm text-dimmed">
style={{
fontSize: "1.035rem",
color: "#808080",
marginBottom: "0.25rem",
}}
>
Address: {event.address_label} Address: {event.address_label}
</div> </div>
)} )}
<small style={{ color: "#b3b3b3" }}> <small className="text-muted">
{formatTimestamp(event.timestamp)} {formatTimestamp(event.timestamp)}
</small> </small>
</li> </li>
@@ -78,7 +61,6 @@ export default function AlertHistory() {
); );
} }
// Helper to format timestamp for display
function formatTimestamp(timestamp) { function formatTimestamp(timestamp) {
const date = new Date(timestamp); const date = new Date(timestamp);
return date.toLocaleString(); return date.toLocaleString();

View File

@@ -0,0 +1,63 @@
.alerts-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2rem;
align-items: start;
}
.alerts__address-info {
padding: 1rem;
margin-bottom: 2rem;
background-color: var(--color-bg-card);
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
}
.alerts__toggle-panel {
margin-bottom: 1.5rem;
padding: 1rem;
background-color: var(--color-bg-card);
border-radius: var(--radius-md);
}
.alerts__toggle-label {
display: flex;
align-items: center;
cursor: pointer;
}
.alerts__toggle-checkbox {
margin-right: 0.5rem;
width: 18px;
height: 18px;
}
.alerts__toggle-hint {
font-size: 0.978rem;
color: var(--color-text-dimmed);
margin-top: 0.5rem;
margin-left: 26px;
}
.alerts__rule {
padding: 1rem;
margin-bottom: 0.5rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background-color: var(--color-bg-card);
}
.alerts__rule--disabled {
opacity: 0.6;
}
.alerts__email-buttons {
display: flex;
gap: 0.5rem;
}
.alerts__save-test {
display: flex;
gap: 0.75rem;
margin-top: 1rem;
}

View File

@@ -1,6 +1,7 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import AlertForm from "../components/AlertForm"; import AlertForm from "../components/AlertForm";
import Button from "../components/Button"; import Button from "../components/Button";
import Input from "../components/Input";
import { getAddresses } from "../api/addresses"; import { getAddresses } from "../api/addresses";
import { import {
getAlerts, getAlerts,
@@ -15,35 +16,7 @@ import {
setupEmail, setupEmail,
sendEmailDigest, sendEmailDigest,
} from "../api/notificationConfig"; } from "../api/notificationConfig";
import "./Alerts.css";
const inputStyle = {
width: "100%",
padding: "0.5rem",
fontSize: "0.9rem",
backgroundColor: "#1a1a1a",
border: "1px solid #444",
borderRadius: "4px",
color: "white",
fontFamily: "monospace",
boxSizing: "border-box",
};
const labelStyle = {
display: "block",
marginBottom: "0.25rem",
fontSize: "0.9rem",
color: "#ccc",
};
const helpLinkStyle = { color: "#0066cc", fontSize: "0.85rem" };
const sectionStyle = {
marginBottom: "1.5rem",
padding: "1rem",
backgroundColor: "#2a2a2a",
borderRadius: "6px",
border: "1px solid #3a3a3a",
};
export default function Alerts() { export default function Alerts() {
const [addresses, setAddresses] = useState([]); const [addresses, setAddresses] = useState([]);
@@ -52,7 +25,6 @@ export default function Alerts() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(null); const [error, setError] = useState(null);
// Notification config state
const [notificationEnabled, setNotificationEnabled] = useState(false); const [notificationEnabled, setNotificationEnabled] = useState(false);
const [discordWebhookUrl, setDiscordWebhookUrl] = useState(""); const [discordWebhookUrl, setDiscordWebhookUrl] = useState("");
const [telegramBotToken, setTelegramBotToken] = useState(""); const [telegramBotToken, setTelegramBotToken] = useState("");
@@ -273,42 +245,28 @@ export default function Alerts() {
const selectedAddress = addresses.find((a) => a.id === selectedAddressId); const selectedAddress = addresses.find((a) => a.id === selectedAddressId);
if (loading) { if (loading) {
return <div style={{ padding: "2rem" }}>Loading addresses...</div>; return <div className="page">Loading addresses...</div>;
} }
if (addresses.length === 0) { if (addresses.length === 0) {
return ( return (
<div style={{ padding: "2rem" }}> <div className="page">
<p> <p>No addresses tracked yet. Add an address first to create alerts.</p>
No addresses tracked yet. Add an address first to create
alerts.
</p>
</div> </div>
); );
} }
return ( return (
<div style={{ maxWidth: "1400px", margin: "0 auto", padding: "2rem" }}> <div className="page page--wide">
<h1 style={{ marginBottom: "2rem" }}> <h1 className="mb-xl">Alert Rules & Notifications</h1>
Alert Rules & Notifications
</h1>
<div <div className="alerts-grid">
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: "2rem",
alignItems: "start",
}}
>
{/* LEFT COLUMN: Alert Rules */} {/* LEFT COLUMN: Alert Rules */}
<div> <div>
<h2 style={{ marginTop: 0 }}>Alert Rules</h2> <h2 className="mt-0">Alert Rules</h2>
<div style={{ marginBottom: "2rem" }}> <div className="mb-xl">
<label <label className="form-label mb-sm">
style={{ display: "block", marginBottom: "0.5rem" }}
>
<strong>Select Address:</strong> <strong>Select Address:</strong>
</label> </label>
<select <select
@@ -316,15 +274,7 @@ export default function Alerts() {
onChange={(e) => onChange={(e) =>
setSelectedAddressId(Number(e.target.value)) setSelectedAddressId(Number(e.target.value))
} }
style={{ className="form-select"
width: "100%",
padding: "0.5rem",
fontSize: "1rem",
backgroundColor: "#1a1a1a",
border: "1px solid #444",
borderRadius: "4px",
color: "white",
}}
> >
{addresses.map((addr) => ( {addresses.map((addr) => (
<option key={addr.id} value={addr.id}> <option key={addr.id} value={addr.id}>
@@ -336,43 +286,19 @@ export default function Alerts() {
{selectedAddress && ( {selectedAddress && (
<> <>
<div <div className="alerts__address-info">
style={{ <div className="text-sm text-muted">
padding: "1rem",
marginBottom: "2rem",
backgroundColor: "#333",
borderRadius: "4px",
border: "1px solid #444",
}}
>
<div
style={{
fontSize: "1.035rem",
color: "#b3b3b3",
}}
>
Managing alerts for: Managing alerts for:
</div> </div>
<div <div className="text-bold">
style={{
fontWeight: "bold",
marginTop: "0.25rem",
}}
>
{selectedAddress.label || "Unlabeled"} {selectedAddress.label || "Unlabeled"}
</div> </div>
<div <div className="text-mono text-sm text-muted">
style={{
fontFamily: "monospace",
fontSize: "1.035rem",
color: "#b3b3b3",
}}
>
{selectedAddress.address} {selectedAddress.address}
</div> </div>
</div> </div>
<div style={{ marginBottom: "2rem" }}> <div className="mb-xl">
<h3>Create New Alert</h3> <h3>Create New Alert</h3>
<AlertForm onSubmit={handleAlertSubmit} /> <AlertForm onSubmit={handleAlertSubmit} />
</div> </div>
@@ -380,112 +306,43 @@ export default function Alerts() {
<div> <div>
<h3>Active Alert Rules</h3> <h3>Active Alert Rules</h3>
{error && ( {error && (
<p style={{ color: "red" }}> <p className="text-error">Error: {error}</p>
Error: {error}
</p>
)} )}
{alerts.length === 0 ? ( {alerts.length === 0 ? (
<p style={{ color: "#666" }}> <p className="text-dimmed">
No alert rules defined yet. Create one No alert rules defined yet. Create one above.
above.
</p> </p>
) : ( ) : (
<ul <ul className="list-unstyled">
style={{
listStyle: "none",
padding: 0,
}}
>
{alerts.map((alert) => ( {alerts.map((alert) => (
<li <li
key={alert.id} key={alert.id}
style={{ className={`alerts__rule ${!alert.enabled ? "alerts__rule--disabled" : ""}`}
padding: "1rem",
marginBottom: "0.5rem",
border: "1px solid #444",
borderRadius: "4px",
backgroundColor: "#333",
opacity: alert.enabled
? 1
: 0.6,
}}
> >
<div <div className="flex flex--between">
style={{ <div>
display: "flex", <div className="text-bold mb-sm">
justifyContent: {formatAlertType(alert.type)}
"space-between",
alignItems:
"flex-start",
}}
>
<div style={{ flex: 1 }}>
<div
style={{
fontWeight:
"bold",
marginBottom:
"0.25rem",
}}
>
{formatAlertType(
alert.type,
)}
</div> </div>
{alert.threshold && ( {alert.threshold && (
<div <div className="text-sm text-muted">
style={{ Threshold: {alert.threshold} ETH
fontSize:
"1.035rem",
color: "#b3b3b3",
}}
>
Threshold:{" "}
{
alert.threshold
}{" "}
ETH
</div> </div>
)} )}
<div <div className="text-xs text-dimmed">
style={{ Status: {alert.enabled ? "Enabled" : "Disabled"}
fontSize:
"0.978rem",
color: "#808080",
marginTop:
"0.25rem",
}}
>
Status:{" "}
{alert.enabled
? "Enabled"
: "Disabled"}
</div> </div>
</div> </div>
<div <div className="flex flex--center gap-sm">
style={{
display: "flex",
gap: "0.5rem",
}}
>
<Button <Button
onClick={() => onClick={() =>
handleToggleAlert( handleToggleAlert(alert.id, alert.enabled)
alert.id,
alert.enabled,
)
} }
> >
{alert.enabled {alert.enabled ? "Disable" : "Enable"}
? "Disable"
: "Enable"}
</Button> </Button>
<Button <Button
onClick={() => onClick={() => handleDeleteAlert(alert.id)}
handleDeleteAlert(
alert.id,
)
}
> >
Delete Delete
</Button> </Button>
@@ -502,329 +359,152 @@ export default function Alerts() {
{/* RIGHT COLUMN: Notification Settings */} {/* RIGHT COLUMN: Notification Settings */}
<div> <div>
<h2 style={{ marginTop: 0 }}>Notification Settings</h2> <h2 className="mt-0">Notification Settings</h2>
{notificationSuccess && ( {notificationSuccess && (
<div <div className="alert alert--success">{notificationSuccess}</div>
style={{
padding: "0.75rem",
marginBottom: "1rem",
backgroundColor: "#00ff0020",
border: "1px solid #00ff00",
borderRadius: "4px",
color: "#00ff00",
}}
>
{notificationSuccess}
</div>
)} )}
{notificationError && ( {notificationError && (
<div <div className="alert alert--error">{notificationError}</div>
style={{
padding: "0.75rem",
marginBottom: "1rem",
backgroundColor: "#ff000020",
border: "1px solid #ff0000",
borderRadius: "4px",
color: "#ff6666",
}}
>
{notificationError}
</div>
)} )}
{/* Master toggle */} {/* Master toggle */}
<div <div className="alerts__toggle-panel">
style={{ <label className="alerts__toggle-label">
marginBottom: "1.5rem",
padding: "1rem",
backgroundColor: "#333",
borderRadius: "4px",
}}
>
<label
style={{
display: "flex",
alignItems: "center",
cursor: "pointer",
}}
>
<input <input
type="checkbox" type="checkbox"
checked={notificationEnabled} checked={notificationEnabled}
onChange={(e) => onChange={(e) =>
setNotificationEnabled(e.target.checked) setNotificationEnabled(e.target.checked)
} }
style={{ className="alerts__toggle-checkbox"
marginRight: "0.5rem",
width: "18px",
height: "18px",
}}
/> />
<span style={{ fontWeight: "bold" }}> <span className="text-bold">Enable Notifications</span>
Enable Notifications
</span>
</label> </label>
<div <div className="alerts__toggle-hint">
style={{
fontSize: "0.978rem",
color: "#808080",
marginTop: "0.5rem",
marginLeft: "26px",
}}
>
Master switch for all notification channels Master switch for all notification channels
</div> </div>
</div> </div>
{/* All channel settings hidden when master toggle is off */} {/* All channel settings -- hidden when master toggle is off */}
<div {notificationEnabled && (
style={{ <>
display: notificationEnabled ? "block" : "none",
}}
>
{/* Telegram */} {/* Telegram */}
<div style={sectionStyle}> <div className="section">
<h3 style={{ marginTop: 0, marginBottom: "1rem" }}> <h3 className="mt-0 mb-md">Telegram</h3>
Telegram <Input
</h3> label="Bot Token"
<div style={{ marginBottom: "0.75rem" }}>
<label style={labelStyle}>Bot Token</label>
<input
type="text"
value={telegramBotToken} value={telegramBotToken}
onChange={(e) => onChange={setTelegramBotToken}
setTelegramBotToken(e.target.value)
}
placeholder="123456789:ABCdefGHIjklMNOpqrSTUvwxYZ" placeholder="123456789:ABCdefGHIjklMNOpqrSTUvwxYZ"
style={inputStyle}
/> />
</div> <Input
label="Chat ID"
<div style={{ marginBottom: "0.5rem" }}>
<label style={labelStyle}>Chat ID</label>
<input
type="text"
value={telegramChatId} value={telegramChatId}
onChange={(e) => onChange={setTelegramChatId}
setTelegramChatId(e.target.value)
}
placeholder="-1001234567890" placeholder="-1001234567890"
style={inputStyle}
/> />
</div>
<a <a
href="https://core.telegram.org/bots#how-do-i-create-a-bot" href="https://core.telegram.org/bots#how-do-i-create-a-bot"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
style={helpLinkStyle} className="help-link"
> >
How to create a Telegram bot & get your Chat ID How to create a Telegram bot & get your Chat ID
</a> </a>
</div> </div>
{/* Email */} {/* Email */}
<div style={sectionStyle}> <div className="section">
<h3 style={{ marginTop: 0, marginBottom: "1rem" }}> <h3 className="mt-0 mb-md">Email</h3>
Email <Input
</h3> label="Email Address"
<div style={{ marginBottom: "0.75rem" }}>
<label style={labelStyle}>Email Address</label>
<input
type="email" type="email"
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={setEmail}
placeholder="you@example.com" placeholder="you@example.com"
style={inputStyle}
/> />
<div className="help-link text-dimmed mb-md">
Alert notifications and digests will be sent to this address
</div> </div>
<div className="alerts__email-buttons">
<div <Button
style={{
fontSize: "0.85rem",
color: "#808080",
marginBottom: "0.75rem",
}}
>
Alert notifications and digests will be sent to
this address
</div>
<div
style={{
display: "flex",
gap: "0.5rem",
}}
>
<button
onClick={handleSetupEmail} onClick={handleSetupEmail}
disabled={settingUpEmail || !email} disabled={settingUpEmail || !email}
style={{ className="btn--sm"
padding: "0.5rem 1rem",
fontSize: "0.85rem",
backgroundColor:
settingUpEmail || !email
? "#333"
: "#0066cc",
color: "white",
border: "none",
borderRadius: "4px",
cursor:
settingUpEmail || !email
? "not-allowed"
: "pointer",
}}
> >
{settingUpEmail {settingUpEmail ? "Setting up..." : "Verify Email"}
? "Setting up..." </Button>
: "Verify Email"} <Button
</button>
<button
onClick={handleSendDigest} onClick={handleSendDigest}
disabled={sendingDigest || !email} disabled={sendingDigest || !email}
style={{ variant="secondary"
padding: "0.5rem 1rem", className="btn--sm"
fontSize: "0.85rem",
backgroundColor:
sendingDigest || !email
? "#333"
: "#6c757d",
color: "white",
border: "none",
borderRadius: "4px",
cursor:
sendingDigest || !email
? "not-allowed"
: "pointer",
}}
> >
{sendingDigest {sendingDigest ? "Sending..." : "Send Digest Now"}
? "Sending..." </Button>
: "Send Digest Now"}
</button>
</div> </div>
</div> </div>
{/* Discord */} {/* Discord */}
<div style={sectionStyle}> <div className="section">
<h3 style={{ marginTop: 0, marginBottom: "1rem" }}> <h3 className="mt-0 mb-md">Discord</h3>
Discord <Input
</h3> label="Discord Webhook URL"
<div style={{ marginBottom: "0.5rem" }}>
<label style={labelStyle}>
Discord Webhook URL
</label>
<input
type="text"
value={discordWebhookUrl} value={discordWebhookUrl}
onChange={(e) => onChange={setDiscordWebhookUrl}
setDiscordWebhookUrl(e.target.value)
}
placeholder="https://discord.com/api/webhooks/..." placeholder="https://discord.com/api/webhooks/..."
style={inputStyle}
/> />
</div>
<a <a
href="https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks" href="https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
style={helpLinkStyle} className="help-link"
> >
How to get a Discord webhook URL How to get a Discord webhook URL
</a> </a>
</div> </div>
{/* Slack */} {/* Slack */}
<div style={sectionStyle}> <div className="section">
<h3 style={{ marginTop: 0, marginBottom: "1rem" }}> <h3 className="mt-0 mb-md">Slack</h3>
Slack <Input
</h3> label="Slack Webhook URL"
<div style={{ marginBottom: "0.5rem" }}>
<label style={labelStyle}>
Slack Webhook URL
</label>
<input
type="text"
value={slackWebhookUrl} value={slackWebhookUrl}
onChange={(e) => onChange={setSlackWebhookUrl}
setSlackWebhookUrl(e.target.value)
}
placeholder="https://hooks.slack.com/services/..." placeholder="https://hooks.slack.com/services/..."
style={inputStyle}
/> />
</div>
<a <a
href="https://api.slack.com/messaging/webhooks" href="https://api.slack.com/messaging/webhooks"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
style={helpLinkStyle} className="help-link"
> >
How to set up Slack Incoming Webhooks How to set up Slack Incoming Webhooks
</a> </a>
</div> </div>
{/* Save & Test buttons at the bottom */} {/* Save & Test buttons */}
<div <div className="alerts__save-test">
style={{ <Button
display: "flex",
gap: "0.75rem",
marginTop: "1rem",
}}
>
<button
onClick={handleSaveNotificationConfig} onClick={handleSaveNotificationConfig}
disabled={notificationLoading} disabled={notificationLoading}
style={{ className="btn--lg"
padding: "0.75rem 1.5rem",
fontSize: "1rem",
backgroundColor: notificationLoading
? "#333"
: "#0066cc",
color: "white",
border: "none",
borderRadius: "4px",
cursor: notificationLoading
? "not-allowed"
: "pointer",
}}
> >
{notificationLoading {notificationLoading ? "Saving..." : "Save Settings"}
? "Saving..." </Button>
: "Save Settings"} <Button
</button>
<button
onClick={handleTestChannels} onClick={handleTestChannels}
disabled={testingChannels} disabled={testingChannels}
style={{ variant="success"
padding: "0.75rem 1.5rem", className="btn--lg"
fontSize: "1rem",
backgroundColor: testingChannels
? "#333"
: "#28a745",
color: "white",
border: "none",
borderRadius: "4px",
cursor: testingChannels
? "not-allowed"
: "pointer",
}}
> >
{testingChannels {testingChannels ? "Testing..." : "Test All Channels"}
? "Testing..." </Button>
: "Test All Channels"}
</button>
</div>
</div> </div>
</>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,64 @@
.login-page {
min-height: 100vh;
background-image: url(/ping.png);
background-size: 67%;
background-position: center;
background-repeat: no-repeat;
}
.login-card {
max-width: 400px;
margin: 20px auto;
padding: 2rem;
padding-top: 8rem;
border: 1px solid var(--color-border-light);
border-radius: var(--radius-xl);
background-color: rgba(0, 0, 0, 0.75);
}
.login-heading {
margin-bottom: 2rem;
text-align: center;
position: relative;
top: -20px;
}
.login-brand {
color: var(--color-brand);
}
.login-button {
width: 100%;
padding: 0.75rem;
font-size: 1.2rem;
margin-top: 16px;
;
}
.login-footer {
margin-top: 1.5rem;
text-align: center;
}
.login-signup-link {
color: var(--color-primary);
text-decoration: none;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.login-form-hidden {
opacity: 0;
}
.login-form-visible {
animation: fadeIn 2s ease-in-out forwards;
}

View File

@@ -1,14 +1,11 @@
/**
* Login Page
*
* Allows existing users to sign in with email and password
*/
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 "./Login.css";
export default function Login() { export default function Login() {
const [isVisible, setIsVisible] = useState(false);
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [error, setError] = useState(""); const [error, setError] = useState("");
@@ -20,7 +17,6 @@ export default function Login() {
async function handleSubmit(e) { async function handleSubmit(e) {
e.preventDefault(); e.preventDefault();
// Validation
if (!email || !password) { if (!email || !password) {
setError("Please fill in all fields"); setError("Please fill in all fields");
return; return;
@@ -30,7 +26,7 @@ export default function Login() {
setError(""); setError("");
setLoading(true); setLoading(true);
await login(email, password); await login(email, password);
navigate("/addresses"); // Redirect to main app navigate("/addresses");
} catch (err) { } catch (err) {
setError("Failed to log in: " + err.message); setError("Failed to log in: " + err.message);
} finally { } finally {
@@ -39,108 +35,49 @@ export default function Login() {
} }
return ( return (
<div <div className="login-page">
style={{ <div className="login-card" onMouseEnter={() => setIsVisible(true)}>
maxWidth: "400px", <h1 className="login-heading">
margin: "4rem auto", <span className="login-brand">Koin Ping</span> - Login
padding: "2rem",
border: "1px solid #333",
borderRadius: "8px",
}}
>
<h1 style={{ marginBottom: "2rem", textAlign: "center" }}>
Koin Ping - Login
</h1> </h1>
{error && ( <div className={isVisible ? "login-form-visible" : "login-form-hidden"}>
<div {error && <div className="alert alert--error">{error}</div>}
style={{
padding: "0.75rem",
marginBottom: "1rem",
backgroundColor: "#ff000020",
border: "1px solid #ff0000",
borderRadius: "4px",
color: "#ff6666",
}}
>
{error}
</div>
)}
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<div style={{ marginBottom: "1rem" }}> <Input
<label style={{ display: "block", marginBottom: "0.5rem" }}> label="Email"
Email
</label>
<input
type="email" type="email"
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={setEmail}
disabled={loading} disabled={loading}
style={{
width: "100%",
padding: "0.5rem",
fontSize: "1rem",
backgroundColor: "#242424",
border: "1px solid #444",
borderRadius: "4px",
color: "white",
}}
required required
/> />
</div> <Input
label="Password"
<div style={{ marginBottom: "1.5rem" }}>
<label style={{ display: "block", marginBottom: "0.5rem" }}>
Password
</label>
<input
type="password" type="password"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={setPassword}
disabled={loading} disabled={loading}
style={{
width: "100%",
padding: "0.5rem",
fontSize: "1rem",
backgroundColor: "#242424",
border: "1px solid #444",
borderRadius: "4px",
color: "white",
}}
required required
className="form-field--last"
/> />
</div>
<button <button type="submit" disabled={loading} className="btn btn--primary login-button">
type="submit"
disabled={loading}
style={{
width: "100%",
padding: "0.75rem",
fontSize: "1rem",
backgroundColor: loading ? "#333" : "#0066cc",
color: "white",
border: "none",
borderRadius: "4px",
cursor: loading ? "not-allowed" : "pointer",
}}
>
{loading ? "Logging in..." : "Log In"} {loading ? "Logging in..." : "Log In"}
</button> </button>
</form> </form>
<div style={{ marginTop: "1.5rem", textAlign: "center" }}> <div className="login-footer">
<p style={{ color: "#b3b3b3" }}> <p className="text-muted">
Don't have an account?{" "} Don't have an account?{" "}
<Link <Link to="/signup" className="login-signup-link">
to="/signup"
style={{ color: "#0066cc", textDecoration: "none" }}
>
Sign up here Sign up here
</Link> </Link>
</p> </p>
</div> </div>
</div> </div>
</div>
</div>
); );
} }

View File

@@ -0,0 +1,185 @@
.onboarding {
min-height: 100vh;
background-color: var(--color-bg);
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
padding-top: 3rem;
padding-bottom: 3rem;
}
.onboarding__container {
width: 100%;
max-width: 540px;
padding: 0 1rem;
}
.onboarding__title {
text-align: center;
margin-bottom: 2rem;
color: var(--color-primary);
letter-spacing: 0.5px;
}
.onboarding__card {
background-color: var(--color-bg-elevated);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-xl);
padding: 2rem;
}
.onboarding__login-link {
text-align: center;
margin-top: 1.25rem;
color: #888;
font-size: 0.9rem;
}
.onboarding__login-link a {
color: var(--color-primary);
}
/* Progress bar */
.progress-bar {
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 2rem;
}
.progress-bar__step {
display: flex;
align-items: center;
}
.progress-bar__connector {
width: 40px;
height: 2px;
margin: 0 4px;
}
.progress-bar__connector--active {
background-color: var(--color-primary);
}
.progress-bar__connector--inactive {
background-color: var(--color-border);
}
.progress-bar__dot {
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
font-size: 0.85rem;
color: white;
margin: 0 auto 4px;
}
.progress-bar__dot--done,
.progress-bar__dot--active {
background-color: var(--color-primary);
}
.progress-bar__dot--active {
border: 2px solid #4499ff;
}
.progress-bar__dot--pending {
background-color: var(--color-bg-card);
border: 2px solid transparent;
}
.progress-bar__label {
font-size: 0.7rem;
white-space: nowrap;
}
.progress-bar__label--active {
color: white;
}
.progress-bar__label--inactive {
color: #888;
}
/* Step 5 summary */
.onboarding__summary {
background-color: #1e2e1e;
border: 1px solid #2d5a2d;
border-radius: var(--radius-lg);
padding: 1rem 1.25rem;
margin-bottom: 1.5rem;
}
.onboarding__summary-title {
margin: 0 0 0.5rem;
color: #90ee90;
font-weight: bold;
}
.onboarding__summary-list {
margin: 0;
padding-left: 1.25rem;
color: var(--color-text-label);
line-height: 1.8;
}
/* Checkbox rows */
.checkbox-row {
margin-bottom: 1rem;
}
.checkbox-row__label {
display: flex;
align-items: center;
gap: 0.6rem;
cursor: pointer;
color: #ddd;
}
.checkbox-row__input {
width: 16px;
height: 16px;
accent-color: var(--color-primary);
}
.checkbox-row__nested {
margin-top: 0.5rem;
margin-left: 1.75rem;
}
/* Footer navigation */
.onboarding__footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 1.5rem;
padding-top: 1rem;
border-top: 1px solid var(--color-border-light);
}
/* Test results */
.test-result {
font-size: 0.9rem;
margin-bottom: 0.25rem;
}
.test-result--success {
color: #90ee90;
}
.test-result--failure {
color: var(--color-error);
}
/* Step subtitle */
.onboarding__subtitle {
color: #aaa;
margin-bottom: 1.5rem;
font-size: 0.9rem;
}

View File

@@ -1,7 +1,7 @@
/** /**
* Onboarding Wizard * Onboarding Wizard
* *
* 5-step guided flow: Create Account Add Wallet Alert Rules Notifications Done * 5-step guided flow: Create Account -> Add Wallet -> Alert Rules -> Notifications -> Done
*/ */
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
@@ -13,6 +13,9 @@ import {
updateNotificationConfig, updateNotificationConfig,
testNotificationChannels, testNotificationChannels,
} from "../api/notificationConfig"; } from "../api/notificationConfig";
import Input from "../components/Input";
import Button from "../components/Button";
import "./Onboarding.css";
const STEPS = [ const STEPS = [
"Create Account", "Create Account",
@@ -22,24 +25,6 @@ const STEPS = [
"Done", "Done",
]; ];
const inputStyle = {
width: "100%",
padding: "0.5rem",
fontSize: "1rem",
backgroundColor: "#2a2a2a",
border: "1px solid #444",
borderRadius: "4px",
color: "white",
boxSizing: "border-box",
};
const labelStyle = {
display: "block",
marginBottom: "0.4rem",
color: "#ccc",
fontSize: "0.9rem",
};
export default function Onboarding() { export default function Onboarding() {
const { currentUser, signup } = useAuth(); const { currentUser, signup } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -51,7 +36,6 @@ export default function Onboarding() {
const [testResults, setTestResults] = useState(null); const [testResults, setTestResults] = useState(null);
const [testLoading, setTestLoading] = useState(false); const [testLoading, setTestLoading] = useState(false);
// Wizard state
const [data, setData] = useState({ const [data, setData] = useState({
email: "", email: "",
password: "", password: "",
@@ -68,7 +52,6 @@ export default function Onboarding() {
discordWebhookUrl: "", discordWebhookUrl: "",
slackWebhookUrl: "", slackWebhookUrl: "",
notificationEmail: "", notificationEmail: "",
// summary
alertsCreated: [], alertsCreated: [],
notificationConfigured: false, notificationConfigured: false,
}); });
@@ -77,7 +60,6 @@ export default function Onboarding() {
setData((prev) => ({ ...prev, [field]: value })); setData((prev) => ({ ...prev, [field]: value }));
} }
// On mount: if already fully onboarded, redirect away
useEffect(() => { useEffect(() => {
if (!currentUser) return; if (!currentUser) return;
getAddresses() getAddresses()
@@ -86,7 +68,7 @@ export default function Onboarding() {
navigate("/addresses", { replace: true }); navigate("/addresses", { replace: true });
} }
}) })
.catch(() => {}); // ignore errors (e.g. mid-signup) .catch(() => {});
}, [currentUser, navigate]); }, [currentUser, navigate]);
// ── Step handlers ───────────────────────────────────────────────────────── // ── Step handlers ─────────────────────────────────────────────────────────
@@ -105,7 +87,6 @@ export default function Onboarding() {
setError("Password must be at least 6 characters"); setError("Password must be at least 6 characters");
return; return;
} }
// If user already exists (browser-close-mid-wizard), skip signup
if (!currentUser) { if (!currentUser) {
try { try {
setLoading(true); setLoading(true);
@@ -237,59 +218,38 @@ export default function Onboarding() {
function ProgressBar() { function ProgressBar() {
return ( return (
<div <div className="progress-bar">
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
marginBottom: "2rem",
}}
>
{STEPS.map((label, i) => { {STEPS.map((label, i) => {
const stepNum = i + 1; const stepNum = i + 1;
const done = step > stepNum; const done = step > stepNum;
const active = step === stepNum; const active = step === stepNum;
const dotClass = done
? "progress-bar__dot--done"
: active
? "progress-bar__dot--active"
: "progress-bar__dot--pending";
return ( return (
<div <div key={label} className="progress-bar__step">
key={label}
style={{ display: "flex", alignItems: "center" }}
>
{i > 0 && ( {i > 0 && (
<div <div
style={{ className={`progress-bar__connector ${
width: "40px", done || active
height: "2px", ? "progress-bar__connector--active"
backgroundColor: done || active ? "#0066cc" : "#444", : "progress-bar__connector--inactive"
margin: "0 4px", }`}
}}
/> />
)} )}
<div style={{ textAlign: "center" }}> <div>
<div <div className={`progress-bar__dot ${dotClass}`}>
style={{ {done ? "\u2713" : stepNum}
width: "32px",
height: "32px",
borderRadius: "50%",
backgroundColor:
done ? "#0066cc" : active ? "#0066cc" : "#333",
border: active ? "2px solid #4499ff" : "2px solid transparent",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontWeight: "bold",
fontSize: "0.85rem",
color: "white",
margin: "0 auto 4px",
}}
>
{done ? "✓" : stepNum}
</div> </div>
<div <div
style={{ className={`progress-bar__label ${
fontSize: "0.7rem", active
color: active ? "white" : "#888", ? "progress-bar__label--active"
whiteSpace: "nowrap", : "progress-bar__label--inactive"
}} }`}
> >
{label} {label}
</div> </div>
@@ -306,40 +266,32 @@ export default function Onboarding() {
function Step1() { function Step1() {
return ( return (
<> <>
<h2 style={{ marginBottom: "1.5rem" }}>Create your account</h2> <h2 className="mb-lg">Create your account</h2>
<div style={{ marginBottom: "1rem" }}> <Input
<label style={labelStyle}>Email</label> label="Email"
<input
type="email" type="email"
value={data.email} value={data.email}
onChange={(e) => set("email", e.target.value)} onChange={(v) => set("email", v)}
disabled={loading} disabled={loading}
style={inputStyle}
placeholder="you@example.com" placeholder="you@example.com"
/> />
</div> <Input
<div style={{ marginBottom: "1rem" }}> label="Password"
<label style={labelStyle}>Password</label>
<input
type="password" type="password"
value={data.password} value={data.password}
onChange={(e) => set("password", e.target.value)} onChange={(v) => set("password", v)}
disabled={loading} disabled={loading}
style={inputStyle}
placeholder="At least 6 characters" placeholder="At least 6 characters"
/> />
</div> <Input
<div style={{ marginBottom: "1.5rem" }}> label="Confirm Password"
<label style={labelStyle}>Confirm Password</label>
<input
type="password" type="password"
value={data.confirmPassword} value={data.confirmPassword}
onChange={(e) => set("confirmPassword", e.target.value)} onChange={(v) => set("confirmPassword", v)}
disabled={loading} disabled={loading}
style={inputStyle}
placeholder="Repeat your password" placeholder="Repeat your password"
className="form-field--last"
/> />
</div>
</> </>
); );
} }
@@ -347,32 +299,25 @@ export default function Onboarding() {
function Step2() { function Step2() {
return ( return (
<> <>
<h2 style={{ marginBottom: "0.5rem" }}>Add a wallet address</h2> <h2 className="mb-sm">Add a wallet address</h2>
<p style={{ color: "#aaa", marginBottom: "1.5rem", fontSize: "0.9rem" }}> <p className="onboarding__subtitle">
Enter the Ethereum address you want to monitor. Enter the Ethereum address you want to monitor.
</p> </p>
<div style={{ marginBottom: "1rem" }}> <Input
<label style={labelStyle}>ETH Address</label> label="ETH Address"
<input
type="text"
value={data.walletAddress} value={data.walletAddress}
onChange={(e) => set("walletAddress", e.target.value)} onChange={(v) => set("walletAddress", v)}
disabled={loading} disabled={loading}
style={inputStyle}
placeholder="0x..." placeholder="0x..."
/> />
</div> <Input
<div style={{ marginBottom: "1.5rem" }}> label="Label (optional)"
<label style={labelStyle}>Label (optional)</label>
<input
type="text"
value={data.walletLabel} value={data.walletLabel}
onChange={(e) => set("walletLabel", e.target.value)} onChange={(v) => set("walletLabel", v)}
disabled={loading} disabled={loading}
style={inputStyle}
placeholder="e.g. My main wallet" placeholder="e.g. My main wallet"
className="form-field--last"
/> />
</div>
</> </>
); );
} }
@@ -380,8 +325,8 @@ export default function Onboarding() {
function Step3() { function Step3() {
return ( return (
<> <>
<h2 style={{ marginBottom: "0.5rem" }}>Configure alert rules</h2> <h2 className="mb-sm">Configure alert rules</h2>
<p style={{ color: "#aaa", marginBottom: "1.5rem", fontSize: "0.9rem" }}> <p className="onboarding__subtitle">
Choose which events trigger notifications. You can change these later. Choose which events trigger notifications. You can change these later.
</p> </p>
@@ -401,12 +346,12 @@ export default function Onboarding() {
label="Large transfer" label="Large transfer"
> >
{data.alertLargeTransfer && ( {data.alertLargeTransfer && (
<div style={{ marginTop: "0.5rem", marginLeft: "1.75rem" }}> <div className="checkbox-row__nested">
<input <Input
type="number" type="number"
label=""
value={data.largeTransferThreshold} value={data.largeTransferThreshold}
onChange={(e) => set("largeTransferThreshold", e.target.value)} onChange={(v) => set("largeTransferThreshold", v)}
style={{ ...inputStyle, width: "160px" }}
placeholder="Threshold (ETH)" placeholder="Threshold (ETH)"
min="0" min="0"
step="0.01" step="0.01"
@@ -420,12 +365,12 @@ export default function Onboarding() {
label="Balance below" label="Balance below"
> >
{data.alertBalanceBelow && ( {data.alertBalanceBelow && (
<div style={{ marginTop: "0.5rem", marginLeft: "1.75rem" }}> <div className="checkbox-row__nested">
<input <Input
type="number" type="number"
label=""
value={data.balanceBelowThreshold} value={data.balanceBelowThreshold}
onChange={(e) => set("balanceBelowThreshold", e.target.value)} onChange={(v) => set("balanceBelowThreshold", v)}
style={{ ...inputStyle, width: "160px" }}
placeholder="Threshold (ETH)" placeholder="Threshold (ETH)"
min="0" min="0"
step="0.01" step="0.01"
@@ -440,66 +385,64 @@ export default function Onboarding() {
function Step4() { function Step4() {
return ( return (
<> <>
<h2 style={{ marginBottom: "0.5rem" }}>Set up notifications</h2> <h2 className="mb-sm">Set up notifications</h2>
<p style={{ color: "#aaa", marginBottom: "1.5rem", fontSize: "0.9rem" }}> <p className="onboarding__subtitle">
Add at least one channel so you receive alerts. All fields are optional. Add at least one channel so you receive alerts. All fields are optional.
</p> </p>
<div style={{ marginBottom: "1.25rem" }}> <div className="mb-md">
<label style={labelStyle}> <label className="form-label">
Discord Webhook URL{" "} Discord Webhook URL{" "}
<a <a
href="https://support.discord.com/hc/en-us/articles/228383668" href="https://support.discord.com/hc/en-us/articles/228383668"
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
style={{ color: "#4499ff", fontSize: "0.8rem" }} className="help-link"
> >
(how to get one) (how to get one)
</a> </a>
</label> </label>
<input <Input
label=""
type="url" type="url"
value={data.discordWebhookUrl} value={data.discordWebhookUrl}
onChange={(e) => set("discordWebhookUrl", e.target.value)} onChange={(v) => set("discordWebhookUrl", v)}
disabled={loading} disabled={loading}
style={inputStyle}
placeholder="https://discord.com/api/webhooks/..." placeholder="https://discord.com/api/webhooks/..."
/> />
</div> </div>
<div style={{ marginBottom: "1.25rem" }}> <div className="mb-md">
<label style={labelStyle}> <label className="form-label">
Slack Webhook URL{" "} Slack Webhook URL{" "}
<a <a
href="https://api.slack.com/messaging/webhooks" href="https://api.slack.com/messaging/webhooks"
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
style={{ color: "#4499ff", fontSize: "0.8rem" }} className="help-link"
> >
(how to get one) (how to get one)
</a> </a>
</label> </label>
<input <Input
label=""
type="url" type="url"
value={data.slackWebhookUrl} value={data.slackWebhookUrl}
onChange={(e) => set("slackWebhookUrl", e.target.value)} onChange={(v) => set("slackWebhookUrl", v)}
disabled={loading} disabled={loading}
style={inputStyle}
placeholder="https://hooks.slack.com/services/..." placeholder="https://hooks.slack.com/services/..."
/> />
</div> </div>
<div style={{ marginBottom: "1.5rem" }}> <Input
<label style={labelStyle}>Email address for alerts</label> label="Email address for alerts"
<input
type="email" type="email"
value={data.notificationEmail} value={data.notificationEmail}
onChange={(e) => set("notificationEmail", e.target.value)} onChange={(v) => set("notificationEmail", v)}
disabled={loading} disabled={loading}
style={inputStyle}
placeholder="you@example.com" placeholder="you@example.com"
className="form-field--last"
/> />
</div>
</> </>
); );
} }
@@ -510,37 +453,27 @@ export default function Onboarding() {
return ( return (
<> <>
<h2 style={{ marginBottom: "1rem" }}>You're all set!</h2> <h2 className="mb-md">You're all set!</h2>
<div <div className="onboarding__summary">
style={{ <p className="onboarding__summary-title">Summary</p>
backgroundColor: "#1e2e1e", <ul className="onboarding__summary-list">
border: "1px solid #2d5a2d",
borderRadius: "6px",
padding: "1rem 1.25rem",
marginBottom: "1.5rem",
}}
>
<p style={{ margin: "0 0 0.5rem", color: "#90ee90", fontWeight: "bold" }}>
Summary
</p>
<ul style={{ margin: 0, paddingLeft: "1.25rem", color: "#ccc", lineHeight: "1.8" }}>
<li> <li>
Wallet address added:{" "} Wallet address added:{" "}
<span style={{ color: "white", fontFamily: "monospace", fontSize: "0.85rem" }}> <span className="text-mono text-white-sm">
{data.walletAddress} {data.walletAddress}
</span> </span>
{data.walletLabel && ` (${data.walletLabel})`} {data.walletLabel && ` (${data.walletLabel})`}
</li> </li>
<li> <li>
Alert rules configured:{" "} Alert rules configured:{" "}
<span style={{ color: "white" }}> <span className="text-white">
{alertCount > 0 ? `${alertCount} rule${alertCount !== 1 ? "s" : ""}` : "None (skipped)"} {alertCount > 0 ? `${alertCount} rule${alertCount !== 1 ? "s" : ""}` : "None (skipped)"}
</span> </span>
</li> </li>
<li> <li>
Notification channels:{" "} Notification channels:{" "}
<span style={{ color: "white" }}> <span className="text-white">
{hasNotif ? "Configured" : "Not set up (skipped)"} {hasNotif ? "Configured" : "Not set up (skipped)"}
</span> </span>
</li> </li>
@@ -548,39 +481,27 @@ export default function Onboarding() {
</div> </div>
{hasNotif && ( {hasNotif && (
<div style={{ marginBottom: "1.5rem" }}> <div className="mb-lg">
<button <Button
onClick={handleTestChannels} onClick={handleTestChannels}
disabled={testLoading} disabled={testLoading}
style={{ variant="ghost"
padding: "0.6rem 1.25rem",
backgroundColor: testLoading ? "#333" : "#1a4d80",
color: "white",
border: "1px solid #0066cc",
borderRadius: "4px",
cursor: testLoading ? "not-allowed" : "pointer",
fontSize: "0.9rem",
}}
> >
{testLoading ? "Testing..." : "Test All Channels"} {testLoading ? "Testing..." : "Test All Channels"}
</button> </Button>
{testResults && ( {testResults && (
<div style={{ marginTop: "0.75rem" }}> <div className="mt-md">
{testResults.error ? ( {testResults.error ? (
<p style={{ color: "#ff6666" }}>{testResults.error}</p> <p className="text-error">{testResults.error}</p>
) : ( ) : (
<ul style={{ listStyle: "none", padding: 0, margin: 0 }}> <ul className="list-unstyled">
{Object.entries(testResults).map(([channel, result]) => ( {Object.entries(testResults).map(([channel, result]) => (
<li <li
key={channel} key={channel}
style={{ className={`test-result ${result.success ? "test-result--success" : "test-result--failure"}`}
color: result.success ? "#90ee90" : "#ff6666",
fontSize: "0.9rem",
marginBottom: "0.25rem",
}}
> >
{result.success ? "" : ""} {channel}:{" "} {result.success ? "\u2713" : "\u2717"} {channel}:{" "}
{result.message || (result.success ? "OK" : "Failed")} {result.message || (result.success ? "OK" : "Failed")}
</li> </li>
))} ))}
@@ -591,21 +512,9 @@ export default function Onboarding() {
</div> </div>
)} )}
<button <Button onClick={() => navigate("/addresses")} className="btn--lg text-bold">
onClick={() => navigate("/addresses")}
style={{
padding: "0.75rem 2rem",
backgroundColor: "#0066cc",
color: "white",
border: "none",
borderRadius: "4px",
cursor: "pointer",
fontSize: "1rem",
fontWeight: "bold",
}}
>
Go to Dashboard Go to Dashboard
</button> </Button>
</> </>
); );
} }
@@ -614,21 +523,13 @@ export default function Onboarding() {
function CheckboxRow({ checked, onChange, label, children }) { function CheckboxRow({ checked, onChange, label, children }) {
return ( return (
<div style={{ marginBottom: "1rem" }}> <div className="checkbox-row">
<label <label className="checkbox-row__label">
style={{
display: "flex",
alignItems: "center",
gap: "0.6rem",
cursor: "pointer",
color: "#ddd",
}}
>
<input <input
type="checkbox" type="checkbox"
checked={checked} checked={checked}
onChange={(e) => onChange(e.target.checked)} onChange={(e) => onChange(e.target.checked)}
style={{ width: "16px", height: "16px", accentColor: "#0066cc" }} className="checkbox-row__input"
/> />
{label} {label}
</label> </label>
@@ -666,67 +567,36 @@ export default function Onboarding() {
} }
return ( return (
<div <div className="onboarding__footer">
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginTop: "1.5rem",
paddingTop: "1rem",
borderTop: "1px solid #333",
}}
>
<div> <div>
{canBack && ( {canBack && (
<button <Button
onClick={handleBack} onClick={handleBack}
disabled={loading} disabled={loading}
style={{ variant="ghost"
padding: "0.5rem 1rem",
backgroundColor: "transparent",
color: "#aaa",
border: "1px solid #444",
borderRadius: "4px",
cursor: loading ? "not-allowed" : "pointer",
}}
> >
Back Back
</button> </Button>
)} )}
</div> </div>
<div style={{ display: "flex", gap: "0.75rem" }}> <div className="flex gap-md">
{canSkip && ( {canSkip && (
<button <Button
onClick={handleSkip} onClick={handleSkip}
disabled={loading} disabled={loading}
style={{ variant="ghost"
padding: "0.5rem 1rem",
backgroundColor: "transparent",
color: "#aaa",
border: "1px solid #444",
borderRadius: "4px",
cursor: loading ? "not-allowed" : "pointer",
}}
> >
Skip for now Skip for now
</button> </Button>
)} )}
<button <Button
onClick={handleNext} onClick={handleNext}
disabled={loading} disabled={loading}
style={{ className="text-bold"
padding: "0.5rem 1.25rem",
backgroundColor: loading ? "#333" : "#0066cc",
color: "white",
border: "none",
borderRadius: "4px",
cursor: loading ? "not-allowed" : "pointer",
fontWeight: "bold",
}}
> >
{loading ? "Please wait..." : step === 4 ? "Finish" : "Next →"} {loading ? "Please wait..." : step === 4 ? "Finish" : "Next →"}
</button> </Button>
</div> </div>
</div> </div>
); );
@@ -743,89 +613,29 @@ export default function Onboarding() {
}; };
return ( return (
<div <div className="onboarding">
style={{ <div className="onboarding__container">
minHeight: "100vh", <h1 className="onboarding__title">Koin Ping</h1>
backgroundColor: "#1a1a1a",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "flex-start",
paddingTop: "3rem",
paddingBottom: "3rem",
}}
>
<div style={{ width: "100%", maxWidth: "540px", padding: "0 1rem" }}>
<h1
style={{
textAlign: "center",
marginBottom: "2rem",
color: "#0066cc",
letterSpacing: "0.5px",
}}
>
Koin Ping
</h1>
<ProgressBar /> <ProgressBar />
{error && ( {error && (
<div <div className="alert alert--error">{error}</div>
style={{
padding: "0.75rem 1rem",
marginBottom: "1rem",
backgroundColor: "#3a1a1a",
border: "1px solid #cc3333",
borderRadius: "4px",
color: "#ff6666",
fontSize: "0.9rem",
}}
>
{error}
</div>
)} )}
{skipWarning && ( {skipWarning && (
<div <div className="alert alert--warning">{skipWarning}</div>
style={{
padding: "0.75rem 1rem",
marginBottom: "1rem",
backgroundColor: "#3a2e00",
border: "1px solid #aa7700",
borderRadius: "4px",
color: "#ffcc44",
fontSize: "0.9rem",
}}
>
{skipWarning}
</div>
)} )}
<div <div className="onboarding__card">
style={{
backgroundColor: "#242424",
border: "1px solid #333",
borderRadius: "8px",
padding: "2rem",
}}
>
{stepContent[step]} {stepContent[step]}
<Footer /> <Footer />
</div> </div>
{step === 1 && ( {step === 1 && (
<p <p className="onboarding__login-link">
style={{
textAlign: "center",
marginTop: "1.25rem",
color: "#888",
fontSize: "0.9rem",
}}
>
Already have an account?{" "} Already have an account?{" "}
<a href="/login" style={{ color: "#0066cc" }}> <a href="/login">Log in here</a>
Log in here
</a>
</p> </p>
)} )}
</div> </div>