Compare commits
25 Commits
feat-discr
...
media-quer
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5c8f91e3f | ||
|
|
f6255ec1f1 | ||
|
|
4b77e7a4d4 | ||
|
|
8f1212813f | ||
|
|
95ec1a520a | ||
|
|
4b228d9b57 | ||
|
|
08bc3e1ea2 | ||
|
|
a091d39c08 | ||
|
|
35699a41bd | ||
|
|
2e96982d3a | ||
|
|
e48de2d6b6 | ||
|
|
9414af8108 | ||
|
|
068ca2f834 | ||
|
|
7f10bcb7de | ||
|
|
f91ca33752 | ||
|
|
2c86bba235 | ||
|
|
44dad43f1d | ||
|
|
2fe0e5b8e9 | ||
|
|
8f08105246 | ||
|
|
2bc06669e9 | ||
|
|
a2d1ff9d76 | ||
|
|
dcbf0756d2 | ||
|
|
239ea7db85 | ||
|
|
9ad414313b | ||
|
|
3ee1598478 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -21,7 +21,7 @@ node_modules/
|
|||||||
*.key
|
*.key
|
||||||
|
|
||||||
# Go build artifacts
|
# Go build artifacts
|
||||||
backend-go/bin/
|
backend/bin/
|
||||||
*.exe
|
*.exe
|
||||||
*.exe~
|
*.exe~
|
||||||
*.dll
|
*.dll
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
frontend/dist/
|
frontend/dist/
|
||||||
frontend/build/
|
frontend/build/
|
||||||
backend-go/bin/
|
backend/bin/
|
||||||
*.lock
|
*.lock
|
||||||
Prompts/
|
Prompts/
|
||||||
.claude/
|
.claude/
|
||||||
|
|||||||
2
Makefile
2
Makefile
@@ -2,7 +2,7 @@
|
|||||||
test-go test-js lint-go lint-js fmt-go fmt-js fmt-check-go fmt-check-js \
|
test-go test-js lint-go lint-js fmt-go fmt-js fmt-check-go fmt-check-js \
|
||||||
build-go build-js
|
build-go build-js
|
||||||
|
|
||||||
GODIR := backend-go
|
GODIR := backend
|
||||||
JSDIR := frontend
|
JSDIR := frontend
|
||||||
PRETTIER := $(JSDIR)/node_modules/.bin/prettier
|
PRETTIER := $(JSDIR)/node_modules/.bin/prettier
|
||||||
|
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ make hooks
|
|||||||
cd frontend && npm install && cd ..
|
cd frontend && npm install && cd ..
|
||||||
|
|
||||||
# Copy and fill in environment variables
|
# Copy and fill in environment variables
|
||||||
cp backend-go/.env.example backend-go/.env
|
cp backend/.env.example backend/.env
|
||||||
# edit backend-go/.env with your DATABASE_URL, FIREBASE_PROJECT_ID, ETH_RPC_URL
|
# edit backend/.env with your DATABASE_URL, FIREBASE_PROJECT_ID, ETH_RPC_URL
|
||||||
|
|
||||||
# Run checks (requires golangci-lint)
|
# Run checks (requires golangci-lint)
|
||||||
make check
|
make check
|
||||||
@@ -38,7 +38,7 @@ make check
|
|||||||
make run
|
make run
|
||||||
|
|
||||||
# Start the poller (separate terminal)
|
# Start the poller (separate terminal)
|
||||||
cd backend-go && go run ./cmd/poller
|
cd backend && go run ./cmd/poller
|
||||||
|
|
||||||
# Start the frontend dev server (separate terminal)
|
# Start the frontend dev server (separate terminal)
|
||||||
cd frontend && npm run dev
|
cd frontend && npm run dev
|
||||||
@@ -63,7 +63,7 @@ frontend:
|
|||||||
|
|
||||||
```
|
```
|
||||||
koin_ping_0.2.0/
|
koin_ping_0.2.0/
|
||||||
├── backend-go/ # Go monorepo root
|
├── backend/ # Go monorepo root
|
||||||
│ ├── cmd/api/ # HTTP REST API server
|
│ ├── cmd/api/ # HTTP REST API server
|
||||||
│ ├── cmd/poller/ # Blockchain polling daemon
|
│ ├── cmd/poller/ # Blockchain polling daemon
|
||||||
│ └── internal/
|
│ └── internal/
|
||||||
|
|||||||
@@ -6,12 +6,12 @@ last_modified: 2026-02-22
|
|||||||
This document covers repository structure, tooling, and workflow standards. Code
|
This document covers repository structure, tooling, and workflow standards. Code
|
||||||
style conventions are in separate documents:
|
style conventions are in separate documents:
|
||||||
|
|
||||||
- [Code Styleguide](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE.md)
|
- [Code Styleguide](<link>)
|
||||||
(general, bash, Docker)
|
(general, bash, Docker)
|
||||||
- [Go](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_GO.md)
|
- [Go](<link>)
|
||||||
- [JavaScript](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_JS.md)
|
- [JavaScript](<link>)
|
||||||
- [Python](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_PYTHON.md)
|
- [Python](<link>)
|
||||||
- [Go HTTP Server Conventions](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/GO_HTTP_SERVER_CONVENTIONS.md)
|
- [Go HTTP Server Conventions](<link>)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -37,7 +37,7 @@ style conventions are in separate documents:
|
|||||||
`make test`, `make lint`, `make fmt` (writes), `make fmt-check` (read-only),
|
`make test`, `make lint`, `make fmt` (writes), `make fmt-check` (read-only),
|
||||||
`make check` (prereqs: `test`, `lint`, `fmt-check`), `make docker`, and
|
`make check` (prereqs: `test`, `lint`, `fmt-check`), `make docker`, and
|
||||||
`make hooks` (installs pre-commit hook). A model Makefile is at
|
`make hooks` (installs pre-commit hook). A model Makefile is at
|
||||||
`https://git.eeqj.de/sneak/prompts/raw/branch/main/Makefile`.
|
`(<link>)`.
|
||||||
|
|
||||||
- Always use Makefile targets (`make fmt`, `make test`, `make lint`, etc.)
|
- Always use Makefile targets (`make fmt`, `make test`, `make lint`, etc.)
|
||||||
instead of invoking the underlying tools directly. The Makefile is the single
|
instead of invoking the underlying tools directly. The Makefile is the single
|
||||||
@@ -95,7 +95,7 @@ style conventions are in separate documents:
|
|||||||
- `.gitignore` should be comprehensive from the start: OS files (`.DS_Store`),
|
- `.gitignore` should be comprehensive from the start: OS files (`.DS_Store`),
|
||||||
editor files (`.swp`, `*~`), language build artifacts, and `node_modules/`.
|
editor files (`.swp`, `*~`), language build artifacts, and `node_modules/`.
|
||||||
Fetch the standard `.gitignore` from
|
Fetch the standard `.gitignore` from
|
||||||
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up
|
`(<link>) when setting up
|
||||||
a new repo.
|
a new repo.
|
||||||
|
|
||||||
- Never use `git add -A` or `git add .`. Always stage files explicitly by name.
|
- Never use `git add -A` or `git add .`. Always stage files explicitly by name.
|
||||||
@@ -107,7 +107,7 @@ style conventions are in separate documents:
|
|||||||
|
|
||||||
- `.golangci.yml` is standardized and must _NEVER_ be modified by an agent, only
|
- `.golangci.yml` is standardized and must _NEVER_ be modified by an agent, only
|
||||||
manually by the user. Fetch from
|
manually by the user. Fetch from
|
||||||
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml`.
|
`(<link>)`.
|
||||||
|
|
||||||
- When pinning images or packages by hash, add a comment above the reference
|
- When pinning images or packages by hash, add a comment above the reference
|
||||||
with the version and date (YYYY-MM-DD).
|
with the version and date (YYYY-MM-DD).
|
||||||
@@ -124,7 +124,7 @@ style conventions are in separate documents:
|
|||||||
- `README.md` is the primary documentation. Required sections:
|
- `README.md` is the primary documentation. Required sections:
|
||||||
- **Description**: First line must include the project name, purpose,
|
- **Description**: First line must include the project name, purpose,
|
||||||
category (web server, SPA, CLI tool, etc.), license, and author. Example:
|
category (web server, SPA, CLI tool, etc.), license, and author. Example:
|
||||||
"µPaaS is an MIT-licensed Go web application by @sneak that receives
|
"µPaaS is an MIT-licensed Go web application by @sjdev that receives
|
||||||
git-frontend webhooks and deploys applications via Docker in realtime."
|
git-frontend webhooks and deploys applications via Docker in realtime."
|
||||||
- **Getting Started**: Copy-pasteable install/usage code block.
|
- **Getting Started**: Copy-pasteable install/usage code block.
|
||||||
- **Rationale**: Why does this exist?
|
- **Rationale**: Why does this exist?
|
||||||
@@ -134,12 +134,11 @@ style conventions are in separate documents:
|
|||||||
left off.
|
left off.
|
||||||
- **License**: MIT, GPL, or WTFPL. Ask the user for new projects. Include a
|
- **License**: MIT, GPL, or WTFPL. Ask the user for new projects. Include a
|
||||||
`LICENSE` file in the repo root and a License section in the README.
|
`LICENSE` file in the repo root and a License section in the README.
|
||||||
- **Author**: [@sneak](https://sneak.berlin).
|
- **Author**: [@sjdev](https://sjdev.co).
|
||||||
|
|
||||||
- First commit of a new repo should contain only `README.md`.
|
- First commit of a new repo should contain only `README.md`.
|
||||||
|
|
||||||
- Go module root: `sneak.berlin/go/<name>`. Always run `go mod tidy` before
|
- Always run `go mod tidy` before committing.
|
||||||
committing.
|
|
||||||
|
|
||||||
- Use SemVer.
|
- Use SemVer.
|
||||||
|
|
||||||
@@ -175,7 +174,7 @@ style conventions are in separate documents:
|
|||||||
|
|
||||||
- When setting up a new repo, files from the `prompts` repo may be used as
|
- When setting up a new repo, files from the `prompts` repo may be used as
|
||||||
templates. Fetch them from
|
templates. Fetch them from
|
||||||
`https://git.eeqj.de/sneak/prompts/raw/branch/main/<path>`.
|
`(<link>)<path>`.
|
||||||
|
|
||||||
- New repos must contain at minimum:
|
- New repos must contain at minimum:
|
||||||
- `README.md`, `.git`, `.gitignore`, `.editorconfig`
|
- `README.md`, `.git`, `.gitignore`, `.editorconfig`
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
# Server
|
|
||||||
PORT=3001
|
|
||||||
API_BASE_PATH=/v1
|
|
||||||
NODE_ENV=development
|
|
||||||
|
|
||||||
# Database
|
|
||||||
DATABASE_URL=postgresql://user:password@localhost:5432/koin_ping
|
|
||||||
|
|
||||||
# Ethereum JSON-RPC
|
|
||||||
ETH_RPC_URL=https://mainnet.infura.io/v3/YOUR-PROJECT-ID
|
|
||||||
|
|
||||||
# Polling interval (ms, minimum 1000)
|
|
||||||
POLL_INTERVAL_MS=60000
|
|
||||||
|
|
||||||
# Firebase
|
|
||||||
FIREBASE_PROJECT_ID=koin-ping
|
|
||||||
|
|
||||||
# Email notifications (Resend — https://resend.com)
|
|
||||||
# RESEND_API_KEY=re_xxxxxxxxxxxx
|
|
||||||
# EMAIL_FROM=Koin Ping <alerts@yourdomain.com>
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
package models
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/domain"
|
|
||||||
)
|
|
||||||
|
|
||||||
type UserModel struct {
|
|
||||||
pool *pgxpool.Pool
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewUserModel(pool *pgxpool.Pool) *UserModel {
|
|
||||||
return &UserModel{pool: pool}
|
|
||||||
}
|
|
||||||
|
|
||||||
// FindOrCreateByFirebaseUID returns the local user for a Firebase UID,
|
|
||||||
// creating one if it doesn't exist yet. On conflict (returning user) the
|
|
||||||
// updated_at timestamp is refreshed.
|
|
||||||
func (m *UserModel) FindOrCreateByFirebaseUID(ctx context.Context, firebaseUID, email string) (*domain.User, error) {
|
|
||||||
var u domain.User
|
|
||||||
err := m.pool.QueryRow(ctx,
|
|
||||||
`INSERT INTO users (firebase_uid, email)
|
|
||||||
VALUES ($1, $2)
|
|
||||||
ON CONFLICT (firebase_uid) DO UPDATE SET updated_at = NOW()
|
|
||||||
RETURNING id, firebase_uid, email, display_name, created_at, updated_at`,
|
|
||||||
firebaseUID, email,
|
|
||||||
).Scan(&u.ID, &u.FirebaseUID, &u.Email, &u.DisplayName, &u.CreatedAt, &u.UpdatedAt)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &u, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *UserModel) GetByID(ctx context.Context, id string) (*domain.User, error) {
|
|
||||||
var u domain.User
|
|
||||||
err := m.pool.QueryRow(ctx,
|
|
||||||
`SELECT id, firebase_uid, email, display_name, created_at, updated_at
|
|
||||||
FROM users
|
|
||||||
WHERE id = $1`,
|
|
||||||
id,
|
|
||||||
).Scan(&u.ID, &u.FirebaseUID, &u.Email, &u.DisplayName, &u.CreatedAt, &u.UpdatedAt)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &u, nil
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
package notifications
|
|
||||||
|
|
||||||
import "context"
|
|
||||||
|
|
||||||
// AlertMetadata holds context about the alert being sent.
|
|
||||||
type AlertMetadata struct {
|
|
||||||
TxHash string
|
|
||||||
AddressLabel string
|
|
||||||
AlertType string
|
|
||||||
Address string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Notifier is the interface implemented by all notification channels.
|
|
||||||
type Notifier interface {
|
|
||||||
Send(ctx context.Context, message string, meta AlertMetadata) error
|
|
||||||
}
|
|
||||||
@@ -2,17 +2,16 @@ Start DB:
|
|||||||
|
|
||||||
brew services start postgresql@15
|
brew services start postgresql@15
|
||||||
|
|
||||||
|
From the backend directory, you have a few options:
|
||||||
From the backend-go directory, you have a few options:
|
|
||||||
|
|
||||||
Option 1: Single command (both API + poller)
|
Option 1: Single command (both API + poller)
|
||||||
cd /Users/kjannette/workspace/koin_ping_0.2.0/backend-gomake dev-all
|
cd /Users/kjannette/workspace/koin_ping_0.2.0/backendmake dev-all
|
||||||
|
|
||||||
Option 2: Two separate terminals
|
Option 2: Two separate terminals
|
||||||
Terminal 1 (API server):
|
Terminal 1 (API server):
|
||||||
cd /Users/kjannette/workspace/koin_ping_0.2.0/backend-gogo run ./cmd/api
|
cd /Users/kjannette/workspace/koin_ping_0.2.0/backend go run ./cmd/api
|
||||||
Terminal 2 (Poller):
|
Terminal 2 (Poller):
|
||||||
cd /Users/kjannette/workspace/koin_ping_0.2.0/backend-gogo run ./cmd/poller
|
cd /Users/kjannette/workspace/koin_ping_0.2.0/backend go run ./cmd/poller
|
||||||
|
|
||||||
make run — Builds and runs the API server.
|
make run — Builds and runs the API server.
|
||||||
make dev — Runs the API server with auto-reload via air (falls back to go run if air isn't installed).
|
make dev — Runs the API server with auto-reload via air (falls back to go run if air isn't installed).
|
||||||
@@ -8,13 +8,13 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/joho/godotenv"
|
"github.com/joho/godotenv"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/config"
|
"github.com/kjannette/koin-ping/backend/internal/config"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/database"
|
"github.com/kjannette/koin-ping/backend/internal/database"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/firebase"
|
"github.com/kjannette/koin-ping/backend/internal/firebase"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/handlers"
|
"github.com/kjannette/koin-ping/backend/internal/handlers"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/middleware"
|
"github.com/kjannette/koin-ping/backend/internal/middleware"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/models"
|
"github.com/kjannette/koin-ping/backend/internal/models"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/services"
|
"github.com/kjannette/koin-ping/backend/internal/services"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -61,8 +61,16 @@ func main() {
|
|||||||
notifConfigHandler := handlers.NewNotificationConfigHandler(notifConfigModel, cfg)
|
notifConfigHandler := handlers.NewNotificationConfigHandler(notifConfigModel, cfg)
|
||||||
emailDigestHandler := handlers.NewEmailDigestHandler(emailDigestSvc, notifConfigModel)
|
emailDigestHandler := handlers.NewEmailDigestHandler(emailDigestSvc, notifConfigModel)
|
||||||
statusHandler := handlers.NewStatusHandler(checkpointModel)
|
statusHandler := handlers.NewStatusHandler(checkpointModel)
|
||||||
|
stripeHandler := handlers.NewStripeHandler(userModel, cfg)
|
||||||
|
accountHandler := handlers.NewAccountHandler(userModel, cfg)
|
||||||
|
|
||||||
authenticate := middleware.Authenticate(userModel)
|
authenticate := middleware.Authenticate(userModel)
|
||||||
|
requireSub := middleware.RequireSubscription(userModel)
|
||||||
|
|
||||||
|
// authAndSub chains authentication + subscription check for protected routes.
|
||||||
|
authAndSub := func(h http.Handler) http.Handler {
|
||||||
|
return authenticate(requireSub(h))
|
||||||
|
}
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
b := cfg.APIBasePath // e.g. "/v1"
|
b := cfg.APIBasePath // e.g. "/v1"
|
||||||
@@ -71,45 +79,62 @@ func main() {
|
|||||||
mux.HandleFunc("GET "+b+"/health", handlers.HealthCheck)
|
mux.HandleFunc("GET "+b+"/health", handlers.HealthCheck)
|
||||||
mux.HandleFunc("GET "+b+"/status", statusHandler.GetStatus)
|
mux.HandleFunc("GET "+b+"/status", statusHandler.GetStatus)
|
||||||
|
|
||||||
// Authenticated routes — addresses
|
// Stripe webhook (public — called by Stripe, not authenticated)
|
||||||
|
mux.HandleFunc("POST "+b+"/stripe/webhook", stripeHandler.HandleWebhook)
|
||||||
|
|
||||||
|
// Stripe routes (auth required, NO subscription required)
|
||||||
|
mux.Handle("POST "+b+"/stripe/create-checkout-session",
|
||||||
|
authenticate(http.HandlerFunc(stripeHandler.CreateCheckoutSession)))
|
||||||
|
mux.Handle("GET "+b+"/stripe/subscription-status",
|
||||||
|
authenticate(http.HandlerFunc(stripeHandler.GetSubscriptionStatus)))
|
||||||
|
mux.Handle("POST "+b+"/stripe/verify-checkout",
|
||||||
|
authenticate(http.HandlerFunc(stripeHandler.VerifyCheckoutSession)))
|
||||||
|
mux.Handle("POST "+b+"/stripe/create-portal-session",
|
||||||
|
authenticate(http.HandlerFunc(stripeHandler.CreatePortalSession)))
|
||||||
|
|
||||||
|
// Account route (auth required, NO subscription required)
|
||||||
|
mux.Handle("GET "+b+"/user/account",
|
||||||
|
authenticate(http.HandlerFunc(accountHandler.GetAccount)))
|
||||||
|
|
||||||
|
// Authenticated + subscribed routes — addresses
|
||||||
mux.Handle("POST "+b+"/addresses",
|
mux.Handle("POST "+b+"/addresses",
|
||||||
authenticate(http.HandlerFunc(addressHandler.Create)))
|
authAndSub(http.HandlerFunc(addressHandler.Create)))
|
||||||
mux.Handle("GET "+b+"/addresses",
|
mux.Handle("GET "+b+"/addresses",
|
||||||
authenticate(http.HandlerFunc(addressHandler.List)))
|
authAndSub(http.HandlerFunc(addressHandler.List)))
|
||||||
mux.Handle("DELETE "+b+"/addresses/{addressId}",
|
mux.Handle("DELETE "+b+"/addresses/{addressId}",
|
||||||
authenticate(http.HandlerFunc(addressHandler.Remove)))
|
authAndSub(http.HandlerFunc(addressHandler.Remove)))
|
||||||
mux.Handle("PATCH "+b+"/addresses/{addressId}",
|
mux.Handle("PATCH "+b+"/addresses/{addressId}",
|
||||||
authenticate(http.HandlerFunc(addressHandler.UpdateLabel)))
|
authAndSub(http.HandlerFunc(addressHandler.UpdateLabel)))
|
||||||
|
|
||||||
// Authenticated routes for alert rules
|
// Authenticated + subscribed routes — alert rules
|
||||||
mux.Handle("POST "+b+"/addresses/{addressId}/alerts",
|
mux.Handle("POST "+b+"/addresses/{addressId}/alerts",
|
||||||
authenticate(http.HandlerFunc(alertRuleHandler.Create)))
|
authAndSub(http.HandlerFunc(alertRuleHandler.Create)))
|
||||||
mux.Handle("GET "+b+"/addresses/{addressId}/alerts",
|
mux.Handle("GET "+b+"/addresses/{addressId}/alerts",
|
||||||
authenticate(http.HandlerFunc(alertRuleHandler.ListByAddress)))
|
authAndSub(http.HandlerFunc(alertRuleHandler.ListByAddress)))
|
||||||
mux.Handle("PATCH "+b+"/alerts/{alertId}",
|
mux.Handle("PATCH "+b+"/alerts/{alertId}",
|
||||||
authenticate(http.HandlerFunc(alertRuleHandler.UpdateStatus)))
|
authAndSub(http.HandlerFunc(alertRuleHandler.UpdateStatus)))
|
||||||
mux.Handle("DELETE "+b+"/alerts/{alertId}",
|
mux.Handle("DELETE "+b+"/alerts/{alertId}",
|
||||||
authenticate(http.HandlerFunc(alertRuleHandler.Remove)))
|
authAndSub(http.HandlerFunc(alertRuleHandler.Remove)))
|
||||||
|
|
||||||
// Authenticated routes — alert events
|
// Authenticated + subscribed routes — alert events
|
||||||
mux.Handle("GET "+b+"/alert-events",
|
mux.Handle("GET "+b+"/alert-events",
|
||||||
authenticate(http.HandlerFunc(alertEventHandler.List)))
|
authAndSub(http.HandlerFunc(alertEventHandler.List)))
|
||||||
|
|
||||||
// Authenticated routes — notification config
|
// Authenticated + subscribed routes — notification config
|
||||||
mux.Handle("GET "+b+"/notification-config",
|
mux.Handle("GET "+b+"/notification-config",
|
||||||
authenticate(http.HandlerFunc(notifConfigHandler.GetConfig)))
|
authAndSub(http.HandlerFunc(notifConfigHandler.GetConfig)))
|
||||||
mux.Handle("PUT "+b+"/notification-config",
|
mux.Handle("PUT "+b+"/notification-config",
|
||||||
authenticate(http.HandlerFunc(notifConfigHandler.UpdateConfig)))
|
authAndSub(http.HandlerFunc(notifConfigHandler.UpdateConfig)))
|
||||||
mux.Handle("DELETE "+b+"/notification-config",
|
mux.Handle("DELETE "+b+"/notification-config",
|
||||||
authenticate(http.HandlerFunc(notifConfigHandler.DeleteConfig)))
|
authAndSub(http.HandlerFunc(notifConfigHandler.DeleteConfig)))
|
||||||
mux.Handle("POST "+b+"/notification-config/test",
|
mux.Handle("POST "+b+"/notification-config/test",
|
||||||
authenticate(http.HandlerFunc(notifConfigHandler.TestChannels)))
|
authAndSub(http.HandlerFunc(notifConfigHandler.TestChannels)))
|
||||||
|
|
||||||
// Authenticated routes — email digest
|
// Authenticated + subscribed routes — email digest
|
||||||
mux.Handle("POST "+b+"/email/setup",
|
mux.Handle("POST "+b+"/email/setup",
|
||||||
authenticate(http.HandlerFunc(emailDigestHandler.SetupEmail)))
|
authAndSub(http.HandlerFunc(emailDigestHandler.SetupEmail)))
|
||||||
mux.Handle("POST "+b+"/email/digest",
|
mux.Handle("POST "+b+"/email/digest",
|
||||||
authenticate(http.HandlerFunc(emailDigestHandler.SendDigest)))
|
authAndSub(http.HandlerFunc(emailDigestHandler.SendDigest)))
|
||||||
|
|
||||||
handler := corsMiddleware(mux)
|
handler := corsMiddleware(mux)
|
||||||
|
|
||||||
@@ -12,11 +12,11 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/joho/godotenv"
|
"github.com/joho/godotenv"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/config"
|
"github.com/kjannette/koin-ping/backend/internal/config"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/database"
|
"github.com/kjannette/koin-ping/backend/internal/database"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/models"
|
"github.com/kjannette/koin-ping/backend/internal/models"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/protocols/ethereum"
|
"github.com/kjannette/koin-ping/backend/internal/protocols/ethereum"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/services"
|
"github.com/kjannette/koin-ping/backend/internal/services"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -138,6 +138,8 @@ func runCycle(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
evaluator.WaitForNotifications()
|
||||||
|
|
||||||
duration := time.Since(startTime)
|
duration := time.Since(startTime)
|
||||||
log.Printf("[%s] Cycle complete: %d observations, %d alerts fired in %s",
|
log.Printf("[%s] Cycle complete: %d observations, %d alerts fired in %s",
|
||||||
time.Now().UTC().Format(time.RFC3339),
|
time.Now().UTC().Format(time.RFC3339),
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
module github.com/kjannette/koin-ping/backend-go
|
module github.com/kjannette/koin-ping/backend
|
||||||
|
|
||||||
go 1.25.0
|
go 1.25.0
|
||||||
|
|
||||||
@@ -44,6 +44,7 @@ require (
|
|||||||
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
|
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
|
||||||
github.com/resend/resend-go/v3 v3.1.1 // indirect
|
github.com/resend/resend-go/v3 v3.1.1 // indirect
|
||||||
github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect
|
github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect
|
||||||
|
github.com/stripe/stripe-go/v82 v82.5.1 // indirect
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||||
go.opentelemetry.io/contrib/detectors/gcp v1.39.0 // indirect
|
go.opentelemetry.io/contrib/detectors/gcp v1.39.0 // indirect
|
||||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 // indirect
|
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 // indirect
|
||||||
@@ -101,6 +101,8 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
|
|||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
github.com/stripe/stripe-go/v82 v82.5.1 h1:05q6ZDKoe8PLMpQV072obF74HCgP4XJeJYoNuRSX2+8=
|
||||||
|
github.com/stripe/stripe-go/v82 v82.5.1/go.mod h1:majCQX6AfObAvJiHraPi/5udwHi4ojRvJnnxckvHrX8=
|
||||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||||
8
backend/infra/migrations/007_add_stripe_to_users.sql
Normal file
8
backend/infra/migrations/007_add_stripe_to_users.sql
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
-- Migration 007: Add Stripe subscription fields to users table
|
||||||
|
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS stripe_customer_id VARCHAR(255);
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS stripe_subscription_id VARCHAR(255);
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS subscription_status VARCHAR(50) DEFAULT 'none';
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS subscription_created_at TIMESTAMP;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_stripe_customer_id ON users(stripe_customer_id);
|
||||||
@@ -11,6 +11,10 @@ CREATE TABLE users (
|
|||||||
firebase_uid VARCHAR(128) NOT NULL UNIQUE,
|
firebase_uid VARCHAR(128) NOT NULL UNIQUE,
|
||||||
email VARCHAR(255) NOT NULL,
|
email VARCHAR(255) NOT NULL,
|
||||||
display_name VARCHAR(255),
|
display_name VARCHAR(255),
|
||||||
|
stripe_customer_id VARCHAR(255),
|
||||||
|
stripe_subscription_id VARCHAR(255),
|
||||||
|
subscription_status VARCHAR(50) DEFAULT 'none',
|
||||||
|
subscription_created_at TIMESTAMP,
|
||||||
created_at TIMESTAMP DEFAULT NOW(),
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
updated_at TIMESTAMP DEFAULT NOW()
|
updated_at TIMESTAMP DEFAULT NOW()
|
||||||
);
|
);
|
||||||
@@ -73,6 +77,7 @@ CREATE TABLE user_notification_configs (
|
|||||||
|
|
||||||
-- Create indexes for common queries
|
-- Create indexes for common queries
|
||||||
CREATE INDEX idx_users_firebase_uid ON users(firebase_uid);
|
CREATE INDEX idx_users_firebase_uid ON users(firebase_uid);
|
||||||
|
CREATE INDEX idx_users_stripe_customer_id ON users(stripe_customer_id);
|
||||||
CREATE INDEX idx_addresses_user_id ON addresses(user_id);
|
CREATE INDEX idx_addresses_user_id ON addresses(user_id);
|
||||||
CREATE INDEX idx_alert_rules_address_id ON alert_rules(address_id);
|
CREATE INDEX idx_alert_rules_address_id ON alert_rules(address_id);
|
||||||
CREATE INDEX idx_alert_rules_enabled ON alert_rules(enabled);
|
CREATE INDEX idx_alert_rules_enabled ON alert_rules(enabled);
|
||||||
@@ -32,6 +32,11 @@ type Config struct {
|
|||||||
ResendAPIKey string
|
ResendAPIKey string
|
||||||
EmailFrom string
|
EmailFrom string
|
||||||
DigestIntervalHours int
|
DigestIntervalHours int
|
||||||
|
StripeSecretKey string
|
||||||
|
StripeWebhookSecret string
|
||||||
|
StripePriceID string
|
||||||
|
StripePublishableKey string
|
||||||
|
FrontendURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load reads configuration from environment variables and returns a Config.
|
// Load reads configuration from environment variables and returns a Config.
|
||||||
@@ -51,7 +56,12 @@ func Load() (*Config, error) {
|
|||||||
NodeEnv: getEnv("NODE_ENV", "development"),
|
NodeEnv: getEnv("NODE_ENV", "development"),
|
||||||
ResendAPIKey: os.Getenv("RESEND_API_KEY"),
|
ResendAPIKey: os.Getenv("RESEND_API_KEY"),
|
||||||
EmailFrom: getEnv("EMAIL_FROM", "Koin Ping <alerts@koinping.com>"),
|
EmailFrom: getEnv("EMAIL_FROM", "Koin Ping <alerts@koinping.com>"),
|
||||||
DigestIntervalHours: getEnvInt("DIGEST_INTERVAL_HOURS", defaultDigestIntervalHours),
|
DigestIntervalHours: getEnvInt("DIGEST_INTERVAL_HOURS", defaultDigestIntervalHours),
|
||||||
|
StripeSecretKey: os.Getenv("STRIPE_SECRET_KEY"),
|
||||||
|
StripeWebhookSecret: os.Getenv("STRIPE_WEBHOOK_SECRET"),
|
||||||
|
StripePriceID: os.Getenv("STRIPE_PRICE_ID"),
|
||||||
|
StripePublishableKey: os.Getenv("STRIPE_PUBLISHABLE_KEY"),
|
||||||
|
FrontendURL: getEnv("FRONTEND_URL", "http://localhost:3000"),
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.PollIntervalMS < minPollIntervalMS {
|
if cfg.PollIntervalMS < minPollIntervalMS {
|
||||||
@@ -3,12 +3,16 @@ package domain
|
|||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
FirebaseUID string `json:"-"`
|
FirebaseUID string `json:"-"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
DisplayName *string `json:"display_name"` //nolint:tagliatelle
|
DisplayName *string `json:"display_name"` //nolint:tagliatelle
|
||||||
CreatedAt time.Time `json:"created_at"` //nolint:tagliatelle
|
StripeCustomerID *string `json:"-"`
|
||||||
UpdatedAt time.Time `json:"updated_at"` //nolint:tagliatelle
|
StripeSubscriptionID *string `json:"-"`
|
||||||
|
SubscriptionStatus string `json:"subscription_status"` //nolint:tagliatelle
|
||||||
|
SubscriptionCreatedAt *time.Time `json:"subscription_created_at,omitempty"` //nolint:tagliatelle
|
||||||
|
CreatedAt time.Time `json:"created_at"` //nolint:tagliatelle
|
||||||
|
UpdatedAt time.Time `json:"updated_at"` //nolint:tagliatelle
|
||||||
}
|
}
|
||||||
|
|
||||||
type Address struct {
|
type Address struct {
|
||||||
@@ -43,7 +47,7 @@ var ThresholdRequiredTypes = []AlertType{ //nolint:gochecknoglobals
|
|||||||
AlertBalanceBelow,
|
AlertBalanceBelow,
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsValidAlertType returns true if the given string matches a known AlertType.
|
// returns true if the given string matches a known AlertType.
|
||||||
func IsValidAlertType(t string) bool {
|
func IsValidAlertType(t string) bool {
|
||||||
for _, v := range ValidAlertTypes {
|
for _, v := range ValidAlertTypes {
|
||||||
if string(v) == t {
|
if string(v) == t {
|
||||||
@@ -54,7 +58,6 @@ func IsValidAlertType(t string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsThresholdRequired returns true if the given AlertType requires a threshold.
|
|
||||||
func IsThresholdRequired(t AlertType) bool {
|
func IsThresholdRequired(t AlertType) bool {
|
||||||
for _, v := range ThresholdRequiredTypes {
|
for _, v := range ThresholdRequiredTypes {
|
||||||
if v == t {
|
if v == t {
|
||||||
@@ -89,7 +92,6 @@ type AddressCheckpoint struct {
|
|||||||
LastCheckedAt time.Time `json:"last_checked_at"` //nolint:tagliatelle
|
LastCheckedAt time.Time `json:"last_checked_at"` //nolint:tagliatelle
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckpointDetail combines checkpoint and address info for reporting.
|
|
||||||
type CheckpointDetail struct {
|
type CheckpointDetail struct {
|
||||||
AddressID int `json:"address_id"` //nolint:tagliatelle
|
AddressID int `json:"address_id"` //nolint:tagliatelle
|
||||||
Address string `json:"address"`
|
Address string `json:"address"`
|
||||||
@@ -98,7 +100,7 @@ type CheckpointDetail struct {
|
|||||||
LastCheckedAt time.Time `json:"last_checked_at"` //nolint:tagliatelle
|
LastCheckedAt time.Time `json:"last_checked_at"` //nolint:tagliatelle
|
||||||
}
|
}
|
||||||
|
|
||||||
// NotificationConfig holds a user's notification preferences.
|
// holds a user's notification preferences.
|
||||||
type NotificationConfig struct {
|
type NotificationConfig struct {
|
||||||
UserID string `json:"user_id"` //nolint:tagliatelle
|
UserID string `json:"user_id"` //nolint:tagliatelle
|
||||||
DiscordWebhookURL *string `json:"discord_webhook_url"` //nolint:tagliatelle
|
DiscordWebhookURL *string `json:"discord_webhook_url"` //nolint:tagliatelle
|
||||||
@@ -127,14 +129,12 @@ type NormalizedTx struct {
|
|||||||
TokenValue *string `json:"token_value,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 {
|
func (tx NormalizedTx) IsTokenTransfer() bool {
|
||||||
return tx.TokenContract != nil
|
return tx.TokenContract != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type Direction string
|
type Direction string
|
||||||
|
|
||||||
// String implements fmt.Stringer.
|
|
||||||
func (d Direction) String() string { return string(d) }
|
func (d Direction) String() string { return string(d) }
|
||||||
|
|
||||||
const (
|
const (
|
||||||
66
backend/internal/handlers/account.go
Normal file
66
backend/internal/handlers/account.go
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kjannette/koin-ping/backend/internal/config"
|
||||||
|
"github.com/kjannette/koin-ping/backend/internal/middleware"
|
||||||
|
"github.com/kjannette/koin-ping/backend/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AccountHandler struct {
|
||||||
|
users *models.UserModel
|
||||||
|
cfg *config.Config
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAccountHandler(users *models.UserModel, cfg *config.Config) *AccountHandler {
|
||||||
|
return &AccountHandler{users: users, cfg: cfg}
|
||||||
|
}
|
||||||
|
|
||||||
|
type accountResponse struct {
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
UserName string `json:"user_name"`
|
||||||
|
SubscriptionStatus string `json:"subscription_status"`
|
||||||
|
SubscriptionPlan string `json:"subscription_plan"`
|
||||||
|
MemberSince *string `json:"member_since,omitempty"`
|
||||||
|
NextBillingDate *string `json:"next_billing_date,omitempty"`
|
||||||
|
CancelAtPeriodEnd bool `json:"cancel_at_period_end"`
|
||||||
|
PeriodEndDate *string `json:"period_end_date,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AccountHandler) GetAccount(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := middleware.GetUserID(r.Context())
|
||||||
|
email := middleware.GetUserEmail(r.Context())
|
||||||
|
|
||||||
|
user, err := h.users.GetByID(r.Context(), userID)
|
||||||
|
if err != nil || user == nil {
|
||||||
|
log.Printf("Account: failed to get user %s: %v", userID, err)
|
||||||
|
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to load user")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := accountResponse{
|
||||||
|
UserID: user.ID,
|
||||||
|
Email: email,
|
||||||
|
UserName: email,
|
||||||
|
SubscriptionStatus: user.SubscriptionStatus,
|
||||||
|
SubscriptionPlan: "monthly/$1.99",
|
||||||
|
}
|
||||||
|
|
||||||
|
if user.SubscriptionCreatedAt != nil {
|
||||||
|
t := user.SubscriptionCreatedAt.Format(time.DateOnly)
|
||||||
|
resp.MemberSince = &t
|
||||||
|
}
|
||||||
|
|
||||||
|
// MOCKED: NextBillingDate, CancelAtPeriodEnd, PeriodEndDate.
|
||||||
|
// TODO: stripe-go v82 removed Subscription.CurrentPeriodEnd / CancelAtPeriodEnd.
|
||||||
|
// Research SubscriptionItem.CurrentPeriodEnd or use Stripe REST API directly.
|
||||||
|
resp.NextBillingDate = nil
|
||||||
|
resp.CancelAtPeriodEnd = false
|
||||||
|
resp.PeriodEndDate = nil
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, resp)
|
||||||
|
}
|
||||||
@@ -8,9 +8,9 @@ import (
|
|||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/domain"
|
"github.com/kjannette/koin-ping/backend/internal/domain"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/middleware"
|
"github.com/kjannette/koin-ping/backend/internal/middleware"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/models"
|
"github.com/kjannette/koin-ping/backend/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
var ethAddressRe = regexp.MustCompile(`^0x[a-fA-F0-9]{40}$`)
|
var ethAddressRe = regexp.MustCompile(`^0x[a-fA-F0-9]{40}$`)
|
||||||
@@ -5,9 +5,9 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/domain"
|
"github.com/kjannette/koin-ping/backend/internal/domain"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/middleware"
|
"github.com/kjannette/koin-ping/backend/internal/middleware"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/models"
|
"github.com/kjannette/koin-ping/backend/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AlertEventHandler handles HTTP requests for alert event history.
|
// AlertEventHandler handles HTTP requests for alert event history.
|
||||||
@@ -9,9 +9,9 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/domain"
|
"github.com/kjannette/koin-ping/backend/internal/domain"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/middleware"
|
"github.com/kjannette/koin-ping/backend/internal/middleware"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/models"
|
"github.com/kjannette/koin-ping/backend/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
var errThresholdFormat = errors.New("unsupported threshold format")
|
var errThresholdFormat = errors.New("unsupported threshold format")
|
||||||
@@ -4,9 +4,9 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/middleware"
|
"github.com/kjannette/koin-ping/backend/internal/middleware"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/models"
|
"github.com/kjannette/koin-ping/backend/internal/models"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/services"
|
"github.com/kjannette/koin-ping/backend/internal/services"
|
||||||
)
|
)
|
||||||
|
|
||||||
type EmailDigestHandler struct {
|
type EmailDigestHandler struct {
|
||||||
@@ -7,11 +7,11 @@ import (
|
|||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/config"
|
"github.com/kjannette/koin-ping/backend/internal/config"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/domain"
|
"github.com/kjannette/koin-ping/backend/internal/domain"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/middleware"
|
"github.com/kjannette/koin-ping/backend/internal/middleware"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/models"
|
"github.com/kjannette/koin-ping/backend/internal/models"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/notifications"
|
"github.com/kjannette/koin-ping/backend/internal/notifications"
|
||||||
)
|
)
|
||||||
|
|
||||||
var emailRe = regexp.MustCompile(`^[^\s@]+@[^\s@]+\.[^\s@]+$`)
|
var emailRe = regexp.MustCompile(`^[^\s@]+@[^\s@]+\.[^\s@]+$`)
|
||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/models"
|
"github.com/kjannette/koin-ping/backend/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
// StatusHandler handles the system status endpoint.
|
// StatusHandler handles the system status endpoint.
|
||||||
287
backend/internal/handlers/stripe.go
Normal file
287
backend/internal/handlers/stripe.go
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/stripe/stripe-go/v82"
|
||||||
|
portalsession "github.com/stripe/stripe-go/v82/billingportal/session"
|
||||||
|
checkoutsession "github.com/stripe/stripe-go/v82/checkout/session"
|
||||||
|
"github.com/stripe/stripe-go/v82/webhook"
|
||||||
|
|
||||||
|
"github.com/kjannette/koin-ping/backend/internal/config"
|
||||||
|
"github.com/kjannette/koin-ping/backend/internal/middleware"
|
||||||
|
"github.com/kjannette/koin-ping/backend/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
const webhookMaxBodyBytes = 65536
|
||||||
|
|
||||||
|
type StripeHandler struct {
|
||||||
|
users *models.UserModel
|
||||||
|
cfg *config.Config
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewStripeHandler(users *models.UserModel, cfg *config.Config) *StripeHandler {
|
||||||
|
stripe.Key = cfg.StripeSecretKey
|
||||||
|
return &StripeHandler{users: users, cfg: cfg}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateCheckoutSession creates a Stripe Checkout session for the monthly subscription.
|
||||||
|
func (h *StripeHandler) CreateCheckoutSession(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := middleware.GetUserID(r.Context())
|
||||||
|
|
||||||
|
user, err := h.users.GetByID(r.Context(), userID)
|
||||||
|
if err != nil || user == nil {
|
||||||
|
log.Printf("Failed to get user %s: %v", userID, err)
|
||||||
|
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to load user")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
params := &stripe.CheckoutSessionParams{
|
||||||
|
Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)),
|
||||||
|
LineItems: []*stripe.CheckoutSessionLineItemParams{
|
||||||
|
{
|
||||||
|
Price: stripe.String(h.cfg.StripePriceID),
|
||||||
|
Quantity: stripe.Int64(1),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
SuccessURL: stripe.String(h.cfg.FrontendURL + "/subscribe?payment=success&session_id={CHECKOUT_SESSION_ID}"),
|
||||||
|
CancelURL: stripe.String(h.cfg.FrontendURL + "/subscribe?payment=cancelled"),
|
||||||
|
ClientReferenceID: stripe.String(userID),
|
||||||
|
CustomerEmail: stripe.String(user.Email),
|
||||||
|
}
|
||||||
|
|
||||||
|
if user.StripeCustomerID != nil && *user.StripeCustomerID != "" {
|
||||||
|
params.Customer = user.StripeCustomerID
|
||||||
|
params.CustomerEmail = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
s, err := checkoutsession.New(params)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to create Stripe checkout session: %v", err)
|
||||||
|
writeError(w, http.StatusInternalServerError, "STRIPE_ERROR", "Failed to create checkout session")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"url": s.URL})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSubscriptionStatus returns the current user's subscription state.
|
||||||
|
func (h *StripeHandler) GetSubscriptionStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := middleware.GetUserID(r.Context())
|
||||||
|
|
||||||
|
user, err := h.users.GetByID(r.Context(), userID)
|
||||||
|
if err != nil || user == nil {
|
||||||
|
log.Printf("Failed to get user %s: %v", userID, err)
|
||||||
|
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to load user")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"subscription_status": user.SubscriptionStatus,
|
||||||
|
"subscription_created_at": user.SubscriptionCreatedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyCheckoutSession retrieves a completed checkout session from Stripe,
|
||||||
|
// confirms payment, and activates the user's subscription in the database.
|
||||||
|
// This is the primary activation path; webhooks serve as a backup.
|
||||||
|
func (h *StripeHandler) VerifyCheckoutSession(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := middleware.GetUserID(r.Context())
|
||||||
|
|
||||||
|
var body struct {
|
||||||
|
SessionID string `json:"session_id"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.SessionID == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "Missing session_id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s, err := checkoutsession.Get(body.SessionID, nil)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to retrieve checkout session %s: %v", body.SessionID, err)
|
||||||
|
writeError(w, http.StatusBadRequest, "STRIPE_ERROR", "Invalid checkout session")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.ClientReferenceID != userID {
|
||||||
|
writeError(w, http.StatusForbidden, "FORBIDDEN", "Session does not belong to this user")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.PaymentStatus != stripe.CheckoutSessionPaymentStatusPaid {
|
||||||
|
writeError(w, http.StatusBadRequest, "PAYMENT_INCOMPLETE", "Payment has not been completed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
customerID := ""
|
||||||
|
if s.Customer != nil {
|
||||||
|
customerID = s.Customer.ID
|
||||||
|
}
|
||||||
|
subscriptionID := ""
|
||||||
|
if s.Subscription != nil {
|
||||||
|
subscriptionID = s.Subscription.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
if customerID != "" {
|
||||||
|
if err := h.users.UpdateStripeCustomer(r.Context(), userID, customerID); err != nil {
|
||||||
|
log.Printf("VerifyCheckout: failed to save customer ID: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if subscriptionID != "" && customerID != "" {
|
||||||
|
if err := h.users.ActivateSubscription(r.Context(), customerID, subscriptionID, "active"); err != nil {
|
||||||
|
log.Printf("VerifyCheckout: failed to activate subscription: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Checkout verified for user %s, customer %s, subscription %s", userID, customerID, subscriptionID)
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"subscription_status": "active"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreatePortalSession creates a Stripe Billing Portal session so the user can
|
||||||
|
// manage their subscription (cancel, update payment method, view invoices).
|
||||||
|
func (h *StripeHandler) CreatePortalSession(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := middleware.GetUserID(r.Context())
|
||||||
|
|
||||||
|
user, err := h.users.GetByID(r.Context(), userID)
|
||||||
|
if err != nil || user == nil {
|
||||||
|
log.Printf("Portal: failed to get user %s: %v", userID, err)
|
||||||
|
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to load user")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if user.StripeCustomerID == nil || *user.StripeCustomerID == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "NO_CUSTOMER", "No Stripe customer on file")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
params := &stripe.BillingPortalSessionParams{
|
||||||
|
Customer: user.StripeCustomerID,
|
||||||
|
ReturnURL: stripe.String(h.cfg.FrontendURL + "/account"),
|
||||||
|
}
|
||||||
|
|
||||||
|
s, err := portalsession.New(params)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Portal: failed to create portal session: %v", err)
|
||||||
|
writeError(w, http.StatusInternalServerError, "STRIPE_ERROR", "Failed to create portal session")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"url": s.URL})
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleWebhook processes incoming Stripe webhook events.
|
||||||
|
// This endpoint must NOT require authentication (Stripe calls it directly).
|
||||||
|
func (h *StripeHandler) HandleWebhook(w http.ResponseWriter, r *http.Request) {
|
||||||
|
payload, err := io.ReadAll(io.LimitReader(r.Body, webhookMaxBodyBytes))
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error reading webhook body: %v", err)
|
||||||
|
w.WriteHeader(http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sig := r.Header.Get("Stripe-Signature")
|
||||||
|
event, err := webhook.ConstructEvent(payload, sig, h.cfg.StripeWebhookSecret)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Webhook signature verification failed: %v", err)
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch event.Type {
|
||||||
|
case "checkout.session.completed":
|
||||||
|
h.handleCheckoutCompleted(r, event)
|
||||||
|
case "customer.subscription.updated":
|
||||||
|
h.handleSubscriptionUpdated(r, event)
|
||||||
|
case "customer.subscription.deleted":
|
||||||
|
h.handleSubscriptionDeleted(r, event)
|
||||||
|
default:
|
||||||
|
log.Printf("Unhandled Stripe event type: %s", event.Type)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *StripeHandler) handleCheckoutCompleted(r *http.Request, event stripe.Event) {
|
||||||
|
var session stripe.CheckoutSession
|
||||||
|
if err := json.Unmarshal(event.Data.Raw, &session); err != nil {
|
||||||
|
log.Printf("Error parsing checkout session: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID := session.ClientReferenceID
|
||||||
|
if userID == "" {
|
||||||
|
log.Println("Checkout session missing client_reference_id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
customerID := ""
|
||||||
|
if session.Customer != nil {
|
||||||
|
customerID = session.Customer.ID
|
||||||
|
}
|
||||||
|
subscriptionID := ""
|
||||||
|
if session.Subscription != nil {
|
||||||
|
subscriptionID = session.Subscription.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
if customerID != "" {
|
||||||
|
if err := h.users.UpdateStripeCustomer(r.Context(), userID, customerID); err != nil {
|
||||||
|
log.Printf("Failed to save Stripe customer ID: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if subscriptionID != "" && customerID != "" {
|
||||||
|
if err := h.users.ActivateSubscription(r.Context(), customerID, subscriptionID, "active"); err != nil {
|
||||||
|
log.Printf("Failed to activate subscription: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Checkout completed for user %s, customer %s, subscription %s", userID, customerID, subscriptionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *StripeHandler) handleSubscriptionUpdated(r *http.Request, event stripe.Event) {
|
||||||
|
var sub stripe.Subscription
|
||||||
|
if err := json.Unmarshal(event.Data.Raw, &sub); err != nil {
|
||||||
|
log.Printf("Error parsing subscription update: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
customerID := ""
|
||||||
|
if sub.Customer != nil {
|
||||||
|
customerID = sub.Customer.ID
|
||||||
|
}
|
||||||
|
if customerID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
status := string(sub.Status)
|
||||||
|
if err := h.users.ActivateSubscription(r.Context(), customerID, sub.ID, status); err != nil {
|
||||||
|
log.Printf("Failed to update subscription status: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Subscription %s updated to %s for customer %s", sub.ID, status, customerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *StripeHandler) handleSubscriptionDeleted(r *http.Request, event stripe.Event) {
|
||||||
|
var sub stripe.Subscription
|
||||||
|
if err := json.Unmarshal(event.Data.Raw, &sub); err != nil {
|
||||||
|
log.Printf("Error parsing subscription deletion: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
customerID := ""
|
||||||
|
if sub.Customer != nil {
|
||||||
|
customerID = sub.Customer.ID
|
||||||
|
}
|
||||||
|
if customerID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.users.UpdateSubscriptionStatus(r.Context(), customerID, "canceled"); err != nil {
|
||||||
|
log.Printf("Failed to mark subscription canceled: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Subscription canceled for customer %s", customerID)
|
||||||
|
}
|
||||||
@@ -8,8 +8,8 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
fbauth "github.com/kjannette/koin-ping/backend-go/internal/firebase"
|
fbauth "github.com/kjannette/koin-ping/backend/internal/firebase"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/models"
|
"github.com/kjannette/koin-ping/backend/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
type contextKey string
|
type contextKey string
|
||||||
@@ -100,6 +100,43 @@ func Authenticate(userModel *models.UserModel) func(http.Handler) http.Handler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RequireSubscription blocks requests from users without an active subscription.
|
||||||
|
// Must be applied after Authenticate.
|
||||||
|
func RequireSubscription(userModel *models.UserModel) func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := GetUserID(r.Context())
|
||||||
|
if userID == "" {
|
||||||
|
writeJSON(w, http.StatusUnauthorized, errorResponse{
|
||||||
|
Error: "UNAUTHORIZED",
|
||||||
|
Message: "Authentication required",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := userModel.GetByID(r.Context(), userID)
|
||||||
|
if err != nil || user == nil {
|
||||||
|
log.Printf("RequireSubscription: failed to load user %s: %v", userID, err)
|
||||||
|
writeJSON(w, http.StatusInternalServerError, errorResponse{
|
||||||
|
Error: "INTERNAL_ERROR",
|
||||||
|
Message: "Failed to verify subscription",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if user.SubscriptionStatus != "active" && user.SubscriptionStatus != "trialing" {
|
||||||
|
writeJSON(w, http.StatusForbidden, errorResponse{
|
||||||
|
Error: "SUBSCRIPTION_REQUIRED",
|
||||||
|
Message: "An active subscription is required to use this feature",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func GetUserID(ctx context.Context) string {
|
func GetUserID(ctx context.Context) string {
|
||||||
if v, ok := ctx.Value(UserIDKey).(string); ok {
|
if v, ok := ctx.Value(UserIDKey).(string); ok {
|
||||||
return v
|
return v
|
||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/domain"
|
"github.com/kjannette/koin-ping/backend/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
type AddressModel struct {
|
type AddressModel struct {
|
||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/domain"
|
"github.com/kjannette/koin-ping/backend/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
type AlertEventModel struct {
|
type AlertEventModel struct {
|
||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/domain"
|
"github.com/kjannette/koin-ping/backend/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
type AlertRuleModel struct {
|
type AlertRuleModel struct {
|
||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/domain"
|
"github.com/kjannette/koin-ping/backend/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
type CheckpointModel struct {
|
type CheckpointModel struct {
|
||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/domain"
|
"github.com/kjannette/koin-ping/backend/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
type NotificationConfigModel struct {
|
type NotificationConfigModel struct {
|
||||||
89
backend/internal/models/user.go
Normal file
89
backend/internal/models/user.go
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
"github.com/kjannette/koin-ping/backend/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
type UserModel struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewUserModel(pool *pgxpool.Pool) *UserModel {
|
||||||
|
return &UserModel{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
const userColumns = `id, firebase_uid, email, display_name,
|
||||||
|
stripe_customer_id, stripe_subscription_id, subscription_status,
|
||||||
|
subscription_created_at, created_at, updated_at`
|
||||||
|
|
||||||
|
func scanUser(row pgx.Row) (*domain.User, error) {
|
||||||
|
var u domain.User
|
||||||
|
err := row.Scan(
|
||||||
|
&u.ID, &u.FirebaseUID, &u.Email, &u.DisplayName,
|
||||||
|
&u.StripeCustomerID, &u.StripeSubscriptionID, &u.SubscriptionStatus,
|
||||||
|
&u.SubscriptionCreatedAt, &u.CreatedAt, &u.UpdatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, nil //nolint:nilnil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindOrCreateByFirebaseUID returns the local user for a Firebase UID,
|
||||||
|
// creating one if it doesn't exist yet. On conflict (returning user) the
|
||||||
|
// updated_at timestamp is refreshed.
|
||||||
|
func (m *UserModel) FindOrCreateByFirebaseUID(ctx context.Context, firebaseUID, email string) (*domain.User, error) {
|
||||||
|
row := m.pool.QueryRow(ctx,
|
||||||
|
`INSERT INTO users (firebase_uid, email)
|
||||||
|
VALUES ($1, $2)
|
||||||
|
ON CONFLICT (firebase_uid) DO UPDATE SET updated_at = NOW()
|
||||||
|
RETURNING `+userColumns,
|
||||||
|
firebaseUID, email,
|
||||||
|
)
|
||||||
|
return scanUser(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *UserModel) GetByID(ctx context.Context, id string) (*domain.User, error) {
|
||||||
|
row := m.pool.QueryRow(ctx,
|
||||||
|
`SELECT `+userColumns+` FROM users WHERE id = $1`, id,
|
||||||
|
)
|
||||||
|
return scanUser(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *UserModel) UpdateStripeCustomer(ctx context.Context, userID, stripeCustomerID string) error {
|
||||||
|
_, err := m.pool.Exec(ctx,
|
||||||
|
`UPDATE users SET stripe_customer_id = $2, updated_at = NOW() WHERE id = $1`,
|
||||||
|
userID, stripeCustomerID,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *UserModel) ActivateSubscription(ctx context.Context, stripeCustomerID, subscriptionID, status string) error {
|
||||||
|
_, err := m.pool.Exec(ctx,
|
||||||
|
`UPDATE users
|
||||||
|
SET stripe_subscription_id = $2,
|
||||||
|
subscription_status = $3,
|
||||||
|
subscription_created_at = COALESCE(subscription_created_at, NOW()),
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE stripe_customer_id = $1`,
|
||||||
|
stripeCustomerID, subscriptionID, status,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *UserModel) UpdateSubscriptionStatus(ctx context.Context, stripeCustomerID, status string) error {
|
||||||
|
_, err := m.pool.Exec(ctx,
|
||||||
|
`UPDATE users SET subscription_status = $2, updated_at = NOW()
|
||||||
|
WHERE stripe_customer_id = $1`,
|
||||||
|
stripeCustomerID, status,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -21,17 +21,14 @@ const (
|
|||||||
colorBlue = 0x0099ff
|
colorBlue = 0x0099ff
|
||||||
)
|
)
|
||||||
|
|
||||||
// discordHTTPClient is a shared HTTP client with a timeout for Discord requests.
|
|
||||||
var discordHTTPClient = &http.Client{ //nolint:gochecknoglobals
|
var discordHTTPClient = &http.Client{ //nolint:gochecknoglobals
|
||||||
Timeout: discordHTTPTimeoutSeconds * time.Second,
|
Timeout: discordHTTPTimeoutSeconds * time.Second,
|
||||||
}
|
}
|
||||||
|
|
||||||
// DiscordNotifier sends alert notifications via a Discord webhook.
|
// sends alert notifications via a Discord webhook.
|
||||||
type DiscordNotifier struct {
|
type DiscordNotifier struct {
|
||||||
WebhookURL string
|
WebhookURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send implements Notifier for Discord.
|
|
||||||
func (d *DiscordNotifier) Send(_ context.Context, message string, meta AlertMetadata) error {
|
func (d *DiscordNotifier) Send(_ context.Context, message string, meta AlertMetadata) error {
|
||||||
_, err := SendDiscordNotification(d.WebhookURL, message, meta)
|
_, err := SendDiscordNotification(d.WebhookURL, message, meta)
|
||||||
return err
|
return err
|
||||||
@@ -104,8 +101,12 @@ func SendDiscordNotification(webhookURL, message string, meta AlertMetadata) (bo
|
|||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
err := fmt.Errorf("discord webhook failed: HTTP %d", resp.StatusCode)
|
||||||
log.Printf("Discord webhook failed: HTTP %d", resp.StatusCode)
|
log.Printf("Discord webhook failed: HTTP %d", resp.StatusCode)
|
||||||
return false, fmt.Errorf("discord webhook failed: HTTP %d", resp.StatusCode)
|
if isPermanentStatusCode(resp.StatusCode) {
|
||||||
|
return false, &PermanentError{Err: err}
|
||||||
|
}
|
||||||
|
return false, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return true, nil
|
return true, nil
|
||||||
@@ -10,14 +10,12 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// EmailNotifier sends alert notifications via email (Resend).
|
|
||||||
type EmailNotifier struct {
|
type EmailNotifier struct {
|
||||||
APIKey string
|
APIKey string
|
||||||
From string
|
From string
|
||||||
To string
|
To string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send implements Notifier for email.
|
|
||||||
func (e *EmailNotifier) Send(_ context.Context, message string, meta AlertMetadata) error {
|
func (e *EmailNotifier) Send(_ context.Context, message string, meta AlertMetadata) error {
|
||||||
_, err := SendEmailNotification(e.APIKey, e.From, e.To, message, meta)
|
_, err := SendEmailNotification(e.APIKey, e.From, e.To, message, meta)
|
||||||
return err
|
return err
|
||||||
@@ -99,8 +97,12 @@ func SendEmailNotification(apiKey, fromAddress, toAddress, message string, meta
|
|||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
err := fmt.Errorf("resend API failed: HTTP %d", resp.StatusCode)
|
||||||
log.Printf("Resend API failed: HTTP %d", resp.StatusCode)
|
log.Printf("Resend API failed: HTTP %d", resp.StatusCode)
|
||||||
return false, fmt.Errorf("resend API failed: HTTP %d", resp.StatusCode)
|
if isPermanentStatusCode(resp.StatusCode) {
|
||||||
|
return false, &PermanentError{Err: err}
|
||||||
|
}
|
||||||
|
return false, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return true, nil
|
return true, nil
|
||||||
38
backend/internal/notifications/notifier.go
Normal file
38
backend/internal/notifications/notifier.go
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
package notifications
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AlertMetadata holds context about the alert being sent.
|
||||||
|
type AlertMetadata struct {
|
||||||
|
TxHash string
|
||||||
|
AddressLabel string
|
||||||
|
AlertType string
|
||||||
|
Address string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Notifier interface {
|
||||||
|
Send(ctx context.Context, message string, meta AlertMetadata) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// PermanentError wraps errors that should not be retried (e.g. 401, 403, 404).
|
||||||
|
type PermanentError struct{ Err error }
|
||||||
|
|
||||||
|
func (e *PermanentError) Error() string { return e.Err.Error() }
|
||||||
|
func (e *PermanentError) Unwrap() error { return e.Err }
|
||||||
|
|
||||||
|
func IsPermanent(err error) bool {
|
||||||
|
var p *PermanentError
|
||||||
|
return errors.As(err, &p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isPermanentStatusCode(code int) bool {
|
||||||
|
return code == http.StatusUnauthorized ||
|
||||||
|
code == http.StatusForbidden ||
|
||||||
|
code == http.StatusNotFound ||
|
||||||
|
code == http.StatusMethodNotAllowed ||
|
||||||
|
code == http.StatusGone
|
||||||
|
}
|
||||||
@@ -87,8 +87,12 @@ func SendSlackNotification(webhookURL, message string, meta AlertMetadata) (bool
|
|||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
err := fmt.Errorf("slack webhook failed: HTTP %d", resp.StatusCode)
|
||||||
log.Printf("Slack webhook failed: HTTP %d", resp.StatusCode)
|
log.Printf("Slack webhook failed: HTTP %d", resp.StatusCode)
|
||||||
return false, fmt.Errorf("slack webhook failed: HTTP %d", resp.StatusCode)
|
if isPermanentStatusCode(resp.StatusCode) {
|
||||||
|
return false, &PermanentError{Err: err}
|
||||||
|
}
|
||||||
|
return false, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return true, nil
|
return true, nil
|
||||||
@@ -63,8 +63,12 @@ func SendTelegramNotification(botToken, chatID, message string, meta AlertMetada
|
|||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
err := fmt.Errorf("telegram API failed: HTTP %d", resp.StatusCode)
|
||||||
log.Printf("Telegram API failed: HTTP %d", resp.StatusCode)
|
log.Printf("Telegram API failed: HTTP %d", resp.StatusCode)
|
||||||
return false, fmt.Errorf("telegram API failed: HTTP %d", resp.StatusCode)
|
if isPermanentStatusCode(resp.StatusCode) {
|
||||||
|
return false, &PermanentError{Err: err}
|
||||||
|
}
|
||||||
|
return false, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return true, nil
|
return true, nil
|
||||||
@@ -12,18 +12,20 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/domain"
|
"github.com/kjannette/koin-ping/backend/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
rpcTimeoutMS = 30000
|
rpcTimeoutMS = 30000
|
||||||
rpcMaxRetries = 3
|
rpcMaxRetries = 3
|
||||||
rpcRetryBaseMS = 1000
|
rpcRetryBaseMS = 2000
|
||||||
|
rpcMinIntervalMS = 1000
|
||||||
)
|
)
|
||||||
|
|
||||||
type JsonRpcEthereum struct {
|
type JsonRpcEthereum struct {
|
||||||
rpcURL string
|
rpcURL string
|
||||||
client *http.Client
|
client *http.Client
|
||||||
|
lastCallAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewJsonRpcEthereum(rpcURL string) (*JsonRpcEthereum, error) {
|
func NewJsonRpcEthereum(rpcURL string) (*JsonRpcEthereum, error) {
|
||||||
@@ -58,6 +60,8 @@ type rpcError struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (j *JsonRpcEthereum) callRPC(ctx context.Context, method string, params ...interface{}) (json.RawMessage, error) {
|
func (j *JsonRpcEthereum) callRPC(ctx context.Context, method string, params ...interface{}) (json.RawMessage, error) {
|
||||||
|
j.throttle(ctx)
|
||||||
|
|
||||||
if params == nil {
|
if params == nil {
|
||||||
params = []interface{}{}
|
params = []interface{}{}
|
||||||
}
|
}
|
||||||
@@ -72,7 +76,25 @@ func (j *JsonRpcEthereum) callRPC(ctx context.Context, method string, params ...
|
|||||||
return nil, fmt.Errorf("marshal RPC request: %w", err)
|
return nil, fmt.Errorf("marshal RPC request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return j.callWithRetry(ctx, method, body)
|
result, callErr := j.callWithRetry(ctx, method, body)
|
||||||
|
j.lastCallAt = time.Now()
|
||||||
|
|
||||||
|
return result, callErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *JsonRpcEthereum) throttle(ctx context.Context) {
|
||||||
|
if j.lastCallAt.IsZero() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
minInterval := time.Duration(rpcMinIntervalMS) * time.Millisecond
|
||||||
|
elapsed := time.Since(j.lastCallAt)
|
||||||
|
if elapsed >= minInterval {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
case <-time.After(minInterval - elapsed):
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// callWithRetry executes a JSON-RPC POST with exponential backoff on transient errors.
|
// callWithRetry executes a JSON-RPC POST with exponential backoff on transient errors.
|
||||||
@@ -3,7 +3,7 @@ package ethereum
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/domain"
|
"github.com/kjannette/koin-ping/backend/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
// EthereumObserver defines the interface for blockchain interaction.
|
// EthereumObserver defines the interface for blockchain interaction.
|
||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/models"
|
"github.com/kjannette/koin-ping/backend/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -20,7 +20,7 @@ const (
|
|||||||
|
|
||||||
var digestHTTPClient = &http.Client{Timeout: emailHTTPTimeout} //nolint:gochecknoglobals
|
var digestHTTPClient = &http.Client{Timeout: emailHTTPTimeout} //nolint:gochecknoglobals
|
||||||
|
|
||||||
// EmailDigestService handles email setup and digest sending via Resend.
|
// handles email setup and digest sending via Resend.
|
||||||
type EmailDigestService struct {
|
type EmailDigestService struct {
|
||||||
apiKey string
|
apiKey string
|
||||||
fromAddress string
|
fromAddress string
|
||||||
@@ -66,7 +66,6 @@ func (s *EmailDigestService) SetupEmail(toAddress string) error {
|
|||||||
return s.send(toAddress, "Koin Ping — Email Alerts Configured", html)
|
return s.send(toAddress, "Koin Ping — Email Alerts Configured", html)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SendDigest compiles recent alert events for a user and sends a digest email.
|
|
||||||
func (s *EmailDigestService) SendDigest(ctx context.Context, userID, toAddress string) error {
|
func (s *EmailDigestService) SendDigest(ctx context.Context, userID, toAddress string) error {
|
||||||
if !s.Configured() {
|
if !s.Configured() {
|
||||||
return fmt.Errorf("email service not configured: RESEND_API_KEY not set") //nolint:err113
|
return fmt.Errorf("email service not configured: RESEND_API_KEY not set") //nolint:err113
|
||||||
@@ -131,8 +130,6 @@ func (s *EmailDigestService) SendDigest(ctx context.Context, userID, toAddress s
|
|||||||
return s.send(toAddress, subject, html)
|
return s.send(toAddress, subject, html)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SendDigestsForAllUsers sends a digest email to every user that has
|
|
||||||
// notifications enabled and an email configured.
|
|
||||||
func (s *EmailDigestService) SendDigestsForAllUsers(ctx context.Context) (int, error) {
|
func (s *EmailDigestService) SendDigestsForAllUsers(ctx context.Context) (int, error) {
|
||||||
if !s.Configured() {
|
if !s.Configured() {
|
||||||
return 0, nil
|
return 0, nil
|
||||||
@@ -4,19 +4,23 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/domain"
|
"golang.org/x/sync/semaphore"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/models"
|
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/notifications"
|
"github.com/kjannette/koin-ping/backend/internal/domain"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/protocols/ethereum"
|
"github.com/kjannette/koin-ping/backend/internal/models"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/wei"
|
"github.com/kjannette/koin-ping/backend/internal/notifications"
|
||||||
|
"github.com/kjannette/koin-ping/backend/internal/protocols/ethereum"
|
||||||
|
"github.com/kjannette/koin-ping/backend/internal/wei"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
notificationTimeout = 30 * time.Second
|
notificationTimeout = 30 * time.Second
|
||||||
notificationMaxRetries = 3
|
notificationMaxRetries = 3
|
||||||
notificationRetryBase = time.Second
|
notificationRetryBase = time.Second
|
||||||
|
maxConcurrentNotifications = 5
|
||||||
)
|
)
|
||||||
|
|
||||||
type EvaluatorService struct {
|
type EvaluatorService struct {
|
||||||
@@ -27,6 +31,8 @@ type EvaluatorService struct {
|
|||||||
notifConfigs *models.NotificationConfigModel
|
notifConfigs *models.NotificationConfigModel
|
||||||
resendAPIKey string
|
resendAPIKey string
|
||||||
emailFrom string
|
emailFrom string
|
||||||
|
notifSem *semaphore.Weighted
|
||||||
|
notifWg sync.WaitGroup
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewEvaluatorService(
|
func NewEvaluatorService(
|
||||||
@@ -46,6 +52,7 @@ func NewEvaluatorService(
|
|||||||
notifConfigs: notifConfigs,
|
notifConfigs: notifConfigs,
|
||||||
resendAPIKey: resendAPIKey,
|
resendAPIKey: resendAPIKey,
|
||||||
emailFrom: emailFrom,
|
emailFrom: emailFrom,
|
||||||
|
notifSem: semaphore.NewWeighted(maxConcurrentNotifications),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,7 +196,14 @@ func (s *EvaluatorService) fireAlert(ctx context.Context, rule domain.AlertRule,
|
|||||||
if addr != nil {
|
if addr != nil {
|
||||||
userID := addr.UserID
|
userID := addr.UserID
|
||||||
address := addr.Address
|
address := addr.Address
|
||||||
|
if err := s.notifSem.Acquire(ctx, 1); err != nil {
|
||||||
|
log.Printf("Failed to acquire notification semaphore for rule %d: %v", rule.ID, err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
s.notifWg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
|
defer s.notifSem.Release(1)
|
||||||
|
defer s.notifWg.Done()
|
||||||
notifCtx, cancel := context.WithTimeout(context.Background(), notificationTimeout)
|
notifCtx, cancel := context.WithTimeout(context.Background(), notificationTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
s.sendNotification(notifCtx, userID, message, obs, addressLabel, rule, address)
|
s.sendNotification(notifCtx, userID, message, obs, addressLabel, rule, address)
|
||||||
@@ -199,6 +213,11 @@ func (s *EvaluatorService) fireAlert(ctx context.Context, rule domain.AlertRule,
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WaitForNotifications blocks until all in-flight notification goroutines finish.
|
||||||
|
func (s *EvaluatorService) WaitForNotifications() {
|
||||||
|
s.notifWg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
func (s *EvaluatorService) buildNotifiers(cfg *domain.NotificationConfig) []notifications.Notifier {
|
func (s *EvaluatorService) buildNotifiers(cfg *domain.NotificationConfig) []notifications.Notifier {
|
||||||
var notifiers []notifications.Notifier
|
var notifiers []notifications.Notifier
|
||||||
|
|
||||||
@@ -242,8 +261,14 @@ func sendWithRetry(ctx context.Context, n notifications.Notifier, message string
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := n.Send(ctx, message, meta); err != nil {
|
if err := n.Send(ctx, message, meta); err != nil {
|
||||||
log.Printf("Notification attempt %d/%d failed: %v", attempt+1, notificationMaxRetries, err)
|
|
||||||
lastErr = err
|
lastErr = err
|
||||||
|
|
||||||
|
if notifications.IsPermanent(err) {
|
||||||
|
log.Printf("Permanent notification failure, skipping retries: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Notification attempt %d/%d failed: %v", attempt+1, notificationMaxRetries, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5,9 +5,9 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/domain"
|
"github.com/kjannette/koin-ping/backend/internal/domain"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/models"
|
"github.com/kjannette/koin-ping/backend/internal/models"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/protocols/ethereum"
|
"github.com/kjannette/koin-ping/backend/internal/protocols/ethereum"
|
||||||
)
|
)
|
||||||
|
|
||||||
const maxBlocksPerRun = 100
|
const maxBlocksPerRun = 100
|
||||||
BIN
backend/poller
Executable file
BIN
backend/poller
Executable file
Binary file not shown.
BIN
frontend/public/koin_spin.mp4
Normal file
BIN
frontend/public/koin_spin.mp4
Normal file
Binary file not shown.
@@ -1,38 +1,40 @@
|
|||||||
import { Routes, Route, Navigate } from "react-router-dom";
|
import { Routes, Route, Navigate } from "react-router-dom";
|
||||||
import { useAuth } from "./contexts/AuthContext";
|
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/Login";
|
||||||
import Signup from "./pages/Signup";
|
import Signup from "./pages/Signup";
|
||||||
import Onboarding from "./pages/Onboarding";
|
import Subscribe from "./pages/subscribe/subscribe";
|
||||||
import Addresses from "./pages/Addresses";
|
import Addresses from "./pages/addresses/Addresses";
|
||||||
import Alerts from "./pages/Alerts";
|
import Alerts from "./pages/alerts/Alerts";
|
||||||
import AlertHistory from "./pages/AlertHistory";
|
import AlertHistory from "./pages/alertHistory/AlertHistory";
|
||||||
|
import Account from "./pages/user_account/Account";
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { currentUser } = useAuth();
|
const { currentUser } = useAuth();
|
||||||
|
|
||||||
if (!currentUser) {
|
|
||||||
return (
|
|
||||||
<Routes>
|
|
||||||
<Route path="/login" element={<Login />} />
|
|
||||||
<Route path="/signup" element={<Signup />} />
|
|
||||||
<Route path="/onboarding" element={<Onboarding />} />
|
|
||||||
<Route path="*" element={<Navigate to="/login" />} />
|
|
||||||
</Routes>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if (!currentUser) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<Routes>
|
||||||
<Navbar />
|
<Route path="/login" element={<Login />} />
|
||||||
<Routes>
|
<Route path="/signup" element={<Signup />} />
|
||||||
<Route path="/" element={<Addresses />} />
|
<Route path="/subscribe" element={<Subscribe />} />
|
||||||
<Route path="/addresses" element={<Addresses />} />
|
<Route path="*" element={<Navigate to="/login" />} />
|
||||||
<Route path="/alerts" element={<Alerts />} />
|
</Routes>
|
||||||
<Route path="/alertevents" element={<AlertHistory />} />
|
|
||||||
<Route path="/onboarding" element={<Onboarding />} />
|
|
||||||
<Route path="*" element={<Navigate to="/addresses" />} />
|
|
||||||
</Routes>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Navbar />
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Addresses />} />
|
||||||
|
<Route path="/addresses" element={<Addresses />} />
|
||||||
|
<Route path="/alerts" element={<Alerts />} />
|
||||||
|
<Route path="/alertevents" element={<AlertHistory />} />
|
||||||
|
<Route path="/account" element={<Account />} />
|
||||||
|
<Route path="/subscribe" element={<Subscribe />} />
|
||||||
|
<Route path="*" element={<Navigate to="/addresses" />} />
|
||||||
|
</Routes>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
25
frontend/src/api/account.js
Normal file
25
frontend/src/api/account.js
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { getAuthHeaders } from "./authHeaders";
|
||||||
|
import { API_BASE } from "./config";
|
||||||
|
|
||||||
|
export async function getAccount() {
|
||||||
|
const headers = await getAuthHeaders();
|
||||||
|
const res = await fetch(`${API_BASE}/user/account`, { headers });
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
throw new Error(data.message || "Failed to load account");
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createPortalSession() {
|
||||||
|
const headers = await getAuthHeaders();
|
||||||
|
const res = await fetch(`${API_BASE}/stripe/create-portal-session`, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
throw new Error(data.message || "Failed to create portal session");
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
41
frontend/src/api/stripe.jsx
Normal file
41
frontend/src/api/stripe.jsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { getAuthHeaders } from "./authHeaders";
|
||||||
|
import { API_BASE } from "./config";
|
||||||
|
|
||||||
|
export async function createCheckoutSession() {
|
||||||
|
const headers = await getAuthHeaders();
|
||||||
|
const res = await fetch(`${API_BASE}/stripe/create-checkout-session`, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
throw new Error(data.message || "Failed to create checkout session");
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyCheckoutSession(sessionId) {
|
||||||
|
const headers = await getAuthHeaders();
|
||||||
|
const res = await fetch(`${API_BASE}/stripe/verify-checkout`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { ...headers, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ session_id: sessionId }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
throw new Error(data.message || "Failed to verify checkout session");
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSubscriptionStatus() {
|
||||||
|
const headers = await getAuthHeaders();
|
||||||
|
const res = await fetch(`${API_BASE}/stripe/subscription-status`, {
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
throw new Error(data.message || "Failed to get subscription status");
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
.input__label {
|
.input__label {
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 0.4rem;
|
margin: 0.8rem 0rem 0.4rem 0rem;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,16 @@
|
|||||||
color: #777;
|
color: #777;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.navbar__user-link {
|
||||||
|
color: var(--color-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar__user-link:hover {
|
||||||
|
color: var(--color-primary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
.navbar__logout {
|
.navbar__logout {
|
||||||
padding: 0.4rem 1rem;
|
padding: 0.4rem 1rem;
|
||||||
font-size: 1.275rem;
|
font-size: 1.275rem;
|
||||||
@@ -54,3 +64,60 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.15s ease;
|
transition: all 0.15s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Navbar Responsive ───────────────────────────────────── */
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.navbar {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar__brand {
|
||||||
|
margin-right: 1rem;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar__links {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar__link {
|
||||||
|
padding: 0.35rem 0.75rem;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar__user {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar__logout {
|
||||||
|
padding: 0.3rem 0.75rem;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.navbar {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 0.75rem;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar__links {
|
||||||
|
width: 100%;
|
||||||
|
gap: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar__link {
|
||||||
|
padding: 0.3rem 0.6rem;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar__brand {
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import "./Navbar.css";
|
|||||||
const navLinks = [
|
const navLinks = [
|
||||||
{ to: "/addresses", label: "Addresses" },
|
{ to: "/addresses", label: "Addresses" },
|
||||||
{ to: "/alerts", label: "Configure Alerts" },
|
{ to: "/alerts", label: "Configure Alerts" },
|
||||||
{ to: "/alertevents", label: "Alert Events" },
|
{ to: "/alertevents", label: "Alert Event History" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function Navbar() {
|
export default function Navbar() {
|
||||||
@@ -35,7 +35,7 @@ export default function Navbar() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex--center gap-lg">
|
<div className="flex flex--center gap-lg">
|
||||||
<span className="navbar__user">{currentUser.email}</span>
|
<Link to="/account" className="navbar__user navbar__user-link">{currentUser.email}</Link>
|
||||||
<button onClick={logout} className="navbar__logout">
|
<button onClick={logout} className="navbar__logout">
|
||||||
Logout
|
Logout
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -356,3 +356,73 @@ button {
|
|||||||
color: white;
|
color: white;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Responsive Breakpoints ──────────────────────────────── */
|
||||||
|
/* Mobile: <= 480px */
|
||||||
|
/* Tablet: 481px – 768px */
|
||||||
|
/* Laptop: 769px – 1024px */
|
||||||
|
/* Desktop: >= 1025px (default styles above) */
|
||||||
|
|
||||||
|
/* Laptop & below */
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.page--wide {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tablet & below */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
html {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page {
|
||||||
|
padding: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--lg {
|
||||||
|
padding: 0.6rem 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 1.15rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile */
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
html {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page {
|
||||||
|
padding: 1rem 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control,
|
||||||
|
.form-select {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 0.45rem 0.85rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--lg {
|
||||||
|
padding: 0.55rem 1rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
import { useState, useEffect } from "react";
|
|
||||||
import AddressForm from "../components/AddressForm";
|
|
||||||
import { getAddresses, createAddress, deleteAddress, updateAddress } from "../api/addresses";
|
|
||||||
import "./Addresses.css";
|
|
||||||
|
|
||||||
export default function Addresses() {
|
|
||||||
const [addresses, setAddresses] = useState([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState(null);
|
|
||||||
const [editingId, setEditingId] = useState(null);
|
|
||||||
const [editLabel, setEditLabel] = useState("");
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
async function fetchAddresses() {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
const data = await getAddresses();
|
|
||||||
setAddresses(data);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err.message);
|
|
||||||
console.error("Failed to fetch addresses:", err);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchAddresses();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
async function handleAddressSubmit(data) {
|
|
||||||
try {
|
|
||||||
const newAddress = await createAddress(data);
|
|
||||||
setAddresses((prev) => [...prev, newAddress]);
|
|
||||||
setError(null);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err.message);
|
|
||||||
console.error("Failed to create address:", err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDelete(id, label) {
|
|
||||||
const displayName = label || "this address";
|
|
||||||
if (!window.confirm(`Remove "${displayName}"? This will also delete all associated alert rules.`)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await deleteAddress(id);
|
|
||||||
setAddresses((prev) => prev.filter((a) => a.id !== id));
|
|
||||||
setError(null);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err.message);
|
|
||||||
console.error("Failed to delete address:", err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleEditStart(addr) {
|
|
||||||
setEditingId(addr.id);
|
|
||||||
setEditLabel(addr.label ?? "");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleEditSave(id) {
|
|
||||||
try {
|
|
||||||
const updated = await updateAddress(id, { label: editLabel || null });
|
|
||||||
setAddresses((prev) => prev.map((a) => (a.id === id ? updated : a)));
|
|
||||||
setEditingId(null);
|
|
||||||
setEditLabel("");
|
|
||||||
setError(null);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err.message);
|
|
||||||
console.error("Failed to update address:", err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleEditCancel() {
|
|
||||||
setEditingId(null);
|
|
||||||
setEditLabel("");
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="page">
|
|
||||||
<h1>Add Addresses to Track</h1>
|
|
||||||
|
|
||||||
<div className="mb-xl">
|
|
||||||
<AddressForm onSubmit={handleAddressSubmit} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h2>Existing Tracked Addresses</h2>
|
|
||||||
{loading && <p>Loading addresses...</p>}
|
|
||||||
{error && <p className="text-error">Error: {error}</p>}
|
|
||||||
{!loading && !error && addresses.length === 0 && (
|
|
||||||
<p className="text-dimmed">
|
|
||||||
No addresses tracked yet. Add one above to get started.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{addresses.length > 0 && (
|
|
||||||
<ul className="list-unstyled">
|
|
||||||
{addresses.map((addr, index) => (
|
|
||||||
<li key={addr.id || index} className="list-item--card">
|
|
||||||
<div className="flex flex--between flex--center">
|
|
||||||
<div>
|
|
||||||
{editingId === addr.id ? (
|
|
||||||
<div className="flex flex--center gap-sm mb-sm">
|
|
||||||
<input
|
|
||||||
value={editLabel}
|
|
||||||
onChange={(e) => setEditLabel(e.target.value)}
|
|
||||||
placeholder="Label (optional)"
|
|
||||||
className="address__edit-input"
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter") handleEditSave(addr.id);
|
|
||||||
if (e.key === "Escape") handleEditCancel();
|
|
||||||
}}
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={() => handleEditSave(addr.id)}
|
|
||||||
className="btn btn--primary btn--sm"
|
|
||||||
>
|
|
||||||
Save
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleEditCancel}
|
|
||||||
className="btn btn--ghost btn--sm"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex--center gap-sm mb-sm">
|
|
||||||
<span className="text-bold">
|
|
||||||
{addr.label || "Unlabeled"}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
onClick={() => handleEditStart(addr)}
|
|
||||||
className="address__edit-link"
|
|
||||||
>
|
|
||||||
Edit
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="text-mono text-sm text-muted">
|
|
||||||
{addr.address}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => handleDelete(addr.id, addr.label)}
|
|
||||||
className="address__remove"
|
|
||||||
>
|
|
||||||
Remove
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
import { useState, useEffect } from "react";
|
|
||||||
import { getAlertEvents } from "../api/alertEvents";
|
|
||||||
import "./AlertHistory.css";
|
|
||||||
|
|
||||||
export default function AlertHistory() {
|
|
||||||
const [alertEvents, setAlertEvents] = useState([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
async function fetchAlertEvents() {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
const data = await getAlertEvents();
|
|
||||||
setAlertEvents(data);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err.message);
|
|
||||||
console.error("Failed to fetch alert events:", err);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchAlertEvents();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return <div className="page">Loading...</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<div className="page text-error">Error: {error}</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="page">
|
|
||||||
<h1>Recent Alert Events</h1>
|
|
||||||
|
|
||||||
{alertEvents.length === 0 ? (
|
|
||||||
<p className="text-dimmed">No alerts yet</p>
|
|
||||||
) : (
|
|
||||||
<ul className="list-unstyled">
|
|
||||||
{alertEvents.map((event) => (
|
|
||||||
<li key={event.id} className="alert-history__item">
|
|
||||||
<div className="mb-sm">{event.message}</div>
|
|
||||||
{event.address_label && (
|
|
||||||
<div className="text-sm text-dimmed">
|
|
||||||
Address: {event.address_label}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<small className="text-muted">
|
|
||||||
{formatTimestamp(event.timestamp)}
|
|
||||||
</small>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTimestamp(timestamp) {
|
|
||||||
const date = new Date(timestamp);
|
|
||||||
return date.toLocaleString();
|
|
||||||
}
|
|
||||||
@@ -1,522 +0,0 @@
|
|||||||
import { useState, useEffect } from "react";
|
|
||||||
import AlertForm from "../components/AlertForm";
|
|
||||||
import Button from "../components/Button";
|
|
||||||
import Input from "../components/Input";
|
|
||||||
import { getAddresses } from "../api/addresses";
|
|
||||||
import {
|
|
||||||
getAlerts,
|
|
||||||
createAlert,
|
|
||||||
updateAlertStatus,
|
|
||||||
deleteAlert,
|
|
||||||
} from "../api/alerts";
|
|
||||||
import {
|
|
||||||
getNotificationConfig,
|
|
||||||
updateNotificationConfig,
|
|
||||||
testNotificationChannels,
|
|
||||||
setupEmail,
|
|
||||||
sendEmailDigest,
|
|
||||||
} from "../api/notificationConfig";
|
|
||||||
import "./Alerts.css";
|
|
||||||
|
|
||||||
export default function Alerts() {
|
|
||||||
const [addresses, setAddresses] = useState([]);
|
|
||||||
const [selectedAddressId, setSelectedAddressId] = useState(null);
|
|
||||||
const [alerts, setAlerts] = useState([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState(null);
|
|
||||||
|
|
||||||
const [notificationEnabled, setNotificationEnabled] = useState(false);
|
|
||||||
const [discordWebhookUrl, setDiscordWebhookUrl] = useState("");
|
|
||||||
const [telegramBotToken, setTelegramBotToken] = useState("");
|
|
||||||
const [telegramChatId, setTelegramChatId] = useState("");
|
|
||||||
const [email, setEmail] = useState("");
|
|
||||||
const [slackWebhookUrl, setSlackWebhookUrl] = useState("");
|
|
||||||
const [notificationLoading, setNotificationLoading] = useState(false);
|
|
||||||
const [notificationError, setNotificationError] = useState(null);
|
|
||||||
const [notificationSuccess, setNotificationSuccess] = useState(null);
|
|
||||||
const [testingChannels, setTestingChannels] = useState(false);
|
|
||||||
const [settingUpEmail, setSettingUpEmail] = useState(false);
|
|
||||||
const [sendingDigest, setSendingDigest] = useState(false);
|
|
||||||
const [hasExistingConfig, setHasExistingConfig] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
async function fetchData() {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
const addressData = await getAddresses();
|
|
||||||
setAddresses(addressData);
|
|
||||||
if (addressData.length > 0) {
|
|
||||||
setSelectedAddressId(addressData[0].id);
|
|
||||||
}
|
|
||||||
|
|
||||||
const configData = await getNotificationConfig();
|
|
||||||
setNotificationEnabled(
|
|
||||||
configData.notification_enabled !== false,
|
|
||||||
);
|
|
||||||
setDiscordWebhookUrl(configData.discord_webhook_url || "");
|
|
||||||
setTelegramBotToken(configData.telegram_bot_token || "");
|
|
||||||
setTelegramChatId(configData.telegram_chat_id || "");
|
|
||||||
setEmail(configData.email || "");
|
|
||||||
setSlackWebhookUrl(configData.slack_webhook_url || "");
|
|
||||||
|
|
||||||
const hasSaved =
|
|
||||||
!!configData.discord_webhook_url ||
|
|
||||||
!!configData.telegram_bot_token ||
|
|
||||||
!!configData.telegram_chat_id ||
|
|
||||||
!!configData.email ||
|
|
||||||
!!configData.slack_webhook_url;
|
|
||||||
setHasExistingConfig(hasSaved);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err.message);
|
|
||||||
console.error("Failed to fetch data:", err);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchData();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!selectedAddressId) {
|
|
||||||
setAlerts([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchAlerts() {
|
|
||||||
try {
|
|
||||||
const data = await getAlerts(selectedAddressId);
|
|
||||||
setAlerts(data);
|
|
||||||
setError(null);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err.message);
|
|
||||||
console.error("Failed to fetch alerts:", err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchAlerts();
|
|
||||||
}, [selectedAddressId]);
|
|
||||||
|
|
||||||
async function handleAlertSubmit(data) {
|
|
||||||
if (!selectedAddressId) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const newAlert = await createAlert(selectedAddressId, data);
|
|
||||||
setAlerts((prev) => [...prev, newAlert]);
|
|
||||||
setError(null);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err.message);
|
|
||||||
console.error("Failed to create alert:", err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleToggleAlert(alertId, currentStatus) {
|
|
||||||
try {
|
|
||||||
const updated = await updateAlertStatus(alertId, !currentStatus);
|
|
||||||
setAlerts((prev) =>
|
|
||||||
prev.map((alert) => (alert.id === alertId ? updated : alert)),
|
|
||||||
);
|
|
||||||
setError(null);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err.message);
|
|
||||||
console.error("Failed to update alert:", err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDeleteAlert(alertId) {
|
|
||||||
try {
|
|
||||||
await deleteAlert(alertId);
|
|
||||||
setAlerts((prev) => prev.filter((alert) => alert.id !== alertId));
|
|
||||||
setError(null);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err.message);
|
|
||||||
console.error("Failed to delete alert:", err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSaveNotificationConfig() {
|
|
||||||
if (hasExistingConfig) {
|
|
||||||
const confirmed = window.confirm(
|
|
||||||
"This will overwrite your previously saved notification settings. Continue?",
|
|
||||||
);
|
|
||||||
if (!confirmed) return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
setNotificationLoading(true);
|
|
||||||
setNotificationError(null);
|
|
||||||
setNotificationSuccess(null);
|
|
||||||
|
|
||||||
const config = {
|
|
||||||
notification_enabled: notificationEnabled,
|
|
||||||
discord_webhook_url: discordWebhookUrl || null,
|
|
||||||
telegram_bot_token: telegramBotToken || null,
|
|
||||||
telegram_chat_id: telegramChatId || null,
|
|
||||||
email: email || null,
|
|
||||||
slack_webhook_url: slackWebhookUrl || null,
|
|
||||||
};
|
|
||||||
|
|
||||||
await updateNotificationConfig(config);
|
|
||||||
setHasExistingConfig(true);
|
|
||||||
setNotificationSuccess("Notification settings saved!");
|
|
||||||
setTimeout(() => setNotificationSuccess(null), 3000);
|
|
||||||
} catch (err) {
|
|
||||||
setNotificationError(err.message);
|
|
||||||
console.error("Failed to save notification config:", err);
|
|
||||||
} finally {
|
|
||||||
setNotificationLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleTestChannels() {
|
|
||||||
try {
|
|
||||||
setTestingChannels(true);
|
|
||||||
setNotificationError(null);
|
|
||||||
setNotificationSuccess(null);
|
|
||||||
|
|
||||||
const data = await testNotificationChannels();
|
|
||||||
const results = data.results || [];
|
|
||||||
|
|
||||||
const failed = results.filter((r) => !r.success);
|
|
||||||
const succeeded = results.filter((r) => r.success);
|
|
||||||
|
|
||||||
if (failed.length === 0 && succeeded.length > 0) {
|
|
||||||
setNotificationSuccess(
|
|
||||||
`Test sent to: ${succeeded.map((r) => r.channel).join(", ")}`,
|
|
||||||
);
|
|
||||||
} else if (failed.length > 0 && succeeded.length > 0) {
|
|
||||||
setNotificationSuccess(
|
|
||||||
`Sent: ${succeeded.map((r) => r.channel).join(", ")}. Failed: ${failed.map((r) => `${r.channel} (${r.error})`).join(", ")}`,
|
|
||||||
);
|
|
||||||
} else if (failed.length > 0) {
|
|
||||||
setNotificationError(
|
|
||||||
`Test failed: ${failed.map((r) => `${r.channel} (${r.error})`).join(", ")}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
setNotificationSuccess(null);
|
|
||||||
setNotificationError(null);
|
|
||||||
}, 6000);
|
|
||||||
} catch (err) {
|
|
||||||
setNotificationError(err.message);
|
|
||||||
} finally {
|
|
||||||
setTestingChannels(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSetupEmail() {
|
|
||||||
try {
|
|
||||||
setSettingUpEmail(true);
|
|
||||||
setNotificationError(null);
|
|
||||||
setNotificationSuccess(null);
|
|
||||||
|
|
||||||
await handleSaveNotificationConfig();
|
|
||||||
|
|
||||||
const result = await setupEmail();
|
|
||||||
setNotificationSuccess(
|
|
||||||
result.message || "Confirmation email sent!",
|
|
||||||
);
|
|
||||||
setTimeout(() => setNotificationSuccess(null), 5000);
|
|
||||||
} catch (err) {
|
|
||||||
setNotificationError(err.message);
|
|
||||||
} finally {
|
|
||||||
setSettingUpEmail(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSendDigest() {
|
|
||||||
try {
|
|
||||||
setSendingDigest(true);
|
|
||||||
setNotificationError(null);
|
|
||||||
setNotificationSuccess(null);
|
|
||||||
|
|
||||||
const result = await sendEmailDigest();
|
|
||||||
setNotificationSuccess(result.message || "Digest email sent!");
|
|
||||||
setTimeout(() => setNotificationSuccess(null), 5000);
|
|
||||||
} catch (err) {
|
|
||||||
setNotificationError(err.message);
|
|
||||||
} finally {
|
|
||||||
setSendingDigest(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectedAddress = addresses.find((a) => a.id === selectedAddressId);
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return <div className="page">Loading addresses...</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (addresses.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="page">
|
|
||||||
<p>No addresses tracked yet. Add an address first to create alerts.</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="page page--wide">
|
|
||||||
<h1 className="mb-xl">Alert Rules & Notifications</h1>
|
|
||||||
|
|
||||||
<div className="alerts-grid">
|
|
||||||
{/* LEFT COLUMN: Alert Rules */}
|
|
||||||
<div>
|
|
||||||
<h2 className="mt-0">Alert Rules</h2>
|
|
||||||
|
|
||||||
<div className="mb-xl">
|
|
||||||
<label className="form-label mb-sm">
|
|
||||||
<strong>Select Address:</strong>
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={selectedAddressId || ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
setSelectedAddressId(Number(e.target.value))
|
|
||||||
}
|
|
||||||
className="form-select"
|
|
||||||
>
|
|
||||||
{addresses.map((addr) => (
|
|
||||||
<option key={addr.id} value={addr.id}>
|
|
||||||
{addr.label || "Unlabeled"} - {addr.address}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{selectedAddress && (
|
|
||||||
<>
|
|
||||||
<div className="alerts__address-info">
|
|
||||||
<div className="text-sm text-muted">
|
|
||||||
Managing alerts for:
|
|
||||||
</div>
|
|
||||||
<div className="text-bold">
|
|
||||||
{selectedAddress.label || "Unlabeled"}
|
|
||||||
</div>
|
|
||||||
<div className="text-mono text-sm text-muted">
|
|
||||||
{selectedAddress.address}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-xl">
|
|
||||||
<h3>Create New Alert</h3>
|
|
||||||
<AlertForm onSubmit={handleAlertSubmit} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h3>Active Alert Rules</h3>
|
|
||||||
{error && (
|
|
||||||
<p className="text-error">Error: {error}</p>
|
|
||||||
)}
|
|
||||||
{alerts.length === 0 ? (
|
|
||||||
<p className="text-dimmed">
|
|
||||||
No alert rules defined yet. Create one above.
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<ul className="list-unstyled">
|
|
||||||
{alerts.map((alert) => (
|
|
||||||
<li
|
|
||||||
key={alert.id}
|
|
||||||
className={`alerts__rule ${!alert.enabled ? "alerts__rule--disabled" : ""}`}
|
|
||||||
>
|
|
||||||
<div className="flex flex--between">
|
|
||||||
<div>
|
|
||||||
<div className="text-bold mb-sm">
|
|
||||||
{formatAlertType(alert.type)}
|
|
||||||
</div>
|
|
||||||
{alert.threshold && (
|
|
||||||
<div className="text-sm text-muted">
|
|
||||||
Threshold: {alert.threshold} ETH
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="text-xs text-dimmed">
|
|
||||||
Status: {alert.enabled ? "Enabled" : "Disabled"}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex--center gap-sm">
|
|
||||||
<Button
|
|
||||||
onClick={() =>
|
|
||||||
handleToggleAlert(alert.id, alert.enabled)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{alert.enabled ? "Disable" : "Enable"}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => handleDeleteAlert(alert.id)}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* RIGHT COLUMN: Notification Settings */}
|
|
||||||
<div>
|
|
||||||
<h2 className="mt-0">Notification Settings</h2>
|
|
||||||
|
|
||||||
{notificationSuccess && (
|
|
||||||
<div className="alert alert--success">{notificationSuccess}</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{notificationError && (
|
|
||||||
<div className="alert alert--error">{notificationError}</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Master toggle */}
|
|
||||||
<div className="alerts__toggle-panel">
|
|
||||||
<label className="alerts__toggle-label">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={notificationEnabled}
|
|
||||||
onChange={(e) =>
|
|
||||||
setNotificationEnabled(e.target.checked)
|
|
||||||
}
|
|
||||||
className="alerts__toggle-checkbox"
|
|
||||||
/>
|
|
||||||
<span className="text-bold">Enable Notifications</span>
|
|
||||||
</label>
|
|
||||||
<div className="alerts__toggle-hint">
|
|
||||||
Master switch for all notification channels
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* All channel settings -- hidden when master toggle is off */}
|
|
||||||
{notificationEnabled && (
|
|
||||||
<>
|
|
||||||
{/* Telegram */}
|
|
||||||
<div className="section">
|
|
||||||
<h3 className="mt-0 mb-md">Telegram</h3>
|
|
||||||
<Input
|
|
||||||
label="Bot Token"
|
|
||||||
value={telegramBotToken}
|
|
||||||
onChange={setTelegramBotToken}
|
|
||||||
placeholder="123456789:ABCdefGHIjklMNOpqrSTUvwxYZ"
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label="Chat ID"
|
|
||||||
value={telegramChatId}
|
|
||||||
onChange={setTelegramChatId}
|
|
||||||
placeholder="-1001234567890"
|
|
||||||
/>
|
|
||||||
<a
|
|
||||||
href="https://core.telegram.org/bots#how-do-i-create-a-bot"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="help-link"
|
|
||||||
>
|
|
||||||
How to create a Telegram bot & get your Chat ID
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Email */}
|
|
||||||
<div className="section">
|
|
||||||
<h3 className="mt-0 mb-md">Email</h3>
|
|
||||||
<Input
|
|
||||||
label="Email Address"
|
|
||||||
type="email"
|
|
||||||
value={email}
|
|
||||||
onChange={setEmail}
|
|
||||||
placeholder="you@example.com"
|
|
||||||
/>
|
|
||||||
<div className="help-link text-dimmed mb-md">
|
|
||||||
Alert notifications and digests will be sent to this address
|
|
||||||
</div>
|
|
||||||
<div className="alerts__email-buttons">
|
|
||||||
<Button
|
|
||||||
onClick={handleSetupEmail}
|
|
||||||
disabled={settingUpEmail || !email}
|
|
||||||
className="btn--sm"
|
|
||||||
>
|
|
||||||
{settingUpEmail ? "Setting up..." : "Verify Email"}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={handleSendDigest}
|
|
||||||
disabled={sendingDigest || !email}
|
|
||||||
variant="secondary"
|
|
||||||
className="btn--sm"
|
|
||||||
>
|
|
||||||
{sendingDigest ? "Sending..." : "Send Digest Now"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Discord */}
|
|
||||||
<div className="section">
|
|
||||||
<h3 className="mt-0 mb-md">Discord</h3>
|
|
||||||
<Input
|
|
||||||
label="Discord Webhook URL"
|
|
||||||
value={discordWebhookUrl}
|
|
||||||
onChange={setDiscordWebhookUrl}
|
|
||||||
placeholder="https://discord.com/api/webhooks/..."
|
|
||||||
/>
|
|
||||||
<a
|
|
||||||
href="https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="help-link"
|
|
||||||
>
|
|
||||||
How to get a Discord webhook URL
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Slack */}
|
|
||||||
<div className="section">
|
|
||||||
<h3 className="mt-0 mb-md">Slack</h3>
|
|
||||||
<Input
|
|
||||||
label="Slack Webhook URL"
|
|
||||||
value={slackWebhookUrl}
|
|
||||||
onChange={setSlackWebhookUrl}
|
|
||||||
placeholder="https://hooks.slack.com/services/..."
|
|
||||||
/>
|
|
||||||
<a
|
|
||||||
href="https://api.slack.com/messaging/webhooks"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="help-link"
|
|
||||||
>
|
|
||||||
How to set up Slack Incoming Webhooks
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Save & Test buttons */}
|
|
||||||
<div className="alerts__save-test">
|
|
||||||
<Button
|
|
||||||
onClick={handleSaveNotificationConfig}
|
|
||||||
disabled={notificationLoading}
|
|
||||||
className="btn--lg"
|
|
||||||
>
|
|
||||||
{notificationLoading ? "Saving..." : "Save Settings"}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={handleTestChannels}
|
|
||||||
disabled={testingChannels}
|
|
||||||
variant="success"
|
|
||||||
className="btn--lg"
|
|
||||||
>
|
|
||||||
{testingChannels ? "Testing..." : "Test All Channels"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatAlertType(type) {
|
|
||||||
const labels = {
|
|
||||||
incoming_tx: "Incoming transaction",
|
|
||||||
outgoing_tx: "Outgoing transaction",
|
|
||||||
large_transfer: "Large transfer",
|
|
||||||
balance_below: "Balance below threshold",
|
|
||||||
};
|
|
||||||
return labels[type] || type;
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
.login-page {
|
|
||||||
min-height: 100vh;
|
|
||||||
background-image: url(/ping.png);
|
|
||||||
background-size: 67%;
|
|
||||||
background-position: center;
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-card {
|
|
||||||
max-width: 400px;
|
|
||||||
margin: 20px auto;
|
|
||||||
padding: 2rem;
|
|
||||||
padding-top: 8rem;
|
|
||||||
border: 1px solid var(--color-border-light);
|
|
||||||
border-radius: var(--radius-xl);
|
|
||||||
background-color: rgba(0, 0, 0, 0.75);
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-heading {
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
text-align: center;
|
|
||||||
position: relative;
|
|
||||||
top: -20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-brand {
|
|
||||||
color: var(--color-brand);
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-button {
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.75rem;
|
|
||||||
font-size: 1.2rem;
|
|
||||||
margin-top: 16px;
|
|
||||||
;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-footer {
|
|
||||||
margin-top: 1.5rem;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-signup-link {
|
|
||||||
color: var(--color-primary);
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes fadeIn {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-form-hidden {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-form-visible {
|
|
||||||
animation: fadeIn 2s ease-in-out forwards;
|
|
||||||
}
|
|
||||||
@@ -1,644 +0,0 @@
|
|||||||
/**
|
|
||||||
* Onboarding Wizard
|
|
||||||
*
|
|
||||||
* 5-step guided flow: Create Account -> Add Wallet -> Alert Rules -> Notifications -> Done
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import { useAuth } from "../contexts/AuthContext";
|
|
||||||
import { createAddress, getAddresses } from "../api/addresses";
|
|
||||||
import { createAlert } from "../api/alerts";
|
|
||||||
import {
|
|
||||||
updateNotificationConfig,
|
|
||||||
testNotificationChannels,
|
|
||||||
} from "../api/notificationConfig";
|
|
||||||
import Input from "../components/Input";
|
|
||||||
import Button from "../components/Button";
|
|
||||||
import "./Onboarding.css";
|
|
||||||
|
|
||||||
const STEPS = [
|
|
||||||
"Create Account",
|
|
||||||
"Add Wallet",
|
|
||||||
"Alert Rules",
|
|
||||||
"Notifications",
|
|
||||||
"Done",
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function Onboarding() {
|
|
||||||
const { currentUser, signup } = useAuth();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const [step, setStep] = useState(1);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [error, setError] = useState("");
|
|
||||||
const [skipWarning, setSkipWarning] = useState("");
|
|
||||||
const [testResults, setTestResults] = useState(null);
|
|
||||||
const [testLoading, setTestLoading] = useState(false);
|
|
||||||
|
|
||||||
const [data, setData] = useState({
|
|
||||||
email: "",
|
|
||||||
password: "",
|
|
||||||
confirmPassword: "",
|
|
||||||
walletAddress: "",
|
|
||||||
walletLabel: "",
|
|
||||||
createdAddressId: null,
|
|
||||||
alertIncomingTx: false,
|
|
||||||
alertOutgoingTx: false,
|
|
||||||
alertLargeTransfer: false,
|
|
||||||
largeTransferThreshold: "",
|
|
||||||
alertBalanceBelow: false,
|
|
||||||
balanceBelowThreshold: "",
|
|
||||||
discordWebhookUrl: "",
|
|
||||||
slackWebhookUrl: "",
|
|
||||||
notificationEmail: "",
|
|
||||||
alertsCreated: [],
|
|
||||||
notificationConfigured: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
function set(field, value) {
|
|
||||||
setData((prev) => ({ ...prev, [field]: value }));
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!currentUser) return;
|
|
||||||
getAddresses()
|
|
||||||
.then((addresses) => {
|
|
||||||
if (addresses.length > 0) {
|
|
||||||
navigate("/addresses", { replace: true });
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => {});
|
|
||||||
}, [currentUser, navigate]);
|
|
||||||
|
|
||||||
// ── Step handlers ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function handleStep1() {
|
|
||||||
setError("");
|
|
||||||
if (!data.email || !data.password || !data.confirmPassword) {
|
|
||||||
setError("Please fill in all fields");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (data.password !== data.confirmPassword) {
|
|
||||||
setError("Passwords do not match");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (data.password.length < 6) {
|
|
||||||
setError("Password must be at least 6 characters");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!currentUser) {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
await signup(data.email, data.password);
|
|
||||||
} catch (err) {
|
|
||||||
if (err.code === "auth/email-already-in-use") {
|
|
||||||
setError("Email already in use. Try logging in instead.");
|
|
||||||
} else if (err.code === "auth/invalid-email") {
|
|
||||||
setError("Invalid email address");
|
|
||||||
} else if (err.code === "auth/weak-password") {
|
|
||||||
setError("Password is too weak");
|
|
||||||
} else {
|
|
||||||
setError("Failed to create account: " + err.message);
|
|
||||||
}
|
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setStep(2);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleStep2() {
|
|
||||||
setError("");
|
|
||||||
if (!data.walletAddress) {
|
|
||||||
setError("Please enter a wallet address");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!/^0x[0-9a-fA-F]{40}$/.test(data.walletAddress)) {
|
|
||||||
setError("Invalid ETH address (must be 0x followed by 40 hex characters)");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
const created = await createAddress({
|
|
||||||
address: data.walletAddress,
|
|
||||||
label: data.walletLabel || undefined,
|
|
||||||
});
|
|
||||||
set("createdAddressId", created.id);
|
|
||||||
setStep(3);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err.message);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleStep3() {
|
|
||||||
setError("");
|
|
||||||
const rules = [];
|
|
||||||
if (data.alertIncomingTx) rules.push({ type: "incoming_tx" });
|
|
||||||
if (data.alertOutgoingTx) rules.push({ type: "outgoing_tx" });
|
|
||||||
if (data.alertLargeTransfer) {
|
|
||||||
if (!data.largeTransferThreshold) {
|
|
||||||
setError("Please enter a threshold for large transfers");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
rules.push({ type: "large_transfer", threshold: data.largeTransferThreshold });
|
|
||||||
}
|
|
||||||
if (data.alertBalanceBelow) {
|
|
||||||
if (!data.balanceBelowThreshold) {
|
|
||||||
setError("Please enter a threshold for balance below");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
rules.push({ type: "balance_below", threshold: data.balanceBelowThreshold });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (rules.length === 0) {
|
|
||||||
setStep(4);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
const created = [];
|
|
||||||
for (const rule of rules) {
|
|
||||||
const result = await createAlert(data.createdAddressId, rule);
|
|
||||||
created.push(result);
|
|
||||||
}
|
|
||||||
set("alertsCreated", created);
|
|
||||||
setStep(4);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err.message);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleStep4() {
|
|
||||||
setError("");
|
|
||||||
const hasAny =
|
|
||||||
data.discordWebhookUrl || data.slackWebhookUrl || data.notificationEmail;
|
|
||||||
if (!hasAny) {
|
|
||||||
setStep(5);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
await updateNotificationConfig({
|
|
||||||
notification_enabled: true,
|
|
||||||
discord_webhook_url: data.discordWebhookUrl || undefined,
|
|
||||||
slack_webhook_url: data.slackWebhookUrl || undefined,
|
|
||||||
email: data.notificationEmail || undefined,
|
|
||||||
});
|
|
||||||
set("notificationConfigured", true);
|
|
||||||
setStep(5);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err.message);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleTestChannels() {
|
|
||||||
setTestLoading(true);
|
|
||||||
setTestResults(null);
|
|
||||||
try {
|
|
||||||
const results = await testNotificationChannels();
|
|
||||||
setTestResults(results);
|
|
||||||
} catch (err) {
|
|
||||||
setTestResults({ error: err.message });
|
|
||||||
} finally {
|
|
||||||
setTestLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Progress bar ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function ProgressBar() {
|
|
||||||
return (
|
|
||||||
<div className="progress-bar">
|
|
||||||
{STEPS.map((label, i) => {
|
|
||||||
const stepNum = i + 1;
|
|
||||||
const done = step > stepNum;
|
|
||||||
const active = step === stepNum;
|
|
||||||
const dotClass = done
|
|
||||||
? "progress-bar__dot--done"
|
|
||||||
: active
|
|
||||||
? "progress-bar__dot--active"
|
|
||||||
: "progress-bar__dot--pending";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={label} className="progress-bar__step">
|
|
||||||
{i > 0 && (
|
|
||||||
<div
|
|
||||||
className={`progress-bar__connector ${
|
|
||||||
done || active
|
|
||||||
? "progress-bar__connector--active"
|
|
||||||
: "progress-bar__connector--inactive"
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div>
|
|
||||||
<div className={`progress-bar__dot ${dotClass}`}>
|
|
||||||
{done ? "\u2713" : stepNum}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={`progress-bar__label ${
|
|
||||||
active
|
|
||||||
? "progress-bar__label--active"
|
|
||||||
: "progress-bar__label--inactive"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Step content ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function Step1() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<h2 className="mb-lg">Create your account</h2>
|
|
||||||
<Input
|
|
||||||
label="Email"
|
|
||||||
type="email"
|
|
||||||
value={data.email}
|
|
||||||
onChange={(v) => set("email", v)}
|
|
||||||
disabled={loading}
|
|
||||||
placeholder="you@example.com"
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label="Password"
|
|
||||||
type="password"
|
|
||||||
value={data.password}
|
|
||||||
onChange={(v) => set("password", v)}
|
|
||||||
disabled={loading}
|
|
||||||
placeholder="At least 6 characters"
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label="Confirm Password"
|
|
||||||
type="password"
|
|
||||||
value={data.confirmPassword}
|
|
||||||
onChange={(v) => set("confirmPassword", v)}
|
|
||||||
disabled={loading}
|
|
||||||
placeholder="Repeat your password"
|
|
||||||
className="form-field--last"
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Step2() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<h2 className="mb-sm">Add a wallet address</h2>
|
|
||||||
<p className="onboarding__subtitle">
|
|
||||||
Enter the Ethereum address you want to monitor.
|
|
||||||
</p>
|
|
||||||
<Input
|
|
||||||
label="ETH Address"
|
|
||||||
value={data.walletAddress}
|
|
||||||
onChange={(v) => set("walletAddress", v)}
|
|
||||||
disabled={loading}
|
|
||||||
placeholder="0x..."
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label="Label (optional)"
|
|
||||||
value={data.walletLabel}
|
|
||||||
onChange={(v) => set("walletLabel", v)}
|
|
||||||
disabled={loading}
|
|
||||||
placeholder="e.g. My main wallet"
|
|
||||||
className="form-field--last"
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Step3() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<h2 className="mb-sm">Configure alert rules</h2>
|
|
||||||
<p className="onboarding__subtitle">
|
|
||||||
Choose which events trigger notifications. You can change these later.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<CheckboxRow
|
|
||||||
checked={data.alertIncomingTx}
|
|
||||||
onChange={(v) => set("alertIncomingTx", v)}
|
|
||||||
label="Incoming transaction"
|
|
||||||
/>
|
|
||||||
<CheckboxRow
|
|
||||||
checked={data.alertOutgoingTx}
|
|
||||||
onChange={(v) => set("alertOutgoingTx", v)}
|
|
||||||
label="Outgoing transaction"
|
|
||||||
/>
|
|
||||||
<CheckboxRow
|
|
||||||
checked={data.alertLargeTransfer}
|
|
||||||
onChange={(v) => set("alertLargeTransfer", v)}
|
|
||||||
label="Large transfer"
|
|
||||||
>
|
|
||||||
{data.alertLargeTransfer && (
|
|
||||||
<div className="checkbox-row__nested">
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
label=""
|
|
||||||
value={data.largeTransferThreshold}
|
|
||||||
onChange={(v) => set("largeTransferThreshold", v)}
|
|
||||||
placeholder="Threshold (ETH)"
|
|
||||||
min="0"
|
|
||||||
step="0.01"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CheckboxRow>
|
|
||||||
<CheckboxRow
|
|
||||||
checked={data.alertBalanceBelow}
|
|
||||||
onChange={(v) => set("alertBalanceBelow", v)}
|
|
||||||
label="Balance below"
|
|
||||||
>
|
|
||||||
{data.alertBalanceBelow && (
|
|
||||||
<div className="checkbox-row__nested">
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
label=""
|
|
||||||
value={data.balanceBelowThreshold}
|
|
||||||
onChange={(v) => set("balanceBelowThreshold", v)}
|
|
||||||
placeholder="Threshold (ETH)"
|
|
||||||
min="0"
|
|
||||||
step="0.01"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CheckboxRow>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Step4() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<h2 className="mb-sm">Set up notifications</h2>
|
|
||||||
<p className="onboarding__subtitle">
|
|
||||||
Add at least one channel so you receive alerts. All fields are optional.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="mb-md">
|
|
||||||
<label className="form-label">
|
|
||||||
Discord Webhook URL{" "}
|
|
||||||
<a
|
|
||||||
href="https://support.discord.com/hc/en-us/articles/228383668"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="help-link"
|
|
||||||
>
|
|
||||||
(how to get one)
|
|
||||||
</a>
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
label=""
|
|
||||||
type="url"
|
|
||||||
value={data.discordWebhookUrl}
|
|
||||||
onChange={(v) => set("discordWebhookUrl", v)}
|
|
||||||
disabled={loading}
|
|
||||||
placeholder="https://discord.com/api/webhooks/..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-md">
|
|
||||||
<label className="form-label">
|
|
||||||
Slack Webhook URL{" "}
|
|
||||||
<a
|
|
||||||
href="https://api.slack.com/messaging/webhooks"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="help-link"
|
|
||||||
>
|
|
||||||
(how to get one)
|
|
||||||
</a>
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
label=""
|
|
||||||
type="url"
|
|
||||||
value={data.slackWebhookUrl}
|
|
||||||
onChange={(v) => set("slackWebhookUrl", v)}
|
|
||||||
disabled={loading}
|
|
||||||
placeholder="https://hooks.slack.com/services/..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
label="Email address for alerts"
|
|
||||||
type="email"
|
|
||||||
value={data.notificationEmail}
|
|
||||||
onChange={(v) => set("notificationEmail", v)}
|
|
||||||
disabled={loading}
|
|
||||||
placeholder="you@example.com"
|
|
||||||
className="form-field--last"
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Step5() {
|
|
||||||
const alertCount = data.alertsCreated.length;
|
|
||||||
const hasNotif = data.notificationConfigured;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<h2 className="mb-md">You're all set!</h2>
|
|
||||||
|
|
||||||
<div className="onboarding__summary">
|
|
||||||
<p className="onboarding__summary-title">Summary</p>
|
|
||||||
<ul className="onboarding__summary-list">
|
|
||||||
<li>
|
|
||||||
Wallet address added:{" "}
|
|
||||||
<span className="text-mono text-white-sm">
|
|
||||||
{data.walletAddress}
|
|
||||||
</span>
|
|
||||||
{data.walletLabel && ` (${data.walletLabel})`}
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Alert rules configured:{" "}
|
|
||||||
<span className="text-white">
|
|
||||||
{alertCount > 0 ? `${alertCount} rule${alertCount !== 1 ? "s" : ""}` : "None (skipped)"}
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Notification channels:{" "}
|
|
||||||
<span className="text-white">
|
|
||||||
{hasNotif ? "Configured" : "Not set up (skipped)"}
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{hasNotif && (
|
|
||||||
<div className="mb-lg">
|
|
||||||
<Button
|
|
||||||
onClick={handleTestChannels}
|
|
||||||
disabled={testLoading}
|
|
||||||
variant="ghost"
|
|
||||||
>
|
|
||||||
{testLoading ? "Testing..." : "Test All Channels"}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{testResults && (
|
|
||||||
<div className="mt-md">
|
|
||||||
{testResults.error ? (
|
|
||||||
<p className="text-error">{testResults.error}</p>
|
|
||||||
) : (
|
|
||||||
<ul className="list-unstyled">
|
|
||||||
{Object.entries(testResults).map(([channel, result]) => (
|
|
||||||
<li
|
|
||||||
key={channel}
|
|
||||||
className={`test-result ${result.success ? "test-result--success" : "test-result--failure"}`}
|
|
||||||
>
|
|
||||||
{result.success ? "\u2713" : "\u2717"} {channel}:{" "}
|
|
||||||
{result.message || (result.success ? "OK" : "Failed")}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button onClick={() => navigate("/addresses")} className="btn--lg text-bold">
|
|
||||||
Go to Dashboard →
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Shared helpers ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function CheckboxRow({ checked, onChange, label, children }) {
|
|
||||||
return (
|
|
||||||
<div className="checkbox-row">
|
|
||||||
<label className="checkbox-row__label">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={checked}
|
|
||||||
onChange={(e) => onChange(e.target.checked)}
|
|
||||||
className="checkbox-row__input"
|
|
||||||
/>
|
|
||||||
{label}
|
|
||||||
</label>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Footer navigation ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function Footer() {
|
|
||||||
if (step === 5) return null;
|
|
||||||
|
|
||||||
const canSkip = step === 3 || step === 4;
|
|
||||||
const canBack = step > 1;
|
|
||||||
|
|
||||||
async function handleNext() {
|
|
||||||
setSkipWarning("");
|
|
||||||
if (step === 1) await handleStep1();
|
|
||||||
else if (step === 2) await handleStep2();
|
|
||||||
else if (step === 3) await handleStep3();
|
|
||||||
else if (step === 4) await handleStep4();
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSkip() {
|
|
||||||
setError("");
|
|
||||||
setSkipWarning("");
|
|
||||||
setStep((s) => s + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleBack() {
|
|
||||||
setError("");
|
|
||||||
setSkipWarning("");
|
|
||||||
setStep((s) => s - 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="onboarding__footer">
|
|
||||||
<div>
|
|
||||||
{canBack && (
|
|
||||||
<Button
|
|
||||||
onClick={handleBack}
|
|
||||||
disabled={loading}
|
|
||||||
variant="ghost"
|
|
||||||
>
|
|
||||||
← Back
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-md">
|
|
||||||
{canSkip && (
|
|
||||||
<Button
|
|
||||||
onClick={handleSkip}
|
|
||||||
disabled={loading}
|
|
||||||
variant="ghost"
|
|
||||||
>
|
|
||||||
Skip for now
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
onClick={handleNext}
|
|
||||||
disabled={loading}
|
|
||||||
className="text-bold"
|
|
||||||
>
|
|
||||||
{loading ? "Please wait..." : step === 4 ? "Finish" : "Next →"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Render ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const stepContent = {
|
|
||||||
1: <Step1 />,
|
|
||||||
2: <Step2 />,
|
|
||||||
3: <Step3 />,
|
|
||||||
4: <Step4 />,
|
|
||||||
5: <Step5 />,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="onboarding">
|
|
||||||
<div className="onboarding__container">
|
|
||||||
<h1 className="onboarding__title">Koin Ping</h1>
|
|
||||||
|
|
||||||
<ProgressBar />
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="alert alert--error">{error}</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{skipWarning && (
|
|
||||||
<div className="alert alert--warning">{skipWarning}</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="onboarding__card">
|
|
||||||
{stepContent[step]}
|
|
||||||
<Footer />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{step === 1 && (
|
|
||||||
<p className="onboarding__login-link">
|
|
||||||
Already have an account?{" "}
|
|
||||||
<a href="/login">Log in here</a>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Navigate } from "react-router-dom";
|
import { Navigate } from "react-router-dom";
|
||||||
|
|
||||||
export default function Signup() {
|
export default function Signup() {
|
||||||
return <Navigate to="/onboarding" replace />;
|
return <Navigate to="/subscribe" replace />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,3 +28,13 @@
|
|||||||
margin-left: 1rem;
|
margin-left: 1rem;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Addresses Responsive ────────────────────────────────── */
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.address__remove {
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
159
frontend/src/pages/addresses/Addresses.jsx
Normal file
159
frontend/src/pages/addresses/Addresses.jsx
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import AddressForm from "../../components/AddressForm";
|
||||||
|
import { getAddresses, createAddress, deleteAddress, updateAddress } from "../../api/addresses";
|
||||||
|
import "./Addresses.css";
|
||||||
|
|
||||||
|
export default function Addresses() {
|
||||||
|
const [addresses, setAddresses] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [editingId, setEditingId] = useState(null);
|
||||||
|
const [editLabel, setEditLabel] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchAddresses() {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const data = await getAddresses();
|
||||||
|
setAddresses(data);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
console.error("Failed to fetch addresses:", err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchAddresses();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function handleAddressSubmit(data) {
|
||||||
|
try {
|
||||||
|
const newAddress = await createAddress(data);
|
||||||
|
setAddresses((prev) => [...prev, newAddress]);
|
||||||
|
setError(null);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
console.error("Failed to create address:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(id, label) {
|
||||||
|
const displayName = label || "this address";
|
||||||
|
if (!window.confirm(`Remove "${displayName}"? This will also delete all associated alert rules.`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await deleteAddress(id);
|
||||||
|
setAddresses((prev) => prev.filter((a) => a.id !== id));
|
||||||
|
setError(null);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
console.error("Failed to delete address:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEditStart(addr) {
|
||||||
|
setEditingId(addr.id);
|
||||||
|
setEditLabel(addr.label ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleEditSave(id) {
|
||||||
|
try {
|
||||||
|
const updated = await updateAddress(id, { label: editLabel || null });
|
||||||
|
setAddresses((prev) => prev.map((a) => (a.id === id ? updated : a)));
|
||||||
|
setEditingId(null);
|
||||||
|
setEditLabel("");
|
||||||
|
setError(null);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
console.error("Failed to update address:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEditCancel() {
|
||||||
|
setEditingId(null);
|
||||||
|
setEditLabel("");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<h1>Add Addresses to Track</h1>
|
||||||
|
|
||||||
|
<div className="mb-xl">
|
||||||
|
<AddressForm onSubmit={handleAddressSubmit} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2>Existing Tracked Addresses</h2>
|
||||||
|
{loading && <p>Loading addresses...</p>}
|
||||||
|
{error && <p className="text-error">Error: {error}</p>}
|
||||||
|
{!loading && !error && addresses.length === 0 && (
|
||||||
|
<p className="text-dimmed">
|
||||||
|
No addresses tracked yet. Add one above to get started.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{addresses.length > 0 && (
|
||||||
|
<ul className="list-unstyled">
|
||||||
|
{addresses.map((addr, index) => (
|
||||||
|
<li key={addr.id || index} className="list-item--card">
|
||||||
|
<div className="flex flex--between flex--center">
|
||||||
|
<div>
|
||||||
|
{editingId === addr.id ? (
|
||||||
|
<div className="flex flex--center gap-sm mb-sm">
|
||||||
|
<input
|
||||||
|
value={editLabel}
|
||||||
|
onChange={(e) => setEditLabel(e.target.value)}
|
||||||
|
placeholder="Label (optional)"
|
||||||
|
className="address__edit-input"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") handleEditSave(addr.id);
|
||||||
|
if (e.key === "Escape") handleEditCancel();
|
||||||
|
}}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => handleEditSave(addr.id)}
|
||||||
|
className="btn btn--primary btn--sm"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleEditCancel}
|
||||||
|
className="btn btn--ghost btn--sm"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex--center gap-sm mb-sm">
|
||||||
|
<span className="text-bold">
|
||||||
|
{addr.label || "Unlabeled"}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => handleEditStart(addr)}
|
||||||
|
className="address__edit-link"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="text-mono text-sm text-muted">
|
||||||
|
{addr.address}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(addr.id, addr.label)}
|
||||||
|
className="address__remove"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
67
frontend/src/pages/alertHistory/AlertHistory.jsx
Normal file
67
frontend/src/pages/alertHistory/AlertHistory.jsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { getAlertEvents } from "../../api/alertEvents";
|
||||||
|
import "./AlertHistory.css";
|
||||||
|
|
||||||
|
export default function AlertHistory() {
|
||||||
|
const [alertEvents, setAlertEvents] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchAlertEvents() {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const data = await getAlertEvents();
|
||||||
|
setAlertEvents(data);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
console.error("Failed to fetch alert events:", err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchAlertEvents();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="page">Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="page text-error">Error: {error}</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<h1>Recent Alert Events</h1>
|
||||||
|
|
||||||
|
{alertEvents.length === 0 ? (
|
||||||
|
<p className="text-dimmed">No alerts yet</p>
|
||||||
|
) : (
|
||||||
|
<ul className="list-unstyled">
|
||||||
|
{alertEvents.map((event) => (
|
||||||
|
<li key={event.id} className="alert-history__item">
|
||||||
|
<div className="mb-sm">{event.message}</div>
|
||||||
|
{event.address_label && (
|
||||||
|
<div className="text-sm text-dimmed">
|
||||||
|
Address: {event.address_label}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<small className="text-muted">
|
||||||
|
{formatTimestamp(event.timestamp)}
|
||||||
|
</small>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimestamp(timestamp) {
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
return date.toLocaleString();
|
||||||
|
}
|
||||||
@@ -61,3 +61,54 @@
|
|||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Alerts Page Responsive ──────────────────────────────── */
|
||||||
|
|
||||||
|
/* Laptop: tighten the gap */
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.alerts-grid {
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tablet & below: collapse to single column */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.alerts-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 2.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile: compact spacing */
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.alerts-grid {
|
||||||
|
gap: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alerts__address-info {
|
||||||
|
padding: 0.75rem;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alerts__rule {
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alerts__rule .flex--between {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alerts__save-test {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alerts__save-test .btn--lg {
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alerts__email-buttons {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
522
frontend/src/pages/alerts/Alerts.jsx
Normal file
522
frontend/src/pages/alerts/Alerts.jsx
Normal file
@@ -0,0 +1,522 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import AlertForm from "../../components/AlertForm";
|
||||||
|
import Button from "../../components/Button";
|
||||||
|
import Input from "../../components/Input";
|
||||||
|
import { getAddresses } from "../../api/addresses";
|
||||||
|
import {
|
||||||
|
getAlerts,
|
||||||
|
createAlert,
|
||||||
|
updateAlertStatus,
|
||||||
|
deleteAlert,
|
||||||
|
} from "../../api/alerts";
|
||||||
|
import {
|
||||||
|
getNotificationConfig,
|
||||||
|
updateNotificationConfig,
|
||||||
|
testNotificationChannels,
|
||||||
|
setupEmail,
|
||||||
|
sendEmailDigest,
|
||||||
|
} from "../../api/notificationConfig";
|
||||||
|
import "./Alerts.css";
|
||||||
|
|
||||||
|
export default function Alerts() {
|
||||||
|
const [addresses, setAddresses] = useState([]);
|
||||||
|
const [selectedAddressId, setSelectedAddressId] = useState(null);
|
||||||
|
const [alerts, setAlerts] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
const [notificationEnabled, setNotificationEnabled] = useState(false);
|
||||||
|
const [discordWebhookUrl, setDiscordWebhookUrl] = useState("");
|
||||||
|
const [telegramBotToken, setTelegramBotToken] = useState("");
|
||||||
|
const [telegramChatId, setTelegramChatId] = useState("");
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [slackWebhookUrl, setSlackWebhookUrl] = useState("");
|
||||||
|
const [notificationLoading, setNotificationLoading] = useState(false);
|
||||||
|
const [notificationError, setNotificationError] = useState(null);
|
||||||
|
const [notificationSuccess, setNotificationSuccess] = useState(null);
|
||||||
|
const [testingChannels, setTestingChannels] = useState(false);
|
||||||
|
const [settingUpEmail, setSettingUpEmail] = useState(false);
|
||||||
|
const [sendingDigest, setSendingDigest] = useState(false);
|
||||||
|
const [hasExistingConfig, setHasExistingConfig] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchData() {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
const addressData = await getAddresses();
|
||||||
|
setAddresses(addressData);
|
||||||
|
if (addressData.length > 0) {
|
||||||
|
setSelectedAddressId(addressData[0].id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const configData = await getNotificationConfig();
|
||||||
|
setNotificationEnabled(
|
||||||
|
configData.notification_enabled !== false,
|
||||||
|
);
|
||||||
|
setDiscordWebhookUrl(configData.discord_webhook_url || "");
|
||||||
|
setTelegramBotToken(configData.telegram_bot_token || "");
|
||||||
|
setTelegramChatId(configData.telegram_chat_id || "");
|
||||||
|
setEmail(configData.email || "");
|
||||||
|
setSlackWebhookUrl(configData.slack_webhook_url || "");
|
||||||
|
|
||||||
|
const hasSaved =
|
||||||
|
!!configData.discord_webhook_url ||
|
||||||
|
!!configData.telegram_bot_token ||
|
||||||
|
!!configData.telegram_chat_id ||
|
||||||
|
!!configData.email ||
|
||||||
|
!!configData.slack_webhook_url;
|
||||||
|
setHasExistingConfig(hasSaved);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
console.error("Failed to fetch data:", err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedAddressId) {
|
||||||
|
setAlerts([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAlerts() {
|
||||||
|
try {
|
||||||
|
const data = await getAlerts(selectedAddressId);
|
||||||
|
setAlerts(data);
|
||||||
|
setError(null);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
console.error("Failed to fetch alerts:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchAlerts();
|
||||||
|
}, [selectedAddressId]);
|
||||||
|
|
||||||
|
async function handleAlertSubmit(data) {
|
||||||
|
if (!selectedAddressId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const newAlert = await createAlert(selectedAddressId, data);
|
||||||
|
setAlerts((prev) => [...prev, newAlert]);
|
||||||
|
setError(null);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
console.error("Failed to create alert:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleToggleAlert(alertId, currentStatus) {
|
||||||
|
try {
|
||||||
|
const updated = await updateAlertStatus(alertId, !currentStatus);
|
||||||
|
setAlerts((prev) =>
|
||||||
|
prev.map((alert) => (alert.id === alertId ? updated : alert)),
|
||||||
|
);
|
||||||
|
setError(null);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
console.error("Failed to update alert:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteAlert(alertId) {
|
||||||
|
try {
|
||||||
|
await deleteAlert(alertId);
|
||||||
|
setAlerts((prev) => prev.filter((alert) => alert.id !== alertId));
|
||||||
|
setError(null);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
console.error("Failed to delete alert:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSaveNotificationConfig() {
|
||||||
|
if (hasExistingConfig) {
|
||||||
|
const confirmed = window.confirm(
|
||||||
|
"This will overwrite your previously saved notification settings. Continue?",
|
||||||
|
);
|
||||||
|
if (!confirmed) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setNotificationLoading(true);
|
||||||
|
setNotificationError(null);
|
||||||
|
setNotificationSuccess(null);
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
notification_enabled: notificationEnabled,
|
||||||
|
discord_webhook_url: discordWebhookUrl || null,
|
||||||
|
telegram_bot_token: telegramBotToken || null,
|
||||||
|
telegram_chat_id: telegramChatId || null,
|
||||||
|
email: email || null,
|
||||||
|
slack_webhook_url: slackWebhookUrl || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
await updateNotificationConfig(config);
|
||||||
|
setHasExistingConfig(true);
|
||||||
|
setNotificationSuccess("Notification settings saved!");
|
||||||
|
setTimeout(() => setNotificationSuccess(null), 3000);
|
||||||
|
} catch (err) {
|
||||||
|
setNotificationError(err.message);
|
||||||
|
console.error("Failed to save notification config:", err);
|
||||||
|
} finally {
|
||||||
|
setNotificationLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleTestChannels() {
|
||||||
|
try {
|
||||||
|
setTestingChannels(true);
|
||||||
|
setNotificationError(null);
|
||||||
|
setNotificationSuccess(null);
|
||||||
|
|
||||||
|
const data = await testNotificationChannels();
|
||||||
|
const results = data.results || [];
|
||||||
|
|
||||||
|
const failed = results.filter((r) => !r.success);
|
||||||
|
const succeeded = results.filter((r) => r.success);
|
||||||
|
|
||||||
|
if (failed.length === 0 && succeeded.length > 0) {
|
||||||
|
setNotificationSuccess(
|
||||||
|
`Test sent to: ${succeeded.map((r) => r.channel).join(", ")}`,
|
||||||
|
);
|
||||||
|
} else if (failed.length > 0 && succeeded.length > 0) {
|
||||||
|
setNotificationSuccess(
|
||||||
|
`Sent: ${succeeded.map((r) => r.channel).join(", ")}. Failed: ${failed.map((r) => `${r.channel} (${r.error})`).join(", ")}`,
|
||||||
|
);
|
||||||
|
} else if (failed.length > 0) {
|
||||||
|
setNotificationError(
|
||||||
|
`Test failed: ${failed.map((r) => `${r.channel} (${r.error})`).join(", ")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
setNotificationSuccess(null);
|
||||||
|
setNotificationError(null);
|
||||||
|
}, 6000);
|
||||||
|
} catch (err) {
|
||||||
|
setNotificationError(err.message);
|
||||||
|
} finally {
|
||||||
|
setTestingChannels(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSetupEmail() {
|
||||||
|
try {
|
||||||
|
setSettingUpEmail(true);
|
||||||
|
setNotificationError(null);
|
||||||
|
setNotificationSuccess(null);
|
||||||
|
|
||||||
|
await handleSaveNotificationConfig();
|
||||||
|
|
||||||
|
const result = await setupEmail();
|
||||||
|
setNotificationSuccess(
|
||||||
|
result.message || "Confirmation email sent!",
|
||||||
|
);
|
||||||
|
setTimeout(() => setNotificationSuccess(null), 5000);
|
||||||
|
} catch (err) {
|
||||||
|
setNotificationError(err.message);
|
||||||
|
} finally {
|
||||||
|
setSettingUpEmail(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSendDigest() {
|
||||||
|
try {
|
||||||
|
setSendingDigest(true);
|
||||||
|
setNotificationError(null);
|
||||||
|
setNotificationSuccess(null);
|
||||||
|
|
||||||
|
const result = await sendEmailDigest();
|
||||||
|
setNotificationSuccess(result.message || "Digest email sent!");
|
||||||
|
setTimeout(() => setNotificationSuccess(null), 5000);
|
||||||
|
} catch (err) {
|
||||||
|
setNotificationError(err.message);
|
||||||
|
} finally {
|
||||||
|
setSendingDigest(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedAddress = addresses.find((a) => a.id === selectedAddressId);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="page">Loading addresses...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (addresses.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<p>No addresses tracked yet. Add an address first to create alerts.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page page--wide">
|
||||||
|
<h1 className="mb-xl">Alert Rules & Notifications</h1>
|
||||||
|
|
||||||
|
<div className="alerts-grid">
|
||||||
|
{/* LEFT COLUMN: Alert Rules */}
|
||||||
|
<div>
|
||||||
|
<h2 className="mt-0">Alert Rules</h2>
|
||||||
|
|
||||||
|
<div className="mb-xl">
|
||||||
|
<label className="form-label mb-sm">
|
||||||
|
<strong>Select Address:</strong>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={selectedAddressId || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSelectedAddressId(Number(e.target.value))
|
||||||
|
}
|
||||||
|
className="form-select"
|
||||||
|
>
|
||||||
|
{addresses.map((addr) => (
|
||||||
|
<option key={addr.id} value={addr.id}>
|
||||||
|
{addr.label || "Unlabeled"} - {addr.address}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedAddress && (
|
||||||
|
<>
|
||||||
|
<div className="alerts__address-info">
|
||||||
|
<div className="text-sm text-muted">
|
||||||
|
Managing alerts for:
|
||||||
|
</div>
|
||||||
|
<div className="text-bold">
|
||||||
|
{selectedAddress.label || "Unlabeled"}
|
||||||
|
</div>
|
||||||
|
<div className="text-mono text-sm text-muted">
|
||||||
|
{selectedAddress.address}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-xl">
|
||||||
|
<h3>Create New Alert</h3>
|
||||||
|
<AlertForm onSubmit={handleAlertSubmit} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3>Active Alert Rules</h3>
|
||||||
|
{error && (
|
||||||
|
<p className="text-error">Error: {error}</p>
|
||||||
|
)}
|
||||||
|
{alerts.length === 0 ? (
|
||||||
|
<p className="text-dimmed">
|
||||||
|
No alert rules defined yet. Create one above.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="list-unstyled">
|
||||||
|
{alerts.map((alert) => (
|
||||||
|
<li
|
||||||
|
key={alert.id}
|
||||||
|
className={`alerts__rule ${!alert.enabled ? "alerts__rule--disabled" : ""}`}
|
||||||
|
>
|
||||||
|
<div className="flex flex--between">
|
||||||
|
<div>
|
||||||
|
<div className="text-bold mb-sm">
|
||||||
|
{formatAlertType(alert.type)}
|
||||||
|
</div>
|
||||||
|
{alert.threshold && (
|
||||||
|
<div className="text-sm text-muted">
|
||||||
|
Threshold: {alert.threshold} ETH
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="text-xs text-dimmed">
|
||||||
|
Status: {alert.enabled ? "Enabled" : "Disabled"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex--center gap-sm">
|
||||||
|
<Button
|
||||||
|
onClick={() =>
|
||||||
|
handleToggleAlert(alert.id, alert.enabled)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{alert.enabled ? "Disable" : "Enable"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => handleDeleteAlert(alert.id)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* RIGHT COLUMN: Notification Settings */}
|
||||||
|
<div>
|
||||||
|
<h2 className="mt-0">Notification Settings</h2>
|
||||||
|
|
||||||
|
{notificationSuccess && (
|
||||||
|
<div className="alert alert--success">{notificationSuccess}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{notificationError && (
|
||||||
|
<div className="alert alert--error">{notificationError}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Master toggle */}
|
||||||
|
<div className="alerts__toggle-panel">
|
||||||
|
<label className="alerts__toggle-label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={notificationEnabled}
|
||||||
|
onChange={(e) =>
|
||||||
|
setNotificationEnabled(e.target.checked)
|
||||||
|
}
|
||||||
|
className="alerts__toggle-checkbox"
|
||||||
|
/>
|
||||||
|
<span className="text-bold">Enable Notifications</span>
|
||||||
|
</label>
|
||||||
|
<div className="alerts__toggle-hint">
|
||||||
|
Master switch for all notification channels
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* All channel settings -- hidden when master toggle is off */}
|
||||||
|
{notificationEnabled && (
|
||||||
|
<>
|
||||||
|
{/* Telegram */}
|
||||||
|
<div className="section">
|
||||||
|
<h3 className="mt-0 mb-md">Telegram</h3>
|
||||||
|
<Input
|
||||||
|
label="Bot Token"
|
||||||
|
value={telegramBotToken}
|
||||||
|
onChange={setTelegramBotToken}
|
||||||
|
placeholder="123456789:ABCdefGHIjklMNOpqrSTUvwxYZ"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Chat ID"
|
||||||
|
value={telegramChatId}
|
||||||
|
onChange={setTelegramChatId}
|
||||||
|
placeholder="-1001234567890"
|
||||||
|
/>
|
||||||
|
<a
|
||||||
|
href="https://core.telegram.org/bots#how-do-i-create-a-bot"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="help-link"
|
||||||
|
>
|
||||||
|
How to create a Telegram bot & get your Chat ID
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Email */}
|
||||||
|
<div className="section">
|
||||||
|
<h3 className="mt-0 mb-md">Email</h3>
|
||||||
|
<Input
|
||||||
|
label="Email Address"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={setEmail}
|
||||||
|
placeholder="you@example.com"
|
||||||
|
/>
|
||||||
|
<div className="help-link text-dimmed mb-md">
|
||||||
|
Alert notifications and digests will be sent to this address
|
||||||
|
</div>
|
||||||
|
<div className="alerts__email-buttons">
|
||||||
|
<Button
|
||||||
|
onClick={handleSetupEmail}
|
||||||
|
disabled={settingUpEmail || !email}
|
||||||
|
className="btn--sm"
|
||||||
|
>
|
||||||
|
{settingUpEmail ? "Setting up..." : "Verify Email"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSendDigest}
|
||||||
|
disabled={sendingDigest || !email}
|
||||||
|
variant="secondary"
|
||||||
|
className="btn--sm"
|
||||||
|
>
|
||||||
|
{sendingDigest ? "Sending..." : "Send Digest Now"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Discord */}
|
||||||
|
<div className="section">
|
||||||
|
<h3 className="mt-0 mb-md">Discord</h3>
|
||||||
|
<Input
|
||||||
|
label="Discord Webhook URL"
|
||||||
|
value={discordWebhookUrl}
|
||||||
|
onChange={setDiscordWebhookUrl}
|
||||||
|
placeholder="https://discord.com/api/webhooks/..."
|
||||||
|
/>
|
||||||
|
<a
|
||||||
|
href="https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="help-link"
|
||||||
|
>
|
||||||
|
How to get a Discord webhook URL
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Slack */}
|
||||||
|
<div className="section">
|
||||||
|
<h3 className="mt-0 mb-md">Slack</h3>
|
||||||
|
<Input
|
||||||
|
label="Slack Webhook URL"
|
||||||
|
value={slackWebhookUrl}
|
||||||
|
onChange={setSlackWebhookUrl}
|
||||||
|
placeholder="https://hooks.slack.com/services/..."
|
||||||
|
/>
|
||||||
|
<a
|
||||||
|
href="https://api.slack.com/messaging/webhooks"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="help-link"
|
||||||
|
>
|
||||||
|
How to set up Slack Incoming Webhooks
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Save & Test buttons */}
|
||||||
|
<div className="alerts__save-test">
|
||||||
|
<Button
|
||||||
|
onClick={handleSaveNotificationConfig}
|
||||||
|
disabled={notificationLoading}
|
||||||
|
className="btn--lg"
|
||||||
|
>
|
||||||
|
{notificationLoading ? "Saving..." : "Save Settings"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleTestChannels}
|
||||||
|
disabled={testingChannels}
|
||||||
|
variant="success"
|
||||||
|
className="btn--lg"
|
||||||
|
>
|
||||||
|
{testingChannels ? "Testing..." : "Test All Channels"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAlertType(type) {
|
||||||
|
const labels = {
|
||||||
|
incoming_tx: "Incoming transaction",
|
||||||
|
outgoing_tx: "Outgoing transaction",
|
||||||
|
large_transfer: "Large transfer",
|
||||||
|
balance_below: "Balance below threshold",
|
||||||
|
};
|
||||||
|
return labels[type] || type;
|
||||||
|
}
|
||||||
116
frontend/src/pages/login/Login.css
Normal file
116
frontend/src/pages/login/Login.css
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
.login-page {
|
||||||
|
position: relative;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
background-color: #000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-bg-video {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
min-width: 100%;
|
||||||
|
min-height: 100%;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
object-fit: cover;
|
||||||
|
z-index: 0;
|
||||||
|
filter: grayscale(85%);
|
||||||
|
-webkit-filter: grayscale(85%);
|
||||||
|
opacity: 0.05;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
max-width: 400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
padding-top: 8rem;
|
||||||
|
border: 1px solid var(--color-border-light);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
background-color: rgba(0, 0, 0, 0.75);
|
||||||
|
top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-heading {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
position: relative;
|
||||||
|
top: -20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-brand {
|
||||||
|
color: var(--color-brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-button {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-footer {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-signup-link {
|
||||||
|
color: var(--color-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card-fadein {
|
||||||
|
opacity: 0;
|
||||||
|
animation: fadeIn 2s ease-in-out forwards;
|
||||||
|
animation-delay: 3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-tagline {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
max-width: 630px;
|
||||||
|
margin: 5rem auto 0;
|
||||||
|
padding-left: 2rem;
|
||||||
|
font-style: italic;
|
||||||
|
color: #FFFFFF;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-interactive-fadein {
|
||||||
|
opacity: 0;
|
||||||
|
animation: fadeIn 2s ease-in-out forwards;
|
||||||
|
animation-delay: 6s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Login Responsive ────────────────────────────────────── */
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.login-card {
|
||||||
|
max-width: 100%;
|
||||||
|
margin: 0 0.75rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
padding-top: 6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-tagline {
|
||||||
|
margin-top: 3rem;
|
||||||
|
padding-left: 1rem;
|
||||||
|
padding-right: 1rem;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
import { useAuth } from "../contexts/AuthContext";
|
import { useAuth } from "../../contexts/AuthContext";
|
||||||
import Input from "../components/Input";
|
import Input from "../../components/Input";
|
||||||
import "./Login.css";
|
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("");
|
||||||
@@ -36,12 +35,22 @@ export default function Login() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="login-page">
|
<div className="login-page">
|
||||||
<div className="login-card" onMouseEnter={() => setIsVisible(true)}>
|
<video
|
||||||
|
autoPlay
|
||||||
|
loop
|
||||||
|
muted
|
||||||
|
playsInline
|
||||||
|
className="login-bg-video"
|
||||||
|
>
|
||||||
|
<source src="/koin_spin.mp4" type="video/mp4" />
|
||||||
|
</video>
|
||||||
|
|
||||||
|
<div className="login-card login-card-fadein">
|
||||||
<h1 className="login-heading">
|
<h1 className="login-heading">
|
||||||
<span className="login-brand">Koin Ping</span> - Login
|
<span className="login-brand">Koin Ping</span> - Login
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<div className={isVisible ? "login-form-visible" : "login-form-hidden"}>
|
<div className="login-interactive-fadein">
|
||||||
{error && <div className="alert alert--error">{error}</div>}
|
{error && <div className="alert alert--error">{error}</div>}
|
||||||
|
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
@@ -78,6 +87,11 @@ export default function Login() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<p className="login-tagline login-card-fadein">
|
||||||
|
A lightweight, on-chain monitoring system giving users real-time
|
||||||
|
situational awareness over blockchain addresses.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
.onboarding {
|
.subscribe {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background-color: var(--color-bg);
|
background-color: var(--color-bg);
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -9,34 +9,34 @@
|
|||||||
padding-bottom: 3rem;
|
padding-bottom: 3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.onboarding__container {
|
.subscribe__container {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 540px;
|
max-width: 540px;
|
||||||
padding: 0 1rem;
|
padding: 0 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.onboarding__title {
|
.subscribe__title {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
color: var(--color-primary);
|
color: var(--color-primary);
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.onboarding__card {
|
.subscribe__card {
|
||||||
background-color: var(--color-bg-elevated);
|
background-color: var(--color-bg-elevated);
|
||||||
border: 1px solid var(--color-border-light);
|
border: 1px solid var(--color-border-light);
|
||||||
border-radius: var(--radius-xl);
|
border-radius: var(--radius-xl);
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.onboarding__login-link {
|
.subscribe__login-link {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin-top: 1.25rem;
|
margin-top: 1.25rem;
|
||||||
color: #888;
|
color: #888;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.onboarding__login-link a {
|
.subscribe__login-link a {
|
||||||
color: var(--color-primary);
|
color: var(--color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,7 +108,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Step 5 summary */
|
/* Step 5 summary */
|
||||||
.onboarding__summary {
|
.subscribe__summary {
|
||||||
background-color: #1e2e1e;
|
background-color: #1e2e1e;
|
||||||
border: 1px solid #2d5a2d;
|
border: 1px solid #2d5a2d;
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
@@ -116,13 +116,13 @@
|
|||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.onboarding__summary-title {
|
.subscribe__summary-title {
|
||||||
margin: 0 0 0.5rem;
|
margin: 0 0 0.5rem;
|
||||||
color: #90ee90;
|
color: #90ee90;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
.onboarding__summary-list {
|
.subscribe__summary-list {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding-left: 1.25rem;
|
padding-left: 1.25rem;
|
||||||
color: var(--color-text-label);
|
color: var(--color-text-label);
|
||||||
@@ -154,7 +154,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Footer navigation */
|
/* Footer navigation */
|
||||||
.onboarding__footer {
|
.subscribe__footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -178,8 +178,83 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Step subtitle */
|
/* Step subtitle */
|
||||||
.onboarding__subtitle {
|
.subscribe__subtitle {
|
||||||
color: #aaa;
|
color: #aaa;
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Subscribe card (Step 2) */
|
||||||
|
.subscribe-card {
|
||||||
|
background-color: var(--color-bg-card, #1a1a2e);
|
||||||
|
border: 1px solid var(--color-border-light);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 1.5rem;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subscribe-card__price {
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subscribe-card__amount {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subscribe-card__period {
|
||||||
|
font-size: 1rem;
|
||||||
|
color: #888;
|
||||||
|
margin-left: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subscribe-card__features {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0 0 1.25rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subscribe-card__features li {
|
||||||
|
padding: 0.35rem 0;
|
||||||
|
color: #ccc;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subscribe-card__features li::before {
|
||||||
|
content: "\2713";
|
||||||
|
color: var(--color-primary);
|
||||||
|
margin-right: 0.6rem;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subscribe-card__commitment {
|
||||||
|
color: #999;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
margin: 0;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Subscribe Responsive ────────────────────────────────── */
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.subscribe {
|
||||||
|
padding-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subscribe__card {
|
||||||
|
padding: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar__connector {
|
||||||
|
width: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar__dot {
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
679
frontend/src/pages/subscribe/subscribe.jsx
Normal file
679
frontend/src/pages/subscribe/subscribe.jsx
Normal file
@@ -0,0 +1,679 @@
|
|||||||
|
/**
|
||||||
|
* Subscribe / Subscribe Wizard
|
||||||
|
*
|
||||||
|
* 5-step guided flow: Create Account -> Add Wallet -> Alert Rules -> Notifications -> Done
|
||||||
|
* After account creation, user is redirected to Stripe Checkout for payment.
|
||||||
|
* On successful payment, they return here at step 2 (Add Wallet).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||||
|
import { useAuth } from "../../contexts/AuthContext";
|
||||||
|
import { createAddress, getAddresses } from "../../api/addresses";
|
||||||
|
import { createAlert } from "../../api/alerts";
|
||||||
|
import {
|
||||||
|
updateNotificationConfig,
|
||||||
|
testNotificationChannels,
|
||||||
|
} from "../../api/notificationConfig";
|
||||||
|
import { createCheckoutSession, getSubscriptionStatus, verifyCheckoutSession } from "../../api/stripe";
|
||||||
|
import Input from "../../components/Input";
|
||||||
|
import Button from "../../components/Button";
|
||||||
|
import "./Subscribe.css";
|
||||||
|
|
||||||
|
const STEPS = [
|
||||||
|
"Create Account",
|
||||||
|
"Add Wallet",
|
||||||
|
"Alert Rules",
|
||||||
|
"Notifications",
|
||||||
|
"Done",
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function Subscribe() {
|
||||||
|
const { currentUser, signup } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
|
||||||
|
const [step, setStep] = useState(1);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [skipWarning, setSkipWarning] = useState("");
|
||||||
|
const [testResults, setTestResults] = useState(null);
|
||||||
|
const [testLoading, setTestLoading] = useState(false);
|
||||||
|
|
||||||
|
const [data, setData] = useState({
|
||||||
|
email: "",
|
||||||
|
password: "",
|
||||||
|
confirmPassword: "",
|
||||||
|
walletAddress: "",
|
||||||
|
walletLabel: "",
|
||||||
|
createdAddressId: null,
|
||||||
|
alertIncomingTx: false,
|
||||||
|
alertOutgoingTx: false,
|
||||||
|
alertLargeTransfer: false,
|
||||||
|
largeTransferThreshold: "",
|
||||||
|
alertBalanceBelow: false,
|
||||||
|
balanceBelowThreshold: "",
|
||||||
|
discordWebhookUrl: "",
|
||||||
|
slackWebhookUrl: "",
|
||||||
|
notificationEmail: "",
|
||||||
|
alertsCreated: [],
|
||||||
|
notificationConfigured: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
function set(field, value) {
|
||||||
|
setData((prev) => ({ ...prev, [field]: value }));
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentUser) return;
|
||||||
|
getAddresses()
|
||||||
|
.then((addresses) => {
|
||||||
|
if (addresses.length > 0) {
|
||||||
|
navigate("/addresses", { replace: true });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => { });
|
||||||
|
}, [currentUser, navigate]);
|
||||||
|
|
||||||
|
// Handle Stripe redirect back from checkout
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentUser) return;
|
||||||
|
const payment = searchParams.get("payment");
|
||||||
|
const sessionId = searchParams.get("session_id");
|
||||||
|
if (payment === "success" && sessionId) {
|
||||||
|
setSearchParams({}, { replace: true });
|
||||||
|
setLoading(true);
|
||||||
|
verifyCheckoutSession(sessionId)
|
||||||
|
.then(() => {
|
||||||
|
setStep(2);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
setError("Payment verification failed: " + err.message);
|
||||||
|
setStep(1);
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
} else if (payment === "cancelled") {
|
||||||
|
setSearchParams({}, { replace: true });
|
||||||
|
setStep(1);
|
||||||
|
setError("Payment was cancelled. Please try again.");
|
||||||
|
}
|
||||||
|
}, [currentUser, searchParams, setSearchParams]);
|
||||||
|
|
||||||
|
// ── 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;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
if (!currentUser) {
|
||||||
|
await signup(data.email, data.password);
|
||||||
|
}
|
||||||
|
const status = await getSubscriptionStatus();
|
||||||
|
if (status.subscription_status === "active" || status.subscription_status === "trialing") {
|
||||||
|
setStep(2);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { url } = await createCheckoutSession();
|
||||||
|
window.location.href = url;
|
||||||
|
} 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleStep2() {
|
||||||
|
setError("");
|
||||||
|
if (!data.walletAddress) {
|
||||||
|
setError("Please enter a wallet address");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!/^0x[0-9a-fA-F]{40}$/.test(data.walletAddress)) {
|
||||||
|
setError("Invalid ETH address (must be 0x followed by 40 hex characters)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const created = await createAddress({
|
||||||
|
address: data.walletAddress,
|
||||||
|
label: data.walletLabel || undefined,
|
||||||
|
});
|
||||||
|
set("createdAddressId", created.id);
|
||||||
|
setStep(3);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleStep3() {
|
||||||
|
setError("");
|
||||||
|
const rules = [];
|
||||||
|
if (data.alertIncomingTx) rules.push({ type: "incoming_tx" });
|
||||||
|
if (data.alertOutgoingTx) rules.push({ type: "outgoing_tx" });
|
||||||
|
if (data.alertLargeTransfer) {
|
||||||
|
if (!data.largeTransferThreshold) {
|
||||||
|
setError("Please enter a threshold for large transfers");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
rules.push({ type: "large_transfer", threshold: data.largeTransferThreshold });
|
||||||
|
}
|
||||||
|
if (data.alertBalanceBelow) {
|
||||||
|
if (!data.balanceBelowThreshold) {
|
||||||
|
setError("Please enter a threshold for balance below");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
rules.push({ type: "balance_below", threshold: data.balanceBelowThreshold });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rules.length === 0) {
|
||||||
|
setStep(4);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const created = [];
|
||||||
|
for (const rule of rules) {
|
||||||
|
const result = await createAlert(data.createdAddressId, rule);
|
||||||
|
created.push(result);
|
||||||
|
}
|
||||||
|
set("alertsCreated", created);
|
||||||
|
setStep(4);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleStep4() {
|
||||||
|
setError("");
|
||||||
|
const hasAny =
|
||||||
|
data.discordWebhookUrl || data.slackWebhookUrl || data.notificationEmail;
|
||||||
|
if (!hasAny) {
|
||||||
|
setStep(5);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
await updateNotificationConfig({
|
||||||
|
notification_enabled: true,
|
||||||
|
discord_webhook_url: data.discordWebhookUrl || undefined,
|
||||||
|
slack_webhook_url: data.slackWebhookUrl || undefined,
|
||||||
|
email: data.notificationEmail || undefined,
|
||||||
|
});
|
||||||
|
set("notificationConfigured", true);
|
||||||
|
setStep(5);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleTestChannels() {
|
||||||
|
setTestLoading(true);
|
||||||
|
setTestResults(null);
|
||||||
|
try {
|
||||||
|
const results = await testNotificationChannels();
|
||||||
|
setTestResults(results);
|
||||||
|
} catch (err) {
|
||||||
|
setTestResults({ error: err.message });
|
||||||
|
} finally {
|
||||||
|
setTestLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Progress bar ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function ProgressBar() {
|
||||||
|
return (
|
||||||
|
<div className="progress-bar">
|
||||||
|
{STEPS.map((label, i) => {
|
||||||
|
const stepNum = i + 1;
|
||||||
|
const done = step > stepNum;
|
||||||
|
const active = step === stepNum;
|
||||||
|
const dotClass = done
|
||||||
|
? "progress-bar__dot--done"
|
||||||
|
: active
|
||||||
|
? "progress-bar__dot--active"
|
||||||
|
: "progress-bar__dot--pending";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={label} className="progress-bar__step">
|
||||||
|
{i > 0 && (
|
||||||
|
<div
|
||||||
|
className={`progress-bar__connector ${done || active
|
||||||
|
? "progress-bar__connector--active"
|
||||||
|
: "progress-bar__connector--inactive"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<div className={`progress-bar__dot ${dotClass}`}>
|
||||||
|
{done ? "\u2713" : stepNum}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`progress-bar__label ${active
|
||||||
|
? "progress-bar__label--active"
|
||||||
|
: "progress-bar__label--inactive"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step content ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function Step1() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h2 className="mb-lg">Create your account</h2>
|
||||||
|
<Input
|
||||||
|
label="Email"
|
||||||
|
type="email"
|
||||||
|
value={data.email}
|
||||||
|
onChange={(v) => set("email", v)}
|
||||||
|
disabled={loading}
|
||||||
|
placeholder="you@example.com"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Password"
|
||||||
|
type="password"
|
||||||
|
value={data.password}
|
||||||
|
onChange={(v) => set("password", v)}
|
||||||
|
disabled={loading}
|
||||||
|
placeholder="At least 6 characters"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Confirm Password"
|
||||||
|
type="password"
|
||||||
|
value={data.confirmPassword}
|
||||||
|
onChange={(v) => set("confirmPassword", v)}
|
||||||
|
disabled={loading}
|
||||||
|
placeholder="Repeat your password"
|
||||||
|
className="form-field--last"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Step2() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h2 className="mb-sm">Add a wallet address</h2>
|
||||||
|
<p className="subscribe__subtitle">
|
||||||
|
Enter the Ethereum address you want to monitor.
|
||||||
|
</p>
|
||||||
|
<Input
|
||||||
|
label="ETH Address"
|
||||||
|
value={data.walletAddress}
|
||||||
|
onChange={(v) => set("walletAddress", v)}
|
||||||
|
disabled={loading}
|
||||||
|
placeholder="0x..."
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Label (optional)"
|
||||||
|
value={data.walletLabel}
|
||||||
|
onChange={(v) => set("walletLabel", v)}
|
||||||
|
disabled={loading}
|
||||||
|
placeholder="e.g. My main wallet"
|
||||||
|
className="form-field--last"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Step3() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h2 className="mb-sm">Configure alert rules</h2>
|
||||||
|
<p className="subscribe__subtitle">
|
||||||
|
Choose which events trigger notifications. You can change these later.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<CheckboxRow
|
||||||
|
checked={data.alertIncomingTx}
|
||||||
|
onChange={(v) => set("alertIncomingTx", v)}
|
||||||
|
label="Incoming transaction"
|
||||||
|
/>
|
||||||
|
<CheckboxRow
|
||||||
|
checked={data.alertOutgoingTx}
|
||||||
|
onChange={(v) => set("alertOutgoingTx", v)}
|
||||||
|
label="Outgoing transaction"
|
||||||
|
/>
|
||||||
|
<CheckboxRow
|
||||||
|
checked={data.alertLargeTransfer}
|
||||||
|
onChange={(v) => set("alertLargeTransfer", v)}
|
||||||
|
label="Large transfer"
|
||||||
|
>
|
||||||
|
{data.alertLargeTransfer && (
|
||||||
|
<div className="checkbox-row__nested">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
label=""
|
||||||
|
value={data.largeTransferThreshold}
|
||||||
|
onChange={(v) => set("largeTransferThreshold", v)}
|
||||||
|
placeholder="Threshold (ETH)"
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CheckboxRow>
|
||||||
|
<CheckboxRow
|
||||||
|
checked={data.alertBalanceBelow}
|
||||||
|
onChange={(v) => set("alertBalanceBelow", v)}
|
||||||
|
label="Balance below"
|
||||||
|
>
|
||||||
|
{data.alertBalanceBelow && (
|
||||||
|
<div className="checkbox-row__nested">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
label=""
|
||||||
|
value={data.balanceBelowThreshold}
|
||||||
|
onChange={(v) => set("balanceBelowThreshold", v)}
|
||||||
|
placeholder="Threshold (ETH)"
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CheckboxRow>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Step4() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h2 className="mb-sm">Set up notifications</h2>
|
||||||
|
<p className="subscribe__subtitle">
|
||||||
|
Add at least one channel so you receive alerts. All fields are optional.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mb-md">
|
||||||
|
<label className="form-label">
|
||||||
|
Discord Webhook URL{" "}
|
||||||
|
<a
|
||||||
|
href="https://support.discord.com/hc/en-us/articles/228383668"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="help-link"
|
||||||
|
>
|
||||||
|
(how to get one)
|
||||||
|
</a>
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
label=""
|
||||||
|
type="url"
|
||||||
|
value={data.discordWebhookUrl}
|
||||||
|
onChange={(v) => set("discordWebhookUrl", v)}
|
||||||
|
disabled={loading}
|
||||||
|
placeholder="https://discord.com/api/webhooks/..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-md">
|
||||||
|
<label className="form-label">
|
||||||
|
Slack Webhook URL{" "}
|
||||||
|
<a
|
||||||
|
href="https://api.slack.com/messaging/webhooks"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="help-link"
|
||||||
|
>
|
||||||
|
(how to get one)
|
||||||
|
</a>
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
label=""
|
||||||
|
type="url"
|
||||||
|
value={data.slackWebhookUrl}
|
||||||
|
onChange={(v) => set("slackWebhookUrl", v)}
|
||||||
|
disabled={loading}
|
||||||
|
placeholder="https://hooks.slack.com/services/..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Email address for alerts"
|
||||||
|
type="email"
|
||||||
|
value={data.notificationEmail}
|
||||||
|
onChange={(v) => set("notificationEmail", v)}
|
||||||
|
disabled={loading}
|
||||||
|
placeholder="you@example.com"
|
||||||
|
className="form-field--last"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Step5() {
|
||||||
|
const alertCount = data.alertsCreated.length;
|
||||||
|
const hasNotif = data.notificationConfigured;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h2 className="mb-md">You're all set!</h2>
|
||||||
|
|
||||||
|
<div className="subscribe__summary">
|
||||||
|
<p className="subscribe__summary-title">Summary</p>
|
||||||
|
<ul className="subscribe__summary-list">
|
||||||
|
<li>
|
||||||
|
Wallet address added:{" "}
|
||||||
|
<span className="text-mono text-white-sm">
|
||||||
|
{data.walletAddress}
|
||||||
|
</span>
|
||||||
|
{data.walletLabel && ` (${data.walletLabel})`}
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Alert rules configured:{" "}
|
||||||
|
<span className="text-white">
|
||||||
|
{alertCount > 0 ? `${alertCount} rule${alertCount !== 1 ? "s" : ""}` : "None (skipped)"}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Notification channels:{" "}
|
||||||
|
<span className="text-white">
|
||||||
|
{hasNotif ? "Configured" : "Not set up (skipped)"}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasNotif && (
|
||||||
|
<div className="mb-lg">
|
||||||
|
<Button
|
||||||
|
onClick={handleTestChannels}
|
||||||
|
disabled={testLoading}
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
{testLoading ? "Testing..." : "Test All Channels"}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{testResults && (
|
||||||
|
<div className="mt-md">
|
||||||
|
{testResults.error ? (
|
||||||
|
<p className="text-error">{testResults.error}</p>
|
||||||
|
) : (
|
||||||
|
<ul className="list-unstyled">
|
||||||
|
{Object.entries(testResults).map(([channel, result]) => (
|
||||||
|
<li
|
||||||
|
key={channel}
|
||||||
|
className={`test-result ${result.success ? "test-result--success" : "test-result--failure"}`}
|
||||||
|
>
|
||||||
|
{result.success ? "\u2713" : "\u2717"} {channel}:{" "}
|
||||||
|
{result.message || (result.success ? "OK" : "Failed")}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button onClick={() => navigate("/addresses")} className="btn--lg text-bold">
|
||||||
|
Go to Dashboard →
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Shared helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function CheckboxRow({ checked, onChange, label, children }) {
|
||||||
|
return (
|
||||||
|
<div className="checkbox-row">
|
||||||
|
<label className="checkbox-row__label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
onChange={(e) => onChange(e.target.checked)}
|
||||||
|
className="checkbox-row__input"
|
||||||
|
/>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Footer navigation ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function Footer() {
|
||||||
|
if (step === 5) return null;
|
||||||
|
|
||||||
|
const canSkip = step === 3 || step === 4;
|
||||||
|
const canBack = step > 2;
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextLabel = step === 1
|
||||||
|
? "Create Account & Subscribe"
|
||||||
|
: step === 4
|
||||||
|
? "Finish"
|
||||||
|
: "Next →";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="subscribe__footer">
|
||||||
|
<div>
|
||||||
|
{canBack && (
|
||||||
|
<Button
|
||||||
|
onClick={handleBack}
|
||||||
|
disabled={loading}
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
← Back
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-md">
|
||||||
|
{canSkip && (
|
||||||
|
<Button
|
||||||
|
onClick={handleSkip}
|
||||||
|
disabled={loading}
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
Skip for now
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
onClick={handleNext}
|
||||||
|
disabled={loading}
|
||||||
|
className="text-bold"
|
||||||
|
>
|
||||||
|
{loading ? "Please wait..." : nextLabel}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Render ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const stepContent = {
|
||||||
|
1: Step1(),
|
||||||
|
2: Step2(),
|
||||||
|
3: Step3(),
|
||||||
|
4: Step4(),
|
||||||
|
5: Step5(),
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="subscribe">
|
||||||
|
<div className="subscribe__container">
|
||||||
|
<h1 className="subscribe__title">Koin Ping</h1>
|
||||||
|
|
||||||
|
{ProgressBar()}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="alert alert--error">{error}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{skipWarning && (
|
||||||
|
<div className="alert alert--warning">{skipWarning}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="subscribe__card">
|
||||||
|
{stepContent[step]}
|
||||||
|
{Footer()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{step === 1 && (
|
||||||
|
<p className="subscribe__login-link">
|
||||||
|
Already have an account?{" "}
|
||||||
|
<a href="/login">Log in here</a>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
77
frontend/src/pages/user_account/Account.css
Normal file
77
frontend/src/pages/user_account/Account.css
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
.account-page h1 {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account__section-title {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 400;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
color: var(--color-text);
|
||||||
|
border-bottom: 1px solid var(--color-border-light);
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account__row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.6rem 0;
|
||||||
|
border-bottom: 1px solid var(--color-border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.account__row--last {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account__label {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account__value {
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account__uuid {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--color-text-dimmed);
|
||||||
|
word-break: break-all;
|
||||||
|
max-width: 60%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account__status {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account__status--active {
|
||||||
|
color: var(--color-success-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.account__status--canceled {
|
||||||
|
color: var(--color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.account__status--canceling {
|
||||||
|
color: var(--color-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.account__status--past_due {
|
||||||
|
color: var(--color-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.account__portal-section {
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
padding-top: 1rem;
|
||||||
|
border-top: 1px solid var(--color-border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.account__portal-hint {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account__password-form {
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
202
frontend/src/pages/user_account/Account.jsx
Normal file
202
frontend/src/pages/user_account/Account.jsx
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { updatePassword } from "firebase/auth";
|
||||||
|
import { auth } from "../../firebase/config";
|
||||||
|
import { getAccount, createPortalSession } from "../../api/account";
|
||||||
|
import "./Account.css";
|
||||||
|
|
||||||
|
export default function Account() {
|
||||||
|
const [account, setAccount] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
const [newPassword, setNewPassword] = useState("");
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
|
const [passwordMsg, setPasswordMsg] = useState(null);
|
||||||
|
const [passwordErr, setPasswordErr] = useState(null);
|
||||||
|
const [changingPassword, setChangingPassword] = useState(false);
|
||||||
|
|
||||||
|
const [portalLoading, setPortalLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchAccount() {
|
||||||
|
try {
|
||||||
|
const data = await getAccount();
|
||||||
|
setAccount(data);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchAccount();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function handlePasswordChange(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
setPasswordMsg(null);
|
||||||
|
setPasswordErr(null);
|
||||||
|
|
||||||
|
if (newPassword.length < 6) {
|
||||||
|
setPasswordErr("Password must be at least 6 characters");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
setPasswordErr("Passwords do not match");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setChangingPassword(true);
|
||||||
|
await updatePassword(auth.currentUser, newPassword);
|
||||||
|
setPasswordMsg("Password updated successfully");
|
||||||
|
setNewPassword("");
|
||||||
|
setConfirmPassword("");
|
||||||
|
} catch (err) {
|
||||||
|
setPasswordErr(err.message);
|
||||||
|
} finally {
|
||||||
|
setChangingPassword(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleManageSubscription() {
|
||||||
|
try {
|
||||||
|
setPortalLoading(true);
|
||||||
|
const { url } = await createPortalSession();
|
||||||
|
window.location.href = url;
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setPortalLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="page">Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error && !account) {
|
||||||
|
return <div className="page text-error">Error: {error}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isCanceling = account.cancel_at_period_end;
|
||||||
|
const statusLabel = isCanceling
|
||||||
|
? "Canceling"
|
||||||
|
: account.subscription_status === "active"
|
||||||
|
? "Active"
|
||||||
|
: account.subscription_status.charAt(0).toUpperCase() +
|
||||||
|
account.subscription_status.slice(1);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page account-page">
|
||||||
|
<h1 className="mb-lg">Account</h1>
|
||||||
|
|
||||||
|
{error && <div className="alert alert--error mb-lg">{error}</div>}
|
||||||
|
|
||||||
|
{/* ── Profile Section ──────────────────────── */}
|
||||||
|
<div className="section mb-lg">
|
||||||
|
<h2 className="account__section-title">Profile</h2>
|
||||||
|
<div className="account__row">
|
||||||
|
<span className="account__label">Email</span>
|
||||||
|
<span className="account__value">{account.email}</span>
|
||||||
|
</div>
|
||||||
|
<div className="account__row">
|
||||||
|
<span className="account__label">User Name</span>
|
||||||
|
<span className="account__value">{account.user_name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="account__row account__row--last">
|
||||||
|
<span className="account__label">User ID</span>
|
||||||
|
<span className="account__value text-mono account__uuid">{account.user_id}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Subscription Section ─────────────────── */}
|
||||||
|
<div className="section mb-lg">
|
||||||
|
<h2 className="account__section-title">Subscription</h2>
|
||||||
|
<div className="account__row">
|
||||||
|
<span className="account__label">Plan</span>
|
||||||
|
<span className="account__value">{account.subscription_plan}</span>
|
||||||
|
</div>
|
||||||
|
<div className="account__row">
|
||||||
|
<span className="account__label">Status</span>
|
||||||
|
<span className={`account__value account__status account__status--${isCanceling ? "canceling" : account.subscription_status}`}>
|
||||||
|
{statusLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{account.member_since && (
|
||||||
|
<div className="account__row">
|
||||||
|
<span className="account__label">Member Since</span>
|
||||||
|
<span className="account__value">{account.member_since}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{account.next_billing_date && !isCanceling && (
|
||||||
|
<div className="account__row">
|
||||||
|
<span className="account__label">Next Billing Date</span>
|
||||||
|
<span className="account__value">{account.next_billing_date}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isCanceling && account.period_end_date && (
|
||||||
|
<div className="account__row">
|
||||||
|
<span className="account__label">Access Until</span>
|
||||||
|
<span className="account__value">{account.period_end_date}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isCanceling && (
|
||||||
|
<div className="alert alert--warning mt-md">
|
||||||
|
Your subscription has been canceled and will not renew.
|
||||||
|
You have full access until {account.period_end_date}.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="account__portal-section">
|
||||||
|
<button
|
||||||
|
onClick={handleManageSubscription}
|
||||||
|
disabled={portalLoading}
|
||||||
|
className="btn btn--ghost"
|
||||||
|
>
|
||||||
|
{portalLoading ? "Redirecting..." : "Manage Subscription"}
|
||||||
|
</button>
|
||||||
|
<p className="text-dimmed text-sm account__portal-hint">
|
||||||
|
Cancel subscription, update payment method, or view invoices via Stripe.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Change Password Section ──────────────── */}
|
||||||
|
<div className="section">
|
||||||
|
<h2 className="account__section-title">Change Password</h2>
|
||||||
|
|
||||||
|
{passwordMsg && <div className="alert alert--success mb-md">{passwordMsg}</div>}
|
||||||
|
{passwordErr && <div className="alert alert--error mb-md">{passwordErr}</div>}
|
||||||
|
|
||||||
|
<form onSubmit={handlePasswordChange} className="account__password-form">
|
||||||
|
<div className="form-field">
|
||||||
|
<label className="form-label">New Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className="form-control"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
|
disabled={changingPassword}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-field form-field--last">
|
||||||
|
<label className="form-label">Confirm Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className="form-control"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
disabled={changingPassword}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button type="submit" disabled={changingPassword} className="btn btn--primary">
|
||||||
|
{changingPassword ? "Updating..." : "Update Password"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,24 +1,28 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2020",
|
"target": "ES2020",
|
||||||
"useDefineForClassFields": true,
|
"useDefineForClassFields": true,
|
||||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
"lib": [
|
||||||
"module": "ESNext",
|
"ES2020",
|
||||||
"skipLibCheck": true,
|
"DOM",
|
||||||
|
"DOM.Iterable"
|
||||||
/* Bundler mode */
|
],
|
||||||
"moduleResolution": "bundler",
|
"module": "ESNext",
|
||||||
"allowImportingTsExtensions": true,
|
"skipLibCheck": true,
|
||||||
"resolveJsonModule": true,
|
/* Bundler mode */
|
||||||
"isolatedModules": true,
|
"moduleResolution": "bundler",
|
||||||
"noEmit": true,
|
"allowImportingTsExtensions": true,
|
||||||
"jsx": "react-jsx",
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
/* Linting */
|
"noEmit": true,
|
||||||
"strict": true,
|
"jsx": "react-jsx",
|
||||||
"noUnusedLocals": true,
|
/* Linting */
|
||||||
"noUnusedParameters": true,
|
"strict": true,
|
||||||
"noFallthroughCasesInSwitch": true
|
"noUnusedLocals": true,
|
||||||
},
|
"noUnusedParameters": true,
|
||||||
"include": ["src"]
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user