Compare commits

..

1 Commits

Author SHA1 Message Date
KS Jannette
1303a2fcf3 test commit 2026-05-11 15:50:44 -04:00
26 changed files with 291 additions and 793 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, alertRuleModel, cfg) stripeHandler := handlers.NewStripeHandler(userModel, cfg)
accountHandler := handlers.NewAccountHandler(userModel, addressModel, cfg) accountHandler := handlers.NewAccountHandler(userModel, addressModel, cfg)
authenticate := middleware.Authenticate(userModel) authenticate := middleware.Authenticate(userModel)

View File

@@ -1,68 +0,0 @@
// 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

@@ -4,10 +4,7 @@ go 1.25.0
require ( require (
firebase.google.com/go/v4 v4.19.0 firebase.google.com/go/v4 v4.19.0
github.com/jackc/pgx/v5 v5.9.2 github.com/jackc/pgx/v5 v5.8.0
github.com/joho/godotenv v1.5.1
github.com/stripe/stripe-go/v82 v82.5.1
golang.org/x/sync v0.19.0
google.golang.org/api v0.269.0 google.golang.org/api v0.269.0
) )
@@ -43,21 +40,25 @@ require (
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/joho/godotenv v1.5.1 // indirect
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
github.com/resend/resend-go/v3 v3.1.1 // indirect
github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect
github.com/stripe/stripe-go/v82 v82.5.1 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/detectors/gcp v1.39.0 // indirect go.opentelemetry.io/contrib/detectors/gcp v1.39.0 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
go.opentelemetry.io/otel v1.43.0 // indirect go.opentelemetry.io/otel v1.39.0 // indirect
go.opentelemetry.io/otel/metric v1.43.0 // indirect go.opentelemetry.io/otel/metric v1.39.0 // indirect
go.opentelemetry.io/otel/sdk v1.43.0 // indirect go.opentelemetry.io/otel/sdk v1.39.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect go.opentelemetry.io/otel/sdk/metric v1.39.0 // indirect
go.opentelemetry.io/otel/trace v1.43.0 // indirect go.opentelemetry.io/otel/trace v1.39.0 // indirect
golang.org/x/crypto v0.48.0 // indirect golang.org/x/crypto v0.48.0 // indirect
golang.org/x/net v0.50.0 // indirect golang.org/x/net v0.50.0 // indirect
golang.org/x/oauth2 v0.35.0 // indirect golang.org/x/oauth2 v0.35.0 // indirect
golang.org/x/sys v0.42.0 // indirect golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect golang.org/x/text v0.34.0 // indirect
golang.org/x/time v0.14.0 // indirect golang.org/x/time v0.14.0 // indirect
google.golang.org/appengine/v2 v2.0.6 // indirect google.golang.org/appengine/v2 v2.0.6 // indirect
@@ -67,11 +68,3 @@ require (
google.golang.org/grpc v1.79.1 // indirect google.golang.org/grpc v1.79.1 // indirect
google.golang.org/protobuf v1.36.11 // indirect google.golang.org/protobuf v1.36.11 // indirect
) )
replace google.golang.org/grpc v1.79.1 => google.golang.org/grpc v1.79.3
replace go.opentelemetry.io/otel v1.39.0 => go.opentelemetry.io/otel v1.41.0
replace go.opentelemetry.io/otel/sdk v1.39.0 => go.opentelemetry.io/otel/sdk v1.43.0
replace github.com/go-jose/go-jose/v4 v4.1.3 => github.com/go-jose/go-jose/v4 v4.1.4

View File

@@ -51,8 +51,8 @@ github.com/envoyproxy/protoc-gen-validate v1.3.0 h1:TvGH1wof4H33rezVKWSpqKz5NXWg
github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA= github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
@@ -81,8 +81,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw= github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
@@ -92,6 +92,8 @@ github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/resend/resend-go/v3 v3.1.1 h1:Uwpf/tZU+O/r/3nMWE6zUAMIG9dX/vTBS3wlQzYJKSw=
github.com/resend/resend-go/v3 v3.1.1/go.mod h1:iI7VA0NoGjWvsNii5iNC5Dy0llsI3HncXPejhniYzwE=
github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo= github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo=
github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@@ -110,18 +112,18 @@ go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.6
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.36.0 h1:rixTyDGXFxRy1xzhKrotaHy3/KXdPhlWARrCgK+eqUY= go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.36.0 h1:rixTyDGXFxRy1xzhKrotaHy3/KXdPhlWARrCgK+eqUY=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.36.0/go.mod h1:dowW6UsM9MKbJq5JTz2AMVp3/5iW5I/TStsk8S+CfHw= go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.36.0/go.mod h1:dowW6UsM9MKbJq5JTz2AMVp3/5iW5I/TStsk8S+CfHw=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
@@ -143,8 +145,8 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -172,8 +174,8 @@ google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:
google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I= google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ= google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=

View File

@@ -32,14 +32,12 @@ 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
StripePriceIDPremiumAnnual string StripePublishableKey string
StripePriceIDProAnnual string FrontendURL 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.
@@ -60,14 +58,12 @@ 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"),
StripePriceIDPremiumAnnual: os.Getenv("STRIPE_PRICE_ID_PREMIUM_ANNUAL"), StripePublishableKey: os.Getenv("STRIPE_PUBLISHABLE_KEY"),
StripePriceIDProAnnual: os.Getenv("STRIPE_PRICE_ID_PRO_ANNUAL"), FrontendURL: getEnv("FRONTEND_URL", "http://localhost:3000"),
StripePublishableKey: os.Getenv("STRIPE_PUBLISHABLE_KEY"),
FrontendURL: getEnv("FRONTEND_URL", "http://localhost:3000"),
} }
if cfg.PollIntervalMS < minPollIntervalMS { if cfg.PollIntervalMS < minPollIntervalMS {
@@ -101,13 +97,10 @@ 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, c.StripePriceIDPremiumAnnual: case c.StripePriceIDPremium:
return "premium" return "premium"
case c.StripePriceIDPro, c.StripePriceIDProAnnual: case c.StripePriceIDPro:
return "pro" return "pro"
default: default:
return "" return ""

View File

@@ -6,10 +6,8 @@ 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 {
@@ -18,8 +16,6 @@ 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

@@ -1,22 +0,0 @@
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 / $8.78 mo", domain.TierPremium: "Premium / $1.99 mo",
domain.TierPro: "Pro / $16.78 mo", domain.TierPro: "Pro / $11.99 mo",
} }
func (h *AccountHandler) GetAccount(w http.ResponseWriter, r *http.Request) { func (h *AccountHandler) GetAccount(w http.ResponseWriter, r *http.Request) {

View File

@@ -1,86 +1,36 @@
package handlers package handlers
import ( import (
"context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"log" "log"
"net/http" "net/http"
kpfirebase "github.com/kjannette/koin-ping/backend/internal/firebase"
"github.com/kjannette/koin-ping/backend/internal/config"
"github.com/kjannette/koin-ping/backend/internal/domain"
"github.com/kjannette/koin-ping/backend/internal/middleware"
"github.com/kjannette/koin-ping/backend/internal/models"
"github.com/stripe/stripe-go/v82" "github.com/stripe/stripe-go/v82"
portalsession "github.com/stripe/stripe-go/v82/billingportal/session" portalsession "github.com/stripe/stripe-go/v82/billingportal/session"
checkoutsession "github.com/stripe/stripe-go/v82/checkout/session" checkoutsession "github.com/stripe/stripe-go/v82/checkout/session"
"github.com/stripe/stripe-go/v82/webhook" "github.com/stripe/stripe-go/v82/webhook"
"github.com/kjannette/koin-ping/backend/internal/config"
"github.com/kjannette/koin-ping/backend/internal/domain"
"github.com/kjannette/koin-ping/backend/internal/middleware"
"github.com/kjannette/koin-ping/backend/internal/models"
) )
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, alerts *models.AlertRuleModel, cfg *config.Config) *StripeHandler { func NewStripeHandler(users *models.UserModel, cfg *config.Config) *StripeHandler {
stripe.Key = cfg.StripeSecretKey stripe.Key = cfg.StripeSecretKey
return &StripeHandler{users: users, alerts: alerts, cfg: cfg} return &StripeHandler{users: users, cfg: cfg}
} }
func (h *StripeHandler) ensureUserFirebaseAndAlertsEnabled(ctx context.Context, localUserID string) { func (h *StripeHandler) priceIDForTier(tier domain.SubscriptionTier) (string, error) {
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
@@ -96,8 +46,7 @@ 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")
@@ -107,9 +56,6 @@ 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 {
@@ -117,7 +63,7 @@ func (h *StripeHandler) CreateCheckoutSession(w http.ResponseWriter, r *http.Req
return return
} }
priceID, err := h.priceIDForTier(tier, body.Interval) priceID, err := h.priceIDForTier(tier)
if err != nil { if err != nil {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", err.Error()) writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", err.Error())
return return
@@ -231,8 +177,6 @@ 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")
} }
} }
@@ -253,8 +197,6 @@ 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",
@@ -267,9 +209,8 @@ 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")
@@ -284,9 +225,6 @@ 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 {
@@ -294,7 +232,7 @@ func (h *StripeHandler) CreateOnboardingCheckout(w http.ResponseWriter, r *http.
return return
} }
priceID, err := h.priceIDForTier(tier, body.Interval) priceID, err := h.priceIDForTier(tier)
if err != nil { if err != nil {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", err.Error()) writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", err.Error())
return return
@@ -424,8 +362,6 @@ 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")
} }
} }
@@ -465,12 +401,8 @@ 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,33 +140,3 @@ 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,46 +130,6 @@ 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

@@ -12,7 +12,7 @@
"firebase": "^12.7.0", "firebase": "^12.7.0",
"react": "^19.2.3", "react": "^19.2.3",
"react-dom": "^19.2.3", "react-dom": "^19.2.3",
"react-router-dom": "^7.12.0" "react-router-dom": "^7.11.0"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^19.2.7", "@types/react": "^19.2.7",
@@ -20,7 +20,7 @@
"@vitejs/plugin-react": "^5.1.2", "@vitejs/plugin-react": "^5.1.2",
"prettier": "^3.8.1", "prettier": "^3.8.1",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vite": "^7.3.2" "vite": "^7.3.0"
} }
}, },
"node_modules/@babel/code-frame": { "node_modules/@babel/code-frame": {
@@ -1513,9 +1513,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@rollup/rollup-android-arm-eabi": { "node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.59.0", "version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.54.0.tgz",
"integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", "integrity": "sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -1527,9 +1527,9 @@
] ]
}, },
"node_modules/@rollup/rollup-android-arm64": { "node_modules/@rollup/rollup-android-arm64": {
"version": "4.59.0", "version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.54.0.tgz",
"integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", "integrity": "sha512-Skx39Uv+u7H224Af+bDgNinitlmHyQX1K/atIA32JP3JQw6hVODX5tkbi2zof/E69M1qH2UoN3Xdxgs90mmNYw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1541,9 +1541,9 @@
] ]
}, },
"node_modules/@rollup/rollup-darwin-arm64": { "node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.59.0", "version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.54.0.tgz",
"integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", "integrity": "sha512-k43D4qta/+6Fq+nCDhhv9yP2HdeKeP56QrUUTW7E6PhZP1US6NDqpJj4MY0jBHlJivVJD5P8NxrjuobZBJTCRw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1555,9 +1555,9 @@
] ]
}, },
"node_modules/@rollup/rollup-darwin-x64": { "node_modules/@rollup/rollup-darwin-x64": {
"version": "4.59.0", "version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.54.0.tgz",
"integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", "integrity": "sha512-cOo7biqwkpawslEfox5Vs8/qj83M/aZCSSNIWpVzfU2CYHa2G3P1UN5WF01RdTHSgCkri7XOlTdtk17BezlV3A==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1569,9 +1569,9 @@
] ]
}, },
"node_modules/@rollup/rollup-freebsd-arm64": { "node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.59.0", "version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.54.0.tgz",
"integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", "integrity": "sha512-miSvuFkmvFbgJ1BevMa4CPCFt5MPGw094knM64W9I0giUIMMmRYcGW/JWZDriaw/k1kOBtsWh1z6nIFV1vPNtA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1583,9 +1583,9 @@
] ]
}, },
"node_modules/@rollup/rollup-freebsd-x64": { "node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.59.0", "version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.54.0.tgz",
"integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", "integrity": "sha512-KGXIs55+b/ZfZsq9aR026tmr/+7tq6VG6MsnrvF4H8VhwflTIuYh+LFUlIsRdQSgrgmtM3fVATzEAj4hBQlaqQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1597,9 +1597,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm-gnueabihf": { "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.59.0", "version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.54.0.tgz",
"integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", "integrity": "sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -1611,9 +1611,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm-musleabihf": { "node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.59.0", "version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.54.0.tgz",
"integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", "integrity": "sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -1625,9 +1625,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm64-gnu": { "node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.59.0", "version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.54.0.tgz",
"integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", "integrity": "sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1639,9 +1639,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm64-musl": { "node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.59.0", "version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.54.0.tgz",
"integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", "integrity": "sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1653,23 +1653,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-loong64-gnu": { "node_modules/@rollup/rollup-linux-loong64-gnu": {
"version": "4.59.0", "version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.54.0.tgz",
"integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", "integrity": "sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-loong64-musl": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz",
"integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==",
"cpu": [ "cpu": [
"loong64" "loong64"
], ],
@@ -1681,23 +1667,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-ppc64-gnu": { "node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.59.0", "version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.54.0.tgz",
"integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", "integrity": "sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-ppc64-musl": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz",
"integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
@@ -1709,9 +1681,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-riscv64-gnu": { "node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.59.0", "version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.54.0.tgz",
"integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", "integrity": "sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
@@ -1723,9 +1695,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-riscv64-musl": { "node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.59.0", "version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.54.0.tgz",
"integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", "integrity": "sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
@@ -1737,9 +1709,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-s390x-gnu": { "node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.59.0", "version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.54.0.tgz",
"integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", "integrity": "sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==",
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
@@ -1751,9 +1723,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-x64-gnu": { "node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.59.0", "version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.54.0.tgz",
"integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", "integrity": "sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1765,9 +1737,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-x64-musl": { "node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.59.0", "version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.54.0.tgz",
"integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", "integrity": "sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1778,24 +1750,10 @@
"linux" "linux"
] ]
}, },
"node_modules/@rollup/rollup-openbsd-x64": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz",
"integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
]
},
"node_modules/@rollup/rollup-openharmony-arm64": { "node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.59.0", "version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.54.0.tgz",
"integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", "integrity": "sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1807,9 +1765,9 @@
] ]
}, },
"node_modules/@rollup/rollup-win32-arm64-msvc": { "node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.59.0", "version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.54.0.tgz",
"integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", "integrity": "sha512-c2V0W1bsKIKfbLMBu/WGBz6Yci8nJ/ZJdheE0EwB73N3MvHYKiKGs3mVilX4Gs70eGeDaMqEob25Tw2Gb9Nqyw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1821,9 +1779,9 @@
] ]
}, },
"node_modules/@rollup/rollup-win32-ia32-msvc": { "node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.59.0", "version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.54.0.tgz",
"integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", "integrity": "sha512-woEHgqQqDCkAzrDhvDipnSirm5vxUXtSKDYTVpZG3nUdW/VVB5VdCYA2iReSj/u3yCZzXID4kuKG7OynPnB3WQ==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
@@ -1835,9 +1793,9 @@
] ]
}, },
"node_modules/@rollup/rollup-win32-x64-gnu": { "node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.59.0", "version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.54.0.tgz",
"integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", "integrity": "sha512-dzAc53LOuFvHwbCEOS0rPbXp6SIhAf2txMP5p6mGyOXXw5mWY8NGGbPMPrs4P1WItkfApDathBj/NzMLUZ9rtQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1849,9 +1807,9 @@
] ]
}, },
"node_modules/@rollup/rollup-win32-x64-msvc": { "node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.59.0", "version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.54.0.tgz",
"integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", "integrity": "sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -2427,9 +2385,9 @@
} }
}, },
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.10", "version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
"integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -2472,9 +2430,10 @@
} }
}, },
"node_modules/protobufjs": { "node_modules/protobufjs": {
"version": "7.5.5", "version": "7.5.4",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.5.tgz", "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz",
"integrity": "sha512-3wY1AxV+VBNW8Yypfd1yQY9pXnqTAN+KwQxL8iYm3/BjKYMNg4i0owhEe26PWDOMaIrzeeF98Lqd5NGz4omiIg==", "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==",
"hasInstallScript": true,
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"dependencies": { "dependencies": {
"@protobufjs/aspromise": "^1.1.2", "@protobufjs/aspromise": "^1.1.2",
@@ -2528,9 +2487,9 @@
} }
}, },
"node_modules/react-router": { "node_modules/react-router": {
"version": "7.12.0", "version": "7.11.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.12.0.tgz", "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.11.0.tgz",
"integrity": "sha512-kTPDYPFzDVGIIGNLS5VJykK0HfHLY5MF3b+xj0/tTyNYL1gF1qs7u67Z9jEhQk2sQ98SUaHxlG31g1JtF7IfVw==", "integrity": "sha512-uI4JkMmjbWCZc01WVP2cH7ZfSzH91JAZUDd7/nIprDgWxBV1TkkmLToFh7EbMTcMak8URFRa2YoBL/W8GWnCTQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"cookie": "^1.0.1", "cookie": "^1.0.1",
@@ -2550,12 +2509,12 @@
} }
}, },
"node_modules/react-router-dom": { "node_modules/react-router-dom": {
"version": "7.12.0", "version": "7.11.0",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.12.0.tgz", "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.11.0.tgz",
"integrity": "sha512-pfO9fiBcpEfX4Tx+iTYKDtPbrSLLCbwJ5EqP+SPYQu1VYCXdy79GSj0wttR0U4cikVdlImZuEZ/9ZNCgoaxwBA==", "integrity": "sha512-e49Ir/kMGRzFOOrYQBdoitq3ULigw4lKbAyKusnvtDu2t4dBX4AGYPrzNvorXmVuOyeakai6FUPW5MmibvVG8g==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"react-router": "7.12.0" "react-router": "7.11.0"
}, },
"engines": { "engines": {
"node": ">=20.0.0" "node": ">=20.0.0"
@@ -2575,9 +2534,9 @@
} }
}, },
"node_modules/rollup": { "node_modules/rollup": {
"version": "4.59.0", "version": "4.54.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.54.0.tgz",
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", "integrity": "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -2591,31 +2550,28 @@
"npm": ">=8.0.0" "npm": ">=8.0.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.59.0", "@rollup/rollup-android-arm-eabi": "4.54.0",
"@rollup/rollup-android-arm64": "4.59.0", "@rollup/rollup-android-arm64": "4.54.0",
"@rollup/rollup-darwin-arm64": "4.59.0", "@rollup/rollup-darwin-arm64": "4.54.0",
"@rollup/rollup-darwin-x64": "4.59.0", "@rollup/rollup-darwin-x64": "4.54.0",
"@rollup/rollup-freebsd-arm64": "4.59.0", "@rollup/rollup-freebsd-arm64": "4.54.0",
"@rollup/rollup-freebsd-x64": "4.59.0", "@rollup/rollup-freebsd-x64": "4.54.0",
"@rollup/rollup-linux-arm-gnueabihf": "4.59.0", "@rollup/rollup-linux-arm-gnueabihf": "4.54.0",
"@rollup/rollup-linux-arm-musleabihf": "4.59.0", "@rollup/rollup-linux-arm-musleabihf": "4.54.0",
"@rollup/rollup-linux-arm64-gnu": "4.59.0", "@rollup/rollup-linux-arm64-gnu": "4.54.0",
"@rollup/rollup-linux-arm64-musl": "4.59.0", "@rollup/rollup-linux-arm64-musl": "4.54.0",
"@rollup/rollup-linux-loong64-gnu": "4.59.0", "@rollup/rollup-linux-loong64-gnu": "4.54.0",
"@rollup/rollup-linux-loong64-musl": "4.59.0", "@rollup/rollup-linux-ppc64-gnu": "4.54.0",
"@rollup/rollup-linux-ppc64-gnu": "4.59.0", "@rollup/rollup-linux-riscv64-gnu": "4.54.0",
"@rollup/rollup-linux-ppc64-musl": "4.59.0", "@rollup/rollup-linux-riscv64-musl": "4.54.0",
"@rollup/rollup-linux-riscv64-gnu": "4.59.0", "@rollup/rollup-linux-s390x-gnu": "4.54.0",
"@rollup/rollup-linux-riscv64-musl": "4.59.0", "@rollup/rollup-linux-x64-gnu": "4.54.0",
"@rollup/rollup-linux-s390x-gnu": "4.59.0", "@rollup/rollup-linux-x64-musl": "4.54.0",
"@rollup/rollup-linux-x64-gnu": "4.59.0", "@rollup/rollup-openharmony-arm64": "4.54.0",
"@rollup/rollup-linux-x64-musl": "4.59.0", "@rollup/rollup-win32-arm64-msvc": "4.54.0",
"@rollup/rollup-openbsd-x64": "4.59.0", "@rollup/rollup-win32-ia32-msvc": "4.54.0",
"@rollup/rollup-openharmony-arm64": "4.59.0", "@rollup/rollup-win32-x64-gnu": "4.54.0",
"@rollup/rollup-win32-arm64-msvc": "4.59.0", "@rollup/rollup-win32-x64-msvc": "4.54.0",
"@rollup/rollup-win32-ia32-msvc": "4.59.0",
"@rollup/rollup-win32-x64-gnu": "4.59.0",
"@rollup/rollup-win32-x64-msvc": "4.59.0",
"fsevents": "~2.3.2" "fsevents": "~2.3.2"
} }
}, },
@@ -2772,9 +2728,9 @@
} }
}, },
"node_modules/vite": { "node_modules/vite": {
"version": "7.3.2", "version": "7.3.0",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz",
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true, "peer": true,

View File

@@ -23,7 +23,7 @@
"firebase": "^12.7.0", "firebase": "^12.7.0",
"react": "^19.2.3", "react": "^19.2.3",
"react-dom": "^19.2.3", "react-dom": "^19.2.3",
"react-router-dom": "^7.12.0" "react-router-dom": "^7.11.0"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^19.2.7", "@types/react": "^19.2.7",
@@ -31,6 +31,6 @@
"@vitejs/plugin-react": "^5.1.2", "@vitejs/plugin-react": "^5.1.2",
"prettier": "^3.8.1", "prettier": "^3.8.1",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vite": "^7.3.2" "vite": "^7.3.0"
} }
} }

View File

@@ -11,7 +11,9 @@ 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 } = useAuth(); const { currentUser, isSubscribed, loading } = 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", interval = "annual") { export async function createCheckoutSession(tier = "premium") {
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, interval }), body: JSON.stringify({ tier }),
}); });
if (!res.ok) { if (!res.ok) {
const data = await res.json(); const data = await res.json();

View File

@@ -1,45 +1,4 @@
.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;
@@ -153,7 +112,7 @@
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.tier-picker__cards { .tier-picker {
grid-template-columns: 1fr; grid-template-columns: 1fr;
max-width: 400px; max-width: 400px;
margin: 0 auto; margin: 0 auto;

View File

@@ -1,12 +1,11 @@
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: { monthly: "$0", annual: "$0" }, price: "$0",
period: { monthly: "", annual: "" }, period: "",
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",
@@ -17,8 +16,8 @@ const TIERS = [
{ {
id: "premium", id: "premium",
name: "Premium Monitoring", name: "Premium Monitoring",
price: { monthly: "$8.78", annual: "$94.78" }, price: "$1.99",
period: { monthly: "/month", annual: "/year" }, period: "/month",
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",
@@ -32,8 +31,8 @@ const TIERS = [
{ {
id: "pro", id: "pro",
name: "Professional Monitoring", name: "Professional Monitoring",
price: { monthly: "$16.78", annual: "$181.78" }, price: "$11.99",
period: { monthly: "/month", annual: "/year" }, period: "/month",
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",
@@ -48,66 +47,43 @@ 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"> {TIERS.map((tier) => (
<button <div
className={`tier-picker__toggle-btn${!isAnnual ? " tier-picker__toggle-btn--active" : ""}`} key={tier.id}
onClick={() => setIsAnnual(false)} className={`tier-picker__card${tier.highlighted ? " tier-picker__card--highlighted" : ""}${selectedTier === tier.id ? " tier-picker__card--selected" : ""}`}
> >
Monthly {tier.highlighted && (
</button> <div className="tier-picker__badge">Most Popular</div>
<button )}
className={`tier-picker__toggle-btn${isAnnual ? " tier-picker__toggle-btn--active" : ""}`} <h3 className="tier-picker__name">{tier.name}</h3>
onClick={() => setIsAnnual(true)} <div className="tier-picker__price">
> <span className="tier-picker__amount">{tier.price}</span>
Annual {tier.period && (
</button> <span className="tier-picker__period">{tier.period}</span>
</div>
<div className="tier-picker__cards">
{TIERS.map((tier) => (
<div
key={tier.id}
className={`tier-picker__card${tier.highlighted ? " tier-picker__card--highlighted" : ""}${selectedTier === tier.id ? " tier-picker__card--selected" : ""}`}
>
{tier.highlighted && (
<div className="tier-picker__badge">Most Popular</div>
)} )}
<h3 className="tier-picker__name">{tier.name}</h3>
<div className="tier-picker__price">
<span className="tier-picker__amount">
{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">&#10003;</span> {f}
</li>
))}
{tier.disabledFeatures.map((f) => (
<li key={f} className="tier-picker__feature tier-picker__feature--disabled">
<span className="tier-picker__dash">&mdash;</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>
))} <ul className="tier-picker__features">
</div> {tier.features.map((f) => (
<li key={f} className="tier-picker__feature">
<span className="tier-picker__check">&#10003;</span> {f}
</li>
))}
{tier.disabledFeatures.map((f) => (
<li key={f} className="tier-picker__feature tier-picker__feature--disabled">
<span className="tier-picker__dash">&mdash;</span> {f}
</li>
))}
</ul>
<button
className={`btn tier-picker__btn${selectedTier === tier.id ? " tier-picker__btn--selected" : ""}`}
onClick={() => onSelect(tier.id)}
>
{selectedTier === tier.id ? "Selected" : "Select"}
</button>
</div>
))}
</div> </div>
); );
} }

View File

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

View File

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

View File

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

View File

@@ -40,7 +40,6 @@
text-align: center; text-align: center;
position: relative; position: relative;
top: -20px; top: -20px;
white-space: nowrap;
} }
.login-brand { .login-brand {
@@ -115,21 +114,4 @@
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;
}
} }

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 className="login-span"> - Login</span> <span className="login-brand">Koin Ping</span> - Login
</h1> </h1>
<div className="login-interactive-fadein"> <div className="login-interactive-fadein">

View File

@@ -52,3 +52,5 @@ export default function CheckoutReturn() {
</div> </div>
); );
} }
// test comment

View File

@@ -128,23 +128,6 @@
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);

View File

@@ -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", "Verify Email", "Choose Plan"]; const STEPS = ["Create Account", "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, sendEmailVerification, reloadUser, refreshAccount } = useAuth(); const { currentUser, signup, 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,16 +30,12 @@ export default function Subscribe() {
setTimeout(forward, 2000, success, session_id); setTimeout(forward, 2000, success, session_id);
const [step, setStep] = useState( const [step, setStep] = useState(currentUser ? 2 : 1);
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"
@@ -69,8 +65,6 @@ 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");
@@ -80,41 +74,6 @@ 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");
@@ -127,7 +86,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, data.billingInterval); const { url } = await createCheckoutSession(data.selectedTier);
window.location.href = url; window.location.href = url;
} }
} catch (err) { } catch (err) {
@@ -214,30 +173,6 @@ 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 (
<> <>
@@ -246,10 +181,7 @@ 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, interval) => { onSelect={(tier) => set("selectedTier", tier)}
set("selectedTier", tier);
set("billingInterval", interval);
}}
selectedTier={data.selectedTier} selectedTier={data.selectedTier}
/> />
</> </>
@@ -262,7 +194,6 @@ 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() {
@@ -273,18 +204,16 @@ export default function Subscribe() {
const nextLabel = const nextLabel =
step === 1 step === 1
? "Create Account" ? "Create Account"
: step === 2 : !data.selectedTier
? "I've verified my email" ? "Continue"
: !data.selectedTier : data.selectedTier === "free"
? "Continue" ? "Start Free Trial"
: data.selectedTier === "free" : "Subscribe & Continue";
? "Start Free Trial"
: "Subscribe & Continue";
return ( return (
<div className="subscribe__footer"> <div className="subscribe__footer">
<div> <div>
{step > 1 && ( {step === 2 && (
<Button onClick={handleBack} disabled={loading} variant="ghost"> <Button onClick={handleBack} disabled={loading} variant="ghost">
Back Back
</Button> </Button>
@@ -293,7 +222,7 @@ export default function Subscribe() {
<Button <Button
onClick={handleNext} onClick={handleNext}
disabled={loading || (step === 3 && !data.selectedTier)} disabled={loading || (step === 2 && !data.selectedTier)}
className="text-bold" className="text-bold"
> >
{loading ? "Please wait..." : nextLabel} {loading ? "Please wait..." : nextLabel}
@@ -306,14 +235,13 @@ export default function Subscribe() {
const stepContent = { const stepContent = {
1: StepCreateAccount(), 1: StepCreateAccount(),
2: StepVerifyEmail(), 2: StepChoosePlan(),
3: StepChoosePlan(),
}; };
return ( return (
<div className="subscribe"> <div className="subscribe">
<div <div
className={`subscribe__container${step === 3 ? " subscribe__container--wide" : ""}`} className={`subscribe__container${step === 2 ? " subscribe__container--wide" : ""}`}
> >
<h1 className="subscribe__title">Koin Ping</h1> <h1 className="subscribe__title">Koin Ping</h1>