Compare commits

..

29 Commits

Author SHA1 Message Date
KS Jannette
5cd5b38df0 add analytics tag 2026-05-12 23:23:17 -04:00
S Jannette
4667e612fd Merge pull request #45 from kjannette/update-README
m
2026-05-12 19:48:07 -04:00
KS Jannette
1d68ce40e7 m 2026-05-12 19:47:42 -04:00
S Jannette
ae256eea91 Merge pull request #44 from kjannette/auth-hack
m
2026-05-12 19:26:24 -04:00
KS Jannette
7bad2ab562 m 2026-05-12 19:26:03 -04:00
S Jannette
54b7c92e83 Merge pull request #43 from kjannette/adjust-url-support-form-submit
Adjust endpoint
2026-05-12 18:59:49 -04:00
KS Jannette
39c6957e2f Adjust endpoint 2026-05-12 18:58:47 -04:00
S Jannette
4f44af5180 Merge pull request #42 from kjannette/add-footer
Add footer
2026-05-12 18:34:46 -04:00
KS Jannette
a341741ac6 additional fetaure buildout for footer and footer links 2026-05-12 18:31:41 -04:00
KS Jannette
c387798be7 added TOS and Privacy content 2026-05-12 18:17:02 -04:00
S Jannette
cb476c38d7 Merge pull request #40 from kjannette/update-subscriptions
Updated subscriptions
2026-05-12 17:40:52 -04:00
KS Jannette
f086ef98ff Updated subscriptions 2026-05-12 17:39:42 -04:00
KS Jannette
2ab6dd0d6a style tweak 2026-05-12 05:56:34 -04:00
KS Jannette
68df446580 fix style 2026-05-12 05:55:11 -04:00
KS Jannette
3fc2c28b43 adjusted css for mobile designs 2026-05-12 05:51:50 -04:00
S Jannette
c4b8022432 Merge pull request #38 from kjannette/fix-login
Fix login issue
2026-05-12 05:21:11 -04:00
KS Jannette
cea031334e add comment 2026-05-12 05:18:33 -04:00
KS Jannette
c4371f7886 fixed login race condition 2026-05-12 05:14:12 -04:00
KS Jannette
8b2db08db1 simplified auth context provider 2026-05-12 04:53:53 -04:00
KS Jannette
f52f7dc89f more style update for mobile 2026-05-12 01:09:41 -04:00
KS Jannette
3eab5d07ff more style 2026-05-12 01:00:33 -04:00
KS Jannette
83ac4b7c14 more style hotfix 2026-05-12 00:52:39 -04:00
KS Jannette
5eca679f54 hotfix 2026-05-12 00:48:01 -04:00
S Jannette
f4e6953046 Merge pull request #37 from kjannette/mobile-style
Adjust css for login page
2026-05-12 00:39:00 -04:00
KS Jannette
8c1d214897 Adjust css for login page 2026-05-12 00:37:39 -04:00
KS Jannette
71443f0abc hotfix 2026-05-11 22:34:23 -04:00
S Jannette
d89970fcac Merge pull request #36 from kjannette/verify-email
add email verification step to new user onboarding flow
2026-05-11 22:26:44 -04:00
KS Jannette
6b07d51cb1 add email verification step to new user onboarding flow 2026-05-11 22:13:53 -04:00
S Jannette
3c282c589c Merge pull request #35 from kjannette/config-updates
config updates
2026-05-11 16:29:57 -04:00
36 changed files with 1382 additions and 211 deletions

View File

@@ -31,3 +31,15 @@ Backend startup quickstart:
make poller — Builds and runs the poller.
make poller-dev — Runs the poller with auto-reload.
## Production: `make build` + restart service
On the server, from this directory:
- **`make build`** — writes fresh **`bin/api`** and **`bin/poller`** (see the Makefile).
- **`make build-api`** — API only; **`make build-poller`** — poller only.
Building alone does **not** reload a process that is already listening (e.g. on port 8080). After deploy:
1. Ensure **`systemctl`** (or your supervisor) **`ExecStart`** runs the binary you just built (e.g. **`.../backend/bin/api`**) or copy `bin/api` to that path.
2. **`sudo systemctl restart <your-api-service>`** (and the poller service if you rebuilt it).
3. Optional check: **`readlink -f /proc/<pid>/exe`** should show your **`bin/api`** path without **`(deleted)`** — if you see `(deleted)`, an old process is still running the previous binary from memory.

View File

@@ -61,8 +61,9 @@ func main() {
notifConfigHandler := handlers.NewNotificationConfigHandler(notifConfigModel, userModel, cfg)
emailDigestHandler := handlers.NewEmailDigestHandler(emailDigestSvc, notifConfigModel)
statusHandler := handlers.NewStatusHandler(checkpointModel)
stripeHandler := handlers.NewStripeHandler(userModel, cfg)
stripeHandler := handlers.NewStripeHandler(userModel, alertRuleModel, cfg)
accountHandler := handlers.NewAccountHandler(userModel, addressModel, cfg)
supportHandler := handlers.NewSupportHandler(cfg)
authenticate := middleware.Authenticate(userModel)
requireSub := middleware.RequireSubscription(userModel)
@@ -101,6 +102,10 @@ func main() {
mux.Handle("GET "+b+"/user/account",
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
mux.Handle("POST "+b+"/addresses",
authAndSub(http.HandlerFunc(addressHandler.Create)))

View 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,
)
}
}

View File

@@ -31,11 +31,14 @@ type Config struct {
NodeEnv string
ResendAPIKey string
EmailFrom string
SupportInboxEmail string
DigestIntervalHours int
StripeSecretKey string
StripeWebhookSecret string
StripePriceIDPremium string
StripePriceIDPro string
StripePriceIDPremiumAnnual string
StripePriceIDProAnnual string
StripePublishableKey string
FrontendURL string
}
@@ -57,11 +60,14 @@ func Load() (*Config, error) {
NodeEnv: getEnv("NODE_ENV", "development"),
ResendAPIKey: os.Getenv("RESEND_API_KEY"),
EmailFrom: getEnv("EMAIL_FROM", "Koin Ping <alerts@koinping.com>"),
SupportInboxEmail: getEnv("SUPPORT_INBOX_EMAIL", "sj@sjdev.co"),
DigestIntervalHours: getEnvInt("DIGEST_INTERVAL_HOURS", defaultDigestIntervalHours),
StripeSecretKey: os.Getenv("STRIPE_SECRET_KEY"),
StripeWebhookSecret: os.Getenv("STRIPE_WEBHOOK_SECRET"),
StripePriceIDPremium: os.Getenv("STRIPE_PRICE_ID_PREMIUM"),
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"),
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
// subscription tier. Returns empty string if the price is unrecognised.
func (c *Config) TierForPriceID(priceID string) string {
if priceID == "" {
return ""
}
switch priceID {
case c.StripePriceIDPremium:
case c.StripePriceIDPremium, c.StripePriceIDPremiumAnnual:
return "premium"
case c.StripePriceIDPro:
case c.StripePriceIDPro, c.StripePriceIDProAnnual:
return "pro"
default:
return ""

View File

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

View 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
}

View File

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

View File

@@ -1,36 +1,86 @@
package handlers
import (
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"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"
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"
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
type StripeHandler struct {
users *models.UserModel
alerts *models.AlertRuleModel
cfg *config.Config
}
func NewStripeHandler(users *models.UserModel, cfg *config.Config) *StripeHandler {
func NewStripeHandler(users *models.UserModel, alerts *models.AlertRuleModel, cfg *config.Config) *StripeHandler {
stripe.Key = cfg.StripeSecretKey
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 {
case domain.TierPremium:
return h.cfg.StripePriceIDPremium, nil
@@ -47,6 +97,7 @@ func (h *StripeHandler) CreateCheckoutSession(w http.ResponseWriter, r *http.Req
var body struct {
Tier string `json:"tier"`
Interval string `json:"interval"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
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 == "" {
body.Tier = "premium"
}
if body.Interval == "" {
body.Interval = "annual"
}
tier := domain.SubscriptionTier(body.Tier)
if tier != domain.TierPremium && tier != domain.TierPro {
@@ -63,7 +117,7 @@ func (h *StripeHandler) CreateCheckoutSession(w http.ResponseWriter, r *http.Req
return
}
priceID, err := h.priceIDForTier(tier)
priceID, err := h.priceIDForTier(tier, body.Interval)
if err != nil {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", err.Error())
return
@@ -177,6 +231,8 @@ func (h *StripeHandler) VerifyCheckoutSession(w http.ResponseWriter, r *http.Req
if subscriptionID != "" && customerID != "" {
if err := h.users.ActivateSubscription(r.Context(), customerID, subscriptionID, "active", tier); err != nil {
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
}
h.ensureUserFirebaseAndAlertsEnabled(r.Context(), userID)
log.Printf("Free tier activated for user %s", userID)
writeJSON(w, http.StatusOK, map[string]string{
"subscription_status": "active",
@@ -211,6 +269,7 @@ func (h *StripeHandler) CreateOnboardingCheckout(w http.ResponseWriter, r *http.
var body struct {
Email string `json:"email"`
Tier string `json:"tier"`
Interval string `json:"interval"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
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 == "" {
body.Tier = "premium"
}
if body.Interval == "" {
body.Interval = "annual"
}
tier := domain.SubscriptionTier(body.Tier)
if tier != domain.TierPremium && tier != domain.TierPro {
@@ -232,7 +294,7 @@ func (h *StripeHandler) CreateOnboardingCheckout(w http.ResponseWriter, r *http.
return
}
priceID, err := h.priceIDForTier(tier)
priceID, err := h.priceIDForTier(tier, body.Interval)
if err != nil {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", err.Error())
return
@@ -362,6 +424,8 @@ func (h *StripeHandler) handleCheckoutCompleted(r *http.Request, event stripe.Ev
if subscriptionID != "" && customerID != "" {
if err := h.users.ActivateSubscription(r.Context(), customerID, subscriptionID, "active", tier); err != nil {
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 {
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)
}

View 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"})
}

View File

@@ -140,3 +140,33 @@ func (m *AlertRuleModel) Remove(ctx context.Context, id int) (bool, error) {
}
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,6 +130,46 @@ func (m *UserModel) GetByID(ctx context.Context, id string) (*domain.User, error
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 {
_, err := m.pool.Exec(ctx,
`UPDATE users SET stripe_customer_id = $2, updated_at = NOW() WHERE id = $1`,

View File

@@ -5,6 +5,8 @@ import (
"context"
"encoding/json"
"fmt"
"html"
"io"
"log"
"net/http"
"time"
@@ -156,3 +158,57 @@ func alertTypeLabel(alertType string) string {
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,14 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Koin Ping - On-Chain Monitoring</title>
<script data-goatcounter="https://koinping.goatcounter.com/count" async src="//gc.zgo.at/count.js"></script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
</html>

View File

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

View File

@@ -1 +1,20 @@
export const API_BASE = import.meta.env.VITE_API_BASE || "/v1";
const DEFAULT_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 { API_BASE } from "./config";
export async function createCheckoutSession(tier = "premium") {
export async function createCheckoutSession(tier = "premium", interval = "annual") {
const headers = await getAuthHeaders();
const res = await fetch(`${API_BASE}/stripe/create-checkout-session`, {
method: "POST",
headers: { ...headers, "Content-Type": "application/json" },
body: JSON.stringify({ tier }),
body: JSON.stringify({ tier, interval }),
});
if (!res.ok) {
const data = await res.json();

View 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 (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

@@ -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;
}

View 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>
);
}

View File

@@ -12,10 +12,12 @@ const navLinks = [
export default function Navbar() {
const { currentUser, logout } = useAuth();
const authData = useAuth();
const location = useLocation();
const [isNavPanelOpen, setIsNavPanelOpen] = useState(false);
if (!currentUser) return null;
console.log(authData)
return (
<>

View File

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

View File

@@ -1,11 +1,12 @@
import { useState } from "react";
import "./TierPicker.css";
const TIERS = [
{
id: "free",
name: "Trial Monitoring",
price: "$0",
period: "",
price: { monthly: "$0", annual: "$0" },
period: { monthly: "", annual: "" },
features: [
"Monitor 1 blockchain address 24/7",
"Configure alerts to fire on trigger events",
@@ -16,8 +17,8 @@ const TIERS = [
{
id: "premium",
name: "Premium Monitoring",
price: "$1.99",
period: "/month",
price: { monthly: "$8.78", annual: "$94.78" },
period: { monthly: "/month", annual: "/year" },
features: [
"Monitor 3 blockchain 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",
name: "Professional Monitoring",
price: "$11.99",
period: "/month",
price: { monthly: "$16.78", annual: "$181.78" },
period: { monthly: "/month", annual: "/year" },
features: [
"Monitor unlimited blockchain addresses",
"Configure unlimited alert rules to fire on unlimited events on any address",
@@ -47,8 +48,26 @@ const TIERS = [
];
export default function TierPicker({ onSelect, selectedTier }) {
const [isAnnual, setIsAnnual] = useState(true);
return (
<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) => (
<div
key={tier.id}
@@ -59,9 +78,13 @@ export default function TierPicker({ onSelect, selectedTier }) {
)}
<h3 className="tier-picker__name">{tier.name}</h3>
<div className="tier-picker__price">
<span className="tier-picker__amount">{tier.price}</span>
{tier.period && (
<span className="tier-picker__period">{tier.period}</span>
<span className="tier-picker__amount">
{isAnnual ? tier.price.annual : tier.price.monthly}
</span>
{(isAnnual ? tier.period.annual : tier.period.monthly) && (
<span className="tier-picker__period">
{isAnnual ? tier.period.annual : tier.period.monthly}
</span>
)}
</div>
<ul className="tier-picker__features">
@@ -78,12 +101,13 @@ export default function TierPicker({ onSelect, selectedTier }) {
</ul>
<button
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"}
</button>
</div>
))}
</div>
</div>
);
}

View File

@@ -76,3 +76,14 @@
cursor: pointer;
transition: all 0.15s ease;
}
@media (max-width: 480px) {
.nav-panel__overlay {
position: fixed;
inset: 0;
background-color: rgba(0, 0, 0, 0.45);
opacity: 0;
z-index: 900;
}
}

View File

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

View File

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

View File

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

View File

@@ -40,6 +40,7 @@
text-align: center;
position: relative;
top: -20px;
white-space: nowrap;
}
.login-brand {
@@ -114,4 +115,21 @@
padding-right: 1rem;
font-size: 1.25rem;
}
.login-span {
color: red
}
h1 {
font-weight: 400 !important
}
.login-bg-video {
opacity: 0.135;
left: 47%;
}
.login-button {
font-size: 1rem;
}
}

View File

@@ -47,7 +47,7 @@ export default function Login() {
<div className="login-card login-card-fadein">
<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>
<div className="login-interactive-fadein">

View 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;
}

View 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>
);
}

View File

@@ -128,6 +128,23 @@
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 {
background-color: var(--color-bg-card, #1a1a2e);

View File

@@ -10,12 +10,12 @@ import Button from "../../components/Button";
import TierPicker from "../../components/TierPicker";
import "./Subscribe.css";
const STEPS = ["Create Account", "Choose Plan"];
const STEPS = ["Create Account", "Verify Email", "Choose Plan"];
export default function Subscribe() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { currentUser, signup, refreshAccount } = useAuth();
const { currentUser, signup, sendEmailVerification, reloadUser, refreshAccount } = useAuth();
const queryParameters = new URLSearchParams(window.location.search)
const success = queryParameters?.get("payment")
@@ -30,12 +30,16 @@ export default function Subscribe() {
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({
email: currentUser?.email || "",
password: "",
confirmPassword: "",
selectedTier: null,
billingInterval: "annual",
});
const [error, setError] = useState(
searchParams.get("payment") === "cancelled"
@@ -65,6 +69,8 @@ export default function Subscribe() {
setLoading(true);
try {
await signup(data.email, data.password);
await sendEmailVerification();
setVerificationSent(true);
setStep(2);
} catch (err) {
setError(err.message || "Failed to create account");
@@ -74,6 +80,41 @@ export default function Subscribe() {
}
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("");
if (!data.selectedTier) {
setError("Please select a plan");
@@ -86,7 +127,7 @@ export default function Subscribe() {
await refreshAccount();
navigate("/addresses", { replace: true });
} else {
const { url } = await createCheckoutSession(data.selectedTier);
const { url } = await createCheckoutSession(data.selectedTier, data.billingInterval);
window.location.href = url;
}
} catch (err) {
@@ -173,6 +214,30 @@ export default function Subscribe() {
);
}
function StepVerifyEmail() {
return (
<>
<h2 className="mb-sm">Verify your email</h2>
<p className="subscribe__subtitle">
We've sent a verification link to <strong>{currentUser?.email}</strong>.
Please check your inbox and click the link to verify your email address.
</p>
{verificationSent && !error && (
<p className="subscribe__verify-sent">Verification email sent!</p>
)}
<div className="subscribe__verify-actions">
<Button
onClick={handleResendVerification}
disabled={loading}
variant="ghost"
>
{loading ? "Sending..." : "Resend verification email"}
</Button>
</div>
</>
);
}
function StepChoosePlan() {
return (
<>
@@ -181,7 +246,10 @@ export default function Subscribe() {
Select the plan that works best for you. You can upgrade anytime.
</p>
<TierPicker
onSelect={(tier) => set("selectedTier", tier)}
onSelect={(tier, interval) => {
set("selectedTier", tier);
set("billingInterval", interval);
}}
selectedTier={data.selectedTier}
/>
</>
@@ -194,6 +262,7 @@ export default function Subscribe() {
async function handleNext() {
if (step === 1) handleStep1();
else if (step === 2) await handleStep2();
else if (step === 3) await handleStep3();
}
function handleBack() {
@@ -204,6 +273,8 @@ export default function Subscribe() {
const nextLabel =
step === 1
? "Create Account"
: step === 2
? "I've verified my email"
: !data.selectedTier
? "Continue"
: data.selectedTier === "free"
@@ -213,7 +284,7 @@ export default function Subscribe() {
return (
<div className="subscribe__footer">
<div>
{step === 2 && (
{step > 1 && (
<Button onClick={handleBack} disabled={loading} variant="ghost">
← Back
</Button>
@@ -222,7 +293,7 @@ export default function Subscribe() {
<Button
onClick={handleNext}
disabled={loading || (step === 2 && !data.selectedTier)}
disabled={loading || (step === 3 && !data.selectedTier)}
className="text-bold"
>
{loading ? "Please wait..." : nextLabel}
@@ -235,13 +306,14 @@ export default function Subscribe() {
const stepContent = {
1: StepCreateAccount(),
2: StepChoosePlan(),
2: StepVerifyEmail(),
3: StepChoosePlan(),
};
return (
<div className="subscribe">
<div
className={`subscribe__container${step === 2 ? " subscribe__container--wide" : ""}`}
className={`subscribe__container${step === 3 ? " subscribe__container--wide" : ""}`}
>
<h1 className="subscribe__title">Koin Ping</h1>

View 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;
}

View 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>
);
}

View 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);
}

View 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 (&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>
);
}