Compare commits

...

18 Commits

Author SHA1 Message Date
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
21 changed files with 602 additions and 147 deletions

View File

@@ -61,7 +61,7 @@ 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)
authenticate := middleware.Authenticate(userModel)

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

@@ -32,12 +32,14 @@ type Config struct {
ResendAPIKey string
EmailFrom string
DigestIntervalHours int
StripeSecretKey string
StripeWebhookSecret string
StripePriceIDPremium string
StripePriceIDPro string
StripePublishableKey string
FrontendURL string
StripeSecretKey string
StripeWebhookSecret string
StripePriceIDPremium string
StripePriceIDPro string
StripePriceIDPremiumAnnual string
StripePriceIDProAnnual string
StripePublishableKey string
FrontendURL string
}
// Load reads configuration from environment variables and returns a Config.
@@ -58,12 +60,14 @@ func Load() (*Config, error) {
ResendAPIKey: os.Getenv("RESEND_API_KEY"),
EmailFrom: getEnv("EMAIL_FROM", "Koin Ping <alerts@koinping.com>"),
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"),
StripePublishableKey: os.Getenv("STRIPE_PUBLISHABLE_KEY"),
FrontendURL: getEnv("FRONTEND_URL", "http://localhost:3000"),
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"),
}
if cfg.PollIntervalMS < minPollIntervalMS {
@@ -97,10 +101,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

@@ -6,8 +6,10 @@ func TestTierForPriceID(t *testing.T) {
t.Parallel()
cfg := &Config{
StripePriceIDPremium: "price_premium_123",
StripePriceIDPro: "price_pro_456",
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
cfg *config.Config
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
@@ -46,7 +96,8 @@ func (h *StripeHandler) CreateCheckoutSession(w http.ResponseWriter, r *http.Req
userID := middleware.GetUserID(r.Context())
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 {
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",
@@ -209,8 +267,9 @@ func (h *StripeHandler) ActivateFreeTier(w http.ResponseWriter, r *http.Request)
// The Firebase account is created on the frontend only after payment succeeds.
func (h *StripeHandler) CreateOnboardingCheckout(w http.ResponseWriter, r *http.Request) {
var body struct {
Email string `json:"email"`
Tier string `json:"tier"`
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

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

@@ -11,9 +11,7 @@ import AlertHistory from "./pages/alertHistory/AlertHistory";
import Account from "./pages/user_account/Account";
export default function App() {
const { currentUser, isSubscribed, loading } = useAuth();
if (loading) return null;
const { currentUser, isSubscribed } = useAuth();
if (!currentUser) {
return (

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

@@ -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,43 +48,66 @@ const TIERS = [
];
export default function TierPicker({ onSelect, selectedTier }) {
const [isAnnual, setIsAnnual] = useState(true);
return (
<div className="tier-picker">
{TIERS.map((tier) => (
<div
key={tier.id}
className={`tier-picker__card${tier.highlighted ? " tier-picker__card--highlighted" : ""}${selectedTier === tier.id ? " tier-picker__card--selected" : ""}`}
<div className="tier-picker__toggle">
<button
className={`tier-picker__toggle-btn${!isAnnual ? " tier-picker__toggle-btn--active" : ""}`}
onClick={() => setIsAnnual(false)}
>
{tier.highlighted && (
<div className="tier-picker__badge">Most Popular</div>
)}
<h3 className="tier-picker__name">{tier.name}</h3>
<div className="tier-picker__price">
<span className="tier-picker__amount">{tier.price}</span>
{tier.period && (
<span className="tier-picker__period">{tier.period}</span>
)}
</div>
<ul className="tier-picker__features">
{tier.features.map((f) => (
<li key={f} className="tier-picker__feature">
<span className="tier-picker__check">&#10003;</span> {f}
</li>
))}
{tier.disabledFeatures.map((f) => (
<li key={f} className="tier-picker__feature tier-picker__feature--disabled">
<span className="tier-picker__dash">&mdash;</span> {f}
</li>
))}
</ul>
<button
className={`btn tier-picker__btn${selectedTier === tier.id ? " tier-picker__btn--selected" : ""}`}
onClick={() => onSelect(tier.id)}
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}
className={`tier-picker__card${tier.highlighted ? " tier-picker__card--highlighted" : ""}${selectedTier === tier.id ? " tier-picker__card--selected" : ""}`}
>
{selectedTier === tier.id ? "Selected" : "Select"}
</button>
</div>
))}
{tier.highlighted && (
<div className="tier-picker__badge">Most Popular</div>
)}
<h3 className="tier-picker__name">{tier.name}</h3>
<div className="tier-picker__price">
<span className="tier-picker__amount">
{isAnnual ? tier.price.annual : tier.price.monthly}
</span>
{(isAnnual ? tier.period.annual : tier.period.monthly) && (
<span className="tier-picker__period">
{isAnnual ? tier.period.annual : tier.period.monthly}
</span>
)}
</div>
<ul className="tier-picker__features">
{tier.features.map((f) => (
<li key={f} className="tier-picker__feature">
<span className="tier-picker__check">&#10003;</span> {f}
</li>
))}
{tier.disabledFeatures.map((f) => (
<li key={f} className="tier-picker__feature tier-picker__feature--disabled">
<span className="tier-picker__dash">&mdash;</span> {f}
</li>
))}
</ul>
<button
className={`btn tier-picker__btn${selectedTier === tier.id ? " tier-picker__btn--selected" : ""}`}
onClick={() => onSelect(tier.id, isAnnual ? "annual" : "monthly")}
>
{selectedTier === tier.id ? "Selected" : "Select"}
</button>
</div>
))}
</div>
</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(
data.subscription_status === "active" ||
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);
if (user) {
await fetchAccount();
} else {
setUserTier("free");
setTierLimits(DEFAULT_TIER_LIMITS);
setIsSubscribed(false);
}
setLoading(false);
});
return unsubscribe;
}, [fetchAccount]);
// Set up Firebase listener once, outside of React
onAuthStateChanged(auth, async (user) => {
setState({ loading: true, currentUser: user });
if (user) {
await fetchAccount();
} else {
setState({ isSubscribed: false, userTier: "free", tierLimits: DEFAULT_TIER_LIMITS });
}
setState({ loading: false });
});
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

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

@@ -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,16 +273,18 @@ export default function Subscribe() {
const nextLabel =
step === 1
? "Create Account"
: !data.selectedTier
? "Continue"
: data.selectedTier === "free"
? "Start Free Trial"
: "Subscribe & Continue";
: step === 2
? "I've verified my email"
: !data.selectedTier
? "Continue"
: data.selectedTier === "free"
? "Start Free Trial"
: "Subscribe & Continue";
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>