Compare commits
7 Commits
setup-free
...
implement-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d7bc65995 | ||
|
|
05453895b9 | ||
|
|
14ed0a23a6 | ||
|
|
40ed4f6afd | ||
|
|
26324150d2 | ||
|
|
b0572451d3 | ||
|
|
d9c3bd1db5 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -22,6 +22,8 @@ node_modules/
|
|||||||
|
|
||||||
# Go build artifacts
|
# Go build artifacts
|
||||||
backend/bin/
|
backend/bin/
|
||||||
|
backend/api
|
||||||
|
backend/poller
|
||||||
*.exe
|
*.exe
|
||||||
*.exe~
|
*.exe~
|
||||||
*.dll
|
*.dll
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
Backend startup quickstarat:
|
Backend startup quickstarat:
|
||||||
|
-----------------------------> BEST
|
||||||
## 1. brew services start postgresql@15
|
## 1. brew services start postgresql@15
|
||||||
|
|
||||||
OR
|
OR
|
||||||
|
|
||||||
/opt/homebrew/opt/postgresql@15/bin/pg_ctl -D /opt/homebrew/var/postgresql@15 start
|
/opt/homebrew/opt/postgresql@15/bin/pg_ctl -D /opt/homebrew/var/postgresql@15 start
|
||||||
|
|
||||||
|
## 2. ALLIN ONE:
|
||||||
|
Make dev-all — Runs both the API and poller concurrently.
|
||||||
|
-----------------------------> BEST
|
||||||
|
|
||||||
|
- OR -
|
||||||
|
|
||||||
## 2. From the backend directory, you have a few options:
|
|
||||||
|
|
||||||
## 3. Option 1: Single command (both API + poller)
|
## 3. Option 1: Single command (both API + poller)
|
||||||
cd /Users/kjannette/workspace/koin_ping_0.2.0/backendmake dev-all
|
cd /Users/kjannette/workspace/koin_ping_0.2.0/backendmake dev-all
|
||||||
@@ -25,5 +30,3 @@ Backend startup quickstarat:
|
|||||||
make poller — Builds and runs the poller.
|
make poller — Builds and runs the poller.
|
||||||
make poller-dev — Runs the poller with auto-reload.
|
make poller-dev — Runs the poller with auto-reload.
|
||||||
|
|
||||||
## 6. ALLIN ONE:
|
|
||||||
Make dev-all — Runs both the API and poller concurrently.
|
|
||||||
BIN
backend/api
BIN
backend/api
Binary file not shown.
@@ -51,6 +51,7 @@ func main() {
|
|||||||
|
|
||||||
defer database.Close()
|
defer database.Close()
|
||||||
|
|
||||||
|
userModel := models.NewUserModel(pool)
|
||||||
addressModel := models.NewAddressModel(pool)
|
addressModel := models.NewAddressModel(pool)
|
||||||
alertRuleModel := models.NewAlertRuleModel(pool)
|
alertRuleModel := models.NewAlertRuleModel(pool)
|
||||||
alertEventModel := models.NewAlertEventModel(pool)
|
alertEventModel := models.NewAlertEventModel(pool)
|
||||||
@@ -59,7 +60,7 @@ func main() {
|
|||||||
|
|
||||||
observer := services.NewObserverService(eth, addressModel, checkpointModel)
|
observer := services.NewObserverService(eth, addressModel, checkpointModel)
|
||||||
evaluator := services.NewEvaluatorService(
|
evaluator := services.NewEvaluatorService(
|
||||||
eth, alertRuleModel, alertEventModel, addressModel, notifConfigModel,
|
eth, alertRuleModel, alertEventModel, addressModel, userModel, notifConfigModel,
|
||||||
)
|
)
|
||||||
digestSvc := services.NewEmailDigestService(cfg.ResendAPIKey, cfg.EmailFrom, alertEventModel, notifConfigModel)
|
digestSvc := services.NewEmailDigestService(cfg.ResendAPIKey, cfg.EmailFrom, alertEventModel, notifConfigModel)
|
||||||
|
|
||||||
|
|||||||
@@ -94,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 {
|
func getEnv(key, fallback string) string {
|
||||||
if v := os.Getenv(key); v != "" {
|
if v := os.Getenv(key); v != "" {
|
||||||
return v
|
return v
|
||||||
|
|||||||
28
backend/internal/config/config_test.go
Normal file
28
backend/internal/config/config_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
104
backend/internal/domain/types_test.go
Normal file
104
backend/internal/domain/types_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -384,11 +384,26 @@ func (h *StripeHandler) handleSubscriptionUpdated(r *http.Request, event stripe.
|
|||||||
}
|
}
|
||||||
|
|
||||||
status := string(sub.Status)
|
status := string(sub.Status)
|
||||||
if err := h.users.UpdateSubscriptionStatus(r.Context(), customerID, 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) {
|
func (h *StripeHandler) handleSubscriptionDeleted(r *http.Request, event stripe.Event) {
|
||||||
|
|||||||
60
backend/internal/middleware/auth_test.go
Normal file
60
backend/internal/middleware/auth_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,12 +55,14 @@ func (m *AddressModel) ListByUser(ctx context.Context, userID string) ([]domain.
|
|||||||
return addresses, rows.Err()
|
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) {
|
func (m *AddressModel) ListAll(ctx context.Context) ([]domain.Address, error) {
|
||||||
rows, err := m.pool.Query(ctx,
|
rows, err := m.pool.Query(ctx,
|
||||||
`SELECT id, user_id, address, label, created_at
|
`SELECT a.id, a.user_id, a.address, a.label, a.created_at
|
||||||
FROM addresses
|
FROM addresses a
|
||||||
ORDER BY created_at DESC`,
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -77,10 +77,12 @@ func (m *NotificationConfigModel) Remove(ctx context.Context, userID string) (bo
|
|||||||
|
|
||||||
func (m *NotificationConfigModel) ListEnabled(ctx context.Context) ([]domain.NotificationConfig, error) {
|
func (m *NotificationConfigModel) ListEnabled(ctx context.Context) ([]domain.NotificationConfig, error) {
|
||||||
rows, err := m.pool.Query(ctx,
|
rows, err := m.pool.Query(ctx,
|
||||||
`SELECT user_id, discord_webhook_url, telegram_chat_id, telegram_bot_token,
|
`SELECT nc.user_id, nc.discord_webhook_url, nc.telegram_chat_id, nc.telegram_bot_token,
|
||||||
email, slack_webhook_url
|
nc.email, nc.slack_webhook_url
|
||||||
FROM user_notification_configs
|
FROM user_notification_configs nc
|
||||||
WHERE notification_enabled = TRUE`,
|
JOIN users u ON u.id = nc.user_id
|
||||||
|
WHERE nc.notification_enabled = TRUE
|
||||||
|
AND u.subscription_status IN ('active', 'trialing')`,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ type EvaluatorService struct {
|
|||||||
alertRules *models.AlertRuleModel
|
alertRules *models.AlertRuleModel
|
||||||
alertEvents *models.AlertEventModel
|
alertEvents *models.AlertEventModel
|
||||||
addresses *models.AddressModel
|
addresses *models.AddressModel
|
||||||
|
users *models.UserModel
|
||||||
notifConfigs *models.NotificationConfigModel
|
notifConfigs *models.NotificationConfigModel
|
||||||
notifSem *semaphore.Weighted
|
notifSem *semaphore.Weighted
|
||||||
notifWg sync.WaitGroup
|
notifWg sync.WaitGroup
|
||||||
@@ -38,6 +39,7 @@ func NewEvaluatorService(
|
|||||||
alertRules *models.AlertRuleModel,
|
alertRules *models.AlertRuleModel,
|
||||||
alertEvents *models.AlertEventModel,
|
alertEvents *models.AlertEventModel,
|
||||||
addresses *models.AddressModel,
|
addresses *models.AddressModel,
|
||||||
|
users *models.UserModel,
|
||||||
notifConfigs *models.NotificationConfigModel,
|
notifConfigs *models.NotificationConfigModel,
|
||||||
) *EvaluatorService {
|
) *EvaluatorService {
|
||||||
return &EvaluatorService{
|
return &EvaluatorService{
|
||||||
@@ -45,6 +47,7 @@ func NewEvaluatorService(
|
|||||||
alertRules: alertRules,
|
alertRules: alertRules,
|
||||||
alertEvents: alertEvents,
|
alertEvents: alertEvents,
|
||||||
addresses: addresses,
|
addresses: addresses,
|
||||||
|
users: users,
|
||||||
notifConfigs: notifConfigs,
|
notifConfigs: notifConfigs,
|
||||||
notifSem: semaphore.NewWeighted(maxConcurrentNotifications),
|
notifSem: semaphore.NewWeighted(maxConcurrentNotifications),
|
||||||
}
|
}
|
||||||
@@ -248,14 +251,16 @@ func (s *EvaluatorService) WaitForNotifications() {
|
|||||||
s.notifWg.Wait()
|
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
|
var notifiers []notifications.Notifier
|
||||||
|
|
||||||
if cfg.DiscordWebhookURL != nil && *cfg.DiscordWebhookURL != "" {
|
if limits.ChannelAllowed("discord") &&
|
||||||
|
cfg.DiscordWebhookURL != nil && *cfg.DiscordWebhookURL != "" {
|
||||||
notifiers = append(notifiers, ¬ifications.DiscordNotifier{WebhookURL: *cfg.DiscordWebhookURL})
|
notifiers = append(notifiers, ¬ifications.DiscordNotifier{WebhookURL: *cfg.DiscordWebhookURL})
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.TelegramBotToken != nil && *cfg.TelegramBotToken != "" &&
|
if limits.ChannelAllowed("telegram") &&
|
||||||
|
cfg.TelegramBotToken != nil && *cfg.TelegramBotToken != "" &&
|
||||||
cfg.TelegramChatID != nil && *cfg.TelegramChatID != "" {
|
cfg.TelegramChatID != nil && *cfg.TelegramChatID != "" {
|
||||||
notifiers = append(notifiers, ¬ifications.TelegramNotifier{
|
notifiers = append(notifiers, ¬ifications.TelegramNotifier{
|
||||||
BotToken: *cfg.TelegramBotToken,
|
BotToken: *cfg.TelegramBotToken,
|
||||||
@@ -263,7 +268,8 @@ 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, ¬ifications.SlackNotifier{WebhookURL: *cfg.SlackWebhookURL})
|
notifiers = append(notifiers, ¬ifications.SlackNotifier{WebhookURL: *cfg.SlackWebhookURL})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -313,6 +319,13 @@ func (s *EvaluatorService) sendNotification(ctx context.Context, userID, message
|
|||||||
return
|
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{
|
meta := notifications.AlertMetadata{
|
||||||
TxHash: obs.Hash,
|
TxHash: obs.Hash,
|
||||||
AddressLabel: addressLabel,
|
AddressLabel: addressLabel,
|
||||||
@@ -320,7 +333,7 @@ func (s *EvaluatorService) sendNotification(ctx context.Context, userID, message
|
|||||||
Address: address,
|
Address: address,
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, n := range s.buildNotifiers(notifConfig) {
|
for _, n := range s.buildNotifiers(notifConfig, limits) {
|
||||||
if err := sendWithRetry(ctx, n, message, meta); err != nil {
|
if err := sendWithRetry(ctx, n, message, meta); err != nil {
|
||||||
log.Printf("Notification channel failed for user %s after retries: %v", userID, err)
|
log.Printf("Notification channel failed for user %s after retries: %v", userID, err)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
BIN
backend/poller
BIN
backend/poller
Binary file not shown.
@@ -10,7 +10,7 @@ import AlertHistory from "./pages/alertHistory/AlertHistory";
|
|||||||
import Account from "./pages/user_account/Account";
|
import Account from "./pages/user_account/Account";
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { currentUser } = useAuth();
|
const { currentUser, isSubscribed } = useAuth();
|
||||||
|
|
||||||
if (!currentUser) {
|
if (!currentUser) {
|
||||||
return (
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Navbar />
|
<Navbar />
|
||||||
|
|||||||
@@ -11,11 +11,7 @@ const TIERS = [
|
|||||||
"Configure alerts to fire on trigger events",
|
"Configure alerts to fire on trigger events",
|
||||||
"1 transaction alert type per trigger (email digest)",
|
"1 transaction alert type per trigger (email digest)",
|
||||||
],
|
],
|
||||||
disabledFeatures: [
|
disabledFeatures: [],
|
||||||
"Real-time Discord alerts",
|
|
||||||
"Real-time Telegram alerts",
|
|
||||||
"Real-time Slack alerts",
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "premium",
|
id: "premium",
|
||||||
@@ -24,15 +20,12 @@ const TIERS = [
|
|||||||
period: "/month",
|
period: "/month",
|
||||||
features: [
|
features: [
|
||||||
"Monitor 3 blockchain addresses",
|
"Monitor 3 blockchain addresses",
|
||||||
"Configure alerts to fire on trigger events",
|
"Configure two types of rule-based alerts to fire on trigger events for each of the three addresses",
|
||||||
"Daily email digest alert",
|
"Daily email digest alert",
|
||||||
"Real-time Discord alerts",
|
"Real-time Discord alerts",
|
||||||
"Real-time Telegram alerts",
|
"Real-time Telegram alerts",
|
||||||
"2 transaction alert types per monitored address",
|
|
||||||
],
|
|
||||||
disabledFeatures: [
|
|
||||||
"Real-time Slack alerts",
|
|
||||||
],
|
],
|
||||||
|
disabledFeatures: [],
|
||||||
highlighted: true,
|
highlighted: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -42,11 +35,11 @@ const TIERS = [
|
|||||||
period: "/month",
|
period: "/month",
|
||||||
features: [
|
features: [
|
||||||
"Monitor unlimited blockchain addresses",
|
"Monitor unlimited blockchain addresses",
|
||||||
"Configure alerts to fire on trigger events",
|
"Configure unlimited alert rules to fire on unlimited events on any address",
|
||||||
"Daily email digest alert",
|
"Daily email digest alert",
|
||||||
"Real-time Discord alerts",
|
"Real-time Discord alerts",
|
||||||
"Real-time Telegram alerts",
|
"Real-time Telegram alerts",
|
||||||
"Real-time Slack alerts",
|
"Real-time Slack alerts configurable for multiple Slack groups or channels",
|
||||||
"Unlimited transaction alert types per monitored address",
|
"Unlimited transaction alert types per monitored address",
|
||||||
],
|
],
|
||||||
disabledFeatures: [],
|
disabledFeatures: [],
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import "./UpgradeBanner.css";
|
import "./UpgradeBanner.css";
|
||||||
|
|
||||||
export default function UpgradeBanner({ message, linkTo = "/account" }) {
|
export default function UpgradeBanner({ message, linkTo = "/subscribe?upgrade=true" }) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ export function AuthProvider({ children }) {
|
|||||||
const [userTier, setUserTier] = useState("free");
|
const [userTier, setUserTier] = useState("free");
|
||||||
const [tierLimits, setTierLimits] = useState(DEFAULT_TIER_LIMITS);
|
const [tierLimits, setTierLimits] = useState(DEFAULT_TIER_LIMITS);
|
||||||
const [addressCount, setAddressCount] = useState(0);
|
const [addressCount, setAddressCount] = useState(0);
|
||||||
|
const [subscriptionStatus, setSubscriptionStatus] = useState("none");
|
||||||
|
|
||||||
const refreshAccount = useCallback(async () => {
|
const refreshAccount = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -52,6 +53,7 @@ export function AuthProvider({ children }) {
|
|||||||
setUserTier(data.subscription_tier || "free");
|
setUserTier(data.subscription_tier || "free");
|
||||||
setTierLimits(data.tier_limits || DEFAULT_TIER_LIMITS);
|
setTierLimits(data.tier_limits || DEFAULT_TIER_LIMITS);
|
||||||
setAddressCount(data.address_count || 0);
|
setAddressCount(data.address_count || 0);
|
||||||
|
setSubscriptionStatus(data.subscription_status || "none");
|
||||||
} catch {
|
} catch {
|
||||||
// account fetch can fail during onboarding before subscription is active
|
// account fetch can fail during onboarding before subscription is active
|
||||||
}
|
}
|
||||||
@@ -93,6 +95,7 @@ export function AuthProvider({ children }) {
|
|||||||
setUserTier("free");
|
setUserTier("free");
|
||||||
setTierLimits(DEFAULT_TIER_LIMITS);
|
setTierLimits(DEFAULT_TIER_LIMITS);
|
||||||
setAddressCount(0);
|
setAddressCount(0);
|
||||||
|
setSubscriptionStatus("none");
|
||||||
await signOut(auth);
|
await signOut(auth);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
@@ -111,6 +114,9 @@ export function AuthProvider({ children }) {
|
|||||||
return unsubscribe;
|
return unsubscribe;
|
||||||
}, [refreshAccount]);
|
}, [refreshAccount]);
|
||||||
|
|
||||||
|
const isSubscribed =
|
||||||
|
subscriptionStatus === "active" || subscriptionStatus === "trialing";
|
||||||
|
|
||||||
const value = {
|
const value = {
|
||||||
currentUser,
|
currentUser,
|
||||||
signup,
|
signup,
|
||||||
@@ -121,6 +127,8 @@ export function AuthProvider({ children }) {
|
|||||||
userTier,
|
userTier,
|
||||||
tierLimits,
|
tierLimits,
|
||||||
addressCount,
|
addressCount,
|
||||||
|
subscriptionStatus,
|
||||||
|
isSubscribed,
|
||||||
refreshAccount,
|
refreshAccount,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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 */
|
/* Mobile */
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
html {
|
html {
|
||||||
|
|||||||
@@ -95,11 +95,9 @@ export default function Addresses() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!atLimit && (
|
<div className={`mb-xl${atLimit ? " tier-locked" : ""}`}>
|
||||||
<div className="mb-xl">
|
<AddressForm onSubmit={handleAddressSubmit} />
|
||||||
<AddressForm onSubmit={handleAddressSubmit} />
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h2>Existing Tracked Addresses</h2>
|
<h2>Existing Tracked Addresses</h2>
|
||||||
|
|||||||
@@ -382,12 +382,10 @@ export default function Alerts() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!atAlertLimit && (
|
<div className={`mb-xl${atAlertLimit ? " tier-locked" : ""}`}>
|
||||||
<div className="mb-xl">
|
<h3>Create New Alert</h3>
|
||||||
<h3>Create New Alert</h3>
|
<AlertForm onSubmit={handleAlertSubmit} />
|
||||||
<AlertForm onSubmit={handleAlertSubmit} />
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h3>Active Alert Rules</h3>
|
<h3>Active Alert Rules</h3>
|
||||||
|
|||||||
@@ -111,52 +111,6 @@
|
|||||||
color: #888;
|
color: #888;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Step 5 summary */
|
|
||||||
.subscribe__summary {
|
|
||||||
background-color: #1e2e1e;
|
|
||||||
border: 1px solid #2d5a2d;
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
padding: 1rem 1.25rem;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.subscribe__summary-title {
|
|
||||||
margin: 0 0 0.5rem;
|
|
||||||
color: #90ee90;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.subscribe__summary-list {
|
|
||||||
margin: 0;
|
|
||||||
padding-left: 1.25rem;
|
|
||||||
color: var(--color-text-label);
|
|
||||||
line-height: 1.8;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Checkbox rows */
|
|
||||||
.checkbox-row {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.checkbox-row__label {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.6rem;
|
|
||||||
cursor: pointer;
|
|
||||||
color: #ddd;
|
|
||||||
}
|
|
||||||
|
|
||||||
.checkbox-row__input {
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
accent-color: var(--color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.checkbox-row__nested {
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
margin-left: 1.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Footer navigation */
|
/* Footer navigation */
|
||||||
.subscribe__footer {
|
.subscribe__footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -167,20 +121,6 @@
|
|||||||
border-top: 1px solid var(--color-border-light);
|
border-top: 1px solid var(--color-border-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Test results */
|
|
||||||
.test-result {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.test-result--success {
|
|
||||||
color: #90ee90;
|
|
||||||
}
|
|
||||||
|
|
||||||
.test-result--failure {
|
|
||||||
color: var(--color-error);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Step subtitle */
|
/* Step subtitle */
|
||||||
.subscribe__subtitle {
|
.subscribe__subtitle {
|
||||||
color: #aaa;
|
color: #aaa;
|
||||||
@@ -188,7 +128,7 @@
|
|||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Subscribe card (Step 2) */
|
/* Subscribe card (Step 2 tier cards) */
|
||||||
.subscribe-card {
|
.subscribe-card {
|
||||||
background-color: var(--color-bg-card, #1a1a2e);
|
background-color: var(--color-bg-card, #1a1a2e);
|
||||||
border: 1px solid var(--color-border-light);
|
border: 1px solid var(--color-border-light);
|
||||||
@@ -241,29 +181,6 @@
|
|||||||
font-style: italic;
|
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 ────────────────────────────────── */
|
/* ── Subscribe Responsive ────────────────────────────────── */
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
|
|||||||
@@ -1,32 +1,17 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||||
import { useAuth } from "../../contexts/AuthContext";
|
import { useAuth } from "../../contexts/AuthContext";
|
||||||
import { createAddress, getAddresses } from "../../api/addresses";
|
|
||||||
import { createAlert } from "../../api/alerts";
|
|
||||||
import {
|
import {
|
||||||
updateNotificationConfig,
|
createOnboardingCheckout,
|
||||||
testNotificationChannels,
|
verifyCheckoutSession,
|
||||||
} from "../../api/notificationConfig";
|
activateFreeTier,
|
||||||
import { createOnboardingCheckout, verifyCheckoutSession, activateFreeTier } from "../../api/stripe";
|
} from "../../api/stripe";
|
||||||
import Input from "../../components/Input";
|
import Input from "../../components/Input";
|
||||||
import Button from "../../components/Button";
|
import Button from "../../components/Button";
|
||||||
import TierPicker from "../../components/TierPicker";
|
import TierPicker from "../../components/TierPicker";
|
||||||
import "./Subscribe.css";
|
import "./Subscribe.css";
|
||||||
|
|
||||||
const TIER_LIMITS = {
|
const STEPS = ["Create Account", "Choose Plan"];
|
||||||
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",
|
|
||||||
"Done",
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function Subscribe() {
|
export default function Subscribe() {
|
||||||
const { currentUser, signup } = useAuth();
|
const { currentUser, signup } = useAuth();
|
||||||
@@ -37,48 +22,19 @@ export default function Subscribe() {
|
|||||||
const [step, setStep] = useState(hasPaymentReturn || currentUser ? 2 : 1);
|
const [step, setStep] = useState(hasPaymentReturn || currentUser ? 2 : 1);
|
||||||
const [loading, setLoading] = useState(hasPaymentReturn);
|
const [loading, setLoading] = useState(hasPaymentReturn);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [skipWarning, setSkipWarning] = useState("");
|
|
||||||
const [testResults, setTestResults] = useState(null);
|
|
||||||
const [testLoading, setTestLoading] = useState(false);
|
|
||||||
|
|
||||||
const [data, setData] = useState({
|
const [data, setData] = useState({
|
||||||
selectedTier: "",
|
selectedTier: "",
|
||||||
email: "",
|
email: "",
|
||||||
password: "",
|
password: "",
|
||||||
confirmPassword: "",
|
confirmPassword: "",
|
||||||
walletAddress: "",
|
|
||||||
walletLabel: "",
|
|
||||||
createdAddressId: null,
|
|
||||||
alertIncomingTx: false,
|
|
||||||
alertOutgoingTx: false,
|
|
||||||
alertLargeTransfer: false,
|
|
||||||
largeTransferThreshold: "",
|
|
||||||
alertBalanceBelow: false,
|
|
||||||
balanceBelowThreshold: "",
|
|
||||||
discordWebhookUrl: "",
|
|
||||||
slackWebhookUrl: "",
|
|
||||||
notificationEmail: "",
|
|
||||||
alertsCreated: [],
|
|
||||||
notificationConfigured: false,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function set(field, value) {
|
function set(field, value) {
|
||||||
setData((prev) => ({ ...prev, [field]: value }));
|
setData((prev) => ({ ...prev, [field]: value }));
|
||||||
}
|
}
|
||||||
|
|
||||||
const tierLimits = TIER_LIMITS[data.selectedTier] || TIER_LIMITS.free;
|
// Handle Stripe payment returns
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!currentUser) return;
|
|
||||||
getAddresses()
|
|
||||||
.then((addresses) => {
|
|
||||||
if (addresses.length > 0) {
|
|
||||||
navigate("/addresses", { replace: true });
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => { });
|
|
||||||
}, [currentUser, navigate]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const payment = searchParams.get("payment");
|
const payment = searchParams.get("payment");
|
||||||
const sessionId = searchParams.get("session_id");
|
const sessionId = searchParams.get("session_id");
|
||||||
@@ -92,9 +48,8 @@ export default function Subscribe() {
|
|||||||
|
|
||||||
if (payment !== "success" || !sessionId) return;
|
if (payment !== "success" || !sessionId) return;
|
||||||
|
|
||||||
// Phase 1: no account yet — create it. This triggers an auth state change
|
// Phase 1: no account yet — create it. Auth state change remounts the
|
||||||
// which remounts the component (App.jsx swaps route trees). The URL params
|
// component (App.jsx swaps route trees) and Phase 2 runs on the next mount.
|
||||||
// are preserved so Phase 2 runs on the next mount.
|
|
||||||
if (!currentUser) {
|
if (!currentUser) {
|
||||||
const savedEmail = sessionStorage.getItem("kp_onboard_email");
|
const savedEmail = sessionStorage.getItem("kp_onboard_email");
|
||||||
const savedPw = sessionStorage.getItem("kp_onboard_pw");
|
const savedPw = sessionStorage.getItem("kp_onboard_pw");
|
||||||
@@ -115,7 +70,7 @@ export default function Subscribe() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 2: authenticated — verify the checkout and activate subscription.
|
// Phase 2: authenticated — verify the checkout and go to the dashboard.
|
||||||
setSearchParams({}, { replace: true });
|
setSearchParams({}, { replace: true });
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
sessionStorage.removeItem("kp_onboard_email");
|
sessionStorage.removeItem("kp_onboard_email");
|
||||||
@@ -123,14 +78,14 @@ export default function Subscribe() {
|
|||||||
|
|
||||||
verifyCheckoutSession(sessionId)
|
verifyCheckoutSession(sessionId)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
setStep(3);
|
navigate("/addresses", { replace: true });
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
setError("Payment verification failed: " + err.message);
|
setError("Payment verification failed: " + err.message);
|
||||||
setStep(2);
|
setStep(2);
|
||||||
})
|
})
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [currentUser, searchParams, setSearchParams, signup]);
|
}, [currentUser, searchParams, setSearchParams, signup, navigate]);
|
||||||
|
|
||||||
// ── Step handlers ─────────────────────────────────────────────────────────
|
// ── Step handlers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -165,13 +120,16 @@ export default function Subscribe() {
|
|||||||
await signup(data.email, data.password);
|
await signup(data.email, data.password);
|
||||||
}
|
}
|
||||||
await activateFreeTier();
|
await activateFreeTier();
|
||||||
setStep(3);
|
navigate("/addresses", { replace: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
sessionStorage.setItem("kp_onboard_email", data.email);
|
sessionStorage.setItem("kp_onboard_email", data.email);
|
||||||
sessionStorage.setItem("kp_onboard_pw", data.password);
|
sessionStorage.setItem("kp_onboard_pw", data.password);
|
||||||
const { url } = await createOnboardingCheckout(data.email, data.selectedTier);
|
const { url } = await createOnboardingCheckout(
|
||||||
|
data.email,
|
||||||
|
data.selectedTier,
|
||||||
|
);
|
||||||
window.location.href = url;
|
window.location.href = url;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.code === "auth/email-already-in-use") {
|
if (err.code === "auth/email-already-in-use") {
|
||||||
@@ -188,125 +146,6 @@ export default function Subscribe() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleStep3() {
|
|
||||||
setError("");
|
|
||||||
if (!data.walletAddress) {
|
|
||||||
setError("Please enter a wallet address");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!/^0x[0-9a-fA-F]{40}$/.test(data.walletAddress)) {
|
|
||||||
setError("Invalid ETH address (must be 0x followed by 40 hex characters)");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
const created = await createAddress({
|
|
||||||
address: data.walletAddress,
|
|
||||||
label: data.walletLabel || undefined,
|
|
||||||
});
|
|
||||||
set("createdAddressId", created.id);
|
|
||||||
setStep(4);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err.message);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleStep4() {
|
|
||||||
setError("");
|
|
||||||
const rules = [];
|
|
||||||
if (data.alertIncomingTx) rules.push({ type: "incoming_tx" });
|
|
||||||
if (data.alertOutgoingTx) rules.push({ type: "outgoing_tx" });
|
|
||||||
if (data.alertLargeTransfer) {
|
|
||||||
if (!data.largeTransferThreshold) {
|
|
||||||
setError("Please enter a threshold for large transfers");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
rules.push({ type: "large_transfer", threshold: data.largeTransferThreshold });
|
|
||||||
}
|
|
||||||
if (data.alertBalanceBelow) {
|
|
||||||
if (!data.balanceBelowThreshold) {
|
|
||||||
setError("Please enter a threshold for balance below");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
rules.push({ type: "balance_below", threshold: data.balanceBelowThreshold });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (rules.length === 0) {
|
|
||||||
setStep(5);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
const created = [];
|
|
||||||
for (const rule of rules) {
|
|
||||||
const result = await createAlert(data.createdAddressId, rule);
|
|
||||||
created.push(result);
|
|
||||||
}
|
|
||||||
set("alertsCreated", created);
|
|
||||||
setStep(5);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err.message);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleStep5() {
|
|
||||||
setError("");
|
|
||||||
const hasAny =
|
|
||||||
data.discordWebhookUrl || data.slackWebhookUrl || data.notificationEmail;
|
|
||||||
if (!hasAny) {
|
|
||||||
setStep(6);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
await updateNotificationConfig({
|
|
||||||
notification_enabled: true,
|
|
||||||
discord_webhook_url: data.discordWebhookUrl || undefined,
|
|
||||||
slack_webhook_url: data.slackWebhookUrl || undefined,
|
|
||||||
email: data.notificationEmail || undefined,
|
|
||||||
});
|
|
||||||
set("notificationConfigured", true);
|
|
||||||
setStep(6);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err.message);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleTestChannels() {
|
|
||||||
setTestLoading(true);
|
|
||||||
setTestResults(null);
|
|
||||||
try {
|
|
||||||
const results = await testNotificationChannels();
|
|
||||||
setTestResults(results);
|
|
||||||
} catch (err) {
|
|
||||||
setTestResults({ error: err.message });
|
|
||||||
} finally {
|
|
||||||
setTestLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 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 ──────────────────────────────────────────────────────────
|
// ── Progress bar ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function ProgressBar() {
|
function ProgressBar() {
|
||||||
@@ -326,10 +165,11 @@ export default function Subscribe() {
|
|||||||
<div key={label} className="progress-bar__step">
|
<div key={label} className="progress-bar__step">
|
||||||
{i > 0 && (
|
{i > 0 && (
|
||||||
<div
|
<div
|
||||||
className={`progress-bar__connector ${done || active
|
className={`progress-bar__connector ${
|
||||||
? "progress-bar__connector--active"
|
done || active
|
||||||
: "progress-bar__connector--inactive"
|
? "progress-bar__connector--active"
|
||||||
}`}
|
: "progress-bar__connector--inactive"
|
||||||
|
}`}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div>
|
<div>
|
||||||
@@ -337,10 +177,11 @@ export default function Subscribe() {
|
|||||||
{done ? "\u2713" : stepNum}
|
{done ? "\u2713" : stepNum}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={`progress-bar__label ${active
|
className={`progress-bar__label ${
|
||||||
? "progress-bar__label--active"
|
active
|
||||||
: "progress-bar__label--inactive"
|
? "progress-bar__label--active"
|
||||||
}`}
|
: "progress-bar__label--inactive"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</div>
|
</div>
|
||||||
@@ -402,352 +243,45 @@ export default function Subscribe() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function StepAddWallet() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<h2 className="mb-sm">Add a wallet address</h2>
|
|
||||||
<p className="subscribe__subtitle">
|
|
||||||
Enter the Ethereum address you want to monitor.
|
|
||||||
</p>
|
|
||||||
<Input
|
|
||||||
label="ETH Address"
|
|
||||||
value={data.walletAddress}
|
|
||||||
onChange={(v) => set("walletAddress", v)}
|
|
||||||
disabled={loading}
|
|
||||||
placeholder="0x..."
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label="Label (optional)"
|
|
||||||
value={data.walletLabel}
|
|
||||||
onChange={(v) => set("walletLabel", v)}
|
|
||||||
disabled={loading}
|
|
||||||
placeholder="e.g. My main wallet"
|
|
||||||
className="form-field--last"
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function 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 ({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">
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
label=""
|
|
||||||
value={data.largeTransferThreshold}
|
|
||||||
onChange={(v) => set("largeTransferThreshold", v)}
|
|
||||||
placeholder="Threshold (ETH)"
|
|
||||||
min="0"
|
|
||||||
step="0.01"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CheckboxRow>
|
|
||||||
<CheckboxRow
|
|
||||||
checked={data.alertBalanceBelow}
|
|
||||||
onChange={(v) => set("alertBalanceBelow", v)}
|
|
||||||
label="Balance below"
|
|
||||||
disabled={!data.alertBalanceBelow && atLimit}
|
|
||||||
>
|
|
||||||
{data.alertBalanceBelow && (
|
|
||||||
<div className="checkbox-row__nested">
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
label=""
|
|
||||||
value={data.balanceBelowThreshold}
|
|
||||||
onChange={(v) => set("balanceBelowThreshold", v)}
|
|
||||||
placeholder="Threshold (ETH)"
|
|
||||||
min="0"
|
|
||||||
step="0.01"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CheckboxRow>
|
|
||||||
|
|
||||||
{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 StepNotifications() {
|
|
||||||
const channels = tierLimits.channels;
|
|
||||||
const canDiscord = channels.includes("discord");
|
|
||||||
const canSlack = channels.includes("slack");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<h2 className="mb-sm">Set up notifications</h2>
|
|
||||||
<p className="subscribe__subtitle">
|
|
||||||
Add at least one channel so you receive alerts. All fields are optional.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<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
|
|
||||||
href="https://support.discord.com/hc/en-us/articles/228383668"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="help-link"
|
|
||||||
>
|
|
||||||
(how to get one)
|
|
||||||
</a>
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
label=""
|
|
||||||
type="url"
|
|
||||||
value={data.discordWebhookUrl}
|
|
||||||
onChange={(v) => set("discordWebhookUrl", v)}
|
|
||||||
disabled={loading || !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${!canSlack ? " subscribe__channel-disabled" : ""}`}>
|
|
||||||
<label className="form-label">
|
|
||||||
Slack Webhook URL{" "}
|
|
||||||
<a
|
|
||||||
href="https://api.slack.com/messaging/webhooks"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="help-link"
|
|
||||||
>
|
|
||||||
(how to get one)
|
|
||||||
</a>
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
label=""
|
|
||||||
type="url"
|
|
||||||
value={data.slackWebhookUrl}
|
|
||||||
onChange={(v) => set("slackWebhookUrl", v)}
|
|
||||||
disabled={loading || !canSlack}
|
|
||||||
placeholder="https://hooks.slack.com/services/..."
|
|
||||||
/>
|
|
||||||
{!canSlack && (
|
|
||||||
<p className="subscribe__tier-hint">Upgrade to Pro to enable Slack alerts</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function StepDone() {
|
|
||||||
const alertCount = data.alertsCreated.length;
|
|
||||||
const hasNotif = data.notificationConfigured;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<h2 className="mb-md">You're all set!</h2>
|
|
||||||
|
|
||||||
<div className="subscribe__summary">
|
|
||||||
<p className="subscribe__summary-title">Summary</p>
|
|
||||||
<ul className="subscribe__summary-list">
|
|
||||||
<li>
|
|
||||||
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">
|
|
||||||
{data.walletAddress}
|
|
||||||
</span>
|
|
||||||
{data.walletLabel && ` (${data.walletLabel})`}
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Alert rules configured:{" "}
|
|
||||||
<span className="text-white">
|
|
||||||
{alertCount > 0 ? `${alertCount} rule${alertCount !== 1 ? "s" : ""}` : "None (skipped)"}
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Notification channels:{" "}
|
|
||||||
<span className="text-white">
|
|
||||||
{hasNotif ? "Configured" : "Not set up (skipped)"}
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{hasNotif && (
|
|
||||||
<div className="mb-lg">
|
|
||||||
<Button
|
|
||||||
onClick={handleTestChannels}
|
|
||||||
disabled={testLoading}
|
|
||||||
variant="ghost"
|
|
||||||
>
|
|
||||||
{testLoading ? "Testing..." : "Test All Channels"}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{testResults && (
|
|
||||||
<div className="mt-md">
|
|
||||||
{testResults.error ? (
|
|
||||||
<p className="text-error">{testResults.error}</p>
|
|
||||||
) : (
|
|
||||||
<ul className="list-unstyled">
|
|
||||||
{Object.entries(testResults).map(([channel, result]) => (
|
|
||||||
<li
|
|
||||||
key={channel}
|
|
||||||
className={`test-result ${result.success ? "test-result--success" : "test-result--failure"}`}
|
|
||||||
>
|
|
||||||
{result.success ? "\u2713" : "\u2717"} {channel}:{" "}
|
|
||||||
{result.message || (result.success ? "OK" : "Failed")}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button onClick={() => navigate("/addresses")} className="btn--lg text-bold">
|
|
||||||
Go to Dashboard →
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Shared helpers ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function CheckboxRow({ checked, onChange, label, children, disabled }) {
|
|
||||||
return (
|
|
||||||
<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>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Footer navigation ─────────────────────────────────────────────────────
|
// ── Footer navigation ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
function Footer() {
|
function Footer() {
|
||||||
if (step === 6) return null;
|
|
||||||
|
|
||||||
const canSkip = step === 4 || step === 5;
|
|
||||||
const canBack = step > 1 && step <= 5;
|
|
||||||
|
|
||||||
async function handleNext() {
|
async function handleNext() {
|
||||||
setSkipWarning("");
|
|
||||||
if (step === 1) handleStep1();
|
if (step === 1) handleStep1();
|
||||||
else if (step === 2) await handleStep2();
|
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() {
|
|
||||||
setError("");
|
|
||||||
setSkipWarning("");
|
|
||||||
setStep((s) => s + 1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleBack() {
|
function handleBack() {
|
||||||
setError("");
|
setError("");
|
||||||
setSkipWarning("");
|
|
||||||
setStep((s) => s - 1);
|
setStep((s) => s - 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextLabel = step === 1
|
const nextLabel =
|
||||||
? "Create Account"
|
step === 1
|
||||||
: step === 2
|
? "Create Account"
|
||||||
? !data.selectedTier
|
: !data.selectedTier
|
||||||
? "Continue"
|
? "Continue"
|
||||||
: data.selectedTier === "free"
|
: data.selectedTier === "free"
|
||||||
? "Start Free Trial"
|
? "Start Free Trial"
|
||||||
: "Subscribe & Continue"
|
: "Subscribe & Continue";
|
||||||
: step === 5
|
|
||||||
? "Finish"
|
|
||||||
: "Next →";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="subscribe__footer">
|
<div className="subscribe__footer">
|
||||||
<div>
|
<div>
|
||||||
{canBack && (
|
{step === 2 && (
|
||||||
<Button
|
<Button onClick={handleBack} disabled={loading} variant="ghost">
|
||||||
onClick={handleBack}
|
|
||||||
disabled={loading}
|
|
||||||
variant="ghost"
|
|
||||||
>
|
|
||||||
← Back
|
← Back
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-md">
|
<Button
|
||||||
{canSkip && (
|
onClick={handleNext}
|
||||||
<Button
|
disabled={loading || (step === 2 && !data.selectedTier)}
|
||||||
onClick={handleSkip}
|
className="text-bold"
|
||||||
disabled={loading}
|
>
|
||||||
variant="ghost"
|
{loading ? "Please wait..." : nextLabel}
|
||||||
>
|
</Button>
|
||||||
Skip for now
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
onClick={handleNext}
|
|
||||||
disabled={loading || (step === 2 && !data.selectedTier)}
|
|
||||||
className="text-bold"
|
|
||||||
>
|
|
||||||
{loading ? "Please wait..." : nextLabel}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -757,26 +291,18 @@ export default function Subscribe() {
|
|||||||
const stepContent = {
|
const stepContent = {
|
||||||
1: StepCreateAccount(),
|
1: StepCreateAccount(),
|
||||||
2: StepChoosePlan(),
|
2: StepChoosePlan(),
|
||||||
3: StepAddWallet(),
|
|
||||||
4: StepAlertRules(),
|
|
||||||
5: StepNotifications(),
|
|
||||||
6: StepDone(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="subscribe">
|
<div className="subscribe">
|
||||||
<div className={`subscribe__container${step === 2 ? " subscribe__container--wide" : ""}`}>
|
<div
|
||||||
|
className={`subscribe__container${step === 2 ? " subscribe__container--wide" : ""}`}
|
||||||
|
>
|
||||||
<h1 className="subscribe__title">Koin Ping</h1>
|
<h1 className="subscribe__title">Koin Ping</h1>
|
||||||
|
|
||||||
{ProgressBar()}
|
{ProgressBar()}
|
||||||
|
|
||||||
{error && (
|
{error && <div className="alert alert--error">{error}</div>}
|
||||||
<div className="alert alert--error">{error}</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{skipWarning && (
|
|
||||||
<div className="alert alert--warning">{skipWarning}</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="subscribe__card">
|
<div className="subscribe__card">
|
||||||
{stepContent[step]}
|
{stepContent[step]}
|
||||||
@@ -785,8 +311,7 @@ export default function Subscribe() {
|
|||||||
|
|
||||||
{step === 1 && (
|
{step === 1 && (
|
||||||
<p className="subscribe__login-link">
|
<p className="subscribe__login-link">
|
||||||
Already have an account?{" "}
|
Already have an account? <a href="/login">Log in here</a>
|
||||||
<a href="/login">Log in here</a>
|
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
BIN
memberships.pdf
Normal file
BIN
memberships.pdf
Normal file
Binary file not shown.
Reference in New Issue
Block a user