wip charlie

This commit is contained in:
Ayush Rodrigues
2025-11-28 17:26:57 +00:00
parent f03df0cbd0
commit f30a8fe408
54 changed files with 1122 additions and 829 deletions

View File

@@ -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")) {

View File

@@ -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,
});

View File

@@ -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,

View File

@@ -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;
});
};

View File

@@ -95,14 +95,14 @@ export function MainLayout() {
includeCredentials={true}
>
<NuqsAdapter>
<main className="w-screen h-screen flex bg-outer-background">
<body className="w-screen h-screen flex bg-outer-background">
<CustomToaster />
<MainSidebar />
<InviteNotifications />
<MainContent />
{/* <ChatWidget /> */}
<CommandBar />
</main>
</body>
</NuqsAdapter>
</AutumnProvider>
);
@@ -120,7 +120,7 @@ const MainContent = () => {
return (
<AppContext.Provider value={{}}>
<div
<main
className={cn(
"w-full h-screen flex flex-col justify-center overflow-hidden py-3 pr-3 relative",
// Default font
@@ -154,7 +154,7 @@ const MainContent = () => {
</div> */}
</div>
</div>
</div>
</main>
</AppContext.Provider>
);
};

View File

@@ -43,7 +43,6 @@ export function AttachProductActions({
null,
);
console.log("org", org);
const ownStripeAccount = org.org?.stripe_connection !== "default";
useEffect(() => {

View File

@@ -5,4 +5,7 @@ export const AttachProductFormSchema = z.object({
prepaidOptions: z.record(z.string(), z.number()),
});
export type AttachProductForm = z.infer<typeof AttachProductFormSchema>;
// Extended type with initialPrepaidOptions (not validated, just for state tracking)
export type AttachProductForm = z.infer<typeof AttachProductFormSchema> & {
initialPrepaidOptions?: Record<string, number>;
};

View File

@@ -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) => (
<field.SelectField
label=""
options={activeProducts.map((p) => ({
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

View File

@@ -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(
<InfoBox key="prepaid-quantity-changes" variant="info">
Prepaid quantities have been updated
</InfoBox>,
);
}
// No billing changes notice
if (!hasBillingChanges) {
if (!hasBillingChanges && !hasPrepaidQuantityChanges) {
boxes.push(
<InfoBox key="no-billing-changes" variant="success">
No changes to billing will be made
@@ -89,3 +107,28 @@ export const UpdateConfirmationInfo = ({
</div>
);
};
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]);
};

View File

@@ -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 (
<div className="space-y-3 text-sm">
<UpdateConfirmationInfo previewData={previewData} />
<UpdateConfirmationInfo
previewData={previewData}
product={product}
form={form}
/>
<Separator />
<SheetAccordion type="single" withSeparator={false} collapsible={true}>

View File

@@ -6,17 +6,20 @@ import {
export function useAttachProductForm({
initialProductId,
initialPrepaidOptions,
}: {
initialProductId?: string;
initialPrepaidOptions?: Record<string, number>;
} = {}) {
return useAppForm({
defaultValues: {
productId: initialProductId || "",
prepaidOptions: {} as Record<string, number>,
} satisfies AttachProductForm,
initialPrepaidOptions: initialPrepaidOptions ?? undefined,
} as AttachProductForm,
validators: {
onChange: AttachProductFormSchema,
onSubmit: AttachProductFormSchema,
onChange: AttachProductFormSchema.passthrough(),
onSubmit: AttachProductFormSchema.passthrough(),
},
});
}

View File

@@ -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({
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem key={option.value} value={option.value}>
<SelectItem
key={option.value}
value={option.value}
className={
option.disabledValue ? "text-t4 pointer-events-none" : ""
}
>
{option.label}
{option.disabledValue && (
<span className="text-xs text-t3 bg-muted px-1 py-0 rounded-md">
{option.disabledValue}
</span>
)}
</SelectItem>
))}
</SelectContent>

View File

@@ -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 (
<ShadcnTableBody className="divide-y">
{rows.map((row) => (
<TableRow
className={cn(
"text-t3 transition-none hover:bg-interactive-secondary-hover dark:hover:bg-interactive-secondary-hover h-12 py-4",
rowClassName,
)}
data-state={row.getIsSelected() && "selected"}
key={row.id}
onClick={() => onRowClick?.(row.original)}
>
{enableSelection && (
<TableCell className="w-[50px]">
<Checkbox
aria-label="Select row"
checked={row.getIsSelected()}
onCheckedChange={(checked) => row.toggleSelected(!!checked)}
/>
</TableCell>
)}
{row.getVisibleCells().map((cell, index) => (
<TableCell
className={cn(
"px-2 h-4 text-t3",
index === 0 && "pl-4 text-t2 font-semibold",
)}
key={cell.id}
style={{ width: `${cell.column.getSize()}px` }}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))}
{rows.map((row) => {
const isSelected = selectedItemId === (row.original as any).id;
return (
<TableRow
className={cn(
"text-t3 transition-none hover:bg-interactive-secondary-hover dark:hover:bg-interactive-secondary-hover h-12 py-4 relative",
rowClassName,
isSelected ? "z-100 " : "",
)}
data-state={row.getIsSelected() && "selected"}
key={row.id}
onClick={() => onRowClick?.(row.original)}
>
{enableSelection && (
<TableCell className="w-[50px]">
<Checkbox
aria-label="Select row"
checked={row.getIsSelected()}
onCheckedChange={(checked) => row.toggleSelected(!!checked)}
/>
</TableCell>
)}
{row.getVisibleCells().map((cell, index) => (
<TableCell
className={cn(
"px-2 h-4 text-t3",
index === 0 && "pl-4 text-t2 font-medium",
)}
key={cell.id}
style={
flexibleTableColumns
? {
maxWidth: `${cell.column.getSize()}px`,
width: `${cell.column.getSize()}px`,
}
: { width: `${cell.column.getSize()}px` }
}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
);
})}
</ShadcnTableBody>
);
}

View File

@@ -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 (
<div className="overflow-hidden rounded-lg border bg-interactive-secondary shadow-sm">
<Table className="table-fixed p-0">{children}</Table>
<div
className={cn(
"rounded-lg border bg-interactive-secondary shadow-sm relative z-50",
className,
)}
>
{sheetType && (
<div className="bg-white/60 dark:bg-black/60 absolute pointer-events-none rounded-lg -inset-[1px] z-70"></div>
)}
<Table
className="p-0 w-full rounded-lg overflow-hidden"
flexibleTableColumns={flexibleTableColumns}
>
{children}
</Table>
</div>
);
}

View File

@@ -11,6 +11,8 @@ export interface TableProps<T> {
rowClassName?: string;
emptyStateChildren?: ReactNode;
emptyStateText?: string;
flexibleTableColumns?: boolean;
selectedItemId?: string | null;
}
//biome-ignore lint/suspicious/noExplicitAny: type could be any here

View File

@@ -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 (
<div
data-slot="table-container"
className={cn("relative w-full overflow-auto rounded-sm p-3 ", className)}
className={cn("relative max-w-full rounded-sm p-3 ", className)}
>
<table
data-slot="table"
className="w-full caption-bottom text-sm table-fixed"
// className="w-full caption-bottom text-sm table-fixed"
className={cn(
"w-full caption-bottom text-sm",
flexibleTableColumns ? "" : "table-fixed",
)}
{...props}
/>
</div>
@@ -56,7 +64,7 @@ function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
<tr
data-slot="table-row"
className={cn(
" data-[state=selected]:bg-zinc-100 dark:hover:bg-zinc-800/50 dark:data-[state=selected]:bg-zinc-800",
" data-[state=selected]:bg-zinc-100 dark:hover:bg-zinc-800/50 dark:data-[state=selected]:bg-zinc-800 ",
className,
)}
{...props}

View File

@@ -12,7 +12,7 @@ import { navigateTo } from "@/utils/genUtils";
interface BreadcrumbItemType {
name: string;
href: string;
href?: string;
}
export default function V2Breadcrumb({
@@ -34,10 +34,10 @@ export default function V2Breadcrumb({
<React.Fragment key={index}>
<BreadcrumbItem
key={item.name}
onClick={() => navigateTo(item.href, navigate, env)}
onClick={() => item.href && navigateTo(item.href, navigate, env)}
className="cursor-pointer"
>
{item.name}
<span className="truncate max-w-36">{item.name}</span>
</BreadcrumbItem>
{index < items.length - 1 && <BreadcrumbSeparator />}
</React.Fragment>

View File

@@ -38,7 +38,7 @@ function DialogOverlay({
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50 dark:bg-black/80",
className,
)}
{...props}

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 242 KiB

After

Width:  |  Height:  |  Size: 213 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 242 KiB

View File

@@ -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({
<>
<div className="p-4">
{title && (
<label htmlFor={id} className="flex items-center gap-2 mb-2 w-fit">
{withTogle && (
<div className="flex items-center gap-2">
<Checkbox
id={id}
checked={checked}
onCheckedChange={setChecked}
/>
</div>
)}
{title && (
<div className={cn("flex items-center gap-2")}>
<h3 className={cn("text-sub select-none")}>{title}</h3>
</div>
<label
htmlFor={id}
className="flex items-center gap-2 mb-2 w-full justify-between h-6"
>
<div>
{withTogle && (
<div className="flex items-center gap-2">
<Checkbox
id={id}
checked={checked}
onCheckedChange={setChecked}
/>
</div>
)}
{title && (
<div className={cn("flex items-center gap-2")}>
<h3 className={cn("text-sub select-none")}>{title}</h3>
</div>
)}
</div>
{actions && (
<div className="flex items-center gap-2">{actions}</div>
)}
</label>
)}

View File

@@ -15,12 +15,15 @@ interface CustomerBalanceSheetState {
featureId: string | null;
// Original entitlements that were aggregated for this feature
originalEntitlements: FullCusEntWithFullCusProduct[];
// Selected customer entitlement ID (for multi-balance selection)
selectedCusEntId: string | null;
// Actions
setSheet: (params: {
type: CustomerBalanceSheetType;
featureId?: string | null;
originalEntitlements?: FullCusEntWithFullCusProduct[];
selectedCusEntId?: string | null;
}) => void;
closeSheet: () => void;
reset: () => void;
@@ -32,6 +35,7 @@ const initialState = {
previousType: null as CustomerBalanceSheetType,
featureId: null as string | null,
originalEntitlements: [] as FullCusEntWithFullCusProduct[],
selectedCusEntId: null as string | null,
};
export const useCustomerBalanceSheetStore = create<CustomerBalanceSheetState>(
@@ -39,12 +43,18 @@ export const useCustomerBalanceSheetStore = create<CustomerBalanceSheetState>(
...initialState,
// Set the sheet type and optional featureId/entitlements
setSheet: ({ type, featureId = null, originalEntitlements = [] }) => {
setSheet: ({
type,
featureId = null,
originalEntitlements = [],
selectedCusEntId = null,
}) => {
set((state) => ({
previousType: state.type,
type,
featureId,
originalEntitlements,
selectedCusEntId,
}));
},
@@ -55,6 +65,7 @@ export const useCustomerBalanceSheetStore = create<CustomerBalanceSheetState>(
type: null,
featureId: null,
originalEntitlements: [],
selectedCusEntId: null,
}));
},

View File

@@ -11,6 +11,8 @@ export type SheetType =
| "attach-product"
| "subscription-detail"
| "subscription-update"
| "balance-selection"
| "balance-edit"
| null;
// Store state interface
@@ -109,8 +111,6 @@ export const useSheetCleanup = () => {
const closeSheet = useSheetStore((s) => s.closeSheet);
useEffect(() => {
return () => {
closeSheet();
};
closeSheet();
}, [closeSheet]);
};

View File

@@ -8,9 +8,9 @@ export const useAdmin = () => {
useEffect(() => {
if (
(data?.user?.role === "admin" ||
notNullish(data?.session.impersonatedBy)) &&
data?.user?.id !== "user_2tMgAiPsQzX8JTHjZZh9m0VdvUv"
data?.user?.role === "admin" ||
notNullish(data?.session.impersonatedBy)
// data?.user?.id !== "user_2tMgAiPsQzX8JTHjZZh9m0VdvUv"
) {
setIsAdmin(true);
} else {

View File

@@ -1,6 +1,7 @@
"use client";
import { useProductsQuery } from "@/hooks/queries/useProductsQuery";
import { useSheetCleanup } from "@/hooks/stores/useSheetStore";
import { CustomerListTable } from "../customers2/components/table/customer-list/CustomerListTable";
import LoadingScreen from "../general/LoadingScreen";
import { CustomersContext } from "./CustomersContext";
@@ -12,6 +13,7 @@ function CustomersPage() {
const { customers } = useCusSearchQuery();
const { isLoading: productsLoading } = useProductsQuery();
useSheetCleanup();
useSavedViewsQuery();
useFullCusSearchQuery();

View File

@@ -9,6 +9,7 @@ import {
sumValues,
} from "@autumn/shared";
import { useCustomerBalanceSheetStore } from "@/hooks/stores/useCustomerBalanceSheetStore";
import { useSheetStore } from "@/hooks/stores/useSheetStore";
import { cn } from "@/lib/utils";
import { formatUnixToDateTimeString } from "@/utils/formatUtils/formatDateUtils";
import { CustomerFeatureUsageBar } from "../table/customer-feature-usage/CustomerFeatureUsageBar";
@@ -28,7 +29,8 @@ export const MeteredFeatureBalanceCard = ({
aggregatedMap: Map<string, FullCusEntWithFullCusProduct[]>;
allEnts: FullCusEntWithFullCusProduct[];
}) => {
const setSheet = useCustomerBalanceSheetStore((s) => s.setSheet);
const setBalanceSheet = useCustomerBalanceSheetStore((s) => s.setSheet);
const setSheet = useSheetStore((s) => s.setSheet);
const originalEnts = aggregatedMap.get(featureId);
const isAggregated = originalEnts && originalEnts.length > 1;
const balanceCount = originalEnts?.length || 1;
@@ -78,11 +80,22 @@ export const MeteredFeatureBalanceCard = ({
onClick={(e) => {
e.stopPropagation();
const ents = aggregatedMap.get(featureId) || [ent];
setSheet({
const hasMultipleBalances = ents.length > 1;
// Set balance data in balance store
setBalanceSheet({
type: "edit-balance",
featureId,
originalEntitlements: ents,
selectedCusEntId: hasMultipleBalances ? null : ents[0].id,
});
// Open the appropriate inline sheet
if (hasMultipleBalances) {
setSheet({ type: "balance-selection" });
} else {
setSheet({ type: "balance-edit" });
}
}}
>
<div className="flex justify-between w-full items-center h-4">

View File

@@ -0,0 +1,314 @@
import {
type FullCusProduct,
type FullCustomerEntitlement,
type FullCustomerPrice,
getCusEntBalance,
} from "@autumn/shared";
import { ArrowLeft } from "@phosphor-icons/react";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { AdminHover } from "@/components/general/AdminHover";
import { DateInputUnix } from "@/components/general/DateInputUnix";
import { Button } from "@/components/v2/buttons/Button";
import { CopyButton } from "@/components/v2/buttons/CopyButton";
import { LabelInput } from "@/components/v2/inputs/LabelInput";
import {
SheetFooter,
SheetHeader,
SheetSection,
} from "@/components/v2/sheets/InlineSheet";
import { useCustomerBalanceSheetStore } from "@/hooks/stores/useCustomerBalanceSheetStore";
import { useSheetStore } from "@/hooks/stores/useSheetStore";
import { CusService } from "@/services/customers/CusService";
import { useAxiosInstance } from "@/services/useAxiosInstance";
import { getBackendErr, notNullish } from "@/utils/genUtils";
import { getCusEntHoverTexts } from "@/views/admin/adminUtils";
import { useCusQuery } from "@/views/customers/customer/hooks/useCusQuery";
import { InfoBox } from "@/views/onboarding2/integrate/components/InfoBox";
import { useCustomerContext } from "../../customer/CustomerContext";
export function BalanceEditSheet() {
const { customer, refetch } = useCusQuery();
const { entityId } = useCustomerContext();
const {
featureId,
originalEntitlements,
selectedCusEntId,
closeSheet: closeBalanceSheet,
} = useCustomerBalanceSheetStore();
const closeSheet = useSheetStore((s) => s.closeSheet);
const setSheet = useSheetStore((s) => s.setSheet);
const setBalanceSheet = useCustomerBalanceSheetStore((s) => s.setSheet);
const axiosInstance = useAxiosInstance();
const [updateLoading, setUpdateLoading] = useState(false);
const hasMultipleBalances = originalEntitlements.length > 1;
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;
}, [originalEntitlements, entityId]);
const [updateFields, setUpdateFields] = useState(initialFields);
// Reset fields when feature changes
useEffect(() => {
setUpdateFields(initialFields);
}, [initialFields]);
const getCusProduct = (cusEnt: FullCustomerEntitlement) => {
const cusProduct = customer?.customer_products.find(
(cp: FullCusProduct) => cp.id === cusEnt.customer_product_id,
);
return cusProduct;
};
const handleClose = () => {
closeBalanceSheet();
closeSheet();
};
const handleBackToSelection = () => {
setBalanceSheet({
type: "edit-balance",
featureId,
originalEntitlements,
selectedCusEntId: null,
});
setSheet({ type: "balance-selection" });
};
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(true);
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();
handleClose();
} catch (error) {
toast.error(getBackendErr(error, "Failed to update entitlement"));
}
setUpdateLoading(false);
};
if (!featureId || !originalEntitlements.length) {
return (
<div className="flex flex-col h-full">
<SheetHeader
title="Edit Balance"
description="Loading balance information..."
/>
</div>
);
}
const firstEnt = originalEntitlements[0];
const feature = firstEnt.entitlement.feature;
// Get the selected entitlement
const selectedCusEnt = hasMultipleBalances
? originalEntitlements.find((ent) => ent.id === selectedCusEntId)
: originalEntitlements[0];
if (!selectedCusEnt) {
return (
<div className="flex flex-col h-full">
<SheetHeader title="Edit Balance" description="No balance selected" />
</div>
);
}
const fields = updateFields.get(selectedCusEnt.id);
if (!fields) return null;
const cusProduct = getCusProduct(selectedCusEnt);
const cusPrice = cusProduct?.customer_prices.find(
(cp: FullCustomerPrice) =>
cp.price.entitlement_id === selectedCusEnt.entitlement.id,
);
return (
<div className="flex flex-col h-full">
<SheetHeader
title={feature.name}
description={
<CopyButton text={feature.id} size="sm" innerClassName="font-mono">
{feature.id}
</CopyButton>
}
>
{hasMultipleBalances && (
<Button
variant="skeleton"
size="sm"
onClick={handleBackToSelection}
className="mt-2 w-fit"
>
<ArrowLeft size={16} />
Back to Selection
</Button>
)}
</SheetHeader>
<div className="flex-1 overflow-y-auto">
<SheetSection title="Plan Details" withSeparator>
<div className="flex flex-col gap-2 bg-secondary p-3 rounded-lg border">
<div className="flex gap-2">
<AdminHover
texts={getCusEntHoverTexts({
cusEnt: selectedCusEnt,
entities: customer?.entities,
})}
asChild
>
<span className="text-t3 text-sm font-medium">Plan ID:</span>
</AdminHover>
<span className="text-t1 text-sm font-mono truncate">
{cusProduct?.product_id || "N/A"}
</span>
</div>
{cusProduct?.entity_id && (
<div className="flex gap-2">
<span className="text-t3 text-sm font-medium">Entity ID:</span>
<span className="text-t1 text-sm font-mono truncate">
{cusProduct.entity_id}
</span>
</div>
)}
<div className="flex gap-2">
<span className="text-t3 text-sm font-medium">
Reset Interval:
</span>
<span className="text-t1 text-sm">
{selectedCusEnt.entitlement.interval === "lifetime"
? "never"
: selectedCusEnt.entitlement.interval}
</span>
</div>
</div>
</SheetSection>
<SheetSection title="Update Balance" withSeparator={false}>
<div className="flex flex-col gap-3">
<div className="flex gap-3">
<LabelInput
label="Balance"
placeholder="Enter balance"
type="number"
className="flex-1"
value={notNullish(fields.balance) ? String(fields.balance) : ""}
onChange={(e) => {
const newFields = new Map(updateFields);
const current = newFields.get(selectedCusEnt.id) || {
balance: null,
next_reset_at: null,
};
newFields.set(selectedCusEnt.id, {
...current,
balance: e.target.value ? parseFloat(e.target.value) : null,
});
setUpdateFields(newFields);
}}
/>
<div className="flex-1">
<div className="text-form-label block mb-1">Next Reset</div>
<DateInputUnix
disabled={!!cusPrice}
unixDate={fields.next_reset_at}
setUnixDate={(unixDate) => {
const newFields = new Map(updateFields);
const current = newFields.get(selectedCusEnt.id) || {
balance: null,
next_reset_at: null,
};
newFields.set(selectedCusEnt.id, {
...current,
next_reset_at: unixDate,
});
setUpdateFields(newFields);
}}
/>
</div>
</div>
{cusPrice && (
<InfoBox classNames={{ infoBox: "text-sm p-2" }}>
Reset cycle cannot be changed for paid features, as it follows
the billing cycle.
</InfoBox>
)}
</div>
</SheetSection>
</div>
<SheetFooter>
<Button variant="secondary" onClick={handleClose}>
Cancel
</Button>
<Button
variant="primary"
isLoading={updateLoading}
onClick={() => handleUpdateCusEntitlement(selectedCusEnt)}
>
Update Balance
</Button>
</SheetFooter>
</div>
);
}

View File

@@ -0,0 +1,120 @@
import {
type FullCusProduct,
type FullCustomerEntitlement,
getCusEntBalance,
} from "@autumn/shared";
import { CopyButton } from "@/components/v2/buttons/CopyButton";
import { SheetHeader, SheetSection } from "@/components/v2/sheets/InlineSheet";
import { useCustomerBalanceSheetStore } from "@/hooks/stores/useCustomerBalanceSheetStore";
import { useSheetStore } from "@/hooks/stores/useSheetStore";
import { notNullish } from "@/utils/genUtils";
import { useCusQuery } from "@/views/customers/customer/hooks/useCusQuery";
import { useCustomerContext } from "../../customer/CustomerContext";
export function BalanceSelectionSheet() {
const { customer } = useCusQuery();
const { entityId } = useCustomerContext();
const {
featureId,
originalEntitlements,
setSheet: setBalanceSheet,
} = useCustomerBalanceSheetStore();
const setSheet = useSheetStore((s) => s.setSheet);
if (!featureId || !originalEntitlements.length) {
return (
<div className="flex flex-col h-full">
<SheetHeader
title="Select Balance"
description="Loading balance information..."
/>
</div>
);
}
const firstEnt = originalEntitlements[0];
const feature = firstEnt.entitlement.feature;
const getCusProduct = (cusEnt: FullCustomerEntitlement) => {
const cusProduct = customer?.customer_products.find(
(cp: FullCusProduct) => cp.id === cusEnt.customer_product_id,
);
return cusProduct;
};
const handleSelectBalance = (cusEntId: string) => {
setBalanceSheet({
type: "edit-balance",
featureId,
originalEntitlements,
selectedCusEntId: cusEntId,
});
setSheet({ type: "balance-edit" });
};
return (
<div className="flex flex-col h-full">
<SheetHeader
title="Select Balance to Update"
description={
<CopyButton text={feature.id} size="sm" innerClassName="font-mono">
{feature.name}
</CopyButton>
}
/>
<div className="flex-1 overflow-y-auto">
<SheetSection withSeparator={false}>
<div className="flex flex-col gap-3">
{originalEntitlements.map((cusEnt: FullCustomerEntitlement) => {
const cusProduct = getCusProduct(cusEnt);
const balance = getCusEntBalance({
cusEnt,
entityId,
}).balance;
return (
<button
key={cusEnt.id}
type="button"
onClick={() => handleSelectBalance(cusEnt.id)}
className="flex flex-col gap-2 bg-secondary p-3 rounded-lg border hover:border-border-hover hover:bg-muted transition-colors text-left"
>
{cusProduct?.name && (
<div className="text-sm font-medium text-t1">
{cusProduct.name}
</div>
)}
<div className="flex flex-col gap-1.5">
<div className="flex gap-2 items-center">
<span className="text-t3 text-sm">Plan ID:</span>
<span className="text-t1 text-sm font-mono truncate">
{cusProduct?.product_id || "N/A"}
</span>
</div>
{cusProduct?.entity_id && (
<div className="flex gap-2 items-center">
<span className="text-t3 text-sm">Entity ID:</span>
<span className="text-t1 text-sm font-mono truncate">
{cusProduct.entity_id}
</span>
</div>
)}
<div className="flex gap-2 items-center">
<span className="text-t3 text-sm">Current Balance:</span>
<span className="text-t1 text-sm font-medium">
{notNullish(balance)
? new Intl.NumberFormat().format(balance)
: "N/A"}
</span>
</div>
</div>
</button>
);
})}
</div>
</SheetSection>
</div>
</div>
);
}

View File

@@ -1,4 +1,5 @@
import {
CusProductStatus,
type Entity,
type FeatureOptions,
getProductItemDisplay,
@@ -8,10 +9,13 @@ import {
Calendar,
CheckCircle,
CreditCard,
CubeIcon,
GitBranchIcon,
Hash,
HashIcon,
Info,
Package,
PencilSimple,
PencilSimpleIcon,
Tag,
XCircle,
} from "@phosphor-icons/react";
@@ -20,12 +24,12 @@ import { useEffect } from "react";
import { useNavigate } from "react-router";
// import { Badge } from "@/components/v2/Badge";
import { Button } from "@/components/v2/buttons/Button";
import { IconButton } from "@/components/v2/buttons/IconButton";
import { SheetHeader, SheetSection } from "@/components/v2/sheets/InlineSheet";
import { SheetFooter } from "@/components/v2/sheets/SharedSheetComponents";
import { useOrg } from "@/hooks/common/useOrg";
import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery";
import {
useHasChanges,
usePrepaidItems,
useProductStore,
} from "@/hooks/stores/useProductStore";
@@ -47,14 +51,18 @@ export function SubscriptionDetailSheet() {
const resetProductStore = useProductStore((s) => s.reset);
const sheetType = useSheetStore((s) => s.type);
// Get edited product from store
const hasChanges = useHasChanges();
const storeProduct = useProductStore((s) => s.product);
// Check if there are changes in the product store
const shouldShowEditedProduct = hasChanges && !!storeProduct;
const showUpdateProduct = storeProduct?.id;
// Get customer product and productV2 by itemId
const { cusProduct, productV2 } = useSubscriptionById({ itemId });
const isExpired = cusProduct?.status === CusProductStatus.Expired;
console.log("cusProduct", cusProduct);
console.log("productV2", productV2);
useEffect(() => {
if (
@@ -120,42 +128,67 @@ export function SubscriptionDetailSheet() {
return (
<div className="flex flex-col h-full">
<SheetHeader
title="Subscription Details"
description={`Details for ${cusProduct.product.name}`}
title={`${cusProduct.product.name ?? "Subscription Details"}`}
description={`Subscription details for ${cusProduct.product.name}`}
/>
<div className="flex-1 overflow-y-auto">
{/* Product Information */}
<SheetSection title="Product" withSeparator={false}>
<div className="space-y-3">
<InfoRow
icon={<Package size={16} weight="duotone" />}
label="Product Name"
value={cusProduct.product.name}
/>
<InfoRow
icon={<Tag size={16} weight="duotone" />}
label="Product ID"
value={cusProduct.product_id}
mono
/>
<InfoRow
icon={<Info size={16} weight="duotone" />}
label="Version"
value={cusProduct.product.version}
/>
{cusProduct.quantity && cusProduct.quantity > 1 && (
<SheetSection
// title="Plan"
withSeparator={false}
actions={
!isExpired && (
<IconButton
variant="secondary"
onClick={handleEditPlan}
icon={<PencilSimpleIcon size={16} weight="duotone" />}
>
Edit Plan
</IconButton>
)
}
>
<div className="flex gap-2 justify-between">
<div className="space-y-3">
<InfoRow
icon={<Info size={16} weight="duotone" />}
label="Quantity"
value={cusProduct.quantity.toString()}
icon={<CubeIcon size={16} weight="duotone" />}
label="Plan"
value={cusProduct.product.name}
/>
<InfoRow
icon={<HashIcon size={16} />}
label="ID"
value={cusProduct.product_id}
mono
/>
<InfoRow
icon={<GitBranchIcon size={16} />}
label="Version"
value={cusProduct.product.version}
/>
{cusProduct.quantity && cusProduct.quantity > 1 && (
<InfoRow
icon={<Info size={16} weight="duotone" />}
label="Quantity"
value={cusProduct.quantity.toString()}
/>
)}
</div>
{!isExpired && (
<IconButton
// variant="secondary"
onClick={handleEditPlan}
icon={<PencilSimpleIcon size={16} weight="duotone" />}
>
Edit Plan
</IconButton>
)}
</div>
</SheetSection>
{/* Status & Dates */}
<SheetSection title="Status & Timeline">
<SheetSection>
<div className="space-y-3">
<InfoRow
icon={<Info size={16} weight="duotone" />}
@@ -257,72 +290,70 @@ export function SubscriptionDetailSheet() {
)}
{/* Edited Plan Items - Show pending changes */}
{shouldShowEditedProduct &&
storeProduct &&
storeProduct.items.length > 0 && (
<SheetSection title="Edited Plan Items (Pending)">
<div className="space-y-2">
<div className="flex items-center gap-2 mb-2 p-2 rounded-lg bg-blue-500/10 border border-blue-500/20">
<Info size={16} weight="duotone" className="text-blue-600" />
<span className="text-xs text-t2">
These changes are pending and will be applied when you save.
</span>
</div>
{storeProduct.items.map((item, index) => {
const display = getProductItemDisplay({
item,
features,
currency: org?.default_currency || "USD",
fullDisplay: true,
amountFormatOptions: { currencyDisplay: "narrowSymbol" },
});
{showUpdateProduct && storeProduct && storeProduct.items.length > 0 && (
<SheetSection title="Edited Plan Items (Pending)">
<div className="space-y-2">
<div className="flex items-center gap-2 mb-2 p-2 rounded-lg bg-blue-500/10 border border-blue-500/20">
<Info size={16} weight="duotone" className="text-blue-600" />
<span className="text-xs text-t2">
These changes are pending and will be applied when you save.
</span>
</div>
{storeProduct.items.map((item, index) => {
const display = getProductItemDisplay({
item,
features,
currency: org?.default_currency || "USD",
fullDisplay: true,
amountFormatOptions: { currencyDisplay: "narrowSymbol" },
});
const isFeatureItem =
item.type === ProductItemType.Feature ||
item.type === ProductItemType.FeaturePrice;
const isFeatureItem =
item.type === ProductItemType.Feature ||
item.type === ProductItemType.FeaturePrice;
// Find prepaid quantity from cusProduct.options
const prepaidOption = cusProduct?.options?.find(
(opt: FeatureOptions) => opt.feature_id === item.feature_id,
);
const prepaidQuantity = prepaidOption
? prepaidOption.quantity / (item.billing_units || 1)
: null;
// Find prepaid quantity from cusProduct.options
const prepaidOption = cusProduct?.options?.find(
(opt: FeatureOptions) => opt.feature_id === item.feature_id,
);
const prepaidQuantity = prepaidOption
? prepaidOption.quantity / (item.billing_units || 1)
: null;
return (
<div
key={item.feature_id || item.price_id || index}
className="flex items-start gap-2 p-2 rounded-lg bg-blue-500/5 border border-blue-500/20"
>
<CheckCircle
size={16}
weight="fill"
className={cn(
"text-blue-600 mt-0.5 shrink-0",
!isFeatureItem && "opacity-0",
)}
/>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-t1 flex items-center gap-2 flex-wrap">
<span>{display.primary_text}</span>
{prepaidQuantity !== null && (
<span className="text-t3 bg-background/50 rounded-sm px-2 py-0.5 text-xs font-normal">
Qty: {prepaidQuantity}
</span>
)}
</div>
{display.secondary_text && (
<div className="text-xs text-t3 mt-0.5">
{display.secondary_text}
</div>
return (
<div
key={item.feature_id || item.price_id || index}
className="flex items-start gap-2 p-2 rounded-lg bg-blue-500/5 border border-blue-500/20"
>
<CheckCircle
size={16}
weight="fill"
className={cn(
"text-blue-600 mt-0.5 shrink-0",
!isFeatureItem && "opacity-0",
)}
/>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-t1 flex items-center gap-2 flex-wrap">
<span>{display.primary_text}</span>
{prepaidQuantity !== null && (
<span className="text-t3 bg-background/50 rounded-sm px-2 py-0.5 text-xs font-normal">
Qty: {prepaidQuantity}
</span>
)}
</div>
{display.secondary_text && (
<div className="text-xs text-t3 mt-0.5">
{display.secondary_text}
</div>
)}
</div>
);
})}
</div>
</SheetSection>
)}
</div>
);
})}
</div>
</SheetSection>
)}
{/* Pricing Summary */}
<SheetSection title="Pricing">
@@ -389,34 +420,21 @@ export function SubscriptionDetailSheet() {
)}
</div>
<SheetFooter>
{shouldShowEditedProduct ? (
<>
<Button variant="secondary" onClick={handleEditPlan}>
<PencilSimple size={16} weight="duotone" />
Edit Plan
{!isExpired && (
<SheetFooter>
<Button variant="secondary" onClick={handleEditPlan}>
<PencilSimple size={16} weight="duotone" />
Edit Plan
</Button>
{hasPrepaidItems && !showUpdateProduct && (
<Button variant="secondary" onClick={handleUpdateQuantities}>
<Hash size={16} weight="duotone" />
Update Quantities
</Button>
<UpdatePlanButton cusProduct={cusProduct} />
</>
) : (
<>
<Button
variant="primary"
onClick={handleEditPlan}
className={hasPrepaidItems ? "" : "col-span-2"}
>
<PencilSimple size={16} weight="duotone" />
Edit Plan
</Button>
{hasPrepaidItems && (
<Button variant="secondary" onClick={handleUpdateQuantities}>
<Hash size={16} weight="duotone" />
Update Quantities
</Button>
)}
</>
)}
</SheetFooter>
)}
{showUpdateProduct && <UpdatePlanButton cusProduct={cusProduct} />}
</SheetFooter>
)}
</div>
);
}
@@ -431,10 +449,12 @@ interface InfoRowProps {
function InfoRow({ icon, label, value, className, mono }: InfoRowProps) {
return (
<div className="flex items-start gap-3">
<div className="text-subtle mt-0.5">{icon}</div>
<div className="flex-1 min-w-0">
<div className="text-t3 text-sm font-medium mb-0.5">{label}</div>
<div className="flex items-center gap-2">
<div className="text-t4/60">{icon}</div>
<div className="flex min-w-0 items-center">
<div className="text-t3 text-sm font-medium w-16 whitespace-nowrap">
{label}
</div>
<div
className={cn(
"text-t1 text-sm wrap-break-word",

View File

@@ -24,12 +24,10 @@ const FormContent = ({
productV2,
cusProduct,
form,
initialPrepaidOptions,
}: {
productV2: ProductV2;
cusProduct: FullCusProduct;
form: UseAttachProductForm;
initialPrepaidOptions: Record<string, number>;
}) => {
const { customer } = useCusQuery();
const customerId = customer?.id;
@@ -39,6 +37,7 @@ const FormContent = ({
const prepaidItems = usePrepaidItems({ product });
const prepaidOptions = form.state.values.prepaidOptions;
const initialPrepaidOptions = form.state.values.initialPrepaidOptions;
const previewQuery = useAttachPreview({
customerId,
@@ -61,7 +60,8 @@ const FormContent = ({
// Check if there are any changes from initial values
const hasQuantityChanges = prepaidItems.some((item) => {
const currentQuantity = prepaidOptions?.[item.feature_id as string];
const initialQuantity = initialPrepaidOptions[item.feature_id as string];
const initialQuantity =
initialPrepaidOptions?.[item.feature_id as string];
return currentQuantity !== initialQuantity;
});
@@ -76,6 +76,7 @@ const FormContent = ({
previewData={previewQuery.data}
isLoading={previewQuery.isLoading}
product={product}
form={form}
/>
<UpdateProductActions
product={product}
@@ -102,10 +103,6 @@ function SheetContent({
const setSheet = useSheetStore((s) => s.setSheet);
const storeProduct = useProductStore((s) => s.product);
const form = useAttachProductForm({
initialProductId: cusProduct?.product.id ?? undefined,
});
// Memoize initial prepaid options from cusProduct
const initialPrepaidOptions = useMemo(
() =>
@@ -119,6 +116,11 @@ function SheetContent({
[cusProduct.options],
);
const form = useAttachProductForm({
initialProductId: cusProduct?.product.id ?? undefined,
initialPrepaidOptions,
});
const product = storeProduct?.id ? storeProduct : (productV2 ?? undefined);
const prepaidItems = usePrepaidItems({ product });
@@ -183,7 +185,6 @@ function SheetContent({
productV2={productV2}
cusProduct={cusProduct}
form={form}
initialPrepaidOptions={initialPrepaidOptions}
/>
</>
)}
@@ -200,7 +201,6 @@ export function SubscriptionUpdateSheet() {
const { cusProduct, productV2 } = useSubscriptionById({ itemId });
const entityId = cusProduct?.entity_id ?? undefined;
const sheetType = useSheetStore((s) => s.type);
const resetProductStore = useProductStore((s) => s.reset);

View File

@@ -4,6 +4,7 @@ import type {
} from "@autumn/shared";
import { Table } from "@/components/general/table";
import { useCustomerBalanceSheetStore } from "@/hooks/stores/useCustomerBalanceSheetStore";
import { useSheetStore } from "@/hooks/stores/useSheetStore";
import { useCustomerTable } from "@/views/customers2/hooks/useCustomerTable";
import { CustomerBalanceTableColumns } from "./CustomerBalanceTableColumns";
@@ -20,7 +21,15 @@ export function CustomerBalanceTable({
aggregatedMap: Map<string, FullCusEntWithFullCusProduct[]>;
isLoading: boolean;
}) {
const setSheet = useCustomerBalanceSheetStore((s) => s.setSheet);
const setBalanceSheet = useCustomerBalanceSheetStore((s) => s.setSheet);
const setSheet = useSheetStore((s) => s.setSheet);
const sheetType = useSheetStore((s) => s.type);
const balanceOpen =
sheetType === "balance-selection" || sheetType === "balance-edit";
const selectedCusEntId = useCustomerBalanceSheetStore(
(s) => s.selectedCusEntId,
);
const selectedFeatureId = useCustomerBalanceSheetStore((s) => s.featureId);
const columns = CustomerBalanceTableColumns({
filteredCustomerProducts,
@@ -38,11 +47,37 @@ export function CustomerBalanceTable({
const handleRowClick = (ent: FullCusEntWithFullCusProduct) => {
const featureId = ent.entitlement.feature.id;
const ents = aggregatedMap.get(featureId) || [ent];
setSheet({
const hasMultipleBalances = ents.length > 1;
// Set balance data in balance store
setBalanceSheet({
type: "edit-balance",
featureId,
originalEntitlements: ents,
selectedCusEntId: hasMultipleBalances ? null : ents[0].id,
});
// Open the appropriate inline sheet
if (hasMultipleBalances) {
setSheet({ type: "balance-selection" });
} else {
setSheet({ type: "balance-edit" });
}
};
// Determine the selected row ID based on whether it's an aggregated balance or single balance
const getSelectedRowId = () => {
if (!balanceOpen) return undefined;
// For single balance selection, match by customer entitlement ID
if (selectedCusEntId) return selectedCusEntId;
// For aggregated balance selection, find the row that matches the feature ID
if (selectedFeatureId) {
const matchingEnt = allEnts.find(
(ent) => ent.entitlement.feature.id === selectedFeatureId,
);
return matchingEnt?.id;
}
return undefined;
};
return (
@@ -53,6 +88,8 @@ export function CustomerBalanceTable({
enableSorting,
isLoading,
onRowClick: handleRowClick,
flexibleTableColumns: true,
selectedItemId: getSelectedRowId(), //decides the highlighted row on sheetopen
}}
>
<Table.Container>

View File

@@ -24,8 +24,9 @@ export const CustomerBalanceTableColumns = ({
}) => [
{
header: "Feature",
size: 160,
accessorKey: "feature",
enableResizing: true,
minSize: 100,
cell: ({ row }: { row: Row<FullCusEntWithFullCusProduct> }) => {
const ent = row.original;
const featureId = ent.entitlement.feature.id;
@@ -49,7 +50,9 @@ export const CustomerBalanceTableColumns = ({
},
{
header: "Usage",
size: 200,
// enableResizing: true,
// size: 200,
// minSize: 100,
accessorKey: "usage",
cell: ({ row }: { row: Row<FullCusEntWithFullCusProduct> }) => {
const ent = row.original;
@@ -136,29 +139,31 @@ export const CustomerBalanceTableColumns = ({
);
},
},
{
header: "Reset Date",
size: 120,
accessorKey: "reset_date",
cell: ({ row }: { row: Row<FullCusEntWithFullCusProduct> }) => {
const ent = row.original;
// {
// header: "Reset Date",
// size: 120,
// accessorKey: "reset_date",
// cell: ({ row }: { row: Row<FullCusEntWithFullCusProduct> }) => {
// const ent = row.original;
if (!ent.next_reset_at) {
return <span className="text-t3"></span>;
}
// if (!ent.next_reset_at) {
// return <span className="text-t3"></span>;
// }
return (
<div className="flex justify-end w-full">
<span className="text-t3 text-tiny flex justify-center !px-1 bg-muted w-fit rounded-md">
Resets {formatUnixToDateTimeString(ent.next_reset_at)}
</span>
</div>
);
},
},
// return (
// <div className="flex justify-end w-full">
// <span className="text-t3 text-tiny flex justify-center !px-1 bg-muted w-fit rounded-md">
// Resets {formatUnixToDateTimeString(ent.next_reset_at)}
// </span>
// </div>
// );
// },
// },
{
header: "Bar",
size: 220,
// maxSize: 220,
// enableResizing: true,
accessorKey: "bar",
cell: ({ row }: { row: Row<FullCusEntWithFullCusProduct> }) => {
const ent = row.original;
@@ -193,12 +198,17 @@ export const CustomerBalanceTableColumns = ({
return (
<div className="flex gap-3 items-center">
{/* <span className="text-t3 text-tiny flex justify-center !px-1 bg-muted w-fit rounded-md">
<span
className={cn(
"text-t3 text-tiny flex justify-center !px-1 bg-muted rounded-md min-w-30",
ent.next_reset_at ? "opacity-100" : "opacity-0",
)}
>
Resets {formatUnixToDateTimeString(ent.next_reset_at)}
</span> */}
</span>
<div
className={cn(
"w-full max-w-50 flex justify-center pr-2 h-full items-center",
"w-full max-w-50 flex justify-center pr-2 h-full items-center min-w-16",
(allowance ?? 0) > 0 ? "opacity-100" : "opacity-0",
)}
>

View File

@@ -78,7 +78,7 @@ export function CustomerInvoicesTable() {
},
});
const hasInvoices = invoices.length > 0;
// const hasInvoices = invoices.length > 0;
return (
<Table.Provider
@@ -89,6 +89,7 @@ export function CustomerInvoicesTable() {
isLoading,
onRowClick: handleRowClick,
emptyStateText: "Invoices will display when a customer makes a payment",
flexibleTableColumns: true,
// rowClassName: "h-14 py-4 cursor-pointer",
}}
>

View File

@@ -21,6 +21,8 @@ export function CustomerListTable({
}) {
const navigate = useNavigate();
// Close any open sheet on mount in useEffect
const columns = useMemo(() => createCustomerListColumns(), []);
const table = useCustomerTable({

View File

@@ -6,27 +6,28 @@ import {
useSheetStore,
} from "@/hooks/stores/useSheetStore";
import { useEntity } from "@/hooks/stores/useSubscriptionStore";
import { cn } from "@/lib/utils";
export function AttachProductSheetTrigger() {
const { setSheet, closeSheet } = useSheetStore();
const isAttachingProduct = useIsAttachingProduct();
const { entity } = useEntity();
const features = useFeaturesQuery();
const sheetType = useSheetStore((s) => s.type);
const feature = features.features.find((f) => f.id === entity?.feature_id);
const handleClick = () => {
if (isAttachingProduct) {
closeSheet();
} else {
setSheet({ type: "attach-product" });
}
setSheet({ type: "attach-product" });
};
return (
<Button
variant="primary"
size="mini"
className="gap-1 font-medium"
className={cn(
"gap-1 font-medium",
isAttachingProduct && "z-90 opacity-70",
)}
onClick={handleClick}
>
<PlusIcon className="size-3.5" />

View File

@@ -18,7 +18,7 @@ export const CustomerProductsColumns = [
const showQuantity = quantity && quantity > 1;
return (
<div className="font-medium text-t1 flex items-center gap-2">
<div className="font-medium text-t1 flex items-center gap-2 ">
<AdminHover texts={getCusProductHoverTexts(row.original)}>
{row.original.product.name}
</AdminHover>

View File

@@ -34,6 +34,9 @@ export function CustomerProductsTable() {
const [selectedProduct, setSelectedProduct] = useState<FullCusProduct | null>(
null,
);
const sheetType = useSheetStore((s) => s.type);
const selectedItemId = useSheetStore((s) => s.itemId);
const detailsOpen = sheetType === "subscription-detail";
const { setEntityId } = useEntity();
@@ -227,6 +230,8 @@ export function CustomerProductsTable() {
isLoading,
onRowClick: handleRowClick,
emptyStateText,
flexibleTableColumns: true,
selectedItemId: detailsOpen ? selectedItemId : undefined,
}}
>
<Table.Container>
@@ -281,6 +286,8 @@ export function CustomerProductsTable() {
isLoading,
onRowClick: handleRowClick,
emptyStateText: "No entity-level plans found",
flexibleTableColumns: true,
selectedItemId: detailsOpen ? selectedItemId : undefined,
}}
>
<Table.Container>

View File

@@ -8,6 +8,7 @@ import {
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart";
import { useIsSheetOpen } from "@/hooks/stores/useSheetStore";
import {
prepareChartData,
prepareTimeseriesChartData,
@@ -23,6 +24,7 @@ export function CustomerUsageAnalyticsChart({
events?: Event[];
daysToShow?: number;
}) {
const isSheetOpen = useIsSheetOpen();
function formatYAxisTick(value: number): string {
// if (value === 0) return "";
@@ -59,7 +61,7 @@ export function CustomerUsageAnalyticsChart({
return (
<ChartContainer
config={chartConfig}
className="h-full pt-3 pr-2 w-full relative bg-interactive-secondary border"
className="h-full pt-3 pr-2 w-full relative bg-interactive-secondary dark:bg-card border rounded-lg"
>
<BarChart
// accessibilityLayer
@@ -113,6 +115,10 @@ export function CustomerUsageAnalyticsChart({
stackId="a"
barSize={20}
fill={`var(--color-${eventName})`}
isAnimationActive={!isSheetOpen}
// animationDuration={300}
// animationEasing="ease-out"
// animationBegin={1}
// radius={
// index === eventNames.length - 1 ? [4, 4, 0, 0] : [0, 0, 0, 0]
// }

View File

@@ -6,7 +6,6 @@ export const CustomerUsageAnalyticsColumns = [
{
header: "Feature",
accessorKey: "event_name",
size: 100,
cell: ({ row }: { row: Row<Event> }) => {
return (
<div className="text-tiny font-mono truncate text-t2!">
@@ -18,7 +17,6 @@ export const CustomerUsageAnalyticsColumns = [
{
header: "Value",
accessorKey: "value",
size: 60,
cell: ({ row }: { row: Row<Event> }) => {
const event = row.original;
return (
@@ -44,14 +42,13 @@ export const CustomerUsageAnalyticsColumns = [
{
header: "Timestamp",
accessorKey: "timestamp",
size: 100,
cell: ({ row }: { row: Row<Event> }) => {
// type is Date but actually comes as a string
const dateObj = new Date(row.original.timestamp as unknown as string);
const dateAsNumber = dateObj.getTime();
return (
<div className="text-tiny text-t3">
<div className="text-tiny text-t3 font-mono min-w-fit">
{/* {formatUnixToDateTimeWithMs(dateAsNumber)} */}
{format(new Date(dateAsNumber), "d MMM HH:mm:ss")}
</div>

View File

@@ -2,7 +2,6 @@ import { ChartBar } from "@phosphor-icons/react";
import { parseAsInteger, useQueryState } from "nuqs";
import { useMemo } from "react";
import { Table } from "@/components/general/table";
import { cn } from "@/lib/utils";
import { useCusEventsQuery } from "@/views/customers/customer/hooks/useCusEventsQuery";
import { useCusQuery } from "@/views/customers/customer/hooks/useCusQuery";
import { useCustomerTable } from "@/views/customers2/hooks/useCustomerTable";
@@ -93,7 +92,8 @@ export function CustomerUsageAnalyticsTable() {
numberOfColumns: CustomerUsageAnalyticsColumns.length,
enableSorting,
isLoading,
rowClassName: "h-8 bg-interactive-secondary",
rowClassName: "h-8 bg-interactive-secondary dark:bg-card",
flexibleTableColumns: true,
}}
>
<Table.Container>
@@ -130,26 +130,15 @@ export function CustomerUsageAnalyticsTable() {
</div>
) : hasEvents ? (
<>
<div className="flex max-w-3/8 w-full min-w-0 flex-col h-[250px]">
<div className="overflow-hidden flex flex-col border h-full bg-card">
<div className="">
<table className="table-fixed p-0 w-full h-full">
<Table.Header />
</table>
</div>
<div
className={cn(
"overflow-auto",
rawEvents?.length < 6 ? "border-b" : "",
)}
>
<table className="table-fixed p-0 w-full">
<Table.Body />
</table>
</div>
<div className="flex max-w-1/2 w-full min-w-0 flex-col h-[250px]">
<div className="overflow-hidden flex flex-col border h-full bg-card rounded-lg">
<Table.Content className="border-none overflow-auto">
<Table.Header />
<Table.Body />
</Table.Content>
</div>
</div>
<div className="flex max-w-5/8 w-full min-w-0 h-[250px]">
<div className="flex max-w-1/2 w-full min-w-0 h-[250px]">
<CustomerUsageAnalyticsChart
timeseriesEvents={timeseriesEvents}
// events={filteredEvents}

View File

@@ -1,7 +1,7 @@
"use client";
import { useEffect, useState } from "react";
import { Link, useParams, useSearchParams } from "react-router";
import { useEffect } from "react";
import { Link, useParams } from "react-router";
import { CustomToaster } from "@/components/general/CustomToaster";
import { useOrg } from "@/hooks/common/useOrg";
import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery";
@@ -12,7 +12,6 @@ import { useCusProductQuery } from "@/views/customers/customer/product/hooks/use
import ErrorScreen from "@/views/general/ErrorScreen";
import LoadingScreen from "@/views/general/LoadingScreen";
import { PlanEditor } from "@/views/products/plan/components/PlanEditor";
import { ProductContext } from "@/views/products/product/ProductContext";
interface OptionValue {
feature_id: string;
@@ -40,8 +39,6 @@ function getProductUrlParams({
export default function CustomerProductView() {
const { customer_id, product_id } = useParams();
const [searchParams] = useSearchParams();
const entityIdParam = searchParams.get("entity_id");
const closeSheet = useSheetStore((s) => 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<OptionValue[]>([]);
const [entityId, setEntityId] = useState<string | null>(entityIdParam);
const [entityFeatureIds, setEntityFeatureIds] = useState<string[]>([]);
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 (
<ErrorScreen>
@@ -132,23 +73,11 @@ export default function CustomerProductView() {
}
return (
<ProductContext.Provider
value={{
// isCusProductView: true,
// product,
// setProduct,
entityId,
setEntityId,
// attachState,
entityFeatureIds,
setEntityFeatureIds,
}}
>
<>
<CustomToaster />
<PlanEditor />
</ProductContext.Provider>
</>
);
}

View File

@@ -1,5 +0,0 @@
import { CustomerBalanceModal } from "./components/CustomerBalanceModal";
export const CustomerBalanceSheets = () => {
return <CustomerBalanceModal />;
};

View File

@@ -53,7 +53,7 @@ export const CustomerBreadcrumbs = () => {
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem className="truncate max-w-48">
<BreadcrumbItem className="truncate max-w-36">
{entityId ? (
<BreadcrumbLink
className="cursor-pointer"
@@ -72,7 +72,7 @@ export const CustomerBreadcrumbs = () => {
{entityId && (
<>
<BreadcrumbSeparator />
<BreadcrumbItem className="truncate max-w-48">
<BreadcrumbItem className="truncate max-w-36">
{entity?.name || entityId}
</BreadcrumbItem>
</>

View File

@@ -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 <SubscriptionDetailSheet />;
case "subscription-update":
return <SubscriptionUpdateSheet />;
case "balance-selection":
return <BalanceSelectionSheet />;
case "balance-edit":
return <BalanceEditSheet />;
default:
return null;
}
};
return createPortal(
return (
<AnimatePresence mode="wait">
{sheetType && (
<motion.div
@@ -33,17 +45,16 @@ export function CustomerSheets() {
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 }}
>
<SheetContainer className="w-full bg-background z-50 border-l h-full relative">
<SheetCloseButton onClose={closeSheet} />
<SheetContainer className="w-full bg-background z-50 border-l dark:border-l-0 h-full relative">
<SheetCloseButton onClose={handleClose} />
{renderSheet()}
</SheetContainer>
</motion.div>
)}
</AnimatePresence>,
document.body,
</AnimatePresence>
);
// return (

View File

@@ -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 <LoadingScreen />;
@@ -104,7 +90,7 @@ export default function CustomerView2() {
</div>
{/* <Separator /> */}
{/* <Separator className="my-2" /> */}
<div className="flex flex-col gap-10 w-full">
<div className="flex flex-col gap-16 w-full">
<CustomerProductsTable />
{/* <Separator /> */}
<CustomerFeatureUsageTable />
@@ -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() {
)}
</motion.div>
<CustomerBalanceSheets />
<CustomerSheets />
</div>
</CustomerContext.Provider>

View File

@@ -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<string | null>(null);
const [selectedCusEntId, setSelectedCusEntId] = useState<string | null>(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 (
<Dialog
open={type === "edit-balance"}
onOpenChange={(open) => {
if (!open) closeSheet();
}}
>
<DialogContent className="max-w-lg bg-card max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Select Balance to Update</DialogTitle>
<CopyButton text={feature.id} size="sm" innerClassName="font-mono">
{feature.name}
</CopyButton>
</DialogHeader>
<div className="flex flex-col gap-3">
{originalEntitlements.map((cusEnt: FullCustomerEntitlement) => {
const cusProduct = getCusProduct(cusEnt);
const fields = updateFields.get(cusEnt.id);
const balance = fields?.balance;
return (
<button
key={cusEnt.id}
type="button"
onClick={() => setSelectedCusEntId(cusEnt.id)}
className="flex flex-col gap-2 bg-secondary p-3 rounded-lg border hover:border-border-hover hover:bg-muted transition-colors text-left"
>
{cusProduct?.name && (
<div className="text-sm font-medium text-t1">
{cusProduct.name}
</div>
)}
<div className="flex flex-col gap-1.5">
<div className="flex gap-2 items-center">
<span className="text-t3 text-sm">Plan ID:</span>
<span className="text-t1 text-sm font-mono truncate">
{cusProduct?.product_id || "N/A"}
</span>
</div>
{cusProduct?.entity_id && (
<div className="flex gap-2 items-center">
<span className="text-t3 text-sm">Entity ID:</span>
<span className="text-t1 text-sm font-mono truncate">
{cusProduct.entity_id}
</span>
</div>
)}
<div className="flex gap-2 items-center">
<span className="text-t3 text-sm">Current Balance:</span>
<span className="text-t1 text-sm font-medium">
{notNullish(balance)
? new Intl.NumberFormat().format(balance)
: "N/A"}
</span>
</div>
</div>
</button>
);
})}
</div>
</DialogContent>
</Dialog>
);
}
// Render update form step
const selectedCusEnt = hasMultipleBalances
? originalEntitlements.find((ent) => ent.id === selectedCusEntId)
: originalEntitlements[0];
if (!selectedCusEnt) return null;
return (
<Dialog
open={type === "edit-balance"}
onOpenChange={(open) => {
if (!open) closeSheet();
}}
>
<DialogContent className="max-w-lg bg-card max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{hasMultipleBalances && (
<button
type="button"
onClick={() => setSelectedCusEntId(null)}
className="text-t3 hover:text-t2 text-sm mr-2"
>
</button>
)}
{feature.name}
</DialogTitle>
<CopyButton text={feature.id} size="sm" innerClassName="font-mono">
{feature.id}
</CopyButton>
</DialogHeader>
<div className="flex flex-col gap-6">
{(() => {
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 (
<div className="flex flex-col gap-4">
{/* {cusProduct?.name && (
<div className="text-sm text-t2">
From product:{" "}
<span className="font-medium">{cusProduct.name}</span>
</div>
)} */}
<div className="flex flex-col gap-2 bg-secondary p-3 rounded-lg border">
<div className="flex gap-2">
<AdminHover
texts={getCusEntHoverTexts({
cusEnt,
entities: customer.entities,
})}
asChild
>
<span className="text-t3 text-sm font-medium">
Plan ID:
</span>
</AdminHover>
<span className="text-t1 text-sm font-mono truncate">
{cusProduct?.product_id || "N/A"}
</span>
</div>
{cusProduct?.entity_id && (
<div className="flex gap-2">
<span className="text-t3 text-sm font-medium">
Entity ID:
</span>
<span className="text-t1 text-sm font-mono truncate">
{cusProduct.entity_id}
</span>
</div>
)}
<div className="flex gap-2">
<span className="text-t3 text-sm font-medium">
Reset Interval:
</span>
<span className="text-t1 text-sm">
{cusEnt.entitlement.interval === "lifetime"
? "never"
: cusEnt.entitlement.interval}
</span>
</div>
</div>
<div className="flex flex-col gap-3">
<div className="flex gap-3">
<LabelInput
label="Balance"
placeholder="Enter balance"
type="number"
className="flex-1"
value={
notNullish(fields.balance) ? String(fields.balance) : ""
}
onChange={(e) => {
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);
}}
/>
<div className="flex-1">
<div className="text-form-label block mb-1">
Next Reset
</div>
<DateInputUnix
disabled={!!cusPrice}
unixDate={fields.next_reset_at}
setUnixDate={(unixDate) => {
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);
}}
/>
</div>
</div>
{cusPrice && (
<InfoBox classNames={{ infoBox: "text-sm p-2" }}>
Reset cycle cannot be changed for paid features, as it
follows the billing cycle.
</InfoBox>
)}
</div>
<div className="flex justify-end">
<Button
variant="primary"
isLoading={updateLoading === cusEnt.id}
// disabled={!hasChanges}
onClick={() => handleUpdateCusEntitlement(cusEnt)}
>
Update Balance
</Button>
</div>
</div>
);
})()}
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -231,7 +231,7 @@ export const ConfigureStripe = () => {
<span className="text-muted-foreground">
{" "}
Visit the Stripe dashboard{" "}
<a
href={dashboardUrl}
target="_blank"
rel="noopener noreferrer"
@@ -289,9 +289,7 @@ export const ConfigureStripe = () => {
placeholder="eg. https://useautumn.com"
className={urlError ? "border-red-500" : ""}
/>
{urlError && (
<p className="text-red-500 text-sm mt-1">{urlError}</p>
)}
{urlError && <p className="text-red-500 text-sm mt-1">{urlError}</p>}
</div>
<div>
@@ -379,4 +377,4 @@ export const ConfigureStripe = () => {
</Dialog>
</div>
);
};
};

View File

@@ -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 (
<AnimatePresence mode="wait">
{sheetType && (
<motion.div
@@ -88,16 +87,15 @@ export const ProductSheets = () => {
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 }}
>
<SheetContainer className="w-full bg-background z-50 border-l h-full relative">
<SheetContainer className="w-full bg-background z-50 border-l dark:border-l-0 h-full relative">
<SheetCloseButton onClose={closeSheet} />
{renderSheet()}
</SheetContainer>
</motion.div>
)}
</AnimatePresence>,
document.body,
</AnimatePresence>
);
};

View File

@@ -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}
/>
<div className="flex flex-col gap-2 p-4 pb-3 border-none shadow-none w-full max-w-5xl mx-auto pt-8 px-12">
<V2Breadcrumb
className="p-0"
items={[
{
name: "Plans",
href: "/products?tab=products",
},
{
name: `${product.name}`,
href: `/products/${product.id}`,
},
]}
/>
{isCusPlanEditor ? (
<CustomerBreadcrumbs />
) : (
<V2Breadcrumb
className="p-0"
items={[
{
name: "Plans",
href: "/products?tab=products",
},
{
name: `${product.name}`,
href: `/products/${product.id}`,
},
]}
/>
)}
<div className="col-span-2 flex">
<div className="flex flex-row items-baseline justify-start gap-2 w-full whitespace-nowrap">
<AdminHover texts={getProductAdminHover() as any}>
@@ -171,7 +177,7 @@ export const EditPlanHeader = () => {
value={currentVersion.toString()}
onValueChange={handleVersionChange}
>
<SelectTrigger className="w-fit min-w-28">
<SelectTrigger className="w-fit min-w-28 !h-6" size="sm">
<SelectValue placeholder="Version" />
</SelectTrigger>
<SelectContent>
@@ -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 (
<V2Breadcrumb
className="p-0"
items={[
{
name: "Customers",
href: "/products?tab=products",
},
{
name: customer.name || customer.email || customer.id,
href: `/customers/${customer.id}`,
},
...(entity_id
? [
{
name: (entity?.name || entity_id) ?? "",
href: `/customers/${customer.id}?entity_id=${entity_id}`,
},
]
: []),
{
name: product?.name || "",
},
]}
/>
);
};

View File

@@ -81,15 +81,7 @@ export const PlanEditor = () => {
<div onClick={(e) => e.stopPropagation()}>
<EditPlanHeader />
</div>
{/* <ManagePlan /> */}
<div
className="flex flex-col w-full h-fit items-center justify-start pt-20 px-10 gap-4"
// onMouseDown={(e) => {
// if (shouldCloseSheetOnMouseDown({ e, item, sheetType })) {
// closeSheet();
// }
// }}
>
<div className="flex flex-col w-full h-fit items-center justify-start pt-20 px-10 gap-4">
{useIsCusPlanEditor() && <CustomerPlanInfoBox />}
<PlanCard />
</div>
@@ -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 })) {

View File

@@ -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() {
<div className="mt-3 space-y-4 billing-type-section">
<div className="flex w-full items-center gap-4">
<PanelButton
isSelected={shouldPreselect && !isFeaturePrice}
isSelected={!isFeaturePrice}
onClick={() => {
setBillingType("included");
}}
@@ -124,7 +120,7 @@ export function BillingType() {
icon={<CoinsIcon size={16} color="currentColor" />}
/>
<div className="flex-1">
<div className="text-body-highlight mb-1">Paid</div>
<div className="text-body-highlight mb-1">Priced</div>
<div className="text-body-secondary leading-tight">
{isConsumable
? `Charge a price for usage of this feature (e.g. $0.05 per ${singleFeatureName}).`

View File

@@ -52,25 +52,21 @@ export function EditPlanFeatureSheet({
<BillingType />
</SheetSection>
{hasChosenBillingType && (
<>
<SheetSection
title={`Included Amount ${isFeaturePrice ? "(optional)" : ""}`}
>
<IncludedUsage />
</SheetSection>
<SheetSection
title={`Grant Amount ${isFeaturePrice ? "(optional)" : ""}`}
>
<IncludedUsage />
</SheetSection>
{isFeaturePrice && (
<SheetSection title="Price">
<PriceTiers />
<UsageReset showBillingLabel={true} />
<PricedFeatureSettings />
</SheetSection>
)}
<AdvancedSettings />
</>
{isFeaturePrice && (
<SheetSection title="Price">
<PriceTiers />
<UsageReset showBillingLabel={true} />
<PricedFeatureSettings />
</SheetSection>
)}
<AdvancedSettings />
</>
)}

View File

@@ -33,7 +33,7 @@ export function NewFeatureDetails({
<div>
<FormLabel>Name</FormLabel>
<Input
placeholder="Chatbot Credits"
placeholder="eg, Usage Credits"
value={feature.name}
onChange={(e) => setSource(e.target.value)}
/>
@@ -42,7 +42,7 @@ export function NewFeatureDetails({
<div>
<FormLabel>ID</FormLabel>
<Input
placeholder="chatbot_credits"
placeholder="usage_credits"
value={feature.id}
onChange={(e) => setTarget(e.target.value)}
/>

View File

@@ -22,7 +22,7 @@ export default function sPlanCard() {
>
{/* Overlay when sheet is open that lets you hover on plan card buttons */}
{sheetType && (
<div className="bg-background/50 absolute pointer-events-none rounded-2xl -inset-[5px]"></div>
<div className="bg-white/50 dark:bg-black/50 absolute pointer-events-none rounded-2xl -inset-[5px]"></div>
)}
<PlanCardHeader />