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 (
); } 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 ( {subscription?.plan !== undefined && subscription.plan.type !== "free" && subscription.canceledAt === undefined ? (
Downgrade plan?
Are you sure you want to downgrade? You will lose access to your current plan's features on{" "} .
Why are you thinking of downgrading?
    {[ "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) => (
  • { if (label === "Lacking features I need") { setIsLackingFeaturesChecked(isChecked); } }} />
  • ))}
{isLackingFeaturesChecked ? "What features do you need? Or how can we improve?" : "What can we do to improve?"}