Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion apps/web/components/add-document/connections.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -335,7 +335,6 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
discounts: promoCode.getDiscounts(),
successUrl: window.location.href,
})
promoCode.clear()
if (result?.paymentUrl) {
window.open(result.paymentUrl, "_self")
return
Expand Down
2 changes: 0 additions & 2 deletions apps/web/components/add-document/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -347,7 +347,6 @@ export function AddDocument({
discounts: promoCode.getDiscounts(),
successUrl: `${window.location.origin}/settings#account`,
})
promoCode.clear()
if (result?.paymentUrl) {
window.open(result.paymentUrl, "_self")
return
Expand Down Expand Up @@ -449,7 +448,6 @@ export function AddDocument({
discounts: promoCode.getDiscounts(),
successUrl: `${window.location.origin}/settings#account`,
})
promoCode.clear()
if (result?.paymentUrl) {
window.open(result.paymentUrl, "_self")
return
Expand Down
1 change: 0 additions & 1 deletion apps/web/components/integrations-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2850,7 +2850,6 @@ export function IntegrationsView({
discounts: promoCode.getDiscounts(),
successUrl: `${window.location.origin}/integrations`,
})
promoCode.clear()
if (result?.paymentUrl) {
window.open(result.paymentUrl, "_self")
return
Expand Down
1 change: 0 additions & 1 deletion apps/web/components/integrations/plugins-detail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -575,7 +575,6 @@ export function PluginsDetail() {
discounts: promoCode.getDiscounts(),
successUrl: `${window.location.origin}/integrations`,
})
promoCode.clear()
if (result?.paymentUrl) {
window.open(result.paymentUrl, "_self")
return
Expand Down
1 change: 0 additions & 1 deletion apps/web/components/onboarding-brain/step-sources.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -637,7 +637,6 @@ function OnboardingPlansModal({
discounts: promoCode.getDiscounts(),
successUrl: window.location.href,
})
promoCode.clear()
if ((result as { paymentUrl?: string })?.paymentUrl) {
window.location.href = (result as { paymentUrl: string }).paymentUrl
return
Expand Down
1 change: 0 additions & 1 deletion apps/web/components/settings/billing.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -703,7 +703,6 @@ export default function Billing() {
discounts: promoCode.getDiscounts(),
successUrl: `${window.location.origin}/settings#billing`,
})
promoCode.clear()
if ((result as { paymentUrl?: string })?.paymentUrl) {
window.location.href = (result as { paymentUrl: string }).paymentUrl
return
Expand Down
1 change: 0 additions & 1 deletion apps/web/components/settings/connections-mcp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -557,7 +557,6 @@ export default function ConnectionsMCP() {
discounts: promoCode.getDiscounts(),
successUrl: `${window.location.origin}/settings#connections`,
})
promoCode.clear()
if (result?.paymentUrl) {
window.open(result.paymentUrl, "_self")
return
Expand Down
62 changes: 62 additions & 0 deletions apps/web/hooks/use-promo-code.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { describe, expect, it } from "bun:test"
import { isPromoCodeSpent, parseStoredPromoCode } from "./use-promo-code"

const NOW = 1_700_000_000_000

describe("parseStoredPromoCode", () => {
it("reads a stored code with its plan and expiry", () => {
const raw = JSON.stringify({
code: "LAUNCH20",
plan: "free",
expiresAt: NOW + 1000,
})

expect(parseStoredPromoCode(raw, NOW)).toEqual({
code: "LAUNCH20",
plan: "free",
expiresAt: NOW + 1000,
})
})

it("drops a code once it has expired", () => {
const raw = JSON.stringify({ code: "LAUNCH20", expiresAt: NOW - 1 })

expect(parseStoredPromoCode(raw, NOW)).toBeNull()
})

it("still reads bare codes written before plan/expiry were stored", () => {
expect(parseStoredPromoCode("LAUNCH20", NOW)).toEqual({
code: "LAUNCH20",
})
})

it("returns null for missing or unusable values", () => {
expect(parseStoredPromoCode(null, NOW)).toBeNull()
expect(parseStoredPromoCode("{oops", NOW)).toBeNull()
expect(
parseStoredPromoCode(JSON.stringify({ plan: "pro" }), NOW),
).toBeNull()
})
})

describe("isPromoCodeSpent", () => {
it("is spent once the org moves up a plan", () => {
expect(isPromoCodeSpent({ code: "X", plan: "free" }, "pro")).toBe(true)
expect(isPromoCodeSpent({ code: "X", plan: "pro" }, "max")).toBe(true)
})

it("survives an unfinished checkout", () => {
// `attach()` resolving only means Stripe handed back a payment URL; the
// user can still abandon it, and the code has to be there when they retry.
expect(isPromoCodeSpent({ code: "X", plan: "free" }, "free")).toBe(false)
})

it("survives a downgrade or trial expiry", () => {
expect(isPromoCodeSpent({ code: "X", plan: "max" }, "pro")).toBe(false)
expect(isPromoCodeSpent({ code: "X", plan: "pro" }, "free")).toBe(false)
})

it("is never spent while no plan has been recorded yet", () => {
expect(isPromoCodeSpent({ code: "X" }, "max")).toBe(false)
})
})
114 changes: 97 additions & 17 deletions apps/web/hooks/use-promo-code.ts
Original file line number Diff line number Diff line change
@@ -1,38 +1,98 @@
"use client"

import { useAuth } from "@lib/auth-context"
import { useCustomer } from "autumn-js/react"
import { useRouter } from "next/navigation"
import { useCallback, useEffect, useMemo } from "react"
import { toast } from "sonner"
import {
normalizePlanType,
PLAN_RANK,
type PlanType,
useTokenUsage,
} from "@/hooks/use-token-usage"

const PENDING_PROMO_CODE_KEY = "sm.promoCode.pending"
const PROMO_TOAST_ID = "promo-code"
/** A code that is never redeemed stops applying after this long. */
const PROMO_TTL_MS = 30 * 24 * 60 * 60 * 1000

export interface StoredPromoCode {
code: string
/** Plan the org was on when the code was stored. */
plan?: PlanType
expiresAt?: number
}

function promoCodeKey(orgId: string): string {
return `sm.promoCode.org_${orgId}`
}

function readOrgPromoCode(orgId?: string): string | null {
/**
* Codes are stored as JSON. Values written before the plan/expiry bookkeeping
* existed are the bare code, and are kept working until `PromoCodeHost` stamps
* them on the next mount.
*/
export function parseStoredPromoCode(
raw: string | null,
now: number = Date.now(),
): StoredPromoCode | null {
if (!raw) return null

let stored: StoredPromoCode
if (raw.startsWith("{")) {
try {
const parsed = JSON.parse(raw) as StoredPromoCode
if (typeof parsed?.code !== "string" || !parsed.code) return null
stored = parsed
} catch {
return null
}
} else {
stored = { code: raw }
}

if (stored.expiresAt !== undefined && stored.expiresAt <= now) return null
return stored
}

/**
* A discount is spent once the org actually moves up a plan — that, not
* `attach()` resolving, is the point at which the checkout it was captured for
* went through. Downgrades and trial expiries leave an unused code alone.
*/
export function isPromoCodeSpent(
stored: StoredPromoCode,
currentPlan: PlanType,
): boolean {
if (!stored.plan) return false
return PLAN_RANK[currentPlan] > PLAN_RANK[stored.plan]
}

function readOrgPromoCode(orgId?: string): StoredPromoCode | null {
if (!orgId || typeof window === "undefined") return null
return window.localStorage.getItem(promoCodeKey(orgId))
return parseStoredPromoCode(window.localStorage.getItem(promoCodeKey(orgId)))
}

function writeOrgPromoCode(orgId: string, code: string, plan: PlanType): void {
const stored: StoredPromoCode = {
code,
plan,
expiresAt: Date.now() + PROMO_TTL_MS,
}
window.localStorage.setItem(promoCodeKey(orgId), JSON.stringify(stored))
}

export function usePromoCode() {
const { org } = useAuth()
const orgId = org?.id

const getDiscounts = useCallback(() => {
const promotionCode = readOrgPromoCode(orgId)
return promotionCode ? [{ promotionCode }] : undefined
const stored = readOrgPromoCode(orgId)
return stored ? [{ promotionCode: stored.code }] : undefined
}, [orgId])

const clear = useCallback(() => {
if (!orgId) return
window.localStorage.removeItem(promoCodeKey(orgId))
toast.dismiss(PROMO_TOAST_ID)
}, [orgId])

return useMemo(() => ({ getDiscounts, clear }), [getDiscounts, clear])
return useMemo(() => ({ getDiscounts }), [getDiscounts])
}

export function PromoCodeCapture() {
Expand All @@ -52,31 +112,51 @@ export function PromoCodeCapture() {
export function PromoCodeHost() {
const { org } = useAuth()
const router = useRouter()
const autumn = useCustomer()
const { currentPlan, isLoading } = useTokenUsage(autumn)
const orgId = org?.id

useEffect(() => {
if (!org?.id) return
if (!orgId) return
// The stored plan is the yardstick for "has this code been redeemed",
// so nothing is stamped until autumn has loaded — recording a
// placeholder "free" would spend the code on the next render.
if (isLoading) return

const plan = normalizePlanType(currentPlan)
const pending = window.localStorage.getItem(PENDING_PROMO_CODE_KEY)
if (pending) {
window.localStorage.setItem(promoCodeKey(org.id), pending)
writeOrgPromoCode(orgId, pending, plan)
window.localStorage.removeItem(PENDING_PROMO_CODE_KEY)
}

const code = readOrgPromoCode(org.id)
if (!code) {
const stored = readOrgPromoCode(orgId)
if (!stored) {
toast.dismiss(PROMO_TOAST_ID)
return
}

if (isPromoCodeSpent(stored, plan)) {
window.localStorage.removeItem(promoCodeKey(orgId))
toast.dismiss(PROMO_TOAST_ID)
return
}

// Codes stored before this bookkeeping existed carry no plan or expiry.
if (!stored.plan || stored.expiresAt === undefined) {
writeOrgPromoCode(orgId, stored.code, plan)
}

toast.success("Discount code active", {
id: PROMO_TOAST_ID,
description: `Code ${code} will apply at checkout.`,
description: `Code ${stored.code} will apply at checkout.`,
duration: Number.POSITIVE_INFINITY,
action: {
label: "Upgrade",
onClick: () => router.push("/settings#billing"),
},
})
}, [org?.id, router])
}, [orgId, router, currentPlan, isLoading])

return null
}
Loading