11import { PlusIcon } from "@heroicons/react/20/solid" ;
2- import { type MetaFunction } from "@remix-run/react" ;
2+ import { Form , type MetaFunction } from "@remix-run/react" ;
33import { type ActionFunctionArgs , type LoaderFunctionArgs } from "@remix-run/server-runtime" ;
44import { tryCatch } from "@trigger.dev/core" ;
5+ import { useState } from "react" ;
56import { typedjson , useTypedLoaderData } from "remix-typedjson" ;
67import { z } from "zod" ;
78import { AdminDebugTooltip } from "~/components/admin/debugTooltip" ;
@@ -12,7 +13,19 @@ import {
1213 PageContainer ,
1314} from "~/components/layout/AppLayout" ;
1415import { Button , LinkButton } from "~/components/primitives/Buttons" ;
16+ import {
17+ Dialog ,
18+ DialogContent ,
19+ DialogFooter ,
20+ DialogHeader ,
21+ DialogTrigger ,
22+ } from "~/components/primitives/Dialog" ;
23+ import { Fieldset } from "~/components/primitives/Fieldset" ;
1524import { Header2 , Header3 } from "~/components/primitives/Headers" ;
25+ import { Input } from "~/components/primitives/Input" ;
26+ import { InputGroup } from "~/components/primitives/InputGroup" ;
27+ import { InputNumberStepper } from "~/components/primitives/InputNumberStepper" ;
28+ import { Label } from "~/components/primitives/Label" ;
1629import { NavBar , PageAccessories , PageTitle } from "~/components/primitives/PageHeader" ;
1730import { Paragraph } from "~/components/primitives/Paragraph" ;
1831import * as Property from "~/components/primitives/PropertyTable" ;
@@ -34,7 +47,9 @@ import {
3447 type EnvironmentWithConcurrency ,
3548 ManageConcurrencyPresenter ,
3649} from "~/presenters/v3/ManageConcurrencyPresenter.server" ;
50+ import { getPlans } from "~/services/platform.v3.server" ;
3751import { requireUser , requireUserId } from "~/services/session.server" ;
52+ import { formatCurrency } from "~/utils/numberFormatter" ;
3853import { EnvironmentParamSchema , regionsPath , v3BillingPath } from "~/utils/pathBuilder" ;
3954import { SetDefaultRegionService } from "~/v3/services/setDefaultRegion.server" ;
4055import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route" ;
@@ -75,6 +90,11 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
7590 } ) ;
7691 }
7792
93+ const plans = await tryCatch ( getPlans ( ) ) ;
94+ if ( ! plans ) {
95+ throw new Response ( null , { status : 404 , statusText : "Plans not found" } ) ;
96+ }
97+
7898 return typedjson ( result ) ;
7999} ;
80100
@@ -128,6 +148,7 @@ export default function Page() {
128148 extraAllocatedConcurrency,
129149 extraUnallocatedConcurrency,
130150 environments,
151+ concurrencyPricing,
131152 } = useTypedLoaderData < typeof loader > ( ) ;
132153
133154 return (
@@ -159,6 +180,7 @@ export default function Page() {
159180 extraAllocatedConcurrency = { extraAllocatedConcurrency }
160181 extraUnallocatedConcurrency = { extraUnallocatedConcurrency }
161182 environments = { environments }
183+ concurrencyPricing = { concurrencyPricing }
162184 />
163185 ) : (
164186 < NotUpgradable environments = { environments } />
@@ -175,6 +197,7 @@ function Upgradable({
175197 extraAllocatedConcurrency,
176198 extraUnallocatedConcurrency,
177199 environments,
200+ concurrencyPricing,
178201} : ConcurrencyResult ) {
179202 const organization = useOrganization ( ) ;
180203
@@ -192,20 +215,16 @@ function Upgradable({
192215 < div className = "flex flex-col gap-2" >
193216 < div className = "flex items-center first-letter:pb-1" >
194217 < Header3 className = "grow" > Extra concurrency</ Header3 >
195- < Button variant = "primary/small" LeadingIcon = { PlusIcon } >
196- Purchase extra concurrency...
197- </ Button >
218+ < PurchaseConcurrencyModal concurrencyPricing = { concurrencyPricing } />
198219 </ div >
199220 < Table >
200- < TableHeader >
221+ < TableBody >
201222 < TableRow >
202- < TableHeaderCell className = "pl-0" > Extra concurrency purchased</ TableHeaderCell >
203- < TableHeaderCell alignment = "right" className = "text-text-bright" >
223+ < TableCell className = "pl-0 text-text-bright " > Extra concurrency purchased</ TableCell >
224+ < TableCell alignment = "right" className = "text-text-bright" >
204225 { extraConcurrency }
205- </ TableHeaderCell >
226+ </ TableCell >
206227 </ TableRow >
207- </ TableHeader >
208- < TableBody >
209228 < TableRow >
210229 < TableCell > Allocated concurrency</ TableCell >
211230 < TableCell alignment = "right" className = "text-text-bright" >
@@ -233,7 +252,7 @@ function Upgradable({
233252 < TableRow >
234253 < TableHeaderCell className = "pl-0" > Environment</ TableHeaderCell >
235254 < TableHeaderCell alignment = "right" >
236- < span className = "flex items-center gap-x-1" >
255+ < span className = "flex items-center justify-end gap-x-1" >
237256 Included{ " " }
238257 < InfoIconTooltip content = "This is the included concurrency based on your plan." />
239258 </ span >
@@ -250,10 +269,20 @@ function Upgradable({
250269 </ TableCell >
251270 < TableCell alignment = "right" > { environment . planConcurrencyLimit } </ TableCell >
252271 < TableCell alignment = "right" className = "text-text-bright" >
253- { Math . max (
254- 0 ,
255- environment . maximumConcurrencyLimit - environment . planConcurrencyLimit
256- ) }
272+ < div className = "flex items-center justify-end" >
273+ < Input
274+ type = "number"
275+ variant = "secondary-small"
276+ className = "text-right"
277+ containerClassName = "w-16 bg-transparent"
278+ fullWidth = { false }
279+ defaultValue = { Math . max (
280+ 0 ,
281+ environment . maximumConcurrencyLimit - environment . planConcurrencyLimit
282+ ) }
283+ min = "0"
284+ />
285+ </ div >
257286 </ TableCell >
258287 < TableCell alignment = "right" > { environment . maximumConcurrencyLimit } </ TableCell >
259288 </ TableRow >
@@ -311,3 +340,77 @@ function NotUpgradable({ environments }: { environments: EnvironmentWithConcurre
311340 </ div >
312341 ) ;
313342}
343+
344+ function PurchaseConcurrencyModal ( {
345+ concurrencyPricing,
346+ } : {
347+ concurrencyPricing : {
348+ stepSize : number ;
349+ centsPerStep : number ;
350+ } ;
351+ } ) {
352+ const [ amount , setAmount ] = useState ( 0 ) ;
353+
354+ return (
355+ < Dialog >
356+ < DialogTrigger asChild >
357+ < Button variant = "primary/small" LeadingIcon = { PlusIcon } >
358+ Purchase extra concurrency
359+ </ Button >
360+ </ DialogTrigger >
361+ < DialogContent >
362+ < 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" >
370+ < Fieldset >
371+ < InputGroup fullWidth >
372+ < Label htmlFor = "amount" className = "text-text-dimmed" >
373+ Extra concurrency to purchase
374+ </ Label >
375+ < InputNumberStepper
376+ type = "number"
377+ id = "amount"
378+ name = "amount"
379+ step = { concurrencyPricing . stepSize }
380+ min = { 0 }
381+ value = { amount }
382+ onChange = { ( e ) => setAmount ( Number ( e . target . value ) ) }
383+ />
384+ </ InputGroup >
385+ </ 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 >
408+ </ div >
409+ </ div >
410+ < DialogFooter >
411+ < Button variant = "primary/small" > Purchase</ Button >
412+ </ DialogFooter >
413+ </ DialogContent >
414+ </ Dialog >
415+ ) ;
416+ }
0 commit comments