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 ( -
- -
- ( - - - $ [USD] - - - { - field.onChange(value) - }} - placeholder={'$'} - /> - {/* { - const value = - e.target.value === '' ? null : Number(e.target.value) - field.onChange(value) - }} - value={field.value ?? ''} - /> */} - - - - )} - /> - - {originalValue !== null && ( - - )} -
-
- - ) - } - - return ( -
-
- {'$ '} - - {originalValue?.toLocaleString()} - -
- - -
- ) -} 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 usage limit? + + API limits will be removed and usage will become uncapped + +
+
+ + +
+
+
+
+ ) +} 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 ( + + + + + +
+
+
+
+ +
+ {title} +
+ + If your API usage hits this limit, all requests including sandbox + creation will be blocked. + +

+ This may disrupt your services. +

+
+
+ + +
+
+
+
+ ) +} 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 ( +
+
+ $ + ( + { + field.ref(el) + inputRef.current = el + }} + className="prose-value-big text-fg h-auto border-0 bg-transparent px-0 py-0 font-mono shadow-none placeholder:text-fg-tertiary hover:bg-transparent focus:bg-transparent focus:[border-bottom:0] focus:outline-none" + disabled={isMutating} + inputMode="numeric" + onChange={(event) => { + if (!isEditing) return + field.onChange(sanitizeCurrencyInput(event.target.value)) + }} + onBlur={field.onBlur} + onFocus={() => { + if (isEditing) return + startEditing() + }} + placeholder="--" + readOnly={!isEditing && originalValue !== null} + value={field.value} + /> + )} + /> +
+
+ {originalValue !== null && !isEditing ? ( + <> + + + + ) : ( + <> + {shouldShowCancel && ( + + )} + + + )} +
+
+ ) +} 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 ( +
+

Usage Alert

+
+
+ +
+
+ +
+
+ +
+ ) +} 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 ( +
+
+ $ + ( + { + field.ref(el) + inputRef.current = el + }} + className="prose-value-big text-fg h-auto border-0 bg-transparent px-0 py-0 font-mono shadow-none placeholder:text-fg-tertiary hover:bg-transparent focus:bg-transparent focus:[border-bottom:0] focus:outline-none" + disabled={isMutating} + inputMode="numeric" + onChange={(event) => { + if (!isEditing) return + field.onChange(sanitizeCurrencyInput(event.target.value)) + }} + onBlur={field.onBlur} + onFocus={() => { + if (isEditing) return + startEditing() + }} + placeholder="--" + readOnly={!isEditing && originalValue !== null} + value={field.value} + /> + )} + /> +
+
+ {originalValue !== null && !isEditing ? ( + <> + + + + ) : ( + <> + {shouldShowCancel && ( + + )} + {isRemoveIntent ? ( + + ) : ( + + )} + + )} +
+ { + form.reset({ amount: '' }) + setIsEditing(true) + }} + /> + + ) +} 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 ( +
+

Usage Limit

+
+
+ +
+
+ +
+
+ +
+ ) +} 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 }