Compare commits

..

20 Commits

Author SHA1 Message Date
KS Jannette
40ed4f6afd m 2026-03-28 22:34:23 -04:00
KS Jannette
26324150d2 removed F up 2026-03-28 11:58:29 -04:00
KS Jannette
b0572451d3 but 2026-03-28 11:55:51 -04:00
S Jannette
d9c3bd1db5 Merge pull request #27 from kjannette/setup-free-tier
Setup free tier
2026-03-28 11:27:18 -04:00
KS Jannette
615dd1dddc Chockfull 2026-03-28 11:10:25 -04:00
KS Jannette
288092e4b4 More 2026-03-28 10:31:14 -04:00
KS Jannette
69a2112df9 update readme 2026-03-28 08:27:06 -04:00
KS Jannette
4ba91c7d9b Stripe integration tweaks 2026-03-10 17:18:54 -04:00
KS Jannette
f9fa7def2b add subscription tiers 2026-03-10 15:22:48 -04:00
S Jannette
7b45c5b2ab Merge pull request #26 from kjannette/add-alert-thresholds
enhanced alert granularity with min/max thresholds
2026-03-06 23:39:15 -05:00
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
52 changed files with 2133 additions and 558 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

@@ -22,6 +22,8 @@ node_modules/
# Go build artifacts
backend/bin/
backend/api
backend/poller
*.exe
*.exe~
*.dll

View File

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

View File

@@ -1,187 +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](<link>)
(general, bash, Docker)
- [Go](<link>)
- [JavaScript](<link>)
- [Python](<link>)
- [Go HTTP Server Conventions](<link>)
---
- 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
`(<link>)`.
- 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
`(<link>) 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
`(<link>)`.
- 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 @sjdev 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**: [@sjdev](https://sjdev.co).
- First commit of a new repo should contain only `README.md`.
- 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
`(<link>)<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

@@ -37,7 +37,7 @@ tidy:
# Database setup
db-setup:
psql -d koin_ping_dev -f infra/schema.sql
psql -d koin_ping -f infra/schema.sql
vet:
go vet ./...

View File

@@ -1,20 +1,32 @@
Start DB:
Backend startup quickstarat:
-----------------------------> BEST
## 1. brew services start postgresql@15
brew services start postgresql@15
OR
From the backend directory, you have a few options:
/opt/homebrew/opt/postgresql@15/bin/pg_ctl -D /opt/homebrew/var/postgresql@15 start
Option 1: Single command (both API + poller)
cd /Users/kjannette/workspace/koin_ping_0.2.0/backendmake dev-all
## 2. ALLIN ONE:
Make dev-all — Runs both the API and poller concurrently.
-----------------------------> BEST
Option 2: Two separate terminals
Terminal 1 (API server):
cd /Users/kjannette/workspace/koin_ping_0.2.0/backend go run ./cmd/api
Terminal 2 (Poller):
cd /Users/kjannette/workspace/koin_ping_0.2.0/backend go run ./cmd/poller
- OR -
## 3. Option 1: Single command (both API + poller)
cd /Users/kjannette/workspace/koin_ping_0.2.0/backendmake dev-all
## 4. Option 2: Two separate terminals
Terminal 1 (API server):
cd /Users/kjannette/workspace/koin_ping_0.2.0/backend go run ./cmd/api
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).
## 5. Terminal 2 (Poller):
cd /Users/kjannette/workspace/koin_ping_0.2.0/backend
go run ./cmd/poller
make poller — Builds and runs the poller.
make poller-dev — Runs the poller with auto-reload.
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 poller — Builds and runs the poller.
make poller-dev — Runs the poller with auto-reload.
make dev-all — Runs both the API and poller concurrently.

Binary file not shown.

View File

@@ -55,14 +55,14 @@ func main() {
cfg.ResendAPIKey, cfg.EmailFrom, alertEventModel, notifConfigModel,
)
addressHandler := handlers.NewAddressHandler(addressModel)
alertRuleHandler := handlers.NewAlertRuleHandler(alertRuleModel, addressModel)
addressHandler := handlers.NewAddressHandler(addressModel, userModel)
alertRuleHandler := handlers.NewAlertRuleHandler(alertRuleModel, addressModel, userModel)
alertEventHandler := handlers.NewAlertEventHandler(alertEventModel)
notifConfigHandler := handlers.NewNotificationConfigHandler(notifConfigModel, cfg)
notifConfigHandler := handlers.NewNotificationConfigHandler(notifConfigModel, userModel, cfg)
emailDigestHandler := handlers.NewEmailDigestHandler(emailDigestSvc, notifConfigModel)
statusHandler := handlers.NewStatusHandler(checkpointModel)
stripeHandler := handlers.NewStripeHandler(userModel, cfg)
accountHandler := handlers.NewAccountHandler(userModel, cfg)
accountHandler := handlers.NewAccountHandler(userModel, addressModel, cfg)
authenticate := middleware.Authenticate(userModel)
requireSub := middleware.RequireSubscription(userModel)
@@ -82,6 +82,9 @@ func main() {
// Stripe webhook (public — called by Stripe, not authenticated)
mux.HandleFunc("POST "+b+"/stripe/webhook", stripeHandler.HandleWebhook)
// Onboarding checkout (public — account doesn't exist yet)
mux.HandleFunc("POST "+b+"/stripe/create-onboarding-checkout", stripeHandler.CreateOnboardingCheckout)
// Stripe routes (auth required, NO subscription required)
mux.Handle("POST "+b+"/stripe/create-checkout-session",
authenticate(http.HandlerFunc(stripeHandler.CreateCheckoutSession)))
@@ -91,6 +94,8 @@ func main() {
authenticate(http.HandlerFunc(stripeHandler.VerifyCheckoutSession)))
mux.Handle("POST "+b+"/stripe/create-portal-session",
authenticate(http.HandlerFunc(stripeHandler.CreatePortalSession)))
mux.Handle("POST "+b+"/stripe/activate-free",
authenticate(http.HandlerFunc(stripeHandler.ActivateFreeTier)))
// Account route (auth required, NO subscription required)
mux.Handle("GET "+b+"/user/account",

View File

@@ -51,6 +51,7 @@ func main() {
defer database.Close()
userModel := models.NewUserModel(pool)
addressModel := models.NewAddressModel(pool)
alertRuleModel := models.NewAlertRuleModel(pool)
alertEventModel := models.NewAlertEventModel(pool)
@@ -59,8 +60,7 @@ func main() {
observer := services.NewObserverService(eth, addressModel, checkpointModel)
evaluator := services.NewEvaluatorService(
eth, alertRuleModel, alertEventModel, addressModel, notifConfigModel,
cfg.ResendAPIKey, cfg.EmailFrom,
eth, alertRuleModel, alertEventModel, addressModel, userModel, notifConfigModel,
)
digestSvc := services.NewEmailDigestService(cfg.ResendAPIKey, cfg.EmailFrom, alertEventModel, notifConfigModel)

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

@@ -0,0 +1,8 @@
ALTER TABLE users ADD COLUMN IF NOT EXISTS subscription_tier VARCHAR(20) DEFAULT 'free';
-- Existing active/trialing subscribers were on the single paid plan,
-- which is now the "premium" tier. Backfill them so they aren't downgraded.
UPDATE users
SET subscription_tier = 'premium'
WHERE subscription_status IN ('active', 'trialing')
AND stripe_subscription_id IS NOT NULL;

View File

@@ -14,6 +14,7 @@ CREATE TABLE users (
stripe_customer_id VARCHAR(255),
stripe_subscription_id VARCHAR(255),
subscription_status VARCHAR(50) DEFAULT 'none',
subscription_tier VARCHAR(20) DEFAULT 'free',
subscription_created_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
@@ -35,6 +36,8 @@ CREATE TABLE alert_rules (
address_id INTEGER NOT NULL REFERENCES addresses(id) ON DELETE CASCADE,
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)
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,
created_at TIMESTAMP DEFAULT NOW(),
@@ -44,7 +47,11 @@ CREATE TABLE alert_rules (
CONSTRAINT positive_threshold CHECK (
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

@@ -34,7 +34,8 @@ type Config struct {
DigestIntervalHours int
StripeSecretKey string
StripeWebhookSecret string
StripePriceID string
StripePriceIDPremium string
StripePriceIDPro string
StripePublishableKey string
FrontendURL string
}
@@ -59,7 +60,8 @@ func Load() (*Config, error) {
DigestIntervalHours: getEnvInt("DIGEST_INTERVAL_HOURS", defaultDigestIntervalHours),
StripeSecretKey: os.Getenv("STRIPE_SECRET_KEY"),
StripeWebhookSecret: os.Getenv("STRIPE_WEBHOOK_SECRET"),
StripePriceID: os.Getenv("STRIPE_PRICE_ID"),
StripePriceIDPremium: os.Getenv("STRIPE_PRICE_ID_PREMIUM"),
StripePriceIDPro: os.Getenv("STRIPE_PRICE_ID_PRO"),
StripePublishableKey: os.Getenv("STRIPE_PUBLISHABLE_KEY"),
FrontendURL: getEnv("FRONTEND_URL", "http://localhost:3000"),
}
@@ -92,6 +94,19 @@ func (c *Config) DSN() string {
)
}
// TierForPriceID maps a Stripe price ID back to the corresponding
// subscription tier. Returns empty string if the price is unrecognised.
func (c *Config) TierForPriceID(priceID string) string {
switch priceID {
case c.StripePriceIDPremium:
return "premium"
case c.StripePriceIDPro:
return "pro"
default:
return ""
}
}
func getEnv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v

View File

@@ -0,0 +1,28 @@
package config
import "testing"
func TestTierForPriceID(t *testing.T) {
t.Parallel()
cfg := &Config{
StripePriceIDPremium: "price_premium_123",
StripePriceIDPro: "price_pro_456",
}
tests := []struct {
priceID string
want string
}{
{"price_premium_123", "premium"},
{"price_pro_456", "pro"},
{"price_unknown", ""},
{"", ""},
}
for _, tt := range tests {
if got := cfg.TierForPriceID(tt.priceID); got != tt.want {
t.Errorf("TierForPriceID(%q) = %q, want %q", tt.priceID, got, tt.want)
}
}
}

View File

@@ -2,6 +2,65 @@ package domain
import "time"
type SubscriptionTier string
const (
TierFree SubscriptionTier = "free"
TierPremium SubscriptionTier = "premium"
TierPro SubscriptionTier = "pro"
)
func IsValidTier(t string) bool {
switch SubscriptionTier(t) {
case TierFree, TierPremium, TierPro:
return true
}
return false
}
const unlimitedLimit = -1
type TierLimits struct {
MaxAddresses int `json:"max_addresses"`
MaxAlertTypes int `json:"max_alert_types"`
AllowedChannels []string `json:"allowed_channels"`
}
func GetTierLimits(tier SubscriptionTier) TierLimits {
switch tier {
case TierPremium:
return TierLimits{
MaxAddresses: 3,
MaxAlertTypes: 2,
AllowedChannels: []string{"email", "discord", "telegram"},
}
case TierPro:
return TierLimits{
MaxAddresses: unlimitedLimit,
MaxAlertTypes: unlimitedLimit,
AllowedChannels: []string{"email", "discord", "telegram", "slack"},
}
default:
return TierLimits{
MaxAddresses: 1,
MaxAlertTypes: 1,
AllowedChannels: []string{"email"},
}
}
}
func (l TierLimits) IsUnlimitedAddresses() bool { return l.MaxAddresses == unlimitedLimit }
func (l TierLimits) IsUnlimitedAlertTypes() bool { return l.MaxAlertTypes == unlimitedLimit }
func (l TierLimits) ChannelAllowed(channel string) bool {
for _, c := range l.AllowedChannels {
if c == channel {
return true
}
}
return false
}
type User struct {
ID string `json:"id"`
FirebaseUID string `json:"-"`
@@ -10,6 +69,7 @@ type User struct {
StripeCustomerID *string `json:"-"`
StripeSubscriptionID *string `json:"-"`
SubscriptionStatus string `json:"subscription_status"` //nolint:tagliatelle
SubscriptionTier SubscriptionTier `json:"subscription_tier"` //nolint:tagliatelle
SubscriptionCreatedAt *time.Time `json:"subscription_created_at,omitempty"` //nolint:tagliatelle
CreatedAt time.Time `json:"created_at"` //nolint:tagliatelle
UpdatedAt time.Time `json:"updated_at"` //nolint:tagliatelle
@@ -73,6 +133,8 @@ type AlertRule struct {
AddressID int `json:"address_id"` //nolint:tagliatelle
Type AlertType `json:"type"`
Threshold *float64 `json:"threshold"`
Minimum *float64 `json:"minimum"`
Maximum *float64 `json:"maximum"`
Enabled bool `json:"enabled"`
CreatedAt time.Time `json:"created_at"` //nolint:tagliatelle
}

View File

@@ -0,0 +1,104 @@
package domain
import "testing"
func TestIsValidTier(t *testing.T) {
t.Parallel()
valid := []string{"free", "premium", "pro"}
for _, tier := range valid {
if !IsValidTier(tier) {
t.Errorf("expected %q to be valid", tier)
}
}
invalid := []string{"", "basic", "enterprise", "FREE", "Pro"}
for _, tier := range invalid {
if IsValidTier(tier) {
t.Errorf("expected %q to be invalid", tier)
}
}
}
func TestGetTierLimits_Free(t *testing.T) {
t.Parallel()
limits := GetTierLimits(TierFree)
if limits.MaxAddresses != 1 {
t.Errorf("free MaxAddresses = %d, want 1", limits.MaxAddresses)
}
if limits.MaxAlertTypes != 1 {
t.Errorf("free MaxAlertTypes = %d, want 1", limits.MaxAlertTypes)
}
if len(limits.AllowedChannels) != 1 || limits.AllowedChannels[0] != "email" {
t.Errorf("free AllowedChannels = %v, want [email]", limits.AllowedChannels)
}
if limits.IsUnlimitedAddresses() {
t.Error("free should not have unlimited addresses")
}
if limits.IsUnlimitedAlertTypes() {
t.Error("free should not have unlimited alert types")
}
}
func TestGetTierLimits_Premium(t *testing.T) {
t.Parallel()
limits := GetTierLimits(TierPremium)
if limits.MaxAddresses != 3 {
t.Errorf("premium MaxAddresses = %d, want 3", limits.MaxAddresses)
}
if limits.MaxAlertTypes != 2 {
t.Errorf("premium MaxAlertTypes = %d, want 2", limits.MaxAlertTypes)
}
if !limits.ChannelAllowed("email") {
t.Error("premium should allow email")
}
if !limits.ChannelAllowed("discord") {
t.Error("premium should allow discord")
}
if !limits.ChannelAllowed("telegram") {
t.Error("premium should allow telegram")
}
if limits.ChannelAllowed("slack") {
t.Error("premium should NOT allow slack")
}
}
func TestGetTierLimits_Pro(t *testing.T) {
t.Parallel()
limits := GetTierLimits(TierPro)
if !limits.IsUnlimitedAddresses() {
t.Error("pro should have unlimited addresses")
}
if !limits.IsUnlimitedAlertTypes() {
t.Error("pro should have unlimited alert types")
}
for _, ch := range []string{"email", "discord", "telegram", "slack"} {
if !limits.ChannelAllowed(ch) {
t.Errorf("pro should allow %s", ch)
}
}
}
func TestGetTierLimits_Unknown(t *testing.T) {
t.Parallel()
limits := GetTierLimits(SubscriptionTier("unknown"))
if limits.MaxAddresses != 1 {
t.Errorf("unknown tier should default to free limits, got MaxAddresses=%d", limits.MaxAddresses)
}
}
func TestChannelAllowed_NotInList(t *testing.T) {
t.Parallel()
limits := GetTierLimits(TierFree)
if limits.ChannelAllowed("discord") {
t.Error("free should not allow discord")
}
if limits.ChannelAllowed("nonexistent") {
t.Error("nonexistent channel should not be allowed")
}
}

View File

@@ -6,17 +6,19 @@ import (
"time"
"github.com/kjannette/koin-ping/backend/internal/config"
"github.com/kjannette/koin-ping/backend/internal/domain"
"github.com/kjannette/koin-ping/backend/internal/middleware"
"github.com/kjannette/koin-ping/backend/internal/models"
)
type AccountHandler struct {
users *models.UserModel
addresses *models.AddressModel
cfg *config.Config
}
func NewAccountHandler(users *models.UserModel, cfg *config.Config) *AccountHandler {
return &AccountHandler{users: users, cfg: cfg}
func NewAccountHandler(users *models.UserModel, addresses *models.AddressModel, cfg *config.Config) *AccountHandler {
return &AccountHandler{users: users, addresses: addresses, cfg: cfg}
}
type accountResponse struct {
@@ -24,13 +26,22 @@ type accountResponse struct {
Email string `json:"email"`
UserName string `json:"user_name"`
SubscriptionStatus string `json:"subscription_status"`
SubscriptionTier string `json:"subscription_tier"`
SubscriptionPlan string `json:"subscription_plan"`
TierLimits domain.TierLimits `json:"tier_limits"`
AddressCount int `json:"address_count"`
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"`
}
var tierPlanLabels = map[domain.SubscriptionTier]string{ //nolint:gochecknoglobals
domain.TierFree: "Free Trial",
domain.TierPremium: "Premium / $1.99 mo",
domain.TierPro: "Pro / $11.99 mo",
}
func (h *AccountHandler) GetAccount(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r.Context())
email := middleware.GetUserEmail(r.Context())
@@ -42,12 +53,26 @@ func (h *AccountHandler) GetAccount(w http.ResponseWriter, r *http.Request) {
return
}
addrCount, err := h.addresses.CountByUser(r.Context(), userID)
if err != nil {
log.Printf("Account: failed to count addresses for %s: %v", userID, err)
addrCount = 0
}
planLabel := tierPlanLabels[user.SubscriptionTier]
if planLabel == "" {
planLabel = "Free Trial"
}
resp := accountResponse{
UserID: user.ID,
Email: email,
UserName: email,
SubscriptionStatus: user.SubscriptionStatus,
SubscriptionPlan: "monthly/$1.99",
SubscriptionTier: string(user.SubscriptionTier),
SubscriptionPlan: planLabel,
TierLimits: domain.GetTierLimits(user.SubscriptionTier),
AddressCount: addrCount,
}
if user.SubscriptionCreatedAt != nil {
@@ -55,9 +80,6 @@ func (h *AccountHandler) GetAccount(w http.ResponseWriter, r *http.Request) {
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

View File

@@ -3,6 +3,7 @@ package handlers
import (
"encoding/json"
"fmt"
"log"
"net/http"
"regexp"
@@ -17,10 +18,11 @@ var ethAddressRe = regexp.MustCompile(`^0x[a-fA-F0-9]{40}$`)
type AddressHandler struct {
addresses *models.AddressModel
users *models.UserModel
}
func NewAddressHandler(addresses *models.AddressModel) *AddressHandler {
return &AddressHandler{addresses: addresses}
func NewAddressHandler(addresses *models.AddressModel, users *models.UserModel) *AddressHandler {
return &AddressHandler{addresses: addresses, users: users}
}
func (h *AddressHandler) Create(w http.ResponseWriter, r *http.Request) {
@@ -49,6 +51,28 @@ func (h *AddressHandler) Create(w http.ResponseWriter, r *http.Request) {
return
}
user, err := h.users.GetByID(r.Context(), userID)
if err != nil || user == nil {
log.Printf("Failed to get user %s for tier check: %v", userID, err)
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to verify account")
return
}
limits := domain.GetTierLimits(user.SubscriptionTier)
if !limits.IsUnlimitedAddresses() {
count, err := h.addresses.CountByUser(r.Context(), userID)
if err != nil {
log.Printf("Failed to count addresses for user %s: %v", userID, err)
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to create address")
return
}
if count >= limits.MaxAddresses {
writeError(w, http.StatusForbidden, "TIER_LIMIT_REACHED",
fmt.Sprintf("Your %s plan allows %d address(es). Upgrade to track more.", user.SubscriptionTier, limits.MaxAddresses))
return
}
}
log.Printf("User %s creating address: %s", userID, body.Address)
addr, err := h.addresses.Create(r.Context(), userID, body.Address, body.Label)

View File

@@ -19,10 +19,11 @@ var errThresholdFormat = errors.New("unsupported threshold format")
type AlertRuleHandler struct {
alertRules *models.AlertRuleModel
addresses *models.AddressModel
users *models.UserModel
}
func NewAlertRuleHandler(alertRules *models.AlertRuleModel, addresses *models.AddressModel) *AlertRuleHandler {
return &AlertRuleHandler{alertRules: alertRules, addresses: addresses}
func NewAlertRuleHandler(alertRules *models.AlertRuleModel, addresses *models.AddressModel, users *models.UserModel) *AlertRuleHandler {
return &AlertRuleHandler{alertRules: alertRules, addresses: addresses, users: users}
}
func (h *AlertRuleHandler) Create(w http.ResponseWriter, r *http.Request) {
@@ -37,6 +38,8 @@ func (h *AlertRuleHandler) Create(w http.ResponseWriter, r *http.Request) {
var body struct {
Type string `json:"type"`
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 {
log.Printf("Failed to decode alert request body: %v", err)
@@ -53,6 +56,40 @@ func (h *AlertRuleHandler) Create(w http.ResponseWriter, r *http.Request) {
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)
if body.Type == "" {
@@ -96,7 +133,29 @@ func (h *AlertRuleHandler) Create(w http.ResponseWriter, r *http.Request) {
return
}
newAlert, err := h.alertRules.Create(r.Context(), addressID, alertType, threshold)
user, userErr := h.users.GetByID(r.Context(), userID)
if userErr != nil || user == nil {
log.Printf("Failed to get user %s for tier check: %v", userID, userErr)
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to verify account")
return
}
limits := domain.GetTierLimits(user.SubscriptionTier)
if !limits.IsUnlimitedAlertTypes() {
typeCount, countErr := h.alertRules.CountDistinctTypesByAddress(r.Context(), addressID)
if countErr != nil {
log.Printf("Failed to count alert types for address %d: %v", addressID, countErr)
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to create alert rule")
return
}
if typeCount >= limits.MaxAlertTypes {
writeError(w, http.StatusForbidden, "TIER_LIMIT_REACHED",
fmt.Sprintf("Your %s plan allows %d alert type(s) per address. Upgrade for more.", user.SubscriptionTier, limits.MaxAlertTypes))
return
}
}
newAlert, err := h.alertRules.Create(r.Context(), addressID, alertType, threshold, minimum, maximum)
if err != nil {
log.Printf("Error creating alert rule: %v", err)
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to create alert rule")
@@ -182,7 +241,7 @@ func (h *AlertRuleHandler) ListByAddress(w http.ResponseWriter, r *http.Request)
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) {
userID := middleware.GetUserID(r.Context())
alertID, ok := parseIntParam(r.PathValue("alertId"))
@@ -194,6 +253,9 @@ func (h *AlertRuleHandler) UpdateStatus(w http.ResponseWriter, r *http.Request)
var body struct {
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 {
log.Printf("Failed to decode update request body: %v", err)
@@ -204,8 +266,8 @@ func (h *AlertRuleHandler) UpdateStatus(w http.ResponseWriter, r *http.Request)
log.Printf("User %s updating alert ID: %d", userID, alertID)
if body.Enabled == nil {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "enabled must be a boolean value")
if body.Enabled == nil && !body.UpdateMinMax {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "must provide enabled (boolean) or update_min_max with minimum/maximum values")
return
}
@@ -224,7 +286,54 @@ func (h *AlertRuleHandler) UpdateStatus(w http.ResponseWriter, r *http.Request)
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 {
log.Printf("Error updating alert: %v", err)
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to update alert")
@@ -233,6 +342,8 @@ func (h *AlertRuleHandler) UpdateStatus(w http.ResponseWriter, r *http.Request)
}
log.Printf("Alert %d updated: enabled=%v", alertID, *body.Enabled)
}
writeJSON(w, http.StatusOK, updated)
}

View File

@@ -18,11 +18,12 @@ var emailRe = regexp.MustCompile(`^[^\s@]+@[^\s@]+\.[^\s@]+$`)
type NotificationConfigHandler struct {
configs *models.NotificationConfigModel
users *models.UserModel
cfg *config.Config
}
func NewNotificationConfigHandler(configs *models.NotificationConfigModel, cfg *config.Config) *NotificationConfigHandler {
return &NotificationConfigHandler{configs: configs, cfg: cfg}
func NewNotificationConfigHandler(configs *models.NotificationConfigModel, users *models.UserModel, cfg *config.Config) *NotificationConfigHandler {
return &NotificationConfigHandler{configs: configs, users: users, cfg: cfg}
}
func (h *NotificationConfigHandler) GetConfig(w http.ResponseWriter, r *http.Request) {
@@ -95,6 +96,26 @@ func (h *NotificationConfigHandler) UpdateConfig(w http.ResponseWriter, r *http.
return
}
user, userErr := h.users.GetByID(r.Context(), userID)
if userErr != nil || user == nil {
log.Printf("Failed to get user %s for tier check: %v", userID, userErr)
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to verify account")
return
}
limits := domain.GetTierLimits(user.SubscriptionTier)
if !limits.ChannelAllowed("discord") {
body.DiscordWebhookURL = nil
}
if !limits.ChannelAllowed("telegram") {
body.TelegramBotToken = nil
body.TelegramChatID = nil
}
if !limits.ChannelAllowed("slack") {
body.SlackWebhookURL = nil
}
enabled := true
if body.NotificationEnabled != nil {
enabled = *body.NotificationEnabled

View File

@@ -2,6 +2,7 @@ package handlers
import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
@@ -12,6 +13,7 @@ import (
"github.com/stripe/stripe-go/v82/webhook"
"github.com/kjannette/koin-ping/backend/internal/config"
"github.com/kjannette/koin-ping/backend/internal/domain"
"github.com/kjannette/koin-ping/backend/internal/middleware"
"github.com/kjannette/koin-ping/backend/internal/models"
)
@@ -28,10 +30,45 @@ func NewStripeHandler(users *models.UserModel, cfg *config.Config) *StripeHandle
return &StripeHandler{users: users, cfg: cfg}
}
// CreateCheckoutSession creates a Stripe Checkout session for the monthly subscription.
func (h *StripeHandler) priceIDForTier(tier domain.SubscriptionTier) (string, error) {
switch tier {
case domain.TierPremium:
return h.cfg.StripePriceIDPremium, nil
case domain.TierPro:
return h.cfg.StripePriceIDPro, nil
default:
return "", fmt.Errorf("no Stripe price for tier %q", tier) //nolint:err113
}
}
// CreateCheckoutSession creates a Stripe Checkout session for the selected tier.
func (h *StripeHandler) CreateCheckoutSession(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r.Context())
var body struct {
Tier string `json:"tier"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "Invalid request body")
return
}
if body.Tier == "" {
body.Tier = "premium"
}
tier := domain.SubscriptionTier(body.Tier)
if tier != domain.TierPremium && tier != domain.TierPro {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Tier must be 'premium' or 'pro'")
return
}
priceID, err := h.priceIDForTier(tier)
if err != nil {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", err.Error())
return
}
user, err := h.users.GetByID(r.Context(), userID)
if err != nil || user == nil {
log.Printf("Failed to get user %s: %v", userID, err)
@@ -43,7 +80,7 @@ func (h *StripeHandler) CreateCheckoutSession(w http.ResponseWriter, r *http.Req
Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)),
LineItems: []*stripe.CheckoutSessionLineItemParams{
{
Price: stripe.String(h.cfg.StripePriceID),
Price: stripe.String(priceID),
Quantity: stripe.Int64(1),
},
},
@@ -53,6 +90,8 @@ func (h *StripeHandler) CreateCheckoutSession(w http.ResponseWriter, r *http.Req
CustomerEmail: stripe.String(user.Email),
}
params.AddMetadata("tier", string(tier))
if user.StripeCustomerID != nil && *user.StripeCustomerID != "" {
params.Customer = user.StripeCustomerID
params.CustomerEmail = nil
@@ -81,13 +120,13 @@ func (h *StripeHandler) GetSubscriptionStatus(w http.ResponseWriter, r *http.Req
writeJSON(w, http.StatusOK, map[string]any{
"subscription_status": user.SubscriptionStatus,
"subscription_tier": user.SubscriptionTier,
"subscription_created_at": user.SubscriptionCreatedAt,
})
}
// VerifyCheckoutSession retrieves a completed checkout session from Stripe,
// confirms payment, and activates the user's subscription in the database.
// This is the primary activation path; webhooks serve as a backup.
func (h *StripeHandler) VerifyCheckoutSession(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r.Context())
@@ -106,7 +145,7 @@ func (h *StripeHandler) VerifyCheckoutSession(w http.ResponseWriter, r *http.Req
return
}
if s.ClientReferenceID != userID {
if s.ClientReferenceID != "" && s.ClientReferenceID != userID {
writeError(w, http.StatusForbidden, "FORBIDDEN", "Session does not belong to this user")
return
}
@@ -116,6 +155,11 @@ func (h *StripeHandler) VerifyCheckoutSession(w http.ResponseWriter, r *http.Req
return
}
tier := domain.TierPremium
if t, ok := s.Metadata["tier"]; ok && domain.IsValidTier(t) {
tier = domain.SubscriptionTier(t)
}
customerID := ""
if s.Customer != nil {
customerID = s.Customer.ID
@@ -131,13 +175,92 @@ func (h *StripeHandler) VerifyCheckoutSession(w http.ResponseWriter, r *http.Req
}
}
if subscriptionID != "" && customerID != "" {
if err := h.users.ActivateSubscription(r.Context(), customerID, subscriptionID, "active"); err != nil {
if err := h.users.ActivateSubscription(r.Context(), customerID, subscriptionID, "active", tier); err != nil {
log.Printf("VerifyCheckout: failed to activate subscription: %v", err)
}
}
log.Printf("Checkout verified for user %s, customer %s, subscription %s", userID, customerID, subscriptionID)
writeJSON(w, http.StatusOK, map[string]string{"subscription_status": "active"})
log.Printf("Checkout verified for user %s, customer %s, subscription %s, tier %s", userID, customerID, subscriptionID, tier)
writeJSON(w, http.StatusOK, map[string]string{
"subscription_status": "active",
"subscription_tier": string(tier),
})
}
// ActivateFreeTier sets the user to the free tier without Stripe involvement.
func (h *StripeHandler) ActivateFreeTier(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r.Context())
if err := h.users.ActivateFreeTier(r.Context(), userID); err != nil {
log.Printf("ActivateFreeTier: failed for user %s: %v", userID, err)
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to activate free tier")
return
}
log.Printf("Free tier activated for user %s", userID)
writeJSON(w, http.StatusOK, map[string]string{
"subscription_status": "active",
"subscription_tier": "free",
})
}
// CreateOnboardingCheckout creates a Stripe Checkout session for a user who
// has not yet created an account. This is a public endpoint (no auth required).
// The Firebase account is created on the frontend only after payment succeeds.
func (h *StripeHandler) CreateOnboardingCheckout(w http.ResponseWriter, r *http.Request) {
var body struct {
Email string `json:"email"`
Tier string `json:"tier"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "Invalid request body")
return
}
if body.Email == "" {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Email is required")
return
}
if body.Tier == "" {
body.Tier = "premium"
}
tier := domain.SubscriptionTier(body.Tier)
if tier != domain.TierPremium && tier != domain.TierPro {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Tier must be 'premium' or 'pro'")
return
}
priceID, err := h.priceIDForTier(tier)
if err != nil {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", err.Error())
return
}
params := &stripe.CheckoutSessionParams{
Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)),
LineItems: []*stripe.CheckoutSessionLineItemParams{
{
Price: stripe.String(priceID),
Quantity: stripe.Int64(1),
},
},
SuccessURL: stripe.String(h.cfg.FrontendURL + "/subscribe?payment=success&session_id={CHECKOUT_SESSION_ID}"),
CancelURL: stripe.String(h.cfg.FrontendURL + "/subscribe?payment=cancelled"),
CustomerEmail: stripe.String(body.Email),
}
params.AddMetadata("tier", string(tier))
params.AddMetadata("onboarding", "true")
s, err := checkoutsession.New(params)
if err != nil {
log.Printf("Failed to create onboarding checkout session: %v", err)
writeError(w, http.StatusInternalServerError, "STRIPE_ERROR", "Failed to create checkout session")
return
}
writeJSON(w, http.StatusOK, map[string]string{"url": s.URL})
}
// CreatePortalSession creates a Stripe Billing Portal session so the user can
@@ -173,7 +296,6 @@ func (h *StripeHandler) CreatePortalSession(w http.ResponseWriter, r *http.Reque
}
// HandleWebhook processes incoming Stripe webhook events.
// This endpoint must NOT require authentication (Stripe calls it directly).
func (h *StripeHandler) HandleWebhook(w http.ResponseWriter, r *http.Request) {
payload, err := io.ReadAll(io.LimitReader(r.Body, webhookMaxBodyBytes))
if err != nil {
@@ -217,6 +339,11 @@ func (h *StripeHandler) handleCheckoutCompleted(r *http.Request, event stripe.Ev
return
}
tier := domain.TierPremium
if t, ok := session.Metadata["tier"]; ok && domain.IsValidTier(t) {
tier = domain.SubscriptionTier(t)
}
customerID := ""
if session.Customer != nil {
customerID = session.Customer.ID
@@ -233,12 +360,12 @@ func (h *StripeHandler) handleCheckoutCompleted(r *http.Request, event stripe.Ev
}
if subscriptionID != "" && customerID != "" {
if err := h.users.ActivateSubscription(r.Context(), customerID, subscriptionID, "active"); err != nil {
if err := h.users.ActivateSubscription(r.Context(), customerID, subscriptionID, "active", tier); err != nil {
log.Printf("Failed to activate subscription: %v", err)
}
}
log.Printf("Checkout completed for user %s, customer %s, subscription %s", userID, customerID, subscriptionID)
log.Printf("Checkout completed for user %s, customer %s, subscription %s, tier %s", userID, customerID, subscriptionID, tier)
}
func (h *StripeHandler) handleSubscriptionUpdated(r *http.Request, event stripe.Event) {
@@ -257,11 +384,26 @@ func (h *StripeHandler) handleSubscriptionUpdated(r *http.Request, event stripe.
}
status := string(sub.Status)
if err := h.users.ActivateSubscription(r.Context(), customerID, sub.ID, status); err != nil {
log.Printf("Failed to update subscription status: %v", err)
// Detect tier from the subscription's current price so that
// upgrades/downgrades via the Stripe portal are reflected.
tier := domain.TierPremium
if sub.Items != nil {
for _, item := range sub.Items.Data {
if item.Price != nil {
if t := h.cfg.TierForPriceID(item.Price.ID); t != "" {
tier = domain.SubscriptionTier(t)
break
}
}
}
}
log.Printf("Subscription %s updated to %s for customer %s", sub.ID, status, customerID)
if err := h.users.ActivateSubscription(r.Context(), customerID, sub.ID, status, tier); err != nil {
log.Printf("Failed to update subscription: %v", err)
}
log.Printf("Subscription %s updated to %s (tier %s) for customer %s", sub.ID, status, tier, customerID)
}
func (h *StripeHandler) handleSubscriptionDeleted(r *http.Request, event stripe.Event) {

View File

@@ -17,6 +17,7 @@ type contextKey string
const (
UserIDKey contextKey = "user_id"
UserEmailKey contextKey = "user_email"
UserTierKey contextKey = "user_tier"
)
type errorResponse struct {
@@ -94,6 +95,7 @@ func Authenticate(userModel *models.UserModel) func(http.Handler) http.Handler {
ctx := context.WithValue(r.Context(), UserIDKey, user.ID)
ctx = context.WithValue(ctx, UserEmailKey, email)
ctx = context.WithValue(ctx, UserTierKey, string(user.SubscriptionTier))
next.ServeHTTP(w, r.WithContext(ctx))
})
@@ -150,3 +152,10 @@ func GetUserEmail(ctx context.Context) string {
}
return ""
}
func GetUserTier(ctx context.Context) string {
if v, ok := ctx.Value(UserTierKey).(string); ok {
return v
}
return "free"
}

View File

@@ -0,0 +1,60 @@
package middleware
import (
"context"
"testing"
)
func TestGetUserID_Empty(t *testing.T) {
t.Parallel()
ctx := context.Background()
if id := GetUserID(ctx); id != "" {
t.Errorf("expected empty user ID, got %q", id)
}
}
func TestGetUserID_Set(t *testing.T) {
t.Parallel()
ctx := context.WithValue(context.Background(), UserIDKey, "abc-123")
if id := GetUserID(ctx); id != "abc-123" {
t.Errorf("expected abc-123, got %q", id)
}
}
func TestGetUserEmail_Empty(t *testing.T) {
t.Parallel()
ctx := context.Background()
if email := GetUserEmail(ctx); email != "" {
t.Errorf("expected empty email, got %q", email)
}
}
func TestGetUserEmail_Set(t *testing.T) {
t.Parallel()
ctx := context.WithValue(context.Background(), UserEmailKey, "test@example.com")
if email := GetUserEmail(ctx); email != "test@example.com" {
t.Errorf("expected test@example.com, got %q", email)
}
}
func TestGetUserTier_Default(t *testing.T) {
t.Parallel()
ctx := context.Background()
if tier := GetUserTier(ctx); tier != "free" {
t.Errorf("expected default tier 'free', got %q", tier)
}
}
func TestGetUserTier_Set(t *testing.T) {
t.Parallel()
ctx := context.WithValue(context.Background(), UserTierKey, "pro")
if tier := GetUserTier(ctx); tier != "pro" {
t.Errorf("expected 'pro', got %q", tier)
}
}

View File

@@ -55,12 +55,14 @@ func (m *AddressModel) ListByUser(ctx context.Context, userID string) ([]domain.
return addresses, rows.Err()
}
// ListAll returns all addresses system-wide (used by the poller).
// ListAll returns addresses for users with an active subscription (used by the poller).
func (m *AddressModel) ListAll(ctx context.Context) ([]domain.Address, error) {
rows, err := m.pool.Query(ctx,
`SELECT id, user_id, address, label, created_at
FROM addresses
ORDER BY created_at DESC`,
`SELECT a.id, a.user_id, a.address, a.label, a.created_at
FROM addresses a
JOIN users u ON u.id = a.user_id
WHERE u.subscription_status IN ('active', 'trialing')
ORDER BY a.created_at DESC`,
)
if err != nil {
return nil, err
@@ -125,6 +127,15 @@ func (m *AddressModel) UpdateLabel(ctx context.Context, id int, userID string, l
return &a, nil
}
func (m *AddressModel) CountByUser(ctx context.Context, userID string) (int, error) {
var count int
err := m.pool.QueryRow(ctx,
`SELECT COUNT(*) FROM addresses WHERE user_id = $1`,
userID,
).Scan(&count)
return count, err
}
func (m *AddressModel) Remove(ctx context.Context, id int, userID string) (bool, error) {
tag, err := m.pool.Exec(ctx,
`DELETE FROM addresses WHERE id = $1 AND user_id = $2`,

View File

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

View File

@@ -77,10 +77,12 @@ func (m *NotificationConfigModel) Remove(ctx context.Context, userID string) (bo
func (m *NotificationConfigModel) ListEnabled(ctx context.Context) ([]domain.NotificationConfig, error) {
rows, err := m.pool.Query(ctx,
`SELECT user_id, discord_webhook_url, telegram_chat_id, telegram_bot_token,
email, slack_webhook_url
FROM user_notification_configs
WHERE notification_enabled = TRUE`,
`SELECT nc.user_id, nc.discord_webhook_url, nc.telegram_chat_id, nc.telegram_bot_token,
nc.email, nc.slack_webhook_url
FROM user_notification_configs nc
JOIN users u ON u.id = nc.user_id
WHERE nc.notification_enabled = TRUE
AND u.subscription_status IN ('active', 'trialing')`,
)
if err != nil {
return nil, err

View File

@@ -19,14 +19,14 @@ func NewUserModel(pool *pgxpool.Pool) *UserModel {
const userColumns = `id, firebase_uid, email, display_name,
stripe_customer_id, stripe_subscription_id, subscription_status,
subscription_created_at, created_at, updated_at`
subscription_tier, subscription_created_at, created_at, updated_at`
func scanUser(row pgx.Row) (*domain.User, error) {
var u domain.User
err := row.Scan(
&u.ID, &u.FirebaseUID, &u.Email, &u.DisplayName,
&u.StripeCustomerID, &u.StripeSubscriptionID, &u.SubscriptionStatus,
&u.SubscriptionCreatedAt, &u.CreatedAt, &u.UpdatedAt,
&u.SubscriptionTier, &u.SubscriptionCreatedAt, &u.CreatedAt, &u.UpdatedAt,
)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
@@ -66,15 +66,37 @@ func (m *UserModel) UpdateStripeCustomer(ctx context.Context, userID, stripeCust
return err
}
func (m *UserModel) ActivateSubscription(ctx context.Context, stripeCustomerID, subscriptionID, status string) error {
func (m *UserModel) ActivateSubscription(ctx context.Context, stripeCustomerID, subscriptionID, status string, tier domain.SubscriptionTier) error {
_, err := m.pool.Exec(ctx,
`UPDATE users
SET stripe_subscription_id = $2,
subscription_status = $3,
subscription_tier = $4,
subscription_created_at = COALESCE(subscription_created_at, NOW()),
updated_at = NOW()
WHERE stripe_customer_id = $1`,
stripeCustomerID, subscriptionID, status,
stripeCustomerID, subscriptionID, status, string(tier),
)
return err
}
func (m *UserModel) UpdateSubscriptionTier(ctx context.Context, userID string, tier domain.SubscriptionTier) error {
_, err := m.pool.Exec(ctx,
`UPDATE users SET subscription_tier = $2, updated_at = NOW() WHERE id = $1`,
userID, string(tier),
)
return err
}
func (m *UserModel) ActivateFreeTier(ctx context.Context, userID string) error {
_, err := m.pool.Exec(ctx,
`UPDATE users
SET subscription_status = 'active',
subscription_tier = 'free',
subscription_created_at = COALESCE(subscription_created_at, NOW()),
updated_at = NOW()
WHERE id = $1`,
userID,
)
return err
}

View File

@@ -28,9 +28,8 @@ type EvaluatorService struct {
alertRules *models.AlertRuleModel
alertEvents *models.AlertEventModel
addresses *models.AddressModel
users *models.UserModel
notifConfigs *models.NotificationConfigModel
resendAPIKey string
emailFrom string
notifSem *semaphore.Weighted
notifWg sync.WaitGroup
}
@@ -40,18 +39,16 @@ func NewEvaluatorService(
alertRules *models.AlertRuleModel,
alertEvents *models.AlertEventModel,
addresses *models.AddressModel,
users *models.UserModel,
notifConfigs *models.NotificationConfigModel,
resendAPIKey string,
emailFrom string,
) *EvaluatorService {
return &EvaluatorService{
eth: eth,
alertRules: alertRules,
alertEvents: alertEvents,
addresses: addresses,
users: users,
notifConfigs: notifConfigs,
resendAPIKey: resendAPIKey,
emailFrom: emailFrom,
notifSem: semaphore.NewWeighted(maxConcurrentNotifications),
}
}
@@ -104,9 +101,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) {
switch rule.Type {
case domain.AlertIncomingTx:
return obs.Direction == domain.DirectionIncoming, nil
return s.matchesDirectionalTx(rule, obs, domain.DirectionIncoming)
case domain.AlertOutgoingTx:
return obs.Direction == domain.DirectionOutgoing, nil
return s.matchesDirectionalTx(rule, obs, domain.DirectionOutgoing)
case domain.AlertLargeTransfer:
return s.matchesLargeTransfer(rule, obs)
case domain.AlertBalanceBelow:
@@ -117,6 +114,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) {
if rule.Threshold == nil {
return false, nil
@@ -218,14 +251,16 @@ func (s *EvaluatorService) WaitForNotifications() {
s.notifWg.Wait()
}
func (s *EvaluatorService) buildNotifiers(cfg *domain.NotificationConfig) []notifications.Notifier {
func (s *EvaluatorService) buildNotifiers(cfg *domain.NotificationConfig, limits domain.TierLimits) []notifications.Notifier {
var notifiers []notifications.Notifier
if cfg.DiscordWebhookURL != nil && *cfg.DiscordWebhookURL != "" {
if limits.ChannelAllowed("discord") &&
cfg.DiscordWebhookURL != nil && *cfg.DiscordWebhookURL != "" {
notifiers = append(notifiers, &notifications.DiscordNotifier{WebhookURL: *cfg.DiscordWebhookURL})
}
if cfg.TelegramBotToken != nil && *cfg.TelegramBotToken != "" &&
if limits.ChannelAllowed("telegram") &&
cfg.TelegramBotToken != nil && *cfg.TelegramBotToken != "" &&
cfg.TelegramChatID != nil && *cfg.TelegramChatID != "" {
notifiers = append(notifiers, &notifications.TelegramNotifier{
BotToken: *cfg.TelegramBotToken,
@@ -233,17 +268,12 @@ func (s *EvaluatorService) buildNotifiers(cfg *domain.NotificationConfig) []noti
})
}
if cfg.SlackWebhookURL != nil && *cfg.SlackWebhookURL != "" {
if limits.ChannelAllowed("slack") &&
cfg.SlackWebhookURL != nil && *cfg.SlackWebhookURL != "" {
notifiers = append(notifiers, &notifications.SlackNotifier{WebhookURL: *cfg.SlackWebhookURL})
}
if cfg.Email != nil && *cfg.Email != "" {
notifiers = append(notifiers, &notifications.EmailNotifier{
APIKey: s.resendAPIKey,
From: s.emailFrom,
To: *cfg.Email,
})
}
// Email is delivered exclusively via the daily digest service, never real-time.
return notifiers
}
@@ -289,6 +319,13 @@ func (s *EvaluatorService) sendNotification(ctx context.Context, userID, message
return
}
user, err := s.users.GetByID(ctx, userID)
if err != nil || user == nil {
log.Printf("Failed to load user %s for tier check in notification: %v", userID, err)
return
}
limits := domain.GetTierLimits(user.SubscriptionTier)
meta := notifications.AlertMetadata{
TxHash: obs.Hash,
AddressLabel: addressLabel,
@@ -296,7 +333,7 @@ func (s *EvaluatorService) sendNotification(ctx context.Context, userID, message
Address: address,
}
for _, n := range s.buildNotifiers(notifConfig) {
for _, n := range s.buildNotifiers(notifConfig, limits) {
if err := sendWithRetry(ctx, n, message, meta); err != nil {
log.Printf("Notification channel failed for user %s after retries: %v", userID, err)
} else {

View File

@@ -90,6 +90,14 @@ func LessThan(weiA, weiB string) (bool, error) {
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".
func FormatAsEth(weiString string, decimals int) (string, error) {
eth, err := ToEth(weiString)

BIN
backend/poller → frontend/public/koin_exp_2.mp4 Executable file → Normal file

Binary file not shown.

View File

@@ -3,14 +3,14 @@ import { useAuth } from "./contexts/AuthContext";
import Navbar from "./components/Navbar";
import Login from "./pages/login/Login";
import Signup from "./pages/Signup";
import Subscribe from "./pages/subscribe/subscribe";
import Subscribe from "./pages/subscribe/Subscribe";
import Addresses from "./pages/addresses/Addresses";
import Alerts from "./pages/alerts/Alerts";
import AlertHistory from "./pages/alertHistory/AlertHistory";
import Account from "./pages/user_account/Account";
export default function App() {
const { currentUser } = useAuth();
const { currentUser, isSubscribed } = useAuth();
if (!currentUser) {
return (
@@ -23,6 +23,16 @@ export default function App() {
);
}
if (!isSubscribed) {
return (
<Routes>
<Route path="/subscribe" element={<Subscribe />} />
<Route path="/account" element={<><Navbar /><Account /></>} />
<Route path="*" element={<Navigate to="/subscribe" />} />
</Routes>
);
}
return (
<div>
<Navbar />

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
* @param {number} alertId - Alert rule ID

View File

@@ -1,15 +1,42 @@
import { getAuthHeaders } from "./authHeaders";
import { API_BASE } from "./config";
export async function createCheckoutSession() {
export async function createCheckoutSession(tier = "premium") {
const headers = await getAuthHeaders();
const res = await fetch(`${API_BASE}/stripe/create-checkout-session`, {
method: "POST",
headers: { ...headers, "Content-Type": "application/json" },
body: JSON.stringify({ tier }),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.message || "Failed to create checkout session");
}
return res.json();
}
export async function createOnboardingCheckout(email, tier = "premium") {
const res = await fetch(`${API_BASE}/stripe/create-onboarding-checkout`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, tier }),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.message || "Failed to create checkout session");
}
return res.json();
}
export async function activateFreeTier() {
const headers = await getAuthHeaders();
const res = await fetch(`${API_BASE}/stripe/activate-free`, {
method: "POST",
headers,
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.message || "Failed to create checkout session");
throw new Error(data.message || "Failed to activate free tier");
}
return res.json();
}

View File

@@ -8,6 +8,19 @@
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 {
font-weight: 700;
font-size: 1.4rem;
@@ -38,6 +51,14 @@
background-color: var(--color-bg-card);
}
/* ── Right Group: Email + Logout + Hamburger ─────────────── */
.navbar__right {
display: flex;
align-items: center;
gap: 1rem;
}
.navbar__user {
font-size: 1.275rem;
color: #777;
@@ -53,6 +74,10 @@
color: var(--color-primary-hover);
}
.navbar__user-link--mobile {
display: none;
}
.navbar__logout {
padding: 0.4rem 1rem;
font-size: 1.275rem;
@@ -65,59 +90,78 @@
transition: all 0.15s ease;
}
/* ── Navbar Responsive ───────────────────────────────────── */
/* ── 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 {
flex-wrap: wrap;
gap: 0.75rem;
padding: 0.75rem 1rem;
}
.navbar__brand {
margin-right: 1rem;
font-size: 1.25rem;
}
.navbar__links {
flex-wrap: wrap;
gap: 0.2rem;
}
.navbar__link {
padding: 0.35rem 0.75rem;
font-size: 1.2rem;
}
.navbar__user {
font-size: 1.1rem;
}
.navbar__logout {
padding: 0.3rem 0.75rem;
font-size: 1.1rem;
}
}
@media (max-width: 480px) {
.navbar {
.navbar__brand-group {
flex-direction: column;
align-items: flex-start;
padding: 0.75rem;
gap: 0.5rem;
}
.navbar__links {
width: 100%;
gap: 0.15rem;
}
.navbar__link {
padding: 0.3rem 0.6rem;
font-size: 1.05rem;
}
.navbar__brand {
margin-right: 0;
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 { useAuth } from "../contexts/AuthContext";
import NavPanel from "./navPanel/NavPanel";
import "./Navbar.css";
const navLinks = [
@@ -11,13 +13,20 @@ const navLinks = [
export default function Navbar() {
const { currentUser, logout } = useAuth();
const location = useLocation();
const [isNavPanelOpen, setIsNavPanelOpen] = useState(false);
if (!currentUser) return null;
return (
<>
<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>
<Link to="/account" className="navbar__user-link navbar__user-link--mobile">
{currentUser.email}
</Link>
</div>
<div className="navbar__links">
{navLinks.map(({ to, label }) => {
const isActive = location.pathname === to;
@@ -34,12 +43,29 @@ export default function Navbar() {
</div>
</div>
<div className="flex flex--center gap-lg">
<Link to="/account" className="navbar__user navbar__user-link">{currentUser.email}</Link>
<div className="navbar__right">
<Link to="/account" className="navbar__user navbar__user-link navbar__user-link--desktop">
{currentUser.email}
</Link>
<button onClick={logout} className="navbar__logout">
Logout
</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,120 @@
.tier-picker {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
}
.tier-picker__card {
position: relative;
display: flex;
flex-direction: column;
padding: 1.5rem 1.25rem;
background-color: var(--color-bg-elevated);
border: 1px solid var(--color-border);
border-radius: var(--radius-xl);
text-align: center;
}
.tier-picker__card--highlighted {
border-color: var(--color-primary);
box-shadow: 0 0 0 1px var(--color-primary);
}
.tier-picker__card--selected {
border-color: var(--color-success);
box-shadow: 0 0 0 1px var(--color-success);
}
.tier-picker__badge {
position: absolute;
top: -10px;
left: 50%;
transform: translateX(-50%);
background-color: var(--color-primary);
color: white;
font-size: 0.7rem;
font-weight: 700;
padding: 2px 12px;
border-radius: 10px;
white-space: nowrap;
}
.tier-picker__name {
margin-bottom: 0.5rem;
font-size: 1.05rem;
color: var(--color-text);
}
.tier-picker__price {
margin-bottom: 1.25rem;
}
.tier-picker__amount {
font-size: 2rem;
font-weight: 700;
color: white;
}
.tier-picker__period {
font-size: 0.85rem;
color: var(--color-text-dimmed);
}
.tier-picker__features {
list-style: none;
padding: 0;
margin: 0 0 1.25rem;
text-align: left;
flex-grow: 1;
}
.tier-picker__feature {
padding: 0.3rem 0;
font-size: 0.82rem;
color: var(--color-text-label);
}
.tier-picker__feature--disabled {
color: var(--color-text-dimmed);
opacity: 0.55;
}
.tier-picker__check {
color: var(--color-primary);
font-weight: bold;
margin-right: 0.4rem;
}
.tier-picker__dash {
margin-right: 0.4rem;
}
.tier-picker__btn {
width: 100%;
padding: 0.6rem;
background-color: var(--color-bg-card);
border: 1px solid var(--color-border);
color: var(--color-text);
cursor: pointer;
border-radius: var(--radius-md);
font-weight: 600;
}
.tier-picker__btn:hover {
background-color: var(--color-primary);
border-color: var(--color-primary);
}
.tier-picker__btn--selected {
background-color: var(--color-success);
border-color: var(--color-success);
color: white;
}
@media (max-width: 768px) {
.tier-picker {
grid-template-columns: 1fr;
max-width: 400px;
margin: 0 auto;
}
}

View File

@@ -0,0 +1,96 @@
import "./TierPicker.css";
const TIERS = [
{
id: "free",
name: "Trial Monitoring",
price: "$0",
period: "",
features: [
"Monitor 1 blockchain address 24/7",
"Configure alerts to fire on trigger events",
"1 transaction alert type per trigger (email digest)",
],
disabledFeatures: [
"Real-time Discord alerts",
"Real-time Telegram alerts",
"Real-time Slack alerts",
],
},
{
id: "premium",
name: "Premium Monitoring",
price: "$1.99",
period: "/month",
features: [
"Monitor 3 blockchain addresses",
"Configure alerts to fire on trigger events",
"Daily email digest alert",
"Real-time Discord alerts",
"Real-time Telegram alerts",
"2 transaction alert types per monitored address",
],
disabledFeatures: [
"Real-time Slack alerts",
],
highlighted: true,
},
{
id: "pro",
name: "Professional Monitoring",
price: "$11.99",
period: "/month",
features: [
"Monitor unlimited blockchain addresses",
"Configure alerts to fire on trigger events",
"Daily email digest alert",
"Real-time Discord alerts",
"Real-time Telegram alerts",
"Real-time Slack alerts",
"Unlimited transaction alert types per monitored address",
],
disabledFeatures: [],
},
];
export default function TierPicker({ onSelect, selectedTier }) {
return (
<div className="tier-picker">
{TIERS.map((tier) => (
<div
key={tier.id}
className={`tier-picker__card${tier.highlighted ? " tier-picker__card--highlighted" : ""}${selectedTier === tier.id ? " tier-picker__card--selected" : ""}`}
>
{tier.highlighted && (
<div className="tier-picker__badge">Most Popular</div>
)}
<h3 className="tier-picker__name">{tier.name}</h3>
<div className="tier-picker__price">
<span className="tier-picker__amount">{tier.price}</span>
{tier.period && (
<span className="tier-picker__period">{tier.period}</span>
)}
</div>
<ul className="tier-picker__features">
{tier.features.map((f) => (
<li key={f} className="tier-picker__feature">
<span className="tier-picker__check">&#10003;</span> {f}
</li>
))}
{tier.disabledFeatures.map((f) => (
<li key={f} className="tier-picker__feature tier-picker__feature--disabled">
<span className="tier-picker__dash">&mdash;</span> {f}
</li>
))}
</ul>
<button
className={`btn tier-picker__btn${selectedTier === tier.id ? " tier-picker__btn--selected" : ""}`}
onClick={() => onSelect(tier.id)}
>
{selectedTier === tier.id ? "Selected" : "Select"}
</button>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,32 @@
.upgrade-banner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.75rem 1rem;
margin-bottom: 1rem;
border-radius: var(--radius-md);
background-color: #3a2e00;
border: 1px solid #aa7700;
color: #ffcc44;
}
.upgrade-banner__message {
font-size: 0.88rem;
}
.upgrade-banner__link {
background: none;
border: 1px solid #aa7700;
color: #ffcc44;
padding: 0.25rem 0.75rem;
border-radius: var(--radius-md);
cursor: pointer;
font-size: 0.82rem;
font-weight: 600;
white-space: nowrap;
}
.upgrade-banner__link:hover {
background-color: #4a3800;
}

View File

@@ -0,0 +1,18 @@
import { useNavigate } from "react-router-dom";
import "./UpgradeBanner.css";
export default function UpgradeBanner({ message, linkTo = "/subscribe?upgrade=true" }) {
const navigate = useNavigate();
return (
<div className="upgrade-banner">
<span className="upgrade-banner__message">{message}</span>
<button
className="upgrade-banner__link"
onClick={() => navigate(linkTo)}
>
Upgrade
</button>
</div>
);
}

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

@@ -1,10 +1,10 @@
/**
* AuthContext - Firebase Authentication State Management
*
* Provides authentication state and methods throughout the app
* Provides authentication state, tier info, and methods throughout the app
*/
import { createContext, useContext, useEffect, useState } from "react";
import { createContext, useContext, useEffect, useState, useCallback } from "react";
import {
createUserWithEmailAndPassword,
signInWithEmailAndPassword,
@@ -12,9 +12,16 @@ import {
onAuthStateChanged,
} from "firebase/auth";
import { auth } from "../firebase/config";
import { getAccount } from "../api/account";
const AuthContext = createContext();
const DEFAULT_TIER_LIMITS = {
max_addresses: 1,
max_alert_types: 1,
allowed_channels: ["email"],
};
/**
* Hook to access auth context
* @returns {Object} Auth context value
@@ -28,16 +35,30 @@ export function useAuth() {
}
/**
* AuthProvider - Wraps app and provides auth state
* AuthProvider - Wraps app and provides auth state + tier info
*/
export function AuthProvider({ children }) {
const [currentUser, setCurrentUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
/**
* Sign up with email and password
*/
const [userTier, setUserTier] = useState("free");
const [tierLimits, setTierLimits] = useState(DEFAULT_TIER_LIMITS);
const [addressCount, setAddressCount] = useState(0);
const [subscriptionStatus, setSubscriptionStatus] = useState("none");
const refreshAccount = useCallback(async () => {
try {
const data = await getAccount();
setUserTier(data.subscription_tier || "free");
setTierLimits(data.tier_limits || DEFAULT_TIER_LIMITS);
setAddressCount(data.address_count || 0);
setSubscriptionStatus(data.subscription_status || "none");
} catch {
// account fetch can fail during onboarding before subscription is active
}
}, []);
async function signup(email, password) {
try {
setError(null);
@@ -53,9 +74,6 @@ export function AuthProvider({ children }) {
}
}
/**
* Log in with email and password
*/
async function login(email, password) {
try {
setError(null);
@@ -71,12 +89,13 @@ export function AuthProvider({ children }) {
}
}
/**
* Log out current user
*/
async function logout() {
try {
setError(null);
setUserTier("free");
setTierLimits(DEFAULT_TIER_LIMITS);
setAddressCount(0);
setSubscriptionStatus("none");
await signOut(auth);
} catch (err) {
setError(err.message);
@@ -84,18 +103,19 @@ export function AuthProvider({ children }) {
}
}
/**
* Listen for auth state changes
*/
useEffect(() => {
const unsubscribe = onAuthStateChanged(auth, (user) => {
setCurrentUser(user);
setLoading(false);
if (user) {
refreshAccount();
}
});
// Cleanup subscription
return unsubscribe;
}, []);
}, [refreshAccount]);
const isSubscribed =
subscriptionStatus === "active" || subscriptionStatus === "trialing";
const value = {
currentUser,
@@ -104,6 +124,12 @@ export function AuthProvider({ children }) {
logout,
error,
loading,
userTier,
tierLimits,
addressCount,
subscriptionStatus,
isSubscribed,
refreshAccount,
};
return (

View File

@@ -397,6 +397,14 @@ button {
}
}
/* Tier-locked: greyed out, non-interactive overlay for features above the user's plan */
.tier-locked {
opacity: 0.45;
pointer-events: none;
filter: grayscale(40%);
user-select: none;
}
/* Mobile */
@media (max-width: 480px) {
html {

View File

@@ -1,15 +1,22 @@
import { useState, useEffect } from "react";
import { useAuth } from "../../contexts/AuthContext";
import AddressForm from "../../components/AddressForm";
import UpgradeBanner from "../../components/UpgradeBanner";
import { getAddresses, createAddress, deleteAddress, updateAddress } from "../../api/addresses";
import "./Addresses.css";
export default function Addresses() {
const { tierLimits, refreshAccount } = useAuth();
const [addresses, setAddresses] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [editingId, setEditingId] = useState(null);
const [editLabel, setEditLabel] = useState("");
const maxAddresses = tierLimits.max_addresses;
const isUnlimited = maxAddresses === -1;
const atLimit = !isUnlimited && addresses.length >= maxAddresses;
useEffect(() => {
async function fetchAddresses() {
try {
@@ -32,6 +39,7 @@ export default function Addresses() {
const newAddress = await createAddress(data);
setAddresses((prev) => [...prev, newAddress]);
setError(null);
refreshAccount();
} catch (err) {
setError(err.message);
console.error("Failed to create address:", err);
@@ -47,6 +55,7 @@ export default function Addresses() {
await deleteAddress(id);
setAddresses((prev) => prev.filter((a) => a.id !== id));
setError(null);
refreshAccount();
} catch (err) {
setError(err.message);
console.error("Failed to delete address:", err);
@@ -80,7 +89,13 @@ export default function Addresses() {
<div className="page">
<h1>Add Addresses to Track</h1>
<div className="mb-xl">
{atLimit && (
<UpgradeBanner
message={`Your plan allows ${maxAddresses} address${maxAddresses !== 1 ? "es" : ""}. Upgrade to track more.`}
/>
)}
<div className={`mb-xl${atLimit ? " tier-locked" : ""}`}>
<AddressForm onSubmit={handleAddressSubmit} />
</div>

View File

@@ -51,6 +51,69 @@
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 {
display: flex;
gap: 0.5rem;
@@ -62,6 +125,20 @@
margin-top: 1rem;
}
/* Locked / disabled notification channel */
.alerts__channel-locked {
opacity: 0.55;
position: relative;
}
.alerts__channel-locked .upgrade-banner {
opacity: 1;
}
.alerts__channel-locked input {
pointer-events: none;
}
/* ── Alerts Page Responsive ──────────────────────────────── */
/* Laptop: tighten the gap */

View File

@@ -1,12 +1,15 @@
import { useState, useEffect } from "react";
import { useAuth } from "../../contexts/AuthContext";
import AlertForm from "../../components/AlertForm";
import Button from "../../components/Button";
import Input from "../../components/Input";
import UpgradeBanner from "../../components/UpgradeBanner";
import { getAddresses } from "../../api/addresses";
import {
getAlerts,
createAlert,
updateAlertStatus,
updateAlertThresholds,
deleteAlert,
} from "../../api/alerts";
import {
@@ -19,6 +22,8 @@ import {
import "./Alerts.css";
export default function Alerts() {
const { tierLimits, userTier } = useAuth();
const [addresses, setAddresses] = useState([]);
const [selectedAddressId, setSelectedAddressId] = useState(null);
const [alerts, setAlerts] = useState([]);
@@ -39,6 +44,79 @@ export default function Alerts() {
const [sendingDigest, setSendingDigest] = useState(false);
const [hasExistingConfig, setHasExistingConfig] = useState(false);
const [openAccordions, setOpenAccordions] = useState({});
const [thresholdEdits, setThresholdEdits] = useState({});
const allowedChannels = tierLimits.allowed_channels || ["email"];
const canTelegram = allowedChannels.includes("telegram");
const canDiscord = allowedChannels.includes("discord");
const canSlack = allowedChannels.includes("slack");
const maxAlertTypes = tierLimits.max_alert_types;
const isUnlimitedAlerts = maxAlertTypes === -1;
function distinctAlertTypeCount() {
const types = new Set(alerts.map((a) => a.type));
return types.size;
}
const atAlertLimit = !isUnlimitedAlerts && distinctAlertTypeCount() >= maxAlertTypes;
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(() => {
async function fetchData() {
try {
@@ -150,11 +228,11 @@ export default function Alerts() {
const config = {
notification_enabled: notificationEnabled,
discord_webhook_url: discordWebhookUrl || null,
telegram_bot_token: telegramBotToken || null,
telegram_chat_id: telegramChatId || null,
discord_webhook_url: canDiscord ? (discordWebhookUrl || null) : null,
telegram_bot_token: canTelegram ? (telegramBotToken || null) : null,
telegram_chat_id: canTelegram ? (telegramChatId || null) : null,
email: email || null,
slack_webhook_url: slackWebhookUrl || null,
slack_webhook_url: canSlack ? (slackWebhookUrl || null) : null,
};
await updateNotificationConfig(config);
@@ -298,7 +376,13 @@ export default function Alerts() {
</div>
</div>
<div className="mb-xl">
{atAlertLimit && userTier !== "pro" && (
<UpgradeBanner
message={`Your ${userTier} plan allows ${maxAlertTypes} alert type${maxAlertTypes !== 1 ? "s" : ""} per address. Upgrade for more.`}
/>
)}
<div className={`mb-xl${atAlertLimit ? " tier-locked" : ""}`}>
<h3>Create New Alert</h3>
<AlertForm onSubmit={handleAlertSubmit} />
</div>
@@ -314,7 +398,14 @@ export default function Alerts() {
</p>
) : (
<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
key={alert.id}
className={`alerts__rule ${!alert.enabled ? "alerts__rule--disabled" : ""}`}
@@ -329,27 +420,110 @@ export default function Alerts() {
Threshold: {alert.threshold} ETH
</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">
Status: {alert.enabled ? "Enabled" : "Disabled"}
Status:{" "}
{alert.enabled ? "Enabled" : "Disabled"}
</div>
</div>
<div className="flex flex--center gap-sm">
<Button
onClick={() =>
handleToggleAlert(alert.id, alert.enabled)
handleToggleAlert(
alert.id,
alert.enabled,
)
}
>
{alert.enabled ? "Disable" : "Enable"}
</Button>
<Button
onClick={() => handleDeleteAlert(alert.id)}
onClick={() =>
handleDeleteAlert(alert.id)
}
>
Delete
</Button>
</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>
))}
);
})}
</ul>
)}
</div>
@@ -391,19 +565,24 @@ export default function Alerts() {
{notificationEnabled && (
<>
{/* Telegram */}
<div className="section">
<div className={`section${!canTelegram ? " alerts__channel-locked" : ""}`}>
<h3 className="mt-0 mb-md">Telegram</h3>
{!canTelegram && (
<UpgradeBanner message="Upgrade to Premium to enable Telegram alerts" />
)}
<Input
label="Bot Token"
value={telegramBotToken}
onChange={setTelegramBotToken}
placeholder="123456789:ABCdefGHIjklMNOpqrSTUvwxYZ"
disabled={!canTelegram}
/>
<Input
label="Chat ID"
value={telegramChatId}
onChange={setTelegramChatId}
placeholder="-1001234567890"
disabled={!canTelegram}
/>
<a
href="https://core.telegram.org/bots#how-do-i-create-a-bot"
@@ -448,13 +627,17 @@ export default function Alerts() {
</div>
{/* Discord */}
<div className="section">
<div className={`section${!canDiscord ? " alerts__channel-locked" : ""}`}>
<h3 className="mt-0 mb-md">Discord</h3>
{!canDiscord && (
<UpgradeBanner message="Upgrade to Premium to enable Discord alerts" />
)}
<Input
label="Discord Webhook URL"
value={discordWebhookUrl}
onChange={setDiscordWebhookUrl}
placeholder="https://discord.com/api/webhooks/..."
disabled={!canDiscord}
/>
<a
href="https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks"
@@ -467,13 +650,17 @@ export default function Alerts() {
</div>
{/* Slack */}
<div className="section">
<div className={`section${!canSlack ? " alerts__channel-locked" : ""}`}>
<h3 className="mt-0 mb-md">Slack</h3>
{!canSlack && (
<UpgradeBanner message="Upgrade to Pro to enable Slack alerts" />
)}
<Input
label="Slack Webhook URL"
value={slackWebhookUrl}
onChange={setSlackWebhookUrl}
placeholder="https://hooks.slack.com/services/..."
disabled={!canSlack}
/>
<a
href="https://api.slack.com/messaging/webhooks"

View File

@@ -89,6 +89,7 @@
color: #FFFFFF;
font-size: 1.5rem;
line-height: 1.5;
text-align: center;
}
.login-interactive-fadein {

View File

@@ -42,7 +42,7 @@ export default function Login() {
playsInline
className="login-bg-video"
>
<source src="/koin_spin.mp4" type="video/mp4" />
<source src="/koin_exp_2.mp4" type="video/mp4" />
</video>
<div className="login-card login-card-fadein">

View File

@@ -15,6 +15,10 @@
padding: 0 1rem;
}
.subscribe__container--wide {
max-width: 920px;
}
.subscribe__title {
text-align: center;
margin-bottom: 2rem;
@@ -237,6 +241,29 @@
font-style: italic;
}
/* Tier limit hints */
.subscribe__tier-hint {
font-size: 0.82rem;
color: #ffcc44;
margin-top: 0.5rem;
}
/* Disabled channel group */
.subscribe__channel-disabled {
opacity: 0.5;
pointer-events: none;
}
.subscribe__channel-disabled .subscribe__tier-hint {
pointer-events: auto;
opacity: 1;
}
/* Disabled checkbox row */
.checkbox-row--disabled {
opacity: 0.5;
}
/* ── Subscribe Responsive ────────────────────────────────── */
@media (max-width: 480px) {

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 { useNavigate, useSearchParams } from "react-router-dom";
import { useAuth } from "../../contexts/AuthContext";
@@ -15,13 +7,21 @@ import {
updateNotificationConfig,
testNotificationChannels,
} from "../../api/notificationConfig";
import { createCheckoutSession, getSubscriptionStatus, verifyCheckoutSession } from "../../api/stripe";
import { createOnboardingCheckout, verifyCheckoutSession, activateFreeTier } from "../../api/stripe";
import Input from "../../components/Input";
import Button from "../../components/Button";
import TierPicker from "../../components/TierPicker";
import "./Subscribe.css";
const TIER_LIMITS = {
free: { maxAlertTypes: 1, channels: ["email"] },
premium: { maxAlertTypes: 2, channels: ["email", "discord", "telegram"] },
pro: { maxAlertTypes: 4, channels: ["email", "discord", "telegram", "slack"] },
};
const STEPS = [
"Create Account",
"Choose Plan",
"Add Wallet",
"Alert Rules",
"Notifications",
@@ -33,14 +33,17 @@ export default function Subscribe() {
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const [step, setStep] = useState(1);
const [loading, setLoading] = useState(false);
const hasPaymentReturn = searchParams.get("payment") === "success";
const isUpgrade = searchParams.get("upgrade") === "true";
const [step, setStep] = useState(hasPaymentReturn || currentUser ? 2 : 1);
const [loading, setLoading] = useState(hasPaymentReturn);
const [error, setError] = useState("");
const [skipWarning, setSkipWarning] = useState("");
const [testResults, setTestResults] = useState(null);
const [testLoading, setTestLoading] = useState(false);
const [data, setData] = useState({
selectedTier: "",
email: "",
password: "",
confirmPassword: "",
@@ -64,8 +67,10 @@ export default function Subscribe() {
setData((prev) => ({ ...prev, [field]: value }));
}
const tierLimits = TIER_LIMITS[data.selectedTier] || TIER_LIMITS.free;
useEffect(() => {
if (!currentUser) return;
if (!currentUser || isUpgrade) return;
getAddresses()
.then((addresses) => {
if (addresses.length > 0) {
@@ -73,35 +78,64 @@ export default function Subscribe() {
}
})
.catch(() => { });
}, [currentUser, navigate]);
}, [currentUser, navigate, isUpgrade]);
// Handle Stripe redirect back from checkout
useEffect(() => {
if (!currentUser) return;
const payment = searchParams.get("payment");
const sessionId = searchParams.get("session_id");
if (payment === "success" && sessionId) {
if (payment === "cancelled") {
setSearchParams({}, { replace: true });
setStep(2);
setError("Payment was cancelled. Please try again.");
return;
}
if (payment !== "success" || !sessionId) return;
// Phase 1: no account yet create it. This triggers an auth state change
// which remounts the component (App.jsx swaps route trees). The URL params
// are preserved so Phase 2 runs on the next mount.
if (!currentUser) {
const savedEmail = sessionStorage.getItem("kp_onboard_email");
const savedPw = sessionStorage.getItem("kp_onboard_pw");
if (!savedEmail || !savedPw) {
setSearchParams({}, { replace: true });
setError("Session expired. Please start the signup process again.");
setStep(1);
setLoading(false);
return;
}
setLoading(true);
signup(savedEmail, savedPw).catch((err) => {
setSearchParams({}, { replace: true });
setError("Account creation failed: " + err.message);
setStep(1);
setLoading(false);
});
return;
}
// Phase 2: authenticated verify the checkout and activate subscription.
setSearchParams({}, { replace: true });
setLoading(true);
sessionStorage.removeItem("kp_onboard_email");
sessionStorage.removeItem("kp_onboard_pw");
verifyCheckoutSession(sessionId)
.then(() => {
setStep(2);
setStep(3);
})
.catch((err) => {
setError("Payment verification failed: " + err.message);
setStep(1);
setStep(2);
})
.finally(() => setLoading(false));
} else if (payment === "cancelled") {
setSearchParams({}, { replace: true });
setStep(1);
setError("Payment was cancelled. Please try again.");
}
}, [currentUser, searchParams, setSearchParams]);
}, [currentUser, searchParams, setSearchParams, signup]);
// Step handlers
async function handleStep1() {
function handleStep1() {
setError("");
if (!data.email || !data.password || !data.confirmPassword) {
setError("Please fill in all fields");
@@ -115,17 +149,30 @@ export default function Subscribe() {
setError("Password must be at least 6 characters");
return;
}
setStep(2);
}
async function handleStep2() {
setError("");
if (!data.selectedTier) {
setError("Please select a plan to continue");
return;
}
try {
setLoading(true);
if (data.selectedTier === "free") {
if (!currentUser) {
await signup(data.email, data.password);
}
const status = await getSubscriptionStatus();
if (status.subscription_status === "active" || status.subscription_status === "trialing") {
setStep(2);
await activateFreeTier();
setStep(3);
return;
}
const { url } = await createCheckoutSession();
sessionStorage.setItem("kp_onboard_email", data.email);
sessionStorage.setItem("kp_onboard_pw", data.password);
const { url } = await createOnboardingCheckout(data.email, data.selectedTier);
window.location.href = url;
} catch (err) {
if (err.code === "auth/email-already-in-use") {
@@ -135,13 +182,14 @@ export default function Subscribe() {
} else if (err.code === "auth/weak-password") {
setError("Password is too weak");
} else {
setError("Failed to create account: " + err.message);
setError("Failed to process plan selection: " + err.message);
}
} finally {
setLoading(false);
}
}
async function handleStep2() {
async function handleStep3() {
setError("");
if (!data.walletAddress) {
setError("Please enter a wallet address");
@@ -158,7 +206,7 @@ export default function Subscribe() {
label: data.walletLabel || undefined,
});
set("createdAddressId", created.id);
setStep(3);
setStep(4);
} catch (err) {
setError(err.message);
} finally {
@@ -166,7 +214,7 @@ export default function Subscribe() {
}
}
async function handleStep3() {
async function handleStep4() {
setError("");
const rules = [];
if (data.alertIncomingTx) rules.push({ type: "incoming_tx" });
@@ -187,7 +235,7 @@ export default function Subscribe() {
}
if (rules.length === 0) {
setStep(4);
setStep(5);
return;
}
@@ -199,7 +247,7 @@ export default function Subscribe() {
created.push(result);
}
set("alertsCreated", created);
setStep(4);
setStep(5);
} catch (err) {
setError(err.message);
} finally {
@@ -207,12 +255,12 @@ export default function Subscribe() {
}
}
async function handleStep4() {
async function handleStep5() {
setError("");
const hasAny =
data.discordWebhookUrl || data.slackWebhookUrl || data.notificationEmail;
if (!hasAny) {
setStep(5);
setStep(6);
return;
}
try {
@@ -224,7 +272,7 @@ export default function Subscribe() {
email: data.notificationEmail || undefined,
});
set("notificationConfigured", true);
setStep(5);
setStep(6);
} catch (err) {
setError(err.message);
} finally {
@@ -245,6 +293,21 @@ export default function Subscribe() {
}
}
// Alert type limit helpers
function countSelectedAlerts() {
let count = 0;
if (data.alertIncomingTx) count++;
if (data.alertOutgoingTx) count++;
if (data.alertLargeTransfer) count++;
if (data.alertBalanceBelow) count++;
return count;
}
function canSelectMoreAlerts() {
return tierLimits.maxAlertTypes > countSelectedAlerts();
}
// Progress bar
function ProgressBar() {
@@ -292,7 +355,7 @@ export default function Subscribe() {
// Step content
function Step1() {
function StepCreateAccount() {
return (
<>
<h2 className="mb-lg">Create your account</h2>
@@ -325,7 +388,22 @@ export default function Subscribe() {
);
}
function Step2() {
function StepChoosePlan() {
return (
<>
<h2 className="mb-sm">Choose your monitoring plan</h2>
<p className="subscribe__subtitle">
Select the plan that works best for you. You can upgrade anytime.
</p>
<TierPicker
onSelect={(tier) => set("selectedTier", tier)}
selectedTier={data.selectedTier}
/>
</>
);
}
function StepAddWallet() {
return (
<>
<h2 className="mb-sm">Add a wallet address</h2>
@@ -351,28 +429,35 @@ export default function Subscribe() {
);
}
function Step3() {
function StepAlertRules() {
const atLimit = !canSelectMoreAlerts();
const maxTypes = tierLimits.maxAlertTypes;
return (
<>
<h2 className="mb-sm">Configure alert rules</h2>
<p className="subscribe__subtitle">
Choose which events trigger notifications. You can change these later.
Choose which events trigger notifications ({countSelectedAlerts()}/{maxTypes} selected).
You can change these later.
</p>
<CheckboxRow
checked={data.alertIncomingTx}
onChange={(v) => set("alertIncomingTx", v)}
label="Incoming transaction"
disabled={!data.alertIncomingTx && atLimit}
/>
<CheckboxRow
checked={data.alertOutgoingTx}
onChange={(v) => set("alertOutgoingTx", v)}
label="Outgoing transaction"
disabled={!data.alertOutgoingTx && atLimit}
/>
<CheckboxRow
checked={data.alertLargeTransfer}
onChange={(v) => set("alertLargeTransfer", v)}
label="Large transfer"
disabled={!data.alertLargeTransfer && atLimit}
>
{data.alertLargeTransfer && (
<div className="checkbox-row__nested">
@@ -392,6 +477,7 @@ export default function Subscribe() {
checked={data.alertBalanceBelow}
onChange={(v) => set("alertBalanceBelow", v)}
label="Balance below"
disabled={!data.alertBalanceBelow && atLimit}
>
{data.alertBalanceBelow && (
<div className="checkbox-row__nested">
@@ -407,11 +493,21 @@ export default function Subscribe() {
</div>
)}
</CheckboxRow>
{atLimit && data.selectedTier !== "pro" && (
<p className="subscribe__tier-hint">
Your {data.selectedTier} plan allows {maxTypes} alert type{maxTypes !== 1 ? "s" : ""} per address. Upgrade for more.
</p>
)}
</>
);
}
function Step4() {
function StepNotifications() {
const channels = tierLimits.channels;
const canDiscord = channels.includes("discord");
const canSlack = channels.includes("slack");
return (
<>
<h2 className="mb-sm">Set up notifications</h2>
@@ -419,7 +515,17 @@ export default function Subscribe() {
Add at least one channel so you receive alerts. All fields are optional.
</p>
<div className="mb-md">
<Input
label="Email address for alerts"
type="email"
value={data.notificationEmail}
onChange={(v) => set("notificationEmail", v)}
disabled={loading}
placeholder="you@example.com"
className="form-field--last"
/>
<div className={`mb-md${!canDiscord ? " subscribe__channel-disabled" : ""}`}>
<label className="form-label">
Discord Webhook URL{" "}
<a
@@ -436,12 +542,15 @@ export default function Subscribe() {
type="url"
value={data.discordWebhookUrl}
onChange={(v) => set("discordWebhookUrl", v)}
disabled={loading}
disabled={loading || !canDiscord}
placeholder="https://discord.com/api/webhooks/..."
/>
{!canDiscord && (
<p className="subscribe__tier-hint">Upgrade to Premium to enable Discord alerts</p>
)}
</div>
<div className="mb-md">
<div className={`mb-md${!canSlack ? " subscribe__channel-disabled" : ""}`}>
<label className="form-label">
Slack Webhook URL{" "}
<a
@@ -458,25 +567,18 @@ export default function Subscribe() {
type="url"
value={data.slackWebhookUrl}
onChange={(v) => set("slackWebhookUrl", v)}
disabled={loading}
disabled={loading || !canSlack}
placeholder="https://hooks.slack.com/services/..."
/>
{!canSlack && (
<p className="subscribe__tier-hint">Upgrade to Pro to enable Slack alerts</p>
)}
</div>
<Input
label="Email address for alerts"
type="email"
value={data.notificationEmail}
onChange={(v) => set("notificationEmail", v)}
disabled={loading}
placeholder="you@example.com"
className="form-field--last"
/>
</>
);
}
function Step5() {
function StepDone() {
const alertCount = data.alertsCreated.length;
const hasNotif = data.notificationConfigured;
@@ -487,6 +589,12 @@ export default function Subscribe() {
<div className="subscribe__summary">
<p className="subscribe__summary-title">Summary</p>
<ul className="subscribe__summary-list">
<li>
Plan:{" "}
<span className="text-white">
{data.selectedTier === "pro" ? "Pro" : data.selectedTier === "premium" ? "Premium" : "Free Trial"}
</span>
</li>
<li>
Wallet address added:{" "}
<span className="text-mono text-white-sm">
@@ -550,15 +658,16 @@ export default function Subscribe() {
// Shared helpers
function CheckboxRow({ checked, onChange, label, children }) {
function CheckboxRow({ checked, onChange, label, children, disabled }) {
return (
<div className="checkbox-row">
<div className={`checkbox-row${disabled ? " checkbox-row--disabled" : ""}`}>
<label className="checkbox-row__label">
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
className="checkbox-row__input"
disabled={disabled}
/>
{label}
</label>
@@ -570,17 +679,18 @@ export default function Subscribe() {
// Footer navigation
function Footer() {
if (step === 5) return null;
if (step === 6) return null;
const canSkip = step === 3 || step === 4;
const canBack = step > 2;
const canSkip = step === 4 || step === 5;
const canBack = step > 1 && step <= 5;
async function handleNext() {
setSkipWarning("");
if (step === 1) await handleStep1();
if (step === 1) handleStep1();
else if (step === 2) await handleStep2();
else if (step === 3) await handleStep3();
else if (step === 4) await handleStep4();
else if (step === 5) await handleStep5();
}
function handleSkip() {
@@ -596,8 +706,14 @@ export default function Subscribe() {
}
const nextLabel = step === 1
? "Create Account & Subscribe"
: step === 4
? "Create Account"
: step === 2
? !data.selectedTier
? "Continue"
: data.selectedTier === "free"
? "Start Free Trial"
: "Subscribe & Continue"
: step === 5
? "Finish"
: "Next →";
@@ -627,7 +743,7 @@ export default function Subscribe() {
)}
<Button
onClick={handleNext}
disabled={loading}
disabled={loading || (step === 2 && !data.selectedTier)}
className="text-bold"
>
{loading ? "Please wait..." : nextLabel}
@@ -640,16 +756,17 @@ export default function Subscribe() {
// Render
const stepContent = {
1: Step1(),
2: Step2(),
3: Step3(),
4: Step4(),
5: Step5(),
1: StepCreateAccount(),
2: StepChoosePlan(),
3: StepAddWallet(),
4: StepAlertRules(),
5: StepNotifications(),
6: StepDone(),
};
return (
<div className="subscribe">
<div className="subscribe__container">
<div className={`subscribe__container${step === 2 ? " subscribe__container--wide" : ""}`}>
<h1 className="subscribe__title">Koin Ping</h1>
{ProgressBar()}

View File

@@ -1,10 +1,18 @@
import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { updatePassword } from "firebase/auth";
import { auth } from "../../firebase/config";
import { getAccount, createPortalSession } from "../../api/account";
import "./Account.css";
const TIER_LABELS = {
free: "Free Trial",
premium: "Premium",
pro: "Pro",
};
export default function Account() {
const navigate = useNavigate();
const [account, setAccount] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
@@ -78,6 +86,7 @@ export default function Account() {
return <div className="page text-error">Error: {error}</div>;
}
const tier = account.subscription_tier || "free";
const isCanceling = account.cancel_at_period_end;
const statusLabel = isCanceling
? "Canceling"
@@ -86,6 +95,9 @@ export default function Account() {
: account.subscription_status.charAt(0).toUpperCase() +
account.subscription_status.slice(1);
const canUpgrade = tier === "free" || tier === "premium";
const hasPaidSub = tier !== "free";
return (
<div className="page account-page">
<h1 className="mb-lg">Account</h1>
@@ -114,7 +126,7 @@ export default function Account() {
<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>
<span className="account__value">{TIER_LABELS[tier] || account.subscription_plan}</span>
</div>
<div className="account__row">
<span className="account__label">Status</span>
@@ -149,6 +161,15 @@ export default function Account() {
)}
<div className="account__portal-section">
{canUpgrade && (
<button
onClick={() => navigate("/subscribe")}
className="btn btn--primary"
>
Upgrade Plan
</button>
)}
{hasPaidSub && (
<button
onClick={handleManageSubscription}
disabled={portalLoading}
@@ -156,8 +177,11 @@ export default function Account() {
>
{portalLoading ? "Redirecting..." : "Manage Subscription"}
</button>
)}
<p className="text-dimmed text-sm account__portal-hint">
Cancel subscription, update payment method, or view invoices via Stripe.
{hasPaidSub
? "Cancel subscription, update payment method, or view invoices via Stripe."
: "Upgrade to unlock more addresses, alert types, and notification channels."}
</p>
</div>
</div>

BIN
memberships.pdf Normal file

Binary file not shown.