diff --git a/apps/code/src/renderer/features/billing/components/UsageLimitModal.tsx b/apps/code/src/renderer/features/billing/components/UsageLimitModal.tsx index 5022542a5f..81e9ab8c33 100644 --- a/apps/code/src/renderer/features/billing/components/UsageLimitModal.tsx +++ b/apps/code/src/renderer/features/billing/components/UsageLimitModal.tsx @@ -1,14 +1,25 @@ import { useUsageLimitStore } from "@features/billing/stores/usageLimitStore"; +import { formatResetTime } from "@features/billing/utils"; import { useSettingsDialogStore } from "@features/settings/stores/settingsDialogStore"; +import { useSeat } from "@hooks/useSeat"; import { WarningCircle } from "@phosphor-icons/react"; import { Button, Dialog, Flex, Text } from "@radix-ui/themes"; +import { trpcClient } from "@renderer/trpc/client"; import { ANALYTICS_EVENTS } from "@shared/types/analytics"; import { track } from "@utils/analytics"; import { useEffect } from "react"; +const SUPPORT_MAILTO = + "mailto:charles@posthog.com?subject=PostHog%20Code%20%E2%80%94%20Pro%20usage%20limit"; + export function UsageLimitModal() { const isOpen = useUsageLimitStore((s) => s.isOpen); + const bucket = useUsageLimitStore((s) => s.bucket); + const resetAt = useUsageLimitStore((s) => s.resetAt); + const eventIsPro = useUsageLimitStore((s) => s.isPro); const hide = useUsageLimitStore((s) => s.hide); + const { isPro: seatIsPro } = useSeat(); + const isPro = eventIsPro ?? seatIsPro; useEffect(() => { if (isOpen) { @@ -26,6 +37,33 @@ export function UsageLimitModal() { useSettingsDialogStore.getState().open("plan-usage"); }; + const handleSupport = () => { + void trpcClient.os.openExternal.mutate({ url: SUPPORT_MAILTO }); + }; + + const isDaily = bucket === "burst"; + const isMonthly = bucket === "sustained"; + const resetLabel = resetAt ? formatResetTime(resetAt) : null; + + const title = isDaily + ? "Daily limit reached" + : isMonthly && !isPro + ? "You're out of usage for this month" + : isMonthly + ? "Monthly limit reached" + : "Usage limit reached"; + + const proCapLabel = isDaily + ? "a daily usage cap" + : isMonthly + ? "a monthly usage cap" + : "usage caps"; + const description = isPro + ? `Your Pro plan has ${proCapLabel}.${resetLabel ? ` ${resetLabel}.` : ""}` + : `You've hit your Free ${ + isDaily ? "daily" : isMonthly ? "monthly" : "usage" + } limit. Upgrade to Pro for 20x more usage.`; + return ( - - You're out of usage for this month - + {title} - You've hit your Free usage limit. Upgrade to Pro for 20× more - usage. + {description} - - + {isPro ? ( + <> + + + + ) : ( + <> + + + + )} diff --git a/apps/code/src/renderer/features/billing/stores/usageLimitStore.test.ts b/apps/code/src/renderer/features/billing/stores/usageLimitStore.test.ts index 23dc20f0bd..f09d8821f3 100644 --- a/apps/code/src/renderer/features/billing/stores/usageLimitStore.test.ts +++ b/apps/code/src/renderer/features/billing/stores/usageLimitStore.test.ts @@ -3,7 +3,11 @@ import { useUsageLimitStore } from "./usageLimitStore"; describe("usageLimitStore", () => { beforeEach(() => { - useUsageLimitStore.setState({ isOpen: false }); + useUsageLimitStore.setState({ + isOpen: false, + bucket: null, + resetAt: null, + }); }); it("starts closed", () => { @@ -11,9 +15,23 @@ describe("usageLimitStore", () => { expect(state.isOpen).toBe(false); }); - it("show opens the modal", () => { + it("show opens the modal with no context", () => { useUsageLimitStore.getState().show(); - expect(useUsageLimitStore.getState().isOpen).toBe(true); + const state = useUsageLimitStore.getState(); + expect(state.isOpen).toBe(true); + expect(state.bucket).toBeNull(); + expect(state.resetAt).toBeNull(); + }); + + it("show stores bucket and resetAt when provided", () => { + useUsageLimitStore.getState().show({ + bucket: "burst", + resetAt: "2026-01-02T03:04:05Z", + }); + const state = useUsageLimitStore.getState(); + expect(state.isOpen).toBe(true); + expect(state.bucket).toBe("burst"); + expect(state.resetAt).toBe("2026-01-02T03:04:05Z"); }); it("hide closes the modal", () => { diff --git a/apps/code/src/renderer/features/billing/stores/usageLimitStore.ts b/apps/code/src/renderer/features/billing/stores/usageLimitStore.ts index c587db0f36..f1c2ca114c 100644 --- a/apps/code/src/renderer/features/billing/stores/usageLimitStore.ts +++ b/apps/code/src/renderer/features/billing/stores/usageLimitStore.ts @@ -1,11 +1,20 @@ import { create } from "zustand"; +export type UsageLimitBucket = "burst" | "sustained"; + interface UsageLimitState { isOpen: boolean; + bucket: UsageLimitBucket | null; + resetAt: string | null; + isPro: boolean | null; } interface UsageLimitActions { - show: () => void; + show: (args?: { + bucket: UsageLimitBucket; + resetAt: string; + isPro?: boolean; + }) => void; hide: () => void; } @@ -13,7 +22,16 @@ type UsageLimitStore = UsageLimitState & UsageLimitActions; export const useUsageLimitStore = create()((set) => ({ isOpen: false, + bucket: null, + resetAt: null, + isPro: null, - show: () => set({ isOpen: true }), + show: (args) => + set({ + isOpen: true, + bucket: args?.bucket ?? null, + resetAt: args?.resetAt ?? null, + isPro: args?.isPro ?? null, + }), hide: () => set({ isOpen: false }), })); diff --git a/apps/code/src/renderer/features/billing/subscriptions.ts b/apps/code/src/renderer/features/billing/subscriptions.ts index 433d3c9ea9..94efa1bb59 100644 --- a/apps/code/src/renderer/features/billing/subscriptions.ts +++ b/apps/code/src/renderer/features/billing/subscriptions.ts @@ -20,7 +20,11 @@ export function registerBillingSubscriptions() { if (event.threshold === 100) { if (event.userIsActive) { - useUsageLimitStore.getState().show(); + useUsageLimitStore.getState().show({ + bucket: event.bucket, + resetAt: event.resetAt, + isPro: event.isPro, + }); return; } toast.error("Usage limit reached", {