Compare commits

..

1 Commits

Author SHA1 Message Date
KS Jannette
1303a2fcf3 test commit 2026-05-11 15:50:44 -04:00
38 changed files with 344 additions and 1545 deletions

View File

@@ -61,9 +61,8 @@ 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)
supportHandler := handlers.NewSupportHandler(cfg)
authenticate := middleware.Authenticate(userModel) authenticate := middleware.Authenticate(userModel)
requireSub := middleware.RequireSubscription(userModel) requireSub := middleware.RequireSubscription(userModel)
@@ -102,10 +101,6 @@ func main() {
mux.Handle("GET "+b+"/user/account", mux.Handle("GET "+b+"/user/account",
authenticate(http.HandlerFunc(accountHandler.GetAccount))) authenticate(http.HandlerFunc(accountHandler.GetAccount)))
// Support (auth required; avoids anonymous spam)
mux.Handle("POST "+b+"/support",
authenticate(http.HandlerFunc(supportHandler.Submit)))
// Authenticated + subscribed routes — addresses // Authenticated + subscribed routes — addresses
mux.Handle("POST "+b+"/addresses", mux.Handle("POST "+b+"/addresses",
authAndSub(http.HandlerFunc(addressHandler.Create))) authAndSub(http.HandlerFunc(addressHandler.Create)))

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

@@ -31,14 +31,11 @@ type Config struct {
NodeEnv string NodeEnv string
ResendAPIKey string ResendAPIKey string
EmailFrom string EmailFrom string
SupportInboxEmail string
DigestIntervalHours int DigestIntervalHours int
StripeSecretKey string StripeSecretKey string
StripeWebhookSecret string StripeWebhookSecret string
StripePriceIDPremium string StripePriceIDPremium string
StripePriceIDPro string StripePriceIDPro string
StripePriceIDPremiumAnnual string
StripePriceIDProAnnual string
StripePublishableKey string StripePublishableKey string
FrontendURL string FrontendURL string
} }
@@ -60,14 +57,11 @@ func Load() (*Config, error) {
NodeEnv: getEnv("NODE_ENV", "development"), NodeEnv: getEnv("NODE_ENV", "development"),
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>"),
SupportInboxEmail: getEnv("SUPPORT_INBOX_EMAIL", "sj@sjdev.co"),
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"),
StripePriceIDProAnnual: os.Getenv("STRIPE_PRICE_ID_PRO_ANNUAL"),
StripePublishableKey: os.Getenv("STRIPE_PUBLISHABLE_KEY"), StripePublishableKey: os.Getenv("STRIPE_PUBLISHABLE_KEY"),
FrontendURL: getEnv("FRONTEND_URL", "http://localhost:3000"), FrontendURL: getEnv("FRONTEND_URL", "http://localhost:3000"),
} }
@@ -103,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

@@ -8,8 +8,6 @@ func TestTierForPriceID(t *testing.T) {
cfg := &Config{ cfg := &Config{
StripePriceIDPremium: "price_premium_123", StripePriceIDPremium: "price_premium_123",
StripePriceIDPro: "price_pro_456", StripePriceIDPro: "price_pro_456",
StripePriceIDPremiumAnnual: "price_premium_yr",
StripePriceIDProAnnual: "price_pro_yr",
} }
tests := []struct { tests := []struct {
@@ -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
@@ -97,7 +47,6 @@ func (h *StripeHandler) CreateCheckoutSession(w http.ResponseWriter, r *http.Req
var body struct { var body struct {
Tier string `json:"tier"` Tier string `json:"tier"`
Interval string `json:"interval"`
} }
if err := json.NewDecoder(r.Body).Decode(&body); err != nil { if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "Invalid request body") writeError(w, http.StatusBadRequest, "BAD_REQUEST", "Invalid request body")
@@ -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",
@@ -269,7 +211,6 @@ func (h *StripeHandler) CreateOnboardingCheckout(w http.ResponseWriter, r *http.
var body struct { var body struct {
Email string `json:"email"` Email string `json:"email"`
Tier string `json:"tier"` Tier string `json:"tier"`
Interval string `json:"interval"`
} }
if err := json.NewDecoder(r.Body).Decode(&body); err != nil { if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "Invalid request body") writeError(w, http.StatusBadRequest, "BAD_REQUEST", "Invalid request body")
@@ -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

@@ -1,111 +0,0 @@
package handlers
import (
"encoding/json"
"log"
"net/http"
"net/mail"
"os"
"regexp"
"strings"
"time"
"github.com/kjannette/koin-ping/backend/internal/config"
"github.com/kjannette/koin-ping/backend/internal/notifications"
)
const (
maxSupportDescriptionLen = 8000
maxSupportEmailLen = 320
)
var descriptionAllowedRE = regexp.MustCompile(`^[a-zA-Z0-9\s]+$`)
type supportRequestBody struct {
Email string `json:"email"`
Description string `json:"description"`
}
// SupportHandler accepts authenticated support form submissions and emails the inbox.
type SupportHandler struct {
cfg *config.Config
}
func NewSupportHandler(cfg *config.Config) *SupportHandler {
return &SupportHandler{cfg: cfg}
}
// Submit handles POST /support.
func (h *SupportHandler) Submit(w http.ResponseWriter, r *http.Request) {
var body supportRequestBody
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "INVALID_JSON", "Request body must be JSON")
return
}
email := strings.TrimSpace(body.Email)
description := strings.TrimSpace(body.Description)
if len(email) > maxSupportEmailLen {
writeError(w, http.StatusBadRequest, "INVALID_EMAIL", "Email is too long")
return
}
parsed, err := mail.ParseAddress(email)
if err != nil || parsed.Address == "" {
writeError(w, http.StatusBadRequest, "INVALID_EMAIL", "Invalid email address")
return
}
canonicalEmail := parsed.Address
if description == "" {
writeError(w, http.StatusBadRequest, "INVALID_DESCRIPTION", "Description is required")
return
}
if len(description) > maxSupportDescriptionLen {
writeError(w, http.StatusBadRequest, "INVALID_DESCRIPTION", "Description is too long")
return
}
if !descriptionAllowedRE.MatchString(description) {
writeError(w, http.StatusBadRequest, "INVALID_DESCRIPTION",
"Description may only contain letters, numbers, and whitespace")
return
}
subject := time.Now().UTC().Format(time.RFC3339) + " - new support issue - koinp.ing"
plainBody := "Contact email: " + canonicalEmail + "\n\nIssue description:\n" + description
// Local dev: skip Resend when SUPPORT_DEV_SKIP_EMAIL=1 (see backend logs for payload).
if strings.EqualFold(h.cfg.NodeEnv, "development") && os.Getenv("SUPPORT_DEV_SKIP_EMAIL") == "1" {
log.Printf("[dev] SUPPORT_DEV_SKIP_EMAIL: skipping Resend; to=%s subject=%s", h.cfg.SupportInboxEmail, subject)
log.Printf("[dev] SUPPORT_DEV_SKIP_EMAIL body:\n%s", plainBody)
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
return
}
if h.cfg.ResendAPIKey == "" {
writeError(w, http.StatusServiceUnavailable, "EMAIL_UNAVAILABLE", "Support email is not configured")
return
}
if err := notifications.SendSupportEmail(
h.cfg.ResendAPIKey,
h.cfg.EmailFrom,
h.cfg.SupportInboxEmail,
subject,
plainBody,
); err != nil {
log.Printf("support Submit: send email: %v", err)
msg := "Could not send email. Confirm RESEND_API_KEY and that EMAIL_FROM uses a domain verified in Resend."
if strings.EqualFold(h.cfg.NodeEnv, "development") {
msg = "Email send failed (development): " + err.Error()
}
writeError(w, http.StatusInternalServerError, "SEND_FAILED", msg)
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}

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

@@ -5,8 +5,6 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"html"
"io"
"log" "log"
"net/http" "net/http"
"time" "time"
@@ -158,57 +156,3 @@ func alertTypeLabel(alertType string) string {
return "Alert" return "Alert"
} }
} }
type resendSupportPayload struct {
From string `json:"from"`
To string `json:"to"`
Subject string `json:"subject"`
Text string `json:"text"`
HTML string `json:"html"`
}
// SendSupportEmail delivers a plain-text support request via Resend (HTML copy is escaped).
func SendSupportEmail(apiKey, fromAddress, toAddress, subject, plainBody string) error {
if apiKey == "" {
return fmt.Errorf("RESEND_API_KEY not set") //nolint:err113
}
escaped := html.EscapeString(plainBody)
htmlBody := `<pre style="font-family:ui-sans-serif,system-ui,sans-serif;white-space:pre-wrap;">` +
escaped + `</pre>`
payload := resendSupportPayload{
From: fromAddress,
To: toAddress,
Subject: subject,
Text: plainBody,
HTML: htmlBody,
}
body, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("marshal support email payload: %w", err)
}
req, err := http.NewRequest(http.MethodPost, "https://api.resend.com/emails", bytes.NewReader(body))
if err != nil {
return fmt.Errorf("create support email request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+apiKey)
resp, err := emailHTTPClient.Do(req)
if err != nil {
log.Printf("Failed to send support email: %v", err)
return err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
log.Printf("Resend support email failed: HTTP %d body: %s", resp.StatusCode, string(snippet))
return fmt.Errorf("resend API failed: HTTP %d: %s", resp.StatusCode, string(snippet)) //nolint:err113
}
return nil
}

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

@@ -1,7 +1,6 @@
import { Routes, Route, Navigate } from "react-router-dom"; import { Routes, Route, Navigate } from "react-router-dom";
import { useAuth } from "./contexts/AuthContext"; import { useAuth } from "./contexts/AuthContext";
import Navbar from "./components/Navbar"; import Navbar from "./components/Navbar";
import Footer from "./components/Footer";
import Login from "./pages/login/Login"; import Login from "./pages/login/Login";
import Signup from "./pages/Signup"; import Signup from "./pages/Signup";
import Subscribe from "./pages/subscribe/Subscribe"; import Subscribe from "./pages/subscribe/Subscribe";
@@ -10,12 +9,11 @@ import Addresses from "./pages/addresses/Addresses";
import Alerts from "./pages/alerts/Alerts"; import Alerts from "./pages/alerts/Alerts";
import AlertHistory from "./pages/alertHistory/AlertHistory"; import AlertHistory from "./pages/alertHistory/AlertHistory";
import Account from "./pages/user_account/Account"; import Account from "./pages/user_account/Account";
import Terms from "./pages/terms/Terms";
import Privacy from "./pages/privacy/Privacy";
import Support from "./pages/support/Support";
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 (
@@ -31,27 +29,18 @@ export default function App() {
if (!isSubscribed) { if (!isSubscribed) {
return ( return (
<div className="app-layout">
<div className="app-layout__main">
<Routes> <Routes>
<Route path="/subscribe" element={<Subscribe />} /> <Route path="/subscribe" element={<Subscribe />} />
<Route path="/subscribe/return/:sessionId" element={<CheckoutReturn />} /> <Route path="/subscribe/return/:sessionId" element={<CheckoutReturn />} />
<Route path="/account" element={<><Navbar /><Account /></>} /> <Route path="/account" element={<><Navbar /><Account /></>} />
<Route path="/terms" element={<><Navbar /><Terms /></>} />
<Route path="/privacy" element={<><Navbar /><Privacy /></>} />
<Route path="/support" element={<><Navbar /><Support /></>} />
<Route path="*" element={<Navigate to="/subscribe" />} /> <Route path="*" element={<Navigate to="/subscribe" />} />
</Routes> </Routes>
</div>
<Footer />
</div>
); );
} }
return ( return (
<div className="app-layout"> <div>
<Navbar /> <Navbar />
<div className="app-layout__main">
<Routes> <Routes>
<Route path="/" element={<Addresses />} /> <Route path="/" element={<Addresses />} />
<Route path="/addresses" element={<Addresses />} /> <Route path="/addresses" element={<Addresses />} />
@@ -60,13 +49,8 @@ export default function App() {
<Route path="/account" element={<Account />} /> <Route path="/account" element={<Account />} />
<Route path="/subscribe" element={<Subscribe />} /> <Route path="/subscribe" element={<Subscribe />} />
<Route path="/subscribe/return/:sessionId" element={<CheckoutReturn />} /> <Route path="/subscribe/return/:sessionId" element={<CheckoutReturn />} />
<Route path="/terms" element={<Terms />} />
<Route path="/privacy" element={<Privacy />} />
<Route path="/support" element={<Support />} />
<Route path="*" element={<Navigate to="/addresses" />} /> <Route path="*" element={<Navigate to="/addresses" />} />
</Routes> </Routes>
</div> </div>
<Footer />
</div>
); );
} }

View File

@@ -1,20 +1 @@
const DEFAULT_API_BASE = "/v1"; export const API_BASE = import.meta.env.VITE_API_BASE || "/v1";
/**
* API origin for fetch(): root-relative prefix (e.g. /v1) or absolute URL (https://host/v1).
* Root-relative values are forced to start with "/" so requests work from any SPA route
* (e.g. /support); otherwise "v1/support" would resolve under the current path and 404.
*/
function normalizeApiBase(raw) {
const s = String(raw ?? "").trim();
const base = s || DEFAULT_API_BASE;
if (/^https?:\/\//i.test(base)) {
return base.replace(/\/+$/, "") || DEFAULT_API_BASE;
}
const path = base.startsWith("/") ? base : `/${base}`;
return path.replace(/\/+$/, "") || DEFAULT_API_BASE;
}
export const API_BASE = normalizeApiBase(import.meta.env.VITE_API_BASE);

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,23 +0,0 @@
import { getAuthHeaders } from "./authHeaders";
import { API_BASE } from "./config";
export async function submitSupportRequest({ email, description }) {
const headers = await getAuthHeaders();
const res = await fetch(`${API_BASE}/support`, {
method: "POST",
headers,
body: JSON.stringify({ email, description }),
});
if (!res.ok) {
let message = "Failed to send support request";
try {
const data = await res.json();
if (data.message) message = data.message;
else if (data.error && res.status) message = `${data.error} (${res.status})`;
} catch {
message = `Request failed (HTTP ${res.status}). The server did not return JSON — check that POST ${API_BASE}/support is proxied to your Go API (e.g. nginx location /v1/).`;
}
throw new Error(message);
}
return res.json();
}

View File

@@ -1,36 +0,0 @@
.footer {
flex-shrink: 0;
margin-top: auto;
padding: 0.75rem 1rem;
background-color: var(--color-bg);
border-top: 1px solid var(--color-border-light);
}
.footer__inner {
display: flex;
flex-wrap: wrap;
justify-content: center;
align-items: center;
gap: 0.5rem 1rem;
max-width: 1400px;
margin: 0 auto;
font-size: 0.85rem;
line-height: 1.4;
color: var(--color-text-dimmed);
}
.footer__copyright {
color: var(--color-text-dimmed);
}
.footer__link {
font-size: 1.265em;
color: inherit;
text-decoration: none;
white-space: nowrap;
}
.footer__link:hover {
color: var(--color-primary);
text-decoration: underline;
}

View File

@@ -1,41 +0,0 @@
import { Link } from "react-router-dom";
import { useAuth } from "../contexts/AuthContext";
import "./Footer.css";
export default function Footer() {
const { currentUser } = useAuth();
if (!currentUser) return null;
return (
<footer className="footer">
<div className="footer__inner">
<span className="footer__copyright">© 2026 sjDev.co</span>
<Link
to="/terms"
target="_blank"
rel="noopener noreferrer"
className="footer__link"
>
Terms
</Link>
<Link
to="/privacy"
target="_blank"
rel="noopener noreferrer"
className="footer__link"
>
Privacy
</Link>
<Link
to="/support"
target="_blank"
rel="noopener noreferrer"
className="footer__link"
>
Contact Support
</Link>
</div>
</footer>
);
}

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,26 +47,8 @@ const TIERS = [
]; ];
export default function TierPicker({ onSelect, selectedTier }) { export default function TierPicker({ onSelect, selectedTier }) {
const [isAnnual, setIsAnnual] = useState(true);
return ( return (
<div className="tier-picker"> <div className="tier-picker">
<div className="tier-picker__toggle">
<button
className={`tier-picker__toggle-btn${!isAnnual ? " tier-picker__toggle-btn--active" : ""}`}
onClick={() => setIsAnnual(false)}
>
Monthly
</button>
<button
className={`tier-picker__toggle-btn${isAnnual ? " tier-picker__toggle-btn--active" : ""}`}
onClick={() => setIsAnnual(true)}
>
Annual
</button>
</div>
<div className="tier-picker__cards">
{TIERS.map((tier) => ( {TIERS.map((tier) => (
<div <div
key={tier.id} key={tier.id}
@@ -78,13 +59,9 @@ export default function TierPicker({ onSelect, selectedTier }) {
)} )}
<h3 className="tier-picker__name">{tier.name}</h3> <h3 className="tier-picker__name">{tier.name}</h3>
<div className="tier-picker__price"> <div className="tier-picker__price">
<span className="tier-picker__amount"> <span className="tier-picker__amount">{tier.price}</span>
{isAnnual ? tier.price.annual : tier.price.monthly} {tier.period && (
</span> <span className="tier-picker__period">{tier.period}</span>
{(isAnnual ? tier.period.annual : tier.period.monthly) && (
<span className="tier-picker__period">
{isAnnual ? tier.period.annual : tier.period.monthly}
</span>
)} )}
</div> </div>
<ul className="tier-picker__features"> <ul className="tier-picker__features">
@@ -101,13 +78,12 @@ export default function TierPicker({ onSelect, selectedTier }) {
</ul> </ul>
<button <button
className={`btn tier-picker__btn${selectedTier === tier.id ? " tier-picker__btn--selected" : ""}`} className={`btn tier-picker__btn${selectedTier === tier.id ? " tier-picker__btn--selected" : ""}`}
onClick={() => onSelect(tier.id, isAnnual ? "annual" : "monthly")} onClick={() => onSelect(tier.id)}
> >
{selectedTier === tier.id ? "Selected" : "Select"} {selectedTier === tier.id ? "Selected" : "Select"}
</button> </button>
</div> </div>
))} ))}
</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);
setIsSubscribed(false);
} }
setState({ loading: 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();
export function AuthProvider({ children }) {
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 () => { async function login(email, password) {
if (auth.currentUser) { const cred = await signInWithEmailAndPassword(auth, email, password);
await auth.currentUser.reload(); return cred.user;
return auth.currentUser; }
async function logout() {
await signOut(auth);
} }
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

@@ -78,18 +78,6 @@ button {
font-weight: 200; font-weight: 200;
} }
/* ── App shell (navbar + main + footer) ───────────────────── */
.app-layout {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.app-layout__main {
flex: 1 0 auto;
}
/* ── Page Layouts ─────────────────────────────────────────── */ /* ── Page Layouts ─────────────────────────────────────────── */
.page { .page {
@@ -424,8 +412,6 @@ button {
} }
.page { .page {
background-color: var(--color-bg);
height: 920px;
padding: 1rem 0.75rem; padding: 1rem 0.75rem;
} }
@@ -436,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 {
@@ -447,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

@@ -1,24 +0,0 @@
.privacy-container {
max-width: 800px;
margin: 0 auto;
padding: 2rem;
color: var(--color-text);
}
.privacy-container h1 {
font-size: 2rem;
font-weight: 200;
margin-bottom: 1.25rem;
}
.privacy-para {
margin-bottom: 1.25rem;
font-size: 1.35rem;
font-weight: 200;
line-height: 1.65;
color: var(--color-text-muted);
}
.privacy-para:last-child {
margin-bottom: 0;
}

View File

@@ -1,30 +0,0 @@
import "./Privacy.css";
export default function Privacy() {
return (
<div className="privacy-container">
<h1>Privacy</h1>
<p className="privacy-para">
Koin Ping is committed to protecting the privacy of our users. Koin Ping
does not sell or rent user information and we do not share user
information without prior consent except as compelled by law.
</p>
<p className="privacy-para">
Koin Ping collects only enough information from users to enable the
functioning of this website and its services. It will never collect
extra information about you and uses no tracking, cookies or logging
with personal information.
</p>
<p className="privacy-para">
The only information ever stored is your chosen email address and
contact information that is provided upon signup. Koin Ping is located
within the United States, and will process and store your information in
the United States.
</p>
<p className="privacy-para">
If you would like to remove your data, or if you have any questions
about how your data is used, please contact us.
</p>
</div>
);
}

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,8 +204,6 @@ export default function Subscribe() {
const nextLabel = const nextLabel =
step === 1 step === 1
? "Create Account" ? "Create Account"
: step === 2
? "I've verified my email"
: !data.selectedTier : !data.selectedTier
? "Continue" ? "Continue"
: data.selectedTier === "free" : data.selectedTier === "free"
@@ -284,7 +213,7 @@ export default function Subscribe() {
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>

View File

@@ -1,31 +0,0 @@
.support-page__title {
font-size: 2rem;
font-weight: 200;
margin-bottom: 0.75rem;
}
.support-page__intro {
font-size: 1.1rem;
font-weight: 200;
color: var(--color-text-muted);
margin-bottom: 1.5rem;
line-height: 1.5;
}
.support-page__alert {
margin-bottom: 1rem;
}
.support-form__description {
display: block;
}
.support-form__textarea {
min-height: 10rem;
resize: vertical;
margin-top: 0.25rem;
}
.support-form__submit {
margin-top: 0.5rem;
}

View File

@@ -1,121 +0,0 @@
import { useEffect, useState } from "react";
import { useAuth } from "../../contexts/AuthContext";
import Input from "../../components/Input";
import Button from "../../components/Button";
import { submitSupportRequest } from "../../api/support";
import "./Support.css";
const EMAIL_MAX = 320;
const DESCRIPTION_MAX = 8000;
const DESCRIPTION_PATTERN = /^[a-zA-Z0-9\s]+$/;
function validateEmail(value) {
const v = value.trim();
if (!v) return "Email is required.";
if (v.length > EMAIL_MAX) return "Email is too long.";
const ok =
/^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+$/.test(
v,
);
if (!ok) return "Enter a valid email address.";
return null;
}
function validateDescription(value) {
const t = value.trim();
if (!t) return "Description of issue is required.";
if (t.length > DESCRIPTION_MAX) return "Description is too long.";
if (!DESCRIPTION_PATTERN.test(t)) {
return "Use only letters, numbers, and spaces (no special characters).";
}
return null;
}
export default function Support() {
const { currentUser } = useAuth();
const [email, setEmail] = useState("");
const [description, setDescription] = useState("");
const [error, setError] = useState(null);
const [success, setSuccess] = useState(false);
const [sending, setSending] = useState(false);
useEffect(() => {
if (currentUser?.email) {
setEmail(currentUser.email);
}
}, [currentUser?.email]);
async function handleSubmit(e) {
e.preventDefault();
setError(null);
setSuccess(false);
const emailErr = validateEmail(email);
if (emailErr) {
setError(emailErr);
return;
}
const descErr = validateDescription(description);
if (descErr) {
setError(descErr);
return;
}
setSending(true);
try {
await submitSupportRequest({
email: email.trim(),
description: description.trim(),
});
setSuccess(true);
setDescription("");
} catch (err) {
setError(err.message || "Something went wrong.");
} finally {
setSending(false);
}
}
return (
<div className="support-page page">
<h1 className="support-page__title">Contact support</h1>
<p className="support-page__intro">
Describe your issue below. We will reply to the email address you
provide.
</p>
{error && <div className="alert alert--error support-page__alert">{error}</div>}
{success && (
<div className="alert alert--success support-page__alert">
Your message was sent. Thank you.
</div>
)}
<form className="support-form" onSubmit={handleSubmit} noValidate>
<Input
label="Email"
type="email"
value={email}
onChange={setEmail}
autoComplete="email"
required
/>
<label className="form-field support-form__description">
<div className="input__label">Description of Issue</div>
<textarea
className="form-control support-form__textarea"
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={8}
maxLength={DESCRIPTION_MAX}
placeholder="Letters, numbers, and spaces only"
aria-required="true"
/>
</label>
<Button type="submit" disabled={sending} className="support-form__submit">
{sending ? "Sending…" : "Send"}
</Button>
</form>
</div>
);
}

View File

@@ -1,29 +0,0 @@
.tos-container {
max-width: 800px;
margin: 0 auto;
padding: 2rem;
color: var(--color-text);
}
.tos-container h1 {
font-size: 2rem;
font-weight: 200;
margin-bottom: 1.25rem;
}
.tos-para {
margin-bottom: 1.25rem;
font-size: 1.35rem;
font-weight: 200;
line-height: 1.65;
color: var(--color-text-muted);
}
.tos-para:last-child {
margin-bottom: 0;
}
.tos-para strong {
font-weight: 700;
color: var(--color-text);
}

View File

@@ -1,141 +0,0 @@
import "./Terms.css";
export default function Terms() {
return (
<div className="tos-container">
<h1>Terms of Service And Use</h1>
<p className="tos-para">
YOU SHOULD READ THESE TERMS OF SERVICE AND USE BEFORE USING THIS WEB
SITE.
</p>
<p className="tos-para">
By using the Website, you acknoweldge that you have read this, and
that you understand it. The Website which is located at the domain
Koin Ping.com and Koin Ping.ai (&quot;Koin Ping&quot;) and/or any mobile
apps available for download (collectively, the &quot;Web Site,&quot;
&quot;Websites&quot; and &quot;Apps&quot;) are governed by these Terms
of Service and Use (the &quot;Agreement,&quot; &quot;Terms of
Service&quot;, or &quot;TOS&quot;). Any reference herein to the
Website, Websites, and/or Apps is intended, and shall be construed, to
pertain to one, all, or any of them, indivuidually and collectively.
</p>
<p className="tos-para">
By using the Web Site and Apps, you (&quot;you&quot; or &quot;User&quot;)
signify your acceptance of this Agreement and your acknowledgement that
all information that you provide, directly or indirectly, through the Web
Sites and App will be managed in accordance with the Privacy Notice. IF
YOU DO NOT ACCEPT THESE TERMS OF SERVICE AND USE, YOU ARE NOT AUTHORIZED
TO ACCESS OR USE THE WEB SITE OR APPS.
</p>
<p className="tos-para">
<strong>Communications.</strong> You agree that by providing your contact information,
you consent to receiving communication, in connection with your
Membership subscription. This may include communication about your
account, features, and services via e-email, push notification, phone,
or text message (including by an automatic telephone dialing system
and/or with an artificial or pre-recorded voice) at any of the phone
numbers provided by you or on your behalf. Standard text messaging
charges applied by your cell phone carrier may apply.
</p>
<p className="tos-para">
<strong>User Eligibility.</strong> The Website should only be accessed and used by
individuals who agree to be bound by these Terms of Use and who are at
least 18 years of age. The Websites may be accessible worldwide;
however, the Websites are intended for use only in the USA and Canada.
If you access/use the Websites from outside the USA or Canada, you do
so at your own risk and are responsible for complying with the laws
and regulations of the territory from which you access/use the
Websites.
</p>
<p className="tos-para">
<strong>Intellectual property rights.</strong> The Web Site and the information, computer
code, and related functionality appearing, featured or otherwise
displayed on the Websites are owned by Koin Ping, its affiliates, and
their respective licensors or other third parties and protected under
the copyright, trademark and other laws of the United States and other
countries sand international treaty provisions.
</p>
<p className="tos-para">
<strong>Limited license.</strong> Koin Ping grants to you a limited, non-exclusive,
non-transferable license to use the Web Site in strict accordance with
these Terms of Service and Use and the instructions provided by us on
the Web Site. The materials provided on the Web Site, including,
without limitation, the Information, computer code, and related
functionality, are for your personal use only. Except as may be
explicitly permitted through the Websites, you may not copy, modify,
upload, republish, distribute, display, post, license, create
derivative works from, or transmit anything you obtain from the Web
Sites, including anything you download from the Websites, unless you
first obtain our written consent.
</p>
<p className="tos-para">
Any rights not expressly granted herein are reserved to Koin Ping and
its affiliates. You may not remove, obscure, or otherwise deface
proprietary notices appearing on the Web Site, or any content or
Information. Any unauthorized use of the Websites or its contents may
violate copyright laws, trademark laws, the laws of privacy and
publicity and communications regulations and statutes.
</p>
<p className="tos-para">
<strong>Restrictions on Use.</strong> As a condition of your use of the Websites, you
warrant that you will not use the Websites for any purpose that is
unlawful or prohibited by these terms, conditions and notices. You may
not use the Websites in any way that could damage, disable, overburden
or impair the Websites or interfere with any other party&apos;s use and
enjoyment of the Websites. You may not obtain or attempt to obtain any
materials or information through any means not intentionally made
available or provided for through the Web Site.
</p>
<p className="tos-para">
<strong>Revocation of privileges.</strong> You agree that your use of the Websites may
be suspended or terminated immediately upon receipt of any notice which
alleges that you have used the Websites in violation of these Terms of
Use and/or for any purpose that violates any local, state, federal or
law of the USA or other jurisdictions, including, but not limited to,
the posting of information that may violate third party rights, may
defame a third party, may be obscene or pornographic, may harass or
assault others, or may violate any laws, rules or regulations,
including, hacking or other criminal regulations. You understand that
actions in violation of these Terms of Use may subject you to serious
civil and criminal legal penalties and Koin Ping reserves the right to
pursue penalties and other remedies to the fullest extent of the law to
protect our rights.
</p>
<p className="tos-para">
<strong>No Warranties.</strong> Koin Ping MAKES NO REPRESENTATIONS OR WARRANTIES ABOUT
THE WEB SITE, THE SUITABILITY OF THE INFORMATION CONTAINED ON OR
RECEIVED THROUGH THE WEB SITE, OR ANY SERVICES OR PRODUCTS RECEIVED
THROUGH THE WEB SITE. ALL INFORMATION AND USE OF THE WEB SITE ARE
PROVIDED &quot;AS IS&quot; WITHOUT WARRANTY OF ANY KIND. Koin Ping HEREBY
EXPRESSLY DISCLAIMS ALL WARRANTIES WITH REGARD TO THE Websites, THE
INFORMATION CONTAINED ON OR RECEIVED THROUGH USE OF THE Websites AND
ANY SERVICES OR PRODUCTS RECEIVED THROUGH THE Websites, INCLUDING ALL
EXPRESS, STATUTORY AND IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE, TITLE AND NON-INFRINGEMENT. Koin Ping DOES
NOT WARRANT THAT THE CONTENTS OR ANY INFORMATION RECEIVED THROUGH THE
Websites ARE ACCURATE, RELIABLE OR CORRECT; THAT THE Websites WILL BE
AVAILABLE AT ANY PARTICULAR TIME OR LOCATION; THAT ANY DEFECTS OR
ERRORS WILL BE CORRECTED; OR THAT THE CONTENTS OR ANY INFORMATION
RECEIVED THROUGH THE Websites ARE FREE OF VIRUSES OR OTHER DESTRUCTIVE
OR HARMFUL COMPONENTS. YOUR USE OF THE Websites IS SOLELY AT YOUR OWN
RISK. USER EXPRESSLY AGREES THAT IT HAS RELIED ON NO WARRANTIES,
REPRESENTATIONS OR STATEMENTS OTHER THAN IN THIS AGREEMENT.
</p>
<p className="tos-para">
<strong>Limitation of Liability.</strong> UNDER NO CIRCUMSTANCES SHALL Koin Ping BE
LIABLE FOR ANY DAMAGES, INCLUDING, WITHOUT LIMITATION, DIRECT,
INDIRECT, PUNITIVE, INCIDENTAL, SPECIAL OR CONSEQUENTIAL DAMAGES OR LOST
PROFITS THAT RESULT FROM, OR ARISE OUT OF OR IN CONNECTION WITH THE USE
OF, OR INABILITY TO USE THE Websites, THE INFORMATION CONTAINED ON OR
RECEIVED THROUGH USE OF THE Websites, OR ANY SERVICES OR PRODUCTS
RECEIVED THROUGH THE Websites. THIS LIMITATION APPLIES WHETHER THE
ALLEGED LIABILITY IS BASED ON CONTRACT, TORT, NEGLIGENCE, STRICT
LIABILITY OR ANY OTHER BASIS, EVEN IF Koin Ping HAS BEEN ADVISED OF THE
POSSIBILITY OF SUCH DAMAGES. BECAUSE SOME JURISDICTIONS DO NOT ALLOW THE
EXCLUSION OR LIMITATION OF INCIDENTAL OR CONSEQUENTIAL DAMAGES, Koin
Ping&apos;s LIABILITY IN SUCH JURISDICTIONS SHALL BE LIMITED TO THE
MAXIMUM EXTENT PERMITTED BY THE LAW OF YOUR JURISDICTION.
</p>
</div>
);
}