Compare commits

..

17 Commits

Author SHA1 Message Date
KS Jannette
b3a08e8f25 css trick
Some checks are pending
check / check (push) Waiting to run
2026-03-02 00:28:13 -05:00
KS Jannette
8716be0201 fudge 2026-03-02 00:09:24 -05:00
KS Jannette
647be874e5 m 2026-03-02 00:09:01 -05:00
KS Jannette
c12c4fe742 add image 2026-03-01 23:14:25 -05:00
S Jannette
5f5253d495 Revise README for clarity and feature description
Updated the README to clarify features and improve readability.
2026-03-01 21:48:53 -05:00
S Jannette
bbe2374703 Merge pull request #11 from kjannette/history-reconfig-possible-bugfix
History reconfig possible bugfix
2026-03-01 20:38:56 -05:00
KS Jannette
205317c920 update docs
Some checks are pending
check / check (push) Waiting to run
2026-03-01 20:35:08 -05:00
KS Jannette
25850825b4 updated types and poller 2026-03-01 20:32:21 -05:00
S Jannette
f01115de86 Merge pull request #10 from kjannette/webhook-configs
webhook configs for users, changes to system
2026-03-01 19:08:42 -05:00
KS Jannette
6735000050 webhook configs for users, changes to system
Some checks are pending
check / check (push) Waiting to run
2026-03-01 19:06:32 -05:00
KS Jannette
42a64fc043 Add multi-step onboarding wizard to replace bare signup form
Introduces a 5-step guided flow (Create Account → Add Wallet → Alert
Rules → Notifications → Done) that takes new users from zero to fully
configured alerts in one linear session. /signup now redirects to
/onboarding; already-onboarded users who revisit /onboarding are
redirected to /addresses.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 12:54:55 -05:00
S Jannette
9935817fa8 Merge pull request #9 from kjannette/setupTelegramonfig
Setup telegramonfig
2026-03-01 09:49:16 -05:00
KS Jannette
8d5716bdb7 Add guide for setup of Telegram
Some checks are pending
check / check (push) Waiting to run
2026-03-01 09:48:52 -05:00
KS Jannette
aafe96d9a1 Finish config 2026-03-01 09:40:54 -05:00
S Jannette
93168209b5 Merge pull request #8 from kjannette/audit1
Audit1
2026-03-01 07:55:57 -05:00
S Jannette
6840af1a47 Merge branch 'master' into audit1 2026-03-01 07:55:49 -05:00
S Jannette
c707b82f36 Revise README with new project overview
Updated project description and added overview section.
2026-03-01 07:51:57 -05:00
22 changed files with 1366 additions and 362 deletions

View File

@@ -2,6 +2,7 @@
A lightweight on-chain monitoring and alerting system designed to give users situational awareness over blockchain addresses they care about.
# Overview
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
@@ -44,16 +45,16 @@ cd frontend && npm run dev
```
The API listens on `http://localhost:3001` and the frontend on
`http://localhost:3000` by default.
`http://localhost:3000` by default for development.
## Rationale
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.
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.
## Design
@@ -98,6 +99,52 @@ to the database, and dispatches Discord notifications.
Firebase, communicates with the API via fetch, and renders the address/alert
management UI.
## Setting Up Your Alert Platforms
Koin Ping can send real-time alerts to **Telegram**, **Discord**, **Slack**, and **Email**. Each channel is configured per-user through the **Notification Settings** panel on the Alerts page.
Below are step-by-step guides for setting up each platform.
---
### Telegram
To receive alerts via Telegram, you need to create a bot and get your chat ID.
#### 1. Create a Telegram Bot
1. Open Telegram and search for **@BotFather** (look for the blue verified checkmark).
2. Open the conversation with BotFather and send: `/newbot`
3. BotFather will ask for a **display name** — enter something like `Koin Ping Alerts`.
4. BotFather will ask for a **username** — it must end in `bot`, e.g. `MyKoinPingBot`.
5. BotFather will reply with your **Bot Token** — a string that looks like `123456789:ABCdefGHIjklMNOpqrSTUvwxYZ`. Copy it.
#### 2. Get Your Chat ID
1. In Telegram, search for the bot username you just created and open the chat.
2. Tap **Start** or send any message (e.g. `hello`).
3. Open the following URL in your browser, replacing `YOUR_BOT_TOKEN` with the token from step 1:
```
https://api.telegram.org/botYOUR_BOT_TOKEN/getUpdates
```
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.
#### 3. Save in Koin Ping
1. Go to the **Alerts** page in Koin Ping.
2. In the **Notification Settings** panel, find the **Telegram** section.
3. Paste your **Bot Token** and **Chat ID** into the corresponding fields.
4. Click **Save Settings**.
5. Click **Test All Channels** to verify — you should receive a test message from your bot in Telegram.
---
*Guides for Discord, Slack, and Email coming soon.*
## License
MIT. See [LICENSE](LICENSE).
@@ -105,3 +152,5 @@ MIT. See [LICENSE](LICENSE).
## Author
Steven Jannette

View File

@@ -2,6 +2,14 @@ Start DB:
brew services start postgresql@15
Run Backend:
cd /Users/kjannette/workspace/koin_ping/backend-go go run ./cmd/api
From the backend-go directory, you have a few options:
Option 1: Single command (both API + poller)
cd /Users/kjannette/workspace/koin_ping_0.2.0/backend-gomake dev-all
Option 2: Two separate terminals
Terminal 1 (API server):
cd /Users/kjannette/workspace/koin_ping_0.2.0/backend-gogo run ./cmd/api
Terminal 2 (Poller):
cd /Users/kjannette/workspace/koin_ping_0.2.0/backend-gogo run ./cmd/poller

BIN
backend-go/api Executable file

Binary file not shown.

View File

@@ -1,4 +1,3 @@
// Package database manages PostgreSQL connection pools.
package database
import (
@@ -11,21 +10,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
// Connect establishes a PostgreSQL connection pool using the given DSN.
// establishPostgreSQL connection pool using the given DSN.
func Connect(dsn string) (*pgxpool.Pool, error) {
cfg, err := pgxpool.ParseConfig(dsn)
if err != nil {
@@ -57,12 +56,10 @@ 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,9 +1,7 @@
// Package domain defines core domain types shared across the application.
package domain
import "time"
// Address represents a tracked Ethereum address.
type Address struct {
ID int `json:"id"`
UserID string `json:"user_id"` //nolint:tagliatelle
@@ -12,13 +10,10 @@ 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"
@@ -26,7 +21,6 @@ const (
AlertBalanceBelow AlertType = "balance_below"
)
// ValidAlertTypes lists all alert types accepted by the API.
var ValidAlertTypes = []AlertType{ //nolint:gochecknoglobals
AlertIncomingTx,
AlertOutgoingTx,
@@ -62,7 +56,6 @@ 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
@@ -72,7 +65,6 @@ 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
@@ -82,7 +74,6 @@ 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,21 +110,29 @@ 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,4 +1,3 @@
// Package firebase provides Firebase authentication integration.
package firebase
import (
@@ -17,7 +16,6 @@ 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()
@@ -48,7 +46,6 @@ func Init(projectID string) error {
return errInit
}
// Auth returns the initialized Firebase auth client.
func Auth() *auth.Client {
return authClient
}

View File

@@ -15,17 +15,14 @@ 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())
@@ -92,7 +89,7 @@ func (h *AddressHandler) List(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, addresses)
}
// UpdateLabel handles PATCH requests to update an address label.
// 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,21 +14,17 @@ 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

@@ -242,6 +242,132 @@ 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,4 +12,5 @@ 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

@@ -0,0 +1,34 @@
package ethereum
import "strings"
// TokenInfo holds metadata for a known ERC-20 token contract.
type TokenInfo struct {
Symbol string
Decimals int
}
//nolint:gochecknoglobals
var wellKnownTokens = map[string]TokenInfo{
"0xdac17f958d2ee523a2206206994597c13d831ec7": {Symbol: "USDT", Decimals: 6},
"0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48": {Symbol: "USDC", Decimals: 6},
"0x6b175474e89094c44da98b954eedeac495271d0f": {Symbol: "DAI", Decimals: 18},
"0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2": {Symbol: "WETH", Decimals: 18},
"0x2260fac5e5542a773aa44fbcfedf7c193bc2c599": {Symbol: "WBTC", Decimals: 8},
"0x514910771af9ca656af840dff83e8264ecf986ca": {Symbol: "LINK", Decimals: 18},
"0x1f9840a85d5af5bf1d1762f925bdaddc4201f984": {Symbol: "UNI", Decimals: 18},
"0x7fc66500c84a76ad7e9c93437bfc5ac33e2ddae9": {Symbol: "AAVE", Decimals: 18},
"0x95ad61b0a150d79219dcf64e1e6cc01f0b64c4ce": {Symbol: "SHIB", Decimals: 18},
"0x6982508145454ce325ddbe47a25d4ec3d2311933": {Symbol: "PEPE", Decimals: 18},
"0xb8c77482e45f1f44de1745f52c74426c631bdd52": {Symbol: "BNB", Decimals: 18},
"0x4fabb145d64652a948d72533023f6e7a623c7c53": {Symbol: "BUSD", Decimals: 18},
"0x75231f58b43240c9718dd58b4967c5114342a86c": {Symbol: "OKB", Decimals: 18},
"0x582d872a1b094fc48f5de31d3b73f2d9be47def1": {Symbol: "TON", Decimals: 9},
"0x4d224452801aced8b2f0aebe155379bb5d594381": {Symbol: "APE", Decimals: 18},
}
// LookupToken returns metadata for a known token contract, if found.
func LookupToken(contractAddress string) (TokenInfo, bool) {
info, ok := wellKnownTokens[strings.ToLower(contractAddress)]
return info, ok
}

View File

@@ -281,6 +281,10 @@ 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)
@@ -305,3 +309,36 @@ 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,6 +80,15 @@ func (s *ObserverService) observeAddress(ctx context.Context, addr domain.Addres
}
}
tokenTxs, err := s.eth.GetTokenTransfers(ctx, startBlock, endBlock, addr.Address)
if err != nil {
log.Printf("Error fetching token transfers for %s: %v", addr.Address, err)
} else {
for _, tx := range tokenTxs {
observations = append(observations, createObservedTx(tx, addr))
}
}
if _, err := s.checkpoint.UpdateLastCheckedBlock(ctx, addr.ID, endBlock); err != nil {
return nil, err
}

View File

@@ -98,3 +98,54 @@ 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()
}

BIN
frontend/public/ping.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

View File

@@ -3,6 +3,7 @@ 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";
@@ -15,6 +16,7 @@ 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>
);
@@ -27,7 +29,8 @@ export default function App() {
<Route path="/" element={<Addresses />} />
<Route path="/addresses" element={<Addresses />} />
<Route path="/alerts" element={<Alerts />} />
<Route path="/history" element={<AlertHistory />} />
<Route path="/alertevents" element={<AlertHistory />} />
<Route path="/onboarding" element={<Onboarding />} />
<Route path="*" element={<Navigate to="/addresses" />} />
</Routes>
</div>

View File

@@ -4,7 +4,7 @@ import { useAuth } from "../contexts/AuthContext";
const navLinks = [
{ to: "/addresses", label: "Addresses" },
{ to: "/alerts", label: "Configure Alerts" },
{ to: "/history", label: "Alert History" },
{ to: "/alertevents", label: "Alert Events" },
];
export default function Navbar() {
@@ -26,8 +26,8 @@ export default function Navbar() {
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
<span style={{
fontWeight: 700,
fontSize: "1.1rem",
color: "#fff",
fontSize: "1.4rem",
color: "#e62525",
marginRight: "2rem",
letterSpacing: "0.5px",
}}>

View File

@@ -36,7 +36,7 @@ export default function AlertHistory() {
return (
<div style={{ maxWidth: "800px", margin: "0 auto", padding: "2rem" }}>
<h1>Recent Alerts</h1>
<h1>Recent Alert Events</h1>
{alertEvents.length === 0 ? (
<p style={{ color: "#808080" }}>No alerts yet</p>

View File

@@ -0,0 +1,88 @@
.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;
}

View File

@@ -1,146 +1,107 @@
/**
* 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 "./Login.css";
export default function Login() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const [isVisible, setIsVisible] = useState(false);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const { login } = useAuth();
const navigate = useNavigate();
const { login } = useAuth();
const navigate = useNavigate();
async function handleSubmit(e) {
e.preventDefault();
async function handleSubmit(e) {
e.preventDefault();
// Validation
if (!email || !password) {
setError("Please fill in all fields");
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);
}
// Validation
if (!email || !password) {
setError("Please fill in all fields");
return;
}
return (
<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>
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);
}
}
{error && (
<div
style={{
padding: "0.75rem",
marginBottom: "1rem",
backgroundColor: "#ff000020",
border: "1px solid #ff0000",
borderRadius: "4px",
color: "#ff6666",
}}
>
{error}
</div>
)}
return (
<div className="login-page">
<div
className="login-card"
onMouseEnter={() => setIsVisible(true)}
>
<h1 className="login-heading">
<span className="login-brand">Koin Ping</span> - Login
</h1>
<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: "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 style={{ visibility: isVisible ? 'visible' : 'hidden' }}>
{error && (
<div className="login-error">
{error}
</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>
);
}

View File

@@ -0,0 +1,834 @@
/**
* 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>
);
}

View File

@@ -1,188 +1,5 @@
/**
* 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";
import { Navigate } from "react-router-dom";
export default function Signup() {
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>
);
return <Navigate to="/onboarding" replace />;
}