mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
fix(web): keep the discount code until the upgrade actually completes
Every checkout call site cleared the stored promo code as soon as `autumn.attach()` resolved, but attach resolving only means Stripe handed back a payment URL. A user who closed or abandoned that checkout page came back with the code already gone from localStorage and the `?discountCode=` param long since stripped, with no way to get it back. Clear centrally instead: `PromoCodeHost` spends the code once the org moves up a plan, which is the point at which the checkout it was captured for actually went through. Downgrades and trial expiries leave an unused code alone. Codes also carry a 30-day expiry now, so one that is never redeemed stops applying to future checkouts instead of discounting them forever.
This commit is contained in:
parent
18a2dfbe39
commit
e8eaac71fb
9 changed files with 159 additions and 25 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
62
apps/web/hooks/use-promo-code.test.ts
Normal file
62
apps/web/hooks/use-promo-code.test.ts
Normal file
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,20 +1,86 @@
|
|||
"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() {
|
||||
|
|
@ -22,17 +88,11 @@ export function usePromoCode() {
|
|||
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() {
|
||||
|
|
@ -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…
Add table
Reference in a new issue