diff --git a/.gitignore b/.gitignore
index b6b85df6d..8d1a59ed8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -39,6 +39,7 @@ next-env.d.ts
# AI agents and related files
CLAUDE.md
+.cursor
.agent
diff --git a/src/__test__/unit/currency.test.ts b/src/__test__/unit/currency.test.ts
new file mode 100644
index 000000000..721aed311
--- /dev/null
+++ b/src/__test__/unit/currency.test.ts
@@ -0,0 +1,33 @@
+import { describe, expect, it } from 'vitest'
+import {
+ CurrencyInputSchema,
+ sanitizeCurrencyInput,
+} from '@/features/dashboard/limits/currency-input'
+
+describe('sanitizeCurrencyInput', () => {
+ it.each([
+ ['$1,250', '1250'],
+ ['1250', '1250'],
+ ['abc', ''],
+ ['', ''],
+ ])('returns %p -> %p', (value: string, expected: string) => {
+ expect(sanitizeCurrencyInput(value)).toBe(expected)
+ })
+})
+
+describe('CurrencyInputSchema', () => {
+ it.each(['100', '1', ' 100 ', '999999'])('accepts %p', (value: string) => {
+ expect(CurrencyInputSchema.safeParse(value).success).toBe(true)
+ })
+
+ it.each([
+ '',
+ ' ',
+ '12.50',
+ '1,250',
+ 'abc',
+ '0',
+ ])('rejects %p', (value: string) => {
+ expect(CurrencyInputSchema.safeParse(value).success).toBe(false)
+ })
+})
diff --git a/src/app/dashboard/[teamSlug]/limits/page.tsx b/src/app/dashboard/[teamSlug]/limits/page.tsx
index e97b7f7b9..334109859 100644
--- a/src/app/dashboard/[teamSlug]/limits/page.tsx
+++ b/src/app/dashboard/[teamSlug]/limits/page.tsx
@@ -1,6 +1,6 @@
-import UsageLimits from '@/features/dashboard/limits/usage-limits'
+import { Page } from '@/features/dashboard/layouts/page'
+import { UsageLimits } from '@/features/dashboard/limits/usage-limits'
import { HydrateClient, prefetch, trpc } from '@/trpc/server'
-import Frame from '@/ui/frame'
interface LimitsPageProps {
params: Promise<{ teamSlug: string }>
@@ -13,14 +13,9 @@ export default async function LimitsPage({ params }: LimitsPageProps) {
return (
-
+
-
+
)
}
diff --git a/src/features/dashboard/layouts/page.tsx b/src/features/dashboard/layouts/page.tsx
index ab28a6947..505068d3d 100644
--- a/src/features/dashboard/layouts/page.tsx
+++ b/src/features/dashboard/layouts/page.tsx
@@ -7,7 +7,7 @@ interface PageProps {
}
export const Page = ({ children, className }: PageProps) => (
-
+
{children}
)
diff --git a/src/features/dashboard/layouts/wrapper.tsx b/src/features/dashboard/layouts/wrapper.tsx
index 2c6c4d078..5cc3f9698 100644
--- a/src/features/dashboard/layouts/wrapper.tsx
+++ b/src/features/dashboard/layouts/wrapper.tsx
@@ -11,7 +11,7 @@ export function DefaultDashboardLayout({
}) {
return (
-
+
(
+
+)
diff --git a/src/features/dashboard/limits/alert-card.tsx b/src/features/dashboard/limits/alert-card.tsx
deleted file mode 100644
index d67bcd719..000000000
--- a/src/features/dashboard/limits/alert-card.tsx
+++ /dev/null
@@ -1,40 +0,0 @@
-'use client'
-
-import type { BillingLimit } from '@/core/modules/billing/models'
-import { useRouteParams } from '@/lib/hooks/use-route-params'
-import { Card, CardContent, CardHeader, CardTitle } from '@/ui/primitives/card'
-import { useDashboard } from '../context'
-import LimitForm from './limit-form'
-
-interface AlertCardProps {
- className?: string
- value: BillingLimit['alert_amount_gte']
-}
-
-export default function AlertCard({ className, value }: AlertCardProps) {
- const { team } = useDashboard()
- const { teamSlug } = useRouteParams<'/dashboard/[teamSlug]/limits'>()
-
- if (!team) return null
-
- return (
-
-
- Set a Budget Alert
-
-
-
-
- If your team exceeds this threshold in a given month, you'll
- receive an alert notification to {team.email} . This will not
- result in any interruptions to your service.
-
-
-
- )
-}
diff --git a/src/features/dashboard/limits/ascii-icon.tsx b/src/features/dashboard/limits/ascii-icon.tsx
new file mode 100644
index 000000000..addc491b6
--- /dev/null
+++ b/src/features/dashboard/limits/ascii-icon.tsx
@@ -0,0 +1,52 @@
+import { type CSSProperties, Fragment } from 'react'
+import { cn } from '@/lib/utils'
+
+type AsciiLineSegment = {
+ className?: string
+ text: string
+}
+
+type AsciiLine = string | readonly AsciiLineSegment[]
+
+const TEXT_STYLE = {
+ fontFamily: 'var(--font-mono)',
+ fontFeatureSettings: "'ss03' 1",
+ fontSize: '3.802px',
+ fontWeight: 600,
+ letterSpacing: '-0.038px',
+ lineHeight: '4px',
+} satisfies CSSProperties
+
+interface AsciiIconProps {
+ className?: string
+ lines: readonly AsciiLine[]
+}
+
+const renderAsciiLine = (line: AsciiLine) => {
+ if (typeof line === 'string') return line
+
+ return line.map((segment, index) => (
+
+ {segment.className ? (
+ {segment.text}
+ ) : (
+ segment.text
+ )}
+
+ ))
+}
+
+export const AsciiIcon = ({ className, lines }: AsciiIconProps) => (
+
+
+ {lines.map((line, index) => (
+
+ {renderAsciiLine(line)}
+
+ ))}
+
+
+)
diff --git a/src/features/dashboard/limits/currency-input.ts b/src/features/dashboard/limits/currency-input.ts
new file mode 100644
index 000000000..a31cd82f9
--- /dev/null
+++ b/src/features/dashboard/limits/currency-input.ts
@@ -0,0 +1,14 @@
+import { z } from 'zod'
+
+// Validates a string as a positive whole USD amount. Example: "1250" -> valid, "abc" -> invalid.
+const CurrencyInputSchema = z
+ .string()
+ .trim()
+ .min(1, 'Enter a value.')
+ .regex(/^\d+$/, 'Enter a whole USD amount.')
+ .refine((value) => Number(value) >= 1, 'Value must be at least 1.')
+
+// Removes non-digits from a USD draft value. Example: "$1,250" -> "1250".
+const sanitizeCurrencyInput = (value: string) => value.replace(/\D+/g, '')
+
+export { CurrencyInputSchema, sanitizeCurrencyInput }
diff --git a/src/features/dashboard/limits/focus-block-input.ts b/src/features/dashboard/limits/focus-block-input.ts
new file mode 100644
index 000000000..5f3baae02
--- /dev/null
+++ b/src/features/dashboard/limits/focus-block-input.ts
@@ -0,0 +1,22 @@
+import type { MouseEvent } from 'react'
+
+const INTERACTIVE_ELEMENT_SELECTOR =
+ 'button, input, textarea, select, a, [role="button"], [data-no-input-focus]'
+
+// Focuses the first editable input in a block, e.g. block click -> input focused, button click -> unchanged.
+const focusBlockInputOnMouseDown = (event: MouseEvent) => {
+ if (!(event.target instanceof Element)) return
+ if (event.target.closest(INTERACTIVE_ELEMENT_SELECTOR)) return
+
+ const input = event.currentTarget.querySelector('input')
+ if (!(input instanceof HTMLInputElement)) return
+ if (input.disabled || input.readOnly) return
+
+ event.preventDefault()
+ input.focus()
+
+ const cursorPosition = input.value.length
+ input.setSelectionRange(cursorPosition, cursorPosition)
+}
+
+export { focusBlockInputOnMouseDown }
diff --git a/src/features/dashboard/limits/limit-ascii-icon.tsx b/src/features/dashboard/limits/limit-ascii-icon.tsx
new file mode 100644
index 000000000..7e9520237
--- /dev/null
+++ b/src/features/dashboard/limits/limit-ascii-icon.tsx
@@ -0,0 +1,70 @@
+import { AsciiIcon } from './ascii-icon'
+
+const INACTIVE_LINES = [
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ---**--- ',
+ ' -**------**- ',
+ ' -**- - -**- ',
+ ' ** -**- ** ',
+ ' *- --- -* ',
+ ' ** ** ',
+ ' -**- -**- ',
+ ' -- -- ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+]
+
+const ACTIVE_LINES = [
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ---**--- ',
+ [
+ { text: ' -**-----' },
+ { className: 'text-accent-secondary-error-highlight', text: '-' },
+ { text: '**- ' },
+ ],
+ ' -**- - -**- ',
+ [
+ { text: ' ' },
+ { className: 'text-accent-main-highlight', text: '**' },
+ { text: ' -**- ** ' },
+ ],
+ [
+ { text: ' *- -' },
+ { className: 'text-accent-main-highlight', text: '-' },
+ { text: '- -* ' },
+ ],
+ ' ** ** ',
+ ' -**- -**- ',
+ [
+ { text: ' -- ' },
+ { className: 'text-accent-main-highlight', text: '-' },
+ { text: '- ' },
+ ],
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+]
+
+interface LimitAsciiIconProps {
+ active?: boolean
+ className?: string
+}
+
+export const LimitAsciiIcon = ({
+ active = false,
+ className,
+}: LimitAsciiIconProps) => (
+
+)
diff --git a/src/features/dashboard/limits/limit-card.tsx b/src/features/dashboard/limits/limit-card.tsx
deleted file mode 100644
index f668eb58f..000000000
--- a/src/features/dashboard/limits/limit-card.tsx
+++ /dev/null
@@ -1,51 +0,0 @@
-'use client'
-
-import type { BillingLimit } from '@/core/modules/billing/models'
-import { useRouteParams } from '@/lib/hooks/use-route-params'
-import { Card, CardContent, CardHeader, CardTitle } from '@/ui/primitives/card'
-import { useDashboard } from '../context'
-import LimitForm from './limit-form'
-
-interface LimitCardProps {
- className?: string
- value: BillingLimit['limit_amount_gte']
-}
-
-export default function LimitCard({ className, value }: LimitCardProps) {
- const { team } = useDashboard()
- const { teamSlug } = useRouteParams<'/dashboard/[teamSlug]/limits'>()
-
- if (!team) return null
-
- return (
-
-
- Enable Budget Limit
-
-
-
-
- If your team exceeds this threshold in a given billing period,
- subsequent API requests will be blocked.
-
-
- You will automatically receive email notifications when your usage
- reaches 50% , 80% , 90% , and 100% of this
- limit.
-
-
-
- Caution: Enabling a budget limit may cause interruptions to
- your service. Once your Budget Limit is reached, your team will not be
- able to create new sandboxes in the given billing period unless the
- limit is increased.
-
-
-
- )
-}
diff --git a/src/features/dashboard/limits/limit-form.tsx b/src/features/dashboard/limits/limit-form.tsx
deleted file mode 100644
index 4e5bd7d35..000000000
--- a/src/features/dashboard/limits/limit-form.tsx
+++ /dev/null
@@ -1,232 +0,0 @@
-'use client'
-
-import { zodResolver } from '@hookform/resolvers/zod'
-import { useMutation, useQueryClient } from '@tanstack/react-query'
-import { useState } from 'react'
-import { useForm } from 'react-hook-form'
-import { z } from 'zod'
-import {
- defaultErrorToast,
- defaultSuccessToast,
- useToast,
-} from '@/lib/hooks/use-toast'
-import { cn } from '@/lib/utils'
-import { useTRPC } from '@/trpc/client'
-import { NumberInput } from '@/ui/number-input'
-import { Button } from '@/ui/primitives/button'
-import {
- Form,
- FormControl,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
-} from '@/ui/primitives/form'
-
-interface LimitFormProps {
- teamSlug: string
- className?: string
- originalValue: number | null
- type: 'limit' | 'alert'
-}
-
-const formSchema = z.object({
- value: z
- .number()
- .min(0, 'Value must be greater than or equal to 0')
- .nullable(),
-})
-
-type FormData = z.infer
-
-export default function LimitForm({
- teamSlug,
- className,
- originalValue,
- type,
-}: LimitFormProps) {
- 'use no memo'
-
- const [isEditing, setIsEditing] = useState(false)
- const { toast } = useToast()
- const trpc = useTRPC()
- const queryClient = useQueryClient()
-
- const form = useForm({
- resolver: zodResolver(formSchema),
- defaultValues: {
- value: originalValue,
- },
- })
-
- const limitsQueryKey = trpc.billing.getLimits.queryOptions({
- teamSlug,
- }).queryKey
-
- const setLimitMutation = useMutation(
- trpc.billing.setLimit.mutationOptions({
- onSuccess: () => {
- toast(
- defaultSuccessToast(
- `Billing ${type === 'limit' ? 'limit' : 'alert'} saved.`
- )
- )
- setIsEditing(false)
- queryClient.invalidateQueries({ queryKey: limitsQueryKey })
- },
- onError: (error) => {
- toast(
- defaultErrorToast(
- error.message ||
- `Failed to save billing ${type === 'limit' ? 'limit' : 'alert'}.`
- )
- )
- },
- })
- )
-
- const clearLimitMutation = useMutation(
- trpc.billing.clearLimit.mutationOptions({
- onSuccess: () => {
- toast(
- defaultSuccessToast(
- `Billing ${type === 'limit' ? 'limit' : 'alert'} cleared.`
- )
- )
- setIsEditing(false)
- form.reset({ value: null })
- queryClient.invalidateQueries({ queryKey: limitsQueryKey })
- },
- onError: () => {
- toast(
- defaultErrorToast(
- `Failed to clear billing ${type === 'limit' ? 'limit' : 'alert'}.`
- )
- )
- },
- })
- )
-
- const handleSave = (data: FormData) => {
- if (!data.value) {
- toast(defaultErrorToast('Input cannot be empty.'))
- return
- }
-
- setLimitMutation.mutate({
- teamSlug,
- type,
- value: data.value,
- })
- }
-
- const handleClear = () => {
- clearLimitMutation.mutate({
- teamSlug,
- type,
- })
- }
-
- const isSaving = setLimitMutation.isPending
- const isClearing = clearLimitMutation.isPending
-
- if (originalValue === null || isEditing) {
- return (
-
-
- )
- }
-
- return (
-
-
- {'$ '}
-
- {originalValue?.toLocaleString()}
-
-
-
setIsEditing(true)}
- >
- Edit
-
-
- Clear
-
-
- )
-}
diff --git a/src/features/dashboard/limits/remove-usage-limit-dialog.tsx b/src/features/dashboard/limits/remove-usage-limit-dialog.tsx
new file mode 100644
index 000000000..ece934309
--- /dev/null
+++ b/src/features/dashboard/limits/remove-usage-limit-dialog.tsx
@@ -0,0 +1,129 @@
+'use client'
+
+import { useMutation, useQueryClient } from '@tanstack/react-query'
+import { useState } from 'react'
+import type { BillingLimit } from '@/core/modules/billing/models'
+import {
+ defaultErrorToast,
+ defaultSuccessToast,
+ useToast,
+} from '@/lib/hooks/use-toast'
+import { useTRPC } from '@/trpc/client'
+import { Button } from '@/ui/primitives/button'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogTitle,
+ DialogTrigger,
+} from '@/ui/primitives/dialog'
+import { TrashIcon } from '@/ui/primitives/icons'
+
+interface RemoveUsageLimitDialogProps {
+ disabled?: boolean
+ hideTrigger?: boolean
+ onRemoved: () => void
+ onOpenChange?: (open: boolean) => void
+ open?: boolean
+ teamSlug: string
+}
+
+export const RemoveUsageLimitDialog = ({
+ disabled = false,
+ hideTrigger = false,
+ onRemoved,
+ onOpenChange,
+ open,
+ teamSlug,
+}: RemoveUsageLimitDialogProps) => {
+ const [internalIsOpen, setInternalIsOpen] = useState(false)
+ const { toast } = useToast()
+ const trpc = useTRPC()
+ const queryClient = useQueryClient()
+ const isControlled = open !== undefined
+ const isOpen = isControlled ? open : internalIsOpen
+
+ const setIsOpen = (nextOpen: boolean) => {
+ if (!isControlled) setInternalIsOpen(nextOpen)
+ onOpenChange?.(nextOpen)
+ }
+
+ const limitsQueryKey = trpc.billing.getLimits.queryOptions({
+ teamSlug,
+ }).queryKey
+
+ const clearLimitMutation = useMutation(
+ trpc.billing.clearLimit.mutationOptions({
+ onSuccess: () => {
+ queryClient.setQueryData(
+ limitsQueryKey,
+ (limits) => {
+ if (!limits) return limits
+ return { ...limits, limit_amount_gte: null }
+ }
+ )
+ toast(defaultSuccessToast('Limit removed successfully.'))
+ onRemoved()
+ setIsOpen(false)
+ queryClient.invalidateQueries({ queryKey: limitsQueryKey })
+ },
+ onError: (error) => {
+ toast(
+ defaultErrorToast(error.message || 'Failed to remove billing limit.')
+ )
+ },
+ })
+ )
+
+ return (
+
+ {!hideTrigger && (
+
+
+
+ Remove
+
+
+ )}
+
+
+
+ Remove usage limit?
+
+ API limits will be removed and usage will become uncapped
+
+
+
+ setIsOpen(false)}
+ >
+ Cancel
+
+
+ clearLimitMutation.mutate({ teamSlug, type: 'limit' })
+ }
+ >
+
+ Remove
+
+
+
+
+
+ )
+}
diff --git a/src/features/dashboard/limits/set-usage-limit-dialog.tsx b/src/features/dashboard/limits/set-usage-limit-dialog.tsx
new file mode 100644
index 000000000..45d0f1b07
--- /dev/null
+++ b/src/features/dashboard/limits/set-usage-limit-dialog.tsx
@@ -0,0 +1,86 @@
+'use client'
+
+import { Button } from '@/ui/primitives/button'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogTitle,
+ DialogTrigger,
+} from '@/ui/primitives/dialog'
+import { WarningIcon } from '@/ui/primitives/icons'
+
+interface SetUsageLimitDialogProps {
+ confirmDisabled: boolean
+ loading: boolean
+ onConfirm: () => void
+ onOpenChange: (open: boolean) => void
+ open: boolean
+ triggerDisabled: boolean
+ title: string
+}
+
+export const SetUsageLimitDialog = ({
+ confirmDisabled,
+ loading,
+ onConfirm,
+ onOpenChange,
+ open,
+ title,
+ triggerDisabled,
+}: SetUsageLimitDialogProps) => {
+ return (
+
+
+
+ Set
+
+
+
+
+
+
+
+ If your API usage hits this limit, all requests—including sandbox
+ creation—will be blocked.
+
+
+ This may disrupt your services.
+
+
+
+ onOpenChange(false)}
+ >
+ Cancel
+
+
+ Set
+
+
+
+
+
+ )
+}
diff --git a/src/features/dashboard/limits/usage-alert-form.tsx b/src/features/dashboard/limits/usage-alert-form.tsx
new file mode 100644
index 000000000..820d00427
--- /dev/null
+++ b/src/features/dashboard/limits/usage-alert-form.tsx
@@ -0,0 +1,282 @@
+'use client'
+
+import { zodResolver } from '@hookform/resolvers/zod'
+import { useMutation, useQueryClient } from '@tanstack/react-query'
+import type { FormEvent } from 'react'
+import { useEffect, useRef, useState } from 'react'
+import { Controller, useForm } from 'react-hook-form'
+import { z } from 'zod'
+import type { BillingLimit } from '@/core/modules/billing/models'
+import {
+ defaultErrorToast,
+ defaultSuccessToast,
+ useToast,
+} from '@/lib/hooks/use-toast'
+import { cn } from '@/lib/utils'
+import { formatNumber } from '@/lib/utils/formatting'
+import { useTRPC } from '@/trpc/client'
+import { Button } from '@/ui/primitives/button'
+import { EditIcon, TrashIcon } from '@/ui/primitives/icons'
+import { Input } from '@/ui/primitives/input'
+import { CurrencyInputSchema, sanitizeCurrencyInput } from './currency-input'
+import { focusBlockInputOnMouseDown } from './focus-block-input'
+
+const AlertFormSchema = z.object({
+ amount: CurrencyInputSchema,
+})
+
+type AlertFormValues = z.infer
+
+interface UsageAlertFormProps {
+ className?: string
+ originalValue: number | null
+ teamSlug: string
+}
+
+export const UsageAlertForm = ({
+ className,
+ originalValue,
+ teamSlug,
+}: UsageAlertFormProps) => {
+ const hasMountedRef = useRef(false)
+ const inputRef = useRef(null)
+ const [isEditing, setIsEditing] = useState(originalValue === null)
+ const { toast } = useToast()
+ const trpc = useTRPC()
+ const queryClient = useQueryClient()
+ const formattedOriginalValue =
+ originalValue === null ? '' : formatNumber(originalValue)
+
+ const limitsQueryKey = trpc.billing.getLimits.queryOptions({
+ teamSlug,
+ }).queryKey
+
+ const form = useForm({
+ resolver: zodResolver(AlertFormSchema),
+ mode: 'onChange',
+ defaultValues: {
+ amount: formattedOriginalValue,
+ },
+ })
+
+ const draftValue = form.watch('amount')
+
+ useEffect(() => {
+ form.reset({
+ amount: formattedOriginalValue,
+ })
+ setIsEditing(originalValue === null)
+ }, [formattedOriginalValue, originalValue, form.reset])
+
+ useEffect(() => {
+ if (!hasMountedRef.current) {
+ hasMountedRef.current = true
+ return
+ }
+
+ if (!isEditing) return
+ inputRef.current?.focus()
+ const inputLength = inputRef.current?.value.length ?? 0
+ inputRef.current?.setSelectionRange(inputLength, inputLength)
+ }, [isEditing])
+
+ const setAlertMutation = useMutation(
+ trpc.billing.setLimit.mutationOptions({
+ onSuccess: (_, variables) => {
+ queryClient.setQueryData(
+ limitsQueryKey,
+ (limits) => {
+ if (!limits) return limits
+ return { ...limits, alert_amount_gte: variables.value }
+ }
+ )
+ toast(defaultSuccessToast('Alert set successfully.'))
+ form.reset({ amount: formatNumber(variables.value) })
+ setIsEditing(false)
+ queryClient.invalidateQueries({ queryKey: limitsQueryKey })
+ },
+ onError: (error) => {
+ toast(
+ defaultErrorToast(error.message || 'Failed to save billing alert.')
+ )
+ },
+ })
+ )
+
+ const clearAlertMutation = useMutation(
+ trpc.billing.clearLimit.mutationOptions({
+ onSuccess: () => {
+ queryClient.setQueryData(
+ limitsQueryKey,
+ (limits) => {
+ if (!limits) return limits
+ return { ...limits, alert_amount_gte: null }
+ }
+ )
+ toast(defaultSuccessToast('Alert removed successfully.'))
+ form.reset({ amount: '' })
+ setIsEditing(true)
+ queryClient.invalidateQueries({ queryKey: limitsQueryKey })
+ },
+ onError: (error) => {
+ toast(
+ defaultErrorToast(error.message || 'Failed to remove billing alert.')
+ )
+ },
+ })
+ )
+
+ const clearAlert = (): void =>
+ clearAlertMutation.mutate({ teamSlug, type: 'alert' })
+
+ const isMutating = setAlertMutation.isPending || clearAlertMutation.isPending
+ const nextValue = form.formState.isValid ? Number(draftValue) : null
+ const isClearIntent =
+ isEditing && originalValue !== null && draftValue.length === 0
+ const canSave =
+ isEditing &&
+ (isClearIntent || (nextValue !== null && nextValue !== originalValue)) &&
+ !isMutating
+ const isInputEditable = isEditing || originalValue === null
+ const shouldShowCancel =
+ isEditing && (originalValue !== null || draftValue.length > 0)
+
+ const startEditing = (): void => {
+ if (originalValue === null) return
+ form.reset({ amount: formattedOriginalValue })
+ setIsEditing(true)
+ }
+
+ const handleCancel = (): void => {
+ const activeElement = document.activeElement
+ if (activeElement instanceof HTMLElement) activeElement.blur()
+ inputRef.current?.blur()
+
+ if (originalValue === null) {
+ form.reset({ amount: '' })
+ return
+ }
+
+ form.reset({ amount: formattedOriginalValue })
+ setIsEditing(false)
+ }
+
+ const handleSubmit = async (event: FormEvent) => {
+ event.preventDefault()
+ if (!isEditing) return
+
+ if (isClearIntent) {
+ clearAlert()
+ return
+ }
+
+ const isValid = await form.trigger()
+ if (!isValid) {
+ toast(
+ defaultErrorToast(
+ form.formState.errors.amount?.message ||
+ 'Enter a billing alert amount.'
+ )
+ )
+ return
+ }
+
+ const value = Number(form.getValues('amount'))
+ if (value === originalValue) return
+ setAlertMutation.mutate({ teamSlug, type: 'alert', value })
+ }
+
+ return (
+
+ )
+}
diff --git a/src/features/dashboard/limits/usage-alert-section.tsx b/src/features/dashboard/limits/usage-alert-section.tsx
new file mode 100644
index 000000000..3dea37b7c
--- /dev/null
+++ b/src/features/dashboard/limits/usage-alert-section.tsx
@@ -0,0 +1,62 @@
+'use client'
+
+import { cn } from '@/lib/utils'
+import { formatNumber } from '@/lib/utils/formatting'
+import { AlertAsciiIcon } from './alert-ascii-icon'
+import { UsageAlertForm } from './usage-alert-form'
+
+interface UsageAlertSectionProps {
+ className?: string
+ email: string
+ teamSlug: string
+ value: number | null
+}
+
+const UsageAlertSectionInfo = ({
+ email,
+ value,
+}: Pick) => {
+ const thresholdText =
+ value === null ? 'this threshold' : `the $${formatNumber(value)} threshold`
+
+ return (
+
+ Informative alert will be sent to
+ {email}
+
+ {' when '}
+
+ {thresholdText} is reached
+
+
+ )
+}
+
+export const UsageAlertSection = ({
+ className,
+ email,
+ teamSlug,
+ value,
+}: UsageAlertSectionProps) => {
+ const isValueSet = value !== null
+
+ return (
+
+ )
+}
diff --git a/src/features/dashboard/limits/usage-limit-form.tsx b/src/features/dashboard/limits/usage-limit-form.tsx
new file mode 100644
index 000000000..23688453a
--- /dev/null
+++ b/src/features/dashboard/limits/usage-limit-form.tsx
@@ -0,0 +1,286 @@
+'use client'
+
+import { zodResolver } from '@hookform/resolvers/zod'
+import { useMutation, useQueryClient } from '@tanstack/react-query'
+import type { FormEvent } from 'react'
+import { useEffect, useRef, useState } from 'react'
+import { Controller, useForm } from 'react-hook-form'
+import { z } from 'zod'
+import type { BillingLimit } from '@/core/modules/billing/models'
+import {
+ defaultErrorToast,
+ defaultSuccessToast,
+ useToast,
+} from '@/lib/hooks/use-toast'
+import { cn } from '@/lib/utils'
+import { formatNumber } from '@/lib/utils/formatting'
+import { useTRPC } from '@/trpc/client'
+import { Button } from '@/ui/primitives/button'
+import { EditIcon, TrashIcon } from '@/ui/primitives/icons'
+import { Input } from '@/ui/primitives/input'
+import { CurrencyInputSchema, sanitizeCurrencyInput } from './currency-input'
+import { focusBlockInputOnMouseDown } from './focus-block-input'
+import { RemoveUsageLimitDialog } from './remove-usage-limit-dialog'
+import { SetUsageLimitDialog } from './set-usage-limit-dialog'
+
+const limitFormSchema = z.object({
+ amount: CurrencyInputSchema,
+})
+
+type LimitFormValues = z.infer
+
+interface UsageLimitFormProps {
+ className?: string
+ originalValue: number | null
+ teamSlug: string
+}
+
+export const UsageLimitForm = ({
+ className,
+ originalValue,
+ teamSlug,
+}: UsageLimitFormProps) => {
+ const inputRef = useRef(null)
+ const [isEditing, setIsEditing] = useState(originalValue === null)
+ const [isRemoveDialogOpen, setIsRemoveDialogOpen] = useState(false)
+ const [isSetDialogOpen, setIsSetDialogOpen] = useState(false)
+ const { toast } = useToast()
+ const trpc = useTRPC()
+ const queryClient = useQueryClient()
+ const formattedOriginalValue =
+ originalValue === null ? '' : formatNumber(originalValue)
+
+ const limitsQueryKey = trpc.billing.getLimits.queryOptions({
+ teamSlug,
+ }).queryKey
+
+ const form = useForm({
+ resolver: zodResolver(limitFormSchema),
+ mode: 'onChange',
+ defaultValues: {
+ amount: formattedOriginalValue,
+ },
+ })
+
+ const draftValue = form.watch('amount')
+
+ useEffect(() => {
+ form.reset({
+ amount: formattedOriginalValue,
+ })
+ setIsEditing(originalValue === null)
+ }, [formattedOriginalValue, originalValue, form.reset])
+
+ useEffect(() => {
+ if (!isEditing) return
+ inputRef.current?.focus()
+ const inputLength = inputRef.current?.value.length ?? 0
+ inputRef.current?.setSelectionRange(inputLength, inputLength)
+ }, [isEditing])
+
+ const setLimitMutation = useMutation(
+ trpc.billing.setLimit.mutationOptions({
+ onSuccess: (_, variables) => {
+ queryClient.setQueryData(
+ limitsQueryKey,
+ (limits) => {
+ if (!limits) return limits
+ return { ...limits, limit_amount_gte: variables.value }
+ }
+ )
+ toast(defaultSuccessToast('Limit set successfully.'))
+ form.reset({ amount: formatNumber(variables.value) })
+ setIsEditing(false)
+ setIsSetDialogOpen(false)
+ queryClient.invalidateQueries({ queryKey: limitsQueryKey })
+ },
+ onError: (error) => {
+ toast(
+ defaultErrorToast(error.message || 'Failed to save billing limit.')
+ )
+ },
+ })
+ )
+
+ const nextValue = form.formState.isValid ? Number(draftValue) : null
+ const isMutating = setLimitMutation.isPending
+ const isRemoveIntent =
+ isEditing && originalValue !== null && draftValue.length === 0
+ const canSave =
+ isEditing &&
+ form.formState.isValid &&
+ nextValue !== originalValue &&
+ !isMutating
+ const isInputEditable = isEditing || originalValue === null
+ const shouldShowCancel =
+ isEditing && (originalValue !== null || draftValue.length > 0)
+ const setLimitTitle = `Set $${nextValue === null ? '--' : formatNumber(nextValue)} usage limit?`
+
+ const startEditing = (): void => {
+ if (originalValue === null) return
+ form.reset({ amount: formattedOriginalValue })
+ setIsEditing(true)
+ }
+
+ const openRemoveDialog = (): void => setIsRemoveDialogOpen(true)
+
+ const handleCancel = (): void => {
+ const activeElement = document.activeElement
+ if (activeElement instanceof HTMLElement) activeElement.blur()
+ inputRef.current?.blur()
+
+ if (originalValue === null) {
+ form.reset({ amount: '' })
+ return
+ }
+
+ form.reset({ amount: formattedOriginalValue })
+ setIsEditing(false)
+ }
+
+ const handleSetConfirm = (): void => {
+ if (!form.formState.isValid) return
+ const value = Number(form.getValues('amount'))
+ if (value === originalValue) return
+ setLimitMutation.mutate({ teamSlug, type: 'limit', value })
+ }
+
+ const handleSubmit = async (event: FormEvent) => {
+ event.preventDefault()
+ if (!isEditing) return
+
+ if (isRemoveIntent) {
+ setIsRemoveDialogOpen(true)
+ return
+ }
+
+ const isValid = await form.trigger()
+ if (!isValid) {
+ toast(
+ defaultErrorToast(
+ form.formState.errors.amount?.message ||
+ 'Enter a billing limit amount.'
+ )
+ )
+ return
+ }
+
+ const value = Number(form.getValues('amount'))
+ if (value === originalValue || isMutating) return
+ setIsSetDialogOpen(true)
+ }
+
+ return (
+
+ )
+}
diff --git a/src/features/dashboard/limits/usage-limit-section.tsx b/src/features/dashboard/limits/usage-limit-section.tsx
new file mode 100644
index 000000000..6bc781f85
--- /dev/null
+++ b/src/features/dashboard/limits/usage-limit-section.tsx
@@ -0,0 +1,79 @@
+'use client'
+
+import { cn } from '@/lib/utils'
+import { formatNumber } from '@/lib/utils/formatting'
+import { AlertIcon, WarningIcon } from '@/ui/primitives/icons'
+import { LimitAsciiIcon } from './limit-ascii-icon'
+import { UsageLimitForm } from './usage-limit-form'
+
+interface UsageLimitSectionProps {
+ className?: string
+ email: string
+ teamSlug: string
+ value: number | null
+}
+
+const UsageLimitSectionInfo = ({
+ email,
+ value,
+}: Pick) => {
+ const isValueSet = value !== null
+ const limitMessage = isValueSet
+ ? `All API requests are blocked after reaching $${formatNumber(value)}`
+ : 'All API requests are blocked after reaching this limit'
+
+ return (
+
+
+
+ {limitMessage}
+
+
+
+
+ Automatic alerts at 50%, 80%, 90% and 100% sent to{' '}
+ {email}
+
+
+
+ )
+}
+
+export const UsageLimitSection = ({
+ className,
+ email,
+ teamSlug,
+ value,
+}: UsageLimitSectionProps) => {
+ const isValueSet = value !== null
+
+ return (
+
+ )
+}
diff --git a/src/features/dashboard/limits/usage-limits.tsx b/src/features/dashboard/limits/usage-limits.tsx
index ca47b1eda..7cc81d210 100644
--- a/src/features/dashboard/limits/usage-limits.tsx
+++ b/src/features/dashboard/limits/usage-limits.tsx
@@ -5,15 +5,17 @@ import { useRouteParams } from '@/lib/hooks/use-route-params'
import { cn } from '@/lib/utils'
import { useTRPC } from '@/trpc/client'
import { Skeleton } from '@/ui/primitives/skeleton'
-import AlertCard from './alert-card'
-import LimitCard from './limit-card'
+import { useDashboard } from '../context'
+import { UsageAlertSection } from './usage-alert-section'
+import { UsageLimitSection } from './usage-limit-section'
interface UsageLimitsProps {
className?: string
}
-export default function UsageLimits({ className }: UsageLimitsProps) {
+export const UsageLimits = ({ className }: UsageLimitsProps) => {
const { teamSlug } = useRouteParams<'/dashboard/[teamSlug]/limits'>()
+ const { team } = useDashboard()
const trpc = useTRPC()
const { data: limits, isLoading } = useQuery({
@@ -21,25 +23,43 @@ export default function UsageLimits({ className }: UsageLimitsProps) {
throwOnError: true,
})
- if (isLoading || !limits) {
- return (
-
- )
- }
-
return (
-
-
-
+
+ {isLoading || !limits ? (
+ <>
+
+
+ >
+ ) : (
+ <>
+
+
+ >
+ )}
)
}
+
+const LimitsSectionSkeleton = () => (
+
+)
diff --git a/src/ui/primitives/dialog.tsx b/src/ui/primitives/dialog.tsx
index e385fa20c..df083de53 100644
--- a/src/ui/primitives/dialog.tsx
+++ b/src/ui/primitives/dialog.tsx
@@ -1,7 +1,7 @@
'use client'
import * as DialogPrimitive from '@radix-ui/react-dialog'
-import * as React from 'react'
+import type * as React from 'react'
import { cn } from '@/lib/utils/ui'
import { CloseIcon } from '@/ui/primitives/icons'
@@ -71,8 +71,8 @@ function DialogContent({
[
'bg-bg-1 text-body text-fg-secondary',
'fixed top-[50%] left-[50%] translate-x-[-50%] translate-y-[-50%]',
- 'z-50 grid w-full max-w-[calc(100%-2rem)] sm:max-w-lg',
- 'gap-3 border p-5 pt-4',
+ 'z-50 grid w-[calc(100%-2rem)] max-h-[calc(100svh-2rem)] overflow-y-auto sm:w-full sm:max-w-lg',
+ 'gap-3 border p-5 pt-4 focus:ring-0 focus:outline-none',
'data-[state=open]:animate-in data-[state=closed]:animate-out',
'anim-ease-appear anim-duration-normal', // exit animation is faster
'data-[state=open]:anim-duration-slow',
@@ -135,7 +135,7 @@ function DialogTitle({
return (
)