Compare commits
18 Commits
config-upd
...
update-sub
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f086ef98ff | ||
|
|
2ab6dd0d6a | ||
|
|
68df446580 | ||
|
|
3fc2c28b43 | ||
|
|
c4b8022432 | ||
|
|
cea031334e | ||
|
|
c4371f7886 | ||
|
|
8b2db08db1 | ||
|
|
f52f7dc89f | ||
|
|
3eab5d07ff | ||
|
|
83ac4b7c14 | ||
|
|
5eca679f54 | ||
|
|
f4e6953046 | ||
|
|
8c1d214897 | ||
|
|
71443f0abc | ||
|
|
d89970fcac | ||
|
|
6b07d51cb1 | ||
|
|
3c282c589c |
@@ -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)
|
||||||
|
|||||||
68
backend/cmd/subscription-sweep/main.go
Normal file
68
backend/cmd/subscription-sweep/main.go
Normal 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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,12 +32,14 @@ type Config struct {
|
|||||||
ResendAPIKey string
|
ResendAPIKey string
|
||||||
EmailFrom string
|
EmailFrom string
|
||||||
DigestIntervalHours int
|
DigestIntervalHours int
|
||||||
StripeSecretKey string
|
StripeSecretKey string
|
||||||
StripeWebhookSecret string
|
StripeWebhookSecret string
|
||||||
StripePriceIDPremium string
|
StripePriceIDPremium string
|
||||||
StripePriceIDPro string
|
StripePriceIDPro string
|
||||||
StripePublishableKey string
|
StripePriceIDPremiumAnnual string
|
||||||
FrontendURL string
|
StripePriceIDProAnnual string
|
||||||
|
StripePublishableKey string
|
||||||
|
FrontendURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load reads configuration from environment variables and returns a Config.
|
// Load reads configuration from environment variables and returns a Config.
|
||||||
@@ -58,12 +60,14 @@ func Load() (*Config, error) {
|
|||||||
ResendAPIKey: os.Getenv("RESEND_API_KEY"),
|
ResendAPIKey: os.Getenv("RESEND_API_KEY"),
|
||||||
EmailFrom: getEnv("EMAIL_FROM", "Koin Ping <alerts@koinping.com>"),
|
EmailFrom: getEnv("EMAIL_FROM", "Koin Ping <alerts@koinping.com>"),
|
||||||
DigestIntervalHours: getEnvInt("DIGEST_INTERVAL_HOURS", defaultDigestIntervalHours),
|
DigestIntervalHours: getEnvInt("DIGEST_INTERVAL_HOURS", defaultDigestIntervalHours),
|
||||||
StripeSecretKey: os.Getenv("STRIPE_SECRET_KEY"),
|
StripeSecretKey: os.Getenv("STRIPE_SECRET_KEY"),
|
||||||
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"),
|
||||||
StripePublishableKey: os.Getenv("STRIPE_PUBLISHABLE_KEY"),
|
StripePriceIDPremiumAnnual: os.Getenv("STRIPE_PRICE_ID_PREMIUM_ANNUAL"),
|
||||||
FrontendURL: getEnv("FRONTEND_URL", "http://localhost:3000"),
|
StripePriceIDProAnnual: os.Getenv("STRIPE_PRICE_ID_PRO_ANNUAL"),
|
||||||
|
StripePublishableKey: os.Getenv("STRIPE_PUBLISHABLE_KEY"),
|
||||||
|
FrontendURL: getEnv("FRONTEND_URL", "http://localhost:3000"),
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.PollIntervalMS < minPollIntervalMS {
|
if cfg.PollIntervalMS < minPollIntervalMS {
|
||||||
@@ -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 ""
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ func TestTierForPriceID(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
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", ""},
|
||||||
{"", ""},
|
{"", ""},
|
||||||
}
|
}
|
||||||
|
|||||||
22
backend/internal/firebase/users.go
Normal file
22
backend/internal/firebase/users.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
cfg *config.Config
|
alerts *models.AlertRuleModel
|
||||||
|
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
|
||||||
@@ -46,7 +96,8 @@ func (h *StripeHandler) CreateCheckoutSession(w http.ResponseWriter, r *http.Req
|
|||||||
userID := middleware.GetUserID(r.Context())
|
userID := middleware.GetUserID(r.Context())
|
||||||
|
|
||||||
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",
|
||||||
@@ -209,8 +267,9 @@ func (h *StripeHandler) ActivateFreeTier(w http.ResponseWriter, r *http.Request)
|
|||||||
// The Firebase account is created on the frontend only after payment succeeds.
|
// The Firebase account is created on the frontend only after payment succeeds.
|
||||||
func (h *StripeHandler) CreateOnboardingCheckout(w http.ResponseWriter, r *http.Request) {
|
func (h *StripeHandler) CreateOnboardingCheckout(w http.ResponseWriter, r *http.Request) {
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,43 +48,66 @@ 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">
|
||||||
{TIERS.map((tier) => (
|
<div className="tier-picker__toggle">
|
||||||
<div
|
<button
|
||||||
key={tier.id}
|
className={`tier-picker__toggle-btn${!isAnnual ? " tier-picker__toggle-btn--active" : ""}`}
|
||||||
className={`tier-picker__card${tier.highlighted ? " tier-picker__card--highlighted" : ""}${selectedTier === tier.id ? " tier-picker__card--selected" : ""}`}
|
onClick={() => setIsAnnual(false)}
|
||||||
>
|
>
|
||||||
{tier.highlighted && (
|
Monthly
|
||||||
<div className="tier-picker__badge">Most Popular</div>
|
</button>
|
||||||
)}
|
<button
|
||||||
<h3 className="tier-picker__name">{tier.name}</h3>
|
className={`tier-picker__toggle-btn${isAnnual ? " tier-picker__toggle-btn--active" : ""}`}
|
||||||
<div className="tier-picker__price">
|
onClick={() => setIsAnnual(true)}
|
||||||
<span className="tier-picker__amount">{tier.price}</span>
|
>
|
||||||
{tier.period && (
|
Annual
|
||||||
<span className="tier-picker__period">{tier.period}</span>
|
</button>
|
||||||
)}
|
</div>
|
||||||
</div>
|
|
||||||
<ul className="tier-picker__features">
|
<div className="tier-picker__cards">
|
||||||
{tier.features.map((f) => (
|
{TIERS.map((tier) => (
|
||||||
<li key={f} className="tier-picker__feature">
|
<div
|
||||||
<span className="tier-picker__check">✓</span> {f}
|
key={tier.id}
|
||||||
</li>
|
className={`tier-picker__card${tier.highlighted ? " tier-picker__card--highlighted" : ""}${selectedTier === tier.id ? " tier-picker__card--selected" : ""}`}
|
||||||
))}
|
|
||||||
{tier.disabledFeatures.map((f) => (
|
|
||||||
<li key={f} className="tier-picker__feature tier-picker__feature--disabled">
|
|
||||||
<span className="tier-picker__dash">—</span> {f}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
<button
|
|
||||||
className={`btn tier-picker__btn${selectedTier === tier.id ? " tier-picker__btn--selected" : ""}`}
|
|
||||||
onClick={() => onSelect(tier.id)}
|
|
||||||
>
|
>
|
||||||
{selectedTier === tier.id ? "Selected" : "Select"}
|
{tier.highlighted && (
|
||||||
</button>
|
<div className="tier-picker__badge">Most Popular</div>
|
||||||
</div>
|
)}
|
||||||
))}
|
<h3 className="tier-picker__name">{tier.name}</h3>
|
||||||
|
<div className="tier-picker__price">
|
||||||
|
<span className="tier-picker__amount">
|
||||||
|
{isAnnual ? tier.price.annual : tier.price.monthly}
|
||||||
|
</span>
|
||||||
|
{(isAnnual ? tier.period.annual : tier.period.monthly) && (
|
||||||
|
<span className="tier-picker__period">
|
||||||
|
{isAnnual ? tier.period.annual : tier.period.monthly}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ul className="tier-picker__features">
|
||||||
|
{tier.features.map((f) => (
|
||||||
|
<li key={f} className="tier-picker__feature">
|
||||||
|
<span className="tier-picker__check">✓</span> {f}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{tier.disabledFeatures.map((f) => (
|
||||||
|
<li key={f} className="tier-picker__feature tier-picker__feature--disabled">
|
||||||
|
<span className="tier-picker__dash">—</span> {f}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<button
|
||||||
|
className={`btn tier-picker__btn${selectedTier === tier.id ? " tier-picker__btn--selected" : ""}`}
|
||||||
|
onClick={() => onSelect(tier.id, isAnnual ? "annual" : "monthly")}
|
||||||
|
>
|
||||||
|
{selectedTier === tier.id ? "Selected" : "Select"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import { createContext, useContext, useState, useEffect, useCallback } from "react";
|
import { createContext, useContext, useSyncExternalStore } from "react";
|
||||||
import {
|
import {
|
||||||
onAuthStateChanged,
|
onAuthStateChanged,
|
||||||
signInWithEmailAndPassword,
|
signInWithEmailAndPassword,
|
||||||
createUserWithEmailAndPassword,
|
createUserWithEmailAndPassword,
|
||||||
signOut,
|
signOut,
|
||||||
|
sendEmailVerification as firebaseSendEmailVerification,
|
||||||
} from "firebase/auth";
|
} from "firebase/auth";
|
||||||
import { auth } from "../firebase/config";
|
import { auth } from "../firebase/config";
|
||||||
import { getAccount } from "../api/account";
|
import { getAccount } from "../api/account";
|
||||||
@@ -16,71 +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,
|
||||||
data.subscription_status === "active" ||
|
isSubscribed:
|
||||||
|
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);
|
setState({ loading: false });
|
||||||
}
|
});
|
||||||
setLoading(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);
|
||||||
}
|
|
||||||
|
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) {
|
||||||
|
return firebaseSendEmailVerification(auth.currentUser);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const reloadUser = async () => {
|
||||||
|
if (auth.currentUser) {
|
||||||
|
await auth.currentUser.reload();
|
||||||
|
return auth.currentUser;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
const value = {
|
const value = {
|
||||||
currentUser,
|
...state,
|
||||||
userTier,
|
|
||||||
tierLimits,
|
|
||||||
isSubscribed,
|
|
||||||
loading,
|
|
||||||
signup,
|
signup,
|
||||||
login,
|
login,
|
||||||
logout,
|
logout,
|
||||||
refreshAccount: fetchAccount,
|
sendEmailVerification,
|
||||||
|
reloadUser,
|
||||||
|
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() {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -40,6 +40,7 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
position: relative;
|
position: relative;
|
||||||
top: -20px;
|
top: -20px;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-brand {
|
.login-brand {
|
||||||
@@ -114,4 +115,21 @@
|
|||||||
padding-right: 1rem;
|
padding-right: 1rem;
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.login-span {
|
||||||
|
color: red
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-weight: 400 !important
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-bg-video {
|
||||||
|
opacity: 0.135;
|
||||||
|
left: 47%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-button {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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> - Login
|
<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">
|
||||||
|
|||||||
@@ -128,6 +128,23 @@
|
|||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.subscribe__subtitle strong {
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Verify email actions */
|
||||||
|
.subscribe__verify-actions {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subscribe__verify-sent {
|
||||||
|
color: var(--color-success, #22c55e);
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* Subscribe card (Step 2 tier cards) */
|
/* Subscribe card (Step 2 tier cards) */
|
||||||
.subscribe-card {
|
.subscribe-card {
|
||||||
background-color: var(--color-bg-card, #1a1a2e);
|
background-color: var(--color-bg-card, #1a1a2e);
|
||||||
|
|||||||
@@ -10,12 +10,12 @@ import Button from "../../components/Button";
|
|||||||
import TierPicker from "../../components/TierPicker";
|
import TierPicker from "../../components/TierPicker";
|
||||||
import "./Subscribe.css";
|
import "./Subscribe.css";
|
||||||
|
|
||||||
const STEPS = ["Create Account", "Choose Plan"];
|
const STEPS = ["Create Account", "Verify Email", "Choose Plan"];
|
||||||
|
|
||||||
export default function Subscribe() {
|
export default function Subscribe() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const { currentUser, signup, refreshAccount } = useAuth();
|
const { currentUser, signup, sendEmailVerification, reloadUser, refreshAccount } = useAuth();
|
||||||
|
|
||||||
const queryParameters = new URLSearchParams(window.location.search)
|
const queryParameters = new URLSearchParams(window.location.search)
|
||||||
const success = queryParameters?.get("payment")
|
const success = queryParameters?.get("payment")
|
||||||
@@ -30,12 +30,16 @@ export default function Subscribe() {
|
|||||||
|
|
||||||
setTimeout(forward, 2000, success, session_id);
|
setTimeout(forward, 2000, success, session_id);
|
||||||
|
|
||||||
const [step, setStep] = useState(currentUser ? 2 : 1);
|
const [step, setStep] = useState(
|
||||||
|
currentUser ? (currentUser.emailVerified ? 3 : 2) : 1
|
||||||
|
);
|
||||||
|
const [verificationSent, setVerificationSent] = useState(false);
|
||||||
const [data, setData] = useState({
|
const [data, setData] = useState({
|
||||||
email: currentUser?.email || "",
|
email: currentUser?.email || "",
|
||||||
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"
|
||||||
@@ -65,6 +69,8 @@ export default function Subscribe() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await signup(data.email, data.password);
|
await signup(data.email, data.password);
|
||||||
|
await sendEmailVerification();
|
||||||
|
setVerificationSent(true);
|
||||||
setStep(2);
|
setStep(2);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message || "Failed to create account");
|
setError(err.message || "Failed to create account");
|
||||||
@@ -74,6 +80,41 @@ export default function Subscribe() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleStep2() {
|
async function handleStep2() {
|
||||||
|
setError("");
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const user = await reloadUser();
|
||||||
|
if (user?.emailVerified) {
|
||||||
|
setStep(3);
|
||||||
|
} else {
|
||||||
|
setError("Email not yet verified. Please check your inbox and click the verification link.");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || "Failed to check verification status");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleResendVerification() {
|
||||||
|
setError("");
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await sendEmailVerification();
|
||||||
|
setVerificationSent(true);
|
||||||
|
setError("");
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code === "auth/too-many-requests") {
|
||||||
|
setError("Too many requests. Please wait a moment before trying again.");
|
||||||
|
} else {
|
||||||
|
setError(err.message || "Failed to resend verification email");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleStep3() {
|
||||||
setError("");
|
setError("");
|
||||||
if (!data.selectedTier) {
|
if (!data.selectedTier) {
|
||||||
setError("Please select a plan");
|
setError("Please select a plan");
|
||||||
@@ -86,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) {
|
||||||
@@ -173,6 +214,30 @@ export default function Subscribe() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function StepVerifyEmail() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h2 className="mb-sm">Verify your email</h2>
|
||||||
|
<p className="subscribe__subtitle">
|
||||||
|
We've sent a verification link to <strong>{currentUser?.email}</strong>.
|
||||||
|
Please check your inbox and click the link to verify your email address.
|
||||||
|
</p>
|
||||||
|
{verificationSent && !error && (
|
||||||
|
<p className="subscribe__verify-sent">Verification email sent!</p>
|
||||||
|
)}
|
||||||
|
<div className="subscribe__verify-actions">
|
||||||
|
<Button
|
||||||
|
onClick={handleResendVerification}
|
||||||
|
disabled={loading}
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
{loading ? "Sending..." : "Resend verification email"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function StepChoosePlan() {
|
function StepChoosePlan() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -181,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}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
@@ -194,6 +262,7 @@ export default function Subscribe() {
|
|||||||
async function handleNext() {
|
async function handleNext() {
|
||||||
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();
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleBack() {
|
function handleBack() {
|
||||||
@@ -204,16 +273,18 @@ export default function Subscribe() {
|
|||||||
const nextLabel =
|
const nextLabel =
|
||||||
step === 1
|
step === 1
|
||||||
? "Create Account"
|
? "Create Account"
|
||||||
: !data.selectedTier
|
: step === 2
|
||||||
? "Continue"
|
? "I've verified my email"
|
||||||
: data.selectedTier === "free"
|
: !data.selectedTier
|
||||||
? "Start Free Trial"
|
? "Continue"
|
||||||
: "Subscribe & Continue";
|
: data.selectedTier === "free"
|
||||||
|
? "Start Free Trial"
|
||||||
|
: "Subscribe & Continue";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="subscribe__footer">
|
<div className="subscribe__footer">
|
||||||
<div>
|
<div>
|
||||||
{step === 2 && (
|
{step > 1 && (
|
||||||
<Button onClick={handleBack} disabled={loading} variant="ghost">
|
<Button onClick={handleBack} disabled={loading} variant="ghost">
|
||||||
← Back
|
← Back
|
||||||
</Button>
|
</Button>
|
||||||
@@ -222,7 +293,7 @@ export default function Subscribe() {
|
|||||||
|
|
||||||
<Button
|
<Button
|
||||||
onClick={handleNext}
|
onClick={handleNext}
|
||||||
disabled={loading || (step === 2 && !data.selectedTier)}
|
disabled={loading || (step === 3 && !data.selectedTier)}
|
||||||
className="text-bold"
|
className="text-bold"
|
||||||
>
|
>
|
||||||
{loading ? "Please wait..." : nextLabel}
|
{loading ? "Please wait..." : nextLabel}
|
||||||
@@ -235,13 +306,14 @@ export default function Subscribe() {
|
|||||||
|
|
||||||
const stepContent = {
|
const stepContent = {
|
||||||
1: StepCreateAccount(),
|
1: StepCreateAccount(),
|
||||||
2: StepChoosePlan(),
|
2: StepVerifyEmail(),
|
||||||
|
3: StepChoosePlan(),
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="subscribe">
|
<div className="subscribe">
|
||||||
<div
|
<div
|
||||||
className={`subscribe__container${step === 2 ? " subscribe__container--wide" : ""}`}
|
className={`subscribe__container${step === 3 ? " subscribe__container--wide" : ""}`}
|
||||||
>
|
>
|
||||||
<h1 className="subscribe__title">Koin Ping</h1>
|
<h1 className="subscribe__title">Koin Ping</h1>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user