Compare commits

..

1 Commits

Author SHA1 Message Date
KS Jannette
35107c0a9d Add notification platform setup guides to README
Some checks are pending
check / check (push) Waiting to run
Made-with: Cursor
2026-03-01 10:25:56 -05:00
42 changed files with 1117 additions and 2231 deletions

View File

@@ -5,6 +5,7 @@ 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.
## Getting Started
### Prerequisites
@@ -45,16 +46,16 @@ cd frontend && npm run dev
```
The API listens on `http://localhost:3001` and the frontend on
`http://localhost:3000` by default for development.
`http://localhost:3000` by default.
## Rationale
Crypto users may actively monitor addresses with webhooks integrating popular
messaging platforms: Discord, Slack, Telegram.
This lightweight, reliable framework makes instant awareness of on-chain
activity trivial, without polling block explorers manually. Koin Ping watches
addresses, evaluates configurable alert rules (incoming transactions, outgoing
transactions, "large" transfers, balance thresholds), and sends notifications.
Crypto users who hold or actively monitor addresses need a lightweight, reliable
way to know when on-chain activity occurs without polling block explorers
manually. Koin Ping fills that gap: it watches a set of Ethereum addresses,
evaluates configurable alert rules (incoming transactions, outgoing
transactions, large transfers, balance thresholds), and notifies the user
through Discord webhooks.
## Design
@@ -131,7 +132,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).
> **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
@@ -143,7 +144,82 @@ To receive alerts via Telegram, you need to create a bot and get your chat ID.
---
*Guides for Discord, Slack, and Email coming soon.*
### Discord
To receive alerts in a Discord channel, you need to create a webhook.
#### 1. Create a Discord Webhook
1. Open **Discord** and navigate to the server where you want to receive alerts.
2. Go to the channel you want alerts posted in (or create a new one, e.g. `#koin-alerts`).
3. Click the **gear icon** next to the channel name to open **Channel Settings**.
4. Go to **Integrations** > **Webhooks**.
5. Click **New Webhook**.
6. Give it a name (e.g. `Koin Ping`) and click **Copy Webhook URL**.
The URL will look like: `https://discord.com/api/webhooks/123456789/AbCdEfGhIjKl...`
#### 2. Save in Koin Ping
1. Go to the **Alerts** page in Koin Ping.
2. In the **Notification Settings** panel, find the **Discord** section.
3. Paste your **Webhook URL** into the field.
4. Click **Save Settings**.
5. Click **Test All Channels** to verify — you should see a test message appear in your Discord channel.
---
### Slack
To receive alerts in a Slack channel, you need to create an Incoming Webhook.
#### 1. Create a Slack App with Incoming Webhooks
1. Go to [https://api.slack.com/apps](https://api.slack.com/apps) and click **Create New App**.
2. Choose **From scratch**, give it a name (e.g. `Koin Ping`), and select your workspace.
3. In the app settings, go to **Incoming Webhooks** in the left sidebar.
4. Toggle **Activate Incoming Webhooks** to **On**.
5. Click **Add New Webhook to Workspace**.
6. Select the channel where you want alerts posted (e.g. `#koin-alerts`) and click **Allow**.
7. Copy the **Webhook URL** — it will start with `https://hooks.slack.com/services/...`
#### 2. Save in Koin Ping
1. Go to the **Alerts** page in Koin Ping.
2. In the **Notification Settings** panel, find the **Slack** section.
3. Paste your **Webhook URL** into the field.
4. Click **Save Settings**.
5. Click **Test All Channels** to verify — you should see a test message appear in your Slack channel.
---
### Email
Koin Ping sends email alerts and digests via [Resend](https://resend.com). To enable email notifications, the application owner must configure a Resend account and verified sending domain.
#### For Application Owners (Deployment Setup)
1. Create an account at [resend.com](https://resend.com).
2. Go to [resend.com/domains](https://resend.com/domains) and add your sending domain.
3. Add the DNS records Resend provides (SPF, DKIM, etc.) to your domain's DNS settings.
4. Once verified, create an API key at [resend.com/api-keys](https://resend.com/api-keys).
5. Set the following in your `.env` file:
```
RESEND_API_KEY=re_your_api_key_here
EMAIL_FROM=Your App <alerts@yourdomain.com>
```
6. Restart the API server and poller to pick up the changes.
#### For Users
1. Go to the **Alerts** page in Koin Ping.
2. In the **Notification Settings** panel, find the **Email** section.
3. Enter the email address where you want to receive alerts.
4. Click **Save Settings**.
5. Click **Verify Email** to receive a confirmation message.
6. Click **Test All Channels** to verify — you should receive a test alert at your email address.
## License
@@ -152,5 +228,3 @@ MIT. See [LICENSE](LICENSE).
## Author
Steven Jannette

View File

@@ -2,20 +2,6 @@ Start DB:
brew services start postgresql@15
Run Backend:
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.
cd /Users/kjannette/workspace/koin_ping/backend-go go run ./cmd/api

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,4 @@
// Package database manages PostgreSQL connection pools.
package database
import (
@@ -10,21 +11,21 @@ import (
)
const (
// maxConnIdleSeconds is the maximum idle time for a connection.
maxConnIdleSeconds = 30
// maxConnLifetimeMinutes is the maximum lifetime for a connection.
maxConnLifetimeMinutes = 5
// connectTimeoutSeconds is the timeout for initial connection.
connectTimeoutSeconds = 10
// maxConns is the maximum number of connections in the pool.
maxConns = 20
// minConns is the minimum number of connections in the pool.
minConns = 2
)
var pool *pgxpool.Pool //nolint:gochecknoglobals
// establishPostgreSQL connection pool using the given DSN.
// Connect establishes a PostgreSQL connection pool using the given DSN.
func Connect(dsn string) (*pgxpool.Pool, error) {
cfg, err := pgxpool.ParseConfig(dsn)
if err != nil {
@@ -56,10 +57,12 @@ func Connect(dsn string) (*pgxpool.Pool, error) {
return p, nil
}
// Pool returns the global connection pool.
func Pool() *pgxpool.Pool {
return pool
}
// Close closes the global connection pool.
func Close() {
if pool != nil {
pool.Close()

View File

@@ -1,16 +1,9 @@
// Package domain defines core domain types shared across the application.
package domain
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
}
// Address represents a tracked Ethereum address.
type Address struct {
ID int `json:"id"`
UserID string `json:"user_id"` //nolint:tagliatelle
@@ -19,10 +12,13 @@ type Address struct {
CreatedAt time.Time `json:"created_at"` //nolint:tagliatelle
}
// AlertType identifies the kind of alert rule.
type AlertType string
// String implements fmt.Stringer.
func (a AlertType) String() string { return string(a) }
// Alert type constants define the supported alert triggers.
const (
AlertIncomingTx AlertType = "incoming_tx"
AlertOutgoingTx AlertType = "outgoing_tx"
@@ -30,6 +26,7 @@ const (
AlertBalanceBelow AlertType = "balance_below"
)
// ValidAlertTypes lists all alert types accepted by the API.
var ValidAlertTypes = []AlertType{ //nolint:gochecknoglobals
AlertIncomingTx,
AlertOutgoingTx,
@@ -65,6 +62,7 @@ func IsThresholdRequired(t AlertType) bool {
return false
}
// AlertRule represents a user-defined alert rule for an address.
type AlertRule struct {
ID int `json:"id"`
AddressID int `json:"address_id"` //nolint:tagliatelle
@@ -74,6 +72,7 @@ type AlertRule struct {
CreatedAt time.Time `json:"created_at"` //nolint:tagliatelle
}
// AlertEvent represents a fired alert event stored for history.
type AlertEvent struct {
ID int `json:"id"`
AlertRuleID int `json:"alert_rule_id"` //nolint:tagliatelle
@@ -83,6 +82,7 @@ type AlertEvent struct {
Timestamp time.Time `json:"timestamp"`
}
// AddressCheckpoint tracks the last block checked for an address.
type AddressCheckpoint struct {
AddressID int `json:"address_id"` //nolint:tagliatelle
LastCheckedBlock int `json:"last_checked_block"` //nolint:tagliatelle
@@ -119,29 +119,21 @@ type NormalizedTx struct {
Value string `json:"value"` // Wei as string for precision
BlockNumber int `json:"block_number"` //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
}
// Direction indicates whether a transaction is incoming or outgoing.
type Direction string
// String implements fmt.Stringer.
func (d Direction) String() string { return string(d) }
// Direction constants indicate the flow of a transaction relative to a watched address.
const (
DirectionIncoming Direction = "incoming"
DirectionOutgoing Direction = "outgoing"
)
// ObservedTx is a NormalizedTx enriched with address and direction context.
type ObservedTx struct {
NormalizedTx
AddressID int `json:"address_id"` //nolint:tagliatelle

View File

@@ -1,3 +1,4 @@
// Package firebase provides Firebase authentication integration.
package firebase
import (
@@ -16,6 +17,7 @@ var ( //nolint:gochecknoglobals
errInit error //nolint:gochecknoglobals
)
// Init initializes the Firebase app and auth client using the given project ID.
func Init(projectID string) error {
once.Do(func() {
ctx := context.Background()
@@ -46,6 +48,7 @@ func Init(projectID string) error {
return errInit
}
// Auth returns the initialized Firebase auth client.
func Auth() *auth.Client {
return authClient
}

View File

@@ -15,14 +15,17 @@ import (
var ethAddressRe = regexp.MustCompile(`^0x[a-fA-F0-9]{40}$`)
// AddressHandler handles HTTP requests for address management.
type AddressHandler struct {
addresses *models.AddressModel
}
// NewAddressHandler creates a new AddressHandler.
func NewAddressHandler(addresses *models.AddressModel) *AddressHandler {
return &AddressHandler{addresses: addresses}
}
// Create handles POST requests to add a new tracked address.
func (h *AddressHandler) Create(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r.Context())
@@ -89,7 +92,7 @@ func (h *AddressHandler) List(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, addresses)
}
// handles PATCH requests to update an address label.
// UpdateLabel handles PATCH requests to update an address label.
func (h *AddressHandler) UpdateLabel(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r.Context())
addressID, ok := parseIntParam(r.PathValue("addressId"))

View File

@@ -14,17 +14,21 @@ import (
"github.com/kjannette/koin-ping/backend-go/internal/models"
)
// errThresholdFormat is returned when the threshold JSON cannot be decoded.
var errThresholdFormat = errors.New("unsupported threshold format")
// AlertRuleHandler handles HTTP requests for alert rule management.
type AlertRuleHandler struct {
alertRules *models.AlertRuleModel
addresses *models.AddressModel
}
// NewAlertRuleHandler creates a new AlertRuleHandler.
func NewAlertRuleHandler(alertRules *models.AlertRuleModel, addresses *models.AddressModel) *AlertRuleHandler {
return &AlertRuleHandler{alertRules: alertRules, addresses: addresses}
}
// Create handles POST requests to create a new alert rule for an address.
func (h *AlertRuleHandler) Create(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r.Context())
addressID, ok := parseIntParam(r.PathValue("addressId"))

View File

@@ -9,7 +9,6 @@ import (
"strings"
fbauth "github.com/kjannette/koin-ping/backend-go/internal/firebase"
"github.com/kjannette/koin-ping/backend-go/internal/models"
)
type contextKey string
@@ -27,14 +26,10 @@ type errorResponse struct {
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v) //nolint:errcheck
json.NewEncoder(w).Encode(v)
}
// 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 {
func Authenticate(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
@@ -77,28 +72,17 @@ func Authenticate(userModel *models.UserModel) func(http.Handler) http.Handler {
return
}
firebaseUID := decoded.UID
userID := decoded.UID
email, _ := decoded.Claims["email"].(string)
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
}
log.Printf("Token verified! User ID: %s, Email: %s", userID, email)
log.Printf("Token verified! User UUID: %s, Email: %s", user.ID, email)
ctx := context.WithValue(r.Context(), UserIDKey, user.ID)
ctx := context.WithValue(r.Context(), UserIDKey, userID)
ctx = context.WithValue(ctx, UserEmailKey, email)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
func GetUserID(ctx context.Context) string {
if v, ok := ctx.Value(UserIDKey).(string); ok {

View File

@@ -1,53 +0,0 @@
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
}

View File

@@ -242,132 +242,6 @@ func (j *JsonRpcEthereum) GetBalance(ctx context.Context, address string) (strin
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) {
hex = strings.TrimPrefix(hex, "0x")
n, ok := new(big.Int).SetString(hex, 16)

View File

@@ -12,5 +12,4 @@ type EthereumObserver interface {
GetLatestBlockNumber(ctx context.Context) (int, error)
GetBlockTransactions(ctx context.Context, blockNumber int) ([]domain.NormalizedTx, error)
GetBalance(ctx context.Context, address string) (string, error)
GetTokenTransfers(ctx context.Context, fromBlock, toBlock int, address string) ([]domain.NormalizedTx, error)
}

View File

@@ -1,34 +0,0 @@
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
}

View File

@@ -281,10 +281,6 @@ func (s *EvaluatorService) sendNotification(ctx context.Context, userID, message
}
func (s *EvaluatorService) buildMessage(rule domain.AlertRule, obs domain.ObservedTx) string {
if obs.IsTokenTransfer() {
return s.buildTokenMessage(rule, obs)
}
switch rule.Type {
case domain.AlertIncomingTx:
ethStr, _ := wei.FormatAsEth(obs.Value, 4)
@@ -309,36 +305,3 @@ func (s *EvaluatorService) buildMessage(rule domain.AlertRule, obs domain.Observ
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)
}
}

View File

@@ -80,15 +80,6 @@ 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 {
return nil, err
}

View File

@@ -98,54 +98,3 @@ func FormatAsEth(weiString string, decimals int) (string, error) {
}
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()
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

View File

@@ -3,7 +3,6 @@ import { useAuth } from "./contexts/AuthContext";
import Navbar from "./components/Navbar";
import Login from "./pages/Login";
import Signup from "./pages/Signup";
import Onboarding from "./pages/Onboarding";
import Addresses from "./pages/Addresses";
import Alerts from "./pages/Alerts";
import AlertHistory from "./pages/AlertHistory";
@@ -16,7 +15,6 @@ export default function App() {
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/signup" element={<Signup />} />
<Route path="/onboarding" element={<Onboarding />} />
<Route path="*" element={<Navigate to="/login" />} />
</Routes>
);
@@ -29,8 +27,7 @@ export default function App() {
<Route path="/" element={<Addresses />} />
<Route path="/addresses" element={<Addresses />} />
<Route path="/alerts" element={<Alerts />} />
<Route path="/alertevents" element={<AlertHistory />} />
<Route path="/onboarding" element={<Onboarding />} />
<Route path="/history" element={<AlertHistory />} />
<Route path="*" element={<Navigate to="/addresses" />} />
</Routes>
</div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,11 +1,10 @@
import { Link, useLocation } from "react-router-dom";
import { useAuth } from "../contexts/AuthContext";
import "./Navbar.css";
const navLinks = [
{ to: "/addresses", label: "Addresses" },
{ to: "/alerts", label: "Configure Alerts" },
{ to: "/alertevents", label: "Alert Events" },
{ to: "/history", label: "Alert History" },
];
export default function Navbar() {
@@ -15,17 +14,43 @@ export default function Navbar() {
if (!currentUser) return null;
return (
<nav className="navbar">
<div className="flex flex--center gap-sm">
<span className="navbar__brand">Koin Ping</span>
<div className="navbar__links">
<nav style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
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 }) => {
const isActive = location.pathname === to;
return (
<Link
key={to}
to={to}
className={`navbar__link ${isActive ? "navbar__link--active" : ""}`}
style={{
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}
</Link>
@@ -34,9 +59,24 @@ export default function Navbar() {
</div>
</div>
<div className="flex flex--center gap-lg">
<span className="navbar__user">{currentUser.email}</span>
<button onClick={logout} className="navbar__logout">
<div style={{ display: "flex", alignItems: "center", gap: "1.25rem" }}>
<span style={{ fontSize: "1.275rem", color: "#777" }}>
{currentUser.email}
</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
</button>
</div>

View File

@@ -1,43 +1,5 @@
@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,
*::after {
@@ -59,300 +21,13 @@ body {
-moz-osx-font-smoothing: grayscale;
}
h1,
h2,
h3,
h4,
h5,
h6 {
h1, h2, h3, h4, h5, h6 {
font-family: 'Nunito', sans-serif;
font-weight: 200;
}
input,
select,
textarea,
button {
input, select, textarea, button {
font-family: 'Nunito', sans-serif;
font-size: 1rem;
font-weight: 200;
}
/* ── Page Layouts ─────────────────────────────────────────── */
.page {
max-width: 800px;
margin: 0 auto;
padding: 2rem;
}
.page--wide {
max-width: 1400px;
}
/* ── Cards & Sections ─────────────────────────────────────── */
.card {
padding: 1rem;
background-color: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
}
.section {
margin-bottom: 1.5rem;
padding: 1rem;
background-color: var(--color-bg-section);
border-radius: var(--radius-lg);
border: 1px solid var(--color-border-section);
}
/* ── Form Controls ────────────────────────────────────────── */
.form-field {
margin-bottom: 1.25rem;
}
.form-field--last {
margin-bottom: 1.75rem;
}
.form-label {
display: block;
margin-bottom: 0.25rem;
font-size: 0.9rem;
color: var(--color-text-label);
}
.form-control {
width: 100%;
padding: 0.5rem;
font-size: 1rem;
background-color: var(--color-bg-elevated);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
color: var(--color-text);
}
.form-control:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 2px rgba(0, 102, 204, 0.25);
}
.form-control--mono {
font-family: monospace;
}
.form-select {
width: 100%;
padding: 0.5rem;
font-size: 1rem;
background-color: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
color: var(--color-text);
}
/* ── Buttons ──────────────────────────────────────────────── */
.btn {
padding: 0.5rem 1rem;
font-size: 1rem;
border: none;
border-radius: var(--radius-md);
cursor: pointer;
color: var(--color-text);
transition: opacity 0.15s ease;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn--primary {
background-color: var(--color-primary);
}
.btn--success {
background-color: var(--color-success);
}
.btn--secondary {
background-color: #6c757d;
}
.btn--danger {
background-color: transparent;
border: 1px solid var(--color-danger-border);
color: var(--color-danger);
}
.btn--ghost {
background-color: transparent;
border: 1px solid var(--color-border);
color: var(--color-text-subtle);
}
.btn--sm {
padding: 0.25rem 0.6rem;
font-size: 0.85rem;
}
.btn--lg {
padding: 0.75rem 1.5rem;
}
/* ── Feedback Alerts ──────────────────────────────────────── */
.alert {
padding: 0.75rem;
margin-bottom: 1rem;
border-radius: var(--radius-md);
}
.alert--error {
background-color: var(--color-error-bg);
border: 1px solid var(--color-error-border);
color: var(--color-error);
}
.alert--success {
background-color: var(--color-success-bg);
border: 1px solid var(--color-success-border);
color: var(--color-success-text);
}
.alert--warning {
background-color: var(--color-warning-bg);
border: 1px solid var(--color-warning-border);
color: var(--color-warning);
}
/* ── Lists ────────────────────────────────────────────────── */
.list-unstyled {
list-style: none;
padding: 0;
}
.list-item--card {
padding: 1rem;
margin-bottom: 0.5rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background-color: var(--color-bg-card);
}
/* ── Typography Helpers ───────────────────────────────────── */
.text-muted {
color: var(--color-text-muted);
}
.text-dimmed {
color: var(--color-text-dimmed);
}
.text-mono {
font-family: monospace;
}
.text-bold {
font-weight: bold;
}
.text-error {
color: red;
}
.text-sm {
font-size: 1.035rem;
}
.text-xs {
font-size: 0.978rem;
}
.help-link {
color: var(--color-primary);
font-size: 0.85rem;
}
/* ── Flex Utilities ───────────────────────────────────────── */
.flex {
display: flex;
}
.flex--between {
justify-content: space-between;
}
.flex--center {
align-items: center;
}
.flex--column {
flex-direction: column;
}
.flex--wrap {
flex-wrap: wrap;
}
.gap-xs {
gap: 0.25rem;
}
.gap-sm {
gap: 0.5rem;
}
.gap-md {
gap: 0.75rem;
}
.gap-lg {
gap: 1rem;
}
/* ── Spacing Helpers ──────────────────────────────────────── */
.mb-0 {
margin-bottom: 0;
}
.mb-sm {
margin-bottom: 0.5rem;
}
.mb-md {
margin-bottom: 1rem;
}
.mb-lg {
margin-bottom: 1.5rem;
}
.mb-xl {
margin-bottom: 2rem;
}
.mt-0 {
margin-top: 0;
}
.mt-md {
margin-top: 1rem;
}
.text-white {
color: white;
}
.text-white-sm {
color: white;
font-size: 0.85rem;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,644 +0,0 @@
/**
* Onboarding Wizard
*
* 5-step guided flow: Create Account -> Add Wallet -> Alert Rules -> Notifications -> Done
*/
import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { useAuth } from "../contexts/AuthContext";
import { createAddress, getAddresses } from "../api/addresses";
import { createAlert } from "../api/alerts";
import {
updateNotificationConfig,
testNotificationChannels,
} from "../api/notificationConfig";
import Input from "../components/Input";
import Button from "../components/Button";
import "./Onboarding.css";
const STEPS = [
"Create Account",
"Add Wallet",
"Alert Rules",
"Notifications",
"Done",
];
export default function Onboarding() {
const { currentUser, signup } = useAuth();
const navigate = useNavigate();
const [step, setStep] = useState(1);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [skipWarning, setSkipWarning] = useState("");
const [testResults, setTestResults] = useState(null);
const [testLoading, setTestLoading] = useState(false);
const [data, setData] = useState({
email: "",
password: "",
confirmPassword: "",
walletAddress: "",
walletLabel: "",
createdAddressId: null,
alertIncomingTx: false,
alertOutgoingTx: false,
alertLargeTransfer: false,
largeTransferThreshold: "",
alertBalanceBelow: false,
balanceBelowThreshold: "",
discordWebhookUrl: "",
slackWebhookUrl: "",
notificationEmail: "",
alertsCreated: [],
notificationConfigured: false,
});
function set(field, value) {
setData((prev) => ({ ...prev, [field]: value }));
}
useEffect(() => {
if (!currentUser) return;
getAddresses()
.then((addresses) => {
if (addresses.length > 0) {
navigate("/addresses", { replace: true });
}
})
.catch(() => {});
}, [currentUser, navigate]);
// ── Step handlers ─────────────────────────────────────────────────────────
async function handleStep1() {
setError("");
if (!data.email || !data.password || !data.confirmPassword) {
setError("Please fill in all fields");
return;
}
if (data.password !== data.confirmPassword) {
setError("Passwords do not match");
return;
}
if (data.password.length < 6) {
setError("Password must be at least 6 characters");
return;
}
if (!currentUser) {
try {
setLoading(true);
await signup(data.email, data.password);
} catch (err) {
if (err.code === "auth/email-already-in-use") {
setError("Email already in use. Try logging in instead.");
} else if (err.code === "auth/invalid-email") {
setError("Invalid email address");
} else if (err.code === "auth/weak-password") {
setError("Password is too weak");
} else {
setError("Failed to create account: " + err.message);
}
setLoading(false);
return;
} finally {
setLoading(false);
}
}
setStep(2);
}
async function handleStep2() {
setError("");
if (!data.walletAddress) {
setError("Please enter a wallet address");
return;
}
if (!/^0x[0-9a-fA-F]{40}$/.test(data.walletAddress)) {
setError("Invalid ETH address (must be 0x followed by 40 hex characters)");
return;
}
try {
setLoading(true);
const created = await createAddress({
address: data.walletAddress,
label: data.walletLabel || undefined,
});
set("createdAddressId", created.id);
setStep(3);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
async function handleStep3() {
setError("");
const rules = [];
if (data.alertIncomingTx) rules.push({ type: "incoming_tx" });
if (data.alertOutgoingTx) rules.push({ type: "outgoing_tx" });
if (data.alertLargeTransfer) {
if (!data.largeTransferThreshold) {
setError("Please enter a threshold for large transfers");
return;
}
rules.push({ type: "large_transfer", threshold: data.largeTransferThreshold });
}
if (data.alertBalanceBelow) {
if (!data.balanceBelowThreshold) {
setError("Please enter a threshold for balance below");
return;
}
rules.push({ type: "balance_below", threshold: data.balanceBelowThreshold });
}
if (rules.length === 0) {
setStep(4);
return;
}
try {
setLoading(true);
const created = [];
for (const rule of rules) {
const result = await createAlert(data.createdAddressId, rule);
created.push(result);
}
set("alertsCreated", created);
setStep(4);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
async function handleStep4() {
setError("");
const hasAny =
data.discordWebhookUrl || data.slackWebhookUrl || data.notificationEmail;
if (!hasAny) {
setStep(5);
return;
}
try {
setLoading(true);
await updateNotificationConfig({
notification_enabled: true,
discord_webhook_url: data.discordWebhookUrl || undefined,
slack_webhook_url: data.slackWebhookUrl || undefined,
email: data.notificationEmail || undefined,
});
set("notificationConfigured", true);
setStep(5);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
async function handleTestChannels() {
setTestLoading(true);
setTestResults(null);
try {
const results = await testNotificationChannels();
setTestResults(results);
} catch (err) {
setTestResults({ error: err.message });
} finally {
setTestLoading(false);
}
}
// ── Progress bar ──────────────────────────────────────────────────────────
function ProgressBar() {
return (
<div className="progress-bar">
{STEPS.map((label, i) => {
const stepNum = i + 1;
const done = step > stepNum;
const active = step === stepNum;
const dotClass = done
? "progress-bar__dot--done"
: active
? "progress-bar__dot--active"
: "progress-bar__dot--pending";
return (
<div key={label} className="progress-bar__step">
{i > 0 && (
<div
className={`progress-bar__connector ${
done || active
? "progress-bar__connector--active"
: "progress-bar__connector--inactive"
}`}
/>
)}
<div>
<div className={`progress-bar__dot ${dotClass}`}>
{done ? "\u2713" : stepNum}
</div>
<div
className={`progress-bar__label ${
active
? "progress-bar__label--active"
: "progress-bar__label--inactive"
}`}
>
{label}
</div>
</div>
</div>
);
})}
</div>
);
}
// ── Step content ──────────────────────────────────────────────────────────
function Step1() {
return (
<>
<h2 className="mb-lg">Create your account</h2>
<Input
label="Email"
type="email"
value={data.email}
onChange={(v) => set("email", v)}
disabled={loading}
placeholder="you@example.com"
/>
<Input
label="Password"
type="password"
value={data.password}
onChange={(v) => set("password", v)}
disabled={loading}
placeholder="At least 6 characters"
/>
<Input
label="Confirm Password"
type="password"
value={data.confirmPassword}
onChange={(v) => set("confirmPassword", v)}
disabled={loading}
placeholder="Repeat your password"
className="form-field--last"
/>
</>
);
}
function Step2() {
return (
<>
<h2 className="mb-sm">Add a wallet address</h2>
<p className="onboarding__subtitle">
Enter the Ethereum address you want to monitor.
</p>
<Input
label="ETH Address"
value={data.walletAddress}
onChange={(v) => set("walletAddress", v)}
disabled={loading}
placeholder="0x..."
/>
<Input
label="Label (optional)"
value={data.walletLabel}
onChange={(v) => set("walletLabel", v)}
disabled={loading}
placeholder="e.g. My main wallet"
className="form-field--last"
/>
</>
);
}
function Step3() {
return (
<>
<h2 className="mb-sm">Configure alert rules</h2>
<p className="onboarding__subtitle">
Choose which events trigger notifications. You can change these later.
</p>
<CheckboxRow
checked={data.alertIncomingTx}
onChange={(v) => set("alertIncomingTx", v)}
label="Incoming transaction"
/>
<CheckboxRow
checked={data.alertOutgoingTx}
onChange={(v) => set("alertOutgoingTx", v)}
label="Outgoing transaction"
/>
<CheckboxRow
checked={data.alertLargeTransfer}
onChange={(v) => set("alertLargeTransfer", v)}
label="Large transfer"
>
{data.alertLargeTransfer && (
<div className="checkbox-row__nested">
<Input
type="number"
label=""
value={data.largeTransferThreshold}
onChange={(v) => set("largeTransferThreshold", v)}
placeholder="Threshold (ETH)"
min="0"
step="0.01"
/>
</div>
)}
</CheckboxRow>
<CheckboxRow
checked={data.alertBalanceBelow}
onChange={(v) => set("alertBalanceBelow", v)}
label="Balance below"
>
{data.alertBalanceBelow && (
<div className="checkbox-row__nested">
<Input
type="number"
label=""
value={data.balanceBelowThreshold}
onChange={(v) => set("balanceBelowThreshold", v)}
placeholder="Threshold (ETH)"
min="0"
step="0.01"
/>
</div>
)}
</CheckboxRow>
</>
);
}
function Step4() {
return (
<>
<h2 className="mb-sm">Set up notifications</h2>
<p className="onboarding__subtitle">
Add at least one channel so you receive alerts. All fields are optional.
</p>
<div className="mb-md">
<label className="form-label">
Discord Webhook URL{" "}
<a
href="https://support.discord.com/hc/en-us/articles/228383668"
target="_blank"
rel="noreferrer"
className="help-link"
>
(how to get one)
</a>
</label>
<Input
label=""
type="url"
value={data.discordWebhookUrl}
onChange={(v) => set("discordWebhookUrl", v)}
disabled={loading}
placeholder="https://discord.com/api/webhooks/..."
/>
</div>
<div className="mb-md">
<label className="form-label">
Slack Webhook URL{" "}
<a
href="https://api.slack.com/messaging/webhooks"
target="_blank"
rel="noreferrer"
className="help-link"
>
(how to get one)
</a>
</label>
<Input
label=""
type="url"
value={data.slackWebhookUrl}
onChange={(v) => set("slackWebhookUrl", v)}
disabled={loading}
placeholder="https://hooks.slack.com/services/..."
/>
</div>
<Input
label="Email address for alerts"
type="email"
value={data.notificationEmail}
onChange={(v) => set("notificationEmail", v)}
disabled={loading}
placeholder="you@example.com"
className="form-field--last"
/>
</>
);
}
function Step5() {
const alertCount = data.alertsCreated.length;
const hasNotif = data.notificationConfigured;
return (
<>
<h2 className="mb-md">You're all set!</h2>
<div className="onboarding__summary">
<p className="onboarding__summary-title">Summary</p>
<ul className="onboarding__summary-list">
<li>
Wallet address added:{" "}
<span className="text-mono text-white-sm">
{data.walletAddress}
</span>
{data.walletLabel && ` (${data.walletLabel})`}
</li>
<li>
Alert rules configured:{" "}
<span className="text-white">
{alertCount > 0 ? `${alertCount} rule${alertCount !== 1 ? "s" : ""}` : "None (skipped)"}
</span>
</li>
<li>
Notification channels:{" "}
<span className="text-white">
{hasNotif ? "Configured" : "Not set up (skipped)"}
</span>
</li>
</ul>
</div>
{hasNotif && (
<div className="mb-lg">
<Button
onClick={handleTestChannels}
disabled={testLoading}
variant="ghost"
>
{testLoading ? "Testing..." : "Test All Channels"}
</Button>
{testResults && (
<div className="mt-md">
{testResults.error ? (
<p className="text-error">{testResults.error}</p>
) : (
<ul className="list-unstyled">
{Object.entries(testResults).map(([channel, result]) => (
<li
key={channel}
className={`test-result ${result.success ? "test-result--success" : "test-result--failure"}`}
>
{result.success ? "\u2713" : "\u2717"} {channel}:{" "}
{result.message || (result.success ? "OK" : "Failed")}
</li>
))}
</ul>
)}
</div>
)}
</div>
)}
<Button onClick={() => navigate("/addresses")} className="btn--lg text-bold">
Go to Dashboard
</Button>
</>
);
}
// ── Shared helpers ────────────────────────────────────────────────────────
function CheckboxRow({ checked, onChange, label, children }) {
return (
<div className="checkbox-row">
<label className="checkbox-row__label">
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
className="checkbox-row__input"
/>
{label}
</label>
{children}
</div>
);
}
// ── Footer navigation ─────────────────────────────────────────────────────
function Footer() {
if (step === 5) return null;
const canSkip = step === 3 || step === 4;
const canBack = step > 1;
async function handleNext() {
setSkipWarning("");
if (step === 1) await handleStep1();
else if (step === 2) await handleStep2();
else if (step === 3) await handleStep3();
else if (step === 4) await handleStep4();
}
function handleSkip() {
setError("");
setSkipWarning("");
setStep((s) => s + 1);
}
function handleBack() {
setError("");
setSkipWarning("");
setStep((s) => s - 1);
}
return (
<div className="onboarding__footer">
<div>
{canBack && (
<Button
onClick={handleBack}
disabled={loading}
variant="ghost"
>
Back
</Button>
)}
</div>
<div className="flex gap-md">
{canSkip && (
<Button
onClick={handleSkip}
disabled={loading}
variant="ghost"
>
Skip for now
</Button>
)}
<Button
onClick={handleNext}
disabled={loading}
className="text-bold"
>
{loading ? "Please wait..." : step === 4 ? "Finish" : "Next →"}
</Button>
</div>
</div>
);
}
// ── Render ────────────────────────────────────────────────────────────────
const stepContent = {
1: <Step1 />,
2: <Step2 />,
3: <Step3 />,
4: <Step4 />,
5: <Step5 />,
};
return (
<div className="onboarding">
<div className="onboarding__container">
<h1 className="onboarding__title">Koin Ping</h1>
<ProgressBar />
{error && (
<div className="alert alert--error">{error}</div>
)}
{skipWarning && (
<div className="alert alert--warning">{skipWarning}</div>
)}
<div className="onboarding__card">
{stepContent[step]}
<Footer />
</div>
{step === 1 && (
<p className="onboarding__login-link">
Already have an account?{" "}
<a href="/login">Log in here</a>
</p>
)}
</div>
</div>
);
}

View File

@@ -1,5 +1,188 @@
import { Navigate } from "react-router-dom";
/**
* Signup Page
*
* Allows new users to create an account with email and password
*/
import { useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { useAuth } from "../contexts/AuthContext";
export default function Signup() {
return <Navigate to="/onboarding" replace />;
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const { signup } = useAuth();
const navigate = useNavigate();
async function handleSubmit(e) {
e.preventDefault();
// Validation
if (!email || !password || !confirmPassword) {
setError("Please fill in all fields");
return;
}
if (password !== confirmPassword) {
setError("Passwords do not match");
return;
}
if (password.length < 6) {
setError("Password must be at least 6 characters");
return;
}
try {
setError("");
setLoading(true);
await signup(email, password);
navigate("/addresses"); // Auto-login and redirect
} catch (err) {
// Firebase-specific error messages
if (err.code === "auth/email-already-in-use") {
setError("Email already in use. Try logging in instead.");
} else if (err.code === "auth/invalid-email") {
setError("Invalid email address");
} else if (err.code === "auth/weak-password") {
setError("Password is too weak");
} else {
setError("Failed to create account: " + err.message);
}
} finally {
setLoading(false);
}
}
return (
<div
style={{
maxWidth: "400px",
margin: "4rem auto",
padding: "2rem",
border: "1px solid #333",
borderRadius: "8px",
}}
>
<h1 style={{ marginBottom: "2rem", textAlign: "center" }}>
Koin Ping - Sign Up
</h1>
{error && (
<div
style={{
padding: "0.75rem",
marginBottom: "1rem",
backgroundColor: "#ff000020",
border: "1px solid #ff0000",
borderRadius: "4px",
color: "#ff6666",
}}
>
{error}
</div>
)}
<form onSubmit={handleSubmit}>
<div style={{ marginBottom: "1rem" }}>
<label style={{ display: "block", marginBottom: "0.5rem" }}>
Email
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={loading}
style={{
width: "100%",
padding: "0.5rem",
fontSize: "1rem",
backgroundColor: "#242424",
border: "1px solid #444",
borderRadius: "4px",
color: "white",
}}
required
/>
</div>
<div style={{ marginBottom: "1rem" }}>
<label style={{ display: "block", marginBottom: "0.5rem" }}>
Password
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={loading}
style={{
width: "100%",
padding: "0.5rem",
fontSize: "1rem",
backgroundColor: "#242424",
border: "1px solid #444",
borderRadius: "4px",
color: "white",
}}
required
/>
</div>
<div style={{ marginBottom: "1.5rem" }}>
<label style={{ display: "block", marginBottom: "0.5rem" }}>
Confirm Password
</label>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
disabled={loading}
style={{
width: "100%",
padding: "0.5rem",
fontSize: "1rem",
backgroundColor: "#242424",
border: "1px solid #444",
borderRadius: "4px",
color: "white",
}}
required
/>
</div>
<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 ? "Creating account..." : "Sign Up"}
</button>
</form>
<div style={{ marginTop: "1.5rem", textAlign: "center" }}>
<p style={{ color: "#b3b3b3" }}>
Already have an account?{" "}
<Link
to="/login"
style={{ color: "#0066cc", textDecoration: "none" }}
>
Log in here
</Link>
</p>
</div>
</div>
);
}