Compare commits

...

7 Commits

Author SHA1 Message Date
KS Jannette
0d7bc65995 adjust flow 2026-03-28 23:09:03 -04:00
KS Jannette
05453895b9 m 2026-03-28 22:54:52 -04:00
S Jannette
14ed0a23a6 Merge pull request #28 from kjannette/mega-blast
Mega blast
2026-03-28 22:34:56 -04:00
KS Jannette
40ed4f6afd m 2026-03-28 22:34:23 -04:00
KS Jannette
26324150d2 removed F up 2026-03-28 11:58:29 -04:00
KS Jannette
b0572451d3 but 2026-03-28 11:55:51 -04:00
S Jannette
d9c3bd1db5 Merge pull request #27 from kjannette/setup-free-tier
Setup free tier
2026-03-28 11:27:18 -04:00
23 changed files with 352 additions and 652 deletions

2
.gitignore vendored
View File

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

View File

@@ -1,13 +1,18 @@
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. From the backend directory, you have a few options: ## 2. ALLIN ONE:
Make dev-all — Runs both the API and poller concurrently.
-----------------------------> BEST
- OR -
## 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.

Binary file not shown.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -55,12 +55,14 @@ func (m *AddressModel) ListByUser(ctx context.Context, userID string) ([]domain.
return addresses, rows.Err() 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

View File

@@ -77,10 +77,12 @@ func (m *NotificationConfigModel) Remove(ctx context.Context, userID string) (bo
func (m *NotificationConfigModel) ListEnabled(ctx context.Context) ([]domain.NotificationConfig, error) { 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

View File

@@ -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, &notifications.DiscordNotifier{WebhookURL: *cfg.DiscordWebhookURL}) notifiers = append(notifiers, &notifications.DiscordNotifier{WebhookURL: *cfg.DiscordWebhookURL})
} }
if cfg.TelegramBotToken != nil && *cfg.TelegramBotToken != "" && if limits.ChannelAllowed("telegram") &&
cfg.TelegramBotToken != nil && *cfg.TelegramBotToken != "" &&
cfg.TelegramChatID != nil && *cfg.TelegramChatID != "" { cfg.TelegramChatID != nil && *cfg.TelegramChatID != "" {
notifiers = append(notifiers, &notifications.TelegramNotifier{ notifiers = append(notifiers, &notifications.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, &notifications.SlackNotifier{WebhookURL: *cfg.SlackWebhookURL}) notifiers = append(notifiers, &notifications.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 {

Binary file not shown.

View File

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

View File

@@ -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: [],

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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) {
@@ -284,4 +201,4 @@
height: 26px; height: 26px;
font-size: 0.75rem; font-size: 0.75rem;
} }
} }

View File

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

Binary file not shown.