diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/items/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/items/page-client.tsx new file mode 100644 index 0000000000..d700f66ef5 --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/items/page-client.tsx @@ -0,0 +1,553 @@ +"use client"; + +import { TeamMemberSearchTable } from "@/components/data-table/team-member-search-table"; +import { TeamSearchTable } from "@/components/data-table/team-search-table"; +import { SmartFormDialog } from "@/components/form-dialog"; +import { PageLayout } from "../page-layout"; +import { useAdminApp } from "../use-admin-app"; +import { KnownErrors } from "@stackframe/stack-shared"; +import { Result } from "@stackframe/stack-shared/dist/utils/results"; +import { + ActionDialog, + Button, + Card, + CardContent, + CardHeader, + CardTitle, + Input, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Skeleton, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, + Typography, + toast, +} from "@stackframe/stack-ui"; +import { Suspense, useEffect, useMemo, useState } from "react"; +import * as yup from "yup"; +import { ChevronsUpDown } from "lucide-react"; +import { ItemDialog } from "@/components/payments/item-dialog"; + +type CustomerType = "user" | "team" | "custom"; + +type SelectedCustomer = { + type: CustomerType, + id: string, + label: string, +}; + +export default function PageClient() { + const adminApp = useAdminApp(); + const project = adminApp.useProject(); + const config = project.useConfig(); + + const [customerType, setCustomerType] = useState("user"); + const [selectedCustomer, setSelectedCustomer] = useState(null); + const [showItemDialog, setShowItemDialog] = useState(false); + + const items = useMemo(() => { + const payments = config.payments; + return Object.entries(payments.items); + }, [config.payments]); + + const itemsForType = useMemo( + () => items.filter(([, itemConfig]) => itemConfig.customerType === customerType), + [items, customerType], + ); + + const paymentsConfigured = Boolean(config.payments); + + const itemDialogTitle = useMemo(() => { + if (customerType === "user") { + return "Create User Item"; + } + if (customerType === "team") { + return "Create Team Item"; + } + return "Create Custom Item"; + }, [customerType]); + + const handleSaveItem = async (item: { id: string, displayName: string, customerType: 'user' | 'team' | 'custom' }) => { + await project.updateConfig({ [`payments.items.${item.id}`]: { displayName: item.displayName, customerType: item.customerType } }); + setShowItemDialog(false); + }; + + return ( + setShowItemDialog(true)}>{itemDialogTitle}} + > +
+ + + +
+ + {!paymentsConfigured && ( + + Payments are not configured for this project yet. Set up payments to define items. + + )} + + {paymentsConfigured && itemsForType.length === 0 && ( + + {customerType === "user" && "No user items are configured yet."} + {customerType === "team" && "No team items are configured yet."} + {customerType === "custom" && "No custom items are configured yet."} + + )} + + {paymentsConfigured && itemsForType.length > 0 && ( + }> + + + )} + + id)} + forceCustomerType={customerType} + /> +
+ ); +} + +type CustomerSelectorProps = { + customerType: CustomerType, + selectedCustomer: SelectedCustomer | null, + onSelect: (customer: SelectedCustomer) => void, +}; + +function CustomerSelector(props: CustomerSelectorProps) { + const [open, setOpen] = useState(false); + const [customIdDraft, setCustomIdDraft] = useState(""); + + useEffect(() => { + if (open && props.customerType === "custom") { + setCustomIdDraft(props.selectedCustomer?.type === "custom" ? props.selectedCustomer.id : ""); + } + }, [open, props.customerType, props.selectedCustomer]); + + const triggerLabel = props.selectedCustomer + ? props.selectedCustomer.label + : props.customerType === "custom" + ? "Select customer" + : `Select ${props.customerType}`; + + const handleSelect = (customer: SelectedCustomer) => { + props.onSelect(customer); + setOpen(false); + }; + + const dialogTitle = props.customerType === "custom" + ? "Select customer" + : `Select ${props.customerType}`; + + const dialogContent = () => { + if (props.customerType === "user") { + return open ? ( + ( + + )} + /> + ) : null; + } + if (props.customerType === "team") { + return open ? ( + ( + + )} + /> + ) : null; + } + return ( +
+ + Enter the identifier for the custom customer. + + setCustomIdDraft(event.target.value)} + placeholder="customer-123" + /> +
+ ); + }; + + return ( + + {triggerLabel} + + + } + title={dialogTitle} + description={props.customerType === "custom" ? "Provide a custom customer identifier to inspect their balances." : undefined} + open={open} + onOpenChange={setOpen} + cancelButton={{ label: "Close" }} + okButton={props.customerType === "custom" ? { + label: "Use customer", + props: { disabled: customIdDraft.trim().length === 0 }, + onClick: async () => { + const trimmed = customIdDraft.trim(); + if (!trimmed) { + return "prevent-close"; + } + handleSelect({ type: "custom", id: trimmed, label: trimmed }); + }, + } : false} + > + {dialogContent()} + + ); +} + +function ItemTable(props: { + items: Array<[string, { displayName?: string | null }]>, + customer: SelectedCustomer | null, +}) { + return ( +
+ + + + Item + Quantity + Actions + + + + {props.items.map(([itemId, itemConfig]) => ( + props.customer ? ( + + ) : ( + + +
+ {itemConfig.displayName ?? itemId} + {itemId} +
+
+ + +
+ ) + ))} +
+
+
+ ); +} + +function ItemRowSuspense(props: ItemRowProps) { + return ( + + +
+ + +
+
+ + + + +
+ + +
+
+ + } + > + +
+ ); +} + +type ItemRowProps = { + itemId: string, + itemDisplayName: string, + customer: SelectedCustomer, +}; + +function ItemRowContent(props: ItemRowProps) { + const adminApp = useAdminApp(); + const [isAdjustOpen, setIsAdjustOpen] = useState(false); + + const item = useItemForCustomer(adminApp, props.customer, props.itemId); + + return ( + <> + + +
+ {props.itemDisplayName} + {props.itemId} +
+
+ +
+ {item.quantity} +
+
+ +
+ +
+
+
+ + + + ); +} + +function useItemForCustomer( + adminApp: ReturnType, + customer: SelectedCustomer, + itemId: string, +) { + let options: Parameters[0] = { customCustomerId: customer.id, itemId }; + if (customer.type === "user") { + options = { userId: customer.id, itemId }; + } + if (customer.type === "team") { + options = { teamId: customer.id, itemId }; + } + return adminApp.useItem(options); +} + +type QuantityDialogProps = { + open: boolean, + onOpenChange: (open: boolean) => void, + customer: SelectedCustomer, + itemId: string, + itemLabel: string, +}; + +function AdjustItemQuantityDialog(props: QuantityDialogProps) { + const adminApp = useAdminApp(); + + const schema = useMemo(() => yup.object({ + quantity: yup + .number() + .defined() + .label("Quantity change") + .meta({ + stackFormFieldPlaceholder: "Eg. 5 or -3", + }) + .test("non-zero", "Please enter a non-zero amount", (value) => (value !== 0)), + description: yup + .string() + .optional() + .label("Description") + .meta({ + type: "textarea", + stackFormFieldPlaceholder: "Optional note for your records", + description: "Appears in transaction history for context.", + }), + expiresAt: yup + .date() + .optional() + .label("Expires at"), + }), []); + + const onSubmit = async (values: yup.InferType) => { + const quantity = values.quantity!; + const customerOptions = customerToMutationOptions(props.customer); + const result = await Result.fromPromise(adminApp.createItemQuantityChange({ + ...customerOptions, + itemId: props.itemId, + quantity, + description: values.description?.trim() ? values.description.trim() : undefined, + expiresAt: values.expiresAt ? values.expiresAt.toISOString() : undefined, + })); + + if (result.status === "ok") { + await refreshItem(adminApp, props.customer, props.itemId); + toast({ title: "Item quantity updated" }); + return; + } + + handleItemQuantityError(result.error); + return "prevent-close"; + }; + + return ( + + ); +} + + +function customerToMutationOptions(customer: SelectedCustomer) { + if (customer.type === "user") { + return { userId: customer.id } as const; + } + if (customer.type === "team") { + return { teamId: customer.id } as const; + } + return { customCustomerId: customer.id } as const; +} + +async function refreshItem( + adminApp: ReturnType, + customer: SelectedCustomer, + itemId: string, +) { + if (customer.type === "user") { + await adminApp.getItem({ userId: customer.id, itemId }); + } else if (customer.type === "team") { + await adminApp.getItem({ teamId: customer.id, itemId }); + } else { + await adminApp.getItem({ customCustomerId: customer.id, itemId }); + } +} + +function handleItemQuantityError(error: unknown) { + if (error instanceof KnownErrors.ItemNotFound) { + toast({ title: "Item not found", variant: "destructive" }); + return; + } + if (error instanceof KnownErrors.UserNotFound) { + toast({ title: "User not found", variant: "destructive" }); + return; + } + if (error instanceof KnownErrors.TeamNotFound) { + toast({ title: "Team not found", variant: "destructive" }); + return; + } + if (error instanceof KnownErrors.ItemCustomerTypeDoesNotMatch) { + toast({ + title: "Customer type mismatch", + description: "This item is not available for the selected customer type.", + variant: "destructive", + }); + return; + } + if (error instanceof KnownErrors.ItemQuantityInsufficientAmount) { + toast({ + title: "Quantity too low", + description: "This change would reduce the quantity below zero.", + variant: "destructive", + }); + return; + } + toast({ title: "Unable to update quantity", variant: "destructive" }); +} + +function ItemTableSkeleton(props: { rows: number }) { + return ( +
+ + + + Item + Quantity + Actions + + + + {Array.from({ length: props.rows }).map((_, index) => ( + + +
+ + +
+
+ + + + +
+ + +
+
+
+ ))} +
+
+
+ ); +} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/items/page.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/items/page.tsx new file mode 100644 index 0000000000..7cc5c1fbba --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/items/page.tsx @@ -0,0 +1,10 @@ +import PageClient from "./page-client"; + +export const metadata = { + title: "Item Quantities", +}; + +export default function Page() { + return ; +} + diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/item-dialog.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/item-dialog.tsx deleted file mode 100644 index d326291e09..0000000000 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/item-dialog.tsx +++ /dev/null @@ -1,179 +0,0 @@ -"use client"; - -import { cn } from "@/lib/utils"; -import { Button, Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, SimpleTooltip, Typography } from "@stackframe/stack-ui"; -import { useState } from "react"; - -type ItemDialogProps = { - open: boolean, - onOpenChange: (open: boolean) => void, - onSave: (item: { id: string, displayName: string, customerType: 'user' | 'team' | 'custom' }) => Promise, - editingItem?: { - id: string, - displayName: string, - customerType: 'user' | 'team' | 'custom', - }, - existingItemIds?: string[], -}; - -export function ItemDialog({ - open, - onOpenChange, - onSave, - editingItem, - existingItemIds = [] -}: ItemDialogProps) { - const [itemId, setItemId] = useState(editingItem?.id || ""); - const [displayName, setDisplayName] = useState(editingItem?.displayName || ""); - const [customerType, setCustomerType] = useState<'user' | 'team' | 'custom'>(editingItem?.customerType || 'user'); - const [errors, setErrors] = useState>({}); - - const validateAndSave = async () => { - const newErrors: Record = {}; - - // Validate item ID - if (!itemId.trim()) { - newErrors.itemId = "Item ID is required"; - } else if (!/^[a-z0-9-]+$/.test(itemId)) { - newErrors.itemId = "Item ID must contain only lowercase letters, numbers, and hyphens"; - } else if (!editingItem && existingItemIds.includes(itemId)) { - newErrors.itemId = "This item ID already exists"; - } - - // Validate display name - if (!displayName.trim()) { - newErrors.displayName = "Display name is required"; - } - - if (Object.keys(newErrors).length > 0) { - setErrors(newErrors); - return; - } - - await onSave({ - id: itemId.trim(), - displayName: displayName.trim(), - customerType - }); - - handleClose(); - }; - - const handleClose = () => { - if (!editingItem) { - setItemId(""); - setDisplayName(""); - setCustomerType('user'); - } - setErrors({}); - onOpenChange(false); - }; - - return ( - - - - {editingItem ? "Edit Item" : "Create Item"} - - Items are features or services that customers receive. They appear as rows in your pricing table. - - - -
- {/* Item ID */} -
- - { - const nextValue = e.target.value.toLowerCase(); - setItemId(nextValue); - if (errors.itemId) { - setErrors(prev => { - const newErrors = { ...prev }; - delete newErrors.itemId; - return newErrors; - }); - } - }} - placeholder="e.g., api-calls" - disabled={!!editingItem} - className={cn(errors.itemId ? "border-destructive" : "")} - /> - {errors.itemId && ( - - {errors.itemId} - - )} -
- - {/* Display Name */} -
- - { - setDisplayName(e.target.value); - if (errors.displayName) { - setErrors(prev => { - const newErrors = { ...prev }; - delete newErrors.displayName; - return newErrors; - }); - } - }} - placeholder="e.g., API Calls" - className={cn(errors.displayName ? "border-destructive" : "")} - /> - {errors.displayName && ( - - {errors.displayName} - - )} -
- - {/* Customer Type */} -
- - -
-
- - - - - -
-
- ); -} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-catalogs-view.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-catalogs-view.tsx index 10e7b74bd4..632dc7f4fa 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-catalogs-view.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-catalogs-view.tsx @@ -39,7 +39,7 @@ import { Fragment, useEffect, useId, useMemo, useRef, useState } from "react"; import { IllustratedInfo } from "../../../../../../../components/illustrated-info"; import { PageLayout } from "../../page-layout"; import { useAdminApp } from "../../use-admin-app"; -import { ItemDialog } from "./item-dialog"; +import { ItemDialog } from "@/components/payments/item-dialog"; import { ProductDialog } from "./product-dialog"; type Product = CompleteConfig['payments']['products'][keyof CompleteConfig['payments']['products']]; diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-list-view.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-list-view.tsx index 4b09d5ccc7..231e38a45a 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-list-view.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-list-view.tsx @@ -12,7 +12,7 @@ import React, { ReactNode, useEffect, useId, useMemo, useRef, useState } from "r import { IllustratedInfo } from "../../../../../../../components/illustrated-info"; import { PageLayout } from "../../page-layout"; import { useAdminApp } from "../../use-admin-app"; -import { ItemDialog } from "./item-dialog"; +import { ItemDialog } from "@/components/payments/item-dialog"; import { ListSection } from "./list-section"; import { ProductDialog } from "./product-dialog"; diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx index 9a554d4cc2..f9cc4a7a52 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx @@ -33,6 +33,7 @@ import { Mail, Menu, Palette, + Package, Receipt, Settings, Settings2, @@ -251,6 +252,13 @@ const navigationItems: (Label | Item | Hidden)[] = [ icon: CreditCard, type: 'item', }, + { + name: "Items", + href: "/items", + regex: /^\/projects\/[^\/]+\/items$/, + icon: Package, + type: 'item', + }, { name: "Transactions", href: "/payments/transactions", diff --git a/apps/dashboard/src/components/data-table/payment-item-table.tsx b/apps/dashboard/src/components/data-table/payment-item-table.tsx deleted file mode 100644 index 9142002ac9..0000000000 --- a/apps/dashboard/src/components/data-table/payment-item-table.tsx +++ /dev/null @@ -1,186 +0,0 @@ -'use client'; -import { useAdminApp } from "@/app/(main)/(protected)/projects/[projectId]/use-admin-app"; -import { SmartFormDialog } from "@/components/form-dialog"; -import { ItemDialog } from "@/components/payments/item-dialog"; -import { KnownErrors } from "@stackframe/stack-shared"; -import { branchPaymentsSchema } from "@stackframe/stack-shared/dist/config/schema"; -import { has } from "@stackframe/stack-shared/dist/utils/objects"; -import { Result } from "@stackframe/stack-shared/dist/utils/results"; -import { ActionCell, ActionDialog, DataTable, DataTableColumnHeader, TextCell, toast } from "@stackframe/stack-ui"; -import { ColumnDef } from "@tanstack/react-table"; -import { useState } from "react"; -import * as yup from "yup"; - -type PaymentItem = { - id: string, -} & yup.InferType["items"][string]; - -const columns: ColumnDef[] = [ - { - accessorKey: "id", - header: ({ column }) => , - cell: ({ row }) => {row.original.id}, - enableSorting: false, - }, - { - accessorKey: "displayName", - header: ({ column }) => , - cell: ({ row }) => {row.original.displayName ?? ""}, - enableSorting: false, - }, - { - accessorKey: "customerType", - header: ({ column }) => , - cell: ({ row }) => {row.original.customerType}, - enableSorting: false, - }, - { - id: "actions", - cell: ({ row }) => , - } -]; - -export function PaymentItemTable({ items }: { items: Record["items"][string]> }) { - const data: PaymentItem[] = Object.entries(items).map(([id, item]) => ({ - id, - ...item, - })); - - return ; -} - -function ActionsCell({ item }: { item: PaymentItem }) { - const [open, setOpen] = useState(false); - const [isEditOpen, setIsEditOpen] = useState(false); - const [isDeleteOpen, setIsDeleteOpen] = useState(false); - const stackAdminApp = useAdminApp(); - const project = stackAdminApp.useProject(); - return ( - <> - setOpen(true), - }, - { - item: "Edit", - onClick: () => setIsEditOpen(true), - }, - '-', - { - item: "Delete", - onClick: () => setIsDeleteOpen(true), - danger: true, - }, - ]} - /> - - - { - const config = await project.getConfig(); - for (const [productId, product] of Object.entries(config.payments.products)) { - if (has(product.includedItems, item.id)) { - toast({ - title: "Item is included in product", - description: `Please remove it from the product "${productId}" before deleting.`, - variant: "destructive", - }); - return "prevent-close"; - } - } - await project.updateConfig({ - [`payments.items.${item.id}`]: null, - }); - toast({ title: "Item deleted" }); - } - }} - /> - - ); -} - -type CreateItemQuantityChangeDialogProps = { - open: boolean, - onOpenChange: (open: boolean) => void, - itemId: string, - customerType: "user" | "team" | "custom" | undefined, -} - -function CreateItemQuantityChangeDialog({ open, onOpenChange, itemId, customerType }: CreateItemQuantityChangeDialogProps) { - const stackAdminApp = useAdminApp(); - - const schema = yup.object({ - customerId: yup.string().defined().label("Customer ID"), - quantity: yup.number().defined().label("Quantity"), - description: yup.string().optional().label("Description"), - expiresAt: yup.date().optional().label("Expires At"), - }); - - const submit = async (values: yup.InferType) => { - const result = await Result.fromPromise(stackAdminApp.createItemQuantityChange({ - ...(customerType === "user" ? - { userId: values.customerId } : - customerType === "team" ? - { teamId: values.customerId } : - { customCustomerId: values.customerId } - ), - itemId, - quantity: values.quantity, - expiresAt: values.expiresAt ? values.expiresAt.toISOString() : undefined, - description: values.description, - })); - if (result.status === "ok") { - toast({ title: "Item quantity change created" }); - return; - } - if (result.error instanceof KnownErrors.ItemNotFound) { - toast({ title: "Item not found", variant: "destructive" }); - } else if (result.error instanceof KnownErrors.UserNotFound) { - toast({ title: "No user found with the given ID", variant: "destructive" }); - } else if (result.error instanceof KnownErrors.TeamNotFound) { - toast({ title: "No team found with the given ID", variant: "destructive" }); - } else { - toast({ title: "An unknown error occurred", variant: "destructive" }); - } - return "prevent-close" as const; - }; - - return ( - - ); -} diff --git a/apps/dashboard/src/components/data-table/team-search-table.tsx b/apps/dashboard/src/components/data-table/team-search-table.tsx new file mode 100644 index 0000000000..eea11a5971 --- /dev/null +++ b/apps/dashboard/src/components/data-table/team-search-table.tsx @@ -0,0 +1,71 @@ +'use client'; + +import { useAdminApp } from "@/app/(main)/(protected)/projects/[projectId]/use-admin-app"; +import { ServerTeam } from "@stackframe/stack"; +import { + DataTable, + DataTableColumnHeader, + SearchToolbarItem, + TextCell, +} from "@stackframe/stack-ui"; +import { ColumnDef, Table } from "@tanstack/react-table"; +import { useMemo } from "react"; + +function toolbarRender(table: Table) { + return ( + + ); +} + +export function TeamSearchTable(props: { + action: (team: ServerTeam) => React.ReactNode, +}) { + const adminApp = useAdminApp(); + const teams = adminApp.useTeams(); + + const tableColumns = useMemo[]>(() => [ + { + accessorKey: "displayName", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + {row.original.displayName} + ), + enableSorting: false, + }, + { + accessorKey: "id", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {row.original.id} + + ), + enableSorting: false, + }, + { + id: "actions", + cell: ({ row }) => props.action(row.original), + enableSorting: false, + }, + ], [props]); + + return ( + + ); +} diff --git a/apps/dashboard/src/components/data-table/team-table.tsx b/apps/dashboard/src/components/data-table/team-table.tsx index e9ec671b2d..e6e91def1d 100644 --- a/apps/dashboard/src/components/data-table/team-table.tsx +++ b/apps/dashboard/src/components/data-table/team-table.tsx @@ -128,11 +128,17 @@ const columns: ColumnDef[] = [ ]; export function TeamTable(props: { teams: ServerTeam[] }) { + const router = useRouter(); + const stackAdminApp = useAdminApp(); + return { + router.push(`/projects/${encodeURIComponent(stackAdminApp.projectId)}/teams/${encodeURIComponent(row.id)}`); + }} />; } diff --git a/apps/dashboard/src/components/payments/item-dialog.tsx b/apps/dashboard/src/components/payments/item-dialog.tsx index 2e47f80ff8..d62730db58 100644 --- a/apps/dashboard/src/components/payments/item-dialog.tsx +++ b/apps/dashboard/src/components/payments/item-dialog.tsx @@ -1,79 +1,188 @@ "use client"; -import { FormDialog } from "@/components/form-dialog"; -import { InputField, SelectField } from "@/components/form-fields"; -import { AdminProject } from "@stackframe/stack"; -import { branchPaymentsSchema } from "@stackframe/stack-shared/dist/config/schema"; -import { userSpecifiedIdSchema } from "@stackframe/stack-shared/dist/schema-fields"; -import { has } from "@stackframe/stack-shared/dist/utils/objects"; -import { toast } from "@stackframe/stack-ui"; -import * as yup from "yup"; - -type Props = { +import { cn } from "@/lib/utils"; +import { Button, Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, SimpleTooltip, Typography } from "@stackframe/stack-ui"; +import { useEffect, useState } from "react"; + +type ItemDialogProps = { open: boolean, onOpenChange: (open: boolean) => void, - project: AdminProject, -} & ( - { - mode: "create", - initial?: undefined, - } | { - mode: "edit", - initial: { - id: string, - value: yup.InferType["items"][string], - }, + onSave: (item: { id: string, displayName: string, customerType: 'user' | 'team' | 'custom' }) => Promise, + editingItem?: { + id: string, + displayName: string, + customerType: 'user' | 'team' | 'custom', + }, + existingItemIds?: string[], + forceCustomerType?: 'user' | 'team' | 'custom', +}; + +export function ItemDialog({ + open, + onOpenChange, + onSave, + editingItem, + existingItemIds = [], + forceCustomerType +}: ItemDialogProps) { + const [itemId, setItemId] = useState(editingItem?.id || ""); + const [displayName, setDisplayName] = useState(editingItem?.displayName || ""); + const [customerType, setCustomerType] = useState<'user' | 'team' | 'custom'>(forceCustomerType || editingItem?.customerType || 'user'); + const [errors, setErrors] = useState>({}); + + const validateAndSave = async () => { + const newErrors: Record = {}; + + // Validate item ID + if (!itemId.trim()) { + newErrors.itemId = "Item ID is required"; + } else if (!/^[a-z0-9-]+$/.test(itemId)) { + newErrors.itemId = "Item ID must contain only lowercase letters, numbers, and hyphens"; + } else if (!editingItem && existingItemIds.includes(itemId)) { + newErrors.itemId = "This item ID already exists"; + } + + // Validate display name + if (!displayName.trim()) { + newErrors.displayName = "Display name is required"; + } + + if (Object.keys(newErrors).length > 0) { + setErrors(newErrors); + return; } - ) -export function ItemDialog({ open, onOpenChange, project, mode, initial }: Props) { - const itemSchema = yup.object({ - itemId: userSpecifiedIdSchema("itemId").defined().label("Item ID"), - displayName: yup.string().optional().label("Display Name"), - customerType: yup.string().oneOf(["user", "team", "custom"]).defined().label("Customer Type"), - }); + await onSave({ + id: itemId.trim(), + displayName: displayName.trim(), + customerType + }); + + handleClose(); + }; + + useEffect(() => { + if (forceCustomerType || editingItem?.customerType) { + setCustomerType(forceCustomerType || editingItem?.customerType || 'user'); + } + }, [forceCustomerType, editingItem]); + + const handleClose = () => { + if (!editingItem) { + setItemId(""); + setDisplayName(""); + setCustomerType('user'); + } + setErrors({}); + onOpenChange(false); + }; return ( - { - if (mode === "create") { - const config = await project.getConfig(); - const itemId = values.itemId; - if (has(config.payments.items, itemId)) { - toast({ title: "An item with this ID already exists", variant: "destructive" }); - return "prevent-close-and-prevent-reset"; - } - } - await project.updateConfig({ - [`payments.items.${values.itemId}`]: { - displayName: values.displayName, - customerType: values.customerType, - }, - }); - }} - render={(form) => ( -
- - - + + + + {editingItem ? "Edit Item" : "Create Item"} + + Items are features or services that customers receive. They appear as rows in your pricing table. + + + +
+ {/* Item ID */} +
+ + { + const nextValue = e.target.value.toLowerCase(); + setItemId(nextValue); + if (errors.itemId) { + setErrors(prev => { + const newErrors = { ...prev }; + delete newErrors.itemId; + return newErrors; + }); + } + }} + placeholder="e.g., api-calls" + disabled={!!editingItem} + className={cn(errors.itemId ? "border-destructive" : "")} + /> + {errors.itemId && ( + + {errors.itemId} + + )} +
+ + {/* Display Name */} +
+ + { + setDisplayName(e.target.value); + if (errors.displayName) { + setErrors(prev => { + const newErrors = { ...prev }; + delete newErrors.displayName; + return newErrors; + }); + } + }} + placeholder="e.g., API Calls" + className={cn(errors.displayName ? "border-destructive" : "")} + /> + {errors.displayName && ( + + {errors.displayName} + + )} +
+ + {/* Customer Type */} +
+ + +
- )} - /> + + + + + +
+
); } - - diff --git a/apps/e2e/tests/js/payments.test.ts b/apps/e2e/tests/js/payments.test.ts index 6939b04ecb..43b8454227 100644 --- a/apps/e2e/tests/js/payments.test.ts +++ b/apps/e2e/tests/js/payments.test.ts @@ -165,7 +165,7 @@ it("admin can increase team item quantity and client sees updated value", async }, { timeout: 40_000 }); it("cannot decrease team item quantity below zero", async ({ expect }) => { - const { clientApp, adminApp } = await createApp({ + const { clientApp, serverApp, adminApp } = await createApp({ config: { clientTeamCreationEnabled: true, }, @@ -197,8 +197,9 @@ it("cannot decrease team item quantity below zero", async ({ expect }) => { expect(current.quantity).toBe(0); // Try to decrease by 1 (should fail with KnownErrors.ItemQuantityInsufficientAmount) - await expect(adminApp.createItemQuantityChange({ teamId: team.id, itemId, quantity: -1 })) - .rejects.toThrow(); + const item = await serverApp.getItem({ teamId: team.id, itemId }); + const success = await item.tryDecreaseQuantity(1); + expect(success).toBe(false); const still = await team.getItem(itemId); expect(still.quantity).toBe(0); diff --git a/packages/stack-ui/src/components/data-table/data-table.tsx b/packages/stack-ui/src/components/data-table/data-table.tsx index f9b790988b..8fe988ded3 100644 --- a/packages/stack-ui/src/components/data-table/data-table.tsx +++ b/packages/stack-ui/src/components/data-table/data-table.tsx @@ -129,6 +129,7 @@ export function DataTable({ defaultColumnFilters, defaultSorting, showDefaultToolbar = true, + onRowClick, }: DataTableProps) { const [sorting, setSorting] = React.useState(defaultSorting); const [columnFilters, setColumnFilters] = React.useState(defaultColumnFilters); @@ -156,6 +157,7 @@ export function DataTable({ globalFilter={globalFilter} setGlobalFilter={setGlobalFilter} showDefaultToolbar={showDefaultToolbar} + onRowClick={onRowClick} />; } diff --git a/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts b/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts index 2838c37f78..4d33fb9666 100644 --- a/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts +++ b/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts @@ -578,6 +578,7 @@ export class _StackAdminAppImplIncomplete