Compare commits

...

19 Commits

Author SHA1 Message Date
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
74 changed files with 1107 additions and 176 deletions

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

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

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

@@ -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 {
@@ -93,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"`
@@ -102,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
@@ -131,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 (

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")

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 {

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 (
@@ -261,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
} }

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

BIN
backend/poller Executable file

Binary file not shown.

Binary file not shown.

View File

@@ -7,6 +7,7 @@ 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

@@ -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,35 +13,59 @@ 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"> <>
<div className="flex flex--center gap-sm"> <nav className="navbar">
<span className="navbar__brand">Koin Ping</span> <div className="navbar__left">
<div className="navbar__links"> <div className="navbar__brand-group">
{navLinks.map(({ to, label }) => { <span className="navbar__brand">Koin Ping</span>
const isActive = location.pathname === to; <Link to="/account" className="navbar__user-link navbar__user-link--mobile">
return ( {currentUser.email}
<Link </Link>
key={to} </div>
to={to} <div className="navbar__links">
className={`navbar__link ${isActive ? "navbar__link--active" : ""}`} {navLinks.map(({ to, label }) => {
> const isActive = location.pathname === to;
{label} return (
</Link> <Link
); key={to}
})} to={to}
className={`navbar__link ${isActive ? "navbar__link--active" : ""}`}
>
{label}
</Link>
);
})}
</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">
<button onClick={logout} className="navbar__logout"> {currentUser.email}
Logout </Link>
</button> <button onClick={logout} className="navbar__logout">
</div> Logout
</nav> </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>
</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

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

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,44 @@
} }
} }
.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;
}
.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_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>
); );
} }

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

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