@@ -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 ./...
|
||||
|
||||
@@ -1,20 +1,29 @@
|
||||
Start DB:
|
||||
Backend startup quickstarat:
|
||||
|
||||
brew services start postgresql@15
|
||||
## 1. brew services start postgresql@15
|
||||
|
||||
From the backend directory, you have a few options:
|
||||
OR
|
||||
|
||||
Option 1: Single command (both API + poller)
|
||||
/opt/homebrew/opt/postgresql@15/bin/pg_ctl -D /opt/homebrew/var/postgresql@15 start
|
||||
|
||||
## 2. From the backend directory, you have a few options:
|
||||
|
||||
## 3. Option 1: Single command (both API + poller)
|
||||
cd /Users/kjannette/workspace/koin_ping_0.2.0/backendmake dev-all
|
||||
|
||||
Option 2: Two separate terminals
|
||||
## 4. 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
|
||||
|
||||
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 dev-all — Runs both the API and poller concurrently.
|
||||
|
||||
## 6. ALLIN ONE:
|
||||
Make dev-all — Runs both the API and poller concurrently.
|
||||
@@ -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",
|
||||
|
||||
@@ -60,7 +60,6 @@ func main() {
|
||||
observer := services.NewObserverService(eth, addressModel, checkpointModel)
|
||||
evaluator := services.NewEvaluatorService(
|
||||
eth, alertRuleModel, alertEventModel, addressModel, notifConfigModel,
|
||||
cfg.ResendAPIKey, cfg.EmailFrom,
|
||||
)
|
||||
digestSvc := services.NewEmailDigestService(cfg.ResendAPIKey, cfg.EmailFrom, alertEventModel, notifConfigModel)
|
||||
|
||||
|
||||
8
backend/infra/migrations/009_add_subscription_tier.sql
Normal file
8
backend/infra/migrations/009_add_subscription_tier.sql
Normal 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;
|
||||
@@ -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()
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
@@ -132,6 +133,28 @@ func (h *AlertRuleHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
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.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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,7 +384,7 @@ 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 {
|
||||
if err := h.users.UpdateSubscriptionStatus(r.Context(), customerID, status); err != nil {
|
||||
log.Printf("Failed to update subscription status: %v", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -125,6 +125,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`,
|
||||
|
||||
@@ -121,6 +121,15 @@ func (m *AlertRuleModel) UpdateThresholds(ctx context.Context, id int, minimum,
|
||||
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`,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -29,8 +29,6 @@ type EvaluatorService struct {
|
||||
alertEvents *models.AlertEventModel
|
||||
addresses *models.AddressModel
|
||||
notifConfigs *models.NotificationConfigModel
|
||||
resendAPIKey string
|
||||
emailFrom string
|
||||
notifSem *semaphore.Weighted
|
||||
notifWg sync.WaitGroup
|
||||
}
|
||||
@@ -41,8 +39,6 @@ func NewEvaluatorService(
|
||||
alertEvents *models.AlertEventModel,
|
||||
addresses *models.AddressModel,
|
||||
notifConfigs *models.NotificationConfigModel,
|
||||
resendAPIKey string,
|
||||
emailFrom string,
|
||||
) *EvaluatorService {
|
||||
return &EvaluatorService{
|
||||
eth: eth,
|
||||
@@ -50,8 +46,6 @@ func NewEvaluatorService(
|
||||
alertEvents: alertEvents,
|
||||
addresses: addresses,
|
||||
notifConfigs: notifConfigs,
|
||||
resendAPIKey: resendAPIKey,
|
||||
emailFrom: emailFrom,
|
||||
notifSem: semaphore.NewWeighted(maxConcurrentNotifications),
|
||||
}
|
||||
}
|
||||
@@ -273,13 +267,7 @@ func (s *EvaluatorService) buildNotifiers(cfg *domain.NotificationConfig) []noti
|
||||
notifiers = append(notifiers, ¬ifications.SlackNotifier{WebhookURL: *cfg.SlackWebhookURL})
|
||||
}
|
||||
|
||||
if cfg.Email != nil && *cfg.Email != "" {
|
||||
notifiers = append(notifiers, ¬ifications.EmailNotifier{
|
||||
APIKey: s.resendAPIKey,
|
||||
From: s.emailFrom,
|
||||
To: *cfg.Email,
|
||||
})
|
||||
}
|
||||
// Email is delivered exclusively via the daily digest service, never real-time.
|
||||
|
||||
return notifiers
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
120
frontend/src/components/TierPicker.css
Normal file
120
frontend/src/components/TierPicker.css
Normal 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;
|
||||
}
|
||||
}
|
||||
96
frontend/src/components/TierPicker.jsx
Normal file
96
frontend/src/components/TierPicker.jsx
Normal 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">✓</span> {f}
|
||||
</li>
|
||||
))}
|
||||
{tier.disabledFeatures.map((f) => (
|
||||
<li key={f} className="tier-picker__feature tier-picker__feature--disabled">
|
||||
<span className="tier-picker__dash">—</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>
|
||||
);
|
||||
}
|
||||
32
frontend/src/components/UpgradeBanner.css
Normal file
32
frontend/src/components/UpgradeBanner.css
Normal 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;
|
||||
}
|
||||
18
frontend/src/components/UpgradeBanner.jsx
Normal file
18
frontend/src/components/UpgradeBanner.jsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import "./UpgradeBanner.css";
|
||||
|
||||
export default function UpgradeBanner({ message, linkTo = "/account" }) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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,28 @@ 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 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);
|
||||
} catch {
|
||||
// account fetch can fail during onboarding before subscription is active
|
||||
}
|
||||
}, []);
|
||||
|
||||
async function signup(email, password) {
|
||||
try {
|
||||
setError(null);
|
||||
@@ -53,9 +72,6 @@ export function AuthProvider({ children }) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log in with email and password
|
||||
*/
|
||||
async function login(email, password) {
|
||||
try {
|
||||
setError(null);
|
||||
@@ -71,12 +87,12 @@ export function AuthProvider({ children }) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log out current user
|
||||
*/
|
||||
async function logout() {
|
||||
try {
|
||||
setError(null);
|
||||
setUserTier("free");
|
||||
setTierLimits(DEFAULT_TIER_LIMITS);
|
||||
setAddressCount(0);
|
||||
await signOut(auth);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
@@ -84,18 +100,16 @@ 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 value = {
|
||||
currentUser,
|
||||
@@ -104,6 +118,10 @@ export function AuthProvider({ children }) {
|
||||
logout,
|
||||
error,
|
||||
loading,
|
||||
userTier,
|
||||
tierLimits,
|
||||
addressCount,
|
||||
refreshAccount,
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -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,9 +89,17 @@ export default function Addresses() {
|
||||
<div className="page">
|
||||
<h1>Add Addresses to Track</h1>
|
||||
|
||||
{atLimit && (
|
||||
<UpgradeBanner
|
||||
message={`Your plan allows ${maxAddresses} address${maxAddresses !== 1 ? "es" : ""}. Upgrade to track more.`}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!atLimit && (
|
||||
<div className="mb-xl">
|
||||
<AddressForm onSubmit={handleAddressSubmit} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<h2>Existing Tracked Addresses</h2>
|
||||
|
||||
@@ -125,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 */
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
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,
|
||||
@@ -20,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([]);
|
||||
@@ -43,6 +47,21 @@ export default function Alerts() {
|
||||
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] }));
|
||||
}
|
||||
@@ -209,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);
|
||||
@@ -357,10 +376,18 @@ export default function Alerts() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{atAlertLimit && userTier !== "pro" && (
|
||||
<UpgradeBanner
|
||||
message={`Your ${userTier} plan allows ${maxAlertTypes} alert type${maxAlertTypes !== 1 ? "s" : ""} per address. Upgrade for more.`}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!atAlertLimit && (
|
||||
<div className="mb-xl">
|
||||
<h3>Create New Alert</h3>
|
||||
<AlertForm onSubmit={handleAlertSubmit} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<h3>Active Alert Rules</h3>
|
||||
@@ -540,19 +567,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"
|
||||
@@ -597,13 +629,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"
|
||||
@@ -616,13 +652,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"
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -7,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",
|
||||
@@ -25,14 +33,16 @@ 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 [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: "",
|
||||
@@ -56,6 +66,8 @@ export default function Subscribe() {
|
||||
setData((prev) => ({ ...prev, [field]: value }));
|
||||
}
|
||||
|
||||
const tierLimits = TIER_LIMITS[data.selectedTier] || TIER_LIMITS.free;
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentUser) return;
|
||||
getAddresses()
|
||||
@@ -67,33 +79,62 @@ export default function Subscribe() {
|
||||
.catch(() => { });
|
||||
}, [currentUser, navigate]);
|
||||
|
||||
// Handle Stripe redirect back from checkout
|
||||
useEffect(() => {
|
||||
if (!currentUser) return;
|
||||
const payment = searchParams.get("payment");
|
||||
const sessionId = searchParams.get("session_id");
|
||||
if (payment === "success" && sessionId) {
|
||||
|
||||
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");
|
||||
@@ -107,17 +148,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") {
|
||||
@@ -127,13 +181,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");
|
||||
@@ -150,7 +205,7 @@ export default function Subscribe() {
|
||||
label: data.walletLabel || undefined,
|
||||
});
|
||||
set("createdAddressId", created.id);
|
||||
setStep(3);
|
||||
setStep(4);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
@@ -158,7 +213,7 @@ export default function Subscribe() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStep3() {
|
||||
async function handleStep4() {
|
||||
setError("");
|
||||
const rules = [];
|
||||
if (data.alertIncomingTx) rules.push({ type: "incoming_tx" });
|
||||
@@ -179,7 +234,7 @@ export default function Subscribe() {
|
||||
}
|
||||
|
||||
if (rules.length === 0) {
|
||||
setStep(4);
|
||||
setStep(5);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -191,7 +246,7 @@ export default function Subscribe() {
|
||||
created.push(result);
|
||||
}
|
||||
set("alertsCreated", created);
|
||||
setStep(4);
|
||||
setStep(5);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
@@ -199,12 +254,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 {
|
||||
@@ -216,7 +271,7 @@ export default function Subscribe() {
|
||||
email: data.notificationEmail || undefined,
|
||||
});
|
||||
set("notificationConfigured", true);
|
||||
setStep(5);
|
||||
setStep(6);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
@@ -237,6 +292,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() {
|
||||
@@ -284,7 +354,7 @@ export default function Subscribe() {
|
||||
|
||||
// ── Step content ──────────────────────────────────────────────────────────
|
||||
|
||||
function Step1() {
|
||||
function StepCreateAccount() {
|
||||
return (
|
||||
<>
|
||||
<h2 className="mb-lg">Create your account</h2>
|
||||
@@ -317,7 +387,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>
|
||||
@@ -343,28 +428,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">
|
||||
@@ -384,6 +476,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">
|
||||
@@ -399,11 +492,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>
|
||||
@@ -411,7 +514,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
|
||||
@@ -428,12 +541,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
|
||||
@@ -450,25 +566,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;
|
||||
|
||||
@@ -479,6 +588,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">
|
||||
@@ -542,15 +657,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>
|
||||
@@ -562,17 +678,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() {
|
||||
@@ -588,8 +705,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 →";
|
||||
|
||||
@@ -619,7 +742,7 @@ export default function Subscribe() {
|
||||
)}
|
||||
<Button
|
||||
onClick={handleNext}
|
||||
disabled={loading}
|
||||
disabled={loading || (step === 2 && !data.selectedTier)}
|
||||
className="text-bold"
|
||||
>
|
||||
{loading ? "Please wait..." : nextLabel}
|
||||
@@ -632,16 +755,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()}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user