Compare commits

..

1 Commits

Author SHA1 Message Date
KS Jannette
4b62e23a4f added style, fixed login issue 2026-05-12 04:45:04 -04:00
29 changed files with 195 additions and 1209 deletions

View File

@@ -61,9 +61,8 @@ func main() {
notifConfigHandler := handlers.NewNotificationConfigHandler(notifConfigModel, userModel, cfg) notifConfigHandler := handlers.NewNotificationConfigHandler(notifConfigModel, userModel, cfg)
emailDigestHandler := handlers.NewEmailDigestHandler(emailDigestSvc, notifConfigModel) emailDigestHandler := handlers.NewEmailDigestHandler(emailDigestSvc, notifConfigModel)
statusHandler := handlers.NewStatusHandler(checkpointModel) statusHandler := handlers.NewStatusHandler(checkpointModel)
stripeHandler := handlers.NewStripeHandler(userModel, alertRuleModel, cfg) stripeHandler := handlers.NewStripeHandler(userModel, cfg)
accountHandler := handlers.NewAccountHandler(userModel, addressModel, cfg) accountHandler := handlers.NewAccountHandler(userModel, addressModel, cfg)
supportHandler := handlers.NewSupportHandler(cfg)
authenticate := middleware.Authenticate(userModel) authenticate := middleware.Authenticate(userModel)
requireSub := middleware.RequireSubscription(userModel) requireSub := middleware.RequireSubscription(userModel)
@@ -102,10 +101,6 @@ func main() {
mux.Handle("GET "+b+"/user/account", mux.Handle("GET "+b+"/user/account",
authenticate(http.HandlerFunc(accountHandler.GetAccount))) authenticate(http.HandlerFunc(accountHandler.GetAccount)))
// Support (auth required; avoids anonymous spam)
mux.Handle("POST "+b+"/support",
authenticate(http.HandlerFunc(supportHandler.Submit)))
// Authenticated + subscribed routes — addresses // Authenticated + subscribed routes — addresses
mux.Handle("POST "+b+"/addresses", mux.Handle("POST "+b+"/addresses",
authAndSub(http.HandlerFunc(addressHandler.Create))) authAndSub(http.HandlerFunc(addressHandler.Create)))

View File

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

View File

@@ -31,14 +31,11 @@ type Config struct {
NodeEnv string NodeEnv string
ResendAPIKey string ResendAPIKey string
EmailFrom string EmailFrom string
SupportInboxEmail string
DigestIntervalHours int DigestIntervalHours int
StripeSecretKey string StripeSecretKey string
StripeWebhookSecret string StripeWebhookSecret string
StripePriceIDPremium string StripePriceIDPremium string
StripePriceIDPro string StripePriceIDPro string
StripePriceIDPremiumAnnual string
StripePriceIDProAnnual string
StripePublishableKey string StripePublishableKey string
FrontendURL string FrontendURL string
} }
@@ -60,14 +57,11 @@ func Load() (*Config, error) {
NodeEnv: getEnv("NODE_ENV", "development"), NodeEnv: getEnv("NODE_ENV", "development"),
ResendAPIKey: os.Getenv("RESEND_API_KEY"), ResendAPIKey: os.Getenv("RESEND_API_KEY"),
EmailFrom: getEnv("EMAIL_FROM", "Koin Ping <alerts@koinping.com>"), EmailFrom: getEnv("EMAIL_FROM", "Koin Ping <alerts@koinping.com>"),
SupportInboxEmail: getEnv("SUPPORT_INBOX_EMAIL", "sj@sjdev.co"),
DigestIntervalHours: getEnvInt("DIGEST_INTERVAL_HOURS", defaultDigestIntervalHours), DigestIntervalHours: getEnvInt("DIGEST_INTERVAL_HOURS", defaultDigestIntervalHours),
StripeSecretKey: os.Getenv("STRIPE_SECRET_KEY"), StripeSecretKey: os.Getenv("STRIPE_SECRET_KEY"),
StripeWebhookSecret: os.Getenv("STRIPE_WEBHOOK_SECRET"), StripeWebhookSecret: os.Getenv("STRIPE_WEBHOOK_SECRET"),
StripePriceIDPremium: os.Getenv("STRIPE_PRICE_ID_PREMIUM"), StripePriceIDPremium: os.Getenv("STRIPE_PRICE_ID_PREMIUM"),
StripePriceIDPro: os.Getenv("STRIPE_PRICE_ID_PRO"), StripePriceIDPro: os.Getenv("STRIPE_PRICE_ID_PRO"),
StripePriceIDPremiumAnnual: os.Getenv("STRIPE_PRICE_ID_PREMIUM_ANNUAL"),
StripePriceIDProAnnual: os.Getenv("STRIPE_PRICE_ID_PRO_ANNUAL"),
StripePublishableKey: os.Getenv("STRIPE_PUBLISHABLE_KEY"), StripePublishableKey: os.Getenv("STRIPE_PUBLISHABLE_KEY"),
FrontendURL: getEnv("FRONTEND_URL", "http://localhost:3000"), FrontendURL: getEnv("FRONTEND_URL", "http://localhost:3000"),
} }
@@ -103,13 +97,10 @@ func (c *Config) DSN() string {
// TierForPriceID maps a Stripe price ID back to the corresponding // TierForPriceID maps a Stripe price ID back to the corresponding
// subscription tier. Returns empty string if the price is unrecognised. // subscription tier. Returns empty string if the price is unrecognised.
func (c *Config) TierForPriceID(priceID string) string { func (c *Config) TierForPriceID(priceID string) string {
if priceID == "" {
return ""
}
switch priceID { switch priceID {
case c.StripePriceIDPremium, c.StripePriceIDPremiumAnnual: case c.StripePriceIDPremium:
return "premium" return "premium"
case c.StripePriceIDPro, c.StripePriceIDProAnnual: case c.StripePriceIDPro:
return "pro" return "pro"
default: default:
return "" return ""

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -78,24 +78,14 @@ 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 {
max-width: 800px; max-width: 800px;
height: 100%;
margin: 0 auto; margin: 0 auto;
padding: 2rem; padding: 2rem;
background-color: red;
} }
.page--wide { .page--wide {
@@ -424,8 +414,6 @@ button {
} }
.page { .page {
background-color: var(--color-bg);
height: 920px;
padding: 1rem 0.75rem; padding: 1rem 0.75rem;
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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