Billing (#789)
* 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:
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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+8
-7
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}, []);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
+2
-2
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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 });
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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})`;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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:*",
|
||||
|
||||
@@ -14,6 +14,7 @@ module.exports = {
|
||||
"@trigger.dev/core",
|
||||
"@trigger.dev/core-backend",
|
||||
"@trigger.dev/sdk",
|
||||
"@trigger.dev/billing",
|
||||
"emails",
|
||||
"highlight.run",
|
||||
"random-words",
|
||||
|
||||
@@ -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"),
|
||||
],
|
||||
};
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
CREATE INDEX idx_event_time_organization_id ON triggerdotdev_events.run_executions (event_time, organization_id);
|
||||
@@ -4,6 +4,10 @@ You can use this to test the performance locally.
|
||||
|
||||
## Setup
|
||||
|
||||
```
|
||||
cd perf
|
||||
```
|
||||
|
||||
```bash
|
||||
pnpm run server
|
||||
```
|
||||
|
||||
Generated
+57
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user