diff --git a/scripts/migrations/migrate-functions.ts b/scripts/migrations/migrate-functions.ts index ea1ca7edd..9996aa668 100644 --- a/scripts/migrations/migrate-functions.ts +++ b/scripts/migrations/migrate-functions.ts @@ -1,6 +1,9 @@ import { initializeDatabaseFunctions } from "@server/db/initializeDatabaseFunctions"; +import { loadLocalEnv } from "@server/utils/envUtils"; import inquirer from "inquirer"; +loadLocalEnv(); + export const migrateFunctions = async () => { const databaseUrl = process.env.DATABASE_URL; if (databaseUrl?.includes("us-west-3")) { diff --git a/server/src/internal/customers/handlers/handleTransferProductV2.ts b/server/src/internal/customers/handlers/handleTransferProductV2.ts index 120987a6c..24a40b1f7 100644 --- a/server/src/internal/customers/handlers/handleTransferProductV2.ts +++ b/server/src/internal/customers/handlers/handleTransferProductV2.ts @@ -86,7 +86,7 @@ export const handleTransferProductV2 = createRoute({ const toCusProduct = customer.customer_products.find((cp: any) => { const productMatch = cusProduct?.product.is_add_on ? cp.product.product_id === product.id - : cp.product.group === product.group; + : cp.product.group === product.group && !cp.product.is_add_on; const entityMatch = toEntity?.internal_id ? cp.internal_entity_id === toEntity.internal_id @@ -97,7 +97,7 @@ export const handleTransferProductV2 = createRoute({ if (toCusProduct) { throw new CusProductAlreadyExistsError({ - productId: product_id, + productId: toCusProduct.product?.id, entityId: toEntity?.id, customerId: from_entity_id && !to_entity_id ? customer_id : undefined, }); diff --git a/shared/api/errors/classes/cusProductErrClasses.ts b/shared/api/errors/classes/cusProductErrClasses.ts index f691b1108..1bee408f8 100644 --- a/shared/api/errors/classes/cusProductErrClasses.ts +++ b/shared/api/errors/classes/cusProductErrClasses.ts @@ -11,8 +11,8 @@ export class CusProductNotFoundError extends RecaseError { entityId?: string; }) { const message = opts.entityId - ? `Product ${opts.productId} not found for entity ${opts.entityId}` - : `Product ${opts.productId} not found for customer ${opts.customerId}`; + ? `Plan ${opts.productId} not found for entity ${opts.entityId}` + : `Plan ${opts.productId} not found for customer ${opts.customerId}`; super({ message, @@ -30,8 +30,8 @@ export class CusProductAlreadyExistsError extends RecaseError { entityId?: string; }) { const message = opts.entityId - ? `Entity ${opts.entityId} already has product ${opts.productId}` - : `Customer ${opts.customerId} already has product ${opts.productId}`; + ? `Entity ${opts.entityId} already has plan ${opts.productId}` + : `Customer ${opts.customerId} already has plan ${opts.productId}`; super({ message, diff --git a/shared/utils/cusProductUtils/filterCusProductUtils.ts b/shared/utils/cusProductUtils/filterCusProductUtils.ts index e203a4390..6c3afc180 100644 --- a/shared/utils/cusProductUtils/filterCusProductUtils.ts +++ b/shared/utils/cusProductUtils/filterCusProductUtils.ts @@ -1,3 +1,4 @@ +import { CusProductStatus, type FullCustomer } from "../../index.js"; import type { Entity } from "../../models/cusModels/entityModels/entityModels.js"; import type { FullCustomerEntitlement } from "../../models/cusProductModels/cusEntModels/cusEntModels.js"; import type { FullCusProduct } from "../../models/cusProductModels/cusProductModels.js"; @@ -94,3 +95,50 @@ export const filterOutEntitiesFromCusProducts = ({ return finalCusProducts; }; + +export const getActiveCusProducts = ({ + customer, +}: { + customer: FullCustomer; +}): FullCusProduct[] => { + return customer.customer_products.filter( + (p: FullCusProduct) => p.status === CusProductStatus.Active, + ); +}; + +export const isProductAlreadyEnabled = ({ + productId, + customer, + entityId, +}: { + productId: string; + customer: FullCustomer; + entityId?: string; +}) => { + return getActiveCusProducts({ customer }).some((cp: FullCusProduct) => { + // Check if product matches and is not an add-on + if (cp.product_id !== productId || cp.product.is_add_on) { + return false; + } + + // If no entityId (attaching to customer), only consider customer-level products + if (!entityId) { + return !cp.internal_entity_id && !cp.entity_id; + } + + // If entityId exists (attaching to entity), only consider products for that entity + const entities = customer?.entities || []; + const entity = entities.find( + (e: Entity) => e.id === entityId || e.internal_id === entityId, + ); + + if (entity) { + return ( + cp.internal_entity_id === entity.internal_id || + cp.entity_id === entity.id + ); + } + + return false; + }); +}; diff --git a/vite/src/app/layout.tsx b/vite/src/app/layout.tsx index 4e23cbe18..7740d6c67 100644 --- a/vite/src/app/layout.tsx +++ b/vite/src/app/layout.tsx @@ -95,14 +95,14 @@ export function MainLayout() { includeCredentials={true} > -
+ {/* */} -
+
); @@ -120,7 +120,7 @@ const MainContent = () => { return ( -
{
*/} - +
); }; diff --git a/vite/src/components/forms/attach-product/attach-product-actions.tsx b/vite/src/components/forms/attach-product/attach-product-actions.tsx index a05a412ad..3a69462ad 100644 --- a/vite/src/components/forms/attach-product/attach-product-actions.tsx +++ b/vite/src/components/forms/attach-product/attach-product-actions.tsx @@ -43,7 +43,6 @@ export function AttachProductActions({ null, ); - console.log("org", org); const ownStripeAccount = org.org?.stripe_connection !== "default"; useEffect(() => { diff --git a/vite/src/components/forms/attach-product/attach-product-form-schema.ts b/vite/src/components/forms/attach-product/attach-product-form-schema.ts index 90f1913ca..547866b23 100644 --- a/vite/src/components/forms/attach-product/attach-product-form-schema.ts +++ b/vite/src/components/forms/attach-product/attach-product-form-schema.ts @@ -5,4 +5,7 @@ export const AttachProductFormSchema = z.object({ prepaidOptions: z.record(z.string(), z.number()), }); -export type AttachProductForm = z.infer; +// Extended type with initialPrepaidOptions (not validated, just for state tracking) +export type AttachProductForm = z.infer & { + initialPrepaidOptions?: Record; +}; diff --git a/vite/src/components/forms/attach-product/attach-product-selection.tsx b/vite/src/components/forms/attach-product/attach-product-selection.tsx index 886c3726c..915eefa47 100644 --- a/vite/src/components/forms/attach-product/attach-product-selection.tsx +++ b/vite/src/components/forms/attach-product/attach-product-selection.tsx @@ -1,9 +1,12 @@ +import { isProductAlreadyEnabled } from "@autumn/shared"; import { PencilSimpleIcon } from "@phosphor-icons/react"; import { useNavigate } from "react-router"; import { IconButton } from "@/components/v2/buttons/IconButton"; import { useProductsQuery } from "@/hooks/queries/useProductsQuery"; import { useHasChanges } from "@/hooks/stores/useProductStore"; +import { useEntity } from "@/hooks/stores/useSubscriptionStore"; import { pushPage } from "@/utils/genUtils"; +import { useCusQuery } from "@/views/customers/customer/hooks/useCusQuery"; import type { UseAttachProductForm } from "./use-attach-product-form"; interface AttachProductSelectionProps { @@ -16,10 +19,12 @@ export function AttachProductSelection({ customerId, }: AttachProductSelectionProps) { const { products } = useProductsQuery(); - const activeProducts = products.filter((p) => !p.archived); + const availableProducts = products.filter((p) => !p.archived); const navigate = useNavigate(); const productId = form.state.values.productId; const hasChanges = useHasChanges(); + const { customer } = useCusQuery(); + const { entityId } = useEntity(); const handleCustomize = ({ productId }: { productId: string }) => { if (!productId || !customerId) { @@ -39,9 +44,16 @@ export function AttachProductSelection({ {(field) => ( ({ + options={availableProducts.map((p) => ({ label: p.name, value: p.id, + disabledValue: isProductAlreadyEnabled({ + productId: p.id, + customer, + entityId: entityId ?? undefined, + }) + ? "Already Enabled" + : undefined, }))} placeholder="Select Product" hideFieldInfo diff --git a/vite/src/components/forms/attach-product/update-confirmation-info.tsx b/vite/src/components/forms/attach-product/update-confirmation-info.tsx index b9d35588e..4f11f3f8e 100644 --- a/vite/src/components/forms/attach-product/update-confirmation-info.tsx +++ b/vite/src/components/forms/attach-product/update-confirmation-info.tsx @@ -1,16 +1,23 @@ -import type { CheckoutResponseV0 } from "@autumn/shared"; +import type { CheckoutResponseV0, ProductV2 } from "@autumn/shared"; import type { ReactNode } from "react"; +import { useMemo } from "react"; import { useHasBillingChanges, useHasChanges, + usePrepaidItems, } from "@/hooks/stores/useProductStore"; import { formatUnixToDate } from "@/utils/formatUtils/formatDateUtils"; import { InfoBox } from "@/views/onboarding2/integrate/components/InfoBox"; +import type { UseAttachProductForm } from "./use-attach-product-form"; export const UpdateConfirmationInfo = ({ previewData, + product, + form, }: { previewData?: CheckoutResponseV0 | null; + product?: ProductV2; + form: UseAttachProductForm; }) => { const hasChanges = useHasChanges(); const hasBillingChanges = useHasBillingChanges({ @@ -18,6 +25,8 @@ export const UpdateConfirmationInfo = ({ newProduct: previewData?.product, }); + const hasPrepaidQuantityChanges = useHasPrepaidQuantityChanges(product, form); + const renderInfoBoxes = (): ReactNode[] => { const boxes: ReactNode[] = []; @@ -44,8 +53,17 @@ export const UpdateConfirmationInfo = ({ ); } + // Prepaid quantity changes notice + if (hasPrepaidQuantityChanges) { + boxes.push( + + Prepaid quantities have been updated + , + ); + } + // No billing changes notice - if (!hasBillingChanges) { + if (!hasBillingChanges && !hasPrepaidQuantityChanges) { boxes.push( No changes to billing will be made @@ -89,3 +107,28 @@ export const UpdateConfirmationInfo = ({ ); }; + +const useHasPrepaidQuantityChanges = ( + product: ProductV2 | undefined, + form: UseAttachProductForm, +) => { + const prepaidItems = usePrepaidItems({ product }); + const currentPrepaidOptions = form.state.values.prepaidOptions; + const initialPrepaidOptions = form.state.values.initialPrepaidOptions; + + return useMemo(() => { + if ( + prepaidItems.length === 0 || + !currentPrepaidOptions || + !initialPrepaidOptions + ) { + return false; + } + + return prepaidItems.some((item) => { + const currentQuantity = currentPrepaidOptions[item.feature_id as string]; + const initialQuantity = initialPrepaidOptions[item.feature_id as string]; + return currentQuantity !== initialQuantity; + }); + }, [prepaidItems, currentPrepaidOptions, initialPrepaidOptions]); +}; diff --git a/vite/src/components/forms/attach-product/update-product-summary.tsx b/vite/src/components/forms/attach-product/update-product-summary.tsx index ea86ff247..51eb791f8 100644 --- a/vite/src/components/forms/attach-product/update-product-summary.tsx +++ b/vite/src/components/forms/attach-product/update-product-summary.tsx @@ -7,15 +7,18 @@ import { } from "@/components/v2/sheets/SheetAccordion"; import { formatUnixToDate } from "@/utils/formatUtils/formatDateUtils"; import { UpdateConfirmationInfo } from "./update-confirmation-info"; +import type { UseAttachProductForm } from "./use-attach-product-form"; export function UpdateProductSummary({ product, previewData, isLoading, + form, }: { product?: ProductV2; previewData?: CheckoutResponseV0 | null; isLoading?: boolean; + form: UseAttachProductForm; }) { if (isLoading) { return ( @@ -43,7 +46,11 @@ export function UpdateProductSummary({ return (
- + diff --git a/vite/src/components/forms/attach-product/use-attach-product-form.ts b/vite/src/components/forms/attach-product/use-attach-product-form.ts index ca53cb67a..b148603cd 100644 --- a/vite/src/components/forms/attach-product/use-attach-product-form.ts +++ b/vite/src/components/forms/attach-product/use-attach-product-form.ts @@ -6,17 +6,20 @@ import { export function useAttachProductForm({ initialProductId, + initialPrepaidOptions, }: { initialProductId?: string; + initialPrepaidOptions?: Record; } = {}) { return useAppForm({ defaultValues: { productId: initialProductId || "", prepaidOptions: {} as Record, - } satisfies AttachProductForm, + initialPrepaidOptions: initialPrepaidOptions ?? undefined, + } as AttachProductForm, validators: { - onChange: AttachProductFormSchema, - onSubmit: AttachProductFormSchema, + onChange: AttachProductFormSchema.passthrough(), + onSubmit: AttachProductFormSchema.passthrough(), }, }); } diff --git a/vite/src/components/general/form/fields/select-field.tsx b/vite/src/components/general/form/fields/select-field.tsx index ffce13cd4..87615b49d 100644 --- a/vite/src/components/general/form/fields/select-field.tsx +++ b/vite/src/components/general/form/fields/select-field.tsx @@ -12,6 +12,7 @@ import { useFieldContext } from "@/hooks/form/form-context"; export type SelectFieldOption = { label: string; value: string; + disabledValue?: string; }; export function SelectField({ @@ -45,8 +46,19 @@ export function SelectField({ {options.map((option) => ( - + {option.label} + {option.disabledValue && ( + + {option.disabledValue} + + )} ))} diff --git a/vite/src/components/general/table/table-body.tsx b/vite/src/components/general/table/table-body.tsx index 6c9499101..1cb1c38b8 100644 --- a/vite/src/components/general/table/table-body.tsx +++ b/vite/src/components/general/table/table-body.tsx @@ -19,6 +19,8 @@ export function TableBody() { rowClassName, emptyStateChildren, emptyStateText, + selectedItemId, + flexibleTableColumns, } = useTableContext(); const rows = table.getRowModel().rows; @@ -47,39 +49,50 @@ export function TableBody() { return ( - {rows.map((row) => ( - onRowClick?.(row.original)} - > - {enableSelection && ( - - row.toggleSelected(!!checked)} - /> - - )} - {row.getVisibleCells().map((cell, index) => ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ))} - - ))} + {rows.map((row) => { + const isSelected = selectedItemId === (row.original as any).id; + return ( + onRowClick?.(row.original)} + > + {enableSelection && ( + + row.toggleSelected(!!checked)} + /> + + )} + {row.getVisibleCells().map((cell, index) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + ); + })} ); } diff --git a/vite/src/components/general/table/table-content.tsx b/vite/src/components/general/table/table-content.tsx index dfc42b66f..96204bbb6 100644 --- a/vite/src/components/general/table/table-content.tsx +++ b/vite/src/components/general/table/table-content.tsx @@ -1,9 +1,34 @@ import { Table } from "@/components/ui/table"; +import { useSheetStore } from "@/hooks/stores/useSheetStore"; +import { cn } from "@/lib/utils"; +import { useTableContext } from "./table-context"; + +export function TableContent({ + children, + className, +}: { + children: React.ReactNode; + className?: string; +}) { + const { flexibleTableColumns } = useTableContext(); + const sheetType = useSheetStore((s) => s.type); -export function TableContent({ children }: { children: React.ReactNode }) { return ( -
- {children}
+
+ {sheetType && ( +
+ )} + + {children} +
); } diff --git a/vite/src/components/general/table/table-context.tsx b/vite/src/components/general/table/table-context.tsx index 56348dc26..297d3c11a 100644 --- a/vite/src/components/general/table/table-context.tsx +++ b/vite/src/components/general/table/table-context.tsx @@ -11,6 +11,8 @@ export interface TableProps { rowClassName?: string; emptyStateChildren?: ReactNode; emptyStateText?: string; + flexibleTableColumns?: boolean; + selectedItemId?: string | null; } //biome-ignore lint/suspicious/noExplicitAny: type could be any here diff --git a/vite/src/components/ui/table.tsx b/vite/src/components/ui/table.tsx index 52d09c744..deb10cb12 100644 --- a/vite/src/components/ui/table.tsx +++ b/vite/src/components/ui/table.tsx @@ -2,15 +2,23 @@ import type * as React from "react"; import { cn } from "@/lib/utils"; -function Table({ className, ...props }: React.ComponentProps<"table">) { +function Table({ + className, + flexibleTableColumns, + ...props +}: React.ComponentProps<"table"> & { flexibleTableColumns?: boolean }) { return (
@@ -56,7 +64,7 @@ function TableRow({ className, ...props }: React.ComponentProps<"tr">) { navigateTo(item.href, navigate, env)} + onClick={() => item.href && navigateTo(item.href, navigate, env)} className="cursor-pointer" > - {item.name} + {item.name} {index < items.length - 1 && } diff --git a/vite/src/components/v2/dialogs/Dialog.tsx b/vite/src/components/v2/dialogs/Dialog.tsx index 6945b9233..3b411c0a7 100644 --- a/vite/src/components/v2/dialogs/Dialog.tsx +++ b/vite/src/components/v2/dialogs/Dialog.tsx @@ -38,7 +38,7 @@ function DialogOverlay({ - + + - - + + - + diff --git a/vite/src/components/v2/empty-states/customers2.svg b/vite/src/components/v2/empty-states/customers2.svg new file mode 100644 index 000000000..708ab4a4b --- /dev/null +++ b/vite/src/components/v2/empty-states/customers2.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/vite/src/components/v2/sheets/SharedSheetComponents.tsx b/vite/src/components/v2/sheets/SharedSheetComponents.tsx index 404920809..7436aa38a 100644 --- a/vite/src/components/v2/sheets/SharedSheetComponents.tsx +++ b/vite/src/components/v2/sheets/SharedSheetComponents.tsx @@ -40,7 +40,7 @@ interface SheetSectionProps { description?: string | React.ReactNode; checked?: boolean; setChecked?: (checked: boolean) => void; - + actions?: React.ReactNode; children: React.ReactNode; withSeparator?: boolean; } @@ -50,7 +50,7 @@ export function SheetSection({ description, checked = true, setChecked, - + actions, children, withSeparator = true, }: SheetSectionProps) { @@ -61,20 +61,28 @@ export function SheetSection({ <>
{title && ( -
- -
-
-
- - -
-
+
+
+ + + +
-
+
s.closeSheet); //Close the subscription detail / attach product sheet when navigating to this page (prevents jank closing animation) @@ -52,68 +49,12 @@ export default function CustomerProductView() { const { isLoading: orgLoading } = useOrg(); const { isLoading: featuresLoading } = useFeaturesQuery(); - const [options, setOptions] = useState([]); - const [entityId, setEntityId] = useState(entityIdParam); - const [entityFeatureIds, setEntityFeatureIds] = useState([]); - - const { - product: originalProduct, - cusProduct, - isLoading, - error, - } = useCusProductQuery(); + const { product: originalProduct, isLoading, error } = useCusProductQuery(); useProductSync({ product: originalProduct }); const { isLoading: cusLoading } = useCusQuery(); - //probs not needed anymore? used to pass entityId into the ProductContext - //now we can get it from CusProductQuery? - // useEffect(() => { - // if (entityIdParam) { - // setEntityId(entityIdParam); - // } else { - // setEntityId(null); - // } - // }, [entityIdParam]); - - // useEffect(() => { - // if (!originalProduct) return; - - // const product = originalProduct; - - // console.log("[CPV] effect", { - // prodId: originalProduct.id, - // v: originalProduct.version, - // cusId: cusProduct?.id, - // }); - - // // Update initialProductRef BEFORE setProduct to ensure useAttachState - // // effect has the correct baseline when it runs - // initialProductRef.current = structuredClone({ - // ...product, - // items: sortProductItems(product.items), - // }); - - // setProduct(product); - - // setEntityFeatureIds( - // Array.from( - // new Set( - // product.items - // .filter((item: ProductItem) => notNullish(item.entity_feature_id)) - // .map((item: ProductItem) => item.entity_feature_id!), - // ), - // ), - // ); - - // if (cusProduct?.options) { - // setOptions(cusProduct.options); - // } else { - // setOptions([]); - // } - // }, [originalProduct, cusProduct]); - if (error) { return ( @@ -132,23 +73,11 @@ export default function CustomerProductView() { } return ( - + <> - + ); } diff --git a/vite/src/views/customers2/customer/CustomerBalanceSheets.tsx b/vite/src/views/customers2/customer/CustomerBalanceSheets.tsx deleted file mode 100644 index e9289b872..000000000 --- a/vite/src/views/customers2/customer/CustomerBalanceSheets.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { CustomerBalanceModal } from "./components/CustomerBalanceModal"; - -export const CustomerBalanceSheets = () => { - return ; -}; diff --git a/vite/src/views/customers2/customer/CustomerBreadcrumbs2.tsx b/vite/src/views/customers2/customer/CustomerBreadcrumbs2.tsx index 728ee73c2..ae018018e 100644 --- a/vite/src/views/customers2/customer/CustomerBreadcrumbs2.tsx +++ b/vite/src/views/customers2/customer/CustomerBreadcrumbs2.tsx @@ -53,7 +53,7 @@ export const CustomerBreadcrumbs = () => { - + {entityId ? ( { {entityId && ( <> - + {entity?.name || entityId} diff --git a/vite/src/views/customers2/customer/CustomerSheets.tsx b/vite/src/views/customers2/customer/CustomerSheets.tsx index 4d039251a..27d5fa654 100644 --- a/vite/src/views/customers2/customer/CustomerSheets.tsx +++ b/vite/src/views/customers2/customer/CustomerSheets.tsx @@ -1,9 +1,11 @@ import { AnimatePresence, motion } from "motion/react"; -import { createPortal } from "react-dom"; import { SheetContainer } from "@/components/v2/sheets/InlineSheet"; import { SheetCloseButton } from "@/components/v2/sheets/SheetCloseButton"; +import { useCustomerBalanceSheetStore } from "@/hooks/stores/useCustomerBalanceSheetStore"; import { useSheetStore } from "@/hooks/stores/useSheetStore"; import { AttachProductSheet } from "../components/sheets/AttachProductSheet"; +import { BalanceEditSheet } from "../components/sheets/BalanceEditSheet"; +import { BalanceSelectionSheet } from "../components/sheets/BalanceSelectionSheet"; import { SubscriptionDetailSheet } from "../components/sheets/SubscriptionDetailSheet"; import { SubscriptionUpdateSheet } from "../components/sheets/SubscriptionUpdateSheet"; import { SHEET_ANIMATION } from "./customerAnimations"; @@ -11,6 +13,12 @@ import { SHEET_ANIMATION } from "./customerAnimations"; export function CustomerSheets() { const sheetType = useSheetStore((s) => s.type); const closeSheet = useSheetStore((s) => s.closeSheet); + const closeBalanceSheet = useCustomerBalanceSheetStore((s) => s.closeSheet); + + const handleClose = () => { + closeSheet(); + closeBalanceSheet(); + }; const renderSheet = () => { switch (sheetType) { @@ -20,12 +28,16 @@ export function CustomerSheets() { return ; case "subscription-update": return ; + case "balance-selection": + return ; + case "balance-edit": + return ; default: return null; } }; - return createPortal( + return ( {sheetType && ( - - + + {renderSheet()} )} - , - document.body, + ); // return ( diff --git a/vite/src/views/customers2/customer/CustomerView2.tsx b/vite/src/views/customers2/customer/CustomerView2.tsx index ba10f4404..972cbee80 100644 --- a/vite/src/views/customers2/customer/CustomerView2.tsx +++ b/vite/src/views/customers2/customer/CustomerView2.tsx @@ -1,10 +1,8 @@ "use client"; import { AnimatePresence, motion } from "motion/react"; -import { useEffect } from "react"; import { createPortal } from "react-dom"; import { Link } from "react-router"; -import { useCustomerBalanceSheetStore } from "@/hooks/stores/useCustomerBalanceSheetStore"; import { useHasChanges } from "@/hooks/stores/useProductStore"; import { useSheetStore } from "@/hooks/stores/useSheetStore"; import { useEntity } from "@/hooks/stores/useSubscriptionStore"; @@ -18,7 +16,6 @@ import { CustomerInvoicesTable } from "../components/table/customer-invoices/Cus import { CustomerProductsTable } from "../components/table/customer-products/CustomerProductsTable"; import { CustomerUsageAnalyticsTable } from "../components/table/customer-usage-analytics/CustomerUsageAnalyticsTable"; import { CustomerActions } from "./CustomerActions"; -import { CustomerBalanceSheets } from "./CustomerBalanceSheets"; import { CustomerBreadcrumbs } from "./CustomerBreadcrumbs2"; import { CustomerContext } from "./CustomerContext"; import { CustomerPageDetails } from "./CustomerPageDetails"; @@ -32,22 +29,11 @@ export default function CustomerView2() { useCusReferralQuery(); const { entityId, setEntityId } = useEntity(); - const closeSheet = useCustomerBalanceSheetStore((s) => s.closeSheet); const sheetType = useSheetStore((s) => s.type); const closeProductSheet = useSheetStore((s) => s.closeSheet); const hasChanges = useHasChanges(); - // Close modal on mount - useEffect(() => { - closeSheet(); - }, [closeSheet]); - - // Clear selected entity on unmount (when navigating away) - // useEffect(() => { - // return () => { - // setEntityId(null); - // }; - // }, [setEntityId]); + // useSheetCleanup(); if (cusLoading) return ; @@ -104,7 +90,7 @@ export default function CustomerView2() {
{/* */} {/* */} -
+
{/* */} @@ -121,7 +107,7 @@ export default function CustomerView2() { initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} - className="fixed inset-0 bg-background/60" + className="fixed inset-0 bg-white/60 dark:bg-black/60" style={{ zIndex: 40 }} onMouseDown={() => { !hasChanges && closeProductSheet(); @@ -133,7 +119,6 @@ export default function CustomerView2() { )} -
diff --git a/vite/src/views/customers2/customer/components/CustomerBalanceModal.tsx b/vite/src/views/customers2/customer/components/CustomerBalanceModal.tsx deleted file mode 100644 index c05d68197..000000000 --- a/vite/src/views/customers2/customer/components/CustomerBalanceModal.tsx +++ /dev/null @@ -1,376 +0,0 @@ -import { - type FullCusProduct, - type FullCustomerEntitlement, - type FullCustomerPrice, - getCusEntBalance, -} from "@autumn/shared"; -import { useEffect, useMemo, useState } from "react"; -import { toast } from "sonner"; -import { DateInputUnix } from "@/components/general/DateInputUnix"; -import { Button } from "@/components/v2/buttons/Button"; -import { CopyButton } from "@/components/v2/buttons/CopyButton"; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, -} from "@/components/v2/dialogs/Dialog"; -import { LabelInput } from "@/components/v2/inputs/LabelInput"; -import { useCustomerBalanceSheetStore } from "@/hooks/stores/useCustomerBalanceSheetStore"; -import { CusService } from "@/services/customers/CusService"; -import { useAxiosInstance } from "@/services/useAxiosInstance"; -import { getBackendErr, notNullish } from "@/utils/genUtils"; -import { useCusQuery } from "@/views/customers/customer/hooks/useCusQuery"; -import { InfoBox } from "@/views/onboarding2/integrate/components/InfoBox"; -import { AdminHover } from "../../../../components/general/AdminHover"; -import { getCusEntHoverTexts } from "../../../admin/adminUtils"; -import { useCustomerContext } from "../CustomerContext"; - -export function CustomerBalanceModal() { - const { customer, refetch } = useCusQuery(); - const { entityId } = useCustomerContext(); - const { type, featureId, originalEntitlements, closeSheet } = - useCustomerBalanceSheetStore(); - - const [updateLoading, setUpdateLoading] = useState(null); - const [selectedCusEntId, setSelectedCusEntId] = useState(null); - const axiosInstance = useAxiosInstance(); - - const initialFields = useMemo(() => { - if (!originalEntitlements.length) { - return new Map< - string, - { balance: number | null; next_reset_at: number | null } - >(); - } - - const fields = new Map< - string, - { balance: number | null; next_reset_at: number | null } - >(); - - for (const cusEnt of originalEntitlements) { - const balance = getCusEntBalance({ - cusEnt, - entityId, - }).balance; - - fields.set(cusEnt.id, { - balance, - next_reset_at: cusEnt.next_reset_at, - }); - } - - return fields; - }, [featureId, entityId, originalEntitlements]); - - const [updateFields, setUpdateFields] = useState(initialFields); - - // Update fields when featureId changes (reset state for new balance) - useEffect(() => { - setUpdateFields(initialFields); - setSelectedCusEntId(null); - }, [initialFields]); - - if (!featureId || !originalEntitlements.length) return null; - - const firstEnt = originalEntitlements[0]; - const feature = firstEnt.entitlement.feature; - const hasMultipleBalances = originalEntitlements.length > 1; - const showSelectionStep = hasMultipleBalances && !selectedCusEntId; - - const getCusProduct = (cusEnt: FullCustomerEntitlement) => { - const cusProduct = customer.customer_products.find( - (cp: FullCusProduct) => cp.id === cusEnt.customer_product_id, - ); - return cusProduct; - }; - - const handleUpdateCusEntitlement = async ( - cusEnt: FullCustomerEntitlement, - ) => { - const fields = updateFields.get(cusEnt.id); - if (!fields) return; - - const balanceInt = parseFloat(String(fields.balance)); - if (Number.isNaN(balanceInt)) { - toast.error("Balance not valid"); - return; - } - - const cusProduct = getCusProduct(cusEnt); - const cusPrice = cusProduct?.customer_prices.find( - (cp: FullCustomerPrice) => - cp.price.entitlement_id === cusEnt.entitlement.id, - ); - - if (cusPrice && fields.next_reset_at !== cusEnt.next_reset_at) { - toast.error(`Not allowed to change reset at for paid features`); - return; - } - - setUpdateLoading(cusEnt.id); - try { - await CusService.updateCusEntitlement( - axiosInstance, - customer.id || customer.internal_id, - cusEnt.id, - { - balance: balanceInt, - next_reset_at: fields.next_reset_at, - entity_id: entityId, - }, - ); - toast.success("Balance updated successfully"); - await refetch(); - closeSheet(); - } catch (error) { - toast.error(getBackendErr(error, "Failed to update entitlement")); - } - setUpdateLoading(null); - }; - - // Render selection step - if (showSelectionStep) { - return ( - { - if (!open) closeSheet(); - }} - > - - - Select Balance to Update - - {feature.name} - - - -
- {originalEntitlements.map((cusEnt: FullCustomerEntitlement) => { - const cusProduct = getCusProduct(cusEnt); - const fields = updateFields.get(cusEnt.id); - const balance = fields?.balance; - - return ( - - ); - })} -
-
-
- ); - } - - // Render update form step - const selectedCusEnt = hasMultipleBalances - ? originalEntitlements.find((ent) => ent.id === selectedCusEntId) - : originalEntitlements[0]; - - if (!selectedCusEnt) return null; - - return ( - { - if (!open) closeSheet(); - }} - > - - - - {hasMultipleBalances && ( - - )} - {feature.name} - - - - {feature.id} - - - -
- {(() => { - const cusEnt = selectedCusEnt; - const fields = updateFields.get(cusEnt.id); - if (!fields) return null; - - const initialFieldsForEnt = initialFields.get(cusEnt.id); - - const hasChanges = - initialFieldsForEnt && - (fields.balance !== initialFieldsForEnt.balance || - fields.next_reset_at !== initialFieldsForEnt.next_reset_at); - - const cusProduct = getCusProduct(cusEnt); - const cusPrice = cusProduct?.customer_prices.find( - (cp: FullCustomerPrice) => - cp.price.entitlement_id === cusEnt.entitlement.id, - ); - - return ( -
- {/* {cusProduct?.name && ( -
- From product:{" "} - {cusProduct.name} -
- )} */} - -
-
- - - Plan ID: - - - - {cusProduct?.product_id || "N/A"} - -
- {cusProduct?.entity_id && ( -
- - Entity ID: - - - {cusProduct.entity_id} - -
- )} -
- - Reset Interval: - - - {cusEnt.entitlement.interval === "lifetime" - ? "never" - : cusEnt.entitlement.interval} - -
-
- -
-
- { - const newFields = new Map(updateFields); - const current = newFields.get(cusEnt.id) || { - balance: null, - next_reset_at: null, - }; - newFields.set(cusEnt.id, { - ...current, - balance: e.target.value - ? parseFloat(e.target.value) - : null, - }); - setUpdateFields(newFields); - }} - /> - -
-
- Next Reset -
- { - const newFields = new Map(updateFields); - const current = newFields.get(cusEnt.id) || { - balance: null, - next_reset_at: null, - }; - newFields.set(cusEnt.id, { - ...current, - next_reset_at: unixDate, - }); - setUpdateFields(newFields); - }} - /> -
-
- - {cusPrice && ( - - Reset cycle cannot be changed for paid features, as it - follows the billing cycle. - - )} -
- -
- -
-
- ); - })()} -
-
-
- ); -} diff --git a/vite/src/views/developer/configure-stripe/ConfigureStripe.tsx b/vite/src/views/developer/configure-stripe/ConfigureStripe.tsx index ed426458c..3d770689d 100644 --- a/vite/src/views/developer/configure-stripe/ConfigureStripe.tsx +++ b/vite/src/views/developer/configure-stripe/ConfigureStripe.tsx @@ -231,7 +231,7 @@ export const ConfigureStripe = () => { {" "} Visit the Stripe dashboard{" "} - + { placeholder="eg. https://useautumn.com" className={urlError ? "border-red-500" : ""} /> - {urlError && ( -

{urlError}

- )} + {urlError &&

{urlError}

}
@@ -379,4 +377,4 @@ export const ConfigureStripe = () => {
); -}; \ No newline at end of file +}; diff --git a/vite/src/views/products/plan/ProductSheets.tsx b/vite/src/views/products/plan/ProductSheets.tsx index 72c05caf9..ed231ae48 100644 --- a/vite/src/views/products/plan/ProductSheets.tsx +++ b/vite/src/views/products/plan/ProductSheets.tsx @@ -1,6 +1,5 @@ import { type ProductItem, productV2ToFeatureItems } from "@autumn/shared"; import { AnimatePresence, motion } from "motion/react"; -import { createPortal } from "react-dom"; import { SheetContainer } from "@/components/v2/sheets/InlineSheet"; import { SheetCloseButton } from "@/components/v2/sheets/SheetCloseButton"; import { useProductStore } from "@/hooks/stores/useProductStore"; @@ -80,7 +79,7 @@ export const ProductSheets = () => { } }; - return createPortal( + return ( {sheetType && ( { animate={{ x: 0 }} exit={{ x: "100%" }} transition={SHEET_ANIMATION} - className="fixed right-0 top-0 bottom-0" + className="absolute right-0 top-0 bottom-0" style={{ width: "28rem", zIndex: 100 }} > - + {renderSheet()} )} - , - document.body, + ); }; diff --git a/vite/src/views/products/plan/components/EditPlanHeader.tsx b/vite/src/views/products/plan/components/EditPlanHeader.tsx index 0cb6e08a7..8be8aca61 100644 --- a/vite/src/views/products/plan/components/EditPlanHeader.tsx +++ b/vite/src/views/products/plan/components/EditPlanHeader.tsx @@ -1,4 +1,5 @@ import { UserIcon } from "@phosphor-icons/react"; +import { parseAsString, useQueryStates } from "nuqs"; import { useState } from "react"; import { toast } from "sonner"; import { AdminHover } from "@/components/general/AdminHover"; @@ -17,10 +18,11 @@ import { useIsCusPlanEditor, useProductStore, } from "@/hooks/stores/useProductStore.ts"; -import { useSheetStore } from "@/hooks/stores/useSheetStore"; import { useAxiosInstance } from "@/services/useAxiosInstance"; import { getBackendErr } from "@/utils/genUtils"; import { isOneOffProduct } from "@/utils/product/priceUtils"; +import { useCusQuery } from "@/views/customers/customer/hooks/useCusQuery.tsx"; +import { useCusProductQuery } from "@/views/customers/customer/product/hooks/useCusProductQuery.tsx"; import { useMigrationsQuery } from "../../product/hooks/queries/useMigrationsQuery.tsx.tsx"; import { useProductCountsQuery } from "../../product/hooks/queries/useProductCountsQuery"; import { @@ -39,7 +41,6 @@ export const EditPlanHeader = () => { const { refetch: refetchMigrations } = useMigrationsQuery(); const { queryStates, setQueryStates } = useProductQueryState(); const axiosInstance = useAxiosInstance(); - const sheetType = useSheetStore((s) => s.type); const isCusPlanEditor = useIsCusPlanEditor(); const [confirmMigrateOpen, setConfirmMigrateOpen] = useState(false); @@ -116,19 +117,24 @@ export const EditPlanHeader = () => { version={version} />
- + {isCusPlanEditor ? ( + + ) : ( + + )} +
@@ -171,7 +177,7 @@ export const EditPlanHeader = () => { value={currentVersion.toString()} onValueChange={handleVersionChange} > - + @@ -190,3 +196,41 @@ export const EditPlanHeader = () => { ); }; + +const CustomerBreadcrumbs = () => { + const { customer } = useCusQuery(); + const { product } = useCusProductQuery(); + const [{ entity_id }] = useQueryStates({ + entity_id: parseAsString, + }); + //find entity name + const entity = customer.entities.find((e: any) => e.id === entity_id); + + return ( + + ); +}; diff --git a/vite/src/views/products/plan/components/PlanEditor.tsx b/vite/src/views/products/plan/components/PlanEditor.tsx index 6eb62ee6c..f7fa6c017 100644 --- a/vite/src/views/products/plan/components/PlanEditor.tsx +++ b/vite/src/views/products/plan/components/PlanEditor.tsx @@ -81,15 +81,7 @@ export const PlanEditor = () => {
e.stopPropagation()}>
- {/* */} -
{ - // if (shouldCloseSheetOnMouseDown({ e, item, sheetType })) { - // closeSheet(); - // } - // }} - > +
{useIsCusPlanEditor() && }
@@ -108,7 +100,7 @@ export const PlanEditor = () => { initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} - className="fixed inset-0 bg-background/70" + className="fixed inset-0 bg-white/70 dark:bg-black/70" style={{ zIndex: 40 }} onMouseDown={(e) => { if (shouldCloseSheetOnMouseDown({ e, item, sheetType })) { diff --git a/vite/src/views/products/plan/components/edit-plan-feature/BillingType.tsx b/vite/src/views/products/plan/components/edit-plan-feature/BillingType.tsx index 8b9a375f2..c4723458a 100644 --- a/vite/src/views/products/plan/components/edit-plan-feature/BillingType.tsx +++ b/vite/src/views/products/plan/components/edit-plan-feature/BillingType.tsx @@ -12,16 +12,12 @@ import { CoinsIcon } from "@phosphor-icons/react"; import { PanelButton } from "@/components/v2/buttons/PanelButton"; import { IncludedUsageIcon } from "@/components/v2/icons/AutumnIcons"; import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery"; -import { useProductStore } from "@/hooks/stores/useProductStore"; import { useProductItemContext } from "@/views/products/product/product-item/ProductItemContext"; export function BillingType() { const { features } = useFeaturesQuery(); const { item, setItem } = useProductItemContext(); - const product = useProductStore((s) => s.product); - const setProduct = useProductStore((s) => s.setProduct); - if (!item) return null; // Derive billing type from item state @@ -97,7 +93,7 @@ export function BillingType() {
{ setBillingType("included"); }} @@ -124,7 +120,7 @@ export function BillingType() { icon={} />
-
Paid
+
Priced
{isConsumable ? `Charge a price for usage of this feature (e.g. $0.05 per ${singleFeatureName}).` diff --git a/vite/src/views/products/plan/components/edit-plan-feature/EditPlanFeatureSheet.tsx b/vite/src/views/products/plan/components/edit-plan-feature/EditPlanFeatureSheet.tsx index 4d78a4839..f46ae69b7 100644 --- a/vite/src/views/products/plan/components/edit-plan-feature/EditPlanFeatureSheet.tsx +++ b/vite/src/views/products/plan/components/edit-plan-feature/EditPlanFeatureSheet.tsx @@ -52,25 +52,21 @@ export function EditPlanFeatureSheet({ - {hasChosenBillingType && ( - <> - - - + + + - {isFeaturePrice && ( - - - - - - )} - - - + {isFeaturePrice && ( + + + + + )} + + )} diff --git a/vite/src/views/products/plan/components/new-feature/NewFeatureDetails.tsx b/vite/src/views/products/plan/components/new-feature/NewFeatureDetails.tsx index b05c6dacb..dcf6e7831 100644 --- a/vite/src/views/products/plan/components/new-feature/NewFeatureDetails.tsx +++ b/vite/src/views/products/plan/components/new-feature/NewFeatureDetails.tsx @@ -33,7 +33,7 @@ export function NewFeatureDetails({
Name setSource(e.target.value)} /> @@ -42,7 +42,7 @@ export function NewFeatureDetails({
ID setTarget(e.target.value)} /> diff --git a/vite/src/views/products/plan/components/plan-card/PlanCard.tsx b/vite/src/views/products/plan/components/plan-card/PlanCard.tsx index d41da6f38..c2dd235e8 100644 --- a/vite/src/views/products/plan/components/plan-card/PlanCard.tsx +++ b/vite/src/views/products/plan/components/plan-card/PlanCard.tsx @@ -22,7 +22,7 @@ export default function sPlanCard() { > {/* Overlay when sheet is open that lets you hover on plan card buttons */} {sheetType && ( -
+
)}