| @@ -20,6 +20,14 @@ export const Route = createFileRoute("/billing_/plans")({ · 19 unmodified lines |
| 20 | component: PlansPage, | 20 | component: PlansPage, |
| 21 | }); | 21 | }); |
| 22 | | 22 | |
| | 23 | const ENTERPRISE_FEATURES = [ |
| | 24 | "Self-hosted or dedicated cloud deployment support", |
| | 25 | "SSO / SAML & SCIM provisioning", |
| | 26 | "Audit logs for every tool call", |
| | 27 | "Dedicated support & onboarding", |
| | 28 | "Security reviews, DPA & SOC 2 on request", |
| | 29 | ]; |
| | 30 | |
| 23 | const PLAN_META: Record<string, { tagline: string; inherits?: string; features: string[] }> = { | 31 | const PLAN_META: Record<string, { tagline: string; inherits?: string; features: string[] }> = { |
| 24 | free: { | 32 | free: { |
| 25 | tagline: "For small teams getting started", | 33 | tagline: "For small teams getting started", |
| @@ -40,6 +48,11 @@ const PLAN_META: Record<string, { tagline: string; inherits?: string; features: · 14 unmodified lines |
| 40 | "$0.20 per 1,000 additional executions", | 48 | "$0.20 per 1,000 additional executions", |
| 41 | ], | 49 | ], |
| 42 | }, | 50 | }, |
| | 51 | enterprise: { |
| | 52 | tagline: "For orgs with custom needs", |
| | 53 | inherits: "Team", |
| | 54 | features: ENTERPRISE_FEATURES, |
| | 55 | }, |
| 43 | }; | 56 | }; |
| 44 | | 57 | |
| 45 | const ACTION_LABELS: Record<string, string> = { | 58 | const ACTION_LABELS: Record<string, string> = { |
| @@ -50,28 +63,7 @@ const ACTION_LABELS: Record<string, string> = { · 4 unmodified lines |
| 50 | purchase: "Purchase", | 63 | purchase: "Purchase", |
| 51 | }; | 64 | }; |
| 52 | | 65 | |
| 53 | const ENTERPRISE_FEATURES = [ | 66 | const PLAN_ORDER = ["free", "team", "enterprise"]; |
| 54 | "Self-hosted or dedicated cloud deployment support", | | |
| 55 | "SSO / SAML & SCIM provisioning", | | |
| 56 | "Audit logs for every tool call", | | |
| 57 | "Dedicated support & onboarding", | | |
| 58 | "Security reviews, DPA & SOC 2 on request", | | |
| 59 | ]; | | |
| 60 | | | |
| 61 | const ENTERPRISE_MAILTO = `mailto:rhys@executor.sh?subject=${encodeURIComponent( | | |
| 62 | "Executor Enterprise inquiry", | | |
| 63 | )}&body=${encodeURIComponent( | | |
| 64 | [ | | |
| 65 | "Hi,", | | |
| 66 | "", | | |
| 67 | "We're interested in Executor Enterprise.", | | |
| 68 | "", | | |
| 69 | "Company:", | | |
| 70 | "Team size:", | | |
| 71 | "Use case:", | | |
| 72 | "Requirements (SSO, self-hosted, compliance, etc.):", | | |
| 73 | ].join("\n"), | | |
| 74 | )}`; | | |
| 75 | | 67 | |
| 76 | function PlansPage() { | 68 | function PlansPage() { |
| 77 | const { attach, openCustomerPortal, isLoading: customerLoading } = useCustomer(); | 69 | const { attach, openCustomerPortal, isLoading: customerLoading } = useCustomer(); |
| @@ -80,8 +72,8 @@ function PlansPage() { · 2 unmodified lines |
| 80 | | 72 | |
| 81 | const isLoading = customerLoading || plansLoading; | 73 | const isLoading = customerLoading || plansLoading; |
| 82 | | 74 | |
| 83 | const selfServePlans = (plans ?? ([] as Plan[])).filter( | 75 | const selfServePlans = PLAN_ORDER.flatMap((id) => |
| 84 | (p: Plan) => p.id === "free" || p.id === "team", | 76 | (plans ?? ([] as Plan[])).filter((plan: Plan) => plan.id === id), |
| 85 | ); | 77 | ); |
| 86 | | 78 | |
| 87 | return ( | 79 | return ( |
| @@ -136,6 +128,7 @@ function PlansPage() { · 48 unmodified lines |
| 136 | const isScheduled = status === "scheduled"; | 128 | const isScheduled = status === "scheduled"; |
| 137 | const label = isCanceling ? "Resume" : (ACTION_LABELS[action] ?? "Select"); | 129 | const label = isCanceling ? "Resume" : (ACTION_LABELS[action] ?? "Select"); |
| 138 | const isUpgradeAction = action === "upgrade" || action === "activate"; | 130 | const isUpgradeAction = action === "upgrade" || action === "activate"; |
| | 131 | const isEnterprise = plan.id === "enterprise"; |
| 139 | | 132 | |
| 140 | return ( | 133 | return ( |
| 141 | <div | 134 | <div |
| @@ -173,14 +166,14 @@ function PlansPage() { · 31 unmodified lines |
| 173 | | 166 | |
| 174 | <div className="mt-4 flex items-baseline gap-1.5"> | 167 | <div className="mt-4 flex items-baseline gap-1.5"> |
| 175 | <span className="text-2xl font-semibold text-foreground tabular-nums"> | 168 | <span className="text-2xl font-semibold text-foreground tabular-nums"> |
| 176 | ${plan.price?.amount ?? 0} | 169 | {plan.id === "enterprise" ? "Custom" : `$${plan.price?.amount ?? 0}`} |
| 177 | </span> | 170 | </span> |
| 178 | {plan.price?.interval && ( | 171 | {plan.id !== "enterprise" && plan.price?.interval && ( |
| 179 | <span className="text-sm text-muted-foreground"> | 172 | <span className="text-sm text-muted-foreground"> |
| 180 | USD / org / {plan.price.interval} | 173 | USD / org / {plan.price.interval} |
| 181 | </span> | 174 | </span> |
| 182 | )} | 175 | )} |
| 183 | {!plan.price?.interval && ( | 176 | {plan.id !== "enterprise" && !plan.price?.interval && ( |
| 184 | <span className="text-sm text-muted-foreground">USD</span> | 177 | <span className="text-sm text-muted-foreground">USD</span> |
| 185 | )} | 178 | )} |
| 186 | </div> | 179 | </div> |
| @@ -190,6 +183,8 @@ function PlansPage() { · 3 unmodified lines |
| 190 | <div className="flex h-9 items-center justify-center rounded-md border border-border bg-muted/30 text-sm font-medium text-muted-foreground"> | 183 | <div className="flex h-9 items-center justify-center rounded-md border border-border bg-muted/30 text-sm font-medium text-muted-foreground"> |
| 191 | {isCurrent ? "Current plan" : "Scheduled"} | 184 | {isCurrent ? "Current plan" : "Scheduled"} |
| 192 | </div> | 185 | </div> |
| | 186 | ) : isEnterprise ? ( |
| | 187 | <EnterpriseContactDialog /> |
| 193 | ) : isCanceling ? ( | 188 | ) : isCanceling ? ( |
| 194 | <Button | 189 | <Button |
| 195 | type="button" | 190 | type="button" |
| @@ -251,48 +246,6 @@ function PlansPage() { · 55 unmodified lines |
| 251 | </div> | 246 | </div> |
| 252 | ); | 247 | ); |
| 253 | })} | 248 | })} |
| 254 | | | |
| 255 | <div className="flex flex-col rounded-xl border border-border p-5"> | | |
| 256 | <div className="flex h-6 items-center justify-between"> | | |
| 257 | <p className="text-base font-semibold text-foreground leading-none">Enterprise</p> | | |
| 258 | </div> | | |
| 259 | <p className="mt-1 text-sm text-muted-foreground">For orgs with custom needs</p> | | |
| 260 | | | |
| 261 | <div className="mt-4 flex items-baseline gap-1.5"> | | |
| 262 | <span className="text-2xl font-semibold text-foreground tabular-nums">Custom</span> | | |
| 263 | </div> | | |
| 264 | | | |
| 265 | <div className="mt-4"> | | |
| 266 | <a | | |
| 267 | href={ENTERPRISE_MAILTO} | | |
| 268 | className="flex h-9 w-full items-center justify-center rounded-md border border-border bg-background text-sm font-medium text-foreground transition-colors hover:bg-muted" | | |
| 269 | > | | |
| 270 | Contact us | | |
| 271 | </a> | | |
| 272 | </div> | | |
| 273 | | | |
| 274 | <p className="mt-5 text-xs font-medium text-foreground">Everything in Team, plus</p> | | |
| 275 | <ul role="list" className="mt-2 space-y-2"> | | |
| 276 | {ENTERPRISE_FEATURES.map((f) => ( | | |
| 277 | <li key={f} className="flex items-start gap-2 text-xs text-muted-foreground"> | | |
| 278 | <svg | | |
| 279 | viewBox="0 0 16 16" | | |
| 280 | fill="none" | | |
| 281 | className="mt-px size-3.5 shrink-0 text-primary/60" | | |
| 282 | > | | |
| 283 | <path | | |
| 284 | d="M3.5 8.5L6.5 11.5L12.5 5" | | |
| 285 | stroke="currentColor" | | |
| 286 | strokeWidth="1.5" | | |
| 287 | strokeLinecap="round" | | |
| 288 | strokeLinejoin="round" | | |
| 289 | /> | | |
| 290 | </svg> | | |
| 291 | <span>{f}</span> | | |
| 292 | </li> | | |
| 293 | ))} | | |
| 294 | </ul> | | |
| 295 | </div> | | |
| 296 | </div> | 249 | </div> |
| 297 | )} | 250 | )} |
| 298 | | 251 | |
| @@ -302,6 +255,48 @@ function PlansPage() { · 3 unmodified lines |
| 302 | ); | 255 | ); |
| 303 | } | 256 | } |
| 304 | | 257 | |
| | 258 | function EnterpriseContactDialog() { |
| | 259 | const [open, setOpen] = useState(false); |
| | 260 | |
| | 261 | return ( |
| | 262 | <Dialog open={open} onOpenChange={setOpen}> |
| | 263 | <DialogTrigger asChild> |
| | 264 | <Button |
| | 265 | type="button" |
| | 266 | variant="outline" |
| | 267 | className="flex h-9 w-full items-center justify-center rounded-md text-sm font-medium" |
| | 268 | > |
| | 269 | Contact us |
| | 270 | </Button> |
| | 271 | </DialogTrigger> |
| | 272 | <DialogContent> |
| | 273 | <DialogHeader> |
| | 274 | <DialogTitle>Talk to us about Enterprise</DialogTitle> |
| | 275 | <DialogDescription> |
| | 276 | Add{" "} |
| | 277 | <a className="font-medium text-foreground underline" href="mailto:rhys@executor.sh"> |
| | 278 | rhys@executor.sh |
| | 279 | </a>{" "} |
| | 280 | on Slack Connect and send the org name, team size, and anything you need covered. |
| | 281 | </DialogDescription> |
| | 282 | </DialogHeader> |
| | 283 | <DialogFooter> |
| | 284 | <DialogClose asChild> |
| | 285 | <Button type="button" variant="outline"> |
| | 286 | Done |
| | 287 | </Button> |
| | 288 | </DialogClose> |
| | 289 | <Button asChild> |
| | 290 | <a href="mailto:rhys@executor.sh?subject=Executor%20Enterprise%20inquiry"> |
| | 291 | Email instead |
| | 292 | </a> |
| | 293 | </Button> |
| | 294 | </DialogFooter> |
| | 295 | </DialogContent> |
| | 296 | </Dialog> |
| | 297 | ); |
| | 298 | } |
| | 299 | |
| 305 | function SlackContactCta() { | 300 | function SlackContactCta() { |
| 306 | const [open, setOpen] = useState(false); | 301 | const [open, setOpen] = useState(false); |
| 307 | | 302 | |