diff --git a/apps/web/app/(app)/layout.tsx b/apps/web/app/(app)/layout.tsx index 700e93a07..7d2d4d979 100644 --- a/apps/web/app/(app)/layout.tsx +++ b/apps/web/app/(app)/layout.tsx @@ -3,10 +3,12 @@ import { EnsureWorkspace } from "@/components/ensure-workspace" import { PWAInstallPrompt } from "@/components/pwa-install-prompt" import { SettingsModalProvider } from "@/components/settings/settings-modal" +import { PromoCodeHost } from "@/hooks/use-promo-code" export default function AppLayout({ children }: { children: React.ReactNode }) { return ( + {children} diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 5de4eb43f..2ba044fde 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -11,6 +11,7 @@ import { Suspense } from "react" import { Toaster } from "@ui/components/sonner" import { NuqsAdapter } from "nuqs/adapters/next/app" import { ThemeProvider } from "@/lib/theme-provider" +import { PromoCodeCapture } from "@/hooks/use-promo-code" const font = Space_Grotesk({ subsets: ["latin"], @@ -95,6 +96,7 @@ export default function RootLayout({ includeCredentials={true} headers={{ "X-App-Source": "nova" }} > + diff --git a/apps/web/components/add-document/connections.tsx b/apps/web/components/add-document/connections.tsx index 56339a085..8b24a6bd2 100644 --- a/apps/web/components/add-document/connections.tsx +++ b/apps/web/components/add-document/connections.tsx @@ -42,6 +42,7 @@ import { getConnectionSubtitle, } from "@/components/settings/sync-utils" import type { ImportProvider } from "@/components/settings/sync-utils" +import { usePromoCode } from "@/hooks/use-promo-code" type GDriveSyncScope = "scoped" | "full" @@ -309,6 +310,7 @@ interface ConnectContentProps { export function ConnectContent({ selectedProject }: ConnectContentProps) { const queryClient = useQueryClient() const autumn = useCustomer() + const promoCode = usePromoCode() const { connectorAccess } = useConnectorAccess() const [connectingProvider, setConnectingProvider] = useState(null) @@ -330,8 +332,10 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) { try { const result = await autumn.attach({ planId, + discounts: promoCode.getDiscounts(), successUrl: window.location.href, }) + promoCode.clear() if (result?.paymentUrl) { window.open(result.paymentUrl, "_self") return diff --git a/apps/web/components/add-document/index.tsx b/apps/web/components/add-document/index.tsx index 1c231f7a0..6ab77d9a4 100644 --- a/apps/web/components/add-document/index.tsx +++ b/apps/web/components/add-document/index.tsx @@ -21,6 +21,7 @@ import { formatUsageNumber } from "@/lib/billing-utils" import { SpaceSelector } from "../space-selector" import { useIsMobile } from "@hooks/use-mobile" import { addDocumentParam } from "@/lib/search-params" +import { usePromoCode } from "@/hooks/use-promo-code" type TabType = "note" | "link" | "file" | "connect" @@ -153,6 +154,7 @@ export function AddDocument({ }) const autumn = useCustomer() + const promoCode = usePromoCode() const { tokensUsed, searchesUsed, @@ -342,8 +344,10 @@ export function AddDocument({ try { const result = await autumn.attach({ planId: "api_pro", + discounts: promoCode.getDiscounts(), successUrl: `${window.location.origin}/settings#account`, }) + promoCode.clear() if (result?.paymentUrl) { window.open(result.paymentUrl, "_self") return @@ -442,8 +446,10 @@ export function AddDocument({ try { const result = await autumn.attach({ planId: "api_pro", + discounts: promoCode.getDiscounts(), successUrl: `${window.location.origin}/settings#account`, }) + promoCode.clear() if (result?.paymentUrl) { window.open(result.paymentUrl, "_self") return diff --git a/apps/web/components/integrations-view.tsx b/apps/web/components/integrations-view.tsx index 12031cd9c..900f4ca6d 100644 --- a/apps/web/components/integrations-view.tsx +++ b/apps/web/components/integrations-view.tsx @@ -80,6 +80,7 @@ import { import { MCPSteps } from "./mcp-modal/mcp-detail-view" import { GranolaConnectModal } from "./granola-connect-modal" import { detectPluginSpace, detectPluginSource } from "@/lib/plugin-space" +import { usePromoCode } from "@/hooks/use-promo-code" type Connection = z.infer @@ -2555,6 +2556,7 @@ export function IntegrationsView({ const { allProjects } = useContainerTags() const shortcutsConnect = useShortcutsConnect() const autumn = useCustomer({ queryOptions: { enabled: !publicMode } }) + const promoCode = usePromoCode() // connectorAccess covers pro-tier connectors (incl. company_brain orgs); plugins // stay on hasProProduct. See useConnectorAccess. const { hasPro: hasProProduct, connectorAccess } = useConnectorAccess({ @@ -2825,8 +2827,10 @@ export function IntegrationsView({ try { const result = await autumn.attach({ planId: checkoutPlanId, + discounts: promoCode.getDiscounts(), successUrl: `${window.location.origin}/integrations`, }) + promoCode.clear() if (result?.paymentUrl) { window.open(result.paymentUrl, "_self") return @@ -2837,7 +2841,7 @@ export function IntegrationsView({ toast.error("Failed to start checkout. Please try again.") } }, - [autumn], + [autumn, promoCode], ) const redirectToLogin = useCallback(() => { diff --git a/apps/web/components/integrations/plugins-detail.tsx b/apps/web/components/integrations/plugins-detail.tsx index cb50ecaf7..cfcba4816 100644 --- a/apps/web/components/integrations/plugins-detail.tsx +++ b/apps/web/components/integrations/plugins-detail.tsx @@ -30,6 +30,7 @@ import { type PluginInfo, } from "@/lib/plugin-catalog" import { INSET, InstallSteps, PillButton } from "./install-steps" +import { usePromoCode } from "@/hooks/use-promo-code" interface ConnectedPlugin { id: string @@ -418,6 +419,7 @@ function PluginRow({ export function PluginsDetail() { const { org } = useAuth() const autumn = useCustomer() + const promoCode = usePromoCode() const queryClient = useQueryClient() const [connectingPlugin, setConnectingPlugin] = useState(null) const [finishSetupPluginId, setFinishSetupPluginId] = useState( @@ -570,8 +572,10 @@ export function PluginsDetail() { try { const result = await autumn.attach({ planId: "api_pro", + discounts: promoCode.getDiscounts(), successUrl: `${window.location.origin}/integrations`, }) + promoCode.clear() if (result?.paymentUrl) { window.open(result.paymentUrl, "_self") return diff --git a/apps/web/components/onboarding-brain/step-sources.tsx b/apps/web/components/onboarding-brain/step-sources.tsx index d37339919..861235e2e 100644 --- a/apps/web/components/onboarding-brain/step-sources.tsx +++ b/apps/web/components/onboarding-brain/step-sources.tsx @@ -82,6 +82,7 @@ import { useCustomer } from "autumn-js/react" import { toast } from "sonner" import { analytics } from "@/lib/analytics" import type { BrainMode } from "./types" +import { usePromoCode } from "@/hooks/use-promo-code" type SourceId = | "drive" @@ -614,6 +615,7 @@ function OnboardingPlansModal({ requestedPlan: RequiredPlan }) { const autumn = useCustomer() + const promoCode = usePromoCode() const { currentPlan, isLoading } = useTokenUsage(autumn) const [upgradingPlan, setUpgradingPlan] = useState( null, @@ -632,8 +634,10 @@ function OnboardingPlansModal({ try { const result = await autumn.attach({ planId, + discounts: promoCode.getDiscounts(), successUrl: window.location.href, }) + promoCode.clear() if ((result as { paymentUrl?: string })?.paymentUrl) { window.location.href = (result as { paymentUrl: string }).paymentUrl return diff --git a/apps/web/components/settings/billing.tsx b/apps/web/components/settings/billing.tsx index b8e366a40..d69474b95 100644 --- a/apps/web/components/settings/billing.tsx +++ b/apps/web/components/settings/billing.tsx @@ -36,6 +36,7 @@ import { } from "lucide-react" import { useEffect, useMemo, useRef, useState } from "react" import { toast } from "sonner" +import { usePromoCode } from "@/hooks/use-promo-code" const API_BASE = process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai" @@ -531,6 +532,7 @@ export default function Billing() { const queryClient = useQueryClient() const { user, org } = useAuth() const autumn = useCustomer() + const promoCode = usePromoCode() const posthog = usePostHog() const isCompanyBrain = useHasCompanyBrain() const brainTrial = useMemo( @@ -698,8 +700,10 @@ export default function Billing() { try { const result = await autumn.attach({ planId, + 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 diff --git a/apps/web/components/settings/connections-mcp.tsx b/apps/web/components/settings/connections-mcp.tsx index ccd098d68..96cd382dd 100644 --- a/apps/web/components/settings/connections-mcp.tsx +++ b/apps/web/components/settings/connections-mcp.tsx @@ -38,6 +38,7 @@ import { getConnectionSubtitle, } from "@/components/settings/sync-utils" import type { ImportProvider } from "@/components/settings/sync-utils" +import { usePromoCode } from "@/hooks/use-promo-code" type Connection = z.infer @@ -420,6 +421,7 @@ function FeatureItem({ text }: { text: string }) { export default function ConnectionsMCP() { const queryClient = useQueryClient() const autumn = useCustomer() + const promoCode = usePromoCode() const [addDoc, setAddDoc] = useQueryState("add", addDocumentParam) const router = useRouter() const [removeDialog, setRemoveDialog] = useState<{ @@ -552,8 +554,10 @@ export default function ConnectionsMCP() { try { const result = await autumn.attach({ planId: "api_pro", + discounts: promoCode.getDiscounts(), successUrl: `${window.location.origin}/settings#connections`, }) + promoCode.clear() if (result?.paymentUrl) { window.open(result.paymentUrl, "_self") return diff --git a/apps/web/hooks/use-promo-code.ts b/apps/web/hooks/use-promo-code.ts new file mode 100644 index 000000000..1bc8ca1c0 --- /dev/null +++ b/apps/web/hooks/use-promo-code.ts @@ -0,0 +1,82 @@ +"use client" + +import { useAuth } from "@lib/auth-context" +import { useRouter } from "next/navigation" +import { useCallback, useEffect, useMemo } from "react" +import { toast } from "sonner" + +const PENDING_PROMO_CODE_KEY = "sm.promoCode.pending" +const PROMO_TOAST_ID = "promo-code" + +function promoCodeKey(orgId: string): string { + return `sm.promoCode.org_${orgId}` +} + +function readOrgPromoCode(orgId?: string): string | null { + if (!orgId || typeof window === "undefined") return null + return window.localStorage.getItem(promoCodeKey(orgId)) +} + +export function usePromoCode() { + const { org } = useAuth() + const orgId = org?.id + + const getDiscounts = useCallback(() => { + const promotionCode = readOrgPromoCode(orgId) + return promotionCode ? [{ promotionCode }] : 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]) +} + +export function PromoCodeCapture() { + useEffect(() => { + const url = new URL(window.location.href) + const code = url.searchParams.get("discountCode") + if (!code) return + + window.localStorage.setItem(PENDING_PROMO_CODE_KEY, code) + url.searchParams.delete("discountCode") + window.history.replaceState({}, "", url.toString()) + }, []) + + return null +} + +export function PromoCodeHost() { + const { org } = useAuth() + const router = useRouter() + + useEffect(() => { + if (!org?.id) return + + const pending = window.localStorage.getItem(PENDING_PROMO_CODE_KEY) + if (pending) { + window.localStorage.setItem(promoCodeKey(org.id), pending) + window.localStorage.removeItem(PENDING_PROMO_CODE_KEY) + } + + const code = readOrgPromoCode(org.id) + if (!code) { + toast.dismiss(PROMO_TOAST_ID) + return + } + toast.success("Discount code active", { + id: PROMO_TOAST_ID, + description: `Code ${code} will apply at checkout.`, + duration: Number.POSITIVE_INFINITY, + action: { + label: "Upgrade", + onClick: () => router.push("/settings#billing"), + }, + }) + }, [org?.id, router]) + + return null +}