|
| 1 | +"use client"; |
| 2 | + |
| 3 | +import type { PlanSummary } from "@cheatcode/types"; |
| 4 | +import { PaidBillingTierSchema } from "@cheatcode/types"; |
| 5 | +import { ModalShell } from "@cheatcode/ui"; |
| 6 | +import { useMutation } from "@tanstack/react-query"; |
| 7 | +import { toast } from "sonner"; |
| 8 | +import { Loader2 } from "@/components/ui/icons"; |
| 9 | +import { requestCheckout } from "@/lib/api/billing"; |
| 10 | +import { useBillingCatalogQuery } from "@/lib/hooks/use-billing"; |
| 11 | + |
| 12 | +/** |
| 13 | + * Plan picker for upgrading. Lists every PAID tier from the billing catalog; a tier |
| 14 | + * is purchasable only when the catalog marks it `available` (i.e. its Polar product |
| 15 | + * id is configured), so Pro/Premium check out while Ultra/Max show "Coming soon" |
| 16 | + * until the owner creates their products. Checkout passes the tier the user picked |
| 17 | + * — no surface hardcodes a single tier. |
| 18 | + */ |
| 19 | +export function UpgradeDialog({ |
| 20 | + getToken, |
| 21 | + onClose, |
| 22 | + open, |
| 23 | +}: { |
| 24 | + getToken: () => Promise<null | string>; |
| 25 | + onClose: () => void; |
| 26 | + open: boolean; |
| 27 | +}) { |
| 28 | + const catalogQuery = useBillingCatalogQuery(getToken); |
| 29 | + const checkoutMutation = useMutation({ |
| 30 | + mutationFn: (tier: PlanSummary["id"]) => |
| 31 | + requestCheckout(getToken, { |
| 32 | + returnUrl: window.location.href, |
| 33 | + successUrl: window.location.href, |
| 34 | + tier: PaidBillingTierSchema.parse(tier), |
| 35 | + }), |
| 36 | + onError: (error) => toast.error(error instanceof Error ? error.message : "Checkout failed"), |
| 37 | + onSuccess: (url) => window.location.assign(url), |
| 38 | + }); |
| 39 | + |
| 40 | + const paidPlans = (catalogQuery.data?.plans ?? []).filter((plan) => plan.id !== "free"); |
| 41 | + |
| 42 | + return ( |
| 43 | + <ModalShell |
| 44 | + ariaLabel="Choose a plan" |
| 45 | + className="m-auto w-full max-w-lg" |
| 46 | + onClose={onClose} |
| 47 | + open={open} |
| 48 | + > |
| 49 | + <div className="flex flex-col gap-4 p-5 text-[#1b1b1b]"> |
| 50 | + <div> |
| 51 | + <h2 className="font-semibold text-[18px]">Choose a plan</h2> |
| 52 | + <p className="mt-1 text-[#5f5f5f] text-[14px]"> |
| 53 | + Sandbox hours are billed monthly. Provider inference stays bring-your-own-key. |
| 54 | + </p> |
| 55 | + </div> |
| 56 | + <UpgradeDialogBody |
| 57 | + isLoading={catalogQuery.isLoading} |
| 58 | + onChoose={(tier) => checkoutMutation.mutate(tier)} |
| 59 | + paidPlans={paidPlans} |
| 60 | + pendingTier={checkoutMutation.isPending ? (checkoutMutation.variables ?? null) : null} |
| 61 | + /> |
| 62 | + </div> |
| 63 | + </ModalShell> |
| 64 | + ); |
| 65 | +} |
| 66 | + |
| 67 | +function UpgradeDialogBody({ |
| 68 | + isLoading, |
| 69 | + onChoose, |
| 70 | + paidPlans, |
| 71 | + pendingTier, |
| 72 | +}: { |
| 73 | + isLoading: boolean; |
| 74 | + onChoose: (tier: PlanSummary["id"]) => void; |
| 75 | + paidPlans: PlanSummary[]; |
| 76 | + pendingTier: PlanSummary["id"] | null; |
| 77 | +}) { |
| 78 | + if (isLoading) { |
| 79 | + return <p className="py-6 text-center text-[#a0a0a0] text-[14px]">Loading plans…</p>; |
| 80 | + } |
| 81 | + if (paidPlans.length === 0) { |
| 82 | + return ( |
| 83 | + <p className="py-6 text-center text-[#a0a0a0] text-[14px]"> |
| 84 | + Plans are temporarily unavailable. |
| 85 | + </p> |
| 86 | + ); |
| 87 | + } |
| 88 | + return ( |
| 89 | + <ul className="flex flex-col gap-2"> |
| 90 | + {paidPlans.map((plan) => ( |
| 91 | + <PlanRow |
| 92 | + isPending={pendingTier === plan.id} |
| 93 | + key={plan.id} |
| 94 | + onChoose={() => onChoose(plan.id)} |
| 95 | + plan={plan} |
| 96 | + /> |
| 97 | + ))} |
| 98 | + </ul> |
| 99 | + ); |
| 100 | +} |
| 101 | + |
| 102 | +function PlanRow({ |
| 103 | + isPending, |
| 104 | + onChoose, |
| 105 | + plan, |
| 106 | +}: { |
| 107 | + isPending: boolean; |
| 108 | + onChoose: () => void; |
| 109 | + plan: PlanSummary; |
| 110 | +}) { |
| 111 | + const purchasable = plan.available && !plan.current; |
| 112 | + return ( |
| 113 | + <li className="flex items-center justify-between gap-3 rounded-[16px] border border-[#ececec] bg-white px-4 py-3"> |
| 114 | + <div className="min-w-0"> |
| 115 | + <p className="font-medium text-[#1b1b1b] text-[15px]"> |
| 116 | + {plan.displayName} |
| 117 | + {plan.current ? " · current" : ""} |
| 118 | + </p> |
| 119 | + <p className="text-[#8a8a8a] text-[12px]"> |
| 120 | + ${plan.monthlyPriceUsd}/mo · {plan.sandboxHoursPerMonth} sandbox-hours |
| 121 | + </p> |
| 122 | + </div> |
| 123 | + {purchasable ? ( |
| 124 | + <button |
| 125 | + className="inline-flex h-9 shrink-0 items-center gap-2 rounded-full bg-[#1b1b1b] px-4 font-medium text-[14px] text-white transition-colors hover:bg-black disabled:opacity-50" |
| 126 | + disabled={isPending} |
| 127 | + onClick={onChoose} |
| 128 | + type="button" |
| 129 | + > |
| 130 | + {isPending ? <Loader2 aria-hidden="true" className="h-4 w-4 animate-spin" /> : null} |
| 131 | + Choose |
| 132 | + </button> |
| 133 | + ) : ( |
| 134 | + <span className="shrink-0 rounded-full border border-[#f1f1f1] px-3 py-1.5 text-[#a0a0a0] text-[12px]"> |
| 135 | + {plan.current ? "Current" : "Coming soon"} |
| 136 | + </span> |
| 137 | + )} |
| 138 | + </li> |
| 139 | + ); |
| 140 | +} |
0 commit comments