Skip to content

Commit d2c70f1

Browse files
committed
Concurrency purchasing working
1 parent 9e8bfbf commit d2c70f1

File tree

3 files changed

+247
-69
lines changed

3 files changed

+247
-69
lines changed

apps/webapp/app/presenters/v3/ManageConcurrencyPresenter.server.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export type ConcurrencyResult = {
1313
extraConcurrency: number;
1414
extraAllocatedConcurrency: number;
1515
extraUnallocatedConcurrency: number;
16+
maxQuota: number;
1617
concurrencyPricing: {
1718
stepSize: number;
1819
centsPerStep: number;
@@ -115,6 +116,7 @@ export class ManageConcurrencyPresenter extends BasePresenter {
115116
extraConcurrency,
116117
extraAllocatedConcurrency: extraAllocated,
117118
extraUnallocatedConcurrency: extraConcurrency - extraAllocated,
119+
maxQuota: currentPlan.v3Subscription.addOns?.concurrentRuns?.quota ?? 0,
118120
environments: sortEnvironments(projectEnvironments).reverse(),
119121
concurrencyPricing: plans.addOnPricing.concurrency,
120122
};

apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.concurrency/route.tsx

Lines changed: 151 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1-
import { PlusIcon } from "@heroicons/react/20/solid";
2-
import { Form, type MetaFunction } from "@remix-run/react";
3-
import { type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/server-runtime";
1+
import { conform, useForm } from "@conform-to/react";
2+
import { parse } from "@conform-to/zod";
3+
import { EnvelopeIcon, PlusIcon } from "@heroicons/react/20/solid";
4+
import { DialogClose } from "@radix-ui/react-dialog";
5+
import { Form, useActionData, useNavigation, type MetaFunction } from "@remix-run/react";
6+
import { json, type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/server-runtime";
47
import { tryCatch } from "@trigger.dev/core";
58
import { useState } from "react";
69
import { typedjson, useTypedLoaderData } from "remix-typedjson";
@@ -13,14 +16,10 @@ import {
1316
PageContainer,
1417
} from "~/components/layout/AppLayout";
1518
import { Button, LinkButton } from "~/components/primitives/Buttons";
16-
import {
17-
Dialog,
18-
DialogContent,
19-
DialogFooter,
20-
DialogHeader,
21-
DialogTrigger,
22-
} from "~/components/primitives/Dialog";
19+
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog";
2320
import { Fieldset } from "~/components/primitives/Fieldset";
21+
import { FormButtons } from "~/components/primitives/FormButtons";
22+
import { FormError } from "~/components/primitives/FormError";
2423
import { Header2, Header3 } from "~/components/primitives/Headers";
2524
import { Input } from "~/components/primitives/Input";
2625
import { InputGroup } from "~/components/primitives/InputGroup";
@@ -43,21 +42,22 @@ import { useOrganization } from "~/hooks/useOrganizations";
4342
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
4443
import { findProjectBySlug } from "~/models/project.server";
4544
import {
45+
ManageConcurrencyPresenter,
4646
type ConcurrencyResult,
4747
type EnvironmentWithConcurrency,
48-
ManageConcurrencyPresenter,
4948
} from "~/presenters/v3/ManageConcurrencyPresenter.server";
5049
import { getPlans } from "~/services/platform.v3.server";
51-
import { requireUser, requireUserId } from "~/services/session.server";
52-
import { formatCurrency } from "~/utils/numberFormatter";
53-
import { EnvironmentParamSchema, regionsPath, v3BillingPath } from "~/utils/pathBuilder";
54-
import { SetDefaultRegionService } from "~/v3/services/setDefaultRegion.server";
50+
import { requireUserId } from "~/services/session.server";
51+
import { formatCurrency, formatNumber } from "~/utils/numberFormatter";
52+
import { concurrencyPath, EnvironmentParamSchema, v3BillingPath } from "~/utils/pathBuilder";
53+
import { SetConcurrencyAddOnService } from "~/v3/services/setConcurrencyAddOn.server";
5554
import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
55+
import { SpinnerWhite } from "~/components/primitives/Spinner";
5656

5757
export const meta: MetaFunction = () => {
5858
return [
5959
{
60-
title: `Concurrency | Trigger.dev`,
60+
title: `Manage concurrency | Trigger.dev`,
6161
},
6262
];
6363
};
@@ -99,16 +99,16 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
9999
};
100100

101101
const FormSchema = z.object({
102-
regionId: z.string(),
102+
action: z.enum(["purchase", "quota-increase"]),
103+
amount: z.coerce.number().min(1, "Amount must be greater than 0"),
103104
});
104105

105106
export const action = async ({ request, params }: ActionFunctionArgs) => {
106-
const user = await requireUser(request);
107+
const userId = await requireUserId(request);
107108
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
108109

109-
const project = await findProjectBySlug(organizationSlug, projectParam, user.id);
110-
111-
const redirectPath = regionsPath(
110+
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
111+
const redirectPath = concurrencyPath(
112112
{ slug: organizationSlug },
113113
{ slug: projectParam },
114114
{ slug: envParam }
@@ -119,26 +119,34 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
119119
}
120120

121121
const formData = await request.formData();
122-
const parsedFormData = FormSchema.safeParse(Object.fromEntries(formData));
122+
const submission = parse(formData, { schema: FormSchema });
123123

124-
if (!parsedFormData.success) {
125-
throw redirectWithErrorMessage(redirectPath, request, "No region specified");
124+
if (!submission.value || submission.intent !== "submit") {
125+
return json(submission);
126126
}
127127

128-
const service = new SetDefaultRegionService();
128+
const service = new SetConcurrencyAddOnService();
129129
const [error, result] = await tryCatch(
130130
service.call({
131+
userId,
131132
projectId: project.id,
132-
regionId: parsedFormData.data.regionId,
133-
isAdmin: user.admin || user.isImpersonating,
133+
organizationId: project.organizationId,
134+
action: submission.value.action,
135+
amount: submission.value.amount,
134136
})
135137
);
136138

137139
if (error) {
138-
return redirectWithErrorMessage(redirectPath, request, error.message);
140+
submission.error.amount = [error instanceof Error ? error.message : "Unknown error"];
141+
return json(submission);
139142
}
140143

141-
return redirectWithSuccessMessage(redirectPath, request, `Set ${result.name} as default`);
144+
if (!result.success) {
145+
submission.error.amount = [result.error];
146+
return json(submission);
147+
}
148+
149+
return redirectWithSuccessMessage(redirectPath, request, "Concurrency updated successfully");
142150
};
143151

144152
export default function Page() {
@@ -149,6 +157,7 @@ export default function Page() {
149157
extraUnallocatedConcurrency,
150158
environments,
151159
concurrencyPricing,
160+
maxQuota,
152161
} = useTypedLoaderData<typeof loader>();
153162

154163
return (
@@ -181,6 +190,7 @@ export default function Page() {
181190
extraUnallocatedConcurrency={extraUnallocatedConcurrency}
182191
environments={environments}
183192
concurrencyPricing={concurrencyPricing}
193+
maxQuota={maxQuota}
184194
/>
185195
) : (
186196
<NotUpgradable environments={environments} />
@@ -198,6 +208,7 @@ function Upgradable({
198208
extraUnallocatedConcurrency,
199209
environments,
200210
concurrencyPricing,
211+
maxQuota,
201212
}: ConcurrencyResult) {
202213
const organization = useOrganization();
203214

@@ -215,7 +226,11 @@ function Upgradable({
215226
<div className="flex flex-col gap-2">
216227
<div className="flex items-center first-letter:pb-1">
217228
<Header3 className="grow">Extra concurrency</Header3>
218-
<PurchaseConcurrencyModal concurrencyPricing={concurrencyPricing} />
229+
<PurchaseConcurrencyModal
230+
concurrencyPricing={concurrencyPricing}
231+
extraConcurrency={extraConcurrency}
232+
maxQuota={maxQuota}
233+
/>
219234
</div>
220235
<Table>
221236
<TableBody>
@@ -343,13 +358,34 @@ function NotUpgradable({ environments }: { environments: EnvironmentWithConcurre
343358

344359
function PurchaseConcurrencyModal({
345360
concurrencyPricing,
361+
extraConcurrency,
362+
maxQuota,
346363
}: {
347364
concurrencyPricing: {
348365
stepSize: number;
349366
centsPerStep: number;
350367
};
368+
extraConcurrency: number;
369+
maxQuota: number;
351370
}) {
352-
const [amount, setAmount] = useState(0);
371+
const lastSubmission = useActionData();
372+
const [form, { amount }] = useForm({
373+
id: "purchase-concurrency",
374+
// TODO: type this
375+
lastSubmission: lastSubmission as any,
376+
onValidate({ formData }) {
377+
return parse(formData, { schema: FormSchema });
378+
},
379+
shouldRevalidate: "onSubmit",
380+
});
381+
382+
const [amountValue, setAmountValue] = useState(0);
383+
const navigation = useNavigation();
384+
console.log(navigation);
385+
const isLoading = navigation.state !== "idle" && navigation.formMethod === "POST";
386+
387+
const maximum = maxQuota - extraConcurrency;
388+
const isAboveMaxQuota = amountValue > maximum;
353389

354390
return (
355391
<Dialog>
@@ -360,56 +396,102 @@ function PurchaseConcurrencyModal({
360396
</DialogTrigger>
361397
<DialogContent>
362398
<DialogHeader>Purchase extra concurrency</DialogHeader>
363-
<div className="flex flex-col gap-4 pt-2">
364-
<Paragraph variant="base/bright" spacing>
365-
You can purchase bundles of {concurrencyPricing.stepSize} concurrency for
366-
{formatCurrency(concurrencyPricing.centsPerStep / 100, false)}/month. You’ll be billed
367-
monthly, with changes available after a full billing cycle.
368-
</Paragraph>
369-
<Form method="post">
399+
<Form method="post" {...form.props}>
400+
<div className="flex flex-col gap-4 pt-2">
401+
<Paragraph variant="base/bright" spacing>
402+
You can purchase bundles of {concurrencyPricing.stepSize} concurrency for{" "}
403+
{formatCurrency(concurrencyPricing.centsPerStep / 100, false)}/month. You’ll be billed
404+
monthly, with changes available after a full billing cycle.
405+
</Paragraph>
370406
<Fieldset>
371407
<InputGroup fullWidth>
372408
<Label htmlFor="amount" className="text-text-dimmed">
373409
Extra concurrency to purchase
374410
</Label>
375411
<InputNumberStepper
376-
type="number"
377-
id="amount"
378-
name="amount"
412+
{...conform.input(amount, { type: "number" })}
379413
step={concurrencyPricing.stepSize}
380414
min={0}
381-
value={amount}
382-
onChange={(e) => setAmount(Number(e.target.value))}
415+
value={amountValue}
416+
onChange={(e) => setAmountValue(Number(e.target.value))}
417+
disabled={isLoading}
383418
/>
419+
<FormError id={amount.errorId}>{amount.error}</FormError>
420+
<FormError>{form.error}</FormError>
384421
</InputGroup>
385422
</Fieldset>
386-
</Form>
387-
<div className="flex flex-col">
388-
<div className="grid grid-cols-2 border-b border-grid-dimmed pb-1">
389-
<Header3 className="font-normal text-text-dimmed">Summary</Header3>
390-
<Header3 className="justify-self-end font-normal text-text-dimmed">Total</Header3>
391-
</div>
392-
<div className="grid grid-cols-2 pt-2">
393-
<Header3 className="pb-0 font-normal">{amount}</Header3>
394-
<Header3 className="justify-self-end font-normal">
395-
{formatCurrency(
396-
(amount * concurrencyPricing.centsPerStep) / concurrencyPricing.stepSize / 100,
397-
false
398-
)}
399-
</Header3>
400-
</div>
401-
<div className="grid grid-cols-2 text-xs">
402-
<span className="text-text-dimmed">
403-
({amount / concurrencyPricing.stepSize} bundles @{" "}
404-
{formatCurrency(concurrencyPricing.centsPerStep / 100, false)}/mth)
405-
</span>
406-
<span className="justify-self-end text-text-dimmed">/mth</span>
407-
</div>
423+
{isAboveMaxQuota ? (
424+
<div className="flex flex-col pb-3">
425+
<Paragraph variant="small" className="text-warning">
426+
Your Org’s total would be {formatNumber(extraConcurrency + amountValue)}{" "}
427+
concurrency. Send us a request to purchase {formatNumber(amountValue - maximum)}{" "}
428+
more, or reduce the amount to buy more today.
429+
</Paragraph>
430+
</div>
431+
) : (
432+
<div className="flex flex-col pb-3">
433+
<div className="grid grid-cols-2 border-b border-grid-dimmed pb-1">
434+
<Header3 className="font-normal text-text-dimmed">Summary</Header3>
435+
<Header3 className="justify-self-end font-normal text-text-dimmed">Total</Header3>
436+
</div>
437+
<div className="grid grid-cols-2 pt-2">
438+
<Header3 className="pb-0 font-normal">{amountValue}</Header3>
439+
<Header3 className="justify-self-end font-normal">
440+
{formatCurrency(
441+
(amountValue * concurrencyPricing.centsPerStep) /
442+
concurrencyPricing.stepSize /
443+
100,
444+
false
445+
)}
446+
</Header3>
447+
</div>
448+
<div className="grid grid-cols-2 text-xs">
449+
<span className="text-text-dimmed">
450+
({amountValue / concurrencyPricing.stepSize} bundles @{" "}
451+
{formatCurrency(concurrencyPricing.centsPerStep / 100, false)}/mth)
452+
</span>
453+
<span className="justify-self-end text-text-dimmed">/mth</span>
454+
</div>
455+
</div>
456+
)}
408457
</div>
409-
</div>
410-
<DialogFooter>
411-
<Button variant="primary/small">Purchase</Button>
412-
</DialogFooter>
458+
<FormButtons
459+
confirmButton={
460+
isAboveMaxQuota ? (
461+
<>
462+
<input type="hidden" name="action" value="quota-increase" />
463+
<Button
464+
LeadingIcon={isLoading ? SpinnerWhite : EnvelopeIcon}
465+
variant="primary/medium"
466+
type="submit"
467+
disabled={isLoading}
468+
>
469+
{`Send request for ${formatNumber(amountValue - maximum)}`}
470+
</Button>
471+
</>
472+
) : (
473+
<>
474+
<input type="hidden" name="action" value="purchase" />
475+
<Button
476+
variant="primary/medium"
477+
type="submit"
478+
disabled={isLoading}
479+
LeadingIcon={isLoading ? SpinnerWhite : undefined}
480+
>
481+
Purchase
482+
</Button>
483+
</>
484+
)
485+
}
486+
cancelButton={
487+
<DialogClose asChild>
488+
<Button variant="tertiary/medium" disabled={isLoading}>
489+
Cancel
490+
</Button>
491+
</DialogClose>
492+
}
493+
/>
494+
</Form>
413495
</DialogContent>
414496
</Dialog>
415497
);

0 commit comments

Comments
 (0)