Compare commits

...

13 Commits

Author SHA1 Message Date
KS Jannette
f086ef98ff Updated subscriptions 2026-05-12 17:39:42 -04:00
KS Jannette
2ab6dd0d6a style tweak 2026-05-12 05:56:34 -04:00
KS Jannette
68df446580 fix style 2026-05-12 05:55:11 -04:00
KS Jannette
3fc2c28b43 adjusted css for mobile designs 2026-05-12 05:51:50 -04:00
S Jannette
c4b8022432 Merge pull request #38 from kjannette/fix-login
Fix login issue
2026-05-12 05:21:11 -04:00
KS Jannette
cea031334e add comment 2026-05-12 05:18:33 -04:00
KS Jannette
c4371f7886 fixed login race condition 2026-05-12 05:14:12 -04:00
KS Jannette
8b2db08db1 simplified auth context provider 2026-05-12 04:53:53 -04:00
KS Jannette
f52f7dc89f more style update for mobile 2026-05-12 01:09:41 -04:00
KS Jannette
3eab5d07ff more style 2026-05-12 01:00:33 -04:00
KS Jannette
83ac4b7c14 more style hotfix 2026-05-12 00:52:39 -04:00
KS Jannette
5eca679f54 hotfix 2026-05-12 00:48:01 -04:00
S Jannette
f4e6953046 Merge pull request #37 from kjannette/mobile-style
Adjust css for login page
2026-05-12 00:39:00 -04:00
20 changed files with 488 additions and 142 deletions

View File

@@ -61,7 +61,7 @@ func main() {
notifConfigHandler := handlers.NewNotificationConfigHandler(notifConfigModel, userModel, cfg) notifConfigHandler := handlers.NewNotificationConfigHandler(notifConfigModel, userModel, cfg)
emailDigestHandler := handlers.NewEmailDigestHandler(emailDigestSvc, notifConfigModel) emailDigestHandler := handlers.NewEmailDigestHandler(emailDigestSvc, notifConfigModel)
statusHandler := handlers.NewStatusHandler(checkpointModel) statusHandler := handlers.NewStatusHandler(checkpointModel)
stripeHandler := handlers.NewStripeHandler(userModel, cfg) stripeHandler := handlers.NewStripeHandler(userModel, alertRuleModel, cfg)
accountHandler := handlers.NewAccountHandler(userModel, addressModel, cfg) accountHandler := handlers.NewAccountHandler(userModel, addressModel, cfg)
authenticate := middleware.Authenticate(userModel) authenticate := middleware.Authenticate(userModel)

View File

@@ -0,0 +1,68 @@
// Package main runs a daily (cron-invoked) job: for paid tiers without an
// active or trialling Stripe subscription, disable Firebase login and turn
// off all alert rules until billing is restored via Stripe webhook / checkout.
package main
import (
"context"
"log"
"github.com/joho/godotenv"
"github.com/kjannette/koin-ping/backend/internal/config"
"github.com/kjannette/koin-ping/backend/internal/database"
"github.com/kjannette/koin-ping/backend/internal/firebase"
"github.com/kjannette/koin-ping/backend/internal/models"
)
func main() {
_ = godotenv.Load()
cfg, err := config.Load()
if err != nil {
log.Fatalf("Failed to load config: %v", err)
}
if err := firebase.Init(cfg.FirebaseProjectID); err != nil {
log.Fatalf("Failed to initialize Firebase: %v", err)
}
pool, connErr := database.Connect(cfg.DSN())
if connErr != nil {
log.Fatalf("Failed to connect to database: %v", connErr)
}
defer database.Close()
ctx := context.Background()
userModel := models.NewUserModel(pool)
alertModel := models.NewAlertRuleModel(pool)
users, listErr := userModel.ListPaidUsersWithoutActiveSubscription(ctx)
if listErr != nil {
log.Fatalf("Failed to list lapsed subscriptions: %v", listErr)
}
if len(users) == 0 {
log.Println("Subscription sweep: no lapsed paid users")
return
}
for _, u := range users {
if disableErr := firebase.SetUserDisabled(ctx, u.FirebaseUID, true); disableErr != nil {
log.Printf("Subscription sweep: Firebase disable failed user %s: %v", u.ID, disableErr)
continue
}
n, rulesErr := alertModel.DisableAllForUser(ctx, u.ID)
if rulesErr != nil {
log.Printf("Subscription sweep: disabling alerts failed for user %s: %v", u.ID, rulesErr)
continue
}
log.Printf(
"Subscription sweep: suspended user %s (%s tier, status=%s), disabled %d alert rules",
u.ID, u.SubscriptionTier, u.SubscriptionStatus, n,
)
}
}

View File

@@ -36,6 +36,8 @@ type Config struct {
StripeWebhookSecret string StripeWebhookSecret string
StripePriceIDPremium string StripePriceIDPremium string
StripePriceIDPro string StripePriceIDPro string
StripePriceIDPremiumAnnual string
StripePriceIDProAnnual string
StripePublishableKey string StripePublishableKey string
FrontendURL string FrontendURL string
} }
@@ -62,6 +64,8 @@ func Load() (*Config, error) {
StripeWebhookSecret: os.Getenv("STRIPE_WEBHOOK_SECRET"), StripeWebhookSecret: os.Getenv("STRIPE_WEBHOOK_SECRET"),
StripePriceIDPremium: os.Getenv("STRIPE_PRICE_ID_PREMIUM"), StripePriceIDPremium: os.Getenv("STRIPE_PRICE_ID_PREMIUM"),
StripePriceIDPro: os.Getenv("STRIPE_PRICE_ID_PRO"), StripePriceIDPro: os.Getenv("STRIPE_PRICE_ID_PRO"),
StripePriceIDPremiumAnnual: os.Getenv("STRIPE_PRICE_ID_PREMIUM_ANNUAL"),
StripePriceIDProAnnual: os.Getenv("STRIPE_PRICE_ID_PRO_ANNUAL"),
StripePublishableKey: os.Getenv("STRIPE_PUBLISHABLE_KEY"), StripePublishableKey: os.Getenv("STRIPE_PUBLISHABLE_KEY"),
FrontendURL: getEnv("FRONTEND_URL", "http://localhost:3000"), FrontendURL: getEnv("FRONTEND_URL", "http://localhost:3000"),
} }
@@ -97,10 +101,13 @@ func (c *Config) DSN() string {
// TierForPriceID maps a Stripe price ID back to the corresponding // TierForPriceID maps a Stripe price ID back to the corresponding
// subscription tier. Returns empty string if the price is unrecognised. // subscription tier. Returns empty string if the price is unrecognised.
func (c *Config) TierForPriceID(priceID string) string { func (c *Config) TierForPriceID(priceID string) string {
if priceID == "" {
return ""
}
switch priceID { switch priceID {
case c.StripePriceIDPremium: case c.StripePriceIDPremium, c.StripePriceIDPremiumAnnual:
return "premium" return "premium"
case c.StripePriceIDPro: case c.StripePriceIDPro, c.StripePriceIDProAnnual:
return "pro" return "pro"
default: default:
return "" return ""

View File

@@ -8,6 +8,8 @@ func TestTierForPriceID(t *testing.T) {
cfg := &Config{ cfg := &Config{
StripePriceIDPremium: "price_premium_123", StripePriceIDPremium: "price_premium_123",
StripePriceIDPro: "price_pro_456", StripePriceIDPro: "price_pro_456",
StripePriceIDPremiumAnnual: "price_premium_yr",
StripePriceIDProAnnual: "price_pro_yr",
} }
tests := []struct { tests := []struct {
@@ -16,6 +18,8 @@ func TestTierForPriceID(t *testing.T) {
}{ }{
{"price_premium_123", "premium"}, {"price_premium_123", "premium"},
{"price_pro_456", "pro"}, {"price_pro_456", "pro"},
{"price_premium_yr", "premium"},
{"price_pro_yr", "pro"},
{"price_unknown", ""}, {"price_unknown", ""},
{"", ""}, {"", ""},
} }

View File

@@ -0,0 +1,22 @@
package firebase
import (
"context"
"fmt"
"firebase.google.com/go/v4/auth"
)
// SetUserDisabled updates the Firebase user record's disabled flag.
func SetUserDisabled(ctx context.Context, uid string, disabled bool) error {
if authClient == nil {
return fmt.Errorf("firebase auth not initialized") //nolint:err113
}
if uid == "" {
return nil
}
params := (&auth.UserToUpdate{}).Disabled(disabled)
_, err := authClient.UpdateUser(ctx, uid, params)
return err
}

View File

@@ -38,8 +38,8 @@ type accountResponse struct {
var tierPlanLabels = map[domain.SubscriptionTier]string{ //nolint:gochecknoglobals var tierPlanLabels = map[domain.SubscriptionTier]string{ //nolint:gochecknoglobals
domain.TierFree: "Free Trial", domain.TierFree: "Free Trial",
domain.TierPremium: "Premium / $1.99 mo", domain.TierPremium: "Premium / $8.78 mo",
domain.TierPro: "Pro / $11.99 mo", domain.TierPro: "Pro / $16.78 mo",
} }
func (h *AccountHandler) GetAccount(w http.ResponseWriter, r *http.Request) { func (h *AccountHandler) GetAccount(w http.ResponseWriter, r *http.Request) {

View File

@@ -1,36 +1,86 @@
package handlers package handlers
import ( import (
"context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"log" "log"
"net/http" "net/http"
"github.com/stripe/stripe-go/v82" kpfirebase "github.com/kjannette/koin-ping/backend/internal/firebase"
portalsession "github.com/stripe/stripe-go/v82/billingportal/session"
checkoutsession "github.com/stripe/stripe-go/v82/checkout/session"
"github.com/stripe/stripe-go/v82/webhook"
"github.com/kjannette/koin-ping/backend/internal/config" "github.com/kjannette/koin-ping/backend/internal/config"
"github.com/kjannette/koin-ping/backend/internal/domain" "github.com/kjannette/koin-ping/backend/internal/domain"
"github.com/kjannette/koin-ping/backend/internal/middleware" "github.com/kjannette/koin-ping/backend/internal/middleware"
"github.com/kjannette/koin-ping/backend/internal/models" "github.com/kjannette/koin-ping/backend/internal/models"
"github.com/stripe/stripe-go/v82"
portalsession "github.com/stripe/stripe-go/v82/billingportal/session"
checkoutsession "github.com/stripe/stripe-go/v82/checkout/session"
"github.com/stripe/stripe-go/v82/webhook"
) )
const webhookMaxBodyBytes = 65536 const webhookMaxBodyBytes = 65536
type StripeHandler struct { type StripeHandler struct {
users *models.UserModel users *models.UserModel
alerts *models.AlertRuleModel
cfg *config.Config cfg *config.Config
} }
func NewStripeHandler(users *models.UserModel, cfg *config.Config) *StripeHandler { func NewStripeHandler(users *models.UserModel, alerts *models.AlertRuleModel, cfg *config.Config) *StripeHandler {
stripe.Key = cfg.StripeSecretKey stripe.Key = cfg.StripeSecretKey
return &StripeHandler{users: users, cfg: cfg} return &StripeHandler{users: users, alerts: alerts, cfg: cfg}
} }
func (h *StripeHandler) priceIDForTier(tier domain.SubscriptionTier) (string, error) { func (h *StripeHandler) ensureUserFirebaseAndAlertsEnabled(ctx context.Context, localUserID string) {
user, err := h.users.GetByID(ctx, localUserID)
if err != nil || user == nil || user.FirebaseUID == "" {
return
}
if firebaseErr := kpfirebase.SetUserDisabled(ctx, user.FirebaseUID, false); firebaseErr != nil {
log.Printf("Billing access restore: firebase enable failed for user %s: %v", localUserID, firebaseErr)
return
}
n, alertsErr := h.alerts.EnableAllForUser(ctx, localUserID)
if alertsErr != nil {
log.Printf("Billing access restore: enable alerts failed for user %s: %v", localUserID, alertsErr)
return
}
log.Printf("Billing access restored: user %s, %d alert rules enabled", localUserID, n)
}
func (h *StripeHandler) restorePaidSubscriptionAccess(ctx context.Context, stripeCustomerID, status string) {
if stripeCustomerID == "" || (status != "active" && status != "trialing") {
return
}
u, err := h.users.GetByStripeCustomerID(ctx, stripeCustomerID)
if err != nil || u == nil {
if err != nil {
log.Printf("restorePaidSubscriptionAccess: lookup %s: %v", stripeCustomerID, err)
}
return
}
h.ensureUserFirebaseAndAlertsEnabled(ctx, u.ID)
}
func (h *StripeHandler) priceIDForTier(tier domain.SubscriptionTier, interval string) (string, error) {
if interval == "annual" {
switch tier {
case domain.TierPremium:
return h.cfg.StripePriceIDPremiumAnnual, nil
case domain.TierPro:
return h.cfg.StripePriceIDProAnnual, nil
default:
return "", fmt.Errorf("no Stripe price for tier %q", tier) //nolint:err113
}
}
switch tier { switch tier {
case domain.TierPremium: case domain.TierPremium:
return h.cfg.StripePriceIDPremium, nil return h.cfg.StripePriceIDPremium, nil
@@ -47,6 +97,7 @@ func (h *StripeHandler) CreateCheckoutSession(w http.ResponseWriter, r *http.Req
var body struct { var body struct {
Tier string `json:"tier"` Tier string `json:"tier"`
Interval string `json:"interval"`
} }
if err := json.NewDecoder(r.Body).Decode(&body); err != nil { if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "Invalid request body") writeError(w, http.StatusBadRequest, "BAD_REQUEST", "Invalid request body")
@@ -56,6 +107,9 @@ func (h *StripeHandler) CreateCheckoutSession(w http.ResponseWriter, r *http.Req
if body.Tier == "" { if body.Tier == "" {
body.Tier = "premium" body.Tier = "premium"
} }
if body.Interval == "" {
body.Interval = "annual"
}
tier := domain.SubscriptionTier(body.Tier) tier := domain.SubscriptionTier(body.Tier)
if tier != domain.TierPremium && tier != domain.TierPro { if tier != domain.TierPremium && tier != domain.TierPro {
@@ -63,7 +117,7 @@ func (h *StripeHandler) CreateCheckoutSession(w http.ResponseWriter, r *http.Req
return return
} }
priceID, err := h.priceIDForTier(tier) priceID, err := h.priceIDForTier(tier, body.Interval)
if err != nil { if err != nil {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", err.Error()) writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", err.Error())
return return
@@ -177,6 +231,8 @@ func (h *StripeHandler) VerifyCheckoutSession(w http.ResponseWriter, r *http.Req
if subscriptionID != "" && customerID != "" { if subscriptionID != "" && customerID != "" {
if err := h.users.ActivateSubscription(r.Context(), customerID, subscriptionID, "active", tier); err != nil { if err := h.users.ActivateSubscription(r.Context(), customerID, subscriptionID, "active", tier); err != nil {
log.Printf("VerifyCheckout: failed to activate subscription: %v", err) log.Printf("VerifyCheckout: failed to activate subscription: %v", err)
} else {
h.restorePaidSubscriptionAccess(r.Context(), customerID, "active")
} }
} }
@@ -197,6 +253,8 @@ func (h *StripeHandler) ActivateFreeTier(w http.ResponseWriter, r *http.Request)
return return
} }
h.ensureUserFirebaseAndAlertsEnabled(r.Context(), userID)
log.Printf("Free tier activated for user %s", userID) log.Printf("Free tier activated for user %s", userID)
writeJSON(w, http.StatusOK, map[string]string{ writeJSON(w, http.StatusOK, map[string]string{
"subscription_status": "active", "subscription_status": "active",
@@ -211,6 +269,7 @@ func (h *StripeHandler) CreateOnboardingCheckout(w http.ResponseWriter, r *http.
var body struct { var body struct {
Email string `json:"email"` Email string `json:"email"`
Tier string `json:"tier"` Tier string `json:"tier"`
Interval string `json:"interval"`
} }
if err := json.NewDecoder(r.Body).Decode(&body); err != nil { if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "Invalid request body") writeError(w, http.StatusBadRequest, "BAD_REQUEST", "Invalid request body")
@@ -225,6 +284,9 @@ func (h *StripeHandler) CreateOnboardingCheckout(w http.ResponseWriter, r *http.
if body.Tier == "" { if body.Tier == "" {
body.Tier = "premium" body.Tier = "premium"
} }
if body.Interval == "" {
body.Interval = "annual"
}
tier := domain.SubscriptionTier(body.Tier) tier := domain.SubscriptionTier(body.Tier)
if tier != domain.TierPremium && tier != domain.TierPro { if tier != domain.TierPremium && tier != domain.TierPro {
@@ -232,7 +294,7 @@ func (h *StripeHandler) CreateOnboardingCheckout(w http.ResponseWriter, r *http.
return return
} }
priceID, err := h.priceIDForTier(tier) priceID, err := h.priceIDForTier(tier, body.Interval)
if err != nil { if err != nil {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", err.Error()) writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", err.Error())
return return
@@ -362,6 +424,8 @@ func (h *StripeHandler) handleCheckoutCompleted(r *http.Request, event stripe.Ev
if subscriptionID != "" && customerID != "" { if subscriptionID != "" && customerID != "" {
if err := h.users.ActivateSubscription(r.Context(), customerID, subscriptionID, "active", tier); err != nil { if err := h.users.ActivateSubscription(r.Context(), customerID, subscriptionID, "active", tier); err != nil {
log.Printf("Failed to activate subscription: %v", err) log.Printf("Failed to activate subscription: %v", err)
} else {
h.restorePaidSubscriptionAccess(r.Context(), customerID, "active")
} }
} }
@@ -401,8 +465,12 @@ func (h *StripeHandler) handleSubscriptionUpdated(r *http.Request, event stripe.
if err := h.users.ActivateSubscription(r.Context(), customerID, sub.ID, status, tier); err != nil { if err := h.users.ActivateSubscription(r.Context(), customerID, sub.ID, status, tier); err != nil {
log.Printf("Failed to update subscription: %v", err) log.Printf("Failed to update subscription: %v", err)
return
} }
h.restorePaidSubscriptionAccess(r.Context(), customerID, status)
log.Printf("Subscription %s updated to %s (tier %s) for customer %s", sub.ID, status, tier, customerID) log.Printf("Subscription %s updated to %s (tier %s) for customer %s", sub.ID, status, tier, customerID)
} }

View File

@@ -140,3 +140,33 @@ func (m *AlertRuleModel) Remove(ctx context.Context, id int) (bool, error) {
} }
return tag.RowsAffected() > 0, nil return tag.RowsAffected() > 0, nil
} }
// DisableAllForUser sets enabled = false on every alert rule owned by addresses of userID.
func (m *AlertRuleModel) DisableAllForUser(ctx context.Context, userID string) (int64, error) {
tag, err := m.pool.Exec(ctx,
`UPDATE alert_rules ar
SET enabled = FALSE
FROM addresses a
WHERE ar.address_id = a.id AND a.user_id = $1`,
userID,
)
if err != nil {
return 0, err
}
return tag.RowsAffected(), nil
}
// EnableAllForUser sets enabled = true on every alert rule owned by addresses of userID.
func (m *AlertRuleModel) EnableAllForUser(ctx context.Context, userID string) (int64, error) {
tag, err := m.pool.Exec(ctx,
`UPDATE alert_rules ar
SET enabled = TRUE
FROM addresses a
WHERE ar.address_id = a.id AND a.user_id = $1`,
userID,
)
if err != nil {
return 0, err
}
return tag.RowsAffected(), nil
}

View File

@@ -130,6 +130,46 @@ func (m *UserModel) GetByID(ctx context.Context, id string) (*domain.User, error
return scanUser(row) return scanUser(row)
} }
// GetByStripeCustomerID loads a user by their Stripe Customer ID if set.
func (m *UserModel) GetByStripeCustomerID(ctx context.Context, stripeCustomerID string) (*domain.User, error) {
row := m.pool.QueryRow(ctx,
`SELECT `+userColumns+` FROM users WHERE stripe_customer_id = $1`,
stripeCustomerID,
)
return scanUser(row)
}
// ListPaidUsersWithoutActiveSubscription finds paid-tier rows whose Stripe
// subscription is not active or trialing. Used by the periodic billing sweep.
func (m *UserModel) ListPaidUsersWithoutActiveSubscription(ctx context.Context) ([]domain.User, error) {
rows, err := m.pool.Query(ctx,
`SELECT `+userColumns+` FROM users
WHERE subscription_tier IN ('premium', 'pro')
AND subscription_status NOT IN ('active', 'trialing')
AND COALESCE(trim(firebase_uid), '') <> ''`,
)
if err != nil {
return nil, err
}
defer rows.Close()
out := []domain.User{}
for rows.Next() {
var u domain.User
rowErr := rows.Scan(
&u.ID, &u.FirebaseUID, &u.Email, &u.DisplayName,
&u.StripeCustomerID, &u.StripeSubscriptionID, &u.SubscriptionStatus,
&u.SubscriptionTier, &u.SubscriptionCreatedAt, &u.CreatedAt, &u.UpdatedAt,
)
if rowErr != nil {
return nil, rowErr
}
out = append(out, u)
}
return out, rows.Err()
}
func (m *UserModel) UpdateStripeCustomer(ctx context.Context, userID, stripeCustomerID string) error { func (m *UserModel) UpdateStripeCustomer(ctx context.Context, userID, stripeCustomerID string) error {
_, err := m.pool.Exec(ctx, _, err := m.pool.Exec(ctx,
`UPDATE users SET stripe_customer_id = $2, updated_at = NOW() WHERE id = $1`, `UPDATE users SET stripe_customer_id = $2, updated_at = NOW() WHERE id = $1`,

View File

@@ -11,9 +11,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, isSubscribed, loading } = useAuth(); const { currentUser, isSubscribed } = useAuth();
if (loading) return null;
if (!currentUser) { if (!currentUser) {
return ( return (

View File

@@ -1,12 +1,12 @@
import { getAuthHeaders } from "./authHeaders"; import { getAuthHeaders } from "./authHeaders";
import { API_BASE } from "./config"; import { API_BASE } from "./config";
export async function createCheckoutSession(tier = "premium") { export async function createCheckoutSession(tier = "premium", interval = "annual") {
const headers = await getAuthHeaders(); const headers = await getAuthHeaders();
const res = await fetch(`${API_BASE}/stripe/create-checkout-session`, { const res = await fetch(`${API_BASE}/stripe/create-checkout-session`, {
method: "POST", method: "POST",
headers: { ...headers, "Content-Type": "application/json" }, headers: { ...headers, "Content-Type": "application/json" },
body: JSON.stringify({ tier }), body: JSON.stringify({ tier, interval }),
}); });
if (!res.ok) { if (!res.ok) {
const data = await res.json(); const data = await res.json();

View File

@@ -1,4 +1,45 @@
.tier-picker { .tier-picker {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.tier-picker__toggle {
display: flex;
justify-content: center;
gap: 0;
}
.tier-picker__toggle-btn {
padding: 0.75rem 1.5rem;
border: 1px solid var(--color-border);
background: transparent;
color: var(--color-text-dimmed);
cursor: pointer;
transition: all 0.2s;
font-weight: 500;
}
.tier-picker__toggle-btn:first-child {
border-radius: 8px 0 0 8px;
}
.tier-picker__toggle-btn:last-child {
border-radius: 0 8px 8px 0;
border-left: none;
}
.tier-picker__toggle-btn--active {
background: var(--color-primary);
color: white;
border-color: var(--color-primary);
}
.tier-picker__toggle-btn:last-child.tier-picker__toggle-btn--active {
border-left: 1px solid var(--color-primary);
}
.tier-picker__cards {
display: grid; display: grid;
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(3, 1fr);
gap: 1rem; gap: 1rem;
@@ -112,7 +153,7 @@
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.tier-picker { .tier-picker__cards {
grid-template-columns: 1fr; grid-template-columns: 1fr;
max-width: 400px; max-width: 400px;
margin: 0 auto; margin: 0 auto;

View File

@@ -1,11 +1,12 @@
import { useState } from "react";
import "./TierPicker.css"; import "./TierPicker.css";
const TIERS = [ const TIERS = [
{ {
id: "free", id: "free",
name: "Trial Monitoring", name: "Trial Monitoring",
price: "$0", price: { monthly: "$0", annual: "$0" },
period: "", period: { monthly: "", annual: "" },
features: [ features: [
"Monitor 1 blockchain address 24/7", "Monitor 1 blockchain address 24/7",
"Configure alerts to fire on trigger events", "Configure alerts to fire on trigger events",
@@ -16,8 +17,8 @@ const TIERS = [
{ {
id: "premium", id: "premium",
name: "Premium Monitoring", name: "Premium Monitoring",
price: "$1.99", price: { monthly: "$8.78", annual: "$94.78" },
period: "/month", period: { monthly: "/month", annual: "/year" },
features: [ features: [
"Monitor 3 blockchain addresses", "Monitor 3 blockchain addresses",
"Configure two types of rule-based alerts to fire on trigger events for each of the three addresses", "Configure two types of rule-based alerts to fire on trigger events for each of the three addresses",
@@ -31,8 +32,8 @@ const TIERS = [
{ {
id: "pro", id: "pro",
name: "Professional Monitoring", name: "Professional Monitoring",
price: "$11.99", price: { monthly: "$16.78", annual: "$181.78" },
period: "/month", period: { monthly: "/month", annual: "/year" },
features: [ features: [
"Monitor unlimited blockchain addresses", "Monitor unlimited blockchain addresses",
"Configure unlimited alert rules to fire on unlimited events on any address", "Configure unlimited alert rules to fire on unlimited events on any address",
@@ -47,8 +48,26 @@ const TIERS = [
]; ];
export default function TierPicker({ onSelect, selectedTier }) { export default function TierPicker({ onSelect, selectedTier }) {
const [isAnnual, setIsAnnual] = useState(true);
return ( return (
<div className="tier-picker"> <div className="tier-picker">
<div className="tier-picker__toggle">
<button
className={`tier-picker__toggle-btn${!isAnnual ? " tier-picker__toggle-btn--active" : ""}`}
onClick={() => setIsAnnual(false)}
>
Monthly
</button>
<button
className={`tier-picker__toggle-btn${isAnnual ? " tier-picker__toggle-btn--active" : ""}`}
onClick={() => setIsAnnual(true)}
>
Annual
</button>
</div>
<div className="tier-picker__cards">
{TIERS.map((tier) => ( {TIERS.map((tier) => (
<div <div
key={tier.id} key={tier.id}
@@ -59,9 +78,13 @@ export default function TierPicker({ onSelect, selectedTier }) {
)} )}
<h3 className="tier-picker__name">{tier.name}</h3> <h3 className="tier-picker__name">{tier.name}</h3>
<div className="tier-picker__price"> <div className="tier-picker__price">
<span className="tier-picker__amount">{tier.price}</span> <span className="tier-picker__amount">
{tier.period && ( {isAnnual ? tier.price.annual : tier.price.monthly}
<span className="tier-picker__period">{tier.period}</span> </span>
{(isAnnual ? tier.period.annual : tier.period.monthly) && (
<span className="tier-picker__period">
{isAnnual ? tier.period.annual : tier.period.monthly}
</span>
)} )}
</div> </div>
<ul className="tier-picker__features"> <ul className="tier-picker__features">
@@ -78,12 +101,13 @@ export default function TierPicker({ onSelect, selectedTier }) {
</ul> </ul>
<button <button
className={`btn tier-picker__btn${selectedTier === tier.id ? " tier-picker__btn--selected" : ""}`} className={`btn tier-picker__btn${selectedTier === tier.id ? " tier-picker__btn--selected" : ""}`}
onClick={() => onSelect(tier.id)} onClick={() => onSelect(tier.id, isAnnual ? "annual" : "monthly")}
> >
{selectedTier === tier.id ? "Selected" : "Select"} {selectedTier === tier.id ? "Selected" : "Select"}
</button> </button>
</div> </div>
))} ))}
</div> </div>
</div>
); );
} }

View File

@@ -76,3 +76,14 @@
cursor: pointer; cursor: pointer;
transition: all 0.15s ease; transition: all 0.15s ease;
} }
@media (max-width: 480px) {
.nav-panel__overlay {
position: fixed;
inset: 0;
background-color: rgba(0, 0, 0, 0.45);
opacity: 0;
z-index: 900;
}
}

View File

@@ -1,4 +1,4 @@
import { createContext, useContext, useState, useEffect, useCallback } from "react"; import { createContext, useContext, useSyncExternalStore } from "react";
import { import {
onAuthStateChanged, onAuthStateChanged,
signInWithEmailAndPassword, signInWithEmailAndPassword,
@@ -17,88 +17,98 @@ const DEFAULT_TIER_LIMITS = {
allowed_channels: ["email"], allowed_channels: ["email"],
}; };
export function AuthProvider({ children }) { // External auth store - lives outside React
const [currentUser, setCurrentUser] = useState(null); const createAuthStore = () => {
const [userTier, setUserTier] = useState("free"); let state = {
const [tierLimits, setTierLimits] = useState(DEFAULT_TIER_LIMITS); currentUser: null,
const [isSubscribed, setIsSubscribed] = useState(false); userTier: "free",
const [loading, setLoading] = useState(true); tierLimits: DEFAULT_TIER_LIMITS,
isSubscribed: false,
loading: true,
};
const listeners = new Set();
const fetchAccount = useCallback(async () => { const notify = () => listeners.forEach((fn) => fn());
const setState = (partial) => {
state = { ...state, ...partial };
notify();
};
const fetchAccount = async () => {
try { try {
const data = await getAccount(); const data = await getAccount();
setUserTier(data.subscription_tier || "free"); setState({
setTierLimits(data.tier_limits || DEFAULT_TIER_LIMITS); userTier: data.subscription_tier || "free",
setIsSubscribed( tierLimits: data.tier_limits || DEFAULT_TIER_LIMITS,
isSubscribed:
data.subscription_status === "active" || data.subscription_status === "active" ||
data.subscription_status === "trialing", data.subscription_status === "trialing",
); });
} catch { } catch {
setUserTier("free"); setState({ isSubscribed: false });
setTierLimits(DEFAULT_TIER_LIMITS);
setIsSubscribed(false);
} }
}, []); };
useEffect(() => { // Set up Firebase listener once, outside of React
const unsubscribe = onAuthStateChanged(auth, async (user) => { onAuthStateChanged(auth, async (user) => {
setCurrentUser(user); setState({ loading: true, currentUser: user });
if (user) { if (user) {
await fetchAccount(); await fetchAccount();
} else { } else {
setUserTier("free"); setState({ isSubscribed: false, userTier: "free", tierLimits: DEFAULT_TIER_LIMITS });
setTierLimits(DEFAULT_TIER_LIMITS);
setIsSubscribed(false);
} }
setLoading(false); setState({ loading: false });
}); });
return unsubscribe;
}, [fetchAccount]);
async function signup(email, password) { return {
const cred = await createUserWithEmailAndPassword(auth, email, password); subscribe: (listener) => {
return cred.user; listeners.add(listener);
} return () => listeners.delete(listener);
},
getSnapshot: () => state,
refreshAccount: fetchAccount,
};
};
async function login(email, password) { const authStore = createAuthStore();
const cred = await signInWithEmailAndPassword(auth, email, password);
return cred.user;
}
async function logout() { export function AuthProvider({ children }) {
await signOut(auth); const state = useSyncExternalStore(authStore.subscribe, authStore.getSnapshot);
}
async function sendEmailVerification() { const signup = (email, password) => createUserWithEmailAndPassword(auth, email, password);
const login = (email, password) => signInWithEmailAndPassword(auth, email, password);
const logout = () => signOut(auth);
const sendEmailVerification = () => {
if (auth.currentUser) { if (auth.currentUser) {
await firebaseSendEmailVerification(auth.currentUser); return firebaseSendEmailVerification(auth.currentUser);
}
} }
};
async function reloadUser() { const reloadUser = async () => {
if (auth.currentUser) { if (auth.currentUser) {
await auth.currentUser.reload(); await auth.currentUser.reload();
setCurrentUser({ ...auth.currentUser });
return auth.currentUser; return auth.currentUser;
} }
return null; return null;
} };
const value = { const value = {
currentUser, ...state,
userTier,
tierLimits,
isSubscribed,
loading,
signup, signup,
login, login,
logout, logout,
sendEmailVerification, sendEmailVerification,
reloadUser, reloadUser,
refreshAccount: fetchAccount, refreshAccount: authStore.refreshAccount,
}; };
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>; return (
<AuthContext.Provider value={value}>
{!state.loading && children}
</AuthContext.Provider>
);
} }
export function useAuth() { export function useAuth() {

View File

@@ -412,6 +412,8 @@ button {
} }
.page { .page {
background-color: var(--color-bg);
height: 920px;
padding: 1rem 0.75rem; padding: 1rem 0.75rem;
} }
@@ -422,7 +424,7 @@ button {
.btn { .btn {
padding: 0.45rem 0.85rem; padding: 0.45rem 0.85rem;
font-size: 0.95rem; font-size: 1.45rem !important;
} }
.btn--lg { .btn--lg {
@@ -433,4 +435,8 @@ button {
.section { .section {
padding: 0.75rem; padding: 0.75rem;
} }
.mb-lg {
color: white;
}
} }

View File

@@ -33,8 +33,9 @@
@media (max-width: 480px) { @media (max-width: 480px) {
.address__remove { .address__remove {
margin-left: 0.5rem; padding: 0rem 0.9rem;
padding: 0.25rem 0.5rem;
font-size: 0.8rem; font-size: 0.8rem;
margin-left: -3rem;
margin-bottom: 2rem;
} }
} }

View File

@@ -116,8 +116,20 @@
font-size: 1.25rem; font-size: 1.25rem;
} }
.login-span {
color: red
}
h1 {
font-weight: 400 !important
}
.login-bg-video { .login-bg-video {
opacity: 0.13; opacity: 0.135;
left: 47%; left: 47%;
} }
.login-button {
font-size: 1rem;
}
} }

View File

@@ -47,7 +47,7 @@ export default function Login() {
<div className="login-card login-card-fadein"> <div className="login-card login-card-fadein">
<h1 className="login-heading"> <h1 className="login-heading">
<span className="login-brand">Koin Ping</span><span> - Login</span> <span className="login-brand">Koin Ping</span><span className="login-span"> - Login</span>
</h1> </h1>
<div className="login-interactive-fadein"> <div className="login-interactive-fadein">

View File

@@ -39,6 +39,7 @@ export default function Subscribe() {
password: "", password: "",
confirmPassword: "", confirmPassword: "",
selectedTier: null, selectedTier: null,
billingInterval: "annual",
}); });
const [error, setError] = useState( const [error, setError] = useState(
searchParams.get("payment") === "cancelled" searchParams.get("payment") === "cancelled"
@@ -126,7 +127,7 @@ export default function Subscribe() {
await refreshAccount(); await refreshAccount();
navigate("/addresses", { replace: true }); navigate("/addresses", { replace: true });
} else { } else {
const { url } = await createCheckoutSession(data.selectedTier); const { url } = await createCheckoutSession(data.selectedTier, data.billingInterval);
window.location.href = url; window.location.href = url;
} }
} catch (err) { } catch (err) {
@@ -245,7 +246,10 @@ export default function Subscribe() {
Select the plan that works best for you. You can upgrade anytime. Select the plan that works best for you. You can upgrade anytime.
</p> </p>
<TierPicker <TierPicker
onSelect={(tier) => set("selectedTier", tier)} onSelect={(tier, interval) => {
set("selectedTier", tier);
set("billingInterval", interval);
}}
selectedTier={data.selectedTier} selectedTier={data.selectedTier}
/> />
</> </>