Billing (#789)
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 0s
🚀 Publish Trigger.dev Docker / units (push) Failing after 0s
🚀 Publish Trigger.dev Docker / e2e (push) Failing after 5s
🚀 Publish Trigger.dev Docker / publish (push) Has been skipped

* Initial commit - started work on new billing components

* Added a slider component

* Added features to the pricing tiers

* Small pricing tier margin tweaks

* WIP on a concurrecy chart

* Reworked the pricing tiers to include a segmented controller and tooltips

* Renamed the charts storybook page

* Made the way data is added more flexible and added some definition tool tips

* Term definitions are used properly in the tiers

* Callouts can now have an optional CTA on the right hand side

* Alignment fix for the callouts

* organize imports

* Definition tooltip now its own component

* renamed the storybook story

* WIP new volume discount table and usage sliders

* Added pricing calculator sliders

* Fixed alignment of the legend

* Breadcrumb now has an upgrade prompt and button

* New Join our Slack button in the side menu

* New progress meter in the side menu

* Use the highest of 2 values to show progress

* An attempt to fix the step count in the calculator slider

* WIP usage progress bar

* Added the 4 progress bars

* More examples of the usage bar

* Better way to include the percentage in the free plan progress meter

* The usage bar now works with the extra runs over the free limit

* Pricing calculator has better slider logic

* Moved the free plan usage bar into it’s own component and added it to storybook

* Usage bar chart now supports a paying customer option and optional billing limit. Also added more usage examples to storybook

* Added more examples of usage to storybook

* tooltip takes classname

* Format numbers nicely

* Added a tooltip to show the precise numbers in the chart

* small improvements to the billing calculator

* New onboarding choose plan page

* pricing tiers better fill the size of their container

* Removed unused code

* Usage bars animate

* Wording tweak

* Callouts fit the button size better

* Added new routes for the 2 new billing pages

* import cleanup

* Added meta info in the header for bill price, plan type and billing period

* made free a noun

* Added pricing calculator to the plans page

* Fixed some illegal markup when using tooltips

* Added container query support

* Added new concurrency chart to the usage page

* billing now has a green theme

* Simplified the plan summary info int the header

* Fixed padding alignment

* Use a custom lable for the concurrent runs chart

* Removed the Job runs table

* Latest lockfile

* Show a message if you haven’t done runs yet

* Added a layoutId to the pageTabs

* Show a message callout if you’ve exceeeded 10k runs on the free plan

* Added a callout on the plans page if you’re over the runs limit

* Fixed button inside button bug

* Removed the background gradients from the app

* The billing package is importing properly

* Getting the curent plan for an org

* Reading the current plan and usage

* Hooked up the free plan bar

* Render basic billing details

* vol discount table has optional values

* Added a new page to show new subscribers

* Created a new hook for confetti on the subscribed page

* Toast styling updated

* The Invoice and Manage card details links are working

* Meta appEnv data optional

* Data for the plans page

* Format the billing period duration in days

* Switch to 20 icons

* URL for the subscribed page now includes the org path

* New pathBuilder path for the subscribed page

* Plans are upgraded/downgraded successfully

* Fixed badly named paths

* Improved some of the display

* Deal with when the user has canceled so they can re-upgrade

* Subscribing from Stripe is working, and canceling

* Tidied up some bits, latest billing package

* The tiers are now rendering using the real data

* The onboarding screen is hooked up, but not linked to yet

* Onboarding price selection working

* Pricing slider working

* Price estimation working

* Pricing calculator working on the select a plan page

* Button copy change

* Improved the layout of the run calculator marker

* Fix for the period end when you’ve canceled

* Improved the formatting in the calculator

* Pricing table tooltip uses the vol discount pricing table

* Remove the vertical lines from the calculator

* Contact us button in Enterprise tier opens the contact us form

* Added composite index to triggerdotdev_events.run_executions for event_time and organization_id

* Concurrent run chart data

* Improves styling, fix for React error with Enterprise contact button

* Show warning box when you’ve hit the concurrency in the past 30 days

* Lots of work o the usage page

* Improvements to the usage page

* Show 31 days of data, fix for not showing the current date…

* Stripe portal links are generated when the user clicks through

* Better alignment of the reference line and x-axis label

* Readme update

* Fixed pricing button loading buttons

* Show warnings about concurrency and runs on the plans page

* Removed some storybook stories

* Join Slack channel shows if you’re subscribed with instructions

* Added a gap between the runs charts

* Improved the definitions

* Added some margin to the page loading spinner

* A wider, cleaner feedback panel

* Modal backgrounds match the Sheet style

* Fixed menu item text being clipped

* Improved icons for execution time and exclusion count

* Concurrency chart now renders the dates nicely

* Fix for plans data on the plans page

* The healthcheck doesn’t need to do a HEAD request to /

* Better disabled states and fixed the disabled hover state issue

* Improved the segmented controller style

* loading spinner now centered inside the button

* Redirect to the project page when selecting the free plan

* Fix for “Runs” and added real date to upgrade warning

* DeCAPITALIZED some things

---------

Co-authored-by: James Ritchie <james@jamesritchie.co.uk>
This commit is contained in:
Matt Aitken
2023-12-13 12:48:26 +00:00
committed by GitHub
parent daae6df428
commit 4bd1786ae5
65 changed files with 3024 additions and 530 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 151 KiB

@@ -0,0 +1,33 @@
import { Header3 } from "./primitives/Headers";
import { Paragraph } from "./primitives/Paragraph";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./primitives/Tooltip";
export function DefinitionTip({
content,
children,
title,
}: {
content: React.ReactNode;
children: React.ReactNode;
title: React.ReactNode;
}) {
return (
<TooltipProvider>
<Tooltip disableHoverableContent>
<TooltipTrigger>
<span className="underline decoration-slate-600 decoration-dashed underline-offset-4 transition hover:decoration-slate-500">
{children}
</span>
</TooltipTrigger>
<TooltipContent align="end" side="right" variant="dark" className="w-[16rem] min-w-[16rem]">
<Header3 className="mb-1">{title}</Header3>
{typeof content === "string" ? (
<Paragraph variant="small">{content}</Paragraph>
) : (
<div>{content}</div>
)}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
+24 -23
View File
@@ -1,12 +1,15 @@
import { conform, useForm } from "@conform-to/react";
import { parse } from "@conform-to/zod";
import { ChevronRightIcon } from "@heroicons/react/24/solid";
import { Form, useActionData, useLocation, useNavigation } from "@remix-run/react";
import { DiscordIcon } from "@trigger.dev/companyicons";
import { ReactNode, useState } from "react";
import { FeedbackType, feedbackTypeLabel, schema } from "~/routes/resources.feedback";
import { Button } from "./primitives/Buttons";
import { Fieldset } from "./primitives/Fieldset";
import { FormButtons } from "./primitives/FormButtons";
import { FormError } from "./primitives/FormError";
import { Header2 } from "./primitives/Headers";
import { InputGroup } from "./primitives/InputGroup";
import { Label } from "./primitives/Label";
import { Paragraph } from "./primitives/Paragraph";
@@ -20,8 +23,6 @@ import {
} from "./primitives/Select";
import { Sheet, SheetBody, SheetContent, SheetHeader, SheetTrigger } from "./primitives/Sheet";
import { TextArea } from "./primitives/TextArea";
import { DiscordIcon } from "@trigger.dev/companyicons";
import { ChevronRightIcon } from "@heroicons/react/24/solid";
type FeedbackProps = {
button: ReactNode;
@@ -55,19 +56,16 @@ export function Feedback({ button, defaultValue = "bug" }: FeedbackProps) {
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild={true}>{button}</SheetTrigger>
<SheetContent size="sm">
<SheetHeader className="justify-between">Help & feedback</SheetHeader>
<SheetBody>
<SheetContent>
<SheetHeader className="justify-between">How can we help?</SheetHeader>
<SheetBody className="flex h-full flex-col justify-between">
<DiscordBanner />
<Paragraph variant="small" className="mb-4 border-t border-slate-800 pt-3">
Or use this form to ask for help or give us feedback. We read every message and will get
back to you as soon as we can.
</Paragraph>
<hr className="mb-3" />
<Header2 className="mb-4">Send us a message</Header2>
<Form method="post" action="/resources/feedback" {...form.props}>
<Fieldset className="max-w-full">
<input value={location.pathname} {...conform.input(path, { type: "hidden" })} />
<InputGroup className="max-w-full">
<Label>How can we help?</Label>
<SelectGroup>
<Select {...conform.input(feedbackType)} defaultValue={defaultValue}>
<SelectTrigger size="medium" width="full">
@@ -90,14 +88,19 @@ export function Feedback({ button, defaultValue = "bug" }: FeedbackProps) {
<FormError id={message.errorId}>{message.error}</FormError>
</InputGroup>
<FormError>{form.error}</FormError>
<FormButtons
className="w-full"
confirmButton={
<Button type="submit" variant="primary/medium">
Send
</Button>
}
/>
<div className="flex w-full items-center justify-between">
<Paragraph variant="small" className="w-full">
We read every message and respond quickly.
</Paragraph>
<FormButtons
className="m-0 w-max"
confirmButton={
<Button type="submit" variant="primary/medium">
Send
</Button>
}
/>
</div>
</Fieldset>
</Form>
</SheetBody>
@@ -109,7 +112,7 @@ export function Feedback({ button, defaultValue = "bug" }: FeedbackProps) {
function DiscordBanner() {
return (
<a
href="https://discord.gg/nkqV9xBYWy"
href="https://trigger.dev/discord"
target="_blank"
className="group mb-4 flex w-full items-center justify-between rounded-md border border-slate-600 bg-gradient-to-br from-blue-400/30 to-indigo-400/50 p-4 transition hover:border-indigo-400"
>
@@ -120,13 +123,11 @@ function DiscordBanner() {
<br />
Discord community
</h2>
<Paragraph variant="small">
<Paragraph variant="small/bright">
Get help or answer questions from the Trigger.dev community.
</Paragraph>
</div>
<div className="h-full">
<ChevronRightIcon className="h-5 w-5 text-slate-400 transition group-hover:translate-x-1 group-hover:text-indigo-400" />
</div>
<ChevronRightIcon className="h-5 w-5 text-slate-400 transition group-hover:translate-x-1 group-hover:text-indigo-400" />
</a>
);
}
@@ -1,12 +0,0 @@
import gradientBackground from "~/assets/images/gradient-background.png";
export function PageGradient({ children }: { children: React.ReactNode }) {
return (
<div
className="-mt-4 h-full w-full bg-cover bg-no-repeat pt-20"
style={{ backgroundImage: `url("${gradientBackground}")` }}
>
{children}
</div>
);
}
@@ -0,0 +1,114 @@
import {
Label,
Line,
LineChart,
ReferenceLine,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { Paragraph } from "../primitives/Paragraph";
const tooltipStyle = {
display: "flex",
alignItems: "center",
gap: "0.5rem",
borderRadius: "0.25rem",
border: "1px solid #1A2434",
backgroundColor: "#0B1018",
padding: "0.3rem 0.5rem",
fontSize: "0.75rem",
color: "#E2E8F0",
};
type DataItem = { date: Date; maxConcurrentRuns: number };
const dateFormatter = new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
});
export function ConcurrentRunsChart({
concurrentRunsLimit,
data,
hasConcurrencyData,
}: {
concurrentRunsLimit?: number;
data: DataItem[];
hasConcurrencyData: boolean;
}) {
return (
<div className="relative">
{!hasConcurrencyData && (
<Paragraph className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
No concurrent Runs to show
</Paragraph>
)}
<ResponsiveContainer width="100%" height="100%" className="relative min-h-[20rem]">
<LineChart
data={data}
margin={{
top: 20,
right: 0,
left: 0,
bottom: 10,
}}
className="-ml-8"
>
<XAxis
stroke="#94A3B8"
fontSize={12}
tickLine={false}
axisLine={false}
dataKey={(item: DataItem) => {
if (item.date.getDate() === 1) {
return dateFormatter.format(item.date);
}
return `${item.date.getDate()}`;
}}
className="text-xs"
>
<Label value="Last 30 days" offset={-8} position="insideBottom" fill="#94A3B8" />
</XAxis>
<YAxis stroke="#94A3B8" fontSize={12} tickLine={false} axisLine={false} />
<Tooltip
cursor={{ fill: "rgba(255,255,255,0.05)" }}
contentStyle={tooltipStyle}
labelFormatter={(value, data) => {
const date = data.at(0)?.payload.date;
if (!date) {
return "";
}
return dateFormatter.format(date);
}}
/>
{concurrentRunsLimit && (
<ReferenceLine
y={concurrentRunsLimit}
stroke="#F43F5E"
strokeWidth={1}
strokeDasharray={5}
ifOverflow="extendDomain"
className="text-xs"
>
<Label
value="Concurrent Runs limit"
offset={8}
position="insideTopLeft"
fill="#F43F5E"
/>
</ReferenceLine>
)}
<Line
dataKey="maxConcurrentRuns"
name="Concurrent runs"
stroke="#16A34A"
strokeWidth={2}
dot={false}
/>
</LineChart>
</ResponsiveContainer>
</div>
);
}
@@ -0,0 +1,40 @@
import { ArrowUpCircleIcon } from "@heroicons/react/24/outline";
import { motion, useMotionValue, useTransform } from "framer-motion";
import { Paragraph } from "../primitives/Paragraph";
import { Link } from "@remix-run/react";
import { cn } from "~/utils/cn";
export function FreePlanUsage({ to, percentage }: { to: string; percentage: number }) {
const cappedPercentage = Math.min(percentage, 1);
const widthProgress = useMotionValue(cappedPercentage * 100);
const color = useTransform(
widthProgress,
[0, 74, 75, 95, 100],
["#22C55E", "#22C55E", "#F59E0B", "#F43F5E", "#F43F5E"]
);
return (
<div className="rounded border border-slate-900 bg-[#101722] p-2.5">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1">
<ArrowUpCircleIcon className="h-5 w-5 text-dimmed" />
<Paragraph className="text-2sm text-bright">Free Plan</Paragraph>
</div>
<Link to={to} className="text-2sm text-indigo-500">
Learn more
</Link>
</div>
<div className="relative mt-3 h-1 rounded-full bg-[#0B1018]">
<motion.div
initial={{ width: 0 }}
animate={{ width: cappedPercentage * 100 + "%" }}
style={{
backgroundColor: color,
}}
transition={{ duration: 1, type: "spring" }}
className={cn("absolute left-0 top-0 h-full rounded-full")}
/>
</div>
</div>
);
}
@@ -0,0 +1,259 @@
import * as Slider from "@radix-ui/react-slider";
import { Plans, estimate } from "@trigger.dev/billing";
import { useCallback, useState } from "react";
import { DefinitionTip } from "../DefinitionTooltip";
import { Header2 } from "../primitives/Headers";
import { Paragraph } from "../primitives/Paragraph";
import { formatCurrency, formatNumberCompact } from "~/utils/numberFormatter";
import { cn } from "~/utils/cn";
export function PricingCalculator({ plans }: { plans: Plans }) {
const [selectedConcurrencyIndex, setSelectedConcurrencyIndex] = useState(0);
const concurrentRunTiers = [
{ code: "free", upto: plans.free.concurrentRuns?.freeAllowance! },
...(plans.paid.concurrentRuns?.pricing?.tiers ?? []),
];
const [runs, setRuns] = useState(0);
const runBrackets = [
...(plans.paid.runs?.pricing?.brackets.map((b, index, arr) => ({
unitCost: b.unitCost,
from: index === 0 ? 0 : arr[index - 1].upto! + 1,
upto: b.upto ?? arr[index - 1].upto! * 10,
})) ?? []),
];
const result = estimate({
usage: { runs, concurrent_runs: concurrentRunTiers[selectedConcurrencyIndex].upto - 1 },
plans: [plans.free, plans.paid],
});
return (
<div className="flex w-full flex-col gap-4">
<ConcurrentRunsSlider
options={concurrentRunTiers}
selectedIndex={selectedConcurrencyIndex}
setSelectedIndex={setSelectedConcurrencyIndex}
cost={result?.cost.concurrentRunCost ?? 0}
/>
<RunsSlider
brackets={runBrackets}
runs={runs}
setRuns={setRuns}
cost={result?.cost.runsCost ?? 0}
/>
<GrandTotal cost={result?.cost.total ?? 0} />
</div>
);
}
function ConcurrentRunsSlider({
options,
selectedIndex,
setSelectedIndex,
cost,
}: {
options: {
code: string;
upto: number;
}[];
selectedIndex: number;
setSelectedIndex: (index: number) => void;
cost: number;
}) {
const selectedOption = options[selectedIndex];
return (
<div>
<div className="flex">
<div className="flex w-full flex-col">
<div className="flex items-center justify-between">
<Header2>
<DefinitionTip
content="The number of runs that can be executed at the same time. Get in touch if you need more than 100."
title="Concurrent runs"
>
Concurrent runs
</DefinitionTip>
</Header2>
<Header2>Up to {selectedOption.upto}</Header2>
</div>
<Slider.Root
className="relative mb-2 mt-4 flex h-5 w-full touch-none select-none items-center"
value={[selectedIndex]}
onValueChange={(value) => setSelectedIndex(value[0])}
max={options.length - 1}
step={1}
>
<Slider.Track className="relative h-[8px] grow rounded-full bg-slate-850">
<Slider.Range className="absolute h-full rounded-full bg-indigo-500" />
</Slider.Track>
<Slider.Thumb
className="block h-5 w-5 rounded-full border-4 border-indigo-500 bg-slate-850 shadow-[0_1px_3px_4px_rgb(0_0_0_/_0.2),_0_1px_2px_-1px_rgb(0_0_0_/_0.1)] transition hover:border-indigo-400 hover:bg-slate-800 focus:shadow-[0_1px_3px_4px_rgb(0_0_0_/_0.2),_0_1px_2px_-1px_rgb(0_0_0_/_0.1)] focus:outline-none"
aria-label="Concurrent runs slider"
/>
</Slider.Root>
<div className="ml-1.5 flex w-[99.85%] items-center justify-between">
{options.map((tier, i) => {
return (
<Paragraph variant="extra-small" className="text-slate-600" key={i}>
{tier.upto}
</Paragraph>
);
})}
</div>
</div>
<div className="flex h-full items-start">
<span className="ml-6 text-dimmed">=</span>
<Header2 className="min-w-[8ch] text-right text-dimmed">
{formatCurrency(cost, true)}
</Header2>
</div>
</div>
<hr className="mt-6 border-border" />
</div>
);
}
const runIncrements = 10_000;
function RunsSlider({
brackets,
runs,
setRuns,
cost,
}: {
brackets: {
from: number;
upto: number;
unitCost: number;
}[];
runs: number;
setRuns: (value: number) => void;
cost: number;
}) {
const [value, setValue] = useState(0);
const updateRuns = useCallback((value: number) => {
setValue(value);
const r = calculateRuns(value / runIncrements, brackets);
setRuns(r);
}, []);
return (
<div>
<div className="flex">
<div className="flex w-full flex-col">
<div className="flex items-center justify-between">
<Header2>
<DefinitionTip content="A single execution of a Job." title="Runs">
Runs
</DefinitionTip>
</Header2>
<Header2>{formatNumberCompact(runs)}</Header2>
</div>
<Slider.Root
className="relative mb-2 mt-4 flex h-5 w-full touch-none select-none items-center"
value={[value]}
onValueChange={(value) => updateRuns(value[0])}
max={runIncrements}
step={1}
>
<Slider.Track className="relative h-[8px] grow rounded-full bg-slate-850">
<Slider.Range className="absolute h-full rounded-full bg-indigo-500" />
</Slider.Track>
<Slider.Thumb
className="block h-5 w-5 rounded-full border-4 border-indigo-500 bg-slate-850 shadow-[0_1px_3px_4px_rgb(0_0_0_/_0.2),_0_1px_2px_-1px_rgb(0_0_0_/_0.1)] transition hover:border-indigo-400 hover:bg-slate-800 focus:shadow-[0_1px_3px_4px_rgb(0_0_0_/_0.2),_0_1px_2px_-1px_rgb(0_0_0_/_0.1)] focus:outline-none"
aria-label="Concurrent runs slider"
/>
</Slider.Root>
<div className="relative w-full">
{brackets.map((bracket, i, arr) => {
const percentagePerBracket = 1 / arr.length;
return (
<SliderMarker
key={i}
percentage={(i / (arr.length - 1)) * percentagePerBracket * (arr.length - 1)}
alignment={i === 0 ? "left" : "center"}
text={formatNumberCompact(bracket.from)}
/>
);
})}
<SliderMarker
percentage={1}
alignment={"right"}
text={formatNumberCompact(brackets[brackets.length - 1].upto)}
/>
</div>
</div>
<div className="flex h-full items-start">
<span className="ml-6 text-dimmed">=</span>
<Header2 className="min-w-[8ch] text-right text-dimmed">
{formatCurrency(cost, true)}
</Header2>
</div>
</div>
<hr className="mt-6 border-border" />
</div>
);
}
function calculateRuns(percentage: number, brackets: { from: number; upto: number }[]) {
//first we find which bucket we're in
const buckets = brackets.length;
const bucket = Math.min(Math.floor(percentage * buckets), brackets.length - 1);
const percentagePerBucket = 1 / buckets;
//relevant bracket
let bracket = brackets[bucket];
const from = bracket.from;
const upto = bracket.upto;
//how far as we into the bracket
const percentageIntoBracket = (percentage - bucket * percentagePerBucket) / percentagePerBucket;
//calculate the runs
const runs = Math.floor(from + (upto - from) * percentageIntoBracket);
return runs;
}
function GrandTotal({ cost }: { cost: number }) {
return (
<div className="flex justify-between">
<Header2>Total monthly estimate</Header2>
<Header2>{formatCurrency(cost, true)}</Header2>
</div>
);
}
function SliderMarker({
percentage,
alignment,
text,
}: {
percentage: number;
alignment: "left" | "center" | "right";
text: string;
}) {
return (
<div
className="absolute top-0 h-4"
style={{
left: `${percentage * 100}%`,
}}
>
<div
className={cn(
"absolute flex items-center",
alignment === "left"
? "left-0 justify-start"
: alignment === "center"
? "-translate-x-1/2 justify-center"
: "justify-middle right-0"
)}
>
<Paragraph variant="extra-small" className="text-slate-600">
{text}
</Paragraph>
</div>
</div>
);
}
@@ -0,0 +1,519 @@
import { useForm } from "@conform-to/react";
import { parse } from "@conform-to/zod";
import { CheckIcon, XMarkIcon } from "@heroicons/react/24/solid";
import { Form, useActionData, useNavigation } from "@remix-run/react";
import { ActiveSubscription, Plan, Plans, SetPlanBodySchema } from "@trigger.dev/billing";
import { useState } from "react";
import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route";
import { cn } from "~/utils/cn";
import { formatNumberCompact } from "~/utils/numberFormatter";
import { DefinitionTip } from "../DefinitionTooltip";
import { Feedback } from "../Feedback";
import { Button, LinkButton } from "../primitives/Buttons";
import SegmentedControl from "../primitives/SegmentedControl";
import { RunsVolumeDiscountTable } from "./RunsVolumeDiscountTable";
import { Spinner } from "../primitives/Spinner";
const pricingDefinitions = {
concurrentRuns: {
title: "Concurrent runs",
content: "The number of runs that can be executed at the same time.",
},
jobRuns: {
title: "Job runs",
content: "A single execution of a job.",
},
jobs: {
title: "Jobs",
content: "A durable function that can be executed on a schedule or in response to an event.",
},
tasks: {
title: "Tasks",
content: "The individual building blocks of a job run.",
},
events: {
title: "Events",
content: "Events trigger jobs to start running.",
},
integrations: {
title: "Integrations",
content: "Easily subscribe to webhooks and perform actions using APIs.",
},
};
export function PricingTiers({
organizationSlug,
plans,
className,
showActionText = true,
freeButtonPath,
}: {
organizationSlug: string;
plans: Plans;
className?: string;
showActionText?: boolean;
freeButtonPath?: string;
}) {
const currentPlan = useCurrentPlan();
//if they've canceled, we set the subscription to undefined so they can re-upgrade
let currentSubscription = currentPlan?.subscription;
if (currentPlan?.subscription?.canceledAt) {
currentSubscription = undefined;
}
return (
<div
className={cn(
"flex min-w-full flex-col items-start justify-center gap-4 md:flex-row",
className
)}
>
<TierFree
plan={plans.free}
currentSubscription={currentSubscription}
organizationSlug={organizationSlug}
showActionText={showActionText}
buttonPath={freeButtonPath}
/>
<TierPro
plan={plans.paid}
currentSubscription={currentSubscription}
organizationSlug={organizationSlug}
showActionText={showActionText}
/>
<TierEnterprise />
</div>
);
}
export function TierFree({
plan,
organizationSlug,
showActionText,
currentSubscription,
buttonPath,
}: {
plan: Plan;
organizationSlug: string;
showActionText: boolean;
currentSubscription?: ActiveSubscription;
buttonPath?: string;
}) {
const lastSubmission = useActionData();
const [form] = useForm({
id: "subscribe",
// TODO: type this
lastSubmission: lastSubmission as any,
onValidate({ formData }) {
return parse(formData, { schema: SetPlanBodySchema });
},
});
const navigation = useNavigation();
const isLoading =
(navigation.state === "submitting" || navigation.state === "loading") &&
navigation.formData?.get("type") === "free";
const isCurrentPlan =
currentSubscription?.isPaying === undefined || currentSubscription?.isPaying === false;
let actionText = "Select plan";
if (showActionText) {
if (isCurrentPlan) {
actionText = "Current Plan";
} else {
actionText = "Downgrade";
}
}
return (
<TierContainer>
<Form action={`/resources/${organizationSlug}/subscribe`} method="post" {...form.props}>
<Header title={plan.title} cost={0} />
<TierLimit>
Up to {plan.concurrentRuns?.freeAllowance}{" "}
<DefinitionTip
title={pricingDefinitions.concurrentRuns.title}
content={pricingDefinitions.concurrentRuns.content}
>
{pricingDefinitions.concurrentRuns.title}
</DefinitionTip>
</TierLimit>
<input type="hidden" name="type" value="free" />
<div className="py-6">
{buttonPath ? (
<LinkButton
variant="secondary/large"
fullWidth
className="text-md font-medium"
to={buttonPath}
>
{actionText}
</LinkButton>
) : (
<Button
variant="secondary/large"
fullWidth
className="text-md font-medium"
disabled={isLoading || isCurrentPlan}
>
{isLoading ? (
<div className="flex items-center gap-2">
<Spinner color="white" />
Updating plan
</div>
) : (
actionText
)}
</Button>
)}
</div>
<ul className="flex flex-col gap-2.5">
<FeatureItem checked>
Up to {plan.runs?.freeAllowance ? formatNumberCompact(plan.runs.freeAllowance) : ""}{" "}
<DefinitionTip
title={pricingDefinitions.jobRuns.title}
content={pricingDefinitions.jobRuns.content}
>
{pricingDefinitions.jobRuns.title}
</DefinitionTip>
</FeatureItem>
<FeatureItem checked>
Unlimited{" "}
<DefinitionTip
title={pricingDefinitions.jobs.title}
content={pricingDefinitions.jobs.content}
>
jobs
</DefinitionTip>
</FeatureItem>
<FeatureItem checked>
Unlimited{" "}
<DefinitionTip
title={pricingDefinitions.tasks.title}
content={pricingDefinitions.tasks.content}
>
tasks
</DefinitionTip>
</FeatureItem>
<FeatureItem checked>
Unlimited{" "}
<DefinitionTip
title={pricingDefinitions.events.title}
content={pricingDefinitions.events.content}
>
events
</DefinitionTip>
</FeatureItem>
<FeatureItem checked>Unlimited team members</FeatureItem>
<FeatureItem checked>24 hour log retention</FeatureItem>
<FeatureItem checked>Community support</FeatureItem>
<FeatureItem>Custom integrations</FeatureItem>
<FeatureItem>Role-based access control</FeatureItem>
<FeatureItem>SSO</FeatureItem>
<FeatureItem>On-prem option</FeatureItem>
</ul>
</Form>
</TierContainer>
);
}
export function TierPro({
plan,
organizationSlug,
showActionText,
currentSubscription,
}: {
plan: Plan;
organizationSlug: string;
showActionText: boolean;
currentSubscription?: ActiveSubscription;
}) {
const lastSubmission = useActionData();
const [form] = useForm({
id: "subscribe",
// TODO: type this
lastSubmission: lastSubmission as any,
onValidate({ formData }) {
return parse(formData, { schema: SetPlanBodySchema });
},
});
const navigation = useNavigation();
const isLoading =
(navigation.state === "submitting" || navigation.state === "loading") &&
navigation.formData?.get("planCode") === plan.code;
const currentConcurrencyTier = currentSubscription?.plan.concurrentRuns.pricing?.code;
const [concurrentBracketCode, setConcurrentBracketCode] = useState(
currentConcurrencyTier ?? plan.concurrentRuns?.pricing?.tiers[0].code
);
const concurrencyTiers = plan.concurrentRuns?.pricing?.tiers ?? [];
const selectedTier = concurrencyTiers.find((c) => c.code === concurrentBracketCode);
const freeRunCount = plan.runs?.pricing?.brackets[0].upto ?? 0;
const mostExpensiveRunCost = plan.runs?.pricing?.brackets[1]?.unitCost ?? 0;
const isCurrentPlan = currentConcurrencyTier === concurrentBracketCode;
let actionText = "Select plan";
if (showActionText) {
if (isCurrentPlan) {
actionText = "Current Plan";
} else {
const currentTierIndex = concurrencyTiers.findIndex((c) => c.code === currentConcurrencyTier);
const selectedTierIndex = concurrencyTiers.findIndex((c) => c.code === concurrentBracketCode);
actionText = currentTierIndex < selectedTierIndex ? "Upgrade" : "Downgrade";
}
}
return (
<TierContainer isHighlighted>
<Form action={`/resources/${organizationSlug}/subscribe`} method="post" {...form.props}>
<Header title={plan.title} isHighlighted cost={selectedTier?.tierCost} />
<div className="mb-2 mt-6 font-sans text-sm font-normal text-bright">
<DefinitionTip
title={pricingDefinitions.concurrentRuns.title}
content={pricingDefinitions.concurrentRuns.content}
>
{pricingDefinitions.concurrentRuns.title}
</DefinitionTip>
</div>
<input type="hidden" name="type" value="paid" />
<input type="hidden" name="planCode" value={plan.code} />
<SegmentedControl
name="concurrentRunBracket"
options={concurrencyTiers.map((c) => ({ label: `Up to ${c.upto}`, value: c.code }))}
fullWidth
value={concurrentBracketCode}
onChange={(v) => setConcurrentBracketCode(v)}
/>
<div className="py-6">
<Button
variant="primary/large"
fullWidth
className="text-md font-medium"
type="submit"
disabled={isLoading || isCurrentPlan}
>
{isLoading ? (
<div className="flex items-center gap-2">
<Spinner color="white" />
Updating plan
</div>
) : (
actionText
)}
</Button>
</div>
<ul className="flex flex-col gap-2.5">
<FeatureItem checked>
Includes {freeRunCount ? formatNumberCompact(freeRunCount) : ""}{" "}
<DefinitionTip
title={pricingDefinitions.jobRuns.title}
content={pricingDefinitions.jobRuns.content}
>
{pricingDefinitions.jobRuns.title}
</DefinitionTip>
, then{" "}
<DefinitionTip
title="Runs volume discount"
content={
<RunsVolumeDiscountTable hideHeader brackets={plan.runs?.pricing?.brackets ?? []} />
}
>
{"<"} ${(mostExpensiveRunCost * 1000).toFixed(2)}/1K runs
</DefinitionTip>
</FeatureItem>
<FeatureItem checked>
Unlimited{" "}
<DefinitionTip
title={pricingDefinitions.jobs.title}
content={pricingDefinitions.jobs.content}
>
jobs
</DefinitionTip>
</FeatureItem>
<FeatureItem checked>
Unlimited{" "}
<DefinitionTip
title={pricingDefinitions.tasks.title}
content={pricingDefinitions.tasks.content}
>
tasks
</DefinitionTip>
</FeatureItem>
<FeatureItem checked>
Unlimited{" "}
<DefinitionTip
title={pricingDefinitions.events.title}
content={pricingDefinitions.events.content}
>
events
</DefinitionTip>
</FeatureItem>
<FeatureItem checked>Unlimited team members</FeatureItem>
<FeatureItem checked>7 day log retention</FeatureItem>
<FeatureItem checked>Dedicated Slack support</FeatureItem>
<FeatureItem>Custom integrations</FeatureItem>
<FeatureItem>Role-based access control</FeatureItem>
<FeatureItem>SSO</FeatureItem>
<FeatureItem>On-prem option</FeatureItem>
</ul>
</Form>
</TierContainer>
);
}
export function TierEnterprise() {
return (
<TierContainer>
<Header title="Enterprise" />
<TierLimit>
Flexible{" "}
<DefinitionTip
title={pricingDefinitions.concurrentRuns.title}
content={pricingDefinitions.concurrentRuns.content}
>
{pricingDefinitions.concurrentRuns.title}
</DefinitionTip>
</TierLimit>
<div className="py-6">
<Feedback
button={
<Button variant="secondary/large" fullWidth className="text-md font-medium">
Contact us
</Button>
}
defaultValue="enterprise"
/>
</div>
<ul className="flex flex-col gap-2.5">
<FeatureItem checked>
Flexible{" "}
<DefinitionTip
title={pricingDefinitions.jobRuns.title}
content={pricingDefinitions.jobRuns.content}
>
{pricingDefinitions.jobRuns.title}
</DefinitionTip>
</FeatureItem>
<FeatureItem checked>
Unlimited{" "}
<DefinitionTip
title={pricingDefinitions.jobs.title}
content={pricingDefinitions.jobs.content}
>
jobs
</DefinitionTip>
</FeatureItem>
<FeatureItem checked>
Unlimited{" "}
<DefinitionTip
title={pricingDefinitions.tasks.title}
content={pricingDefinitions.tasks.content}
>
tasks
</DefinitionTip>
</FeatureItem>
<FeatureItem checked>
Unlimited{" "}
<DefinitionTip
title={pricingDefinitions.events.title}
content={pricingDefinitions.events.content}
>
events
</DefinitionTip>
</FeatureItem>
<FeatureItem checked>Unlimited team members</FeatureItem>
<FeatureItem checked>30 day log retention</FeatureItem>
<FeatureItem checked>Priority support</FeatureItem>
<FeatureItem checked>
Custom{" "}
<DefinitionTip
title={pricingDefinitions.integrations.title}
content={pricingDefinitions.integrations.content}
>
{pricingDefinitions.integrations.title}
</DefinitionTip>
</FeatureItem>
<FeatureItem checked>Role-based access control</FeatureItem>
<FeatureItem checked>SSO</FeatureItem>
<FeatureItem checked>On-prem option</FeatureItem>
</ul>
</TierContainer>
);
}
function TierContainer({
children,
isHighlighted,
}: {
children: React.ReactNode;
isHighlighted?: boolean;
}) {
return (
<div
className={cn(
"flex w-full min-w-[16rem] flex-col rounded-md border p-6",
isHighlighted ? "border-indigo-500" : "border-border"
)}
>
{children}
</div>
);
}
function Header({
title,
cost: flatCost,
isHighlighted,
}: {
title: string;
cost?: number;
isHighlighted?: boolean;
}) {
return (
<div className="flex flex-col gap-2">
<h2 className={cn("text-xl font-medium", isHighlighted ? "text-indigo-500" : "text-dimmed")}>
{title}
</h2>
{flatCost === 0 || flatCost ? (
<h3 className="text-4xl font-medium">
${flatCost}
<span className="text-2sm font-normal tracking-wide text-dimmed">/month</span>
</h3>
) : (
<h2 className="text-4xl font-medium">Custom</h2>
)}
</div>
);
}
function TierLimit({ children }: { children: React.ReactNode }) {
return (
<div>
<hr className="my-[1.9rem]" />
<div className="mb-[0.6rem] mt-6 font-sans text-sm font-normal text-bright">{children}</div>
</div>
);
}
function FeatureItem({ checked, children }: { checked?: boolean; children: React.ReactNode }) {
return (
<li className="flex items-center gap-2">
{checked ? (
<CheckIcon className="h-4 w-4 text-green-500" />
) : (
<XMarkIcon className="h-4 w-4 text-slate-500" />
)}
<div className={cn("font-sans text-sm font-normal", checked ? "text-bright" : "text-dimmed")}>
{children}
</div>
</li>
);
}
@@ -0,0 +1,64 @@
import { RunPriceBracket } from "@trigger.dev/billing";
import { Header2 } from "../primitives/Headers";
import { Paragraph } from "../primitives/Paragraph";
import { formatNumberCompact } from "~/utils/numberFormatter";
export function RunsVolumeDiscountTable({
className,
hideHeader = false,
brackets,
}: {
className?: string;
hideHeader?: boolean;
brackets: RunPriceBracket[];
}) {
const runsVolumeDiscountRow =
"flex justify-between whitespace-nowrap border-b gap-16 border-border last:pb-0 last:border-none py-2";
const bracketData = bracketInfo(brackets);
return (
<div className={className}>
{hideHeader ? null : <Header2 className="mb-2">Runs volume discount</Header2>}
<ul>
{bracketData.map((bracket, index) => (
<li key={index} className={runsVolumeDiscountRow}>
<Paragraph variant="small">{bracket.range}</Paragraph>
<Paragraph variant="small">{bracket.costLabel}</Paragraph>
</li>
))}
</ul>
</div>
);
}
function bracketInfo(brackets: RunPriceBracket[]) {
return brackets.map((bracket, index) => {
const { upto, unitCost } = bracket;
if (index === 0) {
return {
range: `First ${formatNumberCompact(upto!)}/mo`,
costLabel: "Free",
};
}
const from = brackets[index - 1].upto;
const fromFormatted = formatNumberCompact(from!);
const toFormatted = upto ? formatNumberCompact(upto) : undefined;
const costLabel = `$${(unitCost * 1000).toFixed(2)}/1,000`;
if (!upto) {
return {
range: `${fromFormatted} +`,
costLabel,
};
}
return {
range: `${fromFormatted}${toFormatted}`,
costLabel,
};
});
}
@@ -0,0 +1,36 @@
import { ArrowUpCircleIcon } from "@heroicons/react/24/outline";
import { MatchedOrganization } from "~/hooks/useOrganizations";
import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route";
import { formatNumberCompact } from "~/utils/numberFormatter";
import { plansPath } from "~/utils/pathBuilder";
import { LinkButton } from "../primitives/Buttons";
import { Paragraph } from "../primitives/Paragraph";
type UpgradePromptProps = {
organization: MatchedOrganization;
};
export function UpgradePrompt({ organization }: UpgradePromptProps) {
const currentPlan = useCurrentPlan();
if (!currentPlan || !currentPlan.usage.exceededRunCount || !currentPlan.usage.runCountCap) {
return null;
}
return (
<div className="flex h-full w-full items-center gap-4 bg-gradient-to-r from-transparent to-indigo-900/50 pr-1.5">
<Paragraph variant="extra-small" className="text-rose-500">
You have exceeded the monthly {formatNumberCompact(currentPlan.usage.runCountCap)} runs
limit
</Paragraph>
<LinkButton
variant={"primary/small"}
LeadingIcon={ArrowUpCircleIcon}
leadingIconClassName="px-0"
to={plansPath(organization)}
>
Upgrade
</LinkButton>
</div>
);
}
@@ -0,0 +1,167 @@
import { cn } from "~/utils/cn";
import { formatNumberCompact } from "~/utils/numberFormatter";
import { Paragraph } from "../primitives/Paragraph";
import { SimpleTooltip } from "../primitives/Tooltip";
import { motion } from "framer-motion";
type UsageBarProps = {
numberOfCurrentRuns: number;
billingLimit?: number;
tierRunLimit?: number;
projectedRuns: number;
subscribedToPaidTier?: boolean;
};
export function UsageBar({
numberOfCurrentRuns,
billingLimit,
tierRunLimit,
projectedRuns,
subscribedToPaidTier = false,
}: UsageBarProps) {
const getLargestNumber = Math.max(
numberOfCurrentRuns,
tierRunLimit ?? -Infinity,
projectedRuns,
billingLimit ?? -Infinity
);
//creates a maximum range for the progress bar, add 10% to the largest number so the bar doesn't reach the end
const maxRange = Math.round(getLargestNumber * 1.1);
const tierRunLimitPercentage = tierRunLimit ? Math.round((tierRunLimit / maxRange) * 100) : 0;
const projectedRunsPercentage = Math.round((projectedRuns / maxRange) * 100);
const billingLimitPercentage =
billingLimit !== undefined ? Math.round((billingLimit / maxRange) * 100) : 0;
const usagePercentage = Math.round((numberOfCurrentRuns / maxRange) * 100);
//cap the usagePercentage to the freeRunLimitPercentage
const usageCappedToLimitPercentage = Math.min(usagePercentage, tierRunLimitPercentage);
return (
<div className="h-fit w-full py-16">
<div className="relative h-3 w-full rounded-sm bg-slate-800">
{billingLimit && (
<motion.div
initial={{ width: 0 }}
animate={{ width: billingLimitPercentage + "%" }}
transition={{ duration: 1.5, type: "spring" }}
style={{ width: `${billingLimitPercentage}%` }}
className="absolute h-3 rounded-l-sm"
>
<Legend
text="Billing limit:"
value={formatNumberCompact(billingLimit)}
position="bottomRow2"
percentage={billingLimitPercentage}
tooltipContent={`Billing limit: ${formatNumberCompact(billingLimit)}`}
/>
</motion.div>
)}
{tierRunLimit && (
<motion.div
initial={{ width: 0 }}
animate={{ width: tierRunLimitPercentage + "%" }}
transition={{ duration: 1.5, type: "spring" }}
style={{ width: `${tierRunLimitPercentage}%` }}
className="absolute h-3 rounded-l-sm bg-green-900/50"
>
<Legend
text={`${subscribedToPaidTier ? "Included free:" : "Free tier limit:"}`}
value={formatNumberCompact(tierRunLimit)}
position="bottomRow1"
percentage={tierRunLimitPercentage}
tooltipContent={`${
subscribedToPaidTier
? `Runs included free: ${formatNumberCompact(tierRunLimit)}`
: `Free tier runs limit: ${formatNumberCompact(tierRunLimit)}`
}`}
/>
</motion.div>
)}
{projectedRuns !== 0 && (
<motion.div
initial={{ width: 0 }}
animate={{ width: projectedRunsPercentage + "%" }}
transition={{ duration: 1.5, type: "spring" }}
style={{ width: `${projectedRunsPercentage}%` }}
className="absolute h-3 rounded-l-sm"
>
<Legend
text="Projected:"
value={formatNumberCompact(projectedRuns)}
position="topRow2"
percentage={projectedRunsPercentage}
tooltipContent={`Projected runs: ${formatNumberCompact(projectedRuns)}`}
/>
</motion.div>
)}
<motion.div
initial={{ width: 0 }}
animate={{ width: usagePercentage + "%" }}
transition={{ duration: 1.5, type: "spring" }}
style={{ width: `${usagePercentage}%` }}
className={cn(
"absolute h-3 rounded-l-sm",
subscribedToPaidTier ? "bg-green-600" : "bg-rose-600"
)}
>
<Legend
text="Current:"
value={formatNumberCompact(numberOfCurrentRuns)}
position="topRow1"
percentage={usagePercentage}
tooltipContent={`Current run count: ${formatNumberCompact(numberOfCurrentRuns)}`}
/>
</motion.div>
<motion.div
initial={{ width: 0 }}
animate={{ width: usageCappedToLimitPercentage + "%" }}
transition={{ duration: 1.5, type: "spring" }}
style={{ width: `${usageCappedToLimitPercentage}%` }}
className="absolute h-3 rounded-l-sm bg-green-600"
/>
</div>
</div>
);
}
const positions = {
topRow1: "bottom-0 h-9",
topRow2: "bottom-0 h-14",
bottomRow1: "top-0 h-9 items-end",
bottomRow2: "top-0 h-14 items-end",
};
type LegendProps = {
text: string;
value: number | string;
percentage: number;
position: keyof typeof positions;
tooltipContent: string;
};
function Legend({ text, value, position, percentage, tooltipContent }: LegendProps) {
const flipLegendPositionValue = 80;
const flipLegendPosition = percentage > flipLegendPositionValue ? true : false;
return (
<div
className={cn(
"absolute left-full z-10 flex border-slate-400",
positions[position],
flipLegendPosition === true ? "-translate-x-full border-r" : "border-l"
)}
>
<SimpleTooltip
button={
<Paragraph className="mr-px h-fit whitespace-nowrap bg-background px-1.5 text-xs text-dimmed">
{text}
<span className="ml-1 text-bright">{value}</span>
</Paragraph>
}
variant="dark"
side="top"
content={tooltipContent}
className="z-50 h-fit"
/>
</div>
);
}
@@ -1,42 +1,8 @@
import { cn } from "~/utils/cn";
import gradientPath from "./app-container-gradient.svg";
/** This container is used to surround the entire app, it correctly places the nav bar */
export function AppContainer({
children,
showBackgroundGradient,
}: {
children: React.ReactNode;
showBackgroundGradient?: boolean;
}) {
return (
<BackgroundGradient showBackgroundGradient={showBackgroundGradient}>
<div className={cn("grid h-full w-full grid-rows-1 overflow-hidden")}>{children}</div>
</BackgroundGradient>
);
}
export function BackgroundGradient({
children,
showBackgroundGradient,
}: {
children: React.ReactNode;
showBackgroundGradient?: boolean;
}) {
return (
<div
className={cn("h-full w-full bg-contain bg-right-top bg-no-repeat")}
style={
showBackgroundGradient
? {
backgroundImage: `url(${gradientPath})`,
}
: undefined
}
>
{children}
</div>
);
export function AppContainer({ children }: { children: React.ReactNode }) {
return <div className={cn("grid h-full w-full grid-rows-1 overflow-hidden")}>{children}</div>;
}
/** This container should be placed around the content on a page */
@@ -1,9 +1,10 @@
import { useNavigation } from "@remix-run/react";
import { Spinner } from "../primitives/Spinner";
import { cn } from "~/utils/cn";
export function PageNavigationIndicator() {
export function PageNavigationIndicator({ className }: { className?: string }) {
const navigation = useNavigation();
if (navigation.state === "loading") {
return <Spinner color="muted" className="h-4 w-4" />;
return <Spinner color="muted" className={cn("h-4 w-4", className)} />;
}
}
@@ -1,5 +1,6 @@
import {
AcademicCapIcon,
ArrowRightIcon,
ArrowRightOnRectangleIcon,
ChartBarIcon,
EllipsisHorizontalIcon,
@@ -7,11 +8,14 @@ import {
import { UserGroupIcon, UserPlusIcon } from "@heroicons/react/24/solid";
import { useNavigation } from "@remix-run/react";
import { IconExclamationCircle } from "@tabler/icons-react";
import { DiscordIcon, SlackIcon } from "@trigger.dev/companyicons";
import { AnchorHTMLAttributes, Fragment, useEffect, useRef, useState } from "react";
import { useFeatures } from "~/hooks/useFeatures";
import { MatchedOrganization } from "~/hooks/useOrganizations";
import { usePathName } from "~/hooks/usePathName";
import { MatchedProject } from "~/hooks/useProject";
import { User } from "~/models/user.server";
import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route";
import { cn } from "~/utils/cn";
import {
accountPath,
@@ -33,8 +37,12 @@ import {
import { Feedback } from "../Feedback";
import { ImpersonationBanner } from "../ImpersonationBanner";
import { LogoIcon } from "../LogoIcon";
import { UserAvatar, UserProfilePhoto } from "../UserProfilePhoto";
import { StepContentContainer } from "../StepContentContainer";
import { UserProfilePhoto } from "../UserProfilePhoto";
import { FreePlanUsage } from "../billing/FreePlanUsage";
import { Button, LinkButton } from "../primitives/Buttons";
import { ClipboardField } from "../primitives/ClipboardField";
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "../primitives/Dialog";
import { Icon } from "../primitives/Icon";
import { type IconNames } from "../primitives/NamedIcon";
import { Paragraph } from "../primitives/Paragraph";
@@ -46,8 +54,8 @@ import {
PopoverMenuItem,
PopoverSectionHeader,
} from "../primitives/Popover";
import { StepNumber } from "../primitives/StepNumber";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../primitives/Tooltip";
import { useFeatures } from "~/hooks/useFeatures";
type SideMenuUser = Pick<User, "email" | "admin"> & { isImpersonating: boolean };
type SideMenuProject = Pick<
@@ -66,6 +74,7 @@ export function SideMenu({ user, project, organization, organizations }: SideMen
const borderRef = useRef<HTMLDivElement>(null);
const [showHeaderDivider, setShowHeaderDivider] = useState(false);
const { isManagedCloud } = useFeatures();
const currentPlan = useCurrentPlan();
useEffect(() => {
const handleScroll = () => {
@@ -138,7 +147,7 @@ export function SideMenu({ user, project, organization, organizations }: SideMen
<SideMenuItem
name="HTTP endpoints"
icon="http-endpoint"
iconColor="text-green-500"
iconColor="text-pink-500"
count={project.httpEndpointCount}
to={projectHttpEndpointsPath(organization, project)}
data-action="httpendpoints"
@@ -185,12 +194,78 @@ export function SideMenu({ user, project, organization, organizations }: SideMen
name={isManagedCloud ? "Usage & Billing" : "Usage"}
icon={ChartBarIcon}
to={organizationBillingPath(organization)}
iconColor="text-pink-500"
iconColor="text-green-600"
data-action="usage & billing"
/>
</div>
</div>
<div className="flex flex-col gap-1 border-t border-border p-1">
{currentPlan?.subscription?.isPaying === true ? (
<Dialog>
<DialogTrigger asChild>
<Button
variant="small-menu-item"
LeadingIcon={SlackIcon}
data-action="join our slack"
fullWidth
textAlignLeft
>
Join our Slack
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>Join our Slack</DialogHeader>
<div className="mt-2 flex flex-col gap-4">
<div className="flex items-center gap-4">
<Icon icon={SlackIcon} className="h-10 w-10 min-w-[2.5rem]" />
<Paragraph variant="base/bright">
As a subscriber, you have access to a dedicated Slack channel for 1-to-1
support with the Trigger.dev team.
</Paragraph>
</div>
<hr className="border-slate-800" />
<div>
<StepNumber stepNumber="1" title="Create a new Slack channel" />
<StepContentContainer>
<Paragraph>
In your Slack app, create a new channel from the main menu by going to File{" "}
<ArrowRightIcon className="inline h-4 w-4 text-dimmed" /> New Channel
</Paragraph>
</StepContentContainer>
<StepNumber stepNumber="2" title="Setup your channel" />
<StepContentContainer>
<Paragraph>
Name your channel, set its visibility and click 'Create'.
</Paragraph>
</StepContentContainer>
<StepNumber stepNumber="3" title="Invite Trigger.dev" />
<StepContentContainer>
<Paragraph>
Invite this email address to your channel:{" "}
<ClipboardField
variant="primary/medium"
value="james@trigger.dev"
className="my-2"
/>
</Paragraph>
<Paragraph>
As soon as we can, we'll accept your invitation and say hello!
</Paragraph>
</StepContentContainer>
</div>
</div>
</DialogContent>
</Dialog>
) : (
<SideMenuItem
name="Join our Discord"
icon={DiscordIcon}
to="https://trigger.dev/discord"
data-action="join our discord"
target="_blank"
/>
)}
<SideMenuItem
name="Documentation"
icon="docs"
@@ -219,6 +294,12 @@ export function SideMenu({ user, project, organization, organizations }: SideMen
</Button>
}
/>
{currentPlan && !currentPlan.subscription?.isPaying && currentPlan.usage.runCountCap && (
<FreePlanUsage
to={organizationBillingPath(organization)}
percentage={currentPlan.usage.currentRunCount / currentPlan.usage.runCountCap}
/>
)}
</div>
</div>
</div>
@@ -409,8 +490,8 @@ function SideMenuItem({
isActive ? "bg-slate-850 text-bright" : "group-hover:text-bright"
)}
>
<div className="flex w-full items-center justify-between overflow-hidden">
<span className="truncate">{name}</span>
<div className="flex w-full items-center justify-between">
{name}
<div className="flex items-center gap-1">
{count !== undefined && count > 0 && <MenuCount count={count} />}
{typeof hasWarning === "string" ? (
@@ -82,16 +82,16 @@ const variant = {
"primary/large": {
textColor: "text-bright group-hover:text-white transition group-disabled:text-dimmed/80 px-1",
button:
"h-10 px-2 text-sm font-medium bg-indigo-600 group-hover:bg-indigo-500/90 group-disabled:opacity-50",
"h-10 px-2 text-sm font-medium bg-indigo-600 group-hover:bg-indigo-500/90 group-disabled:opacity-50 group-disabled:group-hover:bg-indigo-600",
icon: "h-5",
iconSpacing: "gap-x-0.5",
shortcutVariant: undefined,
shortcut: undefined,
},
"secondary/large": {
textColor: "text-dimmed px-1",
textColor: "text-bright group-disabled:text-dimmed px-1",
button:
"h-10 px-2 text-sm text-dimmed group-hover:text-bright transition font-medium bg-slate-800 group-hover:bg-slate-700/70 disabled:opacity-50",
"h-10 px-2 text-sm transition font-medium bg-slate-800 group-hover:bg-slate-700/70 group-disabled:opacity-40 group-disabled:group-hover:bg-slate-800",
icon: "h-5",
iconSpacing: "gap-x-0.5",
shortcutVariant: undefined,
@@ -7,6 +7,7 @@ import {
import {
ArrowTopRightOnSquareIcon,
BookOpenIcon,
ChartBarIcon,
CheckCircleIcon,
ChevronRightIcon,
} from "@heroicons/react/24/solid";
@@ -58,6 +59,12 @@ export const variantClasses = {
textColor: "text-blue-300",
linkClassName: "transition hover:bg-blue-400/40",
},
pricing: {
className: "border-indigo-400/20 bg-indigo-800/30",
icon: <ChartBarIcon className="h-5 w-5 shrink-0 text-indigo-500" />,
textColor: "text-indigo-200",
linkClassName: "transition hover:bg-indigo-400/40",
},
} as const;
export type CalloutVariant = keyof typeof variantClasses;
@@ -66,12 +73,14 @@ export function Callout({
children,
className,
icon,
cta,
variant,
to,
}: {
children?: React.ReactNode;
className?: string;
icon?: React.ReactNode;
cta?: React.ReactNode;
variant: CalloutVariant;
to?: string;
}) {
@@ -84,13 +93,13 @@ export function Callout({
href={to}
target="_blank"
className={cn(
`flex w-full items-start justify-between gap-2.5 rounded-md border py-2 pl-2 pr-3 shadow-md backdrop-blur-sm`,
`flex w-full items-center justify-between gap-2.5 rounded-md border py-2 pl-2 pr-3 shadow-md backdrop-blur-sm`,
variantDefinition.className,
variantDefinition.linkClassName,
className
)}
>
<div className={"flex w-full items-start gap-x-2"}>
<div className={"flex w-full items-center gap-x-2"}>
{icon ? icon : variantDefinition.icon}
{typeof children === "string" ? (
@@ -135,20 +144,24 @@ export function Callout({
return (
<div
className={cn(
`flex w-full items-start gap-2.5 rounded-md border py-2 pl-2 pr-3 shadow-md backdrop-blur-sm`,
"flex w-full items-center gap-2 rounded-md border pl-2 pr-2 shadow-md backdrop-blur-sm",
cta ? "py-2" : "py-2.5",
variantDefinition.className,
className
)}
>
{icon ? icon : variantDefinition.icon}
<div className={cn(`flex w-full items-center gap-2.5`)}>
{icon ? icon : variantDefinition.icon}
{typeof children === "string" ? (
<Paragraph variant={"small"} className={variantDefinition.textColor}>
{children}
</Paragraph>
) : (
children
)}
{typeof children === "string" ? (
<Paragraph variant={"small"} className={variantDefinition.textColor}>
{children}
</Paragraph>
) : (
children
)}
</div>
{cta && cta}
</div>
);
}
@@ -5,14 +5,26 @@ type DateTimeProps = {
date: Date | string;
timeZone?: string;
includeSeconds?: boolean;
includeTime?: boolean;
};
export const DateTime = ({ date, timeZone = "UTC", includeSeconds = true }: DateTimeProps) => {
export const DateTime = ({
date,
timeZone = "UTC",
includeSeconds = true,
includeTime = true,
}: DateTimeProps) => {
const locales = useLocales();
const realDate = typeof date === "string" ? new Date(date) : date;
const initialFormattedDateTime = formatDateTime(realDate, timeZone, locales, includeSeconds);
const initialFormattedDateTime = formatDateTime(
realDate,
timeZone,
locales,
includeSeconds,
includeTime
);
const [formattedDateTime, setFormattedDateTime] = useState<string>(initialFormattedDateTime);
@@ -20,26 +32,27 @@ export const DateTime = ({ date, timeZone = "UTC", includeSeconds = true }: Date
const resolvedOptions = Intl.DateTimeFormat().resolvedOptions();
setFormattedDateTime(
formatDateTime(realDate, resolvedOptions.timeZone, locales, includeSeconds)
formatDateTime(realDate, resolvedOptions.timeZone, locales, includeSeconds, includeTime)
);
}, [locales, includeSeconds, realDate]);
return <Fragment>{formattedDateTime.replace(/\s/g, String.fromCharCode(32))}</Fragment>;
};
function formatDateTime(
export function formatDateTime(
date: Date,
timeZone: string,
locales: string[],
includeSeconds: boolean
includeSeconds: boolean,
includeTime: boolean
): string {
return new Intl.DateTimeFormat(locales, {
year: "numeric",
month: "short",
day: "numeric",
hour: "numeric",
minute: "numeric",
second: includeSeconds ? "numeric" : undefined,
hour: includeTime ? "numeric" : undefined,
minute: includeTime ? "numeric" : undefined,
second: includeTime && includeSeconds ? "numeric" : undefined,
timeZone,
}).format(date);
}
@@ -26,7 +26,7 @@ const DialogOverlay = React.forwardRef<
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-background/80 backdrop-blur-sm transition-all duration-100 data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=open]:fade-in",
"fixed inset-0 z-50 bg-background/90 backdrop-blur-sm transition-all duration-100 data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=open]:fade-in",
className
)}
{...props}
@@ -43,7 +43,7 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed z-50 grid w-full gap-4 rounded-b-lg border bg-midnight-800 px-4 pb-6 pt-3 shadow-lg animate-in data-[state=open]:fade-in-90 data-[state=open]:slide-in-from-bottom-10 sm:max-w-lg sm:rounded-lg sm:zoom-in-90 data-[state=open]:sm:slide-in-from-bottom-0",
"fixed z-50 grid w-full gap-4 rounded-b-lg border bg-background px-4 pb-6 pt-3 shadow-lg animate-in data-[state=open]:fade-in-90 data-[state=open]:slide-in-from-bottom-10 sm:max-w-lg sm:rounded-lg sm:zoom-in-90 data-[state=open]:sm:slide-in-from-bottom-0",
className
)}
{...props}
@@ -1,72 +0,0 @@
import { RadioGroup } from "@headlessui/react";
import { cn } from "~/utils/cn";
type Options = {
label: string;
value: string;
};
type FormSegmentedControlProps = {
name: string;
defaultValue?: string;
options: Options[];
onChange?: (value: string) => void;
};
export default function FormSegmentedControl({
name,
defaultValue,
options,
onChange,
}: FormSegmentedControlProps) {
return (
<div className="flex h-8 w-fit rounded bg-slate-800 p-1">
<RadioGroup
defaultValue={defaultValue ?? options[0].value}
name={name}
onChange={(c: string) => {
if (onChange) {
onChange(c);
}
}}
>
<div className="flex gap-x-1.5">
{options.map((option) => (
<RadioGroup.Option
key={option.value}
value={option.value}
className={({ active, checked }) =>
cn(
"relative flex cursor-pointer rounded-[2px] px-3 py-[0.13rem] shadow-md focus:outline-none",
active
? "focus-visible:ring focus-visible:ring-indigo-500 focus-visible:ring-opacity-60"
: "",
checked
? "bg-slate-700 text-bright"
: "bg-transparent transition hover:bg-slate-750"
)
}
>
{({ checked }) => (
<>
<div className="flex w-full items-center justify-between">
<div className="flex items-center">
<div className="text-sm">
<RadioGroup.Label
as="p"
className={cn("font-normal", checked ? "text-bright" : "text-dimmed")}
>
{option.label}
</RadioGroup.Label>
</div>
</div>
</div>
</>
)}
</RadioGroup.Option>
))}
</div>
</RadioGroup>
</div>
);
}
@@ -0,0 +1,79 @@
import { RadioGroup } from "@headlessui/react";
import { motion } from "framer-motion";
import { cn } from "~/utils/cn";
type Options = {
label: string;
value: string;
};
type SegmentedControlProps = {
name: string;
value?: string;
defaultValue?: string;
options: Options[];
fullWidth?: boolean;
onChange?: (value: string) => void;
};
export default function SegmentedControl({
name,
value,
defaultValue,
options,
fullWidth,
onChange,
}: SegmentedControlProps) {
return (
<div className={cn("flex h-10 rounded bg-slate-850", fullWidth ? "w-full" : "w-fit")}>
<RadioGroup
value={value}
defaultValue={defaultValue ?? options[0].value}
name={name}
onChange={(c: string) => {
if (onChange) {
onChange(c);
}
}}
className="w-full"
>
<div className="flex h-full w-full items-center justify-between gap-x-1 p-1">
{options.map((option) => (
<RadioGroup.Option
key={option.value}
value={option.value}
className={({ active, checked }) =>
cn(
"relative flex h-full grow cursor-pointer text-center font-normal focus:outline-none",
active
? "ring-offset-2 focus-visible:ring focus-visible:ring-indigo-500 focus-visible:ring-opacity-60"
: "",
checked
? "text-bright"
: "rounded-[2px] text-dimmed transition hover:bg-slate-750/50 hover:text-bright"
)
}
>
{({ checked }) => (
<>
<div className="relative flex h-full w-full items-center justify-between px-3 py-[0.13rem]">
<div className="z-10 flex h-full w-full items-center justify-center text-sm">
<RadioGroup.Label as="p">{option.label}</RadioGroup.Label>
</div>
{checked && (
<motion.div
layoutId={`segmented-control-${name}`}
transition={{ duration: 0.4, type: "spring" }}
className="absolute inset-0 rounded-[2px] bg-indigo-600 shadow-md"
></motion.div>
)}
</div>
</>
)}
</RadioGroup.Option>
))}
</div>
</RadioGroup>
</div>
);
}
+11 -13
View File
@@ -5,6 +5,7 @@ import { Toaster, toast } from "sonner";
import { useTypedLoaderData } from "remix-typedjson";
import { loader } from "~/root";
import { useEffect } from "react";
import { Paragraph } from "./Paragraph";
const defaultToastDuration = 5000;
const permanentToastDuration = 60 * 60 * 24 * 1000;
@@ -38,26 +39,23 @@ export function ToastUI({
}) {
return (
<div
className={`self-end rounded-lg border border-slate-750 bg-midnight-900 shadow-md`}
className={`self-end rounded-md border border-border bg-background`}
style={{
width: toastWidth,
}}
>
<div
className="flex w-full gap-2 rounded-lg bg-no-repeat p-4 text-bright"
style={{
background:
"radial-gradient(at top, hsla(271, 91%, 65%, 0.18), hsla(221, 83%, 53%, 0.18)) hsla(221, 83%, 53%, 0.18)",
}}
>
<div className="flex w-full items-start gap-2 rounded-lg p-3">
{variant === "success" ? (
<CheckCircleIcon className="h-6 w-6 text-green-600" />
<CheckCircleIcon className="mt-1 h-6 min-h-[1.5rem] w-6 min-w-[1.5rem] text-green-600" />
) : (
<ExclamationCircleIcon className="h-6 w-6 text-rose-600" />
<ExclamationCircleIcon className="mt-1 h-6 w-6 min-w-[1.5rem] text-rose-600" />
)}
{message}
<button className="ms-auto p-1" onClick={() => toast.dismiss(t)}>
<XMarkIcon className="h-4 w-4 text-bright" />
<Paragraph className="py-1 text-dimmed">{message}</Paragraph>
<button
className="ms-auto rounded p-2 text-dimmed transition hover:bg-midnight-800 hover:text-bright"
onClick={() => toast.dismiss(t)}
>
<XMarkIcon className="h-4 w-4" />
</button>
</div>
</div>
@@ -1,9 +1,15 @@
"use client";
import * as React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { cn } from "~/utils/cn";
const variantClasses = {
basic:
"bg-popover border border-slate-800 rounded-md px-3 py-1.5 text-sm text-bright shadow-md fade-in-50",
dark: "bg-background border border-border rounded px-3 py-2 text-sm text-bright shadow-md fade-in-50",
};
type Variant = keyof typeof variantClasses;
const TooltipProvider = TooltipPrimitive.Provider;
const TooltipArrow = React.forwardRef<
@@ -22,15 +28,20 @@ Tooltip.displayName = TooltipPrimitive.Root.displayName;
const TooltipTrigger = TooltipPrimitive.Trigger;
type TooltipContentProps = {
variant?: Variant;
} & React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>;
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
TooltipContentProps
>(({ className, sideOffset = 4, variant = "basic", ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md border border-slate-800 bg-popover px-3 py-1.5 text-sm text-bright shadow-md animate-in fade-in-50 data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1",
"z-50 overflow-hidden animate-in data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1",
variantClasses[variant],
className
)}
{...props}
@@ -43,17 +54,26 @@ function SimpleTooltip({
content,
side,
hidden,
variant,
className,
}: {
button: React.ReactNode;
content: React.ReactNode;
side?: React.ComponentProps<typeof TooltipContent>["side"];
hidden?: boolean;
variant?: Variant;
className?: string;
}) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger>{button}</TooltipTrigger>
<TooltipContent side={side} hidden={hidden} className="text-xs">
<TooltipTrigger className="h-fit">{button}</TooltipTrigger>
<TooltipContent
side={side}
hidden={hidden}
className={cn("text-xs", className)}
variant={variant}
>
{content}
</TooltipContent>
</Tooltip>
@@ -27,6 +27,7 @@ import { Button } from "../primitives/Buttons";
import { Callout } from "../primitives/Callout";
import { DateTime } from "../primitives/DateTime";
import { Header2 } from "../primitives/Headers";
import { Icon } from "../primitives/Icon";
import { NamedIcon } from "../primitives/NamedIcon";
import {
PageButtons,
@@ -143,18 +144,18 @@ export function RunOverview({ run, trigger, showRerun, paths }: RunOverviewProps
value={formatDuration(run.startedAt, run.completedAt, { style: "short" })}
/>
<PageInfoProperty
icon={"hourglass"}
icon={<Icon icon="alarm-filled" className="h-4 w-4 text-blue-500" />}
label={"Execution Time"}
value={formatDurationMilliseconds(run.executionDuration, { style: "short" })}
/>
<PageInfoProperty
icon={"list-numbers"}
icon={<Icon icon="list-numbers" className="h-4 w-4 text-yellow-500" />}
label={"Execution Count"}
value={<>{run.executionCount}</>}
/>
</PageInfoGroup>
<PageInfoGroup alignment="right">
<Paragraph variant="extra-small" className="text-slate-600">
<Paragraph variant="extra-small" className="whitespace-nowrap text-slate-600">
RUN ID: {run.id}
</Paragraph>
</PageInfoGroup>
@@ -73,11 +73,11 @@ export function RunsTable({
<TableBody>
{total === 0 && !hasFilters ? (
<TableBlankRow colSpan={showJob ? 10 : 9}>
<NoRuns title="No Runs found" />
<NoRuns title="No runs found" />
</TableBlankRow>
) : runs.length === 0 ? (
<TableBlankRow colSpan={showJob ? 10 : 9}>
<NoRuns title="No Runs match your filters" />
<NoRuns title="No runs match your filters" />
</TableBlankRow>
) : (
runs.map((run) => {
@@ -61,11 +61,11 @@ export function WebhookDeliveryRunsTable({
<TableBody>
{total === 0 && !hasFilters ? (
<TableBlankRow colSpan={8}>
<NoRuns title="No Runs found for this Job" />
<NoRuns title="No runs found for this job" />
</TableBlankRow>
) : runs.length === 0 ? (
<TableBlankRow colSpan={8}>
<NoRuns title="No Runs match your filters" />
<NoRuns title="No runs match your filters" />
</TableBlankRow>
) : (
runs.map((run) => {
@@ -0,0 +1,46 @@
import type { Meta, StoryObj } from "@storybook/react";
import { FreePlanUsage } from "../billing/FreePlanUsage";
import { organizationBillingPath } from "~/utils/pathBuilder";
import { MatchedOrganization } from "~/hooks/useOrganizations";
const meta: Meta<typeof FreePlanUsageBar> = {
title: "Billing/FreePlanUsage",
component: FreePlanUsageBar,
};
export default meta;
type Story = StoryObj<typeof FreePlanUsageBar>;
const mockOrganization: MatchedOrganization = {
id: "mockID",
title: "mockTitle",
slug: "mockSlug",
projects: [
{ id: "mockId1", slug: "mockSlug1", name: "mockName1", jobCount: 1 },
{ id: "mockId2", slug: "mockSlug2", name: "mockName2", jobCount: 2 },
],
hasUnconfiguredIntegrations: false,
memberCount: 1,
};
export const ProgressBar: Story = {
args: {
organization: mockOrganization,
},
render: (args) => <FreePlanUsageBar {...args} />,
};
type FreePlanUsageBarProps = {
organization: MatchedOrganization;
};
function FreePlanUsageBar({ organization }: FreePlanUsageBarProps) {
return (
<div className="flex h-screen flex-col items-center justify-center p-12">
<div className="w-fit">
<FreePlanUsage to={organizationBillingPath(organization)} percentage={0.75} />
</div>
</div>
);
}
@@ -0,0 +1,39 @@
import { ArrowUpCircleIcon } from "@heroicons/react/24/outline";
import type { Meta, StoryObj } from "@storybook/react";
import { LinkButton } from "../primitives/Buttons";
import { Callout } from "../primitives/Callout";
const meta: Meta<typeof PricingCallouts> = {
title: "Billing/PricingCallouts",
component: PricingCallouts,
};
export default meta;
type Story = StoryObj<typeof PricingCallouts>;
export const Callouts: Story = {
render: (args) => <PricingCallouts />,
};
function PricingCallouts() {
return (
<div className="mx-4 flex h-screen flex-col items-center justify-center gap-4">
<Callout
variant={"pricing"}
cta={
<LinkButton
variant={"primary/small"}
LeadingIcon={ArrowUpCircleIcon}
leadingIconClassName="pr-0 pl-0.5"
to="#"
>
Upgrade
</LinkButton>
}
>
Some of your runs are being queued because your run concurrency is limited to 50.
</Callout>
</div>
);
}
@@ -1,19 +1,20 @@
import type { Meta, StoryObj } from "@storybook/react";
import { withDesign } from "storybook-addon-designs";
import FormSegmentedControl from "../primitives/FormSegmentedControl";
import { MainCenteredContainer } from "../layout/AppLayout";
import SegmentedControl from "../primitives/SegmentedControl";
const meta: Meta = {
title: "Primitives/FormSegmentedControl",
const meta: Meta<typeof StyledSegmentedControl> = {
title: "Primitives/SegmentedControl",
decorators: [withDesign],
component: StyledSegmentedControl,
};
export default meta;
type Story = StoryObj<typeof FormSegmentedControl>;
type Story = StoryObj<typeof StyledSegmentedControl>;
export const Basic: Story = {
render: () => <SegmentedControl />,
render: () => <StyledSegmentedControl />,
};
Basic.parameters = {
@@ -28,10 +29,10 @@ const options = [
{ label: "Label 2", value: "Users" },
];
function SegmentedControl() {
function StyledSegmentedControl() {
return (
<MainCenteredContainer>
<FormSegmentedControl name="name" options={options} />
<SegmentedControl name="name" options={options} />
</MainCenteredContainer>
);
}
@@ -20,6 +20,11 @@ function Collection() {
<div className="flex flex-col items-start gap-y-4 p-4">
<ToastUI variant="success" message="Success UI" t="-" />
<ToastUI variant="error" message="Error UI" t="-" />
<ToastUI
variant="success"
message="This is a long error message that wraps over multiple lines so we can test the UI."
t="-"
/>
<br />
<Button
variant="primary/large"
@@ -41,6 +46,7 @@ function Collection() {
>
Error
</Button>
<Toaster />
</div>
);
@@ -0,0 +1,76 @@
import type { Meta, StoryObj } from "@storybook/react";
import { UsageBar } from "../billing/UsageBar";
import { Paragraph } from "../primitives/Paragraph";
const meta: Meta<typeof UsageProgressBar> = {
title: "Billing/UsageBar",
component: UsageProgressBar,
};
export default meta;
type Story = StoryObj<typeof UsageProgressBar>;
export const JobsUsageBar: Story = {
render: () => <UsageProgressBar />,
};
function UsageProgressBar() {
return (
<div className="flex flex-col justify-center gap-4 p-12">
<UsageBarWrapper title="Usage within the free tier limit">
<UsageBar numberOfCurrentRuns={30000} tierRunLimit={50000} projectedRuns={120000} />
</UsageBarWrapper>
<UsageBarWrapper title="Usage over the free tier limit">
<UsageBar numberOfCurrentRuns={90000} tierRunLimit={50000} projectedRuns={120000} />
</UsageBarWrapper>
<UsageBarWrapper title="Billing limit set">
<UsageBar
numberOfCurrentRuns={35674}
tierRunLimit={50000}
projectedRuns={120000}
billingLimit={180000}
/>
</UsageBarWrapper>
<UsageBarWrapper title="Paid subscriber under the free included Runs">
<UsageBar
numberOfCurrentRuns={10000}
tierRunLimit={50000}
billingLimit={180000}
projectedRuns={120000}
subscribedToPaidTier
/>
</UsageBarWrapper>
<UsageBarWrapper title="Paid subscriber over the free included Runs">
<UsageBar
numberOfCurrentRuns={90000}
tierRunLimit={50000}
billingLimit={180000}
projectedRuns={120000}
subscribedToPaidTier
/>
</UsageBarWrapper>
<UsageBarWrapper title="Brand new user usage">
<UsageBar numberOfCurrentRuns={0} tierRunLimit={50000} projectedRuns={0} />
</UsageBarWrapper>
<UsageBarWrapper title="Overlapping UI example">
<UsageBar
numberOfCurrentRuns={95000}
tierRunLimit={50000}
billingLimit={55000}
projectedRuns={93132}
subscribedToPaidTier
/>
</UsageBarWrapper>
</div>
);
}
function UsageBarWrapper({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="rounded-md border border-border p-6">
<Paragraph>{title}</Paragraph>
{children}
</div>
);
}
+1
View File
@@ -12,6 +12,7 @@ export function featuresForRequest(request: Request): TriggerFeatures {
const isManagedCloud =
url.host === "cloud.trigger.dev" ||
url.host === "test-cloud.trigger.dev" ||
url.host === "internal.trigger.dev" ||
process.env.CLOUD_ENV === "development";
return {
@@ -0,0 +1,55 @@
import { useEffect } from "react";
export function useNewCustomerSubscribed() {
useEffect(() => {
if ("confetti" in window && typeof window.confetti !== "undefined") {
const duration = 3.5 * 1000;
const animationEnd = Date.now() + duration;
const defaults = {
startVelocity: 30,
spread: 360,
ticks: 60,
zIndex: 0,
colors: [
"#E7FF52",
"#41FF54",
"rgb(245 158 11)",
"rgb(22 163 74)",
"rgb(37 99 235)",
"rgb(67 56 202)",
"rgb(219 39 119)",
"rgb(225 29 72)",
"rgb(217 70 239)",
],
};
function randomInRange(min: number, max: number): number {
return Math.random() * (max - min) + min;
}
// @ts-ignore
const interval = setInterval(function () {
const timeLeft = animationEnd - Date.now();
if (timeLeft <= 0) {
return clearInterval(interval);
}
const particleCount = 60 * (timeLeft / duration);
// since particles fall down, start a bit higher than random
// @ts-ignore
window.confetti(
Object.assign({}, defaults, {
particleCount,
origin: { x: randomInRange(0.1, 0.4), y: Math.random() - 0.2 },
})
);
// @ts-ignore
window.confetti(
Object.assign({}, defaults, {
particleCount,
origin: { x: randomInRange(0.6, 0.9), y: Math.random() - 0.2 },
})
);
}, 250);
}
}, []);
}
+2 -3
View File
@@ -1,9 +1,8 @@
import { UseDataFunctionReturn, useTypedRouteLoaderData } from "remix-typedjson";
import { UIMatch } from "@remix-run/react";
import { UseDataFunctionReturn } from "remix-typedjson";
import invariant from "tiny-invariant";
import type { loader as orgLoader } from "~/routes/_app.orgs.$organizationSlug/route";
import { hydrateObject, useMatchesData } from "~/utils";
import { useChanged } from "./useChanged";
import { UIMatch } from "@remix-run/react";
import { useTypedMatchesData } from "./useTypedMatchData";
export type MatchedOrganization = UseDataFunctionReturn<typeof orgLoader>["organizations"][number];
@@ -0,0 +1,60 @@
import { PrismaClient, prisma } from "~/db.server";
import { logger } from "~/services/logger.server";
import { BillingService } from "../services/billing.server";
export class OrgBillingPlanPresenter {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
public async call({ slug, isManagedCloud }: { slug: string; isManagedCloud: boolean }) {
const billingPresenter = new BillingService(isManagedCloud);
const plans = await billingPresenter.getPlans();
if (plans === undefined) {
return;
}
const organization = await this.#prismaClient.organization.findFirst({
where: {
slug,
},
});
if (!organization) {
return;
}
const maxConcurrency = await this.#prismaClient.$queryRaw<
{ organization_id: string; max_concurrent_runs: BigInt }[]
>`WITH events AS (
SELECT
re.event_time,
re.organization_id,
re.event_type,
SUM(re.event_type) OVER (PARTITION BY re.organization_id ORDER BY re.event_time) AS running_total
FROM
triggerdotdev_events.run_executions re
WHERE
re.organization_id = ${organization.id}
AND re.event_time >= DATE_TRUNC('month',
CURRENT_DATE)
)
SELECT
organization_id, MAX(running_total) AS max_concurrent_runs
FROM
events
GROUP BY
organization_id;`;
return {
plans,
maxConcurrency:
maxConcurrency.at(0) !== undefined
? Number(maxConcurrency[0].max_concurrent_runs)
: undefined,
};
}
}
@@ -1,4 +1,8 @@
import { estimate } from "@trigger.dev/billing";
import { formatDateTime } from "~/components/primitives/DateTime";
import { PrismaClient, prisma } from "~/db.server";
import { featuresForRequest } from "~/features.server";
import { BillingService } from "~/services/billing.server";
import { logger } from "~/services/logger.server";
export class OrgUsagePresenter {
@@ -8,7 +12,7 @@ export class OrgUsagePresenter {
this.#prismaClient = prismaClient;
}
public async call({ userId, slug }: { userId: string; slug: string }) {
public async call({ userId, slug, request }: { userId: string; slug: string; request: Request }) {
const organization = await this.#prismaClient.organization.findFirst({
where: {
slug,
@@ -24,9 +28,6 @@ export class OrgUsagePresenter {
return;
}
const startOfMonth = new Date(new Date().getFullYear(), new Date().getMonth(), 1);
const startOfLastMonth = new Date(new Date().getFullYear(), new Date().getMonth() - 1, 1); // this works for January as well
// Get count of runs since the start of the current month
const runsCount = await this.#prismaClient.jobRun.count({
where: {
@@ -38,18 +39,6 @@ export class OrgUsagePresenter {
},
});
// Get the count of runs for last month
const runsCountLastMonth = await this.#prismaClient.jobRun.count({
where: {
organizationId: organization.id,
createdAt: {
gte: startOfLastMonth,
lt: startOfMonth,
},
internal: false,
},
});
// Get the count of the runs for the last 6 months, by month. So for example we want the data shape to be:
// [
// { month: "2021-01", count: 10 },
@@ -61,100 +50,121 @@ export class OrgUsagePresenter {
// ]
// This will be used to generate the chart on the usage page
// Use prisma queryRaw for this since prisma doesn't support grouping by month
const chartDataRaw = await this.#prismaClient.$queryRaw<
const monthlyRunsDataRaw = await this.#prismaClient.$queryRaw<
{
month: string;
count: number;
}[]
>`SELECT TO_CHAR("createdAt", 'YYYY-MM') as month, COUNT(*) as count FROM "JobRun" WHERE "organizationId" = ${organization.id} AND "createdAt" >= NOW() - INTERVAL '6 months' AND "internal" = FALSE GROUP BY month ORDER BY month ASC`;
const chartData = chartDataRaw.map((obj) => ({
const hasMonthlyRunData = monthlyRunsDataRaw.length > 0;
const monthlyRunsData = monthlyRunsDataRaw.map((obj) => ({
name: obj.month,
total: Number(obj.count), // Convert BigInt to Number
}));
const totalJobs = await this.#prismaClient.job.count({
where: {
organizationId: organization.id,
internal: false,
},
});
const monthlyRunsDataDisplay = fillInMissingRunMonthlyData(monthlyRunsData, 6);
const totalJobsLastMonth = await this.#prismaClient.job.count({
where: {
organizationId: organization.id,
createdAt: {
lt: startOfMonth,
},
deletedAt: null,
internal: false,
},
});
// Max concurrency each day over past 30 days
const concurrencyChartRawData = await this.#prismaClient.$queryRaw<
{ day: Date; max_concurrent_runs: BigInt }[]
>`
WITH time_boundaries AS (
SELECT generate_series(
NOW() - interval '30 days',
NOW(),
interval '1 day'
) AS day_start
),
events AS (
SELECT
day_start,
event_time,
event_type,
SUM(event_type) OVER (ORDER BY event_time) AS running_total
FROM
time_boundaries
JOIN
triggerdotdev_events.run_executions
ON
event_time >= day_start AND event_time < day_start + interval '1 day'
WHERE triggerdotdev_events.run_executions.organization_id = ${organization.id}
),
max_concurrent_per_day AS (
SELECT
date_trunc('day', event_time) AS day,
MAX(running_total) AS max_concurrent_runs
FROM
events
GROUP BY day
)
SELECT
day,
max_concurrent_runs
FROM
max_concurrent_per_day
ORDER BY
day;`;
const totalIntegrations = await this.#prismaClient.integration.count({
where: {
organizationId: organization.id,
},
});
const ThirtyDaysAgo = new Date();
ThirtyDaysAgo.setDate(ThirtyDaysAgo.getDate() - 30);
ThirtyDaysAgo.setHours(0, 0, 0, 0);
const totalIntegrationsLastMonth = await this.#prismaClient.integration.count({
where: {
organizationId: organization.id,
createdAt: {
lt: startOfMonth,
},
},
});
const hasConcurrencyData = concurrencyChartRawData.length > 0;
const concurrencyChartRawDataFilledIn = fillInMissingConcurrencyDays(
ThirtyDaysAgo,
31,
concurrencyChartRawData
);
const totalMembers = await this.#prismaClient.orgMember.count({
where: {
organizationId: organization.id,
},
});
const endOfMonth = new Date(new Date().getFullYear(), new Date().getMonth() + 1, 1);
endOfMonth.setDate(endOfMonth.getDate() - 1);
const projectedRunsCount = Math.round(
runsCount / (new Date().getDate() / endOfMonth.getDate())
);
const jobs = await this.#prismaClient.job.findMany({
where: {
organizationId: organization.id,
deletedAt: null,
internal: false,
},
select: {
id: true,
slug: true,
_count: {
select: {
runs: {
where: {
createdAt: {
gte: startOfMonth,
},
},
},
},
},
project: {
select: {
id: true,
name: true,
slug: true,
},
},
},
});
const { isManagedCloud } = featuresForRequest(request);
const billingPresenter = new BillingService(isManagedCloud);
const plans = await billingPresenter.getPlans();
const chartDataDisplay = fillInMissingMonthlyData(chartData, 6);
let runCostEstimation: number | undefined = undefined;
let projectedRunCostEstimation: number | undefined = undefined;
if (plans) {
const estimationResult = estimate({
usage: { currentRunCount: runsCount },
plans: [plans.free, plans.paid],
});
runCostEstimation = estimationResult?.cost.runsCost;
const projectedEstimationResult = estimate({
usage: { currentRunCount: projectedRunsCount },
plans: [plans.free, plans.paid],
});
projectedRunCostEstimation = projectedEstimationResult?.cost.runsCost;
}
const periodStart = new Date();
periodStart.setDate(1);
periodStart.setHours(0, 0, 0, 0);
const periodEnd = new Date();
periodEnd.setDate(1);
periodEnd.setMonth(periodEnd.getMonth() + 1);
periodEnd.setHours(0, 0, 0, 0);
return {
id: organization.id,
runsCount,
runsCountLastMonth,
chartData: chartDataDisplay,
totalJobs,
totalJobsLastMonth,
totalIntegrations,
totalIntegrationsLastMonth,
totalMembers,
jobs,
projectedRunsCount,
monthlyRunsData: monthlyRunsDataDisplay,
hasMonthlyRunData,
concurrencyData: concurrencyChartRawDataFilledIn,
hasConcurrencyData,
runCostEstimation,
projectedRunCostEstimation,
periodStart,
periodEnd,
};
}
}
@@ -163,7 +173,7 @@ export class OrgUsagePresenter {
// So for example, if data is [{ name: "2021-01", total: 10 }, { name: "2021-03", total: 30 }] and the totalNumberOfMonths is 6
// And the current month is "2021-04", then this function will return:
// [{ name: "2020-11", total: 0 }, { name: "2020-12", total: 0 }, { name: "2021-01", total: 10 }, { name: "2021-02", total: 0 }, { name: "2021-03", total: 30 }, { name: "2021-04", total: 0 }]
function fillInMissingMonthlyData(
function fillInMissingRunMonthlyData(
data: Array<{ name: string; total: number }>,
totalNumberOfMonths: number
): Array<{ name: string; total: number }> {
@@ -187,6 +197,33 @@ function fillInMissingMonthlyData(
return completeData;
}
function fillInMissingConcurrencyDays(
startDate: Date,
days: number,
data: Array<{ day: Date; max_concurrent_runs: BigInt }>
) {
const outputData: Array<{ date: Date; maxConcurrentRuns: number }> = [];
for (let i = 0; i < days; i++) {
const date = new Date(startDate);
date.setDate(date.getDate() + i);
const foundData = data.find((d) => d.day.toISOString() === date.toISOString());
if (!foundData) {
outputData.push({
date,
maxConcurrentRuns: 0,
});
} else {
outputData.push({
date,
maxConcurrentRuns: Number(foundData.max_concurrent_runs),
});
}
}
return outputData;
}
// Start month will be like 2023-03 and endMonth will be like 2023-10
// The result should be an array of months between these two months, including the start and end month
// So for example, if startMonth is 2023-03 and endMonth is 2023-10, the result should be:
@@ -212,11 +249,3 @@ function getMonthsBetween(startMonth: string, endMonth: string): string[] {
return result;
}
function getLastSecondOfMonth(endMonth: string) {
const [year, month] = endMonth.split("-").map(Number);
const nextMonthFirstDay = new Date(year, month, 1);
nextMonthFirstDay.setDate(0);
nextMonthFirstDay.setHours(23, 59, 59);
return nextMonthFirstDay;
}
+2 -2
View File
@@ -25,7 +25,7 @@ export const links: LinksFunction = () => {
export const meta: TypedMetaFunction<typeof loader> = (args) => {
return metaV1(args, {
title: `Trigger.dev${appEnvTitleTag(args.data.appEnv)}`,
title: `Trigger.dev${appEnvTitleTag(args.data?.appEnv)}`,
charset: "utf-8",
viewport: "width=1024, initial-scale=1",
});
@@ -71,7 +71,7 @@ export function ErrorBoundary() {
<Links />
</head>
<body className="h-full overflow-hidden bg-darkBackground">
<AppContainer showBackgroundGradient={true}>
<AppContainer>
<MainCenteredContainer>
<RouteErrorDisplay />
</MainCenteredContainer>
@@ -0,0 +1,32 @@
import { LoaderFunctionArgs, redirect } from "@remix-run/server-runtime";
import { z } from "zod";
import { prisma } from "~/db.server";
import { redirectWithErrorMessage } from "~/models/message.server";
import { plansPath } from "~/utils/pathBuilder";
const ParamsSchema = z.object({
organizationId: z.string(),
});
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const { organizationId } = ParamsSchema.parse(params);
const org = await prisma.organization.findUnique({
select: {
slug: true,
},
where: {
id: organizationId,
},
});
if (!org) {
throw new Response(null, { status: 404 });
}
return redirectWithErrorMessage(
`${plansPath({ slug: org.slug })}`,
request,
"You didn't complete your details on Stripe. Please try again."
);
};
@@ -0,0 +1,32 @@
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { z } from "zod";
import { prisma } from "~/db.server";
import { redirectWithSuccessMessage } from "~/models/message.server";
import { subscribedPath } from "~/utils/pathBuilder";
const ParamsSchema = z.object({
organizationId: z.string(),
});
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const { organizationId } = ParamsSchema.parse(params);
const org = await prisma.organization.findUnique({
select: {
slug: true,
},
where: {
id: organizationId,
},
});
if (!org) {
throw new Response(null, { status: 404 });
}
return redirectWithSuccessMessage(
`${subscribedPath({ slug: org.slug })}`,
request,
"You are now subscribed to Trigger.dev"
);
};
@@ -0,0 +1,34 @@
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { z } from "zod";
import { prisma } from "~/db.server";
import { redirectWithErrorMessage } from "~/models/message.server";
import { plansPath } from "~/utils/pathBuilder";
const ParamsSchema = z.object({
organizationId: z.string(),
});
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const { organizationId } = ParamsSchema.parse(params);
const org = await prisma.organization.findUnique({
select: {
slug: true,
},
where: {
id: organizationId,
},
});
if (!org) {
throw new Response(null, { status: 404 });
}
const url = new URL(request.url);
const searchParams = new URLSearchParams(url.search);
const reason = searchParams.get("reason");
let errorMessage = reason ? decodeURIComponent(reason) : "Subscribing failed to complete";
return redirectWithErrorMessage(`${plansPath({ slug: org.slug })}`, request, errorMessage);
};
@@ -0,0 +1,193 @@
import { ArrowRightIcon } from "@heroicons/react/20/solid";
import { ArrowUpCircleIcon } from "@heroicons/react/24/outline";
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { Bar, BarChart, ResponsiveContainer, Tooltip, TooltipProps, XAxis, YAxis } from "recharts";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { ConcurrentRunsChart } from "~/components/billing/ConcurrentRunsChart";
import { UsageBar } from "~/components/billing/UsageBar";
import { LinkButton } from "~/components/primitives/Buttons";
import { Callout } from "~/components/primitives/Callout";
import { Header2, Header3 } from "~/components/primitives/Headers";
import { Paragraph } from "~/components/primitives/Paragraph";
import { useOrganization } from "~/hooks/useOrganizations";
import { OrgUsagePresenter } from "~/presenters/OrgUsagePresenter.server";
import { requireUserId } from "~/services/session.server";
import { formatCurrency, formatNumberCompact } from "~/utils/numberFormatter";
import { OrganizationParamsSchema, plansPath } from "~/utils/pathBuilder";
import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
import { DateTime, formatDateTime } from "~/components/primitives/DateTime";
export async function loader({ params, request }: LoaderFunctionArgs) {
const userId = await requireUserId(request);
const { organizationSlug } = OrganizationParamsSchema.parse(params);
const presenter = new OrgUsagePresenter();
const data = await presenter.call({ userId, slug: organizationSlug, request });
if (!data) {
throw new Response(null, { status: 404 });
}
return typedjson(data);
}
const CustomTooltip = ({ active, payload, label }: TooltipProps<number, string>) => {
if (active && payload) {
return (
<div className="flex gap-1 rounded border border-border bg-background px-3 py-2 text-xs text-bright">
<p>{label}:</p>
<p>{payload[0].value}</p>
</div>
);
}
return null;
};
export default function Page() {
const organization = useOrganization();
const loaderData = useTypedLoaderData<typeof loader>();
const currentPlan = useCurrentPlan();
const hitConcurrencyLimit = currentPlan?.subscription?.limits.concurrentRuns
? loaderData.concurrencyData.some(
(c) => c.maxConcurrentRuns >= (currentPlan.subscription?.limits.concurrentRuns ?? Infinity)
)
: false;
const hitsRunLimit = currentPlan?.usage?.runCountCap
? currentPlan.usage.currentRunCount > currentPlan.usage.runCountCap
: false;
return (
<div className="flex flex-col gap-4">
<div>
<Header2 spacing>Concurrent runs</Header2>
<div className="flex w-full flex-col gap-5 rounded border border-border p-6">
{hitConcurrencyLimit && (
<Callout
variant={"pricing"}
cta={
<LinkButton
variant="primary/small"
LeadingIcon={ArrowUpCircleIcon}
leadingIconClassName="px-0"
to={plansPath(organization)}
>
Increase concurrent runs
</LinkButton>
}
>
{`Some of your runs are being queued because the number of concurrent runs is limited to
${currentPlan?.subscription?.limits.concurrentRuns}.`}
</Callout>
)}
<ConcurrentRunsChart
data={loaderData.concurrencyData}
concurrentRunsLimit={currentPlan?.subscription?.limits.concurrentRuns}
hasConcurrencyData={loaderData.hasConcurrencyData}
/>
</div>
</div>
<div className="@container">
<Header2 spacing>Runs</Header2>
<div className="flex flex-col gap-5 rounded border border-border p-6">
{hitsRunLimit && (
<Callout
variant={"pricing"}
cta={
<LinkButton
variant="primary/small"
LeadingIcon={ArrowUpCircleIcon}
leadingIconClassName="px-0"
to={plansPath(organization)}
>
Upgrade
</LinkButton>
}
>
<Paragraph variant="small">
You have exceeded the monthly{" "}
{formatNumberCompact(currentPlan?.subscription?.limits.runs ?? 0)} runs limit.
Upgrade to a paid plan before{" "}
<DateTime date={loaderData.periodEnd} includeSeconds={false} includeTime={false} />.
</Paragraph>
</Callout>
)}
<div className="flex flex-col gap-x-8 @4xl:flex-row">
<div className="flex w-full flex-col gap-4">
{loaderData.runCostEstimation !== undefined &&
loaderData.projectedRunCostEstimation !== undefined && (
<div className="flex w-full items-center gap-6">
<div className="flex flex-col gap-2">
<Header3 className="">Month-to-date</Header3>
<p className="text-3xl font-medium text-bright">
{formatCurrency(loaderData.runCostEstimation, false)}
</p>
</div>
<ArrowRightIcon className="h-6 w-6 text-dimmed/50" />
<div className="flex flex-col gap-2 text-dimmed">
<Header3 className="text-dimmed">Projected</Header3>
<p className="text-3xl font-medium">
{formatCurrency(loaderData.projectedRunCostEstimation, false)}
</p>
</div>
</div>
)}
<UsageBar
numberOfCurrentRuns={loaderData.runsCount}
tierRunLimit={
currentPlan?.usage.runCountCap ??
currentPlan?.subscription?.plan.runs?.pricing?.brackets.at(0)?.upto
}
projectedRuns={loaderData.projectedRunsCount}
/>
</div>
<div className="relative w-full">
<Header3 className="mb-4">Monthly runs</Header3>
{!loaderData.hasMonthlyRunData && (
<Paragraph className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
No runs to show
</Paragraph>
)}
<ResponsiveContainer width="100%" height={200}>
<BarChart
data={loaderData.monthlyRunsData}
margin={{
top: 0,
right: 0,
left: 0,
bottom: 0,
}}
className="-ml-7"
>
<XAxis
dataKey="name"
stroke="#94A3B8"
fontSize={12}
tickLine={false}
axisLine={false}
/>
<YAxis
stroke="#94A3B8"
fontSize={12}
tickLine={false}
axisLine={false}
tickFormatter={(value) => `${value}`}
/>
<Tooltip
cursor={{ fill: "rgba(255,255,255,0.05)" }}
content={<CustomTooltip />}
/>
<Bar dataKey="total" fill="#16A34A" radius={[3, 3, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,85 @@
import { useForm } from "@conform-to/react";
import { parse } from "@conform-to/zod";
import { useActionData } from "@remix-run/react";
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { SetPlanBodySchema } from "@trigger.dev/billing";
import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson";
import { PricingCalculator } from "~/components/billing/PricingCalculator";
import { PricingTiers, TierEnterprise, TierFree, TierPro } from "~/components/billing/PricingTiers";
import { RunsVolumeDiscountTable } from "~/components/billing/RunsVolumeDiscountTable";
import { BreadcrumbLink } from "~/components/navigation/Breadcrumb";
import { Callout } from "~/components/primitives/Callout";
import { Header2 } from "~/components/primitives/Headers";
import { featuresForRequest } from "~/features.server";
import { useFeatures } from "~/hooks/useFeatures";
import { OrgBillingPlanPresenter } from "~/presenters/OrgBillingPlanPresenter";
import { Handle } from "~/utils/handle";
import { OrganizationParamsSchema, organizationBillingPath } from "~/utils/pathBuilder";
import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
import { formatNumberCompact } from "~/utils/numberFormatter";
export async function loader({ params, request }: LoaderFunctionArgs) {
const { organizationSlug } = OrganizationParamsSchema.parse(params);
const { isManagedCloud } = featuresForRequest(request);
if (!isManagedCloud) {
return redirect(organizationBillingPath({ slug: organizationSlug }));
}
const presenter = new OrgBillingPlanPresenter();
const result = await presenter.call({ slug: organizationSlug, isManagedCloud });
if (!result) {
throw new Response(null, { status: 404 });
}
return typedjson({
plans: result.plans,
maxConcurrency: result.maxConcurrency,
organizationSlug,
});
}
export const handle: Handle = {
breadcrumb: (match) => <BreadcrumbLink to={match.pathname} title="Plans" />,
};
export default function Page() {
const { plans, maxConcurrency, organizationSlug } = useTypedLoaderData<typeof loader>();
const currentPlan = useCurrentPlan();
const hitConcurrencyLimit =
currentPlan?.subscription?.limits.concurrentRuns && maxConcurrency
? maxConcurrency >= currentPlan.subscription!.limits.concurrentRuns!
: false;
const hitRunLimit = currentPlan?.usage?.runCountCap
? currentPlan.usage.currentRunCount > currentPlan.usage.runCountCap
: false;
return (
<div className="flex flex-col gap-4">
{hitConcurrencyLimit && (
<Callout variant={"pricing"}>
Some of your runs are being queued because your run concurrency is limited to{" "}
{currentPlan?.subscription?.limits.concurrentRuns}.
</Callout>
)}
{hitRunLimit && (
<Callout variant={"pricing"}>
{`You have exceeded the monthly
${formatNumberCompact(currentPlan!.subscription!.limits.runs!)} runs limit. Upgrade so you
can continue to perform runs.`}
</Callout>
)}
<PricingTiers organizationSlug={organizationSlug} plans={plans} />
<div>
<Header2 spacing>Estimate your usage</Header2>
<div className="flex h-full w-full rounded-md border border-border p-6">
<PricingCalculator plans={plans} />
<div className="mx-6 min-h-full w-px bg-border" />
<RunsVolumeDiscountTable brackets={plans.paid.runs?.pricing?.brackets ?? []} />
</div>
</div>
</div>
);
}
@@ -1,198 +1,132 @@
import { ArrowRightIcon } from "@heroicons/react/20/solid";
import {
ForwardIcon,
SquaresPlusIcon,
UsersIcon,
WrenchScrewdriverIcon,
} from "@heroicons/react/24/solid";
import { Bar, BarChart, ResponsiveContainer, Tooltip, TooltipProps, XAxis, YAxis } from "recharts";
import { CalendarDaysIcon, ReceiptRefundIcon } from "@heroicons/react/20/solid";
import { ArrowUpCircleIcon } from "@heroicons/react/24/outline";
import { Outlet } from "@remix-run/react";
import { ActiveSubscription } from "@trigger.dev/billing";
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
import { Header2 } from "~/components/primitives/Headers";
import { Paragraph } from "~/components/primitives/Paragraph";
import { TextLink } from "~/components/primitives/TextLink";
import { useOrganization } from "~/hooks/useOrganizations";
import {
OrganizationParamsSchema,
jobPath,
newProjectPath,
organizationTeamPath,
} from "~/utils/pathBuilder";
import { Link } from "@remix-run/react/dist/components";
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { OrgUsagePresenter } from "~/presenters/OrgUsagePresenter.server";
import { requireUserId } from "~/services/session.server";
import { LinkButton } from "~/components/primitives/Buttons";
import {
PageHeader,
PageTitleRow,
PageTitle,
PageButtons,
} from "~/components/primitives/PageHeader";
import { Handle } from "~/utils/handle";
import { BreadcrumbLink } from "~/components/navigation/Breadcrumb";
export async function loader({ params, request }: LoaderFunctionArgs) {
const userId = await requireUserId(request);
const { organizationSlug } = OrganizationParamsSchema.parse(params);
const presenter = new OrgUsagePresenter();
const data = await presenter.call({ userId, slug: organizationSlug });
if (!data) {
throw new Response(null, { status: 404 });
}
return typedjson(data);
}
const CustomTooltip = ({ active, payload, label }: TooltipProps<number, string>) => {
if (active && payload) {
return (
<div className="flex items-center gap-2 rounded border border-border bg-slate-900 px-4 py-2 text-sm text-dimmed">
<p className="text-white">{label}:</p>
<p className="text-white">{payload[0].value}</p>
</div>
);
}
return null;
};
import { LinkButton } from "~/components/primitives/Buttons";
import { DateTime } from "~/components/primitives/DateTime";
import {
PageButtons,
PageHeader,
PageInfoGroup,
PageInfoProperty,
PageInfoRow,
PageTabs,
PageTitle,
PageTitleRow,
} from "~/components/primitives/PageHeader";
import { useFeatures } from "~/hooks/useFeatures";
import { useOrganization } from "~/hooks/useOrganizations";
import { formatDurationInDays } from "~/utils";
import { Handle } from "~/utils/handle";
import { plansPath, stripePortalPath, usagePath } from "~/utils/pathBuilder";
import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
export const handle: Handle = {
breadcrumb: (match) => <BreadcrumbLink to={match.pathname} title="Usage & Billing" />,
};
function planLabel(subscription: ActiveSubscription | undefined, periodEnd: Date) {
if (!subscription) {
return "You're currently on the Free plan";
}
if (!subscription.isPaying) {
return `You're currently on the ${subscription.plan.title} plan`;
}
const costDescription = subscription.plan.concurrentRuns.pricing
? `\$${subscription.plan.concurrentRuns.pricing?.tierCost}/mo`
: "";
if (subscription.canceledAt) {
return (
<>
You're on the {costDescription} {subscription.plan.title} plan until{" "}
<DateTime includeTime={false} date={periodEnd} /> when you'll be on the Free plan
</>
);
}
return `You're currently on the ${costDescription} ${subscription.plan.title} plan`;
}
export default function Page() {
const organization = useOrganization();
const loaderData = useTypedLoaderData<typeof loader>();
const { isManagedCloud } = useFeatures();
const currentPlan = useCurrentPlan();
return (
<PageContainer>
<PageHeader>
<PageHeader hideBorder>
<PageTitleRow>
<PageTitle title="Usage & Billing" />
</PageTitleRow>
</PageHeader>
<PageBody>
<div className="mb-4 grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<div className="rounded border border-border p-6">
<div className="flex flex-row items-center justify-between space-y-0 pb-2">
<Header2>Total Runs this month</Header2>
<ForwardIcon className="h-6 w-6 text-dimmed" />
</div>
<div>
<p className="text-3xl font-bold">{loaderData.runsCount.toLocaleString()}</p>
<Paragraph variant="small" className="text-dimmed">
{loaderData.runsCountLastMonth} runs last month
</Paragraph>
</div>
</div>
<div className="rounded border border-border p-6">
<div className="flex flex-row items-center justify-between space-y-0 pb-2">
<Header2>Total Jobs</Header2>
<WrenchScrewdriverIcon className="h-6 w-6 text-dimmed" />
</div>
<div>
<p className="text-3xl font-bold">{loaderData.totalJobs.toLocaleString()}</p>
<Paragraph variant="small" className="text-dimmed">
{loaderData.totalJobs === loaderData.totalJobsLastMonth ? (
<>No change since last month</>
) : loaderData.totalJobs > loaderData.totalJobsLastMonth ? (
<>+{loaderData.totalJobs - loaderData.totalJobsLastMonth} since last month</>
) : (
<>-{loaderData.totalJobsLastMonth - loaderData.totalJobs} since last month</>
)}
</Paragraph>
</div>
</div>
<div className="rounded border border-border p-6">
<div className="flex flex-row items-center justify-between space-y-0 pb-2">
<Header2>Total Integrations</Header2>
<SquaresPlusIcon className="h-6 w-6 text-dimmed" />
</div>
<div>
<p className="text-3xl font-bold">{loaderData.totalIntegrations.toLocaleString()}</p>
<Paragraph variant="small" className="text-dimmed">
{loaderData.totalIntegrations === loaderData.totalIntegrationsLastMonth ? (
<>No change since last month</>
) : loaderData.totalIntegrations > loaderData.totalIntegrationsLastMonth ? (
<PageTitle title={isManagedCloud ? "Usage & Billing" : "Usage"} />
<PageButtons>
{isManagedCloud && (
<>
{currentPlan?.subscription?.isPaying && (
<>
+{loaderData.totalIntegrations - loaderData.totalIntegrationsLastMonth} since
last month
</>
) : (
<>
-{loaderData.totalIntegrationsLastMonth - loaderData.totalIntegrations} since
last month
<LinkButton to={stripePortalPath(organization)} variant="secondary/small">
Invoices
</LinkButton>
<LinkButton to={stripePortalPath(organization)} variant="secondary/small">
Manage card details
</LinkButton>
</>
)}
</Paragraph>
</div>
</div>
<div className="rounded border border-border p-6">
<div className="flex flex-row items-center justify-between space-y-0 pb-2">
<Header2>Team members</Header2>
<UsersIcon className="h-6 w-6 text-dimmed" />
</div>
<div>
<p className="text-3xl font-bold">{loaderData.totalMembers.toLocaleString()}</p>
<TextLink
to={organizationTeamPath(organization)}
className="group text-sm text-dimmed hover:text-bright"
>
Manage
<ArrowRightIcon className="-mb-0.5 ml-0.5 h-4 w-4 text-dimmed transition group-hover:translate-x-1 group-hover:text-bright" />
</TextLink>
</div>
</div>
</div>
<div className="flex max-h-[500px] gap-x-4">
<div className="w-1/2 rounded border border-border py-6 pr-2">
<Header2 className="mb-8 pl-6">Job Runs per month</Header2>
<ResponsiveContainer width="100%" height={400}>
<BarChart data={loaderData.chartData}>
<XAxis
dataKey="name"
stroke="#888888"
fontSize={12}
tickLine={false}
axisLine={false}
/>
<YAxis
stroke="#888888"
fontSize={12}
tickLine={false}
axisLine={false}
tickFormatter={(value) => `${value}`}
/>
<Tooltip cursor={{ fill: "rgba(255,255,255,0.05)" }} content={<CustomTooltip />} />
<Bar dataKey="total" fill="#DB2777" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
<div className="w-1/2 overflow-y-auto rounded border border-border px-3 py-6 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700">
<div className="mb-2 flex items-baseline justify-between border-b border-border px-3 pb-4">
<Header2 className="">Jobs</Header2>
<Header2 className="">Runs</Header2>
</div>
<div className="space-y-2">
{loaderData.jobs.map((job) => (
<Link
to={jobPath(organization, job.project, job)}
className="flex items-center rounded px-4 py-3 transition hover:bg-slate-850"
key={job.id}
<LinkButton
to={plansPath(organization)}
variant="primary/small"
LeadingIcon={ArrowUpCircleIcon}
leadingIconClassName="px-0"
>
<div className="space-y-1">
<p className="text-sm font-medium leading-none">{job.slug}</p>
<p className="text-sm text-muted-foreground">Project: {job.project.name}</p>
</div>
<div className="ml-auto font-medium">{job._count.runs.toLocaleString()}</div>
</Link>
))}
</div>
</div>
Upgrade
</LinkButton>
</>
)}
</PageButtons>
</PageTitleRow>
<PageInfoRow>
<PageInfoGroup>
{currentPlan?.subscription && (
<PageInfoProperty
icon={<ReceiptRefundIcon className="h-4 w-4 text-green-600" />}
value={planLabel(currentPlan.subscription, currentPlan.usage.periodEnd)}
/>
)}
{currentPlan?.subscription?.isPaying && (
<PageInfoProperty
icon={<CalendarDaysIcon className="h-4 w-4 text-green-600" />}
label={"Billing period"}
value={
<>
<DateTime date={currentPlan.usage.periodStart} includeTime={false} /> to{" "}
<DateTime date={currentPlan.usage.periodEnd} includeTime={false} /> (
{formatDurationInDays(currentPlan.usage.periodRemainingDuration)} remaining)
</>
}
/>
)}
</PageInfoGroup>
</PageInfoRow>
{isManagedCloud && (
<PageTabs
tabs={[
{
label: "Usage",
to: usagePath(organization),
},
{
label: "Plans",
to: plansPath(organization),
},
]}
layoutId="usage-and-billing"
/>
)}
</PageHeader>
<PageBody scrollable={false}>
<div className="h-full overflow-y-auto p-4 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700">
<Outlet />
</div>
</PageBody>
</PageContainer>
@@ -53,7 +53,7 @@ export default function Page() {
<PageContainer>
<PageHeader>
<PageTitleRow>
<PageTitle title={`${project.name} Runs`} />
<PageTitle title={`${project.name} runs`} />
<PageButtons>
<LinkButton
LeadingIcon={"docs"}
@@ -64,7 +64,7 @@ export default function Page() {
</LinkButton>
</PageButtons>
</PageTitleRow>
<PageDescription>All Job Runs in this project</PageDescription>
<PageDescription>All job runs in this project</PageDescription>
</PageHeader>
<PageBody scrollable={false}>
@@ -1,17 +1,19 @@
import { Outlet } from "@remix-run/react";
import { Outlet, UIMatch } from "@remix-run/react";
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { RouteErrorDisplay } from "~/components/ErrorDisplay";
import { UpgradePrompt } from "~/components/billing/UpgradePrompt";
import { Breadcrumb, BreadcrumbLink } from "~/components/navigation/Breadcrumb";
import { PageNavigationIndicator } from "~/components/navigation/PageNavigationIndicator";
import { SideMenu } from "~/components/navigation/SideMenu";
import { featuresForRequest } from "~/features.server";
import { useOptionalOrganization } from "~/hooks/useOrganizations";
import { useOptionalProject } from "~/hooks/useProject";
import { useTypedMatchData } from "~/hooks/useTypedMatchData";
import { useTypedMatchData, useTypedMatchesData } from "~/hooks/useTypedMatchData";
import { useUser } from "~/hooks/useUser";
import { BillingService } from "~/services/billing.server";
import { OrganizationsPresenter } from "~/presenters/OrganizationsPresenter.server";
import { getCurrentProjectId } from "~/services/currentProject.server";
import { getImpersonationId } from "~/services/impersonation.server";
import { requireUserId } from "~/services/session.server";
import { telemetry } from "~/services/telemetry.server";
@@ -23,6 +25,14 @@ const ParamsSchema = z.object({
projectParam: z.string().optional(),
});
export function useCurrentPlan(matches?: UIMatch[]) {
const data = useTypedMatchesData<typeof loader>({
id: "routes/_app.orgs.$organizationSlug",
matches,
});
return data?.currentPlan;
}
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const impersonationId = await getImpersonationId(request);
@@ -39,11 +49,16 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
telemetry.organization.identify({ organization });
const { isManagedCloud } = featuresForRequest(request);
const billingPresenter = new BillingService(isManagedCloud);
const currentPlan = await billingPresenter.currentPlan(organization.id);
return typedjson({
organizations,
organization,
currentProject: project,
isImpersonating: !!impersonationId,
currentPlan,
});
};
@@ -74,9 +89,12 @@ export default function Organization() {
organizations={organizations}
/>
<div className="grid grid-rows-[2.25rem_1fr] overflow-hidden">
<div className="flex w-full items-center justify-between border-b border-ui-border pr-2">
<div className="flex w-full items-center justify-between border-b border-ui-border">
<Breadcrumb />
<PageNavigationIndicator />
<div className="flex h-full items-center gap-4">
<PageNavigationIndicator className="mr-2" />
<UpgradePrompt organization={organization} />
</div>
</div>
<Outlet />
</div>
@@ -0,0 +1,89 @@
import { ChartBarIcon } from "@heroicons/react/20/solid";
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson";
import { PricingCalculator } from "~/components/billing/PricingCalculator";
import { PricingTiers } from "~/components/billing/PricingTiers";
import { RunsVolumeDiscountTable } from "~/components/billing/RunsVolumeDiscountTable";
import { Button } from "~/components/primitives/Buttons";
import { Header1 } from "~/components/primitives/Headers";
import {
Sheet,
SheetBody,
SheetContent,
SheetHeader,
SheetTrigger,
} from "~/components/primitives/Sheet";
import { featuresForRequest } from "~/features.server";
import { useOptionalProject, useProject } from "~/hooks/useProject";
import { OrgBillingPlanPresenter } from "~/presenters/OrgBillingPlanPresenter";
import { OrganizationsPresenter } from "~/presenters/OrganizationsPresenter.server";
import { requireUserId } from "~/services/session.server";
import {
OrganizationParamsSchema,
organizationBillingPath,
organizationPath,
projectPath,
} from "~/utils/pathBuilder";
export async function loader({ params, request }: LoaderFunctionArgs) {
const userId = await requireUserId(request);
const { organizationSlug } = OrganizationParamsSchema.parse(params);
const { isManagedCloud } = featuresForRequest(request);
if (!isManagedCloud) {
return redirect(organizationBillingPath({ slug: organizationSlug }));
}
const presenter = new OrgBillingPlanPresenter();
const result = await presenter.call({ slug: organizationSlug, isManagedCloud });
if (!result) {
throw new Response(null, { status: 404 });
}
const orgsPresenter = new OrganizationsPresenter();
const { organizations, organization, project } = await orgsPresenter.call({
userId,
request,
organizationSlug,
});
return typedjson({ plans: result.plans, organizationSlug, projectSlug: project.slug });
}
export default function ChoosePlanPage() {
const { plans, organizationSlug, projectSlug } = useTypedLoaderData<typeof loader>();
const project = useOptionalProject();
return (
<div className="mx-auto flex h-full w-full max-w-[80rem] flex-col items-center justify-center gap-12 overflow-y-auto px-12">
<Header1>Subscribe for full access</Header1>
<PricingTiers
organizationSlug={organizationSlug}
plans={plans}
showActionText={false}
freeButtonPath={projectPath({ slug: organizationSlug }, { slug: projectSlug })}
/>
<Sheet>
<SheetTrigger asChild>
<Button variant="tertiary/small" LeadingIcon={ChartBarIcon} leadingIconClassName="px-0">
Estimate usage
</Button>
</SheetTrigger>
<SheetContent size="lg">
<SheetHeader className="justify-between">
<div className="flex items-center gap-4">
<Header1>Estimate your usage</Header1>
</div>
</SheetHeader>
<SheetBody>
<PricingCalculator plans={plans} />
<div className="mt-8 rounded border border-border p-6">
<RunsVolumeDiscountTable brackets={plans.paid.runs?.pricing?.brackets ?? []} />
</div>
</SheetBody>
</SheetContent>
</Sheet>
</div>
);
}
@@ -0,0 +1,96 @@
import { CheckBadgeIcon } from "@heroicons/react/24/solid";
import { RunsVolumeDiscountTable } from "~/components/billing/RunsVolumeDiscountTable";
import { MainCenteredContainer } from "~/components/layout/AppLayout";
import { LinkButton } from "~/components/primitives/Buttons";
import { FormButtons } from "~/components/primitives/FormButtons";
import { FormTitle } from "~/components/primitives/FormTitle";
import { Paragraph } from "~/components/primitives/Paragraph";
import { useNewCustomerSubscribed } from "~/hooks/useNewCustomerSubscribed";
import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
import { Handle } from "~/utils/handle";
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { requireUserId } from "~/services/session.server";
import { OrganizationsPresenter } from "~/presenters/OrganizationsPresenter.server";
import { OrganizationParamsSchema } from "~/utils/pathBuilder";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { featuresForRequest } from "~/features.server";
import { BillingService } from "~/services/billing.server";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const { organizationSlug } = OrganizationParamsSchema.parse(params);
const orgsPresenter = new OrganizationsPresenter();
const { organization } = await orgsPresenter.call({
userId,
request,
organizationSlug,
});
const { isManagedCloud } = featuresForRequest(request);
const billingPresenter = new BillingService(isManagedCloud);
const currentPlan = await billingPresenter.currentPlan(organization.id);
const plans = await billingPresenter.getPlans();
return typedjson({
currentPlan,
plans,
});
};
export const handle: Handle = {
scripts: () => [
{
src: "https://cdn.jsdelivr.net/npm/canvas-confetti@1.5.1/dist/confetti.browser.min.js",
crossOrigin: "anonymous",
},
],
};
export default function Subscribed() {
const { currentPlan, plans } = useTypedLoaderData<typeof loader>();
useNewCustomerSubscribed();
return (
<MainCenteredContainer className="max-w-[22rem]">
<FormTitle
LeadingIcon={<CheckBadgeIcon className="h-7 w-7 text-green-600" />}
title="You're subscribed!"
className="mb-0"
/>
<ul>
<PlanItem item="Your plan" value={currentPlan?.subscription?.plan.title ?? ""} />
<PlanItem
item="Concurrent runs/mo"
value={`${currentPlan?.subscription?.plan.concurrentRuns.pricing?.upto}`}
/>
<PlanItem item="Runs/mo" value="Volume discounted" />
</ul>
<RunsVolumeDiscountTable
hideHeader
className="mb-4 border-b border-border pb-2 pl-4"
brackets={plans?.paid.runs?.pricing?.brackets ?? []}
/>
<FormButtons
confirmButton={
<LinkButton to={"/"} variant={"primary/small"} TrailingIcon={"arrow-right"}>
Continue
</LinkButton>
}
/>
</MainCenteredContainer>
);
}
function PlanItem({ item, value }: { item: string; value: string }) {
return (
<li className="flex items-center justify-between border-b border-border py-2">
<Paragraph>{item}</Paragraph>
<Paragraph variant="base/bright" className="font-medium">
{value}
</Paragraph>
</li>
);
}
+15 -4
View File
@@ -17,12 +17,13 @@ import { Input } from "~/components/primitives/Input";
import { InputGroup } from "~/components/primitives/InputGroup";
import { Label } from "~/components/primitives/Label";
import { RadioGroupItem } from "~/components/primitives/RadioButton";
import { featuresForRequest } from "~/features.server";
import { useFeatures } from "~/hooks/useFeatures";
import { createOrganization } from "~/models/organization.server";
import { NewOrganizationPresenter } from "~/presenters/NewOrganizationPresenter.server";
import { commitCurrentProjectSession, setCurrentProjectId } from "~/services/currentProject.server";
import { requireUserId } from "~/services/session.server";
import { projectPath, rootPath } from "~/utils/pathBuilder";
import { plansPath, projectPath, rootPath, selectPlanPath } from "~/utils/pathBuilder";
const schema = z.object({
orgName: z.string().min(3).max(50),
@@ -61,10 +62,20 @@ export const action: ActionFunction = async ({ request }) => {
const project = organization.projects[0];
const session = await setCurrentProjectId(project.id, request);
const { isManagedCloud } = featuresForRequest(request);
const headers = {
"Set-Cookie": await commitCurrentProjectSession(session),
};
if (isManagedCloud) {
return redirect(selectPlanPath(organization), {
headers,
});
}
return redirect(projectPath(organization, project), {
headers: {
"Set-Cookie": await commitCurrentProjectSession(session),
},
headers,
});
} catch (error: any) {
return json({ errors: { body: error.message } }, { status: 400 });
+2 -6
View File
@@ -3,7 +3,6 @@ import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { redirect, typedjson } from "remix-typedjson";
import { RouteErrorDisplay } from "~/components/ErrorDisplay";
import { AppContainer, MainCenteredContainer } from "~/components/layout/AppLayout";
import { useIsOrgChildPage } from "~/hooks/useIsOrgChildPage";
import { clearRedirectTo, commitSession } from "~/services/redirectTo.server";
import { requireUser } from "~/services/session.server";
import { confirmBasicDetailsPath } from "~/utils/pathBuilder";
@@ -22,11 +21,8 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
};
export default function App() {
const isOrgChildPage = useIsOrgChildPage();
const showBackgroundGradient = !isOrgChildPage;
return (
<AppContainer showBackgroundGradient={showBackgroundGradient}>
<AppContainer>
<Outlet />
</AppContainer>
);
@@ -35,7 +31,7 @@ export default function App() {
export function ErrorBoundary() {
return (
<>
<AppContainer showBackgroundGradient={true}>
<AppContainer>
<MainCenteredContainer>
<RouteErrorDisplay />
</MainCenteredContainer>
+1 -1
View File
@@ -123,7 +123,7 @@ export default function Page() {
});
return (
<AppContainer showBackgroundGradient={true}>
<AppContainer>
<MainCenteredContainer>
<FormTitle LeadingIcon="user" title="Profile" />
<Form method="post" {...form.props} className="max-w-md">
@@ -137,7 +137,7 @@ export default function Page() {
const shouldShowConfirm = user.email !== enteredEmail || user.email === "";
return (
<AppContainer showBackgroundGradient={true}>
<AppContainer>
<MainCenteredContainer className="max-w-[22rem]">
<Form method="post" {...form.props}>
<FormTitle
+1 -16
View File
@@ -1,24 +1,9 @@
// learn more: https://fly.io/docs/reference/configuration/#services-http_checks
import { prisma } from "~/db.server";
import type { LoaderFunction } from "@remix-run/node";
export const loader: LoaderFunction = async ({ request }) => {
const host = request.headers.get("X-Forwarded-Host") ?? request.headers.get("host");
try {
const url = new URL("/", `http://${host}`);
if (request.headers.get("x-forwarded-proto") === "https") {
url.protocol = "https:";
}
// if we can connect to the database and make a simple query
// and make a HEAD request to ourselves, then we're good.
await Promise.all([
prisma.user.count(),
fetch(url.href, { method: "HEAD" }).then((r) => {
if (!r.ok) return Promise.reject(r);
}),
]);
await prisma.user.count();
return new Response("OK");
} catch (error: unknown) {
console.log("healthcheck ❌", { error });
+1 -1
View File
@@ -97,7 +97,7 @@ export default function Page() {
});
return (
<AppContainer showBackgroundGradient={true}>
<AppContainer>
<MainCenteredContainer>
<div>
<FormTitle
@@ -0,0 +1,79 @@
import { parse } from "@conform-to/zod";
import { ActionFunctionArgs, json } from "@remix-run/server-runtime";
import { SetPlanBodySchema } from "@trigger.dev/billing";
import { redirect } from "remix-typedjson";
import { prisma } from "~/db.server";
import { redirectWithSuccessMessage } from "~/models/message.server";
import { BillingService } from "~/services/billing.server";
import { logger } from "~/services/logger.server";
import { requireUser } from "~/services/session.server";
import {
OrganizationParamsSchema,
organizationBillingPath,
subscribedPath,
} from "~/utils/pathBuilder";
export async function action({ request, params }: ActionFunctionArgs) {
const user = await requireUser(request);
const { organizationSlug } = OrganizationParamsSchema.parse(params);
const formData = await request.formData();
const submission = parse(formData, { schema: SetPlanBodySchema });
if (!submission.value || submission.intent !== "submit") {
return json(submission);
}
try {
const org = await prisma.organization.findUnique({
select: {
id: true,
},
where: {
slug: organizationSlug,
members: {
some: {
userId: user.id,
},
},
},
});
if (!org) {
submission.error.message = "Invalid organization";
return json(submission);
}
const billingPresenter = new BillingService(true);
const result = await billingPresenter.setPlan(org.id, submission.value);
if (result === undefined) {
submission.error.message = "No billing client";
return json(submission);
}
if (!result.success) {
submission.error.message = result.error;
return json(submission);
}
switch (result.action) {
case "create_subscription_flow_start": {
return redirect(result.checkoutUrl);
}
case "canceled_subscription": {
return redirectWithSuccessMessage(
organizationBillingPath({ slug: organizationSlug }),
request,
"Your subscription has been canceled."
);
}
case "updated_subscription": {
return redirect(subscribedPath({ slug: organizationSlug }), request);
}
}
} catch (e) {
logger.error("Error setting plan", { error: e });
submission.error.message = e instanceof Error ? e.message : JSON.stringify(e);
return json(submission);
}
}
@@ -0,0 +1,47 @@
import { ActionFunctionArgs } from "@remix-run/server-runtime";
import { redirect } from "remix-typedjson";
import { prisma } from "~/db.server";
import { redirectBackWithErrorMessage, redirectWithErrorMessage } from "~/models/message.server";
import { BillingService } from "~/services/billing.server";
import { requireUser } from "~/services/session.server";
import { OrganizationParamsSchema, usagePath } from "~/utils/pathBuilder";
export async function loader({ request, params }: ActionFunctionArgs) {
const user = await requireUser(request);
const { organizationSlug } = OrganizationParamsSchema.parse(params);
const org = await prisma.organization.findUnique({
select: {
id: true,
},
where: {
slug: organizationSlug,
members: {
some: {
userId: user.id,
},
},
},
});
if (!org) {
return redirectWithErrorMessage(
usagePath({ slug: organizationSlug }),
request,
"Something went wrong. Please try again later."
);
}
const billingPresenter = new BillingService(true);
const result = await billingPresenter.customerPortalUrl(org.id, organizationSlug);
if (!result || !result.success || !result.customerPortalUrl) {
return redirectWithErrorMessage(
usagePath({ slug: organizationSlug }),
request,
"Something went wrong. Please try again later."
);
}
return redirect(result.customerPortalUrl);
}
@@ -14,6 +14,7 @@ export const feedbackTypeLabel = {
feature: "Feature request",
help: "Help me out",
integration: "Request an Integration",
enterprise: "Enterprise enquiry",
};
export type FeedbackType = keyof typeof feedbackTypeLabel;
+111
View File
@@ -0,0 +1,111 @@
import { BillingClient, SetPlanBody } from "@trigger.dev/billing";
import { PrismaClient, prisma } from "~/db.server";
import { env } from "~/env.server";
import { logger } from "~/services/logger.server";
import { organizationBillingPath } from "~/utils/pathBuilder";
export class BillingService {
#billingClient: BillingClient | undefined;
#prismaClient: PrismaClient;
constructor(isManagedCloud: boolean, prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
if (isManagedCloud && process.env.BILLING_API_URL && process.env.BILLING_API_KEY) {
this.#billingClient = new BillingClient({
url: process.env.BILLING_API_URL,
apiKey: process.env.BILLING_API_KEY,
});
console.log(`Billing client initialized: ${process.env.BILLING_API_URL}`);
} else {
console.log(`Billing client not initialized`);
}
}
async currentPlan(orgId: string) {
if (!this.#billingClient) return undefined;
try {
const result = await this.#billingClient.currentPlan(orgId);
const firstDayOfMonth = new Date();
firstDayOfMonth.setDate(1);
firstDayOfMonth.setHours(0, 0, 0, 0);
const firstDayOfNextMonth = new Date();
firstDayOfNextMonth.setDate(1);
firstDayOfNextMonth.setMonth(firstDayOfNextMonth.getMonth() + 1);
firstDayOfNextMonth.setHours(0, 0, 0, 0);
const currentRunCount = await this.#prismaClient.jobRun.count({
where: {
organizationId: orgId,
createdAt: {
gte: firstDayOfMonth,
},
},
});
if (!result.success) {
logger.error("Error getting current plan", { orgId, error: result.error });
return undefined;
}
const periodStart = firstDayOfMonth;
const periodEnd = firstDayOfNextMonth;
const periodRemainingDuration = periodEnd.getTime() - new Date().getTime();
const usage = {
currentRunCount,
runCountCap: result.subscription?.plan.runs?.freeAllowance,
exceededRunCount: result.subscription?.plan.runs?.freeAllowance
? currentRunCount > result.subscription?.plan.runs?.freeAllowance
: false,
periodStart,
periodEnd,
periodRemainingDuration,
};
return { ...result, usage };
} catch (e) {
logger.error("Error getting current plan", { orgId, error: e });
return undefined;
}
}
async customerPortalUrl(orgId: string, orgSlug: string) {
if (!this.#billingClient) return undefined;
try {
return this.#billingClient.createPortalSession(orgId, {
returnUrl: `${env.APP_ORIGIN}${organizationBillingPath({ slug: orgSlug })}`,
});
} catch (e) {
logger.error("Error getting customer portal Url", { orgId, error: e });
return undefined;
}
}
async getPlans() {
if (!this.#billingClient) return undefined;
try {
const result = await this.#billingClient.plans();
if (!result.success) {
logger.error("Error getting plans", { error: result.error });
return undefined;
}
return result;
} catch (e) {
logger.error("Error getting plans", { error: e });
return undefined;
}
}
async setPlan(orgId: string, plan: SetPlanBody) {
if (!this.#billingClient) return undefined;
try {
const result = await this.#billingClient.setPlan(orgId, plan);
return result;
} catch (e) {
logger.error("Error setting plan", { orgId, error: e });
return undefined;
}
}
}
+11 -1
View File
@@ -141,6 +141,16 @@ export function formatDurationMilliseconds(
return duration;
}
export function formatDurationInDays(milliseconds: number): string {
let duration = humanizeDuration(milliseconds, {
maxDecimalPoints: 0,
largest: 2,
units: ["d"],
});
return duration;
}
export function titleCase(original: string): string {
return original
.split(" ")
@@ -163,5 +173,5 @@ export function appEnvTitleTag(appEnv?: string): string {
return "";
}
return ` (${appEnv})`
return ` (${appEnv})`;
}
+21
View File
@@ -0,0 +1,21 @@
const compactFormatter = Intl.NumberFormat("en", { notation: "compact", compactDisplay: "short" });
export const formatNumberCompact = (num: number): string => {
return compactFormatter.format(num);
};
const roundedCurrencyFormatter = Intl.NumberFormat("en-US", {
style: "currency",
currencyDisplay: "symbol",
maximumFractionDigits: 0,
currency: "USD",
});
const currencyFormatter = Intl.NumberFormat("en-US", {
style: "currency",
currencyDisplay: "symbol",
currency: "USD",
});
export const formatCurrency = (num: number, rounded: boolean): string => {
return rounded ? roundedCurrencyFormatter.format(num) : currencyFormatter.format(num);
};
+20
View File
@@ -105,6 +105,10 @@ export function newOrganizationPath() {
return `/orgs/new`;
}
export function selectPlanPath(organization: OrgForPath) {
return `${organizationPath(organization)}/select-plan`;
}
export function organizationTeamPath(organization: OrgForPath) {
return `${organizationPath(organization)}/team`;
}
@@ -117,6 +121,22 @@ export function organizationBillingPath(organization: OrgForPath) {
return `${organizationPath(organization)}/billing`;
}
export function usagePath(organization: OrgForPath) {
return `${organizationPath(organization)}/billing`;
}
export function stripePortalPath(organization: OrgForPath) {
return `/resources/${organization.slug}/subscription/portal`;
}
export function plansPath(organization: OrgForPath) {
return `${organizationPath(organization)}/billing/plans`;
}
export function subscribedPath(organization: OrgForPath) {
return `${organizationPath(organization)}/subscribed`;
}
function organizationParam(organization: OrgForPath) {
return organization.slug;
}
+3
View File
@@ -53,6 +53,7 @@
"@radix-ui/react-popover": "^1.0.5",
"@radix-ui/react-radio-group": "^1.1.3",
"@radix-ui/react-select": "^1.2.1",
"@radix-ui/react-slider": "^1.1.2",
"@radix-ui/react-switch": "^1.0.3",
"@radix-ui/react-tabs": "^1.0.3",
"@radix-ui/react-tooltip": "^1.0.5",
@@ -63,7 +64,9 @@
"@remix-run/server-runtime": "2.1.0",
"@remix-run/v1-meta": "^0.1.3",
"@tabler/icons-react": "^2.39.0",
"@tailwindcss/container-queries": "^0.1.1",
"@team-plain/typescript-sdk": "^3.5.0",
"@trigger.dev/billing": "^1.0.10",
"@trigger.dev/companyicons": "^1.5.32",
"@trigger.dev/core": "workspace:*",
"@trigger.dev/core-backend": "workspace:*",
+1
View File
@@ -14,6 +14,7 @@ module.exports = {
"@trigger.dev/core",
"@trigger.dev/core-backend",
"@trigger.dev/sdk",
"@trigger.dev/billing",
"emails",
"highlight.run",
"random-words",
+3 -2
View File
@@ -76,7 +76,7 @@ module.exports = {
fontFamily: {
sans: ["Inter", "sans-serif"],
mono: ["Roboto Mono", "monospace"],
title: ["Poppins", "sans-serif"]
title: ["Poppins", "sans-serif"],
},
fontSize: {
xxs: [
@@ -201,10 +201,11 @@ module.exports = {
},
},
plugins: [
require("@tailwindcss/container-queries"),
require("@tailwindcss/forms"),
require("@tailwindcss/typography"),
require("tailwindcss-animate"),
require("tailwind-scrollbar"),
require('tailwind-scrollbar-hide')
require("tailwind-scrollbar-hide"),
],
};
@@ -0,0 +1 @@
CREATE INDEX idx_event_time_organization_id ON triggerdotdev_events.run_executions (event_time, organization_id);
+4
View File
@@ -4,6 +4,10 @@ You can use this to test the performance locally.
## Setup
```
cd perf
```
```bash
pnpm run server
```
+57
View File
@@ -101,6 +101,7 @@ importers:
'@radix-ui/react-popover': ^1.0.5
'@radix-ui/react-radio-group': ^1.1.3
'@radix-ui/react-select': ^1.2.1
'@radix-ui/react-slider': ^1.1.2
'@radix-ui/react-switch': ^1.0.3
'@radix-ui/react-tabs': ^1.0.3
'@radix-ui/react-tooltip': ^1.0.5
@@ -127,10 +128,12 @@ importers:
'@swc/core': ^1.3.4
'@swc/helpers': ^0.4.11
'@tabler/icons-react': ^2.39.0
'@tailwindcss/container-queries': ^0.1.1
'@tailwindcss/forms': ^0.5.3
'@tailwindcss/typography': ^0.5.9
'@team-plain/typescript-sdk': ^3.5.0
'@total-typescript/ts-reset': ^0.4.2
'@trigger.dev/billing': ^1.0.10
'@trigger.dev/companyicons': ^1.5.32
'@trigger.dev/core': workspace:*
'@trigger.dev/core-backend': workspace:*
@@ -255,6 +258,7 @@ importers:
'@radix-ui/react-popover': 1.0.5_bwbutfx4xj25dewzmxso6o3wga
'@radix-ui/react-radio-group': 1.1.3_daadhm4ohxobgnrt365as5bhny
'@radix-ui/react-select': 1.2.1_bwbutfx4xj25dewzmxso6o3wga
'@radix-ui/react-slider': 1.1.2_daadhm4ohxobgnrt365as5bhny
'@radix-ui/react-switch': 1.0.3_daadhm4ohxobgnrt365as5bhny
'@radix-ui/react-tabs': 1.0.3_biqbaboplfbrettd7655fr4n2y
'@radix-ui/react-tooltip': 1.0.5_bwbutfx4xj25dewzmxso6o3wga
@@ -265,7 +269,9 @@ importers:
'@remix-run/server-runtime': 2.1.0_typescript@5.2.2
'@remix-run/v1-meta': 0.1.3_ybjp5xbtg4zthziheradfmei64
'@tabler/icons-react': 2.40.0_react@18.2.0
'@tailwindcss/container-queries': 0.1.1_tailwindcss@3.3.2
'@team-plain/typescript-sdk': 3.5.0
'@trigger.dev/billing': 1.0.10
'@trigger.dev/companyicons': 1.5.32_biqbaboplfbrettd7655fr4n2y
'@trigger.dev/core': link:../../packages/core
'@trigger.dev/core-backend': link:../../packages/core-backend
@@ -9448,6 +9454,12 @@ packages:
'@babel/runtime': 7.22.5
dev: false
/@radix-ui/number/1.0.1:
resolution: {integrity: sha512-T5gIdVO2mmPW3NNhjNgEP3cqMXjXL9UbO0BzWcXfvdBs+BohbQxvd/K5hSVKmn9/lbTdsQVKbUcP5WLCwvUbBg==}
dependencies:
'@babel/runtime': 7.22.5
dev: false
/@radix-ui/primitive/1.0.0:
resolution: {integrity: sha512-3e7rn8FDMin4CgeL7Z/49smCA3rFYY3Ha2rUQ7HRWFadS5iCRw08ZgVT1LaNTCNqgvrUiyczLflrVrF0SRQtNA==}
dependencies:
@@ -10070,6 +10082,37 @@ packages:
- '@types/react'
dev: false
/@radix-ui/react-slider/1.1.2_daadhm4ohxobgnrt365as5bhny:
resolution: {integrity: sha512-NKs15MJylfzVsCagVSWKhGGLNR1W9qWs+HtgbmjjVUB3B9+lb3PYoXxVju3kOrpf0VKyVCtZp+iTwVoqpa1Chw==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0
react-dom: ^16.8 || ^17.0 || ^18.0
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
dependencies:
'@babel/runtime': 7.22.5
'@radix-ui/number': 1.0.1
'@radix-ui/primitive': 1.0.1
'@radix-ui/react-collection': 1.0.3_daadhm4ohxobgnrt365as5bhny
'@radix-ui/react-compose-refs': 1.0.1_e74vmjybjy5dsfplslbsgtbvvi
'@radix-ui/react-context': 1.0.1_e74vmjybjy5dsfplslbsgtbvvi
'@radix-ui/react-direction': 1.0.1_e74vmjybjy5dsfplslbsgtbvvi
'@radix-ui/react-primitive': 1.0.3_daadhm4ohxobgnrt365as5bhny
'@radix-ui/react-use-controllable-state': 1.0.1_e74vmjybjy5dsfplslbsgtbvvi
'@radix-ui/react-use-layout-effect': 1.0.1_e74vmjybjy5dsfplslbsgtbvvi
'@radix-ui/react-use-previous': 1.0.1_e74vmjybjy5dsfplslbsgtbvvi
'@radix-ui/react-use-size': 1.0.1_e74vmjybjy5dsfplslbsgtbvvi
'@types/react': 18.2.17
'@types/react-dom': 18.2.7
react: 18.2.0
react-dom: 18.2.0_react@18.2.0
dev: false
/@radix-ui/react-slot/1.0.0_react@18.2.0:
resolution: {integrity: sha512-3mrKauI/tWXo1Ll+gN5dHcxDPdm/Df1ufcDLCecn+pnCIVcdWE7CujXo8QaXOWRJyZyQWWbpB8eFwHzWXlv5mQ==}
peerDependencies:
@@ -13280,6 +13323,14 @@ packages:
resolution: {integrity: sha512-VqKsBSX159cLFTnCzkCmGhZtSPJHNN0lM2sC4xe0HPOfPUnjiex7rDHDdut4oe4iKRecDDpwXwM9BcU6xCPlCg==}
dev: false
/@tailwindcss/container-queries/0.1.1_tailwindcss@3.3.2:
resolution: {integrity: sha512-p18dswChx6WnTSaJCSGx6lTmrGzNNvm2FtXmiO6AuA1V4U5REyoqwmT6kgAsIMdjo07QdAfYXHJ4hnMtfHzWgA==}
peerDependencies:
tailwindcss: '>=3.2.0'
dependencies:
tailwindcss: 3.3.2_ts-node@10.9.1
dev: false
/@tailwindcss/forms/0.5.3_tailwindcss@3.1.8:
resolution: {integrity: sha512-y5mb86JUoiUgBjY/o6FJSFZSEttfb3Q5gllE4xoKjAAD+vBrnIhE4dViwUuow3va8mpH4s9jyUbUbrRGoRdc2Q==}
peerDependencies:
@@ -13398,6 +13449,12 @@ packages:
resolution: {integrity: sha512-vqd7ZUDSrXFVT1n8b2kc3LnklncDQFPvR58yUS1kEP23/nHPAO9l1lMjUfnPrXYYk4Hj54rrLKMW5ipwk7k09A==}
dev: true
/@trigger.dev/billing/1.0.10:
resolution: {integrity: sha512-vkPd1UD0fRji4qFP+s2pP26oztYzyQezQqT4+12I/5+7pOKFbOr2j0tVZj/sf7BTWgRE1Ybsk4CKT0gc9yXfKA==}
dependencies:
zod: 3.22.3
dev: false
/@trigger.dev/companyicons/1.5.32_biqbaboplfbrettd7655fr4n2y:
resolution: {integrity: sha512-+slhDClUWJ0hW4laLRvUgJ88tiPwthzI7LHUJopQGpqJV0eF/rkobOdn3fMlAIQFT1SWtcnwYxpman9aFpvtcg==}
peerDependencies: