diff --git a/apps/gateway/test/index.spec.ts b/apps/gateway/test/index.spec.ts index e0f1a5f7bd..b46dde3e79 100644 --- a/apps/gateway/test/index.spec.ts +++ b/apps/gateway/test/index.spec.ts @@ -1,7 +1,7 @@ -// test/index.spec.ts -import { SELF, createExecutionContext, env, waitOnExecutionContext } from "cloudflare:test"; import { describe, expect, it } from "vitest"; import worker from "../src/index"; +// test/index.spec.ts +import { SELF, createExecutionContext, env, waitOnExecutionContext } from "cloudflare:test"; // For now, you'll need to do something like this to get a correctly-typed // `Request` to pass to `worker.fetch()`. diff --git a/apps/web/app/(authenticated)/(app)/app/settings/billing/budgets-table.tsx b/apps/web/app/(authenticated)/(app)/app/settings/billing/budgets-table.tsx new file mode 100644 index 0000000000..fd4c7a399a --- /dev/null +++ b/apps/web/app/(authenticated)/(app)/app/settings/billing/budgets-table.tsx @@ -0,0 +1,66 @@ +"use client"; + +import { Badge } from "@/components/ui/badge"; +import { Progress } from "@/components/ui/progress"; +import { + Table, + TableBody, + TableCaption, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Check, Minus, X } from "lucide-react"; +import type { BudgetsSectionProps } from "./budgets"; +import { DeleteBudgetButton } from "./delete-budget-button"; +import { EditBudgetButton } from "./edit-budget-button"; + +export const BudgetsTable: React.FC = ({ budgets, currentBilling }) => { + return ( + + {budgets.length === 0 ? No budgets found : null} + + + Enabled + Name + Budget + Amount used + Current vs. budgeted + + + + + {budgets.map((budget, i) => { + const usage = Math.floor((currentBilling / budget.fixedAmount) * 10000) / 100; + + return ( + // biome-ignore lint/suspicious/noArrayIndexKey: I got nothing better right now + + {budget.enabled ? : } + + {budget.name ? ( + {budget.name} + ) : ( + + )} + + ${budget.fixedAmount} + ${currentBilling} + +
+ + {`${usage}%`} +
+
+ + + + +
+ ); + })} +
+
+ ); +}; diff --git a/apps/web/app/(authenticated)/(app)/app/settings/billing/budgets.tsx b/apps/web/app/(authenticated)/(app)/app/settings/billing/budgets.tsx new file mode 100644 index 0000000000..8fb6065c51 --- /dev/null +++ b/apps/web/app/(authenticated)/(app)/app/settings/billing/budgets.tsx @@ -0,0 +1,29 @@ +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import type { Budget } from "@unkey/db"; +import { BudgetsTable } from "./budgets-table"; +import { CreateBudgetButton } from "./create-budget-button"; + +export type BudgetsSectionProps = { + currentBilling: number; + budgets: Budget[]; +}; + +export function BudgetsSection({ currentBilling, budgets }: BudgetsSectionProps) { + return ( + + + + Budgets + + + + Set custom budgets that alert you when your costs and usage exceed your budgeted amount. + + + + + + + + ); +} diff --git a/apps/web/app/(authenticated)/(app)/app/settings/billing/create-budget-button.tsx b/apps/web/app/(authenticated)/(app)/app/settings/billing/create-budget-button.tsx new file mode 100644 index 0000000000..f82025abb3 --- /dev/null +++ b/apps/web/app/(authenticated)/(app)/app/settings/billing/create-budget-button.tsx @@ -0,0 +1,188 @@ +"use client"; + +import { Loading } from "@/components/dashboard/loading"; +import { Button } from "@/components/ui/button"; + +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { toast } from "@/components/ui/toaster"; +import { trpc } from "@/lib/trpc/client"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +export const createBudgetFormSchema = z.object({ + name: z.string().optional(), + fixedAmount: z.coerce + .number({ + errorMap: (issue, { defaultError }) => ({ + message: + issue.code === "invalid_type" ? "Budget amount must be greater than 0" : defaultError, + }), + }) + .positive({ message: "Budget amount must be greater than 0" }), + additionalEmails: z + .string() + .optional() + .refine( + (val) => + !val || + z.array(z.string().email("Invalid email format")).max(10).safeParse(val.split(",")).success, + "Invalid email list. Ensure emails are correctly formatted and do not exceed 10.", + ), +}); + +export const CreateBudgetButton: React.FC> = ( + props, +) => { + const router = useRouter(); + + const [isOpen, setIsOpen] = useState(false); + + const form = useForm>({ + resolver: zodResolver(createBudgetFormSchema), + defaultValues: { + additionalEmails: "", + }, + }); + + const createBudget = trpc.budget.create.useMutation({ + onSuccess() { + toast.success("Your Budget has been created"); + + router.refresh(); + + setIsOpen(false); + }, + onError(err) { + console.error(err); + toast.error(err.message); + }, + }); + + async function onSubmit(values: z.infer) { + await createBudget.mutateAsync({ + ...values, + additionalEmails: values.additionalEmails?.split(",").filter(Boolean) || undefined, + }); + } + + function onOpenChange(open: boolean) { + if (open) { + form.reset({ + fixedAmount: undefined, + additionalEmails: "", + }); + } + setIsOpen(open); + } + + return ( + + + + + + + + Create a new Budget + +
+ + ( + + Budget Name + + + + Provide a descriptive name for this budget. + + + )} + /> + + ( + + Budget amount ($) + + + + Enter your budgeted amount. + + + )} + /> + + ( + + Email recipients + +