wip charlie
This commit is contained in:
@@ -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")) {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -43,7 +43,6 @@ export function AttachProductActions({
|
||||
null,
|
||||
);
|
||||
|
||||
console.log("org", org);
|
||||
const ownStripeAccount = org.org?.stripe_connection !== "default";
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]);
|
||||
};
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 |
9
vite/src/components/v2/empty-states/customers2.svg
Normal file
9
vite/src/components/v2/empty-states/customers2.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 242 KiB |
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
},
|
||||
|
||||
|
||||
@@ -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]);
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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">
|
||||
|
||||
314
vite/src/views/customers2/components/sheets/BalanceEditSheet.tsx
Normal file
314
vite/src/views/customers2/components/sheets/BalanceEditSheet.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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",
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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]
|
||||
// }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import { CustomerBalanceModal } from "./components/CustomerBalanceModal";
|
||||
|
||||
export const CustomerBalanceSheets = () => {
|
||||
return <CustomerBalanceModal />;
|
||||
};
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 || "",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 })) {
|
||||
|
||||
@@ -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}).`
|
||||
|
||||
@@ -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 />
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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 />
|
||||
|
||||
|
||||
Reference in New Issue
Block a user