Compare commits
16 Commits
webhook-co
...
feat-discr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
91336130b9 | ||
|
|
e4e859d051 | ||
|
|
6f6a2c6d18 | ||
|
|
63eecaaf72 | ||
|
|
8a5f5db903 | ||
|
|
c2515472b2 | ||
|
|
5ad51271af | ||
|
|
b3a08e8f25 | ||
|
|
8716be0201 | ||
|
|
647be874e5 | ||
|
|
c12c4fe742 | ||
|
|
5f5253d495 | ||
|
|
bbe2374703 | ||
|
|
205317c920 | ||
|
|
25850825b4 | ||
|
|
f01115de86 |
19
README.md
19
README.md
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,20 @@ Start DB:
|
|||||||
|
|
||||||
brew services start postgresql@15
|
brew services start postgresql@15
|
||||||
|
|
||||||
Run Backend:
|
|
||||||
|
|
||||||
cd /Users/kjannette/workspace/koin_ping/backend-go go run ./cmd/api
|
From the backend-go directory, you have a few options:
|
||||||
|
|
||||||
|
Option 1: Single command (both API + poller)
|
||||||
|
cd /Users/kjannette/workspace/koin_ping_0.2.0/backend-gomake dev-all
|
||||||
|
|
||||||
|
Option 2: Two separate terminals
|
||||||
|
Terminal 1 (API server):
|
||||||
|
cd /Users/kjannette/workspace/koin_ping_0.2.0/backend-gogo run ./cmd/api
|
||||||
|
Terminal 2 (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.
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
13
backend-go/infra/migrations/005_create_users_table.sql
Normal file
13
backend-go/infra/migrations/005_create_users_table.sql
Normal 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);
|
||||||
73
backend-go/infra/migrations/006_migrate_user_ids_to_uuid.sql
Normal file
73
backend-go/infra/migrations/006_migrate_user_ids_to_uuid.sql
Normal 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;
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -110,6 +119,17 @@ type NormalizedTx struct {
|
|||||||
Value string `json:"value"` // Wei as string for precision
|
Value string `json:"value"` // Wei as string for precision
|
||||||
BlockNumber int `json:"block_number"` //nolint:tagliatelle
|
BlockNumber int `json:"block_number"` //nolint:tagliatelle
|
||||||
BlockTimestamp int64 `json:"block_timestamp"` //nolint:tagliatelle
|
BlockTimestamp int64 `json:"block_timestamp"` //nolint:tagliatelle
|
||||||
|
|
||||||
|
// ERC-20 token transfer fields (nil for native ETH transfers)
|
||||||
|
TokenContract *string `json:"token_contract,omitempty"` //nolint:tagliatelle
|
||||||
|
TokenSymbol *string `json:"token_symbol,omitempty"` //nolint:tagliatelle
|
||||||
|
TokenDecimals *int `json:"token_decimals,omitempty"` //nolint:tagliatelle
|
||||||
|
TokenValue *string `json:"token_value,omitempty"` //nolint:tagliatelle
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsTokenTransfer returns true if this transaction represents an ERC-20 token transfer.
|
||||||
|
func (tx NormalizedTx) IsTokenTransfer() bool {
|
||||||
|
return tx.TokenContract != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type Direction string
|
type Direction string
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
53
backend-go/internal/models/user.go
Normal file
53
backend-go/internal/models/user.go
Normal 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
|
||||||
|
}
|
||||||
@@ -242,6 +242,132 @@ func (j *JsonRpcEthereum) GetBalance(ctx context.Context, address string) (strin
|
|||||||
return hexToDecimalString(hexBalance), nil
|
return hexToDecimalString(hexBalance), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const erc20TransferTopic = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"
|
||||||
|
|
||||||
|
const minTransferTopics = 3
|
||||||
|
|
||||||
|
type rpcLog struct {
|
||||||
|
Address string `json:"address"`
|
||||||
|
Topics []string `json:"topics"`
|
||||||
|
Data string `json:"data"`
|
||||||
|
BlockNumber string `json:"blockNumber"`
|
||||||
|
TxHash string `json:"transactionHash"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTokenTransfers fetches ERC-20 Transfer events for a given address
|
||||||
|
// across a block range, covering both incoming and outgoing transfers.
|
||||||
|
func (j *JsonRpcEthereum) GetTokenTransfers(ctx context.Context, fromBlock, toBlock int, address string) ([]domain.NormalizedTx, error) {
|
||||||
|
paddedAddr := padAddress(address)
|
||||||
|
hexFrom := fmt.Sprintf("0x%x", fromBlock)
|
||||||
|
hexTo := fmt.Sprintf("0x%x", toBlock)
|
||||||
|
|
||||||
|
incomingLogs, err := j.getTransferLogs(ctx, hexFrom, hexTo, "", paddedAddr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("fetch incoming token transfers: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
outgoingLogs, err := j.getTransferLogs(ctx, hexFrom, hexTo, paddedAddr, "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("fetch outgoing token transfers: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
var txs []domain.NormalizedTx
|
||||||
|
|
||||||
|
for _, entry := range append(incomingLogs, outgoingLogs...) {
|
||||||
|
key := entry.TxHash + "|" + entry.Address + "|" + entry.Data
|
||||||
|
if seen[key] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[key] = true
|
||||||
|
|
||||||
|
tx, parseErr := parseTransferLog(entry)
|
||||||
|
if parseErr != nil {
|
||||||
|
log.Printf("Skipping unparseable transfer log in tx %s: %v", entry.TxHash, parseErr)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
txs = append(txs, tx)
|
||||||
|
}
|
||||||
|
|
||||||
|
return txs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *JsonRpcEthereum) getTransferLogs(ctx context.Context, fromBlock, toBlock, fromAddr, toAddr string) ([]rpcLog, error) {
|
||||||
|
topics := make([]interface{}, minTransferTopics)
|
||||||
|
topics[0] = erc20TransferTopic
|
||||||
|
|
||||||
|
if fromAddr != "" {
|
||||||
|
topics[1] = fromAddr
|
||||||
|
}
|
||||||
|
if toAddr != "" {
|
||||||
|
topics[2] = toAddr
|
||||||
|
}
|
||||||
|
|
||||||
|
filter := map[string]interface{}{
|
||||||
|
"fromBlock": fromBlock,
|
||||||
|
"toBlock": toBlock,
|
||||||
|
"topics": topics,
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := j.callRPC(ctx, "eth_getLogs", filter)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var logs []rpcLog
|
||||||
|
if err := json.Unmarshal(result, &logs); err != nil {
|
||||||
|
return nil, fmt.Errorf("unmarshal logs: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return logs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseTransferLog(entry rpcLog) (domain.NormalizedTx, error) {
|
||||||
|
if len(entry.Topics) < minTransferTopics {
|
||||||
|
return domain.NormalizedTx{}, fmt.Errorf("transfer log has %d topics, expected >= 3", len(entry.Topics))
|
||||||
|
}
|
||||||
|
|
||||||
|
from := topicToAddress(entry.Topics[1])
|
||||||
|
to := topicToAddress(entry.Topics[2])
|
||||||
|
tokenValue := hexToDecimalString(entry.Data)
|
||||||
|
blockNumber, _ := hexToInt(entry.BlockNumber)
|
||||||
|
|
||||||
|
contractAddr := strings.ToLower(entry.Address)
|
||||||
|
tokenInfo, known := LookupToken(contractAddr)
|
||||||
|
|
||||||
|
tx := domain.NormalizedTx{
|
||||||
|
Hash: entry.TxHash,
|
||||||
|
From: from,
|
||||||
|
To: &to,
|
||||||
|
Value: "0",
|
||||||
|
BlockNumber: blockNumber,
|
||||||
|
TokenContract: &contractAddr,
|
||||||
|
TokenValue: &tokenValue,
|
||||||
|
}
|
||||||
|
|
||||||
|
if known {
|
||||||
|
tx.TokenSymbol = &tokenInfo.Symbol
|
||||||
|
tx.TokenDecimals = &tokenInfo.Decimals
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func padAddress(addr string) string {
|
||||||
|
clean := strings.TrimPrefix(strings.ToLower(addr), "0x")
|
||||||
|
const addressHexLen = 64
|
||||||
|
return "0x" + strings.Repeat("0", addressHexLen-len(clean)) + clean
|
||||||
|
}
|
||||||
|
|
||||||
|
func topicToAddress(topic string) string {
|
||||||
|
clean := strings.TrimPrefix(topic, "0x")
|
||||||
|
const ethAddrLen = 40
|
||||||
|
if len(clean) > ethAddrLen {
|
||||||
|
clean = clean[len(clean)-ethAddrLen:]
|
||||||
|
}
|
||||||
|
return "0x" + strings.ToLower(clean)
|
||||||
|
}
|
||||||
|
|
||||||
func hexToInt(hex string) (int, error) {
|
func hexToInt(hex string) (int, error) {
|
||||||
hex = strings.TrimPrefix(hex, "0x")
|
hex = strings.TrimPrefix(hex, "0x")
|
||||||
n, ok := new(big.Int).SetString(hex, 16)
|
n, ok := new(big.Int).SetString(hex, 16)
|
||||||
|
|||||||
@@ -12,4 +12,5 @@ type EthereumObserver interface {
|
|||||||
GetLatestBlockNumber(ctx context.Context) (int, error)
|
GetLatestBlockNumber(ctx context.Context) (int, error)
|
||||||
GetBlockTransactions(ctx context.Context, blockNumber int) ([]domain.NormalizedTx, error)
|
GetBlockTransactions(ctx context.Context, blockNumber int) ([]domain.NormalizedTx, error)
|
||||||
GetBalance(ctx context.Context, address string) (string, error)
|
GetBalance(ctx context.Context, address string) (string, error)
|
||||||
|
GetTokenTransfers(ctx context.Context, fromBlock, toBlock int, address string) ([]domain.NormalizedTx, error)
|
||||||
}
|
}
|
||||||
|
|||||||
34
backend-go/internal/protocols/ethereum/tokens.go
Normal file
34
backend-go/internal/protocols/ethereum/tokens.go
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
package ethereum
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// TokenInfo holds metadata for a known ERC-20 token contract.
|
||||||
|
type TokenInfo struct {
|
||||||
|
Symbol string
|
||||||
|
Decimals int
|
||||||
|
}
|
||||||
|
|
||||||
|
//nolint:gochecknoglobals
|
||||||
|
var wellKnownTokens = map[string]TokenInfo{
|
||||||
|
"0xdac17f958d2ee523a2206206994597c13d831ec7": {Symbol: "USDT", Decimals: 6},
|
||||||
|
"0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48": {Symbol: "USDC", Decimals: 6},
|
||||||
|
"0x6b175474e89094c44da98b954eedeac495271d0f": {Symbol: "DAI", Decimals: 18},
|
||||||
|
"0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2": {Symbol: "WETH", Decimals: 18},
|
||||||
|
"0x2260fac5e5542a773aa44fbcfedf7c193bc2c599": {Symbol: "WBTC", Decimals: 8},
|
||||||
|
"0x514910771af9ca656af840dff83e8264ecf986ca": {Symbol: "LINK", Decimals: 18},
|
||||||
|
"0x1f9840a85d5af5bf1d1762f925bdaddc4201f984": {Symbol: "UNI", Decimals: 18},
|
||||||
|
"0x7fc66500c84a76ad7e9c93437bfc5ac33e2ddae9": {Symbol: "AAVE", Decimals: 18},
|
||||||
|
"0x95ad61b0a150d79219dcf64e1e6cc01f0b64c4ce": {Symbol: "SHIB", Decimals: 18},
|
||||||
|
"0x6982508145454ce325ddbe47a25d4ec3d2311933": {Symbol: "PEPE", Decimals: 18},
|
||||||
|
"0xb8c77482e45f1f44de1745f52c74426c631bdd52": {Symbol: "BNB", Decimals: 18},
|
||||||
|
"0x4fabb145d64652a948d72533023f6e7a623c7c53": {Symbol: "BUSD", Decimals: 18},
|
||||||
|
"0x75231f58b43240c9718dd58b4967c5114342a86c": {Symbol: "OKB", Decimals: 18},
|
||||||
|
"0x582d872a1b094fc48f5de31d3b73f2d9be47def1": {Symbol: "TON", Decimals: 9},
|
||||||
|
"0x4d224452801aced8b2f0aebe155379bb5d594381": {Symbol: "APE", Decimals: 18},
|
||||||
|
}
|
||||||
|
|
||||||
|
// LookupToken returns metadata for a known token contract, if found.
|
||||||
|
func LookupToken(contractAddress string) (TokenInfo, bool) {
|
||||||
|
info, ok := wellKnownTokens[strings.ToLower(contractAddress)]
|
||||||
|
return info, ok
|
||||||
|
}
|
||||||
@@ -281,6 +281,10 @@ func (s *EvaluatorService) sendNotification(ctx context.Context, userID, message
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *EvaluatorService) buildMessage(rule domain.AlertRule, obs domain.ObservedTx) string {
|
func (s *EvaluatorService) buildMessage(rule domain.AlertRule, obs domain.ObservedTx) string {
|
||||||
|
if obs.IsTokenTransfer() {
|
||||||
|
return s.buildTokenMessage(rule, obs)
|
||||||
|
}
|
||||||
|
|
||||||
switch rule.Type {
|
switch rule.Type {
|
||||||
case domain.AlertIncomingTx:
|
case domain.AlertIncomingTx:
|
||||||
ethStr, _ := wei.FormatAsEth(obs.Value, 4)
|
ethStr, _ := wei.FormatAsEth(obs.Value, 4)
|
||||||
@@ -305,3 +309,36 @@ func (s *EvaluatorService) buildMessage(rule domain.AlertRule, obs domain.Observ
|
|||||||
return "Alert triggered"
|
return "Alert triggered"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const defaultTokenDecimals = 18
|
||||||
|
|
||||||
|
func (s *EvaluatorService) buildTokenMessage(rule domain.AlertRule, obs domain.ObservedTx) string {
|
||||||
|
symbol := "tokens"
|
||||||
|
if obs.TokenSymbol != nil {
|
||||||
|
symbol = *obs.TokenSymbol
|
||||||
|
}
|
||||||
|
|
||||||
|
amount := "unknown"
|
||||||
|
if obs.TokenValue != nil {
|
||||||
|
decimals := defaultTokenDecimals
|
||||||
|
if obs.TokenDecimals != nil {
|
||||||
|
decimals = *obs.TokenDecimals
|
||||||
|
}
|
||||||
|
amount = wei.FormatTokenAmount(*obs.TokenValue, decimals)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch rule.Type {
|
||||||
|
case domain.AlertIncomingTx:
|
||||||
|
return fmt.Sprintf("Incoming transfer: %s %s received", amount, symbol)
|
||||||
|
case domain.AlertOutgoingTx:
|
||||||
|
return fmt.Sprintf("Outgoing transfer: %s %s sent", amount, symbol)
|
||||||
|
case domain.AlertLargeTransfer:
|
||||||
|
threshold := float64(0)
|
||||||
|
if rule.Threshold != nil {
|
||||||
|
threshold = *rule.Threshold
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("Large token transfer: %s %s (threshold: %g)", amount, symbol, threshold)
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("Token transfer: %s %s", amount, symbol)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -80,6 +80,15 @@ func (s *ObserverService) observeAddress(ctx context.Context, addr domain.Addres
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tokenTxs, err := s.eth.GetTokenTransfers(ctx, startBlock, endBlock, addr.Address)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error fetching token transfers for %s: %v", addr.Address, err)
|
||||||
|
} else {
|
||||||
|
for _, tx := range tokenTxs {
|
||||||
|
observations = append(observations, createObservedTx(tx, addr))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if _, err := s.checkpoint.UpdateLastCheckedBlock(ctx, addr.ID, endBlock); err != nil {
|
if _, err := s.checkpoint.UpdateLastCheckedBlock(ctx, addr.ID, endBlock); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,3 +98,54 @@ func FormatAsEth(weiString string, decimals int) (string, error) {
|
|||||||
}
|
}
|
||||||
return fmt.Sprintf("%.*f ETH", decimals, eth), nil
|
return fmt.Sprintf("%.*f ETH", decimals, eth), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FormatTokenAmount formats a raw token amount using the token's decimal places.
|
||||||
|
// For example, 1000000 USDT (6 decimals) becomes "1".
|
||||||
|
func FormatTokenAmount(rawValue string, tokenDecimals int) string {
|
||||||
|
if rawValue == "" || rawValue == "0" {
|
||||||
|
return "0"
|
||||||
|
}
|
||||||
|
|
||||||
|
n, ok := new(big.Int).SetString(rawValue, 10)
|
||||||
|
if !ok {
|
||||||
|
return "0"
|
||||||
|
}
|
||||||
|
|
||||||
|
divisor := new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(tokenDecimals)), nil) //nolint:mnd
|
||||||
|
whole := new(big.Int).Div(n, divisor)
|
||||||
|
remainder := new(big.Int).Mod(n, divisor)
|
||||||
|
|
||||||
|
if remainder.Sign() == 0 {
|
||||||
|
return addThousandsSeparators(whole.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
fracStr := fmt.Sprintf("%0*s", tokenDecimals, remainder.String())
|
||||||
|
fracStr = strings.TrimRight(fracStr, "0")
|
||||||
|
const maxDisplayDecimals = 4
|
||||||
|
if len(fracStr) > maxDisplayDecimals {
|
||||||
|
fracStr = fracStr[:maxDisplayDecimals]
|
||||||
|
}
|
||||||
|
|
||||||
|
return addThousandsSeparators(whole.String()) + "." + fracStr
|
||||||
|
}
|
||||||
|
|
||||||
|
func addThousandsSeparators(s string) string {
|
||||||
|
if len(s) <= 3 { //nolint:mnd
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
var result strings.Builder
|
||||||
|
offset := len(s) % 3 //nolint:mnd
|
||||||
|
if offset > 0 {
|
||||||
|
result.WriteString(s[:offset])
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := offset; i < len(s); i += 3 { //nolint:mnd
|
||||||
|
if result.Len() > 0 {
|
||||||
|
result.WriteByte(',')
|
||||||
|
}
|
||||||
|
result.WriteString(s[i : i+3]) //nolint:mnd
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.String()
|
||||||
|
}
|
||||||
|
|||||||
BIN
frontend/public/ping.png
Normal file
BIN
frontend/public/ping.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
@@ -29,7 +29,7 @@ export default function App() {
|
|||||||
<Route path="/" element={<Addresses />} />
|
<Route path="/" element={<Addresses />} />
|
||||||
<Route path="/addresses" element={<Addresses />} />
|
<Route path="/addresses" element={<Addresses />} />
|
||||||
<Route path="/alerts" element={<Alerts />} />
|
<Route path="/alerts" element={<Alerts />} />
|
||||||
<Route path="/history" element={<AlertHistory />} />
|
<Route path="/alertevents" element={<AlertHistory />} />
|
||||||
<Route path="/onboarding" element={<Onboarding />} />
|
<Route path="/onboarding" element={<Onboarding />} />
|
||||||
<Route path="*" element={<Navigate to="/addresses" />} />
|
<Route path="*" element={<Navigate to="/addresses" />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
7
frontend/src/components/AlertForm.css
Normal file
7
frontend/src/components/AlertForm.css
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
.alert-form__options {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-form__radio {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
14
frontend/src/components/Button.css
Normal file
14
frontend/src/components/Button.css
Normal 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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
21
frontend/src/components/Input.css
Normal file
21
frontend/src/components/Input.css
Normal 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);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
56
frontend/src/components/Navbar.css
Normal file
56
frontend/src/components/Navbar.css
Normal 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;
|
||||||
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
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" },
|
||||||
{ to: "/alerts", label: "Configure Alerts" },
|
{ to: "/alerts", label: "Configure Alerts" },
|
||||||
{ to: "/history", label: "Alert History" },
|
{ to: "/alertevents", label: "Alert Events" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function Navbar() {
|
export default function Navbar() {
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
30
frontend/src/pages/Addresses.css
Normal file
30
frontend/src/pages/Addresses.css
Normal 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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
7
frontend/src/pages/AlertHistory.css
Normal file
7
frontend/src/pages/AlertHistory.css
Normal 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);
|
||||||
|
}
|
||||||
@@ -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 Alerts</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();
|
||||||
|
|||||||
63
frontend/src/pages/Alerts.css
Normal file
63
frontend/src/pages/Alerts.css
Normal 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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
64
frontend/src/pages/Login.css
Normal file
64
frontend/src/pages/Login.css
Normal 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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
185
frontend/src/pages/Onboarding.css
Normal file
185
frontend/src/pages/Onboarding.css
Normal 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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user