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..d78aed6cf
--- /dev/null
+++ b/src/__test__/unit/currency.test.ts
@@ -0,0 +1,45 @@
+import { describe, expect, it } from 'vitest'
+import {
+ CurrencyInputSchema,
+ formatCurrencyValue,
+ sanitizeCurrencyInput,
+} from '@/lib/utils/currency'
+
+describe('sanitizeCurrencyInput', () => {
+ it.each([
+ ['$1,250', '1250'],
+ ['1250', '1250'],
+ ['abc', ''],
+ ['', ''],
+ ])('returns %p -> %p', (value: string, expected: string) => {
+ expect(sanitizeCurrencyInput(value)).toBe(expected)
+ })
+})
+
+describe('formatCurrencyValue', () => {
+ it.each([
+ [1250, '1,250'],
+ [100, '100'],
+ [0, '0'],
+ [1000000, '1,000,000'],
+ ])('returns %p -> %p', (value: number, expected: string) => {
+ expect(formatCurrencyValue(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..7590f26df 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/limits/alert-ascii-icon.tsx b/src/features/dashboard/limits/alert-ascii-icon.tsx
new file mode 100644
index 000000000..f9cf611f9
--- /dev/null
+++ b/src/features/dashboard/limits/alert-ascii-icon.tsx
@@ -0,0 +1,45 @@
+import type React from 'react'
+import { cn } from '@/lib/utils/index'
+
+const LINES = [
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' -********- ',
+ ' -*-- -*- ',
+ ' -*- -*- ',
+ ' **- -** ',
+ ' -*- -*- ',
+ ' -**----------**- ',
+ ' ----**----**---- ',
+ ' -******- ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+]
+
+const TEXT_STYLE: React.CSSProperties = {
+ fontSize: '3.802px',
+ fontFamily: 'var(--font-mono)',
+ fontWeight: 600,
+ letterSpacing: '-0.038px',
+ lineHeight: '4px',
+ fontFeatureSettings: "'ss03' 1",
+}
+
+export const AlertAsciiIcon = ({ className }: { className?: string }) => (
+
+
+ {LINES.map((line, i) => (
+
+ {line}
+
+ ))}
+
+
+)
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/limit-ascii-icon.tsx b/src/features/dashboard/limits/limit-ascii-icon.tsx
new file mode 100644
index 000000000..775e69374
--- /dev/null
+++ b/src/features/dashboard/limits/limit-ascii-icon.tsx
@@ -0,0 +1,45 @@
+import type React from 'react'
+import { cn } from '@/lib/utils/index'
+
+const LINES = [
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ---**--- ',
+ ' -**------**- ',
+ ' -**- - -**- ',
+ ' ** -**- ** ',
+ ' *- --- -* ',
+ ' ** ** ',
+ ' -**- -**- ',
+ ' -- -- ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+]
+
+const TEXT_STYLE: React.CSSProperties = {
+ fontSize: '3.802px',
+ fontFamily: 'var(--font-mono)',
+ fontWeight: 600,
+ letterSpacing: '-0.038px',
+ lineHeight: '4px',
+ fontFeatureSettings: "'ss03' 1",
+}
+
+export const LimitAsciiIcon = ({ className }: { className?: string }) => (
+
+
+ {LINES.map((line, i) => (
+
+ {line}
+
+ ))}
+
+
+)
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 2d99dfa1a..000000000
--- a/src/features/dashboard/limits/limit-form.tsx
+++ /dev/null
@@ -1,237 +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..81d1189af
--- /dev/null
+++ b/src/features/dashboard/limits/remove-usage-limit-dialog.tsx
@@ -0,0 +1,132 @@
+'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('Billing limit removed.'))
+ 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..be0282c06
--- /dev/null
+++ b/src/features/dashboard/limits/set-usage-limit-dialog.tsx
@@ -0,0 +1,89 @@
+'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..90b98db37
--- /dev/null
+++ b/src/features/dashboard/limits/usage-alert-form.tsx
@@ -0,0 +1,285 @@
+'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 {
+ CurrencyInputSchema,
+ formatCurrencyValue,
+ sanitizeCurrencyInput,
+} from '@/lib/utils/currency'
+import { useTRPC } from '@/trpc/client'
+import { Button } from '@/ui/primitives/button'
+import { EditIcon, TrashIcon } from '@/ui/primitives/icons'
+import { Input } from '@/ui/primitives/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 ? '' : formatCurrencyValue(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('Billing alert saved.'))
+ form.reset({ amount: formatCurrencyValue(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('Billing alert removed.'))
+ 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 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..88b1cfdf3
--- /dev/null
+++ b/src/features/dashboard/limits/usage-alert-section.tsx
@@ -0,0 +1,53 @@
+'use client'
+
+import { cn } from '@/lib/utils'
+import { formatCurrencyValue } from '@/lib/utils/currency'
+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 $${formatCurrencyValue(value)} threshold`
+
+ return (
+
+ Informative alert will be sent to{' '}
+ {email} when {thresholdText}{' '}
+ is reached
+
+ )
+}
+
+export const UsageAlertSection = ({
+ className,
+ email,
+ teamSlug,
+ value,
+}: UsageAlertSectionProps) => {
+ 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..cf6eb6153
--- /dev/null
+++ b/src/features/dashboard/limits/usage-limit-form.tsx
@@ -0,0 +1,287 @@
+'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 {
+ CurrencyInputSchema,
+ formatCurrencyValue,
+ sanitizeCurrencyInput,
+} from '@/lib/utils/currency'
+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 { 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 ? '' : formatCurrencyValue(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('Billing limit saved.'))
+ form.reset({ amount: formatCurrencyValue(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 shouldShowCancel =
+ isEditing && (originalValue !== null || draftValue.length > 0)
+ const setLimitTitle = `Set $${nextValue === null ? '--' : formatCurrencyValue(nextValue)} usage limit?`
+
+ const startEditing = (): void => {
+ if (originalValue === null) return
+ form.reset({ amount: formattedOriginalValue })
+ setIsEditing(true)
+ }
+
+ const openRemoveDialog = (): void => setIsRemoveDialogOpen(true)
+
+ const handleCancel = (): void => {
+ 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..d57175eb8
--- /dev/null
+++ b/src/features/dashboard/limits/usage-limit-section.tsx
@@ -0,0 +1,72 @@
+'use client'
+
+import { cn } from '@/lib/utils'
+import { formatCurrencyValue } from '@/lib/utils/currency'
+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 $${formatCurrencyValue(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) => {
+ return (
+
+ )
+}
diff --git a/src/features/dashboard/limits/usage-limits.tsx b/src/features/dashboard/limits/usage-limits.tsx
index ca47b1eda..a1c43ef23 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,45 @@ export default function UsageLimits({ className }: UsageLimitsProps) {
throwOnError: true,
})
- if (isLoading || !limits) {
- return (
-
- )
- }
+ if (!team) return null
return (
-
-
-
+
+ {isLoading || !limits ? (
+ <>
+
+
+ >
+ ) : (
+ <>
+
+
+ >
+ )}
)
}
+
+const LimitsSectionSkeleton = () => (
+
+)
diff --git a/src/lib/utils/currency.ts b/src/lib/utils/currency.ts
new file mode 100644
index 000000000..6936648f7
--- /dev/null
+++ b/src/lib/utils/currency.ts
@@ -0,0 +1,19 @@
+import { z } from 'zod'
+
+const usdIntegerFormatter = new Intl.NumberFormat('en-US')
+
+// 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, '')
+
+// Formats a USD integer for display. Example: 1250 -> "1,250".
+const formatCurrencyValue = (value: number) => usdIntegerFormatter.format(value)
+
+export { CurrencyInputSchema, formatCurrencyValue, sanitizeCurrencyInput }