Compare commits

...

27 Commits

Author SHA1 Message Date
KS Jannette
0fb4dcd7d1 enhanced alert granularity with min/max thresholds 2026-03-06 23:33:12 -05:00
KS Jannette
d3530deb46 m 2026-03-06 03:09:44 -05:00
KS Jannette
aadcdc628e hot 3 2026-03-06 03:08:47 -05:00
KS Jannette
2d4fcefdcf hottie 2026-03-06 03:00:14 -05:00
KS Jannette
81303573fd quick style changes 2026-03-06 02:51:35 -05:00
KS Jannette
76fadf05d9 hot f 2026-03-05 16:14:51 -05:00
KS Jannette
b9e3d8ae82 hotfix 2026-03-05 14:52:35 -05:00
S Jannette
6c5ffc2340 Merge pull request #25 from kjannette/media-queries-2
mobile navigation
2026-03-05 11:04:07 -05:00
KS Jannette
e32aa4b6aa mobile navigation
Some checks are pending
check / check (push) Waiting to run
2026-03-05 11:03:42 -05:00
S Jannette
682c168250 Merge pull request #24 from kjannette/media-queries
media queries
2026-03-05 10:31:18 -05:00
KS Jannette
e5c8f91e3f media queries
Some checks are pending
check / check (push) Waiting to run
2026-03-05 10:29:37 -05:00
S Jannette
f6255ec1f1 Merge pull request #23 from kjannette/account-info-page
Account info page
2026-03-05 08:26:39 -05:00
KS Jannette
4b77e7a4d4 add user account page and associated functionaility to support it
Some checks are pending
check / check (push) Waiting to run
2026-03-05 08:25:24 -05:00
KS Jannette
8f1212813f edits 2026-03-05 07:39:46 -05:00
S Jannette
95ec1a520a Merge pull request #22 from kjannette/frontend-feature-roundup
style
2026-03-05 07:27:41 -05:00
KS Jannette
4b228d9b57 style
Some checks are pending
check / check (push) Waiting to run
2026-03-05 07:19:12 -05:00
S Jannette
08bc3e1ea2 Merge pull request #21 from kjannette/poller-refinements-3
adjust rpc call to eth block timing
2026-03-05 06:51:54 -05:00
KS Jannette
a091d39c08 adjust rpc call to eth block timing
Some checks are pending
check / check (push) Waiting to run
2026-03-05 06:48:11 -05:00
S Jannette
35699a41bd Merge pull request #20 from kjannette/loginpage-style
Loginpage style
2026-03-05 02:18:22 -05:00
KS Jannette
2e96982d3a m
Some checks are pending
check / check (push) Waiting to run
2026-03-05 02:17:11 -05:00
KS Jannette
e48de2d6b6 style tweaks 2026-03-05 02:14:55 -05:00
KS Jannette
9414af8108 adjust css 2026-03-05 02:07:51 -05:00
S Jannette
068ca2f834 Merge pull request #19 from kjannette/more-poller-refinement
Some checks are pending
check / check (push) Waiting to run
Added PermanentError type, IsPermanent() helper.
2026-03-05 00:00:49 -05:00
KS Jannette
7f10bcb7de Added PermanentError type, IsPermanent() helper. Permanent errors are logged and not retried
Some checks are pending
check / check (push) Waiting to run
2026-03-05 00:00:09 -05:00
S Jannette
f91ca33752 Merge pull request #18 from kjannette/cleanup
general cleanup - removed old comments, enforced naming conventions etc
2026-03-04 23:32:01 -05:00
KS Jannette
2c86bba235 general cleanup - removed old comments, enforced naming conventions etc
Some checks are pending
check / check (push) Waiting to run
2026-03-04 23:29:58 -05:00
S Jannette
44dad43f1d Merge pull request #17 from kjannette/poller-tweaks
updated evaluator service -
2026-03-04 23:16:56 -05:00
81 changed files with 1577 additions and 432 deletions

View File

@@ -1,9 +0,0 @@
name: check
on: [push]
jobs:
check:
runs-on: ubuntu-latest
steps:
# actions/checkout v4.2.2, 2026-02-22
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- run: docker build .

2
.gitignore vendored
View File

@@ -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

View File

@@ -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/

View File

@@ -2,8 +2,7 @@ MIT License
Copyright (c) 2026 Steven Jannette Copyright (c) 2026 Steven Jannette
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is copies of the Software, and to permit persons to whom the Software is

View File

@@ -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

View File

@@ -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/

View File

@@ -1,188 +0,0 @@
---
title: Repository Policies
last_modified: 2026-02-22
---
This document covers repository structure, tooling, and workflow standards. Code
style conventions are in separate documents:
- [Code Styleguide](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE.md)
(general, bash, Docker)
- [Go](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_GO.md)
- [JavaScript](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_JS.md)
- [Python](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_PYTHON.md)
- [Go HTTP Server Conventions](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/GO_HTTP_SERVER_CONVENTIONS.md)
---
- Cross-project documentation (such as this file) must include
`last_modified: YYYY-MM-DD` in the YAML front matter so it can be kept in sync
with the authoritative source as policies evolve.
- **ALL external references must be pinned by cryptographic hash.** This
includes Docker base images, Go modules, npm packages, GitHub Actions, and
anything else fetched from a remote source. Version tags (`@v4`, `@latest`,
`:3.21`, etc.) are server-mutable and therefore remote code execution
vulnerabilities. The ONLY acceptable way to reference an external dependency
is by its content hash (Docker `@sha256:...`, Go module hash in `go.sum`, npm
integrity hash in lockfile, GitHub Actions `@<commit-sha>`). No exceptions.
This also means never `curl | bash` to install tools like pyenv, nvm, rustup,
etc. Instead, download a specific release archive from GitHub, verify its hash
(hardcoded in the Dockerfile or script), and only then install. Unverified
install scripts are arbitrary remote code execution. This is the single most
important rule in this document. Double-check every external reference in
every file before committing. There are zero exceptions to this rule.
- Every repo with software must have a root `Makefile` with these targets:
`make test`, `make lint`, `make fmt` (writes), `make fmt-check` (read-only),
`make check` (prereqs: `test`, `lint`, `fmt-check`), `make docker`, and
`make hooks` (installs pre-commit hook). A model Makefile is at
`https://git.eeqj.de/sneak/prompts/raw/branch/main/Makefile`.
- Always use Makefile targets (`make fmt`, `make test`, `make lint`, etc.)
instead of invoking the underlying tools directly. The Makefile is the single
source of truth for how these operations are run.
- The Makefile is authoritative documentation for how the repo is used. Beyond
the required targets above, it should have targets for every common operation:
running a local development server (`make run`, `make dev`), re-initializing
or migrating the database (`make db-reset`, `make migrate`), building
artifacts (`make build`), generating code, seeding data, or anything else a
developer would do regularly. If someone checks out the repo and types
`make<tab>`, they should see every meaningful operation available. A new
contributor should be able to understand the entire development workflow by
reading the Makefile.
- Every repo should have a `Dockerfile`. All Dockerfiles must run `make check`
as a build step so the build fails if the branch is not green. For non-server
repos, the Dockerfile should bring up a development environment and run
`make check`. For server repos, `make check` should run as an early build
stage before the final image is assembled.
- Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that
runs `docker build .` on push. Since the Dockerfile already runs `make check`,
a successful build implies all checks pass.
- Use platform-standard formatters: `black` for Python, `prettier` for
JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with
two exceptions: four-space indents (except Go), and `proseWrap: always` for
Markdown (hard-wrap at 80 columns). Documentation and writing repos (Markdown,
HTML, CSS) should also have `.prettierrc` and `.prettierignore`.
- Pre-commit hook: `make check` if local testing is possible, otherwise
`make lint && make fmt-check`. The Makefile should provide a `make hooks`
target to install the pre-commit hook.
- All repos with software must have tests that run via the platform-standard
test framework (`go test`, `pytest`, `jest`/`vitest`, etc.). If no meaningful
tests exist yet, add the most minimal test possible — e.g. importing the
module under test to verify it compiles/parses. There is no excuse for
`make test` to be a no-op.
- `make test` must complete in under 20 seconds. Add a 30-second timeout in the
Makefile.
- Docker builds must complete in under 5 minutes.
- `make check` must not modify any files in the repo. Tests may use temporary
directories.
- `main` must always pass `make check`, no exceptions.
- Never commit secrets. `.env` files, credentials, API keys, and private keys
must be in `.gitignore`. No exceptions.
- `.gitignore` should be comprehensive from the start: OS files (`.DS_Store`),
editor files (`.swp`, `*~`), language build artifacts, and `node_modules/`.
Fetch the standard `.gitignore` from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up
a new repo.
- Never use `git add -A` or `git add .`. Always stage files explicitly by name.
- Never force-push to `main`.
- Make all changes on a feature branch. You can do whatever you want on a
feature branch.
- `.golangci.yml` is standardized and must _NEVER_ be modified by an agent, only
manually by the user. Fetch from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml`.
- When pinning images or packages by hash, add a comment above the reference
with the version and date (YYYY-MM-DD).
- Use `yarn`, not `npm`.
- Write all dates as YYYY-MM-DD (ISO 8601).
- Simple projects should be configured with environment variables.
- Dockerized web services listen on port 8080 by default, overridable with
`PORT`.
- `README.md` is the primary documentation. Required sections:
- **Description**: First line must include the project name, purpose,
category (web server, SPA, CLI tool, etc.), license, and author. Example:
"µPaaS is an MIT-licensed Go web application by @sneak that receives
git-frontend webhooks and deploys applications via Docker in realtime."
- **Getting Started**: Copy-pasteable install/usage code block.
- **Rationale**: Why does this exist?
- **Design**: How is the program structured?
- **TODO**: Update meticulously, even between commits. When planning, put
the todo list in the README so a new agent can pick up where the last one
left off.
- **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.
- **Author**: [@sneak](https://sneak.berlin).
- First commit of a new repo should contain only `README.md`.
- Go module root: `sneak.berlin/go/<name>`. Always run `go mod tidy` before
committing.
- Use SemVer.
- Database migrations live in `internal/db/migrations/` and must be embedded in
the binary.
- `000_migration.sql` — contains ONLY the creation of the migrations
tracking table itself. Nothing else.
- `001_schema.sql` — the full application schema.
- **Pre-1.0.0:** never add additional migration files (002, 003, etc.).
There is no installed base to migrate. Edit `001_schema.sql` directly.
- **Post-1.0.0:** add new numbered migration files for each schema change.
Never edit existing migrations after release.
- All repos should have an `.editorconfig` enforcing the project's indentation
settings.
- Avoid putting files in the repo root unless necessary. Root should contain
only project-level config files (`README.md`, `Makefile`, `Dockerfile`,
`LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`, and
language-specific config). Everything else goes in a subdirectory. Canonical
subdirectory names:
- `bin/` — executable scripts and tools
- `cmd/` — Go command entrypoints
- `configs/` — configuration templates and examples
- `deploy/` — deployment manifests (k8s, compose, terraform)
- `docs/` — documentation and markdown (README.md stays in root)
- `internal/` — Go internal packages
- `internal/db/migrations/` — database migrations
- `pkg/` — Go library packages
- `share/` — systemd units, data files
- `static/` — static assets (images, fonts, etc.)
- `web/` — web frontend source
- When setting up a new repo, files from the `prompts` repo may be used as
templates. Fetch them from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/<path>`.
- New repos must contain at minimum:
- `README.md`, `.git`, `.gitignore`, `.editorconfig`
- `LICENSE`, `REPO_POLICIES.md` (copy from the `prompts` repo)
- `Makefile`
- `Dockerfile`, `.dockerignore`
- `.gitea/workflows/check.yml`
- Go: `go.mod`, `go.sum`, `.golangci.yml`
- JS: `package.json`, `yarn.lock`, `.prettierrc`, `.prettierignore`
- Python: `pyproject.toml`

View File

@@ -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>

View File

@@ -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
}

View File

@@ -2,16 +2,16 @@ Start DB:
brew services start postgresql@15 brew services start postgresql@15
From the backend-go directory, you have a few options: From the backend 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-go go 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-go go 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).

View File

@@ -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 (
@@ -62,6 +62,7 @@ func main() {
emailDigestHandler := handlers.NewEmailDigestHandler(emailDigestSvc, notifConfigModel) emailDigestHandler := handlers.NewEmailDigestHandler(emailDigestSvc, notifConfigModel)
statusHandler := handlers.NewStatusHandler(checkpointModel) statusHandler := handlers.NewStatusHandler(checkpointModel)
stripeHandler := handlers.NewStripeHandler(userModel, cfg) stripeHandler := handlers.NewStripeHandler(userModel, cfg)
accountHandler := handlers.NewAccountHandler(userModel, cfg)
authenticate := middleware.Authenticate(userModel) authenticate := middleware.Authenticate(userModel)
requireSub := middleware.RequireSubscription(userModel) requireSub := middleware.RequireSubscription(userModel)
@@ -88,6 +89,12 @@ func main() {
authenticate(http.HandlerFunc(stripeHandler.GetSubscriptionStatus))) authenticate(http.HandlerFunc(stripeHandler.GetSubscriptionStatus)))
mux.Handle("POST "+b+"/stripe/verify-checkout", mux.Handle("POST "+b+"/stripe/verify-checkout",
authenticate(http.HandlerFunc(stripeHandler.VerifyCheckoutSession))) 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 // Authenticated + subscribed routes — addresses
mux.Handle("POST "+b+"/addresses", mux.Handle("POST "+b+"/addresses",

View File

@@ -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 (

View File

@@ -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

View File

@@ -0,0 +1,6 @@
ALTER TABLE alert_rules ADD COLUMN minimum DECIMAL(20, 6);
ALTER TABLE alert_rules ADD COLUMN maximum DECIMAL(20, 6);
ALTER TABLE alert_rules ADD CONSTRAINT non_negative_minimum CHECK (minimum IS NULL OR minimum >= 0);
ALTER TABLE alert_rules ADD CONSTRAINT non_negative_maximum CHECK (maximum IS NULL OR maximum >= 0);
ALTER TABLE alert_rules ADD CONSTRAINT min_lte_max CHECK (minimum IS NULL OR maximum IS NULL OR minimum <= maximum);

View File

@@ -35,6 +35,8 @@ CREATE TABLE alert_rules (
address_id INTEGER NOT NULL REFERENCES addresses(id) ON DELETE CASCADE, address_id INTEGER NOT NULL REFERENCES addresses(id) ON DELETE CASCADE,
type VARCHAR(50) NOT NULL, -- 'incoming_tx', 'outgoing_tx', 'large_transfer', 'balance_below' type VARCHAR(50) NOT NULL, -- 'incoming_tx', 'outgoing_tx', 'large_transfer', 'balance_below'
threshold DECIMAL(20, 6), -- ETH amount threshold (nullable for tx types that don't need it) threshold DECIMAL(20, 6), -- ETH amount threshold (nullable for tx types that don't need it)
minimum DECIMAL(20, 6), -- Optional min amount filter for incoming/outgoing alerts
maximum DECIMAL(20, 6), -- Optional max amount filter for incoming/outgoing alerts
enabled BOOLEAN DEFAULT TRUE, enabled BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT NOW(), created_at TIMESTAMP DEFAULT NOW(),
@@ -44,7 +46,11 @@ CREATE TABLE alert_rules (
CONSTRAINT positive_threshold CHECK ( CONSTRAINT positive_threshold CHECK (
threshold IS NULL OR threshold > 0 threshold IS NULL OR threshold > 0
) ),
CONSTRAINT non_negative_minimum CHECK (minimum IS NULL OR minimum >= 0),
CONSTRAINT non_negative_maximum CHECK (maximum IS NULL OR maximum >= 0),
CONSTRAINT min_lte_max CHECK (minimum IS NULL OR maximum IS NULL OR minimum <= maximum)
); );

View File

@@ -47,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 {
@@ -58,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 {
@@ -74,6 +73,8 @@ type AlertRule struct {
AddressID int `json:"address_id"` //nolint:tagliatelle AddressID int `json:"address_id"` //nolint:tagliatelle
Type AlertType `json:"type"` Type AlertType `json:"type"`
Threshold *float64 `json:"threshold"` Threshold *float64 `json:"threshold"`
Minimum *float64 `json:"minimum"`
Maximum *float64 `json:"maximum"`
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
CreatedAt time.Time `json:"created_at"` //nolint:tagliatelle CreatedAt time.Time `json:"created_at"` //nolint:tagliatelle
} }
@@ -93,7 +94,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"`
@@ -102,7 +102,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
@@ -131,14 +131,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 (

View 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)
}

View File

@@ -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}$`)

View File

@@ -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.

View File

@@ -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")
@@ -37,6 +37,8 @@ func (h *AlertRuleHandler) Create(w http.ResponseWriter, r *http.Request) {
var body struct { var body struct {
Type string `json:"type"` Type string `json:"type"`
Threshold json.RawMessage `json:"threshold"` Threshold json.RawMessage `json:"threshold"`
Minimum json.RawMessage `json:"minimum"`
Maximum json.RawMessage `json:"maximum"`
} }
if err := json.NewDecoder(r.Body).Decode(&body); err != nil { if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
log.Printf("Failed to decode alert request body: %v", err) log.Printf("Failed to decode alert request body: %v", err)
@@ -53,6 +55,40 @@ func (h *AlertRuleHandler) Create(w http.ResponseWriter, r *http.Request) {
return return
} }
minimum, err := parseThreshold(body.Minimum)
if err != nil {
log.Printf("Failed to parse minimum: %v", err)
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "minimum must be a valid number")
return
}
maximum, err := parseThreshold(body.Maximum)
if err != nil {
log.Printf("Failed to parse maximum: %v", err)
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "maximum must be a valid number")
return
}
if minimum != nil && *minimum < 0 {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "minimum must be non-negative")
return
}
if maximum != nil && *maximum < 0 {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "maximum must be non-negative")
return
}
if minimum != nil && maximum != nil && *minimum > *maximum {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "minimum must not exceed maximum")
return
}
log.Printf("User %s creating alert: type=%s, addressID=%d", userID, body.Type, addressID) log.Printf("User %s creating alert: type=%s, addressID=%d", userID, body.Type, addressID)
if body.Type == "" { if body.Type == "" {
@@ -96,7 +132,7 @@ func (h *AlertRuleHandler) Create(w http.ResponseWriter, r *http.Request) {
return return
} }
newAlert, err := h.alertRules.Create(r.Context(), addressID, alertType, threshold) newAlert, err := h.alertRules.Create(r.Context(), addressID, alertType, threshold, minimum, maximum)
if err != nil { if err != nil {
log.Printf("Error creating alert rule: %v", err) log.Printf("Error creating alert rule: %v", err)
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to create alert rule") writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to create alert rule")
@@ -182,7 +218,7 @@ func (h *AlertRuleHandler) ListByAddress(w http.ResponseWriter, r *http.Request)
writeJSON(w, http.StatusOK, alerts) writeJSON(w, http.StatusOK, alerts)
} }
// UpdateStatus handles PATCH requests to enable or disable an alert rule. // UpdateStatus handles PATCH requests to enable/disable an alert rule and/or update min/max thresholds.
func (h *AlertRuleHandler) UpdateStatus(w http.ResponseWriter, r *http.Request) { func (h *AlertRuleHandler) UpdateStatus(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r.Context()) userID := middleware.GetUserID(r.Context())
alertID, ok := parseIntParam(r.PathValue("alertId")) alertID, ok := parseIntParam(r.PathValue("alertId"))
@@ -194,6 +230,9 @@ func (h *AlertRuleHandler) UpdateStatus(w http.ResponseWriter, r *http.Request)
var body struct { var body struct {
Enabled *bool `json:"enabled"` Enabled *bool `json:"enabled"`
Minimum json.RawMessage `json:"minimum"`
Maximum json.RawMessage `json:"maximum"`
UpdateMinMax bool `json:"update_min_max"` //nolint:tagliatelle
} }
if err := json.NewDecoder(r.Body).Decode(&body); err != nil { if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
log.Printf("Failed to decode update request body: %v", err) log.Printf("Failed to decode update request body: %v", err)
@@ -204,8 +243,8 @@ func (h *AlertRuleHandler) UpdateStatus(w http.ResponseWriter, r *http.Request)
log.Printf("User %s updating alert ID: %d", userID, alertID) log.Printf("User %s updating alert ID: %d", userID, alertID)
if body.Enabled == nil { if body.Enabled == nil && !body.UpdateMinMax {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "enabled must be a boolean value") writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "must provide enabled (boolean) or update_min_max with minimum/maximum values")
return return
} }
@@ -224,7 +263,54 @@ func (h *AlertRuleHandler) UpdateStatus(w http.ResponseWriter, r *http.Request)
return return
} }
updated, err := h.alertRules.UpdateEnabled(r.Context(), alertID, *body.Enabled) var updated *domain.AlertRule
if body.UpdateMinMax {
minimum, parseErr := parseThreshold(body.Minimum)
if parseErr != nil {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "minimum must be a valid number")
return
}
maximum, parseErr := parseThreshold(body.Maximum)
if parseErr != nil {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "maximum must be a valid number")
return
}
if minimum != nil && *minimum < 0 {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "minimum must be non-negative")
return
}
if maximum != nil && *maximum < 0 {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "maximum must be non-negative")
return
}
if minimum != nil && maximum != nil && *minimum > *maximum {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "minimum must not exceed maximum")
return
}
updated, err = h.alertRules.UpdateThresholds(r.Context(), alertID, minimum, maximum)
if err != nil {
log.Printf("Error updating alert thresholds: %v", err)
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to update alert")
return
}
log.Printf("Alert %d thresholds updated: min=%v, max=%v", alertID, minimum, maximum)
}
if body.Enabled != nil {
updated, err = h.alertRules.UpdateEnabled(r.Context(), alertID, *body.Enabled)
if err != nil { if err != nil {
log.Printf("Error updating alert: %v", err) log.Printf("Error updating alert: %v", err)
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to update alert") writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to update alert")
@@ -233,6 +319,8 @@ func (h *AlertRuleHandler) UpdateStatus(w http.ResponseWriter, r *http.Request)
} }
log.Printf("Alert %d updated: enabled=%v", alertID, *body.Enabled) log.Printf("Alert %d updated: enabled=%v", alertID, *body.Enabled)
}
writeJSON(w, http.StatusOK, updated) writeJSON(w, http.StatusOK, updated)
} }

View File

@@ -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 {

View File

@@ -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@]+$`)

View File

@@ -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.

View File

@@ -7,12 +7,13 @@ import (
"net/http" "net/http"
"github.com/stripe/stripe-go/v82" "github.com/stripe/stripe-go/v82"
portalsession "github.com/stripe/stripe-go/v82/billingportal/session"
checkoutsession "github.com/stripe/stripe-go/v82/checkout/session" checkoutsession "github.com/stripe/stripe-go/v82/checkout/session"
"github.com/stripe/stripe-go/v82/webhook" "github.com/stripe/stripe-go/v82/webhook"
"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/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"
) )
const webhookMaxBodyBytes = 65536 const webhookMaxBodyBytes = 65536
@@ -139,6 +140,38 @@ func (h *StripeHandler) VerifyCheckoutSession(w http.ResponseWriter, r *http.Req
writeJSON(w, http.StatusOK, map[string]string{"subscription_status": "active"}) 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. // HandleWebhook processes incoming Stripe webhook events.
// This endpoint must NOT require authentication (Stripe calls it directly). // This endpoint must NOT require authentication (Stripe calls it directly).
func (h *StripeHandler) HandleWebhook(w http.ResponseWriter, r *http.Request) { func (h *StripeHandler) HandleWebhook(w http.ResponseWriter, r *http.Request) {

View File

@@ -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

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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 {
@@ -17,14 +17,14 @@ func NewAlertRuleModel(pool *pgxpool.Pool) *AlertRuleModel {
return &AlertRuleModel{pool: pool} return &AlertRuleModel{pool: pool}
} }
func (m *AlertRuleModel) Create(ctx context.Context, addressID int, alertType domain.AlertType, threshold *float64) (*domain.AlertRule, error) { func (m *AlertRuleModel) Create(ctx context.Context, addressID int, alertType domain.AlertType, threshold, minimum, maximum *float64) (*domain.AlertRule, error) {
var r domain.AlertRule var r domain.AlertRule
err := m.pool.QueryRow(ctx, err := m.pool.QueryRow(ctx,
`INSERT INTO alert_rules (address_id, type, threshold, enabled) `INSERT INTO alert_rules (address_id, type, threshold, minimum, maximum, enabled)
VALUES ($1, $2, $3, TRUE) VALUES ($1, $2, $3, $4, $5, TRUE)
RETURNING id, address_id, type, threshold, enabled, created_at`, RETURNING id, address_id, type, threshold, minimum, maximum, enabled, created_at`,
addressID, alertType.String(), threshold, addressID, alertType.String(), threshold, minimum, maximum,
).Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Enabled, &r.CreatedAt) ).Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Minimum, &r.Maximum, &r.Enabled, &r.CreatedAt)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -33,7 +33,7 @@ func (m *AlertRuleModel) Create(ctx context.Context, addressID int, alertType do
func (m *AlertRuleModel) ListByAddress(ctx context.Context, addressID int) ([]domain.AlertRule, error) { func (m *AlertRuleModel) ListByAddress(ctx context.Context, addressID int) ([]domain.AlertRule, error) {
rows, err := m.pool.Query(ctx, rows, err := m.pool.Query(ctx,
`SELECT id, address_id, type, threshold, enabled, created_at `SELECT id, address_id, type, threshold, minimum, maximum, enabled, created_at
FROM alert_rules FROM alert_rules
WHERE address_id = $1 WHERE address_id = $1
ORDER BY created_at DESC`, ORDER BY created_at DESC`,
@@ -47,7 +47,7 @@ func (m *AlertRuleModel) ListByAddress(ctx context.Context, addressID int) ([]do
var rules []domain.AlertRule var rules []domain.AlertRule
for rows.Next() { for rows.Next() {
var r domain.AlertRule var r domain.AlertRule
if err := rows.Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Enabled, &r.CreatedAt); err != nil { if err := rows.Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Minimum, &r.Maximum, &r.Enabled, &r.CreatedAt); err != nil {
return nil, err return nil, err
} }
rules = append(rules, r) rules = append(rules, r)
@@ -61,19 +61,19 @@ func (m *AlertRuleModel) FindByID(ctx context.Context, id int, userID *string) (
if userID != nil { if userID != nil {
err = m.pool.QueryRow(ctx, err = m.pool.QueryRow(ctx,
`SELECT ar.id, ar.address_id, ar.type, ar.threshold, ar.enabled, ar.created_at `SELECT ar.id, ar.address_id, ar.type, ar.threshold, ar.minimum, ar.maximum, ar.enabled, ar.created_at
FROM alert_rules ar FROM alert_rules ar
JOIN addresses a ON a.id = ar.address_id JOIN addresses a ON a.id = ar.address_id
WHERE ar.id = $1 AND a.user_id = $2`, WHERE ar.id = $1 AND a.user_id = $2`,
id, *userID, id, *userID,
).Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Enabled, &r.CreatedAt) ).Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Minimum, &r.Maximum, &r.Enabled, &r.CreatedAt)
} else { } else {
err = m.pool.QueryRow(ctx, err = m.pool.QueryRow(ctx,
`SELECT id, address_id, type, threshold, enabled, created_at `SELECT id, address_id, type, threshold, minimum, maximum, enabled, created_at
FROM alert_rules FROM alert_rules
WHERE id = $1`, WHERE id = $1`,
id, id,
).Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Enabled, &r.CreatedAt) ).Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Minimum, &r.Maximum, &r.Enabled, &r.CreatedAt)
} }
if err != nil { if err != nil {
@@ -91,9 +91,27 @@ func (m *AlertRuleModel) UpdateEnabled(ctx context.Context, id int, enabled bool
`UPDATE alert_rules `UPDATE alert_rules
SET enabled = $2 SET enabled = $2
WHERE id = $1 WHERE id = $1
RETURNING id, address_id, type, threshold, enabled, created_at`, RETURNING id, address_id, type, threshold, minimum, maximum, enabled, created_at`,
id, enabled, id, enabled,
).Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Enabled, &r.CreatedAt) ).Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Minimum, &r.Maximum, &r.Enabled, &r.CreatedAt)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, err
}
return &r, nil
}
func (m *AlertRuleModel) UpdateThresholds(ctx context.Context, id int, minimum, maximum *float64) (*domain.AlertRule, error) {
var r domain.AlertRule
err := m.pool.QueryRow(ctx,
`UPDATE alert_rules
SET minimum = $2, maximum = $3
WHERE id = $1
RETURNING id, address_id, type, threshold, minimum, maximum, enabled, created_at`,
id, minimum, maximum,
).Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Minimum, &r.Maximum, &r.Enabled, &r.CreatedAt)
if err != nil { if err != nil {
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return nil, nil return nil, nil

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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 UserModel struct { type UserModel struct {

View File

@@ -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

View File

@@ -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

View 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
}

View File

@@ -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

View File

@@ -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

View File

@@ -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 = 2000 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.

View File

@@ -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.

View File

@@ -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

View File

@@ -9,11 +9,11 @@ import (
"golang.org/x/sync/semaphore" "golang.org/x/sync/semaphore"
"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/notifications" "github.com/kjannette/koin-ping/backend/internal/notifications"
"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/wei" "github.com/kjannette/koin-ping/backend/internal/wei"
) )
const ( const (
@@ -104,9 +104,9 @@ func (s *EvaluatorService) evaluateObservation(ctx context.Context, obs domain.O
func (s *EvaluatorService) ruleMatches(ctx context.Context, rule domain.AlertRule, obs domain.ObservedTx) (bool, error) { func (s *EvaluatorService) ruleMatches(ctx context.Context, rule domain.AlertRule, obs domain.ObservedTx) (bool, error) {
switch rule.Type { switch rule.Type {
case domain.AlertIncomingTx: case domain.AlertIncomingTx:
return obs.Direction == domain.DirectionIncoming, nil return s.matchesDirectionalTx(rule, obs, domain.DirectionIncoming)
case domain.AlertOutgoingTx: case domain.AlertOutgoingTx:
return obs.Direction == domain.DirectionOutgoing, nil return s.matchesDirectionalTx(rule, obs, domain.DirectionOutgoing)
case domain.AlertLargeTransfer: case domain.AlertLargeTransfer:
return s.matchesLargeTransfer(rule, obs) return s.matchesLargeTransfer(rule, obs)
case domain.AlertBalanceBelow: case domain.AlertBalanceBelow:
@@ -117,6 +117,42 @@ func (s *EvaluatorService) ruleMatches(ctx context.Context, rule domain.AlertRul
} }
} }
func (s *EvaluatorService) matchesDirectionalTx(rule domain.AlertRule, obs domain.ObservedTx, expected domain.Direction) (bool, error) {
if obs.Direction != expected {
return false, nil
}
if rule.Minimum != nil {
minWei, err := wei.FromEth(*rule.Minimum)
if err != nil {
return false, err
}
aboveMin, err := wei.GreaterThanOrEqual(obs.Value, minWei)
if err != nil {
return false, err
}
if !aboveMin {
return false, nil
}
}
if rule.Maximum != nil {
maxWei, err := wei.FromEth(*rule.Maximum)
if err != nil {
return false, err
}
belowMax, err := wei.LessThanOrEqual(obs.Value, maxWei)
if err != nil {
return false, err
}
if !belowMax {
return false, nil
}
}
return true, nil
}
func (s *EvaluatorService) matchesLargeTransfer(rule domain.AlertRule, obs domain.ObservedTx) (bool, error) { func (s *EvaluatorService) matchesLargeTransfer(rule domain.AlertRule, obs domain.ObservedTx) (bool, error) {
if rule.Threshold == nil { if rule.Threshold == nil {
return false, nil return false, nil
@@ -261,8 +297,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
} }

View File

@@ -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

View File

@@ -90,6 +90,14 @@ func LessThan(weiA, weiB string) (bool, error) {
return cmp < 0, nil return cmp < 0, nil
} }
func LessThanOrEqual(weiA, weiB string) (bool, error) {
cmp, err := Compare(weiA, weiB)
if err != nil {
return false, err
}
return cmp <= 0, nil
}
// FormatAsEth formats a Wei string as "X.XXXX ETH". // FormatAsEth formats a Wei string as "X.XXXX ETH".
func FormatAsEth(weiString string, decimals int) (string, error) { func FormatAsEth(weiString string, decimals int) (string, error) {
eth, err := ToEth(weiString) eth, err := ToEth(weiString)

BIN
backend/poller Executable file

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -3,10 +3,11 @@ import { useAuth } from "./contexts/AuthContext";
import Navbar from "./components/Navbar"; import Navbar from "./components/Navbar";
import Login from "./pages/login/Login"; import Login from "./pages/login/Login";
import Signup from "./pages/Signup"; import Signup from "./pages/Signup";
import Subscribe from "./pages/subscribe/subscribe"; import Subscribe from "./pages/subscribe/Subscribe";
import Addresses from "./pages/addresses/Addresses"; import Addresses from "./pages/addresses/Addresses";
import Alerts from "./pages/alerts/Alerts"; import Alerts from "./pages/alerts/Alerts";
import AlertHistory from "./pages/alertHistory/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();
@@ -30,6 +31,7 @@ export default function App() {
<Route path="/addresses" element={<Addresses />} /> <Route path="/addresses" element={<Addresses />} />
<Route path="/alerts" element={<Alerts />} /> <Route path="/alerts" element={<Alerts />} />
<Route path="/alertevents" element={<AlertHistory />} /> <Route path="/alertevents" element={<AlertHistory />} />
<Route path="/account" element={<Account />} />
<Route path="/subscribe" element={<Subscribe />} /> <Route path="/subscribe" element={<Subscribe />} />
<Route path="*" element={<Navigate to="/addresses" />} /> <Route path="*" element={<Navigate to="/addresses" />} />
</Routes> </Routes>

View 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();
}

View File

@@ -129,6 +129,48 @@ export async function updateAlertStatus(alertId, enabled) {
} }
} }
/**
* Update the min/max thresholds on an alert rule
* @param {number} alertId - Alert rule ID
* @param {number|null} minimum - Minimum amount (null to clear)
* @param {number|null} maximum - Maximum amount (null to clear)
* @returns {Promise<Object>} Updated alert rule
*/
export async function updateAlertThresholds(alertId, minimum, maximum) {
try {
const headers = await getAuthHeaders();
const response = await fetch(`${API_BASE}/alerts/${alertId}`, {
method: "PATCH",
headers: headers,
body: JSON.stringify({
update_min_max: true,
minimum: minimum ?? null,
maximum: maximum ?? null,
}),
});
if (!response.ok) {
let errorMessage = "Failed to update alert thresholds";
try {
const error = await response.json();
errorMessage = error.message || errorMessage;
} catch {
errorMessage = `Server error: ${response.status} ${response.statusText}`;
}
throw new Error(errorMessage);
}
return response.json();
} catch (error) {
if (error.message.includes("fetch")) {
throw new Error(
"Cannot connect to server. Is the backend running?",
);
}
throw error;
}
}
/** /**
* Delete an alert rule * Delete an alert rule
* @param {number} alertId - Alert rule ID * @param {number} alertId - Alert rule ID

View File

@@ -8,6 +8,19 @@
border-bottom: 1px solid var(--color-border-light); border-bottom: 1px solid var(--color-border-light);
} }
/* ── Left Group: Brand + Links ───────────────────────────── */
.navbar__left {
display: flex;
align-items: center;
gap: 0.5rem;
}
.navbar__brand-group {
display: flex;
align-items: center;
}
.navbar__brand { .navbar__brand {
font-weight: 700; font-weight: 700;
font-size: 1.4rem; font-size: 1.4rem;
@@ -38,11 +51,33 @@
background-color: var(--color-bg-card); background-color: var(--color-bg-card);
} }
/* ── Right Group: Email + Logout + Hamburger ─────────────── */
.navbar__right {
display: flex;
align-items: center;
gap: 1rem;
}
.navbar__user { .navbar__user {
font-size: 1.275rem; font-size: 1.275rem;
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__user-link--mobile {
display: none;
}
.navbar__logout { .navbar__logout {
padding: 0.4rem 1rem; padding: 0.4rem 1rem;
font-size: 1.275rem; font-size: 1.275rem;
@@ -54,3 +89,79 @@
cursor: pointer; cursor: pointer;
transition: all 0.15s ease; transition: all 0.15s ease;
} }
/* ── Hamburger Icon (hidden on desktop) ──────────────────── */
.navbar__hamburger {
display: none;
flex-direction: column;
justify-content: center;
gap: 5px;
background: none;
border: none;
cursor: pointer;
padding: 4px;
}
.navbar__hamburger-bar {
display: block;
width: 28px;
height: 3px;
background-color: var(--color-brand);
border-radius: 2px;
}
/* ── Responsive: Tablet & below (≤ 768px) ────────────────── */
@media (max-width: 768px) {
.navbar {
padding: 0.75rem 1rem;
}
.navbar__brand-group {
flex-direction: column;
align-items: flex-start;
}
.navbar__brand {
margin-right: 0;
font-size: 1.25rem;
}
.navbar__links {
display: none;
}
.navbar__user-link--mobile {
display: block;
font-size: 0.95rem;
}
.navbar__user-link--desktop {
display: none;
}
.navbar__logout {
display: none;
}
.navbar__hamburger {
display: flex;
}
}
/* ── Responsive: Mobile (≤ 480px) ────────────────────────── */
@media (max-width: 480px) {
.navbar {
padding: 0.75rem;
}
.navbar__brand {
font-size: 1.15rem;
}
.navbar__user-link--mobile {
font-size: 0.85rem;
}
}

View File

@@ -1,5 +1,7 @@
import { useState } from "react";
import { Link, useLocation } from "react-router-dom"; import { Link, useLocation } from "react-router-dom";
import { useAuth } from "../contexts/AuthContext"; import { useAuth } from "../contexts/AuthContext";
import NavPanel from "./navPanel/NavPanel";
import "./Navbar.css"; import "./Navbar.css";
const navLinks = [ const navLinks = [
@@ -11,13 +13,20 @@ const navLinks = [
export default function Navbar() { export default function Navbar() {
const { currentUser, logout } = useAuth(); const { currentUser, logout } = useAuth();
const location = useLocation(); const location = useLocation();
const [isNavPanelOpen, setIsNavPanelOpen] = useState(false);
if (!currentUser) return null; if (!currentUser) return null;
return ( return (
<>
<nav className="navbar"> <nav className="navbar">
<div className="flex flex--center gap-sm"> <div className="navbar__left">
<div className="navbar__brand-group">
<span className="navbar__brand">Koin Ping</span> <span className="navbar__brand">Koin Ping</span>
<Link to="/account" className="navbar__user-link navbar__user-link--mobile">
{currentUser.email}
</Link>
</div>
<div className="navbar__links"> <div className="navbar__links">
{navLinks.map(({ to, label }) => { {navLinks.map(({ to, label }) => {
const isActive = location.pathname === to; const isActive = location.pathname === to;
@@ -34,12 +43,29 @@ export default function Navbar() {
</div> </div>
</div> </div>
<div className="flex flex--center gap-lg"> <div className="navbar__right">
<span className="navbar__user">{currentUser.email}</span> <Link to="/account" className="navbar__user navbar__user-link navbar__user-link--desktop">
{currentUser.email}
</Link>
<button onClick={logout} className="navbar__logout"> <button onClick={logout} className="navbar__logout">
Logout Logout
</button> </button>
<button
className="navbar__hamburger"
onClick={() => setIsNavPanelOpen(true)}
aria-label="Open navigation menu"
>
<span className="navbar__hamburger-bar" />
<span className="navbar__hamburger-bar" />
<span className="navbar__hamburger-bar" />
</button>
</div> </div>
</nav> </nav>
<NavPanel
isOpen={isNavPanelOpen}
onClose={() => setIsNavPanelOpen(false)}
/>
</>
); );
} }

View File

@@ -0,0 +1,78 @@
/* ── Overlay Backdrop ─────────────────────────────────────── */
.nav-panel__overlay {
position: fixed;
inset: 0;
background-color: rgba(0, 0, 0, 0.45);
opacity: 0;
pointer-events: none;
transition: opacity 0.3s ease;
z-index: 900;
}
.nav-panel__overlay--visible {
opacity: 1;
pointer-events: auto;
}
/* ── Slide-out Panel ─────────────────────────────────────── */
.nav-panel {
position: fixed;
top: 0;
left: 0;
width: 260px;
height: 100%;
background-color: var(--color-bg-elevated);
border-right: 1px solid var(--color-border);
transform: translateX(-100%);
transition: transform 0.3s ease;
z-index: 950;
display: flex;
flex-direction: column;
padding: 1.5rem 0;
}
.nav-panel--open {
transform: translateX(0);
}
/* ── Nav Links ───────────────────────────────────────────── */
.nav-panel__nav {
display: flex;
flex-direction: column;
flex: 1;
}
.nav-panel__link {
padding: 0.9rem 1.5rem;
text-decoration: none;
font-size: 1.3rem;
font-weight: 200;
color: var(--color-text-subtle);
border-left: 3px solid transparent;
transition: background-color 0.15s ease, color 0.15s ease;
}
.nav-panel__link--active {
font-weight: 600;
color: var(--color-text);
background-color: var(--color-bg-card);
border-left-color: var(--color-brand);
}
/* ── Logout Button ───────────────────────────────────────── */
.nav-panel__logout {
margin: 1rem 1.5rem;
padding: 0.5rem 1rem;
font-size: 1.15rem;
font-weight: 400;
background-color: transparent;
color: var(--color-text-subtle);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
cursor: pointer;
transition: all 0.15s ease;
}

View File

@@ -0,0 +1,52 @@
import { Link, useLocation } from "react-router-dom";
import { useAuth } from "../../contexts/AuthContext";
import "./NavPanel.css";
const navLinks = [
{ to: "/addresses", label: "Addresses" },
{ to: "/alerts", label: "Configure Alerts" },
{ to: "/alertevents", label: "Alert Event History" },
];
export default function NavPanel({ isOpen, onClose }) {
const { logout } = useAuth();
const location = useLocation();
const handleNavClick = () => {
onClose();
};
const handleLogout = () => {
onClose();
logout();
};
return (
<>
<div
className={`nav-panel__overlay${isOpen ? " nav-panel__overlay--visible" : ""}`}
onClick={onClose}
/>
<aside className={`nav-panel${isOpen ? " nav-panel--open" : ""}`}>
<nav className="nav-panel__nav">
{navLinks.map(({ to, label }) => {
const isActive = location.pathname === to;
return (
<Link
key={to}
to={to}
className={`nav-panel__link${isActive ? " nav-panel__link--active" : ""}`}
onClick={handleNavClick}
>
{label}
</Link>
);
})}
</nav>
<button onClick={handleLogout} className="nav-panel__logout">
Logout
</button>
</aside>
</>
);
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -51,6 +51,69 @@
opacity: 0.6; opacity: 0.6;
} }
.alerts__accordion-toggle {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.75rem;
padding: 0.5rem 0 0;
border-top: 1px solid var(--color-border);
cursor: pointer;
font-size: 0.85rem;
color: var(--color-text-muted);
background: none;
border-left: none;
border-right: none;
border-bottom: none;
width: 100%;
text-align: left;
}
.alerts__accordion-toggle:hover {
color: var(--color-text);
}
.alerts__accordion-chevron {
display: inline-block;
transition: transform 0.2s ease;
font-size: 0.7rem;
}
.alerts__accordion-chevron--open {
transform: rotate(90deg);
}
.alerts__accordion-panel {
overflow: hidden;
max-height: 0;
opacity: 0;
transition: max-height 0.25s ease, opacity 0.2s ease, padding 0.25s ease;
padding: 0 0.25rem;
}
.alerts__accordion-panel--open {
max-height: 200px;
opacity: 1;
padding: 0.75rem 0.25rem 0.25rem;
}
.alerts__threshold-inputs {
display: flex;
gap: 1rem;
align-items: flex-end;
}
.alerts__threshold-inputs .form-field {
flex: 1;
margin-bottom: 0;
}
.alerts__threshold-actions {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
}
.alerts__email-buttons { .alerts__email-buttons {
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
@@ -61,3 +124,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;
}
}

View File

@@ -7,6 +7,7 @@ import {
getAlerts, getAlerts,
createAlert, createAlert,
updateAlertStatus, updateAlertStatus,
updateAlertThresholds,
deleteAlert, deleteAlert,
} from "../../api/alerts"; } from "../../api/alerts";
import { import {
@@ -39,6 +40,64 @@ export default function Alerts() {
const [sendingDigest, setSendingDigest] = useState(false); const [sendingDigest, setSendingDigest] = useState(false);
const [hasExistingConfig, setHasExistingConfig] = useState(false); const [hasExistingConfig, setHasExistingConfig] = useState(false);
const [openAccordions, setOpenAccordions] = useState({});
const [thresholdEdits, setThresholdEdits] = useState({});
function toggleAccordion(alertId) {
setOpenAccordions((prev) => ({ ...prev, [alertId]: !prev[alertId] }));
}
function getThresholdEdit(alert) {
if (thresholdEdits[alert.id]) return thresholdEdits[alert.id];
return {
minimum: alert.minimum != null ? String(alert.minimum) : "",
maximum: alert.maximum != null ? String(alert.maximum) : "",
};
}
function setThresholdEdit(alertId, field, value) {
setThresholdEdits((prev) => {
const current = prev[alertId] || getThresholdEditForAlert(alertId);
return { ...prev, [alertId]: { ...current, [field]: value } };
});
}
function getThresholdEditForAlert(alertId) {
const alert = alerts.find((a) => a.id === alertId);
return {
minimum: alert?.minimum != null ? String(alert.minimum) : "",
maximum: alert?.maximum != null ? String(alert.maximum) : "",
};
}
async function handleSaveThresholds(alertId) {
const edit = thresholdEdits[alertId];
if (!edit) return;
const min = edit.minimum.trim() === "" ? null : Number(edit.minimum);
const max = edit.maximum.trim() === "" ? null : Number(edit.maximum);
if (min !== null && isNaN(min)) return;
if (max !== null && isNaN(max)) return;
if (min !== null && max !== null && min > max) return;
try {
const updated = await updateAlertThresholds(alertId, min, max);
setAlerts((prev) =>
prev.map((a) => (a.id === alertId ? updated : a)),
);
setThresholdEdits((prev) => {
const next = { ...prev };
delete next[alertId];
return next;
});
setError(null);
} catch (err) {
setError(err.message);
console.error("Failed to update thresholds:", err);
}
}
useEffect(() => { useEffect(() => {
async function fetchData() { async function fetchData() {
try { try {
@@ -314,7 +373,14 @@ export default function Alerts() {
</p> </p>
) : ( ) : (
<ul className="list-unstyled"> <ul className="list-unstyled">
{alerts.map((alert) => ( {alerts.map((alert) => {
const hasAccordion =
alert.type === "incoming_tx" ||
alert.type === "outgoing_tx";
const isOpen = !!openAccordions[alert.id];
const edit = getThresholdEdit(alert);
return (
<li <li
key={alert.id} key={alert.id}
className={`alerts__rule ${!alert.enabled ? "alerts__rule--disabled" : ""}`} className={`alerts__rule ${!alert.enabled ? "alerts__rule--disabled" : ""}`}
@@ -329,27 +395,110 @@ export default function Alerts() {
Threshold: {alert.threshold} ETH Threshold: {alert.threshold} ETH
</div> </div>
)} )}
{alert.minimum != null && (
<div className="text-sm text-muted">
Min: {alert.minimum} ETH
</div>
)}
{alert.maximum != null && (
<div className="text-sm text-muted">
Max: {alert.maximum} ETH
</div>
)}
<div className="text-xs text-dimmed"> <div className="text-xs text-dimmed">
Status: {alert.enabled ? "Enabled" : "Disabled"} Status:{" "}
{alert.enabled ? "Enabled" : "Disabled"}
</div> </div>
</div> </div>
<div className="flex flex--center gap-sm"> <div className="flex flex--center gap-sm">
<Button <Button
onClick={() => onClick={() =>
handleToggleAlert(alert.id, alert.enabled) handleToggleAlert(
alert.id,
alert.enabled,
)
} }
> >
{alert.enabled ? "Disable" : "Enable"} {alert.enabled ? "Disable" : "Enable"}
</Button> </Button>
<Button <Button
onClick={() => handleDeleteAlert(alert.id)} onClick={() =>
handleDeleteAlert(alert.id)
}
> >
Delete Delete
</Button> </Button>
</div> </div>
</div> </div>
{hasAccordion && (
<>
<button
type="button"
className="alerts__accordion-toggle"
onClick={() =>
toggleAccordion(alert.id)
}
>
<span
className={`alerts__accordion-chevron ${isOpen ? "alerts__accordion-chevron--open" : ""}`}
>
&#9654;
</span>
Add optional minimum and maximum
threshold values
</button>
<div
className={`alerts__accordion-panel ${isOpen ? "alerts__accordion-panel--open" : ""}`}
>
<div className="alerts__threshold-inputs">
<Input
label="Minimum"
type="number"
step="0.000001"
min="0"
value={edit.minimum}
onChange={(v) =>
setThresholdEdit(
alert.id,
"minimum",
v,
)
}
placeholder="No minimum"
/>
<Input
label="Maximum"
type="number"
step="0.000001"
min="0"
value={edit.maximum}
onChange={(v) =>
setThresholdEdit(
alert.id,
"maximum",
v,
)
}
placeholder="No maximum"
/>
</div>
<div className="alerts__threshold-actions">
<Button
onClick={() =>
handleSaveThresholds(alert.id)
}
className="btn--sm"
>
Save
</Button>
</div>
</div>
</>
)}
</li> </li>
))} );
})}
</ul> </ul>
)} )}
</div> </div>

View File

@@ -1,19 +1,38 @@
.login-page { .login-page {
min-height: 100vh; position: relative;
background-image: url(/ping.png); width: 100vw;
background-size: 67%; height: 100vh;
background-position: center; overflow: hidden;
background-repeat: no-repeat; 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 { .login-card {
position: relative;
z-index: 1;
max-width: 400px; max-width: 400px;
margin: 20px auto; margin: 0 auto;
padding: 2rem; padding: 2rem;
padding-top: 8rem; padding-top: 8rem;
border: 1px solid var(--color-border-light); border: 1px solid var(--color-border-light);
border-radius: var(--radius-xl); border-radius: var(--radius-xl);
background-color: rgba(0, 0, 0, 0.75); background-color: rgba(0, 0, 0, 0.75);
top: 20px;
} }
.login-heading { .login-heading {
@@ -32,7 +51,6 @@
padding: 0.75rem; padding: 0.75rem;
font-size: 1.2rem; font-size: 1.2rem;
margin-top: 16px; margin-top: 16px;
;
} }
.login-footer { .login-footer {
@@ -55,10 +73,45 @@
} }
} }
.login-form-hidden { .login-card-fadein {
opacity: 0; opacity: 0;
animation: fadeIn 2s ease-in-out forwards;
animation-delay: 3s;
} }
.login-form-visible { .login-tagline {
animation: fadeIn 2s ease-in-out forwards; 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;
text-align: center;
}
.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;
}
} }

View File

@@ -5,7 +5,6 @@ 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_exp_2.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>
); );
} }

View File

@@ -236,3 +236,25 @@
margin: 0; margin: 0;
font-style: italic; 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;
}
}

View File

@@ -1,11 +1,3 @@
/**
* 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 { useState, useEffect } from "react";
import { useNavigate, useSearchParams } from "react-router-dom"; import { useNavigate, useSearchParams } from "react-router-dom";
import { useAuth } from "../../contexts/AuthContext"; import { useAuth } from "../../contexts/AuthContext";

View 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;
}

View 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>
);
}