attach flows done

This commit is contained in:
Ayush Rodrigues
2025-11-27 16:02:40 +00:00
parent 5f9285b8fa
commit b21402fc4f
32 changed files with 804 additions and 613 deletions

View File

@@ -71,11 +71,12 @@ export const handleTransferProductV2 = createRoute({
: nullish(cp.internal_entity_id)) && cp.product.id === product_id,
);
const toCusProduct = customer.customer_products.find(
(cp: any) =>
cp.internal_entity_id === toEntity.internal_id &&
cp.product.group === product.group,
);
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;
return cp.internal_entity_id === toEntity.internal_id && productMatch;
});
if (toCusProduct) {
throw new CusProductAlreadyExistsError({

View File

@@ -34,14 +34,14 @@ export const freeTrialsAreSame = ({
const freeTrialsAreDiff = Object.values(diffs).some((d) => d.condition);
if (freeTrialsAreDiff) {
console.log("Free trials different");
console.log(
"Differences:",
Object.values(diffs)
.filter((d) => d.condition)
.map((d) => d.message),
);
}
// if (freeTrialsAreDiff) {
// console.log("Free trials different");
// console.log(
// "Differences:",
// Object.values(diffs)
// .filter((d) => d.condition)
// .map((d) => d.message),
// );
// }
return !freeTrialsAreDiff;
};

View File

@@ -1,15 +1,14 @@
import type { CheckoutResponse } from "@autumn/shared";
import type { ReactNode } from "react";
import {
useHasChanges,
useIsLatestVersion,
} from "@/hooks/stores/useProductStore";
import { useIsLatestVersion } from "@/hooks/stores/useProductStore";
import { formatUnixToDate } from "@/utils/formatUtils/formatDateUtils";
import { InfoBox } from "@/views/onboarding2/integrate/components/InfoBox";
import { useAttachPreview } from "./use-attach-preview";
export const AttachConfirmationInfo = () => {
const { data: previewData } = useAttachPreview();
const hasChanges = useHasChanges();
export const AttachConfirmationInfo = ({
previewData,
}: {
previewData?: CheckoutResponse | null;
}) => {
const isLatestVersion = useIsLatestVersion(previewData?.product);
const renderInfoBoxes = (): ReactNode[] => {
@@ -18,14 +17,6 @@ export const AttachConfirmationInfo = () => {
if (!previewData) {
return boxes;
}
console.log("hasChanges", hasChanges);
if (hasChanges) {
boxes.push(
<InfoBox key="changes" variant="success">
This plan has been customized for this customer
</InfoBox>,
);
}
if (!isLatestVersion) {
boxes.push(
@@ -78,13 +69,14 @@ export const AttachConfirmationInfo = () => {
| "upgrade"
| "downgrade"
| "cancel"
| "new"
| string;
switch (scenario) {
case "upgrade":
boxes.push(
<InfoBox key="product-upgrade" variant="note">
This upgrade will replace the customer's current plan:{" "}
This upgrade will immediately replace the customer's current plan:{" "}
{previewData.current_product.name}
</InfoBox>,
);
@@ -105,11 +97,10 @@ export const AttachConfirmationInfo = () => {
</InfoBox>,
);
break;
default:
case "new":
boxes.push(
<InfoBox key="product-switch" variant="info">
This will replace the customer's current plan:{" "}
{previewData.current_product.name}
<InfoBox key="new-product" variant="info">
This will be enabled alongside existing plans{" "}
</InfoBox>,
);
}

View File

@@ -1,4 +1,9 @@
import type { FullCusProduct, ProductItem } from "@autumn/shared";
import type {
CheckoutResponse,
FullCusEntWithFullCusProduct,
FullCusProduct,
ProductItem,
} from "@autumn/shared";
import { getCusEntBalance, ProductItemFeatureType } from "@autumn/shared";
import { useMemo } from "react";
import { useCusQuery } from "@/views/customers/customer/hooks/useCusQuery";
@@ -6,7 +11,6 @@ import {
deduplicateEntitlements,
flattenCustomerEntitlements,
} from "@/views/customers2/components/table/customer-feature-usage/customerFeatureUsageUtils";
import { useAttachPreview } from "./use-attach-preview";
interface FeatureBalanceChange {
featureId: string;
@@ -20,9 +24,12 @@ interface FeatureBalanceChange {
};
}
export function AttachFeaturePreview({ customerId }: { customerId: string }) {
const { customer } = useCusQuery({ enabled: !!customerId });
const { data: previewData } = useAttachPreview();
export function AttachFeaturePreview({
previewData,
}: {
previewData?: CheckoutResponse | null;
}) {
const { customer } = useCusQuery();
const featureChanges = useMemo((): FeatureBalanceChange[] => {
if (!previewData?.product || !customer) return [];
@@ -91,7 +98,14 @@ export function AttachFeaturePreview({ customerId }: { customerId: string }) {
]),
);
const newFeaturesMap = new Map(
const newFeaturesMap = new Map<
string,
{
includedUsage: number;
feature: ProductItem["feature"];
display: Record<string, unknown>;
}
>(
newFeatureItems
.filter((item: ProductItem) => item.feature_id)
.map((item: ProductItem) => [
@@ -99,7 +113,7 @@ export function AttachFeaturePreview({ customerId }: { customerId: string }) {
{
includedUsage: item.included_usage || 0,
feature: item.feature,
display: item.feature?.display || {},
display: (item.feature?.display as Record<string, unknown>) || {},
},
]),
);
@@ -113,14 +127,18 @@ export function AttachFeaturePreview({ customerId }: { customerId: string }) {
const changes: FeatureBalanceChange[] = [];
for (const featureId of allFeatureIds) {
const currentIncludedUsage = currentFeaturesMap.get(featureId);
const newFeatureData = newFeaturesMap.get(featureId);
const currentBalanceData = currentBalanceMap.get(featureId);
const featureIdStr = String(featureId);
const currentIncludedUsage = currentFeaturesMap.get(featureIdStr);
const newFeatureData = newFeaturesMap.get(featureIdStr);
const currentBalanceData = currentBalanceMap.get(featureIdStr);
// Use actual current balance if available, otherwise use included usage from product
const currentBalance =
currentBalanceData?.balance ?? currentIncludedUsage ?? null;
const newBalance = newFeatureData?.includedUsage ?? null;
const currentBalance: number | null =
currentBalanceData?.balance ??
(typeof currentIncludedUsage === "number"
? currentIncludedUsage
: null);
const newBalance: number | null = newFeatureData?.includedUsage ?? null;
// Determine status
let status: "changed" | "added" | "removed";
@@ -133,24 +151,25 @@ export function AttachFeaturePreview({ customerId }: { customerId: string }) {
}
// Get feature name and display info
let featureName = "";
let display = {};
if (newFeatureData?.feature) {
featureName = newFeatureData.feature.name;
display = newFeatureData.display;
} else if (currentBalanceData) {
featureName = currentBalanceData.feature.entitlement.feature.name;
display = currentBalanceData.feature.entitlement.feature.display || {};
}
const featureName =
newFeatureData?.feature?.name ??
currentBalanceData?.feature.entitlement.feature.name ??
"";
const display =
newFeatureData?.display ??
(currentBalanceData?.feature.entitlement.feature.display as Record<
string,
unknown
>) ??
{};
changes.push({
featureId,
featureName,
featureId: featureIdStr,
featureName: featureName ?? "",
currentBalance,
newBalance,
status,
display,
display: display as { singular?: string; plural?: string },
});
}

View File

@@ -1,4 +1,6 @@
import type { CheckoutResponse, ProductV2 } from "@autumn/shared";
import { ArrowUpRightFromSquare } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { useAttachProductMutation } from "@/components/forms/attach-product/use-attach-product-mutation";
import {
@@ -7,36 +9,54 @@ import {
PopoverTrigger,
} from "@/components/ui/popover";
import { Button } from "@/components/v2/buttons/Button";
import { useOrg } from "@/hooks/common/useOrg";
import { useOrgStripeQuery } from "@/hooks/queries/useOrgStripeQuery";
import { useEntity } from "@/hooks/stores/useSubscriptionStore";
import { useEnv } from "@/utils/envUtils";
import { getStripeInvoiceLink } from "@/utils/linkUtils";
import { useAttachPreview } from "./use-attach-preview";
import type { UseAttachProductForm } from "./use-attach-product-form";
interface AttachProductActionsProps {
form: UseAttachProductForm;
product: ProductV2;
customerId: string;
onSuccess?: () => void;
isPreviewLoading: boolean;
previewData?: CheckoutResponse | null;
isPreviewLoading?: boolean;
}
export function AttachProductActions({
form,
product,
customerId,
onSuccess,
previewData,
isPreviewLoading,
}: AttachProductActionsProps) {
const { stripeAccount } = useOrgStripeQuery();
const env = useEnv();
const { data: previewData, isLoading: isAttachPreviewLoading } =
useAttachPreview();
const org = useOrg();
const { entityId } = useEntity();
const buttonRef = useRef<HTMLButtonElement>(null);
const [buttonWidth, setButtonWidth] = useState<number>(0);
const [activeAction, setActiveAction] = useState<"invoice" | "attach" | null>(
null,
);
console.log("org", org);
const ownStripeAccount = org.org?.stripe_connection !== "default";
useEffect(() => {
if (buttonRef.current) {
setButtonWidth(buttonRef.current.offsetWidth);
}
}, []);
const attachMutation = useAttachProductMutation({
customerId,
onSuccess: () => {
form.reset();
setActiveAction(null);
onSuccess?.();
},
});
@@ -44,52 +64,57 @@ export function AttachProductActions({
const handleAttach = async ({
useInvoice,
enableProductImmediately,
action,
}: {
useInvoice: boolean;
enableProductImmediately?: boolean;
action: "invoice" | "attach";
}) => {
const { productId, prepaidOptions } = form.state.values;
const { prepaidOptions } = form.state.values;
setActiveAction(action);
if (!productId) {
toast.error("Please select a product");
return;
}
if (previewData?.url) {
if (previewData?.url && action === "attach") {
window.open(previewData.url, "_blank");
setActiveAction(null);
return;
}
//does the attach
const result = await attachMutation.mutateAsync({
productId,
prepaidOptions: prepaidOptions || {},
useInvoice,
enableProductImmediately,
entityId: entityId ?? undefined,
});
try {
//does the attach
const result = await attachMutation.mutateAsync({
product,
prepaidOptions: prepaidOptions || {},
useInvoice,
enableProductImmediately,
entityId: entityId ?? undefined,
});
// Handle checkout URLs and invoice links
if (result.data.invoice) {
window.open(
getStripeInvoiceLink({
stripeInvoice: result.data.invoice,
env,
accountId: stripeAccount?.id,
}),
"_blank",
);
console.log("result", result);
// Handle checkout URLs and invoice links
if (result.data.invoice) {
window.open(
getStripeInvoiceLink({
stripeInvoice: result.data.invoice,
env,
accountId: stripeAccount?.id,
}),
"_blank",
);
toast.success("Redirected to Stripe to finalize the invoice");
}
} catch (error) {
setActiveAction(null);
throw error;
}
};
if (isAttachPreviewLoading) {
return null;
}
const isLoading = attachMutation.isPending;
const isInvoiceLoading = isLoading && activeAction === "invoice";
const isAttachLoading = isLoading && activeAction === "attach";
// Don't show buttons if preview is loading
if (isPreviewLoading || !form.state.values.productId) {
if (isPreviewLoading || !product) {
return null;
}
@@ -100,23 +125,30 @@ export function AttachProductActions({
<Popover>
<PopoverTrigger asChild>
<Button
ref={buttonRef}
variant="secondary"
className="w-full"
isLoading={isLoading}
disabled={isLoading}
isLoading={isInvoiceLoading}
disabled={isLoading || !ownStripeAccount}
type="button"
>
Send an Invoice
</Button>
</PopoverTrigger>
<PopoverContent className="w-80 p-0" align="start">
<PopoverContent
className="p-0 z-100 rounded-lg"
align="start"
style={{ width: buttonWidth > 0 ? `${buttonWidth}px` : "auto" }}
>
<div className="flex flex-col">
<button
type="button"
disabled={isLoading}
onClick={() =>
handleAttach({
useInvoice: true,
enableProductImmediately: true,
action: "invoice",
})
}
className="px-4 py-3 text-left text-sm hover:bg-accent"
@@ -124,15 +156,17 @@ export function AttachProductActions({
<div className="font-medium">Enable plan immediately</div>
<div className="text-xs text-muted-foreground">
Enable the plan immediately and redirect to Stripe to finalize
the invoice
the invoice. Customer can pay by the invoice due date.
</div>
</button>
<button
type="button"
disabled={isLoading}
onClick={() =>
handleAttach({
useInvoice: true,
enableProductImmediately: false,
action: "invoice",
})
}
className="px-4 py-3 text-left text-sm hover:bg-accent border-t"
@@ -150,11 +184,12 @@ export function AttachProductActions({
<Button
variant="primary"
className="w-full flex items-center gap-2"
isLoading={isLoading}
isLoading={isAttachLoading}
disabled={isLoading}
onClick={() =>
handleAttach({
useInvoice: false,
action: "attach",
})
}
>

View File

@@ -36,25 +36,32 @@ function FormContent({
form,
onSuccess,
}: FormContentProps) {
const { isLoading: isPreviewLoading } = useAttachPreview();
const customizedProduct = useAttachProductStore((s) => s.customizedProduct);
const storeProduct = useProductStore((s) => s.product);
// Use customizedProduct if available, otherwise find from products by productId
const product =
customizedProduct ??
products.find((p) => p.id === productId && !p.archived);
// Use customized product if it exists and has changes, otherwise find by form productId
const product = storeProduct?.id
? storeProduct
: products.find((p) => p.id === productId && !p.archived);
const prepaidItems = usePrepaidItems({ product });
const prepaidOptions = useAttachProductStore((s) => s.prepaidOptions);
const prepaidOptions = form.state.values.prepaidOptions;
//get is loading from useAttachPreview
const { isLoading } = useAttachPreview();
const { entityId } = useEntity();
// Call preview once here and pass data down to children
const previewQuery = useAttachPreview({
customerId,
product,
entityId: entityId ?? undefined,
prepaidOptions: prepaidOptions ?? undefined,
version: product?.version,
});
// Check if there are prepaid items and if any are not set (undefined/null)
// Note: 0 is a valid quantity value
if (prepaidItems.length > 0) {
const hasUnsetPrepaidQuantity = prepaidItems.some((item) => {
const quantity = prepaidOptions[item.feature_id as string];
const quantity = prepaidOptions?.[item.feature_id as string];
return quantity === undefined || quantity === null;
});
@@ -63,23 +70,25 @@ function FormContent({
}
}
if (!form.state.values.productId) {
if (!form.state.values.productId || !product) {
return null;
}
return (
<>
<AttachProductSummary
productId={productId}
products={products}
customerId={customerId}
previewData={previewQuery.data}
isLoading={previewQuery.isLoading}
product={product}
/>
<AttachProductActions
form={form}
product={product}
customerId={customerId}
onSuccess={onSuccess}
isPreviewLoading={isPreviewLoading}
previewData={previewQuery.data}
isPreviewLoading={previewQuery.isLoading}
/>
</>
);
@@ -93,12 +102,9 @@ export function AttachProductForm({
onSuccess?: () => void;
}) {
const itemId = useSheetStore((s) => s.itemId); // The productId being customized
const form = useAttachProductForm({ initialProductId: itemId || undefined }); //load from cusplaneditorbar if its being customized
const form = useAttachProductForm({ initialProductId: itemId || undefined });
const { products, isLoading } = useProductsQuery();
const resetProductStore = useProductStore((s) => s.reset);
// Get store setters
const setFormValues = useAttachProductStore((s) => s.setFormValues);
const setCustomerId = useAttachProductStore((s) => s.setCustomerId);
const activeProducts = products.filter((p) => !p.archived);
@@ -115,33 +121,19 @@ export function AttachProductForm({
useEffect(() => {
// Set customerId on mount
setCustomerId(customerId);
}, [customerId, setCustomerId]);
// Subscribe to form changes
useEffect(() => {
// Reset product store when productId changes (unless it matches itemId from customization)
const subscription = form.store.subscribe(() => {
const values = form.store.state.values;
const productId = values.productId;
// Sync form values to store (no need to pass customerId again)
setFormValues({
productId: values.productId,
prepaidOptions: values.prepaidOptions ?? {},
});
// Reset product store when productId changes (unless it matches itemId from customization)
const productId = form.store.state.values.productId;
if (productId && productId !== itemId) {
resetProductStore();
}
});
return () => subscription();
}, [
customerId,
form.store,
itemId,
resetProductStore,
setFormValues,
setCustomerId,
]);
}, [form.store, itemId, resetProductStore]);
if (isLoading) {
return <div className="text-sm text-t3">Loading products...</div>;
@@ -172,6 +164,7 @@ export function AttachProductForm({
<form.Subscribe
selector={(state) => ({
productId: state.values.productId,
prepaidOptions: state.values.prepaidOptions,
})}
>
{(values) => (

View File

@@ -1,6 +1,8 @@
import { useProductsQuery } from "@/hooks/queries/useProductsQuery";
import { usePrepaidItems } from "@/hooks/stores/useProductStore";
import { useAttachProductStore } from "@/hooks/stores/useSubscriptionStore";
import {
usePrepaidItems,
useProductStore,
} from "@/hooks/stores/useProductStore";
import type { UseAttachProductForm } from "./use-attach-product-form";
interface PrepaidOptionsFieldProps {
@@ -10,14 +12,14 @@ interface PrepaidOptionsFieldProps {
export function AttachProductPrepaidOptions({
form,
}: PrepaidOptionsFieldProps) {
const customizedProduct = useAttachProductStore((s) => s.customizedProduct);
const storeProduct = useProductStore((s) => s.product);
const { products = [] } = useProductsQuery();
const selectedProductId = form.state.values.productId;
// Use customizedProduct if available, otherwise find from products by productId
const product =
customizedProduct ??
products.find((p) => p.id === selectedProductId && !p.archived);
// Use customized product if it has changes, otherwise find by form productId
const product = storeProduct?.id
? storeProduct
: products.find((p) => p.id === selectedProductId && !p.archived);
const prepaidItems = usePrepaidItems({ product });

View File

@@ -1,10 +1,8 @@
import { PencilSimpleIcon } from "@phosphor-icons/react";
import { useEffect } from "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 { useAttachProductStore } from "@/hooks/stores/useSubscriptionStore";
import { pushPage } from "@/utils/genUtils";
import type { UseAttachProductForm } from "./use-attach-product-form";
@@ -20,21 +18,9 @@ export function AttachProductSelection({
const { products } = useProductsQuery();
const activeProducts = products.filter((p) => !p.archived);
const navigate = useNavigate();
const setCustomizedProduct = useAttachProductStore(
(s) => s.setCustomizedProduct,
);
const productId = form.state.values.productId;
const customizedProduct = useAttachProductStore((s) => s.customizedProduct);
const hasChanges = useHasChanges();
//reset customized product to prevent any stale edited products (from SubscriptionDetailSheet)
useEffect(() => {
// Only reset if productId is not undefined/null (meaning user changed it)
if (productId !== undefined && customizedProduct?.id !== productId) {
setCustomizedProduct(null);
}
}, [productId, setCustomizedProduct]);
const handleCustomize = ({ productId }: { productId: string }) => {
if (!productId || !customerId) {
return;

View File

@@ -1,4 +1,4 @@
import type { ProductV2 } from "@autumn/shared";
import type { CheckoutResponse, ProductV2 } from "@autumn/shared";
import SmallSpinner from "@/components/general/SmallSpinner";
import { Separator } from "@/components/v2/separator";
import {
@@ -8,29 +8,24 @@ import {
import { formatUnixToDate } from "@/utils/formatUtils/formatDateUtils";
import { AttachConfirmationInfo } from "./attach-confirmation-info";
import { AttachFeaturePreview } from "./attach-feature-preview";
import { useAttachPreview } from "./use-attach-preview";
export function AttachProductSummary({
productId,
products,
customerId,
product,
previewData,
isLoading,
}: {
productId: string;
products: ProductV2[];
customerId: string;
product: ProductV2;
previewData?: CheckoutResponse | null;
isLoading?: boolean;
}) {
const { data: previewData, isLoading } = useAttachPreview();
if (isLoading) {
return (
<div className="flex items-center justify-center py-6">
<div className="flex items-center justify-center py-2">
<SmallSpinner />
</div>
);
}
const product = products.find((p) => p.id === productId);
// Use preview data if available, otherwise calculate from product prices
const lineItems =
previewData?.lines?.map((line) => {
@@ -48,9 +43,9 @@ export function AttachProductSummary({
return (
<div className="space-y-3 text-sm">
<AttachConfirmationInfo />
<AttachConfirmationInfo previewData={previewData} />
<AttachFeaturePreview customerId={customerId} />
<AttachFeaturePreview previewData={previewData} />
<Separator />
<SheetAccordion type="single" withSeparator={false} collapsible={true}>
{lineItems.length > 0 && (

View File

@@ -1,3 +1,4 @@
import type { CheckoutResponse } from "@autumn/shared";
import type { ReactNode } from "react";
import {
useHasBillingChanges,
@@ -5,10 +6,12 @@ import {
} from "@/hooks/stores/useProductStore";
import { formatUnixToDate } from "@/utils/formatUtils/formatDateUtils";
import { InfoBox } from "@/views/onboarding2/integrate/components/InfoBox";
import { useAttachPreview } from "./use-attach-preview";
export const UpdateConfirmationInfo = () => {
const { data: previewData } = useAttachPreview();
export const UpdateConfirmationInfo = ({
previewData,
}: {
previewData?: CheckoutResponse | null;
}) => {
const hasChanges = useHasChanges();
const hasBillingChanges = useHasBillingChanges({
baseProduct: previewData?.current_product,
@@ -45,13 +48,7 @@ export const UpdateConfirmationInfo = () => {
if (!hasBillingChanges) {
boxes.push(
<InfoBox key="no-billing-changes" variant="success">
No billing changes will be made
</InfoBox>,
);
} else {
boxes.push(
<InfoBox key="billing-changes" variant="warning">
Billing changes will be made
No changes to billing will be made
</InfoBox>,
);
}

View File

@@ -1,3 +1,4 @@
import type { CheckoutResponse, ProductV2 } from "@autumn/shared";
import { ArrowUpRightFromSquare } from "lucide-react";
import { useAttachProductMutation } from "@/components/forms/attach-product/use-attach-product-mutation";
import {
@@ -7,31 +8,36 @@ import {
} from "@/components/ui/popover";
import { Button } from "@/components/v2/buttons/Button";
import { useOrgStripeQuery } from "@/hooks/queries/useOrgStripeQuery";
import { useAttachProductStore } from "@/hooks/stores/useSubscriptionStore";
import { useEnv } from "@/utils/envUtils";
import { getStripeInvoiceLink } from "@/utils/linkUtils";
import { useAttachPreview } from "./use-attach-preview";
import type { UseAttachProductForm } from "./use-attach-product-form";
interface UpdateProductActionsProps {
customerId: string;
product?: ProductV2;
customerId?: string;
entityId?: string;
onSuccess?: () => void;
isPreviewLoading: boolean;
previewData?: CheckoutResponse | null;
isPreviewLoading?: boolean;
version?: number;
form: UseAttachProductForm;
}
export function UpdateProductActions({
form,
product,
customerId,
entityId,
onSuccess,
previewData,
isPreviewLoading,
version,
}: UpdateProductActionsProps) {
const { stripeAccount } = useOrgStripeQuery();
const env = useEnv();
const { data: previewData } = useAttachPreview();
const customizedProduct = useAttachProductStore((s) => s.customizedProduct);
const attachMutation = useAttachProductMutation({
customerId,
customerId: customerId ?? "",
successMessage: "Plan updated successfully",
onSuccess: () => {
onSuccess?.();
@@ -52,10 +58,12 @@ export function UpdateProductActions({
// Does the update
const result = await attachMutation.mutateAsync({
product: customizedProduct ?? undefined,
product,
entityId,
useInvoice,
enableProductImmediately,
prepaidOptions: form.state.values.prepaidOptions ?? undefined,
version,
});
// Handle checkout URLs and invoice links
@@ -74,7 +82,7 @@ export function UpdateProductActions({
const isLoading = attachMutation.isPending;
// Don't show buttons if preview is loading
if (isPreviewLoading || !customizedProduct) {
if (isPreviewLoading || !product) {
return null;
}

View File

@@ -1,9 +1,9 @@
import { usePrepaidItems } from "@/hooks/stores/useProductStore";
import { useSheetStore } from "@/hooks/stores/useSheetStore";
import {
useAttachProductStore,
useSubscriptionById,
} from "@/hooks/stores/useSubscriptionStore";
usePrepaidItems,
useProductStore,
} from "@/hooks/stores/useProductStore";
import { useSheetStore } from "@/hooks/stores/useSheetStore";
import { useSubscriptionById } from "@/hooks/stores/useSubscriptionStore";
import type { UseAttachProductForm } from "./use-attach-product-form";
export function UpdateProductPrepaidOptions({
@@ -11,14 +11,15 @@ export function UpdateProductPrepaidOptions({
}: {
form: UseAttachProductForm;
}) {
const customizedProduct = useAttachProductStore((s) => s.customizedProduct);
const storeProduct = useProductStore((s) => s.product);
const itemId = useSheetStore((s) => s.itemId);
const { productV2: product } = useSubscriptionById({ itemId });
const { productV2 } = useSubscriptionById({ itemId });
const prepaidItems = usePrepaidItems({
product: product ?? customizedProduct ?? undefined,
});
// Use store product if it has a real ID, otherwise use productV2 from subscription
const product = storeProduct?.id ? storeProduct : (productV2 ?? undefined);
const prepaidItems = usePrepaidItems({ product });
if (prepaidItems.length === 0) {
return null;

View File

@@ -1,20 +1,22 @@
import type { CheckoutResponse, ProductV2 } from "@autumn/shared";
import SmallSpinner from "@/components/general/SmallSpinner";
import { Separator } from "@/components/v2/separator";
import {
SheetAccordion,
SheetAccordionItem,
} from "@/components/v2/sheets/SheetAccordion";
import { useAttachProductStore } from "@/hooks/stores/useSubscriptionStore";
import { formatUnixToDate } from "@/utils/formatUtils/formatDateUtils";
import { UpdateConfirmationInfo } from "./update-confirmation-info";
import { useAttachPreview } from "./use-attach-preview";
export function UpdateProductSummary() {
const { data: previewData, isLoading } = useAttachPreview();
const customizedProduct = useAttachProductStore((s) => s.customizedProduct);
const storeProductId = useAttachProductStore((s) => s.productId);
export function UpdateProductSummary({
product,
previewData,
isLoading,
}: {
product?: ProductV2;
previewData?: CheckoutResponse | null;
isLoading?: boolean;
}) {
if (isLoading) {
return (
<div className="flex items-center justify-center py-6">
@@ -23,11 +25,12 @@ export function UpdateProductSummary() {
);
}
console.log("previewData", previewData);
// Use preview data if available, otherwise calculate from product prices
const lineItems =
previewData?.lines?.map((line) => {
return {
name: line.description || customizedProduct?.name || "Unknown",
name: line.description || product?.name || "Unknown",
total: line.amount,
};
}) || [];
@@ -40,7 +43,7 @@ export function UpdateProductSummary() {
return (
<div className="space-y-3 text-sm">
<UpdateConfirmationInfo />
<UpdateConfirmationInfo previewData={previewData} />
<Separator />
<SheetAccordion type="single" withSeparator={false} collapsible={true}>

View File

@@ -1,11 +1,8 @@
import type { ProductV2 } from "@autumn/shared";
import { useMemo } from "react";
import { useProductsQuery } from "@/hooks/queries/useProductsQuery";
import { useHasChanges } from "@/hooks/stores/useProductStore";
import {
useAttachProductStore,
useEntity,
} from "@/hooks/stores/useSubscriptionStore";
import { useHasChanges, useProductStore } from "@/hooks/stores/useProductStore";
import { useEntity } from "@/hooks/stores/useSubscriptionStore";
import { getAttachBody } from "@/views/customers/customer/product/components/attachProductUtils";
interface AttachBodyBuilderParams {
@@ -20,31 +17,36 @@ interface AttachBodyBuilderParams {
}
/**
* Shared hook to build attach body from various sources (params, store, products list)
* Shared hook to build attach body from explicit params
* Used by both useAttachPreview and useAttachProductMutation to keep logic DRY
*/
export function useAttachBodyBuilder(params: AttachBodyBuilderParams = {}) {
const { products } = useProductsQuery();
const customizedProduct = useAttachProductStore((s) => s.customizedProduct);
const hasChanges = useHasChanges();
const storeProduct = useProductStore((s) => s.product);
const { entityId: storeEntityId } = useEntity();
const storeProductId = useAttachProductStore((s) => s.productId);
// Memoized builder function that can be called with runtime params
const buildAttachBody = useMemo(
() => (runtimeParams?: AttachBodyBuilderParams) => {
const mergedParams = { ...params, ...runtimeParams };
// Resolve the product: use provided product, or customized product from store, or find by ID
// Resolve the product: use provided product or find by ID
const product =
mergedParams.product ||
customizedProduct ||
products.find((p) => p.id === mergedParams.productId);
if (!product || !mergedParams.customerId) {
return null;
}
// Determine if this is a custom product (from store with changes)
const isCustom =
hasChanges && !!storeProduct?.id && product === storeProduct
? true
: undefined;
const version = storeProduct?.id ? storeProduct.version : undefined;
// Convert prepaidOptions to options array
const options = mergedParams.prepaidOptions
? Object.entries(mergedParams.prepaidOptions).map(
@@ -61,13 +63,13 @@ export function useAttachBodyBuilder(params: AttachBodyBuilderParams = {}) {
product,
entityId: mergedParams.entityId ?? storeEntityId ?? undefined,
optionsInput: options.length > 0 ? options : undefined,
isCustom: !!customizedProduct,
version: mergedParams.version ?? product.version,
isCustom,
version,
useInvoice: mergedParams.useInvoice,
enableProductImmediately: mergedParams.enableProductImmediately,
});
},
[customizedProduct, products, hasChanges, storeEntityId, params],
[products, hasChanges, storeProduct, storeEntityId, params],
);
// For simple usage, return the built body with current params

View File

@@ -1,14 +1,12 @@
import type { CheckoutResponse, ProductV2 } from "@autumn/shared";
import { useQuery } from "@tanstack/react-query";
import { useEffect, useMemo, useState } from "react";
import { useAttachProductStore } from "@/hooks/stores/useSubscriptionStore";
import { useAxiosInstance } from "@/services/useAxiosInstance";
import { useAttachBodyBuilder } from "./use-attach-body-builder";
interface AttachPreviewParams {
// Override store values
// Required params - no fallbacks
customerId?: string;
productId?: string;
product?: ProductV2;
entityId?: string;
prepaidOptions?: Record<string, number>;
@@ -21,38 +19,25 @@ interface AttachPreviewParams {
export function useAttachPreview(params: AttachPreviewParams = {}) {
const axiosInstance = useAxiosInstance();
// Get form values from store (can be overridden by params)
const storeCustomerId = useAttachProductStore((s) => s.customerId);
const storeProductId = useAttachProductStore((s) => s.productId);
const storePrepaidOptions = useAttachProductStore((s) => s.prepaidOptions);
// Use params if provided, otherwise fall back to store
const customerId = params.customerId ?? storeCustomerId;
const productId = params.productId ?? storeProductId;
const prepaidOptions = params.prepaidOptions ?? storePrepaidOptions;
// Build attach body using shared hook
// Build attach body using shared hook with explicit params
const { attachBody } = useAttachBodyBuilder({
customerId: customerId ?? undefined,
productId: productId ?? undefined,
product: params.product, //customizedProduct is accessed within the hook, but can be overridden here
customerId: params.customerId,
product: params.product,
entityId: params.entityId,
prepaidOptions: prepaidOptions ?? undefined,
prepaidOptions: params.prepaidOptions,
version: params.version,
});
console.log("attachBody", attachBody);
// Auto-enable if not explicitly set and all required data is present
const shouldEnable =
params.enabled !== undefined
? params.enabled
: !!(customerId && (productId || params.product) && attachBody);
: !!(params.customerId && params.product && attachBody);
// Create a stable serialized key from attachBody (which already captures all dependencies)
const queryKeyDeps = useMemo(() => JSON.stringify(attachBody), [attachBody]);
// Debounce the query key to delay API calls by 200ms
// Debounce the query key to delay API calls by 150ms
const [debouncedQueryKey, setDebouncedQueryKey] = useState(queryKeyDeps);
useEffect(() => {
@@ -65,7 +50,7 @@ export function useAttachPreview(params: AttachPreviewParams = {}) {
return useQuery({
queryKey: ["attach-checkout", debouncedQueryKey],
queryFn: async () => {
if (!attachBody || !customerId) {
if (!attachBody || !params.customerId) {
return null;
}

View File

@@ -1,5 +1,6 @@
import type { ProductV2 } from "@autumn/shared";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { toast } from "sonner";
import { useSheetStore } from "@/hooks/stores/useSheetStore";
import { CusService } from "@/services/customers/CusService";
@@ -73,7 +74,10 @@ export function useAttachProductMutation({
if (onError) {
onError(error);
} else {
toast.error("Failed to attach product");
toast.error(
(error as AxiosError<{ message: string }>)?.response?.data?.message ??
"Failed to attach product",
);
console.error(error);
}
},

View File

@@ -1,7 +1,7 @@
"use client";
import * as React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import type * as React from "react";
import { cn } from "@/lib/utils";
@@ -47,9 +47,9 @@ function TooltipContent({
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
`z-50 overflow-hidden rounded-sm px-4 py-2 text-xs max-w-56 animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2
`z-50 overflow-hidden rounded-lg px-4 py-2 text-xs max-w-56 animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2
bg-white backdrop-blur-sm shadow-lg text-t2
backdrop-blur-sm shadow-lg text-t2 border bg-outer-background
`,
className,
)}

View File

@@ -14,7 +14,7 @@ export const SheetCloseButton = ({ onClose }: SheetCloseButtonProps) => {
onClick={onClose}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
className="ring-offset-background focus:ring-ring absolute top-4 right-2 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none"
className="ring-offset-background focus:ring-ring absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none"
aria-label="Close"
>
<XIcon className="size-4" />

View File

@@ -103,7 +103,12 @@ export const useHasBillingChanges = ({
features,
});
return !comparison.onlyEntsChanged;
console.log("comparison", comparison);
const hasBillingChanges =
!comparison.onlyEntsChanged || !comparison.freeTrialsSame;
return hasBillingChanges;
}, [baseProduct, newProduct, features]);
};
@@ -219,7 +224,6 @@ export const useSetCurrentItem = () => {
/**
* Hook to check if the current product is the latest version.
* Checks customizedProduct first, then fallbacks to product from store.
*/
export const useIsLatestVersion = (product: FrontendProduct) => {
const { products = [] } = useProductsQuery();

View File

@@ -1,7 +1,6 @@
import {
cusProductToProduct,
type Entity,
type FrontendProduct,
type FullCusProduct,
type FullCustomer,
mapToProductV2,
@@ -13,35 +12,18 @@ import { useCusQuery } from "@/views/customers/customer/hooks/useCusQuery";
interface AttachProductFormValues {
customerId: string | null;
productId: string;
prepaidOptions: Record<string, number>;
customizedProduct: FrontendProduct | null;
customerProductId: string | null;
selectedEntityId: string | null;
}
interface AttachProductState extends AttachProductFormValues {
// Actions
setCustomerId: (customerId: string | null) => void;
setProductId: (productId: string) => void;
setPrepaidOptions: (options: Record<string, number>) => void;
setCustomizedProduct: (
product: {
product: FrontendProduct;
customer_product_id?: string | null;
} | null,
) => void;
setSelectedEntityId: (entityId: string | null) => void;
setFormValues: (values: Partial<AttachProductFormValues>) => void;
reset: () => void;
}
const initialState: AttachProductFormValues = {
customerId: null,
productId: "",
prepaidOptions: {},
customizedProduct: null,
customerProductId: null,
selectedEntityId: null,
};
@@ -49,31 +31,11 @@ export const useAttachProductStore = create<AttachProductState>((set) => ({
...initialState,
setCustomerId: (customerId) => set({ customerId }),
setProductId: (productId) => set({ productId }),
setPrepaidOptions: (prepaidOptions) => set({ prepaidOptions }),
setCustomizedProduct: (customizedProduct) =>
set({
customizedProduct: customizedProduct?.product ?? null,
customerProductId: customizedProduct?.customer_product_id ?? null,
}),
setSelectedEntityId: (selectedEntityId) => set({ selectedEntityId }),
// Convenience method to set multiple values at once
setFormValues: (values) => set(values),
reset: () => set(initialState),
}));
// Convenience selector hook to get all form values
export const useAttachProductFormValues = () =>
useAttachProductStore((s) => ({
customerId: s.customerId,
productId: s.productId,
prepaidOptions: s.prepaidOptions,
customizedProduct: s.customizedProduct,
customerProductId: s.customerProductId,
}));
// Hook to sync entity_id between query params and store
export const useEntity = () => {
const [{ entity_id }, setQueryStates] = useQueryStates({

View File

@@ -1,9 +1,20 @@
import { useEffect } from "react";
import { AttachProductForm } from "@/components/forms/attach-product/attach-product-form";
import { SheetHeader, SheetSection } from "@/components/v2/sheets/InlineSheet";
import { useProductStore } from "@/hooks/stores/useProductStore";
import { useSheetStore } from "@/hooks/stores/useSheetStore";
import { useCustomerContext } from "../../customer/CustomerContext";
export function AttachProductSheet() {
const { customer } = useCustomerContext();
const sheetType = useSheetStore((s) => s.type);
const resetProductStore = useProductStore((s) => s.reset);
//remove any stale customized product data from store
useEffect(() => {
if (sheetType !== "attach-product") {
resetProductStore();
}
}, [sheetType, resetProductStore]);
return (
<div className="flex flex-col h-full">

View File

@@ -16,6 +16,7 @@ import {
XCircle,
} from "@phosphor-icons/react";
import { format } from "date-fns";
import { useEffect } from "react";
import { useNavigate } from "react-router";
// import { Badge } from "@/components/v2/Badge";
import { Button } from "@/components/v2/buttons/Button";
@@ -23,12 +24,13 @@ 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 { usePrepaidItems } from "@/hooks/stores/useProductStore";
import { useSheetStore } from "@/hooks/stores/useSheetStore";
import {
useAttachProductStore,
useSubscriptionById,
} from "@/hooks/stores/useSubscriptionStore";
useHasChanges,
usePrepaidItems,
useProductStore,
} from "@/hooks/stores/useProductStore";
import { useSheetStore } from "@/hooks/stores/useSheetStore";
import { useSubscriptionById } from "@/hooks/stores/useSubscriptionStore";
import { cn } from "@/lib/utils";
import { pushPage } from "@/utils/genUtils";
import { useCusQuery } from "@/views/customers/customer/hooks/useCusQuery";
@@ -42,20 +44,27 @@ export function SubscriptionDetailSheet() {
const itemId = useSheetStore((s) => s.itemId);
const setSheet = useSheetStore((s) => s.setSheet);
const navigate = useNavigate();
const resetProductStore = useProductStore((s) => s.reset);
const sheetType = useSheetStore((s) => s.type);
// Get edited product from store
const customizedProduct = useAttachProductStore((s) => s.customizedProduct);
const editedCustomerProductId = useAttachProductStore(
(s) => s.customerProductId,
);
const hasChanges = useHasChanges();
const storeProduct = useProductStore((s) => s.product);
// Check if the edited product is for this subscription
const shouldShowEditedProduct =
customizedProduct && editedCustomerProductId === itemId;
// Check if there are changes in the product store
const shouldShowEditedProduct = hasChanges && !!storeProduct;
// Get customer product and productV2 by itemId
const { cusProduct, productV2 } = useSubscriptionById({ itemId });
useEffect(() => {
if (
sheetType !== "subscription-detail" &&
sheetType !== "subscription-update"
) {
resetProductStore();
}
}, [sheetType, resetProductStore]);
// Check for prepaid items in the product (must be called before any returns)
const prepaidItems = usePrepaidItems({ product: productV2 ?? undefined });
const hasPrepaidItems = prepaidItems.length > 0;
@@ -248,70 +257,72 @@ export function SubscriptionDetailSheet() {
)}
{/* Edited Plan Items - Show pending changes */}
{shouldShowEditedProduct && customizedProduct.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>
{customizedProduct.items.map((item, index) => {
const display = getProductItemDisplay({
item,
features,
currency: org?.default_currency || "USD",
fullDisplay: true,
amountFormatOptions: { currencyDisplay: "narrowSymbol" },
});
{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" },
});
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>
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>
{display.secondary_text && (
<div className="text-xs text-t3 mt-0.5">
{display.secondary_text}
</div>
)}
</div>
</div>
);
})}
</div>
</SheetSection>
)}
);
})}
</div>
</SheetSection>
)}
{/* Pricing Summary */}
<SheetSection title="Pricing">
@@ -385,10 +396,7 @@ export function SubscriptionDetailSheet() {
<PencilSimple size={16} weight="duotone" />
Edit Plan
</Button>
<UpdatePlanButton
cusProduct={cusProduct}
customizedProduct={customizedProduct}
/>
<UpdatePlanButton cusProduct={cusProduct} />
</>
) : (
<>

View File

@@ -1,140 +1,149 @@
import type { FrontendProduct } from "@autumn/shared";
import type { FullCusProduct, ProductV2 } from "@autumn/shared";
import { ArrowLeft } from "@phosphor-icons/react";
import { useEffect } from "react";
import { useEffect, useMemo } from "react";
import { UpdateProductActions } from "@/components/forms/attach-product/update-product-actions";
import { UpdateProductPrepaidOptions } from "@/components/forms/attach-product/update-product-prepaid-options";
import { UpdateProductSummary } from "@/components/forms/attach-product/update-product-summary";
import { useAttachPreview } from "@/components/forms/attach-product/use-attach-preview";
import { useAttachProductForm } from "@/components/forms/attach-product/use-attach-product-form";
import {
type UseAttachProductForm,
useAttachProductForm,
} from "@/components/forms/attach-product/use-attach-product-form";
import { FormWrapper } from "@/components/general/form/form-wrapper";
import { Button } from "@/components/v2/buttons/Button";
import { SheetHeader, SheetSection } from "@/components/v2/sheets/InlineSheet";
import { usePrepaidItems } from "@/hooks/stores/useProductStore";
import { useSheetStore } from "@/hooks/stores/useSheetStore";
import {
useAttachProductStore,
useSubscriptionById,
} from "@/hooks/stores/useSubscriptionStore";
usePrepaidItems,
useProductStore,
} from "@/hooks/stores/useProductStore";
import { useSheetStore } from "@/hooks/stores/useSheetStore";
import { useSubscriptionById } from "@/hooks/stores/useSubscriptionStore";
import { useCusQuery } from "@/views/customers/customer/hooks/useCusQuery";
export function SubscriptionUpdateSheet() {
const FormContent = ({
productV2,
cusProduct,
form,
initialPrepaidOptions,
}: {
productV2: ProductV2;
cusProduct: FullCusProduct;
form: UseAttachProductForm;
initialPrepaidOptions: Record<string, number>;
}) => {
const { customer } = useCusQuery();
const itemId = useSheetStore((s) => s.itemId);
const setSheet = useSheetStore((s) => s.setSheet);
const customerId = customer?.id;
const storeProduct = useProductStore((s) => s.product);
const product = storeProduct?.id ? storeProduct : (productV2 ?? undefined);
const entityId = cusProduct?.entity_id ?? undefined;
const prepaidItems = usePrepaidItems({ product });
// Get edited product from store
const customizedProduct = useAttachProductStore((s) => s.customizedProduct);
const setCustomerId = useAttachProductStore((s) => s.setCustomerId);
const setFormValues = useAttachProductStore((s) => s.setFormValues);
const setProductId = useAttachProductStore((s) => s.setProductId);
const productId = useAttachProductStore((s) => s.productId);
const setCustomizedProduct = useAttachProductStore(
(s) => s.setCustomizedProduct,
const prepaidOptions = form.state.values.prepaidOptions;
const previewQuery = useAttachPreview({
customerId,
product,
entityId,
prepaidOptions: prepaidOptions ?? undefined,
version: product?.version,
});
if (prepaidItems.length > 0) {
const hasUnsetPrepaidQuantity = prepaidItems.some((item) => {
const quantity = prepaidOptions?.[item.feature_id as string];
return quantity === undefined || quantity === null;
});
if (hasUnsetPrepaidQuantity) {
return null;
}
// 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];
return currentQuantity !== initialQuantity;
});
if (!hasQuantityChanges && !storeProduct?.id) {
return null;
}
}
return (
<>
<UpdateProductSummary
previewData={previewQuery.data}
isLoading={previewQuery.isLoading}
product={product}
/>
<UpdateProductActions
product={product}
customerId={customerId}
entityId={entityId}
previewData={previewQuery.data}
isPreviewLoading={previewQuery.isLoading}
form={form}
/>
</>
);
};
function SheetContent({
cusProduct,
productV2,
itemId,
}: {
cusProduct: FullCusProduct;
productV2: ProductV2;
itemId: string | null;
}) {
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(
() =>
cusProduct.options.reduce(
(acc, option) => {
acc[option.feature_id] = option.quantity;
return acc;
},
{} as Record<string, number>,
),
[cusProduct.options],
);
const { cusProduct, productV2: product } = useSubscriptionById({ itemId });
const product = storeProduct?.id ? storeProduct : (productV2 ?? undefined);
const prepaidItems = usePrepaidItems({ product });
setProductId(cusProduct?.product.id ?? "");
// This gets the prepaid items from the product, sees if there are existing quantities from the cusProduct/subscription, and sets them if so
useEffect(() => {
// Build prepaid options based on current product's prepaid items
const newPrepaidOptions = prepaidItems.reduce(
(acc, item) => {
const featureId = item.feature_id as string;
// Use initial value if this feature existed in original, otherwise undefined
acc[featureId] = initialPrepaidOptions[featureId] ?? undefined;
return acc;
},
{} as Record<string, number | undefined>,
);
const { isLoading: isPreviewLoading } = useAttachPreview();
form.setFieldValue(
"prepaidOptions",
newPrepaidOptions as Record<string, number>,
);
}, [prepaidItems, initialPrepaidOptions, form]);
const handleBackToDetail = () => {
setSheet({ type: "subscription-detail", itemId });
setCustomizedProduct({
product: product as unknown as FrontendProduct,
});
};
const form = useAttachProductForm({
initialProductId: cusProduct?.id ?? undefined,
});
useEffect(() => {
if (!customer?.id && !customer?.internal_id) return;
const customerId = customer.id || customer.internal_id;
setCustomerId(customerId);
// Subscribe to form changes
const subscription = form.store.subscribe(() => {
const values = form.store.state.values;
// Sync form values to store
setFormValues({
productId: values.productId ?? productId,
prepaidOptions: values.prepaidOptions ?? {},
});
});
return () => subscription();
}, [customer, form.store, setCustomerId, setFormValues, productId]);
// const cusProduct = useMemo(() => {
// if (!itemId || !customer?.customer_products) return null;
// return customer.customer_products.find(
// (p: FullCusProduct) =>
// p.id === itemId || p.internal_product_id === itemId,
// );
// }, [itemId, customer?.customer_products]);
// const entity = customer?.entities?.find(
// (e: Entity) =>
// e.internal_id === cusProduct?.internal_entity_id ||
// e.id === cusProduct?.entity_id,
// );
// const customerId = customer?.id || customer?.internal_id;
// const entityId = entity ? entity.id || entity.internal_id : undefined;
// Get prepaid items from the customized product
const prepaidItems = usePrepaidItems({
product: product ?? undefined,
});
// Create form with prepopulated values
// Initialize store and form on mount
// useEffect(() => {
// if (customerId && customizedProduct) {
// setCustomerId(customerId);
// setFormValues({
// productId: customizedProduct.id,
// prepaidOptions: initialPrepaidOptions,
// });
// // Set form values
// form.setFieldValue("productId", customizedProduct.id);
// form.setFieldValue("prepaidOptions", initialPrepaidOptions);
// }
// }, [
// customerId,
// customizedProduct,
// setCustomerId,
// setFormValues,
// initialPrepaidOptions,
// ]);
// Sync form changes to store
if (!cusProduct) {
return (
<div className="flex flex-col h-full">
<SheetHeader
title="Update Plan"
description="Loading plan information..."
>
<Button
variant="skeleton"
size="sm"
onClick={handleBackToDetail}
className="mt-2 w-fit"
>
<ArrowLeft size={16} />
Back to Details
</Button>
</SheetHeader>
</div>
);
}
return (
<FormWrapper form={form}>
@@ -155,22 +164,87 @@ export function SubscriptionUpdateSheet() {
</SheetHeader>
<div className="flex-1 overflow-y-auto">
{prepaidItems.length > 0 && (
<SheetSection title="Prepaid Quantities" withSeparator={false}>
<UpdateProductPrepaidOptions form={form} />
</SheetSection>
)}
<UpdateProductSummary />
<UpdateProductActions
customerId={customer?.id || customer?.internal_id}
entityId={cusProduct.entity_id ?? undefined}
isPreviewLoading={isPreviewLoading}
/>
<form.Subscribe
selector={(state) => ({
prepaidOptions: state.values.prepaidOptions,
})}
>
{() => (
<>
{prepaidItems.length > 0 && (
<SheetSection
title="Prepaid Quantities"
withSeparator={false}
>
<UpdateProductPrepaidOptions form={form} />
</SheetSection>
)}
<FormContent
productV2={productV2}
cusProduct={cusProduct}
form={form}
initialPrepaidOptions={initialPrepaidOptions}
/>
</>
)}
</form.Subscribe>
</div>
</div>
</FormWrapper>
);
}
function resetProductStore() {
throw new Error("Function not implemented.");
export function SubscriptionUpdateSheet() {
const itemId = useSheetStore((s) => s.itemId);
const setSheet = useSheetStore((s) => s.setSheet);
const { cusProduct, productV2 } = useSubscriptionById({ itemId });
const entityId = cusProduct?.entity_id ?? undefined;
const sheetType = useSheetStore((s) => s.type);
const resetProductStore = useProductStore((s) => s.reset);
useEffect(() => {
if (
sheetType !== "subscription-detail" &&
sheetType !== "subscription-update"
) {
resetProductStore();
}
}, [sheetType, resetProductStore]);
// Load subscription's product into store on mount
if (!cusProduct) {
return (
<div className="flex flex-col h-full">
<SheetHeader
title="Update Plan"
description="Loading plan information..."
>
<Button
variant="skeleton"
size="sm"
onClick={() => setSheet({ type: "subscription-detail", itemId })}
className="mt-2 w-fit"
>
<ArrowLeft size={16} />
Back to Details
</Button>
</SheetHeader>
</div>
);
}
if (!productV2) {
return null;
}
return (
<SheetContent
cusProduct={cusProduct}
productV2={productV2}
itemId={itemId}
/>
);
}

View File

@@ -1,11 +1,10 @@
import type { FullCusProduct, ProductV2 } from "@autumn/shared";
import type { FullCusProduct } from "@autumn/shared";
import { CheckCircle } from "@phosphor-icons/react";
import { Button } from "@/components/v2/buttons/Button";
import { useSheetStore } from "@/hooks/stores/useSheetStore";
interface UpdatePlanButtonProps {
cusProduct: FullCusProduct;
customizedProduct: ProductV2;
}
export function UpdatePlanButton({ cusProduct }: UpdatePlanButtonProps) {

View File

@@ -6,13 +6,13 @@ import {
} from "@autumn/shared";
import type { Row } from "@tanstack/react-table";
import type { z } from "zod/v4";
import { MiniCopyButton } from "@/components/v2/buttons/CopyButton";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { MiniCopyButton } from "@/components/v2/buttons/CopyButton";
} from "@/components/v2/tooltips/Tooltip";
import { formatUnixToDateTime } from "@/utils/formatUtils/formatDateUtils";
import { CustomerProductsStatus } from "../customer-products/CustomerProductsStatus";
import { CustomerListRowToolbar } from "./CustomerListRowToolbar";
@@ -47,66 +47,6 @@ const getCusProductsInfo = ({
return <span className="text-t3"></span>;
}
// const getProductBadge = ({
// cusProduct,
// versionCounts,
// }: {
// cusProduct: FullCusProduct;
// versionCounts: Record<string, number>;
// }) => {
// const name = cusProduct.product.name;
// const status = cusProduct.status;
// const versionCount = versionCounts[cusProduct.product.id];
// const version = cusProduct.product.version;
// const prodName = (
// <>
// {name}
// {versionCount > 1 && (
// <Badge
// variant="outline"
// className="text-xs bg-stone-50 text-t3 px-2 ml-2 font-mono py-0"
// >
// v{version}
// </Badge>
// )}
// </>
// );
// if (status === CusProductStatus.PastDue) {
// return (
// <>
// <span>{prodName}</span>{" "}
// <Badge variant="status" className="bg-red-500">
// Past Due
// </Badge>
// </>
// );
// }
// if (cusProduct.canceled_at) {
// return (
// <>
// <span>{prodName}</span>{" "}
// <Badge variant="status" className="bg-yellow-500">
// Canceled
// </Badge>
// </>
// );
// }
// if (cusProduct.trial_ends_at && !unixHasPassed(cusProduct.trial_ends_at)) {
// return (
// <>
// <span>{prodName}</span>{" "}
// <Badge variant="status" className="bg-lime-500">
// Trial
// </Badge>
// </>
// );
// }
// return <span>{prodName}</span>;
// };
return (
<div className="flex ">
{activeProducts

View File

@@ -1,6 +1,6 @@
import { type FullCusProduct, isTrialing } from "@autumn/shared";
import type { Row, Table } from "@tanstack/react-table";
import { Delete } from "lucide-react";
import { ArrowRightLeft, Delete } from "lucide-react";
import { TableDropdownMenuCell } from "@/components/general/table/table-dropdown-menu-cell";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import { createDateTimeColumn } from "@/views/customers2/utils/ColumnHelpers";
@@ -68,12 +68,25 @@ export const CustomerProductsColumns = [
}) => {
const meta = table.options.meta as {
onCancelClick?: (product: FullCusProduct) => void;
onTransferClick?: (product: FullCusProduct) => void;
hasEntities?: boolean;
};
if (!meta?.onCancelClick) return null;
return (
<TableDropdownMenuCell>
{meta.hasEntities && meta.onTransferClick && (
<DropdownMenuItem
className="flex items-center gap-2 text-xs"
onClick={(e) => {
e.stopPropagation();
meta.onTransferClick?.(row.original);
}}
>
<ArrowRightLeft size={16} /> Transfer
</DropdownMenuItem>
)}
<DropdownMenuItem
className="flex items-center gap-2 text-xs text-red-500 dark:text-red-400"
onClick={(e) => {

View File

@@ -18,6 +18,7 @@ import { CustomerProductPrice } from "./CustomerProductPrice";
import { CustomerProductsColumns } from "./CustomerProductsColumns";
import { filterCustomerProductsByEntity } from "./customerProductsTableFilters";
import { ShowExpiredActionButton } from "./ShowExpiredActionButton";
import { TransferProductDialog } from "./TransferProductDialog";
export function CustomerProductsTable() {
const { customer, isLoading } = useCusQuery();
@@ -29,6 +30,7 @@ export function CustomerProductsTable() {
);
const [cancelOpen, setCancelOpen] = useState(false);
const [transferOpen, setTransferOpen] = useState(false);
const [selectedProduct, setSelectedProduct] = useState<FullCusProduct | null>(
null,
);
@@ -78,6 +80,25 @@ export function CustomerProductsTable() {
const entityProductsTableColumns = useMemo(
() => [
{
header: "Plan",
accessorKey: "plan",
cell: ({ row }: { row: Row<FullCusProduct> }) => {
const quantity = row.original.quantity;
const showQuantity = quantity && quantity > 1;
return (
<div className="font-semibold flex items-center gap-2 text-t1">
{row.original.product.name}
{showQuantity && (
<div className="text-t3 bg-muted rounded-sm p-1 py-0">
{quantity}
</div>
)}
</div>
);
},
},
{
header: "Entity",
accessorKey: "entity",
@@ -101,7 +122,7 @@ export function CustomerProductsTable() {
<Button
variant="skeleton"
onClick={handleEntityClick}
className="text-t1 font-medium hover:text-purple-600 cursor-pointer max-w-full px-0! hover:bg-transparent active:bg-transparent active:border-none"
className="font-medium hover:text-purple-600 cursor-pointer max-w-full px-0! hover:bg-transparent active:bg-transparent active:border-none"
>
<span className="truncate w-full">
{entity.name || entity.id || entity.internal_id}
@@ -110,25 +131,7 @@ export function CustomerProductsTable() {
);
},
},
{
header: "Name",
accessorKey: "name",
cell: ({ row }: { row: Row<FullCusProduct> }) => {
const quantity = row.original.quantity;
const showQuantity = quantity && quantity > 1;
return (
<div className="font-semibold flex items-center gap-2">
{row.original.product.name}
{showQuantity && (
<div className="text-t3 bg-muted rounded-sm p-1 py-0">
{quantity}
</div>
)}
</div>
);
},
},
{
header: "Price",
accessorKey: "price",
@@ -150,6 +153,11 @@ export function CustomerProductsTable() {
setCancelOpen(true);
};
const handleTransferClick = (product: FullCusProduct) => {
setSelectedProduct(product);
setTransferOpen(true);
};
const handleRowClick = (cusProduct: FullCusProduct) => {
setSheet({
type: "subscription-detail",
@@ -157,6 +165,8 @@ export function CustomerProductsTable() {
});
};
const hasEntities = customer.entities.length > 0;
const enableSorting = false;
const table = useCustomerTable({
data: displayedProducts,
@@ -166,6 +176,8 @@ export function CustomerProductsTable() {
enableGlobalFilter: true,
meta: {
onCancelClick: handleCancelClick,
onTransferClick: handleTransferClick,
hasEntities,
},
},
});
@@ -178,6 +190,8 @@ export function CustomerProductsTable() {
enableGlobalFilter: true,
meta: {
onCancelClick: handleCancelClick,
onTransferClick: handleTransferClick,
hasEntities,
},
},
});
@@ -192,11 +206,18 @@ export function CustomerProductsTable() {
return (
<div className="flex flex-col gap-4">
{selectedProduct && (
<CancelProductDialog
cusProduct={selectedProduct}
open={cancelOpen}
setOpen={setCancelOpen}
/>
<>
<CancelProductDialog
cusProduct={selectedProduct}
open={cancelOpen}
setOpen={setCancelOpen}
/>
<TransferProductDialog
cusProduct={selectedProduct}
open={transferOpen}
setOpen={setTransferOpen}
/>
</>
)}
<Table.Provider
config={{
@@ -259,6 +280,7 @@ export function CustomerProductsTable() {
enableSorting,
isLoading,
onRowClick: handleRowClick,
emptyStateText: "No entity-level plans found",
}}
>
<Table.Container>

View File

@@ -0,0 +1,153 @@
import type { Entity, FullCusProduct } from "@autumn/shared";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/v2/buttons/Button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/v2/dialogs/Dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/v2/selects/Select";
import { useAxiosInstance } from "@/services/useAxiosInstance";
import { getBackendErr } from "@/utils/genUtils";
import { useCusQuery } from "@/views/customers/customer/hooks/useCusQuery";
const CUSTOMER_LEVEL_VALUE = "__customer__";
export const TransferProductDialog = ({
cusProduct,
open,
setOpen,
}: {
cusProduct: FullCusProduct;
open: boolean;
setOpen: (open: boolean) => void;
}) => {
const { customer, refetch } = useCusQuery();
const axiosInstance = useAxiosInstance();
const [loading, setLoading] = useState(false);
const [selectedValue, setSelectedValue] = useState<string>("");
const filteredEntities = customer.entities.filter(
(entity: Entity) => entity.internal_id !== cusProduct.internal_entity_id,
);
// Check if product is currently on an entity
const isOnEntity = !!cusProduct.entity_id || !!cusProduct.internal_entity_id;
useEffect(() => {
if (open) {
setSelectedValue("");
}
}, [open]);
const handleTransfer = async () => {
if (!selectedValue) {
toast.error("Please select a destination");
return;
}
setLoading(true);
try {
const fromEntity = customer.entities.find(
(e: Entity) => e.internal_id === cusProduct.internal_entity_id,
);
const isMovingToCustomer = selectedValue === CUSTOMER_LEVEL_VALUE;
const toEntity = isMovingToCustomer
? null
: customer.entities.find((e: Entity) => e.id === selectedValue);
await axiosInstance.post(
`/v1/customers/${cusProduct.customer_id}/transfer`,
{
from_entity_id: fromEntity?.id,
to_entity_id: isMovingToCustomer ? null : toEntity?.id,
product_id: cusProduct.product_id,
},
);
await refetch();
toast.success("Plan transferred successfully");
setOpen(false);
} catch (error) {
console.log(error);
toast.error(getBackendErr(error, "Failed to transfer plan"));
} finally {
setLoading(false);
}
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent
className="w-md bg-card"
onClick={(e) => e.stopPropagation()}
>
<DialogHeader>
<DialogTitle>Transfer Plan</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-3">
<p className="text-sm text-t2">
{isOnEntity
? "Transfer this plan to another entity or move it back to the customer level."
: "Transfer this plan to an entity."}
</p>
<Select value={selectedValue} onValueChange={setSelectedValue}>
<SelectTrigger>
<SelectValue placeholder="Select destination" />
</SelectTrigger>
<SelectContent>
{/* Move to Customer option - only show if product is currently on an entity */}
{isOnEntity && (
<SelectItem value={CUSTOMER_LEVEL_VALUE}>
<span className="font-medium">Move to Customer</span>
</SelectItem>
)}
{/* Entity options */}
{filteredEntities.map((entity: Entity) => (
<SelectItem
key={entity.id || entity.internal_id}
value={entity.id}
>
<div className="flex gap-2 items-center min-w-0">
{entity.name && (
<span className="truncate max-w-[120px]">
{entity.name}
</span>
)}
<span className="truncate text-t3 font-mono text-xs">
{entity.id || entity.internal_id}
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<DialogFooter>
<Button
onClick={handleTransfer}
isLoading={loading}
disabled={!selectedValue}
>
Transfer
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@@ -8,7 +8,6 @@ import {
useProductStore,
} from "@/hooks/stores/useProductStore";
import { useSheetStore } from "@/hooks/stores/useSheetStore";
import { useAttachProductStore } from "@/hooks/stores/useSubscriptionStore";
import { pushPage } from "@/utils/genUtils";
import { useCusQuery } from "@/views/customers/customer/hooks/useCusQuery";
import { useCusProductQuery } from "@/views/customers/customer/product/hooks/useCusProductQuery";
@@ -24,9 +23,6 @@ export const CustomerPlanEditorBar = () => {
const { customer } = useCusQuery();
const hasChanges = useHasChanges();
const isLatestVersion = useIsLatestVersion(product);
const setCustomizedProduct = useAttachProductStore(
(s) => s.setCustomizedProduct,
);
const { type: sheetType } = useSheetStore();
const [queryStates, setQueryStates] = useQueryStates({
@@ -72,24 +68,17 @@ export const CustomerPlanEditorBar = () => {
{ history: "replace" },
);
};
if (sheetType) return null;
if (!changesMade) {
return <GoBackBar returnToCustomer={returnToCustomer} />;
}
// const selectedEntity = useSelectedEntity();
const handleSaveClicked = async () => {
setCustomizedProduct({
product,
customer_product_id: queryStates.id || null,
});
// Product is already in store, just navigate back
returnToCustomer();
};
if (sheetType) return null;
if (isLoading) return null;
// if (!hasChanges && activeVersion === product.version) {
// return null;
// }

View File

@@ -5,11 +5,9 @@ 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 {
useAttachProductStore,
useEntity,
} from "@/hooks/stores/useSubscriptionStore";
import { useEntity } from "@/hooks/stores/useSubscriptionStore";
import { pushPage } from "@/utils/genUtils";
import ErrorScreen from "@/views/general/ErrorScreen";
import LoadingScreen from "@/views/general/LoadingScreen";
@@ -37,7 +35,7 @@ export default function CustomerView2() {
const closeSheet = useCustomerBalanceSheetStore((s) => s.closeSheet);
const sheetType = useSheetStore((s) => s.type);
const closeProductSheet = useSheetStore((s) => s.closeSheet);
const customizedProduct = useAttachProductStore((s) => s.customizedProduct);
const hasChanges = useHasChanges();
// Close modal on mount
useEffect(() => {
@@ -123,10 +121,10 @@ export default function CustomerView2() {
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-background/90"
className="fixed inset-0 bg-background/60"
style={{ zIndex: 40 }}
onMouseDown={() => {
!customizedProduct && closeProductSheet();
!hasChanges && closeProductSheet();
}}
/>
)}

View File

@@ -67,10 +67,6 @@ export default function PlanEditorView() {
<ConfirmNewVersionDialog
open={showNewVersionDialog}
setOpen={setShowNewVersionDialog}
onVersionCreated={() => {
// Reset sheet when new version is created
setSheet({ type: "edit-plan" });
}}
/>
<PlanEditor />
</ProductContext.Provider>

View File

@@ -75,8 +75,8 @@ export const ProductSheets = () => {
return <NewFeatureSheet />;
case "select-feature":
return <SelectFeatureSheet />;
default:
return <EditPlanSheet />;
// default:
// return <EditPlanSheet />;
}
};