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" ;
47import { tryCatch } from "@trigger.dev/core" ;
58import { useState } from "react" ;
69import { typedjson , useTypedLoaderData } from "remix-typedjson" ;
@@ -13,14 +16,10 @@ import {
1316 PageContainer ,
1417} from "~/components/layout/AppLayout" ;
1518import { 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" ;
2320import { Fieldset } from "~/components/primitives/Fieldset" ;
21+ import { FormButtons } from "~/components/primitives/FormButtons" ;
22+ import { FormError } from "~/components/primitives/FormError" ;
2423import { Header2 , Header3 } from "~/components/primitives/Headers" ;
2524import { Input } from "~/components/primitives/Input" ;
2625import { InputGroup } from "~/components/primitives/InputGroup" ;
@@ -43,21 +42,22 @@ import { useOrganization } from "~/hooks/useOrganizations";
4342import { redirectWithErrorMessage , redirectWithSuccessMessage } from "~/models/message.server" ;
4443import { findProjectBySlug } from "~/models/project.server" ;
4544import {
45+ ManageConcurrencyPresenter ,
4646 type ConcurrencyResult ,
4747 type EnvironmentWithConcurrency ,
48- ManageConcurrencyPresenter ,
4948} from "~/presenters/v3/ManageConcurrencyPresenter.server" ;
5049import { 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" ;
5554import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route" ;
55+ import { SpinnerWhite } from "~/components/primitives/Spinner" ;
5656
5757export 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
101101const 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
105106export 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
144152export 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
344359function 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