Compare commits
1 Commits
migrate-in
...
configureD
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35107c0a9d |
96
README.md
96
README.md
@@ -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.
|
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
|
||||||
@@ -45,16 +46,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 for development.
|
`http://localhost:3000` by default.
|
||||||
|
|
||||||
## Rationale
|
## Rationale
|
||||||
|
|
||||||
Crypto users may actively monitor addresses with webhooks integrating popular
|
Crypto users who hold or actively monitor addresses need a lightweight, reliable
|
||||||
messaging platforms: Discord, Slack, Telegram.
|
way to know when on-chain activity occurs without polling block explorers
|
||||||
This lightweight, reliable framework makes instant awareness of on-chain
|
manually. Koin Ping fills that gap: it watches a set of Ethereum addresses,
|
||||||
activity trivial, without polling block explorers manually. Koin Ping watches
|
evaluates configurable alert rules (incoming transactions, outgoing
|
||||||
addresses, evaluates configurable alert rules (incoming transactions, outgoing
|
transactions, large transfers, balance thresholds), and notifies the user
|
||||||
transactions, "large" transfers, balance thresholds), and sends notifications.
|
through Discord webhooks.
|
||||||
|
|
||||||
## Design
|
## 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).
|
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
|
||||||
|
|
||||||
@@ -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
|
## License
|
||||||
|
|
||||||
@@ -152,5 +228,3 @@ MIT. See [LICENSE](LICENSE).
|
|||||||
## Author
|
## Author
|
||||||
|
|
||||||
Steven Jannette
|
Steven Jannette
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,14 +2,6 @@ Start DB:
|
|||||||
|
|
||||||
brew services start postgresql@15
|
brew services start postgresql@15
|
||||||
|
|
||||||
|
Run Backend:
|
||||||
|
|
||||||
From the backend-go directory, you have a few options:
|
cd /Users/kjannette/workspace/koin_ping/backend-go go run ./cmd/api
|
||||||
|
|
||||||
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
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// Package database manages PostgreSQL connection pools.
|
||||||
package database
|
package database
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -10,21 +11,21 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
// maxConnIdleSeconds is the maximum idle time for a connection.
|
||||||
maxConnIdleSeconds = 30
|
maxConnIdleSeconds = 30
|
||||||
|
// maxConnLifetimeMinutes is the maximum lifetime for a connection.
|
||||||
maxConnLifetimeMinutes = 5
|
maxConnLifetimeMinutes = 5
|
||||||
|
// connectTimeoutSeconds is the timeout for initial connection.
|
||||||
connectTimeoutSeconds = 10
|
connectTimeoutSeconds = 10
|
||||||
|
// maxConns is the maximum number of connections in the pool.
|
||||||
maxConns = 20
|
maxConns = 20
|
||||||
|
// minConns is the minimum number of connections in the pool.
|
||||||
minConns = 2
|
minConns = 2
|
||||||
)
|
)
|
||||||
|
|
||||||
var pool *pgxpool.Pool //nolint:gochecknoglobals
|
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) {
|
func Connect(dsn string) (*pgxpool.Pool, error) {
|
||||||
cfg, err := pgxpool.ParseConfig(dsn)
|
cfg, err := pgxpool.ParseConfig(dsn)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -56,10 +57,12 @@ func Connect(dsn string) (*pgxpool.Pool, error) {
|
|||||||
return p, nil
|
return p, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pool returns the global connection pool.
|
||||||
func Pool() *pgxpool.Pool {
|
func Pool() *pgxpool.Pool {
|
||||||
return pool
|
return pool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Close closes the global connection pool.
|
||||||
func Close() {
|
func Close() {
|
||||||
if pool != nil {
|
if pool != nil {
|
||||||
pool.Close()
|
pool.Close()
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
// Package domain defines core domain types shared across the application.
|
||||||
package domain
|
package domain
|
||||||
|
|
||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
|
// Address represents a tracked Ethereum address.
|
||||||
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
|
||||||
@@ -10,10 +12,13 @@ type Address struct {
|
|||||||
CreatedAt time.Time `json:"created_at"` //nolint:tagliatelle
|
CreatedAt time.Time `json:"created_at"` //nolint:tagliatelle
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AlertType identifies the kind of alert rule.
|
||||||
type AlertType string
|
type AlertType string
|
||||||
|
|
||||||
|
// String implements fmt.Stringer.
|
||||||
func (a AlertType) String() string { return string(a) }
|
func (a AlertType) String() string { return string(a) }
|
||||||
|
|
||||||
|
// Alert type constants define the supported alert triggers.
|
||||||
const (
|
const (
|
||||||
AlertIncomingTx AlertType = "incoming_tx"
|
AlertIncomingTx AlertType = "incoming_tx"
|
||||||
AlertOutgoingTx AlertType = "outgoing_tx"
|
AlertOutgoingTx AlertType = "outgoing_tx"
|
||||||
@@ -21,6 +26,7 @@ const (
|
|||||||
AlertBalanceBelow AlertType = "balance_below"
|
AlertBalanceBelow AlertType = "balance_below"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ValidAlertTypes lists all alert types accepted by the API.
|
||||||
var ValidAlertTypes = []AlertType{ //nolint:gochecknoglobals
|
var ValidAlertTypes = []AlertType{ //nolint:gochecknoglobals
|
||||||
AlertIncomingTx,
|
AlertIncomingTx,
|
||||||
AlertOutgoingTx,
|
AlertOutgoingTx,
|
||||||
@@ -56,6 +62,7 @@ func IsThresholdRequired(t AlertType) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AlertRule represents a user-defined alert rule for an address.
|
||||||
type AlertRule struct {
|
type AlertRule struct {
|
||||||
ID int `json:"id"`
|
ID int `json:"id"`
|
||||||
AddressID int `json:"address_id"` //nolint:tagliatelle
|
AddressID int `json:"address_id"` //nolint:tagliatelle
|
||||||
@@ -65,6 +72,7 @@ type AlertRule struct {
|
|||||||
CreatedAt time.Time `json:"created_at"` //nolint:tagliatelle
|
CreatedAt time.Time `json:"created_at"` //nolint:tagliatelle
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AlertEvent represents a fired alert event stored for history.
|
||||||
type AlertEvent struct {
|
type AlertEvent struct {
|
||||||
ID int `json:"id"`
|
ID int `json:"id"`
|
||||||
AlertRuleID int `json:"alert_rule_id"` //nolint:tagliatelle
|
AlertRuleID int `json:"alert_rule_id"` //nolint:tagliatelle
|
||||||
@@ -74,6 +82,7 @@ type AlertEvent struct {
|
|||||||
Timestamp time.Time `json:"timestamp"`
|
Timestamp time.Time `json:"timestamp"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AddressCheckpoint tracks the last block checked for an address.
|
||||||
type AddressCheckpoint struct {
|
type AddressCheckpoint struct {
|
||||||
AddressID int `json:"address_id"` //nolint:tagliatelle
|
AddressID int `json:"address_id"` //nolint:tagliatelle
|
||||||
LastCheckedBlock int `json:"last_checked_block"` //nolint:tagliatelle
|
LastCheckedBlock int `json:"last_checked_block"` //nolint:tagliatelle
|
||||||
@@ -110,29 +119,21 @@ 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Direction indicates whether a transaction is incoming or outgoing.
|
||||||
type Direction string
|
type Direction string
|
||||||
|
|
||||||
// String implements fmt.Stringer.
|
// String implements fmt.Stringer.
|
||||||
func (d Direction) String() string { return string(d) }
|
func (d Direction) String() string { return string(d) }
|
||||||
|
|
||||||
|
// Direction constants indicate the flow of a transaction relative to a watched address.
|
||||||
const (
|
const (
|
||||||
DirectionIncoming Direction = "incoming"
|
DirectionIncoming Direction = "incoming"
|
||||||
DirectionOutgoing Direction = "outgoing"
|
DirectionOutgoing Direction = "outgoing"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ObservedTx is a NormalizedTx enriched with address and direction context.
|
||||||
type ObservedTx struct {
|
type ObservedTx struct {
|
||||||
NormalizedTx
|
NormalizedTx
|
||||||
AddressID int `json:"address_id"` //nolint:tagliatelle
|
AddressID int `json:"address_id"` //nolint:tagliatelle
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// Package firebase provides Firebase authentication integration.
|
||||||
package firebase
|
package firebase
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -16,6 +17,7 @@ var ( //nolint:gochecknoglobals
|
|||||||
errInit error //nolint:gochecknoglobals
|
errInit error //nolint:gochecknoglobals
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Init initializes the Firebase app and auth client using the given project ID.
|
||||||
func Init(projectID string) error {
|
func Init(projectID string) error {
|
||||||
once.Do(func() {
|
once.Do(func() {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@@ -46,6 +48,7 @@ func Init(projectID string) error {
|
|||||||
return errInit
|
return errInit
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auth returns the initialized Firebase auth client.
|
||||||
func Auth() *auth.Client {
|
func Auth() *auth.Client {
|
||||||
return authClient
|
return authClient
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,14 +15,17 @@ import (
|
|||||||
|
|
||||||
var ethAddressRe = regexp.MustCompile(`^0x[a-fA-F0-9]{40}$`)
|
var ethAddressRe = regexp.MustCompile(`^0x[a-fA-F0-9]{40}$`)
|
||||||
|
|
||||||
|
// AddressHandler handles HTTP requests for address management.
|
||||||
type AddressHandler struct {
|
type AddressHandler struct {
|
||||||
addresses *models.AddressModel
|
addresses *models.AddressModel
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewAddressHandler creates a new AddressHandler.
|
||||||
func NewAddressHandler(addresses *models.AddressModel) *AddressHandler {
|
func NewAddressHandler(addresses *models.AddressModel) *AddressHandler {
|
||||||
return &AddressHandler{addresses: addresses}
|
return &AddressHandler{addresses: addresses}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create handles POST requests to add a new tracked address.
|
||||||
func (h *AddressHandler) Create(w http.ResponseWriter, r *http.Request) {
|
func (h *AddressHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||||
userID := middleware.GetUserID(r.Context())
|
userID := middleware.GetUserID(r.Context())
|
||||||
|
|
||||||
@@ -89,7 +92,7 @@ func (h *AddressHandler) List(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, addresses)
|
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) {
|
func (h *AddressHandler) UpdateLabel(w http.ResponseWriter, r *http.Request) {
|
||||||
userID := middleware.GetUserID(r.Context())
|
userID := middleware.GetUserID(r.Context())
|
||||||
addressID, ok := parseIntParam(r.PathValue("addressId"))
|
addressID, ok := parseIntParam(r.PathValue("addressId"))
|
||||||
|
|||||||
@@ -14,17 +14,21 @@ import (
|
|||||||
"github.com/kjannette/koin-ping/backend-go/internal/models"
|
"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")
|
var errThresholdFormat = errors.New("unsupported threshold format")
|
||||||
|
|
||||||
|
// AlertRuleHandler handles HTTP requests for alert rule management.
|
||||||
type AlertRuleHandler struct {
|
type AlertRuleHandler struct {
|
||||||
alertRules *models.AlertRuleModel
|
alertRules *models.AlertRuleModel
|
||||||
addresses *models.AddressModel
|
addresses *models.AddressModel
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewAlertRuleHandler creates a new AlertRuleHandler.
|
||||||
func NewAlertRuleHandler(alertRules *models.AlertRuleModel, addresses *models.AddressModel) *AlertRuleHandler {
|
func NewAlertRuleHandler(alertRules *models.AlertRuleModel, addresses *models.AddressModel) *AlertRuleHandler {
|
||||||
return &AlertRuleHandler{alertRules: alertRules, addresses: addresses}
|
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) {
|
func (h *AlertRuleHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||||
userID := middleware.GetUserID(r.Context())
|
userID := middleware.GetUserID(r.Context())
|
||||||
addressID, ok := parseIntParam(r.PathValue("addressId"))
|
addressID, ok := parseIntParam(r.PathValue("addressId"))
|
||||||
|
|||||||
@@ -242,132 +242,6 @@ 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,5 +12,4 @@ 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)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -281,10 +281,6 @@ 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)
|
||||||
@@ -309,36 +305,3 @@ 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,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 {
|
if _, err := s.checkpoint.UpdateLastCheckedBlock(ctx, addr.ID, endBlock); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,54 +98,3 @@ 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()
|
|
||||||
}
|
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 30 KiB |
@@ -3,7 +3,6 @@ import { useAuth } from "./contexts/AuthContext";
|
|||||||
import Navbar from "./components/Navbar";
|
import Navbar from "./components/Navbar";
|
||||||
import Login from "./pages/Login";
|
import Login from "./pages/Login";
|
||||||
import Signup from "./pages/Signup";
|
import Signup from "./pages/Signup";
|
||||||
import Onboarding from "./pages/Onboarding";
|
|
||||||
import Addresses from "./pages/Addresses";
|
import Addresses from "./pages/Addresses";
|
||||||
import Alerts from "./pages/Alerts";
|
import Alerts from "./pages/Alerts";
|
||||||
import AlertHistory from "./pages/AlertHistory";
|
import AlertHistory from "./pages/AlertHistory";
|
||||||
@@ -16,7 +15,6 @@ export default function App() {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<Login />} />
|
<Route path="/login" element={<Login />} />
|
||||||
<Route path="/signup" element={<Signup />} />
|
<Route path="/signup" element={<Signup />} />
|
||||||
<Route path="/onboarding" element={<Onboarding />} />
|
|
||||||
<Route path="*" element={<Navigate to="/login" />} />
|
<Route path="*" element={<Navigate to="/login" />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
);
|
);
|
||||||
@@ -29,8 +27,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="/alertevents" element={<AlertHistory />} />
|
<Route path="/history" element={<AlertHistory />} />
|
||||||
<Route path="/onboarding" element={<Onboarding />} />
|
|
||||||
<Route path="*" element={<Navigate to="/addresses" />} />
|
<Route path="*" element={<Navigate to="/addresses" />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useAuth } from "../contexts/AuthContext";
|
|||||||
const navLinks = [
|
const navLinks = [
|
||||||
{ to: "/addresses", label: "Addresses" },
|
{ to: "/addresses", label: "Addresses" },
|
||||||
{ to: "/alerts", label: "Configure Alerts" },
|
{ to: "/alerts", label: "Configure Alerts" },
|
||||||
{ to: "/alertevents", label: "Alert Events" },
|
{ to: "/history", label: "Alert History" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function Navbar() {
|
export default function Navbar() {
|
||||||
@@ -26,8 +26,8 @@ export default function Navbar() {
|
|||||||
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
||||||
<span style={{
|
<span style={{
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
fontSize: "1.4rem",
|
fontSize: "1.1rem",
|
||||||
color: "#e62525",
|
color: "#fff",
|
||||||
marginRight: "2rem",
|
marginRight: "2rem",
|
||||||
letterSpacing: "0.5px",
|
letterSpacing: "0.5px",
|
||||||
}}>
|
}}>
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export default function AlertHistory() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ maxWidth: "800px", margin: "0 auto", padding: "2rem" }}>
|
<div style={{ maxWidth: "800px", margin: "0 auto", padding: "2rem" }}>
|
||||||
<h1>Recent Alert Events</h1>
|
<h1>Recent Alerts</h1>
|
||||||
|
|
||||||
{alertEvents.length === 0 ? (
|
{alertEvents.length === 0 ? (
|
||||||
<p style={{ color: "#808080" }}>No alerts yet</p>
|
<p style={{ color: "#808080" }}>No alerts yet</p>
|
||||||
|
|||||||
@@ -1,88 +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 #333;
|
|
||||||
border-radius: 8px;
|
|
||||||
background-color: rgba(0, 0, 0, 0.75);
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-heading {
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-brand {
|
|
||||||
color: #e62525;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-error {
|
|
||||||
padding: 0.75rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
background-color: #ff000020;
|
|
||||||
border: 1px solid #ff0000;
|
|
||||||
border-radius: 4px;
|
|
||||||
color: #ff6666;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-field {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-field-last {
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-label {
|
|
||||||
display: block;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-input {
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.5rem;
|
|
||||||
font-size: 1.2rem;
|
|
||||||
background-color: #242424;
|
|
||||||
border: 1px solid #444;
|
|
||||||
border-radius: 4px;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-button {
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.75rem;
|
|
||||||
font-size: 1.2rem;
|
|
||||||
background-color: #0066cc;
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-button:disabled {
|
|
||||||
background-color: #333;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-footer {
|
|
||||||
margin-top: 1.5rem;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-footer-text {
|
|
||||||
color: #b3b3b3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-signup-link {
|
|
||||||
color: #0066cc;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
@@ -1,107 +1,146 @@
|
|||||||
|
/**
|
||||||
|
* 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 "./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("");
|
const [loading, setLoading] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
const { login } = useAuth();
|
const { login } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
async function handleSubmit(e) {
|
async function handleSubmit(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
// Validation
|
// Validation
|
||||||
if (!email || !password) {
|
if (!email || !password) {
|
||||||
setError("Please fill in all fields");
|
setError("Please fill in all fields");
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setError("");
|
||||||
|
setLoading(true);
|
||||||
|
await login(email, password);
|
||||||
|
navigate("/addresses"); // Redirect to main app
|
||||||
|
} catch (err) {
|
||||||
|
setError("Failed to log in: " + err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
return (
|
||||||
setError("");
|
<div
|
||||||
setLoading(true);
|
style={{
|
||||||
await login(email, password);
|
maxWidth: "400px",
|
||||||
navigate("/addresses"); // Redirect to main app
|
margin: "4rem auto",
|
||||||
} catch (err) {
|
padding: "2rem",
|
||||||
setError("Failed to log in: " + err.message);
|
border: "1px solid #333",
|
||||||
} finally {
|
borderRadius: "8px",
|
||||||
setLoading(false);
|
}}
|
||||||
}
|
>
|
||||||
}
|
<h1 style={{ marginBottom: "2rem", textAlign: "center" }}>
|
||||||
|
Koin Ping - Login
|
||||||
|
</h1>
|
||||||
|
|
||||||
return (
|
{error && (
|
||||||
<div className="login-page">
|
<div
|
||||||
<div
|
style={{
|
||||||
className="login-card"
|
padding: "0.75rem",
|
||||||
onMouseEnter={() => setIsVisible(true)}
|
marginBottom: "1rem",
|
||||||
>
|
backgroundColor: "#ff000020",
|
||||||
<h1 className="login-heading">
|
border: "1px solid #ff0000",
|
||||||
<span className="login-brand">Koin Ping</span> - Login
|
borderRadius: "4px",
|
||||||
</h1>
|
color: "#ff6666",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div style={{ visibility: isVisible ? 'visible' : 'hidden' }}>
|
<form onSubmit={handleSubmit}>
|
||||||
{error && (
|
<div style={{ marginBottom: "1rem" }}>
|
||||||
<div className="login-error">
|
<label style={{ display: "block", marginBottom: "0.5rem" }}>
|
||||||
{error}
|
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: "1.5rem" }}>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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 style={{ marginTop: "1.5rem", textAlign: "center" }}>
|
||||||
|
<p style={{ color: "#b3b3b3" }}>
|
||||||
|
Don't have an account?{" "}
|
||||||
|
<Link
|
||||||
|
to="/signup"
|
||||||
|
style={{ color: "#0066cc", textDecoration: "none" }}
|
||||||
|
>
|
||||||
|
Sign up here
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit}>
|
|
||||||
<div className="login-field">
|
|
||||||
<label className="login-label">
|
|
||||||
Email
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
value={email}
|
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
|
||||||
disabled={loading}
|
|
||||||
className="login-input"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="login-field-last">
|
|
||||||
<label className="login-label">
|
|
||||||
Password
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
disabled={loading}
|
|
||||||
className="login-input"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={loading}
|
|
||||||
className="login-button"
|
|
||||||
>
|
|
||||||
{loading ? "Logging in..." : "Log In"}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div className="login-footer">
|
|
||||||
<p className="login-footer-text">
|
|
||||||
Don't have an account?{" "}
|
|
||||||
<Link
|
|
||||||
to="/signup"
|
|
||||||
className="login-signup-link"
|
|
||||||
>
|
|
||||||
Sign up here
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,834 +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";
|
|
||||||
|
|
||||||
const STEPS = [
|
|
||||||
"Create Account",
|
|
||||||
"Add Wallet",
|
|
||||||
"Alert Rules",
|
|
||||||
"Notifications",
|
|
||||||
"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() {
|
|
||||||
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);
|
|
||||||
|
|
||||||
// Wizard state
|
|
||||||
const [data, setData] = useState({
|
|
||||||
email: "",
|
|
||||||
password: "",
|
|
||||||
confirmPassword: "",
|
|
||||||
walletAddress: "",
|
|
||||||
walletLabel: "",
|
|
||||||
createdAddressId: null,
|
|
||||||
alertIncomingTx: false,
|
|
||||||
alertOutgoingTx: false,
|
|
||||||
alertLargeTransfer: false,
|
|
||||||
largeTransferThreshold: "",
|
|
||||||
alertBalanceBelow: false,
|
|
||||||
balanceBelowThreshold: "",
|
|
||||||
discordWebhookUrl: "",
|
|
||||||
slackWebhookUrl: "",
|
|
||||||
notificationEmail: "",
|
|
||||||
// summary
|
|
||||||
alertsCreated: [],
|
|
||||||
notificationConfigured: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
function set(field, value) {
|
|
||||||
setData((prev) => ({ ...prev, [field]: value }));
|
|
||||||
}
|
|
||||||
|
|
||||||
// On mount: if already fully onboarded, redirect away
|
|
||||||
useEffect(() => {
|
|
||||||
if (!currentUser) return;
|
|
||||||
getAddresses()
|
|
||||||
.then((addresses) => {
|
|
||||||
if (addresses.length > 0) {
|
|
||||||
navigate("/addresses", { replace: true });
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => {}); // ignore errors (e.g. mid-signup)
|
|
||||||
}, [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 user already exists (browser-close-mid-wizard), skip signup
|
|
||||||
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
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
marginBottom: "2rem",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{STEPS.map((label, i) => {
|
|
||||||
const stepNum = i + 1;
|
|
||||||
const done = step > stepNum;
|
|
||||||
const active = step === stepNum;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={label}
|
|
||||||
style={{ display: "flex", alignItems: "center" }}
|
|
||||||
>
|
|
||||||
{i > 0 && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: "40px",
|
|
||||||
height: "2px",
|
|
||||||
backgroundColor: done || active ? "#0066cc" : "#444",
|
|
||||||
margin: "0 4px",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div style={{ textAlign: "center" }}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
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
|
|
||||||
style={{
|
|
||||||
fontSize: "0.7rem",
|
|
||||||
color: active ? "white" : "#888",
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Step content ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function Step1() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<h2 style={{ marginBottom: "1.5rem" }}>Create your account</h2>
|
|
||||||
<div style={{ marginBottom: "1rem" }}>
|
|
||||||
<label style={labelStyle}>Email</label>
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
value={data.email}
|
|
||||||
onChange={(e) => set("email", e.target.value)}
|
|
||||||
disabled={loading}
|
|
||||||
style={inputStyle}
|
|
||||||
placeholder="you@example.com"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div style={{ marginBottom: "1rem" }}>
|
|
||||||
<label style={labelStyle}>Password</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={data.password}
|
|
||||||
onChange={(e) => set("password", e.target.value)}
|
|
||||||
disabled={loading}
|
|
||||||
style={inputStyle}
|
|
||||||
placeholder="At least 6 characters"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div style={{ marginBottom: "1.5rem" }}>
|
|
||||||
<label style={labelStyle}>Confirm Password</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={data.confirmPassword}
|
|
||||||
onChange={(e) => set("confirmPassword", e.target.value)}
|
|
||||||
disabled={loading}
|
|
||||||
style={inputStyle}
|
|
||||||
placeholder="Repeat your password"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Step2() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<h2 style={{ marginBottom: "0.5rem" }}>Add a wallet address</h2>
|
|
||||||
<p style={{ color: "#aaa", marginBottom: "1.5rem", fontSize: "0.9rem" }}>
|
|
||||||
Enter the Ethereum address you want to monitor.
|
|
||||||
</p>
|
|
||||||
<div style={{ marginBottom: "1rem" }}>
|
|
||||||
<label style={labelStyle}>ETH Address</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={data.walletAddress}
|
|
||||||
onChange={(e) => set("walletAddress", e.target.value)}
|
|
||||||
disabled={loading}
|
|
||||||
style={inputStyle}
|
|
||||||
placeholder="0x..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div style={{ marginBottom: "1.5rem" }}>
|
|
||||||
<label style={labelStyle}>Label (optional)</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={data.walletLabel}
|
|
||||||
onChange={(e) => set("walletLabel", e.target.value)}
|
|
||||||
disabled={loading}
|
|
||||||
style={inputStyle}
|
|
||||||
placeholder="e.g. My main wallet"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Step3() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<h2 style={{ marginBottom: "0.5rem" }}>Configure alert rules</h2>
|
|
||||||
<p style={{ color: "#aaa", marginBottom: "1.5rem", fontSize: "0.9rem" }}>
|
|
||||||
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 style={{ marginTop: "0.5rem", marginLeft: "1.75rem" }}>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={data.largeTransferThreshold}
|
|
||||||
onChange={(e) => set("largeTransferThreshold", e.target.value)}
|
|
||||||
style={{ ...inputStyle, width: "160px" }}
|
|
||||||
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 style={{ marginTop: "0.5rem", marginLeft: "1.75rem" }}>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={data.balanceBelowThreshold}
|
|
||||||
onChange={(e) => set("balanceBelowThreshold", e.target.value)}
|
|
||||||
style={{ ...inputStyle, width: "160px" }}
|
|
||||||
placeholder="Threshold (ETH)"
|
|
||||||
min="0"
|
|
||||||
step="0.01"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CheckboxRow>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Step4() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<h2 style={{ marginBottom: "0.5rem" }}>Set up notifications</h2>
|
|
||||||
<p style={{ color: "#aaa", marginBottom: "1.5rem", fontSize: "0.9rem" }}>
|
|
||||||
Add at least one channel so you receive alerts. All fields are optional.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div style={{ marginBottom: "1.25rem" }}>
|
|
||||||
<label style={labelStyle}>
|
|
||||||
Discord Webhook URL{" "}
|
|
||||||
<a
|
|
||||||
href="https://support.discord.com/hc/en-us/articles/228383668"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
style={{ color: "#4499ff", fontSize: "0.8rem" }}
|
|
||||||
>
|
|
||||||
(how to get one)
|
|
||||||
</a>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="url"
|
|
||||||
value={data.discordWebhookUrl}
|
|
||||||
onChange={(e) => set("discordWebhookUrl", e.target.value)}
|
|
||||||
disabled={loading}
|
|
||||||
style={inputStyle}
|
|
||||||
placeholder="https://discord.com/api/webhooks/..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ marginBottom: "1.25rem" }}>
|
|
||||||
<label style={labelStyle}>
|
|
||||||
Slack Webhook URL{" "}
|
|
||||||
<a
|
|
||||||
href="https://api.slack.com/messaging/webhooks"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
style={{ color: "#4499ff", fontSize: "0.8rem" }}
|
|
||||||
>
|
|
||||||
(how to get one)
|
|
||||||
</a>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="url"
|
|
||||||
value={data.slackWebhookUrl}
|
|
||||||
onChange={(e) => set("slackWebhookUrl", e.target.value)}
|
|
||||||
disabled={loading}
|
|
||||||
style={inputStyle}
|
|
||||||
placeholder="https://hooks.slack.com/services/..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ marginBottom: "1.5rem" }}>
|
|
||||||
<label style={labelStyle}>Email address for alerts</label>
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
value={data.notificationEmail}
|
|
||||||
onChange={(e) => set("notificationEmail", e.target.value)}
|
|
||||||
disabled={loading}
|
|
||||||
style={inputStyle}
|
|
||||||
placeholder="you@example.com"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Step5() {
|
|
||||||
const alertCount = data.alertsCreated.length;
|
|
||||||
const hasNotif = data.notificationConfigured;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<h2 style={{ marginBottom: "1rem" }}>You're all set!</h2>
|
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
backgroundColor: "#1e2e1e",
|
|
||||||
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>
|
|
||||||
Wallet address added:{" "}
|
|
||||||
<span style={{ color: "white", fontFamily: "monospace", fontSize: "0.85rem" }}>
|
|
||||||
{data.walletAddress}
|
|
||||||
</span>
|
|
||||||
{data.walletLabel && ` (${data.walletLabel})`}
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Alert rules configured:{" "}
|
|
||||||
<span style={{ color: "white" }}>
|
|
||||||
{alertCount > 0 ? `${alertCount} rule${alertCount !== 1 ? "s" : ""}` : "None (skipped)"}
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Notification channels:{" "}
|
|
||||||
<span style={{ color: "white" }}>
|
|
||||||
{hasNotif ? "Configured" : "Not set up (skipped)"}
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{hasNotif && (
|
|
||||||
<div style={{ marginBottom: "1.5rem" }}>
|
|
||||||
<button
|
|
||||||
onClick={handleTestChannels}
|
|
||||||
disabled={testLoading}
|
|
||||||
style={{
|
|
||||||
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"}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{testResults && (
|
|
||||||
<div style={{ marginTop: "0.75rem" }}>
|
|
||||||
{testResults.error ? (
|
|
||||||
<p style={{ color: "#ff6666" }}>{testResults.error}</p>
|
|
||||||
) : (
|
|
||||||
<ul style={{ listStyle: "none", padding: 0, margin: 0 }}>
|
|
||||||
{Object.entries(testResults).map(([channel, result]) => (
|
|
||||||
<li
|
|
||||||
key={channel}
|
|
||||||
style={{
|
|
||||||
color: result.success ? "#90ee90" : "#ff6666",
|
|
||||||
fontSize: "0.9rem",
|
|
||||||
marginBottom: "0.25rem",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{result.success ? "✓" : "✗"} {channel}:{" "}
|
|
||||||
{result.message || (result.success ? "OK" : "Failed")}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button
|
|
||||||
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 →
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Shared helpers ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function CheckboxRow({ checked, onChange, label, children }) {
|
|
||||||
return (
|
|
||||||
<div style={{ marginBottom: "1rem" }}>
|
|
||||||
<label
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: "0.6rem",
|
|
||||||
cursor: "pointer",
|
|
||||||
color: "#ddd",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={checked}
|
|
||||||
onChange={(e) => onChange(e.target.checked)}
|
|
||||||
style={{ width: "16px", height: "16px", accentColor: "#0066cc" }}
|
|
||||||
/>
|
|
||||||
{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
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
marginTop: "1.5rem",
|
|
||||||
paddingTop: "1rem",
|
|
||||||
borderTop: "1px solid #333",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
{canBack && (
|
|
||||||
<button
|
|
||||||
onClick={handleBack}
|
|
||||||
disabled={loading}
|
|
||||||
style={{
|
|
||||||
padding: "0.5rem 1rem",
|
|
||||||
backgroundColor: "transparent",
|
|
||||||
color: "#aaa",
|
|
||||||
border: "1px solid #444",
|
|
||||||
borderRadius: "4px",
|
|
||||||
cursor: loading ? "not-allowed" : "pointer",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
← Back
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: "flex", gap: "0.75rem" }}>
|
|
||||||
{canSkip && (
|
|
||||||
<button
|
|
||||||
onClick={handleSkip}
|
|
||||||
disabled={loading}
|
|
||||||
style={{
|
|
||||||
padding: "0.5rem 1rem",
|
|
||||||
backgroundColor: "transparent",
|
|
||||||
color: "#aaa",
|
|
||||||
border: "1px solid #444",
|
|
||||||
borderRadius: "4px",
|
|
||||||
cursor: loading ? "not-allowed" : "pointer",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Skip for now
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={handleNext}
|
|
||||||
disabled={loading}
|
|
||||||
style={{
|
|
||||||
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 →"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Render ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const stepContent = {
|
|
||||||
1: <Step1 />,
|
|
||||||
2: <Step2 />,
|
|
||||||
3: <Step3 />,
|
|
||||||
4: <Step4 />,
|
|
||||||
5: <Step5 />,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
minHeight: "100vh",
|
|
||||||
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 />
|
|
||||||
|
|
||||||
{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 && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: "0.75rem 1rem",
|
|
||||||
marginBottom: "1rem",
|
|
||||||
backgroundColor: "#3a2e00",
|
|
||||||
border: "1px solid #aa7700",
|
|
||||||
borderRadius: "4px",
|
|
||||||
color: "#ffcc44",
|
|
||||||
fontSize: "0.9rem",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{skipWarning}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
backgroundColor: "#242424",
|
|
||||||
border: "1px solid #333",
|
|
||||||
borderRadius: "8px",
|
|
||||||
padding: "2rem",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{stepContent[step]}
|
|
||||||
<Footer />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{step === 1 && (
|
|
||||||
<p
|
|
||||||
style={{
|
|
||||||
textAlign: "center",
|
|
||||||
marginTop: "1.25rem",
|
|
||||||
color: "#888",
|
|
||||||
fontSize: "0.9rem",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Already have an account?{" "}
|
|
||||||
<a href="/login" style={{ color: "#0066cc" }}>
|
|
||||||
Log in here
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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() {
|
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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user