Compare commits
24 Commits
subscribe-
...
add-footer
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a341741ac6 | ||
|
|
c387798be7 | ||
|
|
cb476c38d7 | ||
|
|
f086ef98ff | ||
|
|
2ab6dd0d6a | ||
|
|
68df446580 | ||
|
|
3fc2c28b43 | ||
|
|
c4b8022432 | ||
|
|
cea031334e | ||
|
|
c4371f7886 | ||
|
|
8b2db08db1 | ||
|
|
f52f7dc89f | ||
|
|
3eab5d07ff | ||
|
|
83ac4b7c14 | ||
|
|
5eca679f54 | ||
|
|
f4e6953046 | ||
|
|
8c1d214897 | ||
|
|
71443f0abc | ||
|
|
d89970fcac | ||
|
|
6b07d51cb1 | ||
|
|
3c282c589c | ||
|
|
ec7912d1ee | ||
|
|
3c82537378 | ||
|
|
93424823e6 |
@@ -61,8 +61,9 @@ func main() {
|
|||||||
notifConfigHandler := handlers.NewNotificationConfigHandler(notifConfigModel, userModel, cfg)
|
notifConfigHandler := handlers.NewNotificationConfigHandler(notifConfigModel, userModel, cfg)
|
||||||
emailDigestHandler := handlers.NewEmailDigestHandler(emailDigestSvc, notifConfigModel)
|
emailDigestHandler := handlers.NewEmailDigestHandler(emailDigestSvc, notifConfigModel)
|
||||||
statusHandler := handlers.NewStatusHandler(checkpointModel)
|
statusHandler := handlers.NewStatusHandler(checkpointModel)
|
||||||
stripeHandler := handlers.NewStripeHandler(userModel, cfg)
|
stripeHandler := handlers.NewStripeHandler(userModel, alertRuleModel, cfg)
|
||||||
accountHandler := handlers.NewAccountHandler(userModel, addressModel, cfg)
|
accountHandler := handlers.NewAccountHandler(userModel, addressModel, cfg)
|
||||||
|
supportHandler := handlers.NewSupportHandler(cfg)
|
||||||
|
|
||||||
authenticate := middleware.Authenticate(userModel)
|
authenticate := middleware.Authenticate(userModel)
|
||||||
requireSub := middleware.RequireSubscription(userModel)
|
requireSub := middleware.RequireSubscription(userModel)
|
||||||
@@ -101,6 +102,10 @@ 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)))
|
||||||
|
|||||||
68
backend/cmd/subscription-sweep/main.go
Normal file
68
backend/cmd/subscription-sweep/main.go
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
// Package main runs a daily (cron-invoked) job: for paid tiers without an
|
||||||
|
// active or trialling Stripe subscription, disable Firebase login and turn
|
||||||
|
// off all alert rules until billing is restored via Stripe webhook / checkout.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"github.com/joho/godotenv"
|
||||||
|
|
||||||
|
"github.com/kjannette/koin-ping/backend/internal/config"
|
||||||
|
"github.com/kjannette/koin-ping/backend/internal/database"
|
||||||
|
"github.com/kjannette/koin-ping/backend/internal/firebase"
|
||||||
|
"github.com/kjannette/koin-ping/backend/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
_ = godotenv.Load()
|
||||||
|
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to load config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := firebase.Init(cfg.FirebaseProjectID); err != nil {
|
||||||
|
log.Fatalf("Failed to initialize Firebase: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pool, connErr := database.Connect(cfg.DSN())
|
||||||
|
if connErr != nil {
|
||||||
|
log.Fatalf("Failed to connect to database: %v", connErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer database.Close()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
userModel := models.NewUserModel(pool)
|
||||||
|
alertModel := models.NewAlertRuleModel(pool)
|
||||||
|
|
||||||
|
users, listErr := userModel.ListPaidUsersWithoutActiveSubscription(ctx)
|
||||||
|
if listErr != nil {
|
||||||
|
log.Fatalf("Failed to list lapsed subscriptions: %v", listErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(users) == 0 {
|
||||||
|
log.Println("Subscription sweep: no lapsed paid users")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, u := range users {
|
||||||
|
if disableErr := firebase.SetUserDisabled(ctx, u.FirebaseUID, true); disableErr != nil {
|
||||||
|
log.Printf("Subscription sweep: Firebase disable failed user %s: %v", u.ID, disableErr)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
n, rulesErr := alertModel.DisableAllForUser(ctx, u.ID)
|
||||||
|
if rulesErr != nil {
|
||||||
|
log.Printf("Subscription sweep: disabling alerts failed for user %s: %v", u.ID, rulesErr)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf(
|
||||||
|
"Subscription sweep: suspended user %s (%s tier, status=%s), disabled %d alert rules",
|
||||||
|
u.ID, u.SubscriptionTier, u.SubscriptionStatus, n,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,10 @@ 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.8.0
|
github.com/jackc/pgx/v5 v5.9.2
|
||||||
|
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
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -40,25 +43,21 @@ 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.39.0 // indirect
|
go.opentelemetry.io/otel v1.43.0 // indirect
|
||||||
go.opentelemetry.io/otel/metric v1.39.0 // indirect
|
go.opentelemetry.io/otel/metric v1.43.0 // indirect
|
||||||
go.opentelemetry.io/otel/sdk v1.39.0 // indirect
|
go.opentelemetry.io/otel/sdk v1.43.0 // indirect
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.39.0 // indirect
|
go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect
|
||||||
go.opentelemetry.io/otel/trace v1.39.0 // indirect
|
go.opentelemetry.io/otel/trace v1.43.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/sync v0.19.0 // indirect
|
golang.org/x/sys v0.42.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
|
||||||
@@ -68,3 +67,11 @@ 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
|
||||||
|
|||||||
@@ -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.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
|
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
|
||||||
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
github.com/go-jose/go-jose/v4 v4.1.4/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.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
|
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
|
||||||
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
|
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||||
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,8 +92,6 @@ 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=
|
||||||
@@ -112,18 +110,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.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
|
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
|
||||||
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
|
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
|
||||||
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.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
|
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
|
||||||
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
|
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
|
||||||
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
|
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
|
||||||
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
|
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
|
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
|
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
|
||||||
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
|
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
|
||||||
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
|
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
|
||||||
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=
|
||||||
@@ -145,8 +143,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.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
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=
|
||||||
@@ -174,8 +172,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.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
|
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
|
||||||
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
google.golang.org/grpc v1.79.3/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=
|
||||||
|
|||||||
@@ -31,11 +31,14 @@ 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
|
||||||
}
|
}
|
||||||
@@ -57,11 +60,14 @@ 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"),
|
||||||
}
|
}
|
||||||
@@ -97,10 +103,13 @@ func (c *Config) DSN() string {
|
|||||||
// TierForPriceID maps a Stripe price ID back to the corresponding
|
// TierForPriceID maps a Stripe price ID back to the corresponding
|
||||||
// subscription tier. Returns empty string if the price is unrecognised.
|
// subscription tier. Returns empty string if the price is unrecognised.
|
||||||
func (c *Config) TierForPriceID(priceID string) string {
|
func (c *Config) TierForPriceID(priceID string) string {
|
||||||
|
if priceID == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
switch priceID {
|
switch priceID {
|
||||||
case c.StripePriceIDPremium:
|
case c.StripePriceIDPremium, c.StripePriceIDPremiumAnnual:
|
||||||
return "premium"
|
return "premium"
|
||||||
case c.StripePriceIDPro:
|
case c.StripePriceIDPro, c.StripePriceIDProAnnual:
|
||||||
return "pro"
|
return "pro"
|
||||||
default:
|
default:
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ func TestTierForPriceID(t *testing.T) {
|
|||||||
cfg := &Config{
|
cfg := &Config{
|
||||||
StripePriceIDPremium: "price_premium_123",
|
StripePriceIDPremium: "price_premium_123",
|
||||||
StripePriceIDPro: "price_pro_456",
|
StripePriceIDPro: "price_pro_456",
|
||||||
|
StripePriceIDPremiumAnnual: "price_premium_yr",
|
||||||
|
StripePriceIDProAnnual: "price_pro_yr",
|
||||||
}
|
}
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
@@ -16,6 +18,8 @@ func TestTierForPriceID(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{"price_premium_123", "premium"},
|
{"price_premium_123", "premium"},
|
||||||
{"price_pro_456", "pro"},
|
{"price_pro_456", "pro"},
|
||||||
|
{"price_premium_yr", "premium"},
|
||||||
|
{"price_pro_yr", "pro"},
|
||||||
{"price_unknown", ""},
|
{"price_unknown", ""},
|
||||||
{"", ""},
|
{"", ""},
|
||||||
}
|
}
|
||||||
|
|||||||
22
backend/internal/firebase/users.go
Normal file
22
backend/internal/firebase/users.go
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
package firebase
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"firebase.google.com/go/v4/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SetUserDisabled updates the Firebase user record's disabled flag.
|
||||||
|
func SetUserDisabled(ctx context.Context, uid string, disabled bool) error {
|
||||||
|
if authClient == nil {
|
||||||
|
return fmt.Errorf("firebase auth not initialized") //nolint:err113
|
||||||
|
}
|
||||||
|
if uid == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
params := (&auth.UserToUpdate{}).Disabled(disabled)
|
||||||
|
_, err := authClient.UpdateUser(ctx, uid, params)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -38,8 +38,8 @@ type accountResponse struct {
|
|||||||
|
|
||||||
var tierPlanLabels = map[domain.SubscriptionTier]string{ //nolint:gochecknoglobals
|
var tierPlanLabels = map[domain.SubscriptionTier]string{ //nolint:gochecknoglobals
|
||||||
domain.TierFree: "Free Trial",
|
domain.TierFree: "Free Trial",
|
||||||
domain.TierPremium: "Premium / $1.99 mo",
|
domain.TierPremium: "Premium / $8.78 mo",
|
||||||
domain.TierPro: "Pro / $11.99 mo",
|
domain.TierPro: "Pro / $16.78 mo",
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *AccountHandler) GetAccount(w http.ResponseWriter, r *http.Request) {
|
func (h *AccountHandler) GetAccount(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -1,36 +1,86 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/stripe/stripe-go/v82"
|
kpfirebase "github.com/kjannette/koin-ping/backend/internal/firebase"
|
||||||
portalsession "github.com/stripe/stripe-go/v82/billingportal/session"
|
|
||||||
checkoutsession "github.com/stripe/stripe-go/v82/checkout/session"
|
|
||||||
"github.com/stripe/stripe-go/v82/webhook"
|
|
||||||
|
|
||||||
"github.com/kjannette/koin-ping/backend/internal/config"
|
"github.com/kjannette/koin-ping/backend/internal/config"
|
||||||
"github.com/kjannette/koin-ping/backend/internal/domain"
|
"github.com/kjannette/koin-ping/backend/internal/domain"
|
||||||
"github.com/kjannette/koin-ping/backend/internal/middleware"
|
"github.com/kjannette/koin-ping/backend/internal/middleware"
|
||||||
"github.com/kjannette/koin-ping/backend/internal/models"
|
"github.com/kjannette/koin-ping/backend/internal/models"
|
||||||
|
"github.com/stripe/stripe-go/v82"
|
||||||
|
portalsession "github.com/stripe/stripe-go/v82/billingportal/session"
|
||||||
|
checkoutsession "github.com/stripe/stripe-go/v82/checkout/session"
|
||||||
|
"github.com/stripe/stripe-go/v82/webhook"
|
||||||
)
|
)
|
||||||
|
|
||||||
const webhookMaxBodyBytes = 65536
|
const webhookMaxBodyBytes = 65536
|
||||||
|
|
||||||
type StripeHandler struct {
|
type StripeHandler struct {
|
||||||
users *models.UserModel
|
users *models.UserModel
|
||||||
|
alerts *models.AlertRuleModel
|
||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewStripeHandler(users *models.UserModel, cfg *config.Config) *StripeHandler {
|
func NewStripeHandler(users *models.UserModel, alerts *models.AlertRuleModel, cfg *config.Config) *StripeHandler {
|
||||||
stripe.Key = cfg.StripeSecretKey
|
stripe.Key = cfg.StripeSecretKey
|
||||||
return &StripeHandler{users: users, cfg: cfg}
|
return &StripeHandler{users: users, alerts: alerts, cfg: cfg}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *StripeHandler) priceIDForTier(tier domain.SubscriptionTier) (string, error) {
|
func (h *StripeHandler) ensureUserFirebaseAndAlertsEnabled(ctx context.Context, localUserID string) {
|
||||||
|
user, err := h.users.GetByID(ctx, localUserID)
|
||||||
|
if err != nil || user == nil || user.FirebaseUID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if firebaseErr := kpfirebase.SetUserDisabled(ctx, user.FirebaseUID, false); firebaseErr != nil {
|
||||||
|
log.Printf("Billing access restore: firebase enable failed for user %s: %v", localUserID, firebaseErr)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
n, alertsErr := h.alerts.EnableAllForUser(ctx, localUserID)
|
||||||
|
if alertsErr != nil {
|
||||||
|
log.Printf("Billing access restore: enable alerts failed for user %s: %v", localUserID, alertsErr)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Billing access restored: user %s, %d alert rules enabled", localUserID, n)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *StripeHandler) restorePaidSubscriptionAccess(ctx context.Context, stripeCustomerID, status string) {
|
||||||
|
if stripeCustomerID == "" || (status != "active" && status != "trialing") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := h.users.GetByStripeCustomerID(ctx, stripeCustomerID)
|
||||||
|
if err != nil || u == nil {
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("restorePaidSubscriptionAccess: lookup %s: %v", stripeCustomerID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.ensureUserFirebaseAndAlertsEnabled(ctx, u.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *StripeHandler) priceIDForTier(tier domain.SubscriptionTier, interval string) (string, error) {
|
||||||
|
if interval == "annual" {
|
||||||
|
switch tier {
|
||||||
|
case domain.TierPremium:
|
||||||
|
return h.cfg.StripePriceIDPremiumAnnual, nil
|
||||||
|
case domain.TierPro:
|
||||||
|
return h.cfg.StripePriceIDProAnnual, nil
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("no Stripe price for tier %q", tier) //nolint:err113
|
||||||
|
}
|
||||||
|
}
|
||||||
switch tier {
|
switch tier {
|
||||||
case domain.TierPremium:
|
case domain.TierPremium:
|
||||||
return h.cfg.StripePriceIDPremium, nil
|
return h.cfg.StripePriceIDPremium, nil
|
||||||
@@ -47,6 +97,7 @@ func (h *StripeHandler) CreateCheckoutSession(w http.ResponseWriter, r *http.Req
|
|||||||
|
|
||||||
var body struct {
|
var body struct {
|
||||||
Tier string `json:"tier"`
|
Tier string `json:"tier"`
|
||||||
|
Interval string `json:"interval"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "Invalid request body")
|
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "Invalid request body")
|
||||||
@@ -56,6 +107,9 @@ func (h *StripeHandler) CreateCheckoutSession(w http.ResponseWriter, r *http.Req
|
|||||||
if body.Tier == "" {
|
if body.Tier == "" {
|
||||||
body.Tier = "premium"
|
body.Tier = "premium"
|
||||||
}
|
}
|
||||||
|
if body.Interval == "" {
|
||||||
|
body.Interval = "annual"
|
||||||
|
}
|
||||||
|
|
||||||
tier := domain.SubscriptionTier(body.Tier)
|
tier := domain.SubscriptionTier(body.Tier)
|
||||||
if tier != domain.TierPremium && tier != domain.TierPro {
|
if tier != domain.TierPremium && tier != domain.TierPro {
|
||||||
@@ -63,7 +117,7 @@ func (h *StripeHandler) CreateCheckoutSession(w http.ResponseWriter, r *http.Req
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
priceID, err := h.priceIDForTier(tier)
|
priceID, err := h.priceIDForTier(tier, body.Interval)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", err.Error())
|
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", err.Error())
|
||||||
return
|
return
|
||||||
@@ -177,6 +231,8 @@ func (h *StripeHandler) VerifyCheckoutSession(w http.ResponseWriter, r *http.Req
|
|||||||
if subscriptionID != "" && customerID != "" {
|
if subscriptionID != "" && customerID != "" {
|
||||||
if err := h.users.ActivateSubscription(r.Context(), customerID, subscriptionID, "active", tier); err != nil {
|
if err := h.users.ActivateSubscription(r.Context(), customerID, subscriptionID, "active", tier); err != nil {
|
||||||
log.Printf("VerifyCheckout: failed to activate subscription: %v", err)
|
log.Printf("VerifyCheckout: failed to activate subscription: %v", err)
|
||||||
|
} else {
|
||||||
|
h.restorePaidSubscriptionAccess(r.Context(), customerID, "active")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,6 +253,8 @@ func (h *StripeHandler) ActivateFreeTier(w http.ResponseWriter, r *http.Request)
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
h.ensureUserFirebaseAndAlertsEnabled(r.Context(), userID)
|
||||||
|
|
||||||
log.Printf("Free tier activated for user %s", userID)
|
log.Printf("Free tier activated for user %s", userID)
|
||||||
writeJSON(w, http.StatusOK, map[string]string{
|
writeJSON(w, http.StatusOK, map[string]string{
|
||||||
"subscription_status": "active",
|
"subscription_status": "active",
|
||||||
@@ -211,6 +269,7 @@ func (h *StripeHandler) CreateOnboardingCheckout(w http.ResponseWriter, r *http.
|
|||||||
var body struct {
|
var body struct {
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
Tier string `json:"tier"`
|
Tier string `json:"tier"`
|
||||||
|
Interval string `json:"interval"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "Invalid request body")
|
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "Invalid request body")
|
||||||
@@ -225,6 +284,9 @@ func (h *StripeHandler) CreateOnboardingCheckout(w http.ResponseWriter, r *http.
|
|||||||
if body.Tier == "" {
|
if body.Tier == "" {
|
||||||
body.Tier = "premium"
|
body.Tier = "premium"
|
||||||
}
|
}
|
||||||
|
if body.Interval == "" {
|
||||||
|
body.Interval = "annual"
|
||||||
|
}
|
||||||
|
|
||||||
tier := domain.SubscriptionTier(body.Tier)
|
tier := domain.SubscriptionTier(body.Tier)
|
||||||
if tier != domain.TierPremium && tier != domain.TierPro {
|
if tier != domain.TierPremium && tier != domain.TierPro {
|
||||||
@@ -232,7 +294,7 @@ func (h *StripeHandler) CreateOnboardingCheckout(w http.ResponseWriter, r *http.
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
priceID, err := h.priceIDForTier(tier)
|
priceID, err := h.priceIDForTier(tier, body.Interval)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", err.Error())
|
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", err.Error())
|
||||||
return
|
return
|
||||||
@@ -362,6 +424,8 @@ func (h *StripeHandler) handleCheckoutCompleted(r *http.Request, event stripe.Ev
|
|||||||
if subscriptionID != "" && customerID != "" {
|
if subscriptionID != "" && customerID != "" {
|
||||||
if err := h.users.ActivateSubscription(r.Context(), customerID, subscriptionID, "active", tier); err != nil {
|
if err := h.users.ActivateSubscription(r.Context(), customerID, subscriptionID, "active", tier); err != nil {
|
||||||
log.Printf("Failed to activate subscription: %v", err)
|
log.Printf("Failed to activate subscription: %v", err)
|
||||||
|
} else {
|
||||||
|
h.restorePaidSubscriptionAccess(r.Context(), customerID, "active")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -401,8 +465,12 @@ func (h *StripeHandler) handleSubscriptionUpdated(r *http.Request, event stripe.
|
|||||||
|
|
||||||
if err := h.users.ActivateSubscription(r.Context(), customerID, sub.ID, status, tier); err != nil {
|
if err := h.users.ActivateSubscription(r.Context(), customerID, sub.ID, status, tier); err != nil {
|
||||||
log.Printf("Failed to update subscription: %v", err)
|
log.Printf("Failed to update subscription: %v", err)
|
||||||
|
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
h.restorePaidSubscriptionAccess(r.Context(), customerID, status)
|
||||||
|
|
||||||
log.Printf("Subscription %s updated to %s (tier %s) for customer %s", sub.ID, status, tier, customerID)
|
log.Printf("Subscription %s updated to %s (tier %s) for customer %s", sub.ID, status, tier, customerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
111
backend/internal/handlers/support.go
Normal file
111
backend/internal/handlers/support.go
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
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"})
|
||||||
|
}
|
||||||
@@ -140,3 +140,33 @@ func (m *AlertRuleModel) Remove(ctx context.Context, id int) (bool, error) {
|
|||||||
}
|
}
|
||||||
return tag.RowsAffected() > 0, nil
|
return tag.RowsAffected() > 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DisableAllForUser sets enabled = false on every alert rule owned by addresses of userID.
|
||||||
|
func (m *AlertRuleModel) DisableAllForUser(ctx context.Context, userID string) (int64, error) {
|
||||||
|
tag, err := m.pool.Exec(ctx,
|
||||||
|
`UPDATE alert_rules ar
|
||||||
|
SET enabled = FALSE
|
||||||
|
FROM addresses a
|
||||||
|
WHERE ar.address_id = a.id AND a.user_id = $1`,
|
||||||
|
userID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return tag.RowsAffected(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnableAllForUser sets enabled = true on every alert rule owned by addresses of userID.
|
||||||
|
func (m *AlertRuleModel) EnableAllForUser(ctx context.Context, userID string) (int64, error) {
|
||||||
|
tag, err := m.pool.Exec(ctx,
|
||||||
|
`UPDATE alert_rules ar
|
||||||
|
SET enabled = TRUE
|
||||||
|
FROM addresses a
|
||||||
|
WHERE ar.address_id = a.id AND a.user_id = $1`,
|
||||||
|
userID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return tag.RowsAffected(), nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -130,6 +130,46 @@ func (m *UserModel) GetByID(ctx context.Context, id string) (*domain.User, error
|
|||||||
return scanUser(row)
|
return scanUser(row)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetByStripeCustomerID loads a user by their Stripe Customer ID if set.
|
||||||
|
func (m *UserModel) GetByStripeCustomerID(ctx context.Context, stripeCustomerID string) (*domain.User, error) {
|
||||||
|
row := m.pool.QueryRow(ctx,
|
||||||
|
`SELECT `+userColumns+` FROM users WHERE stripe_customer_id = $1`,
|
||||||
|
stripeCustomerID,
|
||||||
|
)
|
||||||
|
return scanUser(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListPaidUsersWithoutActiveSubscription finds paid-tier rows whose Stripe
|
||||||
|
// subscription is not active or trialing. Used by the periodic billing sweep.
|
||||||
|
func (m *UserModel) ListPaidUsersWithoutActiveSubscription(ctx context.Context) ([]domain.User, error) {
|
||||||
|
rows, err := m.pool.Query(ctx,
|
||||||
|
`SELECT `+userColumns+` FROM users
|
||||||
|
WHERE subscription_tier IN ('premium', 'pro')
|
||||||
|
AND subscription_status NOT IN ('active', 'trialing')
|
||||||
|
AND COALESCE(trim(firebase_uid), '') <> ''`,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
out := []domain.User{}
|
||||||
|
for rows.Next() {
|
||||||
|
var u domain.User
|
||||||
|
rowErr := rows.Scan(
|
||||||
|
&u.ID, &u.FirebaseUID, &u.Email, &u.DisplayName,
|
||||||
|
&u.StripeCustomerID, &u.StripeSubscriptionID, &u.SubscriptionStatus,
|
||||||
|
&u.SubscriptionTier, &u.SubscriptionCreatedAt, &u.CreatedAt, &u.UpdatedAt,
|
||||||
|
)
|
||||||
|
if rowErr != nil {
|
||||||
|
return nil, rowErr
|
||||||
|
}
|
||||||
|
out = append(out, u)
|
||||||
|
}
|
||||||
|
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
func (m *UserModel) UpdateStripeCustomer(ctx context.Context, userID, stripeCustomerID string) error {
|
func (m *UserModel) UpdateStripeCustomer(ctx context.Context, userID, stripeCustomerID string) error {
|
||||||
_, err := m.pool.Exec(ctx,
|
_, err := m.pool.Exec(ctx,
|
||||||
`UPDATE users SET stripe_customer_id = $2, updated_at = NOW() WHERE id = $1`,
|
`UPDATE users SET stripe_customer_id = $2, updated_at = NOW() WHERE id = $1`,
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"html"
|
||||||
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
@@ -156,3 +158,57 @@ 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
|
||||||
|
}
|
||||||
|
|||||||
264
frontend/package-lock.json
generated
264
frontend/package-lock.json
generated
@@ -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.11.0"
|
"react-router-dom": "^7.12.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.0"
|
"vite": "^7.3.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"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.54.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz",
|
||||||
"integrity": "sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng==",
|
"integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -1527,9 +1527,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-android-arm64": {
|
"node_modules/@rollup/rollup-android-arm64": {
|
||||||
"version": "4.54.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz",
|
||||||
"integrity": "sha512-Skx39Uv+u7H224Af+bDgNinitlmHyQX1K/atIA32JP3JQw6hVODX5tkbi2zof/E69M1qH2UoN3Xdxgs90mmNYw==",
|
"integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1541,9 +1541,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-darwin-arm64": {
|
"node_modules/@rollup/rollup-darwin-arm64": {
|
||||||
"version": "4.54.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz",
|
||||||
"integrity": "sha512-k43D4qta/+6Fq+nCDhhv9yP2HdeKeP56QrUUTW7E6PhZP1US6NDqpJj4MY0jBHlJivVJD5P8NxrjuobZBJTCRw==",
|
"integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1555,9 +1555,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-darwin-x64": {
|
"node_modules/@rollup/rollup-darwin-x64": {
|
||||||
"version": "4.54.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz",
|
||||||
"integrity": "sha512-cOo7biqwkpawslEfox5Vs8/qj83M/aZCSSNIWpVzfU2CYHa2G3P1UN5WF01RdTHSgCkri7XOlTdtk17BezlV3A==",
|
"integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1569,9 +1569,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-freebsd-arm64": {
|
"node_modules/@rollup/rollup-freebsd-arm64": {
|
||||||
"version": "4.54.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz",
|
||||||
"integrity": "sha512-miSvuFkmvFbgJ1BevMa4CPCFt5MPGw094knM64W9I0giUIMMmRYcGW/JWZDriaw/k1kOBtsWh1z6nIFV1vPNtA==",
|
"integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1583,9 +1583,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-freebsd-x64": {
|
"node_modules/@rollup/rollup-freebsd-x64": {
|
||||||
"version": "4.54.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz",
|
||||||
"integrity": "sha512-KGXIs55+b/ZfZsq9aR026tmr/+7tq6VG6MsnrvF4H8VhwflTIuYh+LFUlIsRdQSgrgmtM3fVATzEAj4hBQlaqQ==",
|
"integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1597,9 +1597,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
||||||
"version": "4.54.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz",
|
||||||
"integrity": "sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==",
|
"integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -1611,9 +1611,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
||||||
"version": "4.54.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz",
|
||||||
"integrity": "sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==",
|
"integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -1625,9 +1625,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
||||||
"version": "4.54.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz",
|
||||||
"integrity": "sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==",
|
"integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1639,9 +1639,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
||||||
"version": "4.54.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz",
|
||||||
"integrity": "sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==",
|
"integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1653,9 +1653,23 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
||||||
"version": "4.54.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz",
|
||||||
"integrity": "sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==",
|
"integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==",
|
||||||
|
"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"
|
||||||
],
|
],
|
||||||
@@ -1667,9 +1681,23 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
||||||
"version": "4.54.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz",
|
||||||
"integrity": "sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==",
|
"integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==",
|
||||||
|
"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"
|
||||||
],
|
],
|
||||||
@@ -1681,9 +1709,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
||||||
"version": "4.54.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz",
|
||||||
"integrity": "sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==",
|
"integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
@@ -1695,9 +1723,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
||||||
"version": "4.54.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz",
|
||||||
"integrity": "sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==",
|
"integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
@@ -1709,9 +1737,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
||||||
"version": "4.54.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz",
|
||||||
"integrity": "sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==",
|
"integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
@@ -1723,9 +1751,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||||
"version": "4.54.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz",
|
||||||
"integrity": "sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==",
|
"integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1737,9 +1765,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-x64-musl": {
|
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||||
"version": "4.54.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz",
|
||||||
"integrity": "sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==",
|
"integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1750,10 +1778,24 @@
|
|||||||
"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.54.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz",
|
||||||
"integrity": "sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==",
|
"integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1765,9 +1807,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
||||||
"version": "4.54.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz",
|
||||||
"integrity": "sha512-c2V0W1bsKIKfbLMBu/WGBz6Yci8nJ/ZJdheE0EwB73N3MvHYKiKGs3mVilX4Gs70eGeDaMqEob25Tw2Gb9Nqyw==",
|
"integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1779,9 +1821,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
||||||
"version": "4.54.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz",
|
||||||
"integrity": "sha512-woEHgqQqDCkAzrDhvDipnSirm5vxUXtSKDYTVpZG3nUdW/VVB5VdCYA2iReSj/u3yCZzXID4kuKG7OynPnB3WQ==",
|
"integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
@@ -1793,9 +1835,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
||||||
"version": "4.54.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz",
|
||||||
"integrity": "sha512-dzAc53LOuFvHwbCEOS0rPbXp6SIhAf2txMP5p6mGyOXXw5mWY8NGGbPMPrs4P1WItkfApDathBj/NzMLUZ9rtQ==",
|
"integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1807,9 +1849,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
||||||
"version": "4.54.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz",
|
||||||
"integrity": "sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg==",
|
"integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -2385,9 +2427,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.6",
|
"version": "8.5.10",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
|
||||||
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
|
"integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -2430,10 +2472,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/protobufjs": {
|
"node_modules/protobufjs": {
|
||||||
"version": "7.5.4",
|
"version": "7.5.5",
|
||||||
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.5.tgz",
|
||||||
"integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==",
|
"integrity": "sha512-3wY1AxV+VBNW8Yypfd1yQY9pXnqTAN+KwQxL8iYm3/BjKYMNg4i0owhEe26PWDOMaIrzeeF98Lqd5NGz4omiIg==",
|
||||||
"hasInstallScript": true,
|
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@protobufjs/aspromise": "^1.1.2",
|
"@protobufjs/aspromise": "^1.1.2",
|
||||||
@@ -2487,9 +2528,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-router": {
|
"node_modules/react-router": {
|
||||||
"version": "7.11.0",
|
"version": "7.12.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.11.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.12.0.tgz",
|
||||||
"integrity": "sha512-uI4JkMmjbWCZc01WVP2cH7ZfSzH91JAZUDd7/nIprDgWxBV1TkkmLToFh7EbMTcMak8URFRa2YoBL/W8GWnCTQ==",
|
"integrity": "sha512-kTPDYPFzDVGIIGNLS5VJykK0HfHLY5MF3b+xj0/tTyNYL1gF1qs7u67Z9jEhQk2sQ98SUaHxlG31g1JtF7IfVw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cookie": "^1.0.1",
|
"cookie": "^1.0.1",
|
||||||
@@ -2509,12 +2550,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-router-dom": {
|
"node_modules/react-router-dom": {
|
||||||
"version": "7.11.0",
|
"version": "7.12.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.11.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.12.0.tgz",
|
||||||
"integrity": "sha512-e49Ir/kMGRzFOOrYQBdoitq3ULigw4lKbAyKusnvtDu2t4dBX4AGYPrzNvorXmVuOyeakai6FUPW5MmibvVG8g==",
|
"integrity": "sha512-pfO9fiBcpEfX4Tx+iTYKDtPbrSLLCbwJ5EqP+SPYQu1VYCXdy79GSj0wttR0U4cikVdlImZuEZ/9ZNCgoaxwBA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react-router": "7.11.0"
|
"react-router": "7.12.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.0.0"
|
"node": ">=20.0.0"
|
||||||
@@ -2534,9 +2575,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/rollup": {
|
"node_modules/rollup": {
|
||||||
"version": "4.54.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
|
||||||
"integrity": "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==",
|
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -2550,28 +2591,31 @@
|
|||||||
"npm": ">=8.0.0"
|
"npm": ">=8.0.0"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@rollup/rollup-android-arm-eabi": "4.54.0",
|
"@rollup/rollup-android-arm-eabi": "4.59.0",
|
||||||
"@rollup/rollup-android-arm64": "4.54.0",
|
"@rollup/rollup-android-arm64": "4.59.0",
|
||||||
"@rollup/rollup-darwin-arm64": "4.54.0",
|
"@rollup/rollup-darwin-arm64": "4.59.0",
|
||||||
"@rollup/rollup-darwin-x64": "4.54.0",
|
"@rollup/rollup-darwin-x64": "4.59.0",
|
||||||
"@rollup/rollup-freebsd-arm64": "4.54.0",
|
"@rollup/rollup-freebsd-arm64": "4.59.0",
|
||||||
"@rollup/rollup-freebsd-x64": "4.54.0",
|
"@rollup/rollup-freebsd-x64": "4.59.0",
|
||||||
"@rollup/rollup-linux-arm-gnueabihf": "4.54.0",
|
"@rollup/rollup-linux-arm-gnueabihf": "4.59.0",
|
||||||
"@rollup/rollup-linux-arm-musleabihf": "4.54.0",
|
"@rollup/rollup-linux-arm-musleabihf": "4.59.0",
|
||||||
"@rollup/rollup-linux-arm64-gnu": "4.54.0",
|
"@rollup/rollup-linux-arm64-gnu": "4.59.0",
|
||||||
"@rollup/rollup-linux-arm64-musl": "4.54.0",
|
"@rollup/rollup-linux-arm64-musl": "4.59.0",
|
||||||
"@rollup/rollup-linux-loong64-gnu": "4.54.0",
|
"@rollup/rollup-linux-loong64-gnu": "4.59.0",
|
||||||
"@rollup/rollup-linux-ppc64-gnu": "4.54.0",
|
"@rollup/rollup-linux-loong64-musl": "4.59.0",
|
||||||
"@rollup/rollup-linux-riscv64-gnu": "4.54.0",
|
"@rollup/rollup-linux-ppc64-gnu": "4.59.0",
|
||||||
"@rollup/rollup-linux-riscv64-musl": "4.54.0",
|
"@rollup/rollup-linux-ppc64-musl": "4.59.0",
|
||||||
"@rollup/rollup-linux-s390x-gnu": "4.54.0",
|
"@rollup/rollup-linux-riscv64-gnu": "4.59.0",
|
||||||
"@rollup/rollup-linux-x64-gnu": "4.54.0",
|
"@rollup/rollup-linux-riscv64-musl": "4.59.0",
|
||||||
"@rollup/rollup-linux-x64-musl": "4.54.0",
|
"@rollup/rollup-linux-s390x-gnu": "4.59.0",
|
||||||
"@rollup/rollup-openharmony-arm64": "4.54.0",
|
"@rollup/rollup-linux-x64-gnu": "4.59.0",
|
||||||
"@rollup/rollup-win32-arm64-msvc": "4.54.0",
|
"@rollup/rollup-linux-x64-musl": "4.59.0",
|
||||||
"@rollup/rollup-win32-ia32-msvc": "4.54.0",
|
"@rollup/rollup-openbsd-x64": "4.59.0",
|
||||||
"@rollup/rollup-win32-x64-gnu": "4.54.0",
|
"@rollup/rollup-openharmony-arm64": "4.59.0",
|
||||||
"@rollup/rollup-win32-x64-msvc": "4.54.0",
|
"@rollup/rollup-win32-arm64-msvc": "4.59.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"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -2728,9 +2772,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "7.3.0",
|
"version": "7.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
|
||||||
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
|
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
"peer": true,
|
||||||
|
|||||||
@@ -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.11.0"
|
"react-router-dom": "^7.12.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.0"
|
"vite": "^7.3.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
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";
|
||||||
@@ -9,11 +10,12 @@ 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, loading } = useAuth();
|
const { currentUser, isSubscribed } = useAuth();
|
||||||
|
|
||||||
if (loading) return null;
|
|
||||||
|
|
||||||
if (!currentUser) {
|
if (!currentUser) {
|
||||||
return (
|
return (
|
||||||
@@ -29,18 +31,27 @@ 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>
|
<div className="app-layout">
|
||||||
<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 />} />
|
||||||
@@ -49,8 +60,13 @@ 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { getAuthHeaders } from "./authHeaders";
|
import { getAuthHeaders } from "./authHeaders";
|
||||||
import { API_BASE } from "./config";
|
import { API_BASE } from "./config";
|
||||||
|
|
||||||
export async function createCheckoutSession(tier = "premium") {
|
export async function createCheckoutSession(tier = "premium", interval = "annual") {
|
||||||
const headers = await getAuthHeaders();
|
const headers = await getAuthHeaders();
|
||||||
const res = await fetch(`${API_BASE}/stripe/create-checkout-session`, {
|
const res = await fetch(`${API_BASE}/stripe/create-checkout-session`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { ...headers, "Content-Type": "application/json" },
|
headers: { ...headers, "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ tier }),
|
body: JSON.stringify({ tier, interval }),
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|||||||
23
frontend/src/api/support.js
Normal file
23
frontend/src/api/support.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
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 (${res.status}). Is the API running on port 3001?`;
|
||||||
|
}
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
36
frontend/src/components/Footer.css
Normal file
36
frontend/src/components/Footer.css
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
.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;
|
||||||
|
}
|
||||||
41
frontend/src/components/Footer.jsx
Normal file
41
frontend/src/components/Footer.jsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,45 @@
|
|||||||
.tier-picker {
|
.tier-picker {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-picker__toggle {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-picker__toggle-btn {
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text-dimmed);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-picker__toggle-btn:first-child {
|
||||||
|
border-radius: 8px 0 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-picker__toggle-btn:last-child {
|
||||||
|
border-radius: 0 8px 8px 0;
|
||||||
|
border-left: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-picker__toggle-btn--active {
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: white;
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-picker__toggle-btn:last-child.tier-picker__toggle-btn--active {
|
||||||
|
border-left: 1px solid var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-picker__cards {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, 1fr);
|
grid-template-columns: repeat(3, 1fr);
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
@@ -112,7 +153,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.tier-picker {
|
.tier-picker__cards {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
max-width: 400px;
|
max-width: 400px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
|
import { useState } from "react";
|
||||||
import "./TierPicker.css";
|
import "./TierPicker.css";
|
||||||
|
|
||||||
const TIERS = [
|
const TIERS = [
|
||||||
{
|
{
|
||||||
id: "free",
|
id: "free",
|
||||||
name: "Trial Monitoring",
|
name: "Trial Monitoring",
|
||||||
price: "$0",
|
price: { monthly: "$0", annual: "$0" },
|
||||||
period: "",
|
period: { monthly: "", annual: "" },
|
||||||
features: [
|
features: [
|
||||||
"Monitor 1 blockchain address 24/7",
|
"Monitor 1 blockchain address 24/7",
|
||||||
"Configure alerts to fire on trigger events",
|
"Configure alerts to fire on trigger events",
|
||||||
@@ -16,8 +17,8 @@ const TIERS = [
|
|||||||
{
|
{
|
||||||
id: "premium",
|
id: "premium",
|
||||||
name: "Premium Monitoring",
|
name: "Premium Monitoring",
|
||||||
price: "$1.99",
|
price: { monthly: "$8.78", annual: "$94.78" },
|
||||||
period: "/month",
|
period: { monthly: "/month", annual: "/year" },
|
||||||
features: [
|
features: [
|
||||||
"Monitor 3 blockchain addresses",
|
"Monitor 3 blockchain addresses",
|
||||||
"Configure two types of rule-based alerts to fire on trigger events for each of the three addresses",
|
"Configure two types of rule-based alerts to fire on trigger events for each of the three addresses",
|
||||||
@@ -31,8 +32,8 @@ const TIERS = [
|
|||||||
{
|
{
|
||||||
id: "pro",
|
id: "pro",
|
||||||
name: "Professional Monitoring",
|
name: "Professional Monitoring",
|
||||||
price: "$11.99",
|
price: { monthly: "$16.78", annual: "$181.78" },
|
||||||
period: "/month",
|
period: { monthly: "/month", annual: "/year" },
|
||||||
features: [
|
features: [
|
||||||
"Monitor unlimited blockchain addresses",
|
"Monitor unlimited blockchain addresses",
|
||||||
"Configure unlimited alert rules to fire on unlimited events on any address",
|
"Configure unlimited alert rules to fire on unlimited events on any address",
|
||||||
@@ -47,8 +48,26 @@ const TIERS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export default function TierPicker({ onSelect, selectedTier }) {
|
export default function TierPicker({ onSelect, selectedTier }) {
|
||||||
|
const [isAnnual, setIsAnnual] = useState(true);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="tier-picker">
|
<div className="tier-picker">
|
||||||
|
<div className="tier-picker__toggle">
|
||||||
|
<button
|
||||||
|
className={`tier-picker__toggle-btn${!isAnnual ? " tier-picker__toggle-btn--active" : ""}`}
|
||||||
|
onClick={() => setIsAnnual(false)}
|
||||||
|
>
|
||||||
|
Monthly
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`tier-picker__toggle-btn${isAnnual ? " tier-picker__toggle-btn--active" : ""}`}
|
||||||
|
onClick={() => setIsAnnual(true)}
|
||||||
|
>
|
||||||
|
Annual
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="tier-picker__cards">
|
||||||
{TIERS.map((tier) => (
|
{TIERS.map((tier) => (
|
||||||
<div
|
<div
|
||||||
key={tier.id}
|
key={tier.id}
|
||||||
@@ -59,9 +78,13 @@ export default function TierPicker({ onSelect, selectedTier }) {
|
|||||||
)}
|
)}
|
||||||
<h3 className="tier-picker__name">{tier.name}</h3>
|
<h3 className="tier-picker__name">{tier.name}</h3>
|
||||||
<div className="tier-picker__price">
|
<div className="tier-picker__price">
|
||||||
<span className="tier-picker__amount">{tier.price}</span>
|
<span className="tier-picker__amount">
|
||||||
{tier.period && (
|
{isAnnual ? tier.price.annual : tier.price.monthly}
|
||||||
<span className="tier-picker__period">{tier.period}</span>
|
</span>
|
||||||
|
{(isAnnual ? tier.period.annual : tier.period.monthly) && (
|
||||||
|
<span className="tier-picker__period">
|
||||||
|
{isAnnual ? tier.period.annual : tier.period.monthly}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<ul className="tier-picker__features">
|
<ul className="tier-picker__features">
|
||||||
@@ -78,12 +101,13 @@ export default function TierPicker({ onSelect, selectedTier }) {
|
|||||||
</ul>
|
</ul>
|
||||||
<button
|
<button
|
||||||
className={`btn tier-picker__btn${selectedTier === tier.id ? " tier-picker__btn--selected" : ""}`}
|
className={`btn tier-picker__btn${selectedTier === tier.id ? " tier-picker__btn--selected" : ""}`}
|
||||||
onClick={() => onSelect(tier.id)}
|
onClick={() => onSelect(tier.id, isAnnual ? "annual" : "monthly")}
|
||||||
>
|
>
|
||||||
{selectedTier === tier.id ? "Selected" : "Select"}
|
{selectedTier === tier.id ? "Selected" : "Select"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,3 +76,14 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.15s ease;
|
transition: all 0.15s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.nav-panel__overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background-color: rgba(0, 0, 0, 0.45);
|
||||||
|
opacity: 0;
|
||||||
|
z-index: 900;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import { createContext, useContext, useState, useEffect, useCallback } from "react";
|
import { createContext, useContext, useSyncExternalStore } from "react";
|
||||||
import {
|
import {
|
||||||
onAuthStateChanged,
|
onAuthStateChanged,
|
||||||
signInWithEmailAndPassword,
|
signInWithEmailAndPassword,
|
||||||
createUserWithEmailAndPassword,
|
createUserWithEmailAndPassword,
|
||||||
signOut,
|
signOut,
|
||||||
|
sendEmailVerification as firebaseSendEmailVerification,
|
||||||
} from "firebase/auth";
|
} from "firebase/auth";
|
||||||
import { auth } from "../firebase/config";
|
import { auth } from "../firebase/config";
|
||||||
import { getAccount } from "../api/account";
|
import { getAccount } from "../api/account";
|
||||||
@@ -16,71 +17,98 @@ const DEFAULT_TIER_LIMITS = {
|
|||||||
allowed_channels: ["email"],
|
allowed_channels: ["email"],
|
||||||
};
|
};
|
||||||
|
|
||||||
export function AuthProvider({ children }) {
|
// External auth store - lives outside React
|
||||||
const [currentUser, setCurrentUser] = useState(null);
|
const createAuthStore = () => {
|
||||||
const [userTier, setUserTier] = useState("free");
|
let state = {
|
||||||
const [tierLimits, setTierLimits] = useState(DEFAULT_TIER_LIMITS);
|
currentUser: null,
|
||||||
const [isSubscribed, setIsSubscribed] = useState(false);
|
userTier: "free",
|
||||||
const [loading, setLoading] = useState(true);
|
tierLimits: DEFAULT_TIER_LIMITS,
|
||||||
|
isSubscribed: false,
|
||||||
|
loading: true,
|
||||||
|
};
|
||||||
|
const listeners = new Set();
|
||||||
|
|
||||||
const fetchAccount = useCallback(async () => {
|
const notify = () => listeners.forEach((fn) => fn());
|
||||||
|
|
||||||
|
const setState = (partial) => {
|
||||||
|
state = { ...state, ...partial };
|
||||||
|
notify();
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchAccount = async () => {
|
||||||
try {
|
try {
|
||||||
const data = await getAccount();
|
const data = await getAccount();
|
||||||
setUserTier(data.subscription_tier || "free");
|
setState({
|
||||||
setTierLimits(data.tier_limits || DEFAULT_TIER_LIMITS);
|
userTier: data.subscription_tier || "free",
|
||||||
setIsSubscribed(
|
tierLimits: data.tier_limits || DEFAULT_TIER_LIMITS,
|
||||||
|
isSubscribed:
|
||||||
data.subscription_status === "active" ||
|
data.subscription_status === "active" ||
|
||||||
data.subscription_status === "trialing",
|
data.subscription_status === "trialing",
|
||||||
);
|
});
|
||||||
} catch {
|
} catch {
|
||||||
setUserTier("free");
|
setState({ isSubscribed: false });
|
||||||
setTierLimits(DEFAULT_TIER_LIMITS);
|
|
||||||
setIsSubscribed(false);
|
|
||||||
}
|
}
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
// Set up Firebase listener once, outside of React
|
||||||
const unsubscribe = onAuthStateChanged(auth, async (user) => {
|
onAuthStateChanged(auth, async (user) => {
|
||||||
setCurrentUser(user);
|
setState({ loading: true, currentUser: user });
|
||||||
if (user) {
|
if (user) {
|
||||||
await fetchAccount();
|
await fetchAccount();
|
||||||
} else {
|
} else {
|
||||||
setUserTier("free");
|
setState({ isSubscribed: false, userTier: "free", tierLimits: DEFAULT_TIER_LIMITS });
|
||||||
setTierLimits(DEFAULT_TIER_LIMITS);
|
|
||||||
setIsSubscribed(false);
|
|
||||||
}
|
}
|
||||||
setLoading(false);
|
setState({ loading: false });
|
||||||
});
|
});
|
||||||
return unsubscribe;
|
|
||||||
}, [fetchAccount]);
|
|
||||||
|
|
||||||
async function signup(email, password) {
|
return {
|
||||||
const cred = await createUserWithEmailAndPassword(auth, email, password);
|
subscribe: (listener) => {
|
||||||
return cred.user;
|
listeners.add(listener);
|
||||||
}
|
return () => listeners.delete(listener);
|
||||||
|
},
|
||||||
|
getSnapshot: () => state,
|
||||||
|
refreshAccount: fetchAccount,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
async function login(email, password) {
|
const authStore = createAuthStore();
|
||||||
const cred = await signInWithEmailAndPassword(auth, email, password);
|
|
||||||
return cred.user;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function logout() {
|
export function AuthProvider({ children }) {
|
||||||
await signOut(auth);
|
const state = useSyncExternalStore(authStore.subscribe, authStore.getSnapshot);
|
||||||
|
|
||||||
|
const signup = (email, password) => createUserWithEmailAndPassword(auth, email, password);
|
||||||
|
const login = (email, password) => signInWithEmailAndPassword(auth, email, password);
|
||||||
|
const logout = () => signOut(auth);
|
||||||
|
|
||||||
|
const sendEmailVerification = () => {
|
||||||
|
if (auth.currentUser) {
|
||||||
|
return firebaseSendEmailVerification(auth.currentUser);
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const reloadUser = async () => {
|
||||||
|
if (auth.currentUser) {
|
||||||
|
await auth.currentUser.reload();
|
||||||
|
return auth.currentUser;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
const value = {
|
const value = {
|
||||||
currentUser,
|
...state,
|
||||||
userTier,
|
|
||||||
tierLimits,
|
|
||||||
isSubscribed,
|
|
||||||
loading,
|
|
||||||
signup,
|
signup,
|
||||||
login,
|
login,
|
||||||
logout,
|
logout,
|
||||||
refreshAccount: fetchAccount,
|
sendEmailVerification,
|
||||||
|
reloadUser,
|
||||||
|
refreshAccount: authStore.refreshAccount,
|
||||||
};
|
};
|
||||||
|
|
||||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
return (
|
||||||
|
<AuthContext.Provider value={value}>
|
||||||
|
{!state.loading && children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useAuth() {
|
export function useAuth() {
|
||||||
|
|||||||
@@ -78,6 +78,18 @@ 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 {
|
||||||
@@ -412,6 +424,8 @@ button {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.page {
|
.page {
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
height: 920px;
|
||||||
padding: 1rem 0.75rem;
|
padding: 1rem 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -422,7 +436,7 @@ button {
|
|||||||
|
|
||||||
.btn {
|
.btn {
|
||||||
padding: 0.45rem 0.85rem;
|
padding: 0.45rem 0.85rem;
|
||||||
font-size: 0.95rem;
|
font-size: 1.45rem !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn--lg {
|
.btn--lg {
|
||||||
@@ -433,4 +447,8 @@ button {
|
|||||||
.section {
|
.section {
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mb-lg {
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -33,8 +33,9 @@
|
|||||||
|
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
.address__remove {
|
.address__remove {
|
||||||
margin-left: 0.5rem;
|
padding: 0rem 0.9rem;
|
||||||
padding: 0.25rem 0.5rem;
|
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
|
margin-left: -3rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -40,6 +40,7 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
position: relative;
|
position: relative;
|
||||||
top: -20px;
|
top: -20px;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-brand {
|
.login-brand {
|
||||||
@@ -114,4 +115,21 @@
|
|||||||
padding-right: 1rem;
|
padding-right: 1rem;
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.login-span {
|
||||||
|
color: red
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-weight: 400 !important
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-bg-video {
|
||||||
|
opacity: 0.135;
|
||||||
|
left: 47%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-button {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -47,7 +47,7 @@ export default function Login() {
|
|||||||
|
|
||||||
<div className="login-card login-card-fadein">
|
<div className="login-card login-card-fadein">
|
||||||
<h1 className="login-heading">
|
<h1 className="login-heading">
|
||||||
<span className="login-brand">Koin Ping</span> - Login
|
<span className="login-brand">Koin Ping</span><span className="login-span"> - Login</span>
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<div className="login-interactive-fadein">
|
<div className="login-interactive-fadein">
|
||||||
|
|||||||
24
frontend/src/pages/privacy/Privacy.css
Normal file
24
frontend/src/pages/privacy/Privacy.css
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
.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;
|
||||||
|
}
|
||||||
30
frontend/src/pages/privacy/Privacy.jsx
Normal file
30
frontend/src/pages/privacy/Privacy.jsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -128,6 +128,23 @@
|
|||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.subscribe__subtitle strong {
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Verify email actions */
|
||||||
|
.subscribe__verify-actions {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subscribe__verify-sent {
|
||||||
|
color: var(--color-success, #22c55e);
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* Subscribe card (Step 2 tier cards) */
|
/* Subscribe card (Step 2 tier cards) */
|
||||||
.subscribe-card {
|
.subscribe-card {
|
||||||
background-color: var(--color-bg-card, #1a1a2e);
|
background-color: var(--color-bg-card, #1a1a2e);
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||||
import { useAuth } from "../../contexts/AuthContext";
|
import { useAuth } from "../../contexts/AuthContext";
|
||||||
import { useUserProperties } from "../../contexts/UserPropertiesContext";
|
|
||||||
import {
|
import {
|
||||||
createCheckoutSession,
|
createCheckoutSession,
|
||||||
activateFreeTier,
|
activateFreeTier,
|
||||||
@@ -11,12 +10,12 @@ import Button from "../../components/Button";
|
|||||||
import TierPicker from "../../components/TierPicker";
|
import TierPicker from "../../components/TierPicker";
|
||||||
import "./Subscribe.css";
|
import "./Subscribe.css";
|
||||||
|
|
||||||
const STEPS = ["Create Account", "Choose Plan"];
|
const STEPS = ["Create Account", "Verify Email", "Choose Plan"];
|
||||||
|
|
||||||
export default function Subscribe() {
|
export default function Subscribe() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const { currentUser, signup, refreshAccount } = useAuth();
|
const { currentUser, signup, sendEmailVerification, reloadUser, refreshAccount } = useAuth();
|
||||||
|
|
||||||
const queryParameters = new URLSearchParams(window.location.search)
|
const queryParameters = new URLSearchParams(window.location.search)
|
||||||
const success = queryParameters?.get("payment")
|
const success = queryParameters?.get("payment")
|
||||||
@@ -31,12 +30,16 @@ export default function Subscribe() {
|
|||||||
|
|
||||||
setTimeout(forward, 2000, success, session_id);
|
setTimeout(forward, 2000, success, session_id);
|
||||||
|
|
||||||
const [step, setStep] = useState(currentUser ? 2 : 1);
|
const [step, setStep] = useState(
|
||||||
|
currentUser ? (currentUser.emailVerified ? 3 : 2) : 1
|
||||||
|
);
|
||||||
|
const [verificationSent, setVerificationSent] = useState(false);
|
||||||
const [data, setData] = useState({
|
const [data, setData] = useState({
|
||||||
email: currentUser?.email || "",
|
email: currentUser?.email || "",
|
||||||
password: "",
|
password: "",
|
||||||
confirmPassword: "",
|
confirmPassword: "",
|
||||||
selectedTier: null,
|
selectedTier: null,
|
||||||
|
billingInterval: "annual",
|
||||||
});
|
});
|
||||||
const [error, setError] = useState(
|
const [error, setError] = useState(
|
||||||
searchParams.get("payment") === "cancelled"
|
searchParams.get("payment") === "cancelled"
|
||||||
@@ -66,6 +69,8 @@ export default function Subscribe() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await signup(data.email, data.password);
|
await signup(data.email, data.password);
|
||||||
|
await sendEmailVerification();
|
||||||
|
setVerificationSent(true);
|
||||||
setStep(2);
|
setStep(2);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message || "Failed to create account");
|
setError(err.message || "Failed to create account");
|
||||||
@@ -75,6 +80,41 @@ export default function Subscribe() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleStep2() {
|
async function handleStep2() {
|
||||||
|
setError("");
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const user = await reloadUser();
|
||||||
|
if (user?.emailVerified) {
|
||||||
|
setStep(3);
|
||||||
|
} else {
|
||||||
|
setError("Email not yet verified. Please check your inbox and click the verification link.");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || "Failed to check verification status");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleResendVerification() {
|
||||||
|
setError("");
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await sendEmailVerification();
|
||||||
|
setVerificationSent(true);
|
||||||
|
setError("");
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code === "auth/too-many-requests") {
|
||||||
|
setError("Too many requests. Please wait a moment before trying again.");
|
||||||
|
} else {
|
||||||
|
setError(err.message || "Failed to resend verification email");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleStep3() {
|
||||||
setError("");
|
setError("");
|
||||||
if (!data.selectedTier) {
|
if (!data.selectedTier) {
|
||||||
setError("Please select a plan");
|
setError("Please select a plan");
|
||||||
@@ -87,7 +127,7 @@ export default function Subscribe() {
|
|||||||
await refreshAccount();
|
await refreshAccount();
|
||||||
navigate("/addresses", { replace: true });
|
navigate("/addresses", { replace: true });
|
||||||
} else {
|
} else {
|
||||||
const { url } = await createCheckoutSession(data.selectedTier);
|
const { url } = await createCheckoutSession(data.selectedTier, data.billingInterval);
|
||||||
window.location.href = url;
|
window.location.href = url;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -96,8 +136,6 @@ export default function Subscribe() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
function ProgressBar() {
|
function ProgressBar() {
|
||||||
return (
|
return (
|
||||||
<div className="progress-bar">
|
<div className="progress-bar">
|
||||||
@@ -176,6 +214,30 @@ export default function Subscribe() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function StepVerifyEmail() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h2 className="mb-sm">Verify your email</h2>
|
||||||
|
<p className="subscribe__subtitle">
|
||||||
|
We've sent a verification link to <strong>{currentUser?.email}</strong>.
|
||||||
|
Please check your inbox and click the link to verify your email address.
|
||||||
|
</p>
|
||||||
|
{verificationSent && !error && (
|
||||||
|
<p className="subscribe__verify-sent">Verification email sent!</p>
|
||||||
|
)}
|
||||||
|
<div className="subscribe__verify-actions">
|
||||||
|
<Button
|
||||||
|
onClick={handleResendVerification}
|
||||||
|
disabled={loading}
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
{loading ? "Sending..." : "Resend verification email"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function StepChoosePlan() {
|
function StepChoosePlan() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -184,19 +246,23 @@ export default function Subscribe() {
|
|||||||
Select the plan that works best for you. You can upgrade anytime.
|
Select the plan that works best for you. You can upgrade anytime.
|
||||||
</p>
|
</p>
|
||||||
<TierPicker
|
<TierPicker
|
||||||
onSelect={(tier) => set("selectedTier", tier)}
|
onSelect={(tier, interval) => {
|
||||||
|
set("selectedTier", tier);
|
||||||
|
set("billingInterval", interval);
|
||||||
|
}}
|
||||||
selectedTier={data.selectedTier}
|
selectedTier={data.selectedTier}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Footer navigation ─────────────────────────────────────────────────────
|
// ── Footer nav ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function Footer() {
|
function Footer() {
|
||||||
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() {
|
||||||
@@ -207,6 +273,8 @@ 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"
|
||||||
@@ -216,7 +284,7 @@ export default function Subscribe() {
|
|||||||
return (
|
return (
|
||||||
<div className="subscribe__footer">
|
<div className="subscribe__footer">
|
||||||
<div>
|
<div>
|
||||||
{step === 2 && (
|
{step > 1 && (
|
||||||
<Button onClick={handleBack} disabled={loading} variant="ghost">
|
<Button onClick={handleBack} disabled={loading} variant="ghost">
|
||||||
← Back
|
← Back
|
||||||
</Button>
|
</Button>
|
||||||
@@ -225,7 +293,7 @@ export default function Subscribe() {
|
|||||||
|
|
||||||
<Button
|
<Button
|
||||||
onClick={handleNext}
|
onClick={handleNext}
|
||||||
disabled={loading || (step === 2 && !data.selectedTier)}
|
disabled={loading || (step === 3 && !data.selectedTier)}
|
||||||
className="text-bold"
|
className="text-bold"
|
||||||
>
|
>
|
||||||
{loading ? "Please wait..." : nextLabel}
|
{loading ? "Please wait..." : nextLabel}
|
||||||
@@ -238,13 +306,14 @@ export default function Subscribe() {
|
|||||||
|
|
||||||
const stepContent = {
|
const stepContent = {
|
||||||
1: StepCreateAccount(),
|
1: StepCreateAccount(),
|
||||||
2: StepChoosePlan(),
|
2: StepVerifyEmail(),
|
||||||
|
3: StepChoosePlan(),
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="subscribe">
|
<div className="subscribe">
|
||||||
<div
|
<div
|
||||||
className={`subscribe__container${step === 2 ? " subscribe__container--wide" : ""}`}
|
className={`subscribe__container${step === 3 ? " subscribe__container--wide" : ""}`}
|
||||||
>
|
>
|
||||||
<h1 className="subscribe__title">Koin Ping</h1>
|
<h1 className="subscribe__title">Koin Ping</h1>
|
||||||
|
|
||||||
|
|||||||
31
frontend/src/pages/support/Support.css
Normal file
31
frontend/src/pages/support/Support.css
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
.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;
|
||||||
|
}
|
||||||
121
frontend/src/pages/support/Support.jsx
Normal file
121
frontend/src/pages/support/Support.jsx
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
frontend/src/pages/terms/Terms.css
Normal file
29
frontend/src/pages/terms/Terms.css
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
.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);
|
||||||
|
}
|
||||||
141
frontend/src/pages/terms/Terms.jsx
Normal file
141
frontend/src/pages/terms/Terms.jsx
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
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 ("Koin Ping") and/or any mobile
|
||||||
|
apps available for download (collectively, the "Web Site,"
|
||||||
|
"Websites" and "Apps") are governed by these Terms
|
||||||
|
of Service and Use (the "Agreement," "Terms of
|
||||||
|
Service", or "TOS"). 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 ("you" or "User")
|
||||||
|
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'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 "AS IS" 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's LIABILITY IN SUCH JURISDICTIONS SHALL BE LIMITED TO THE
|
||||||
|
MAXIMUM EXTENT PERMITTED BY THE LAW OF YOUR JURISDICTION.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user