Files
triggerdotdev--trigger.dev/apps/webapp/app/routes/resources.orgs.$organizationSlug.select-plan.tsx
2026-07-13 13:32:57 +08:00

1195 lines
40 KiB
TypeScript

import { CheckIcon, XMarkIcon } from "@heroicons/react/20/solid";
import { ArrowDownCircleIcon, ArrowUpCircleIcon } from "@heroicons/react/24/outline";
import { Form, useLocation, useNavigation } from "@remix-run/react";
import { uiComponent } from "@team-plain/typescript-sdk";
import {
type AddOnPricing,
type FreePlanDefinition,
type Limits,
type PaidPlanDefinition,
type Plans,
type SetPlanBody,
type SubscriptionResult,
} from "@trigger.dev/platform";
import React, { useEffect, useState } from "react";
import { z } from "zod";
import { DefinitionTip } from "~/components/DefinitionTooltip";
import { Feedback } from "~/components/Feedback";
import { Button } from "~/components/primitives/Buttons";
import { CheckboxWithLabel } from "~/components/primitives/Checkbox";
import { DateTime } from "~/components/primitives/DateTime";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTrigger,
} from "~/components/primitives/Dialog";
import { Header2 } from "~/components/primitives/Headers";
import { Paragraph } from "~/components/primitives/Paragraph";
import { Spinner } from "~/components/primitives/Spinner";
import { TextArea } from "~/components/primitives/TextArea";
import { TextLink } from "~/components/primitives/TextLink";
import { prisma } from "~/db.server";
import { redirectWithErrorMessage } from "~/models/message.server";
import { resolveOrgIdFromSlug } from "~/models/organization.server";
import { logger } from "~/services/logger.server";
import { applyPromoCode, bustPromoCreditsCache, setPlan } from "~/services/platform.v3.server";
import { clearPromoCodeCookie, getPromoCodeFromCookie } from "~/services/promoCode.server";
import { dashboardAction } from "~/services/routeBuilders/dashboardBuilder";
import { engine } from "~/v3/runEngine.server";
import { cn } from "~/utils/cn";
import { sendToPlain } from "~/utils/plain.server";
import { formatCurrency } from "~/utils/numberFormatter";
import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
const Params = z.object({
organizationSlug: z.string(),
});
const schema = z.object({
type: z.enum(["free", "paid"]),
planCode: z.string().optional(),
callerPath: z.string(),
reasons: z.union([z.string(), z.array(z.string())]).optional(),
message: z.string().optional(),
});
export const action = dashboardAction(
{
params: Params,
context: async (params) => {
const organizationId = await resolveOrgIdFromSlug(params.organizationSlug);
return organizationId ? { organizationId } : {};
},
authorization: { action: "manage", resource: { type: "billing" } },
},
async ({ request, params, user }) => {
const { organizationSlug } = params;
const formData = await request.formData();
const reasons = formData.getAll("reasons");
const message = formData.get("message");
const form = schema.parse({
...Object.fromEntries(formData),
reasons,
message: message || undefined,
});
const organization = await prisma.organization.findFirst({
where: { slug: organizationSlug, members: { some: { userId: user.id } } },
});
if (!organization) {
throw await redirectWithErrorMessage(form.callerPath, request, "Organization not found");
}
let payload: SetPlanBody;
switch (form.type) {
case "free": {
try {
if (reasons.length > 0 || (message && message.toString().trim() !== "")) {
await sendToPlain({
userId: user.id,
email: user.email,
name: user.name ?? "",
title: "Plan cancelation feedback",
components: [
uiComponent.text({
text: `${user.name} (${user.email}) just canceled their plan.`,
}),
uiComponent.divider({ spacingSize: "M" }),
...(reasons.length > 0
? [
uiComponent.spacer({ size: "L" }),
uiComponent.text({
size: "L",
color: "NORMAL",
text: "Reasons:",
}),
uiComponent.text({
text: reasons.join(", "),
}),
]
: []),
...(message
? [
uiComponent.spacer({ size: "L" }),
uiComponent.text({
size: "L",
color: "NORMAL",
text: "Comment:",
}),
uiComponent.text({
text: message.toString(),
}),
]
: []),
],
});
}
} catch (e) {
logger.error("Failed to submit to Plain the unsubscribe reason", { error: e });
}
payload = {
type: "free" as const,
userId: user.id,
};
break;
}
case "paid": {
if (form.planCode === undefined) {
throw await redirectWithErrorMessage(form.callerPath, request, "Not a valid plan");
}
payload = {
type: "paid" as const,
planCode: form.planCode,
userId: user.id,
};
break;
}
default: {
throw new Error("Invalid form type");
}
}
const result = await setPlan(organization, request, form.callerPath, payload, {
invalidateBillingCache: engine.invalidateBillingCache.bind(engine),
// Redeem a promo code carried from the /promo landing page. This runs only
// once the Free plan has actually been provisioned (the grant target), so a
// failed plan change never burns the one-time code. Best-effort: it must
// never change the plan-selection outcome.
onFreePlanProvisioned: async (response) => {
const promoCode = await getPromoCodeFromCookie(request);
if (!promoCode) {
return;
}
const applied = await applyPromoCode(organization.id, user.id, promoCode);
if (applied?.applied) {
bustPromoCreditsCache(organization.id);
response.headers.append("Set-Cookie", await clearPromoCodeCookie());
}
},
});
return result;
}
);
const pricingDefinitions = {
usage: {
title: "Usage",
content: "The compute cost when tasks are executing.",
},
concurrentRuns: {
title: "Concurrent runs",
content: "The number of runs that can be executed at the same time.",
},
additionalConcurrency: {
title: "Additional concurrency",
},
taskRun: {
title: "Task runs",
content: "A single execution of a task.",
},
tasks: {
title: "Tasks",
content:
"Tasks are functions that can run for a long time and provide strong resilience to failure.",
},
environment: {
title: "Environments",
content: "The different environments available for running your tasks.",
},
schedules: {
title: "Schedules",
content: "You can attach recurring schedules to tasks using cron syntax.",
},
additionalSchedules: {
title: "Additional schedules",
content: "Then $10/month per 1,000",
},
alerts: {
title: "Alert destination",
content:
"A single email address, Slack channel, or webhook URL that you want to send alerts to.",
},
realtime: {
title: "Realtime connections",
content:
"Realtime allows you to send the live status and data from your runs to your frontend. This is the number of simultaneous Realtime connections that can be made.",
},
additionalRealtimeConnections: {
title: "Additional Realtime connections",
content: "Then $10/month per 1000",
},
additionalSeats: {
title: "Additional seats",
content: "Then $20/month per seat",
},
branches: {
title: "Branches",
content:
"Preview branches allow you to test changes before deploying to production. You can have a limited number active at once (but can archive old ones).",
},
additionalBranches: {
title: "Additional branches",
content: "Then $10/month per branch",
},
metricDashboards: {
title: "Custom dashboards",
content: "Custom metric dashboards for monitoring and visualizing your task data.",
},
additionalDashboards: {
title: "Additional dashboards",
content: "Then $10/month per dashboard",
},
queryPeriod: {
title: "Query period",
content: "The maximum number of days a query can look back when analyzing your task data.",
},
hipaaBaa: {
title: "HIPAA BAA",
content:
"A signed Business Associate Agreement (BAA) is required to run tasks that process Protected Health Information (PHI) on Trigger.dev Cloud.",
},
};
type PricingPlansProps = {
plans: Plans;
concurrencyAddOnPricing: AddOnPricing;
subscription?: SubscriptionResult;
organizationSlug: string;
hasPromotedPlan: boolean;
periodEnd: Date;
};
export function PricingPlans({
plans,
concurrencyAddOnPricing,
subscription,
organizationSlug,
hasPromotedPlan,
periodEnd,
}: PricingPlansProps) {
return (
<div className="flex w-full flex-col">
<div className="flex flex-col gap-3 lg:flex-row">
<TierFree
plan={plans.free}
subscription={subscription}
organizationSlug={organizationSlug}
periodEnd={periodEnd}
/>
<TierHobby
plan={plans.hobby}
organizationSlug={organizationSlug}
subscription={subscription}
isHighlighted={hasPromotedPlan}
/>
<TierPro
plan={plans.pro}
organizationSlug={organizationSlug}
subscription={subscription}
concurrencyAddOnPricing={concurrencyAddOnPricing}
/>
</div>
<div className="mt-3">
<TierEnterprise />
</div>
</div>
);
}
export function TierFree({
plan,
subscription,
organizationSlug,
periodEnd,
}: {
plan: FreePlanDefinition;
subscription?: SubscriptionResult;
organizationSlug: string;
periodEnd: Date;
}) {
const location = useLocation();
const navigation = useNavigation();
const formAction = `/resources/orgs/${organizationSlug}/select-plan`;
const isLoading = navigation.formAction === formAction;
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [isLackingFeaturesChecked, setIsLackingFeaturesChecked] = useState(false);
useEffect(() => {
setIsDialogOpen(false);
}, [subscription]);
return (
<TierContainer>
<PricingHeader title={plan.title} cost={0} />
{subscription?.plan !== undefined &&
subscription.plan.type !== "free" &&
subscription.canceledAt === undefined ? (
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen} key="cancel">
<DialogTrigger asChild>
<div className="my-6">
<Button variant="secondary/large" fullWidth className="text-md font-medium">
{`Downgrade to ${plan.title}`}
</Button>
</div>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<Form action={formAction} method="post" id="subscribe">
<input type="hidden" name="type" value="free" />
<input type="hidden" name="callerPath" value={location.pathname} />
<DialogHeader>Downgrade plan?</DialogHeader>
<div className="flex items-start gap-3 pb-6 pr-2 pt-8">
<ArrowDownCircleIcon className="size-12 min-w-12 text-error" />
<Paragraph variant="base/bright" className="text-text-bright">
Are you sure you want to downgrade? You will lose access to your current plan's
features on{" "}
<DateTime includeTime={false} date={new Date(periodEnd.getTime() + 86400000)} />.
</Paragraph>
</div>
<div>
<div className="mb-4">
<Header2 className="mb-1">Why are you thinking of downgrading?</Header2>
<ul className="space-y-1">
{[
"The Free plan is all I need",
"Subscription or usage costs too expensive",
"Bugs or technical issues",
"No longer need the service",
"Found a better alternative",
"Lacking features I need",
].map((label, index) => (
<li key={index}>
<CheckboxWithLabel
id={`reason-${index + 1}`}
name="reasons"
value={label}
variant="simple"
label={label}
labelClassName="text-text-dimmed"
onChange={(isChecked: boolean) => {
if (label === "Lacking features I need") {
setIsLackingFeaturesChecked(isChecked);
}
}}
/>
</li>
))}
</ul>
</div>
<div>
<Header2 className="mb-1">
{isLackingFeaturesChecked
? "What features do you need? Or how can we improve?"
: "What can we do to improve?"}
</Header2>
<TextArea id="improvement-suggestions" name="message" />
</div>
</div>
<DialogFooter className="mt-2">
<Button variant="secondary/medium" onClick={() => setIsDialogOpen(false)}>
Dismiss
</Button>
<Button
variant="danger/medium"
disabled={isLoading}
LeadingIcon={isLoading ? () => <Spinner color="white" /> : undefined}
type="submit"
>
Downgrade plan
</Button>
</DialogFooter>
</Form>
</DialogContent>
</Dialog>
) : (
<Form action={formAction} method="post" id="subscribe-verified" className="my-6">
<input type="hidden" name="type" value="free" />
<input type="hidden" name="callerPath" value={location.pathname} />
<Button
variant="secondary/large"
type="submit"
form="subscribe-verified"
fullWidth
className="text-md font-medium"
disabled={
isLoading ||
subscription?.plan?.type === plan.type ||
subscription?.canceledAt !== undefined
}
LeadingIcon={
isLoading && navigation.formData?.get("planCode") === null ? Spinner : undefined
}
>
{subscription?.plan === undefined
? "Select plan"
: subscription.plan.type === "free"
? "Current plan"
: subscription.canceledAt !== undefined
? "Current plan"
: "Select plan"}
</Button>
</Form>
)}
<ul className="flex flex-col gap-2.5">
<FeatureItem checked>
<DefinitionTip
title="Free credits"
content={`You get $${plan.limits.includedUsage / 100} of compute each month for free.`}
>
${plan.limits.includedUsage / 100} / month free credits
</DefinitionTip>
</FeatureItem>
<ConcurrentRuns limits={plan.limits} />
<FeatureItem checked>
Unlimited{" "}
<DefinitionTip
title={pricingDefinitions.tasks.title}
content={pricingDefinitions.tasks.content}
>
tasks
</DefinitionTip>
</FeatureItem>
<TeamMembers limits={plan.limits} />
<Environments limits={plan.limits} />
<Branches limits={plan.limits} />
<MetricDashboards limits={plan.limits} />
<Schedules limits={plan.limits} />
<LogRetention limits={plan.limits} />
<QueryPeriod limits={plan.limits} />
<SupportLevel limits={plan.limits} />
<Alerts limits={plan.limits} />
<RealtimeConcurrency limits={plan.limits} />
</ul>
</TierContainer>
);
}
export function TierHobby({
plan,
organizationSlug,
subscription,
isHighlighted,
}: {
plan: PaidPlanDefinition;
organizationSlug: string;
subscription?: SubscriptionResult;
isHighlighted: boolean;
}) {
const location = useLocation();
const navigation = useNavigation();
const formAction = `/resources/orgs/${organizationSlug}/select-plan`;
const isLoading = navigation.formAction === formAction;
const [isDialogOpen, setIsDialogOpen] = useState(false);
useEffect(() => {
setIsDialogOpen(false);
}, [subscription]);
return (
<TierContainer isHighlighted={isHighlighted}>
<PricingHeader title={plan.title} isHighlighted={isHighlighted} cost={plan.tierPrice} />
<Form action={formAction} method="post" id="subscribe-hobby" className="py-6">
<input type="hidden" name="type" value="paid" />
<input type="hidden" name="planCode" value={plan.code} />
<input type="hidden" name="callerPath" value={location.pathname} />
{subscription?.plan !== undefined &&
subscription.plan.type !== "free" &&
subscription.canceledAt === undefined &&
subscription.plan.code !== plan.code ? (
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen} key="downgrade">
<DialogTrigger asChild>
<Button variant="secondary/large" fullWidth className="text-md font-medium">
{`Downgrade to ${plan.title}`}
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>Downgrade plan?</DialogHeader>
<div className="mb-2 mt-4 flex items-start gap-3">
<span>
<ArrowDownCircleIcon className="size-12 text-blue-500" />
</span>
<Paragraph variant="base/bright" className="text-text-bright">
By downgrading you will lose access to your current plan's features and your
included credits will be reduced.
</Paragraph>
</div>
<DialogFooter>
<Button variant="secondary/medium" onClick={() => setIsDialogOpen(false)}>
Dismiss
</Button>
<Button
variant="secondary/medium"
disabled={isLoading}
LeadingIcon={isLoading ? () => <Spinner color="white" /> : undefined}
form="subscribe-hobby"
>
{`Downgrade to ${plan.title}`}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
) : (
<Button
variant={isHighlighted ? "primary/large" : "secondary/large"}
fullWidth
className="text-md font-medium"
form="subscribe-hobby"
disabled={
isLoading ||
(subscription?.plan?.code === plan.code && subscription.canceledAt === undefined)
}
LeadingIcon={
isLoading && navigation.formData?.get("planCode") === plan.code ? Spinner : undefined
}
>
{subscription?.plan === undefined
? "Select plan"
: subscription.plan.type === "free" || subscription.canceledAt !== undefined
? `Upgrade to ${plan.title}`
: subscription.plan.code === plan.code
? "Current plan"
: `Upgrade to ${plan.title}`}
</Button>
)}
</Form>
<ul className="flex flex-col gap-2.5">
<FeatureItem checked>
<DefinitionTip
disableHoverableContent={false}
title="Credits included"
content={
<Paragraph variant="small/dimmed" spacing>
This plan includes ${plan.limits.includedUsage / 100} of compute each month. After
that's used, tasks keep running and you're billed per our{" "}
<TextLink target="_blank" to="https://trigger.dev/pricing#computePricing">
compute usage rates
</TextLink>
.
</Paragraph>
}
>
${plan.limits.includedUsage / 100} / month credits included
</DefinitionTip>
</FeatureItem>
<ConcurrentRuns limits={plan.limits} />
<FeatureItem checked>
Unlimited{" "}
<DefinitionTip
title={pricingDefinitions.tasks.title}
content={pricingDefinitions.tasks.content}
>
tasks
</DefinitionTip>
</FeatureItem>
<TeamMembers limits={plan.limits} />
<Environments limits={plan.limits} />
<Branches limits={plan.limits} />
<MetricDashboards limits={plan.limits} />
<Schedules limits={plan.limits} />
<LogRetention limits={plan.limits} />
<QueryPeriod limits={plan.limits} />
<SupportLevel limits={plan.limits} />
<Alerts limits={plan.limits} />
<RealtimeConcurrency limits={plan.limits} />
<HIPAAAddOn>
<Feedback
defaultValue="hipaa"
button={
<span className="cursor-pointer underline decoration-text-faint underline-offset-4 transition hover:decoration-text-bright">
Request a BAA
</span>
}
/>
</HIPAAAddOn>
</ul>
</TierContainer>
);
}
export function TierPro({
plan,
concurrencyAddOnPricing,
organizationSlug,
subscription,
}: {
plan: PaidPlanDefinition;
concurrencyAddOnPricing: AddOnPricing;
organizationSlug: string;
subscription?: SubscriptionResult;
}) {
const location = useLocation();
const navigation = useNavigation();
const formAction = `/resources/orgs/${organizationSlug}/select-plan`;
const isLoading = navigation.formAction === formAction;
const [isDialogOpen, setIsDialogOpen] = useState(false);
useEffect(() => {
setIsDialogOpen(false);
}, [subscription]);
return (
<TierContainer>
<PricingHeader title={plan.title} cost={plan.tierPrice} />
<Form action={formAction} method="post" id="subscribe-pro">
<div className="py-6">
<input type="hidden" name="type" value="paid" />
<input type="hidden" name="planCode" value={plan.code} />
<input type="hidden" name="callerPath" value={location.pathname} />
{subscription?.plan !== undefined &&
subscription?.plan?.type === "paid" &&
subscription?.plan?.code !== plan.code &&
subscription.canceledAt === undefined ? (
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen} key="upgrade">
<DialogTrigger asChild>
<Button variant="secondary/large" fullWidth className="text-md font-medium">
{`Upgrade to ${plan.title}`}
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>Upgrade plan</DialogHeader>
<div className="mb-2 mt-4 flex items-start gap-3">
<span>
<ArrowUpCircleIcon className="size-12 text-primary" />
</span>
<Paragraph variant="base/bright" className="text-text-bright">
Upgrade to get instant access to all the Pro features. You will be charged the
new plan price for the remainder of this month on a pro rata basis.
</Paragraph>
</div>
<DialogFooter>
<Button variant="secondary/medium" onClick={() => setIsDialogOpen(false)}>
Dismiss
</Button>
<Button
variant="primary/medium"
disabled={isLoading}
LeadingIcon={isLoading ? () => <Spinner color="white" /> : undefined}
form="subscribe-pro"
>
{`Upgrade to ${plan.title}`}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
) : (
<Button
variant="secondary/large"
fullWidth
form="subscribe-pro"
className="text-md font-medium"
disabled={
isLoading ||
(subscription?.plan?.code === plan.code && subscription.canceledAt === undefined)
}
LeadingIcon={
isLoading && navigation.formData?.get("planCode") === plan.code
? Spinner
: undefined
}
>
{subscription?.plan === undefined
? "Select plan"
: subscription.plan.type === "free" || subscription.canceledAt !== undefined
? `Upgrade to ${plan.title}`
: subscription.plan.code === plan.code
? "Current plan"
: `Upgrade to ${plan.title}`}
</Button>
)}
</div>
</Form>
<ul className="flex flex-col gap-2.5">
<FeatureItem checked>
<DefinitionTip
disableHoverableContent={false}
title="Credits included"
content={
<Paragraph variant="small/dimmed" spacing>
This plan includes ${plan.limits.includedUsage / 100} of compute each month. After
that's used, tasks keep running and you're billed per our{" "}
<TextLink target="_blank" to="https://trigger.dev/pricing#computePricing">
compute usage rates
</TextLink>
.
</Paragraph>
}
>
${plan.limits.includedUsage / 100} / month credits included
</DefinitionTip>
</FeatureItem>
<ConcurrentRuns limits={plan.limits}>
{`Then ${formatCurrency(concurrencyAddOnPricing.centsPerStep / 100, true)}/month per ${
concurrencyAddOnPricing.stepSize
}`}
</ConcurrentRuns>
<FeatureItem checked>
Unlimited{" "}
<DefinitionTip
title={pricingDefinitions.tasks.title}
content={pricingDefinitions.tasks.content}
>
tasks
</DefinitionTip>
</FeatureItem>
<TeamMembers limits={plan.limits}>{pricingDefinitions.additionalSeats.content}</TeamMembers>
<Environments limits={plan.limits} />
<Branches limits={plan.limits}>{pricingDefinitions.additionalBranches.content}</Branches>
<MetricDashboards limits={plan.limits}>
{pricingDefinitions.additionalDashboards.content}
</MetricDashboards>
<Schedules limits={plan.limits}>{pricingDefinitions.additionalSchedules.content}</Schedules>
<LogRetention limits={plan.limits} />
<QueryPeriod limits={plan.limits} />
<SupportLevel limits={plan.limits} />
<Alerts limits={plan.limits} />
<RealtimeConcurrency limits={plan.limits}>
{pricingDefinitions.additionalRealtimeConnections.content}
</RealtimeConcurrency>
<HIPAAAddOn>
<Feedback
defaultValue="hipaa"
button={
<span className="cursor-pointer underline decoration-text-faint underline-offset-4 transition hover:decoration-text-bright">
Request a BAA
</span>
}
/>
</HIPAAAddOn>
</ul>
</TierContainer>
);
}
export function TierEnterprise() {
return (
<TierContainer>
<div className="mb-4 flex w-full flex-col items-start justify-between gap-4 lg:flex-row lg:items-center">
<div className="flex flex-col gap-0.5">
<h2 className="text-xl font-medium text-text-dimmed">Enterprise</h2>
<p className="font-sans text-lg font-normal text-text-bright">Tailor a custom plan</p>
</div>
<div className="w-full lg:w-auto lg:max-w-64">
<Feedback
defaultValue="enterprise"
button={
<div className="flex h-10 w-full cursor-pointer items-center justify-center rounded border border-border-bright bg-tertiary px-8 text-base font-medium transition hover:border-border-brighter hover:bg-surface-control">
<span className="text-center text-text-bright">Contact us</span>
</div>
}
/>
</div>
</div>
<div className="flex w-full flex-wrap items-start justify-between gap-2 lg:flex-nowrap">
<ul className="flex w-full flex-col gap-y-3 lg:gap-y-1">
<FeatureItem checked checkedColor="bright">
All Pro plan features +
</FeatureItem>
<FeatureItem checked checkedColor="bright">
Custom log retention
</FeatureItem>
</ul>
<ul className="flex w-full flex-col gap-y-3 lg:gap-y-1">
<FeatureItem checked checkedColor="bright">
Priority support
</FeatureItem>
<FeatureItem checked checkedColor="bright">
Role-based access control
</FeatureItem>
</ul>
<ul className="flex w-full flex-col gap-y-3 lg:gap-y-1">
<FeatureItem checked checkedColor="bright">
SOC 2 report
</FeatureItem>
<FeatureItem checked checkedColor="bright">
SSO
</FeatureItem>
</ul>
<ul className="flex w-full flex-col gap-y-3 lg:gap-y-1">
<HIPAAAddOn checkedColor="bright">
<Feedback
defaultValue="hipaa"
button={
<span className="cursor-pointer underline decoration-text-faint underline-offset-4 transition hover:decoration-text-bright">
Request a BAA
</span>
}
/>
</HIPAAAddOn>
</ul>
</div>
</TierContainer>
);
}
function TierContainer({
children,
isHighlighted,
className,
}: {
children: React.ReactNode;
isHighlighted?: boolean;
className?: string;
}) {
return (
<div
className={cn(
"flex w-full min-w-64 flex-col p-6",
isHighlighted ? "border border-indigo-500" : "border border-grid-dimmed",
className
)}
>
{children}
</div>
);
}
function PricingHeader({
title,
cost: flatCost,
isHighlighted,
per = "/month",
maximumFractionDigits = 0,
}: {
title: string;
cost?: number;
isHighlighted?: boolean;
per?: string;
maximumFractionDigits?: number;
}) {
const dollarFormatter = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
maximumFractionDigits,
});
return (
<div className="flex flex-col gap-2">
<h2
className={cn(
"text-xl font-medium",
isHighlighted ? "text-indigo-500" : "text-text-dimmed"
)}
>
{title}
</h2>
{flatCost === 0 || flatCost ? (
<h3 className="text-4xl font-medium tabular-nums text-text-bright">
{dollarFormatter.format(flatCost)}
<span className="ml-1 text-sm font-normal tracking-wide text-text-dimmed">{per}</span>
</h3>
) : (
<h2 className="text-4xl font-medium">Custom</h2>
)}
</div>
);
}
function FeatureItem({
checked,
checkedColor = "primary",
children,
}: {
checked?: boolean;
checkedColor?: "primary" | "bright";
children: React.ReactNode;
}) {
return (
<li className="flex items-start gap-2">
{checked ? (
<CheckIcon
className={cn(
"mt-0.5 size-4 min-w-4",
checkedColor === "primary" ? "text-primary" : "text-text-bright"
)}
/>
) : (
<XMarkIcon className="mt-0.5 size-4 min-w-4 text-text-faint" />
)}
<div
className={cn(
"font-sans text-sm font-normal",
checked ? "text-text-bright" : "text-text-dimmed"
)}
>
{children}
</div>
</li>
);
}
function ConcurrentRuns({ limits, children }: { limits: Limits; children?: React.ReactNode }) {
return (
<FeatureItem checked>
<div className="flex flex-col gap-y-0.5">
<div className="flex items-center gap-1">
{limits.concurrentRuns.canExceed ? (
<>
{limits.concurrentRuns.number}
{"+"}
</>
) : (
<>{limits.concurrentRuns.number} </>
)}
<DefinitionTip
title={pricingDefinitions.concurrentRuns.title}
content={
<div className="flex flex-col">
<Paragraph variant="small/dimmed" spacing>
{pricingDefinitions.concurrentRuns.content}
</Paragraph>
<div className="flex items-center gap-x-1">
<EnvironmentLabel environment={{ type: "PRODUCTION" }} />
<span>
{limits.concurrentRuns.production}
{limits.concurrentRuns.canExceed ? "+" : ""}
</span>
</div>
<div className="flex items-center gap-x-1">
<EnvironmentLabel environment={{ type: "STAGING" }} />
<span>
{limits.concurrentRuns.staging}
{limits.concurrentRuns.canExceed ? "+" : ""}
</span>
</div>
<div className="flex items-center gap-x-1">
<EnvironmentLabel environment={{ type: "PREVIEW" }} />
<span>
{limits.concurrentRuns.preview}
{limits.concurrentRuns.canExceed ? "+" : ""}
</span>
</div>
<div className="flex items-center gap-x-1">
<EnvironmentLabel environment={{ type: "DEVELOPMENT" }} />
<span>
{limits.concurrentRuns.development}
{limits.concurrentRuns.canExceed ? "+" : ""}
</span>
</div>
</div>
}
>
concurrent runs
</DefinitionTip>
</div>
{children && <span className="text-xs text-text-dimmed">{children}</span>}
</div>
</FeatureItem>
);
}
function TeamMembers({ limits, children }: { limits: Limits; children?: React.ReactNode }) {
return (
<FeatureItem checked>
<div className="flex flex-col gap-y-0.5">
<div className="flex items-center gap-1">
{limits.teamMembers.number}
{limits.teamMembers.canExceed ? "+" : ""} team members
</div>
{children && <span className="text-xs text-text-dimmed">{children}</span>}
</div>
</FeatureItem>
);
}
function Environments({ limits }: { limits: Limits }) {
return (
<FeatureItem checked>
{limits.hasStagingEnvironment ? "Dev, Preview and Prod" : "Dev and Prod"}{" "}
<DefinitionTip
title={pricingDefinitions.environment.title}
content={pricingDefinitions.environment.content}
>
environments
</DefinitionTip>
</FeatureItem>
);
}
function Schedules({ limits, children }: { limits: Limits; children?: React.ReactNode }) {
return (
<FeatureItem checked>
<div className="flex flex-col gap-y-0.5">
<div className="flex items-center gap-1">
{limits.schedules.number}
{limits.schedules.canExceed ? "+" : ""}{" "}
<DefinitionTip
title={pricingDefinitions.schedules.title}
content={pricingDefinitions.schedules.content}
>
schedules
</DefinitionTip>
</div>
{children && <span className="text-xs text-text-dimmed">{children}</span>}
</div>
</FeatureItem>
);
}
function LogRetention({ limits }: { limits: Limits }) {
return <FeatureItem checked>{limits.logRetentionDays.number} day log retention</FeatureItem>;
}
function SupportLevel({ limits }: { limits: Limits }) {
return (
<FeatureItem checked>
{limits.support === "community" ? "Community support" : "Priority support"}
</FeatureItem>
);
}
function Alerts({ limits }: { limits: Limits }) {
if (limits.alerts.number === 0) {
return (
<FeatureItem>
<DefinitionTip
title={pricingDefinitions.alerts.title}
content={pricingDefinitions.alerts.content}
>
Alert destinations
</DefinitionTip>
</FeatureItem>
);
}
return (
<FeatureItem checked>
{limits.alerts.number}
{limits.alerts.canExceed ? "+" : ""}{" "}
<DefinitionTip
title={pricingDefinitions.alerts.title}
content={pricingDefinitions.alerts.content}
>
alert destinations
</DefinitionTip>
</FeatureItem>
);
}
function RealtimeConcurrency({ limits, children }: { limits: Limits; children?: React.ReactNode }) {
return (
<FeatureItem checked>
<div className="flex flex-col gap-y-0.5">
<div className="flex items-start gap-1">
{limits.realtimeConcurrentConnections.canExceed ? (
<>
{limits.realtimeConcurrentConnections.number}
{"+"}
</>
) : (
<>{limits.realtimeConcurrentConnections.number} </>
)}{" "}
<DefinitionTip
title={pricingDefinitions.realtime.title}
content={pricingDefinitions.realtime.content}
>
concurrent Realtime connections
</DefinitionTip>
</div>
{children && <span className="text-xs text-text-dimmed">{children}</span>}
</div>
</FeatureItem>
);
}
function MetricDashboards({ limits, children }: { limits: Limits; children?: React.ReactNode }) {
if (limits.metricDashboards.number === 0) {
return (
<FeatureItem>
<DefinitionTip
title={pricingDefinitions.metricDashboards.title}
content={pricingDefinitions.metricDashboards.content}
>
Custom dashboards
</DefinitionTip>
</FeatureItem>
);
}
return (
<FeatureItem checked>
<div className="flex flex-col gap-y-0.5">
<div className="flex items-center gap-1">
{limits.metricDashboards.number}
{limits.metricDashboards.canExceed ? "+" : ""}{" "}
<DefinitionTip
title={pricingDefinitions.metricDashboards.title}
content={pricingDefinitions.metricDashboards.content}
>
custom {limits.metricDashboards.number === 1 ? "dashboard" : "dashboards"}
</DefinitionTip>
</div>
{children && <span className="text-xs text-text-dimmed">{children}</span>}
</div>
</FeatureItem>
);
}
function QueryPeriod({ limits }: { limits: Limits }) {
return (
<FeatureItem checked>
{limits.queryPeriodDays.number} {limits.queryPeriodDays.number === 1 ? "day" : "days"}{" "}
<DefinitionTip
title={pricingDefinitions.queryPeriod.title}
content={pricingDefinitions.queryPeriod.content}
>
query period
</DefinitionTip>
</FeatureItem>
);
}
function HIPAAAddOn({
children,
checkedColor = "primary",
}: {
children?: React.ReactNode;
checkedColor?: "primary" | "bright";
}) {
return (
<FeatureItem checked checkedColor={checkedColor}>
<div className="flex flex-col gap-y-0.5">
<div>
<DefinitionTip
title={pricingDefinitions.hipaaBaa.title}
content={pricingDefinitions.hipaaBaa.content}
>
HIPAA BAA
</DefinitionTip>{" "}
add-on
</div>
{children && <span className="text-xs text-text-dimmed">{children}</span>}
</div>
</FeatureItem>
);
}
function Branches({ limits, children }: { limits: Limits; children?: React.ReactNode }) {
return (
<FeatureItem checked={limits.branches.number > 0}>
<div className="flex flex-col gap-y-0.5">
<div className="flex items-center gap-1">
{limits.branches.number > 0 && (
<>
{limits.branches.number}
{limits.branches.canExceed ? "+ " : " "}
</>
)}
<DefinitionTip
title={pricingDefinitions.branches.title}
content={pricingDefinitions.branches.content}
>
{limits.branches.number > 0 ? "preview" : "Preview"} branches
</DefinitionTip>
</div>
{children && <span className="text-xs text-text-dimmed">{children}</span>}
</div>
</FeatureItem>
);
}