1195 lines
40 KiB
TypeScript
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>
|
|
);
|
|
}
|