fix: preview for update prepaid quantity with trial

This commit is contained in:
John Yeo
2025-07-04 17:56:03 +01:00
parent 66e1ce87cd
commit 15e36b433a
25 changed files with 924 additions and 468 deletions

View File

@@ -11,6 +11,7 @@ if [ -z "$LOCALTUNNEL_RESERVED_KEY" ]; then
fi
echo "Installing localtunnel..."
echo "Reserved key: ${LOCALTUNNEL_RESERVED_KEY}"
npm install -g localtunnel

View File

@@ -14,7 +14,7 @@ import {
import { getAttachScenario } from "./attachToCheckPreview/getAttachScenario.js";
import { getProductResponse } from "@/internal/products/productUtils/productResponseUtils/getProductResponse.js";
import { isOneOff } from "@/internal/products/productUtils.js";
import { isFreeProduct, isOneOff } from "@/internal/products/productUtils.js";
import { formatAmount } from "@/utils/formatUtils.js";
import { Decimal } from "decimal.js";
import { notNullish } from "@/utils/genUtils.js";
@@ -49,6 +49,13 @@ export const attachToCheckPreview = async ({
// 1. If check
let attachFunc = preview.func;
if (
attachFunc == AttachFunction.AddProduct &&
isFreeProduct(product.prices)
) {
return null;
}
const noOptions = !preview.options || preview.options.length === 0;
if (attachFunc == AttachFunction.CreateCheckout && noOptions) {
return null;

View File

@@ -17,7 +17,10 @@ import { AttachConfig } from "@autumn/shared";
import { handleScheduleFunction } from "../attachFunctions/scheduleFlow/handleScheduleFunction.js";
import { handleUpdateQuantityFunction } from "../attachFunctions/updateQuantityFlow/updateQuantityFlow.js";
import { SuccessCode } from "@autumn/shared";
import { attachParamToCusProducts } from "./convertAttachParams.js";
import {
attachParamsToCurCusProduct,
attachParamToCusProducts,
} from "./convertAttachParams.js";
import { deleteCurrentScheduledProduct } from "./deleteCurrentScheduledProduct.js";
import { handleOneOffFunction } from "../attachFunctions/addProductFlow/handleOneOffFunction.js";
import { handleUpgradeSameInterval } from "../attachFunctions/upgradeSameIntFlow/handleUpgradeSameInt.js";
@@ -84,6 +87,10 @@ export const getAttachFunction = async ({
// 4. Prepaid scenarios
if (branch == AttachBranch.UpdatePrepaidQuantity) {
let curSameProduct = attachParamsToCurCusProduct({ attachParams });
if (curSameProduct?.free_trial) {
attachParams.freeTrial = curSameProduct.free_trial;
}
return AttachFunction.UpdatePrepaidQuantity;
}

View File

@@ -30,6 +30,7 @@ export const handleAttachPreview = (req: any, res: any) =>
});
res.status(200).json(attachPreview);
return;
// // Handle existing product

View File

@@ -1,8 +1,9 @@
import { CusProductStatus } from "@autumn/shared";
import { DrizzleCli } from "@/db/initDrizzle.js";
import { customerProducts } from "@autumn/shared";
import { eq, isNotNull, sql, countDistinct, count } from "drizzle-orm";
import { eq, isNotNull, sql, countDistinct, count, inArray } from "drizzle-orm";
const activeStatuses = [CusProductStatus.Active, CusProductStatus.PastDue];
export class CusProdReadService {
static getCounts = async ({
db,
@@ -14,16 +15,16 @@ export class CusProdReadService {
let result = await db
.select({
active: countDistinct(
sql`CASE WHEN ${eq(customerProducts.status, CusProductStatus.Active)} THEN ${customerProducts.internal_customer_id} END`,
sql`CASE WHEN ${inArray(customerProducts.status, activeStatuses)} THEN ${customerProducts.internal_customer_id} END`,
).as("active"),
canceled: count(
sql`CASE WHEN ${isNotNull(customerProducts.canceled_at)} AND ${eq(customerProducts.status, CusProductStatus.Active)} THEN 1 END`,
sql`CASE WHEN ${isNotNull(customerProducts.canceled_at)} AND ${inArray(customerProducts.status, activeStatuses)} THEN 1 END`,
).as("canceled"),
custom: count(
sql`CASE WHEN ${eq(customerProducts.is_custom, true)} AND ${eq(customerProducts.status, CusProductStatus.Active)} THEN 1 END`,
sql`CASE WHEN ${eq(customerProducts.is_custom, true)} AND ${inArray(customerProducts.status, activeStatuses)} THEN 1 END`,
).as("custom"),
trialing: count(
sql`CASE WHEN ${isNotNull(customerProducts.trial_ends_at)} AND ${sql`${customerProducts.trial_ends_at} > (EXTRACT(EPOCH FROM NOW()) * 1000)::bigint`} AND ${eq(customerProducts.status, CusProductStatus.Active)} THEN 1 END`,
sql`CASE WHEN ${isNotNull(customerProducts.trial_ends_at)} AND ${sql`${customerProducts.trial_ends_at} > (EXTRACT(EPOCH FROM NOW()) * 1000)::bigint`} AND ${inArray(customerProducts.status, activeStatuses)} THEN 1 END`,
).as("trialing"),
all: countDistinct(customerProducts.internal_customer_id).as("all"),
})

View File

@@ -15,6 +15,7 @@ import { getProration } from "./getItemsForNewProduct.js";
import { getCusPriceUsage } from "@/internal/customers/cusProducts/cusPrices/cusPriceUtils.js";
import { priceToUsageModel } from "@/internal/products/prices/priceUtils/convertPrice.js";
import { getContUseInvoiceItems } from "@/internal/customers/attach/attachUtils/getContUseItems/getContUseInvoiceItems.js";
import { isTrialing } from "@/internal/customers/cusProducts/cusProductUtils.js";
export const getItemsForCurProduct = async ({
stripeSubs,
@@ -36,6 +37,7 @@ export const getItemsForCurProduct = async ({
const curPrices = cusProductToPrices({ cusProduct: curCusProduct });
let items: PreviewLineItem[] = [];
let onTrial = isTrialing(curMainProduct!);
for (const sub of stripeSubs) {
for (const item of sub.items.data) {
@@ -54,7 +56,11 @@ export const getItemsForCurProduct = async ({
continue;
const totalAmountCents = getSubItemAmount({ subItem: item });
const totalAmount = new Decimal(totalAmountCents).div(100).toNumber();
let totalAmount = new Decimal(totalAmountCents).div(100).toNumber();
if (onTrial) {
totalAmount = 0;
}
const periodEnd = sub.current_period_end * 1000;

View File

@@ -148,7 +148,7 @@ export const getItemsForNewProduct = async ({
});
if (isFixedPrice({ price })) {
const amount = finalProration
let amount = finalProration
? calculateProrationAmount({
periodEnd: finalProration.end,
periodStart: finalProration.start,
@@ -157,6 +157,10 @@ export const getItemsForNewProduct = async ({
})
: getPriceForOverage(price, 0);
if (freeTrial) {
amount = 0;
}
let description = newPriceToInvoiceDescription({
org,
price,
@@ -221,56 +225,3 @@ export const getItemsForNewProduct = async ({
return items;
};
// if (
// billingType == BillingType.UsageInAdvance ||
// billingType == BillingType.InArrearProrated
// )
// continue;
// const usage = getExistingUsageFromCusProducts({
// entitlement: ent,
// cusProducts: attachParams.cusProducts,
// entities: attachParams.entities,
// carryExistingUsages: undefined,
// internalEntityId: attachParams.internalEntityId,
// });
// let description = newPriceToInvoiceDescription({
// org,
// price,
// product: newProduct,
// quantity: usage,
// });
// if (usage == 0) {
// items.push({
// price_id: price.id,
// price: getDefaultPriceStr({ org, price, ent, features }),
// amount: undefined,
// description,
// usage_model: priceToUsageModel(price),
// });
// } else {
// const overage = new Decimal(usage).sub(ent.allowance!).toNumber();
// const amount = finalProration
// ? calculateProrationAmount({
// periodEnd: finalProration.end,
// periodStart: finalProration.start,
// now,
// amount: getPriceForOverage(price, overage),
// })
// : getPriceForOverage(price, overage);
// if (proration) {
// description = `${description} (from ${formatUnixToDate(now)})`;
// }
// items.push({
// price_id: price.id,
// price: "",
// description,
// amount,
// usage_model: priceToUsageModel(price),
// });
// }

View File

@@ -17,7 +17,7 @@ export const getAttachScenario = ({
cusProducts: fullCus?.customer_products || [],
});
if (!curMainProduct) return AttachScenario.New;
if (!curMainProduct || fullProduct.is_add_on) return AttachScenario.New;
// 1. If current product is the same as the product, return active
if (curMainProduct?.product.id == fullProduct.id) {

View File

@@ -74,10 +74,20 @@ export const initCustomer = async ({
fingerprint,
};
// Create and delete customer
try {
let customer = await CusService.get({
db,
idOrInternalId: customerId,
orgId: org.id,
env: env,
});
if (customer) {
await autumn.customers.delete(customerId);
} catch (error) {}
}
// // Create and delete customer
// try {
// } catch (error) {}
let testClockId = null;
try {

View File

@@ -130,12 +130,14 @@ describe(`${chalk.yellowBright(`${testCase}: Testing upgrades with prepaid conti
quantity: 6,
},
];
it("should create entity, then upgrade to premium product (arrear prorated)", async function () {
curUnix = await advanceTestClock({
stripeCli,
testClockId,
advanceTo: addWeeks(curUnix, 1).getTime(),
});
return;
await runAttachTest({
autumn,
@@ -149,6 +151,7 @@ describe(`${chalk.yellowBright(`${testCase}: Testing upgrades with prepaid conti
});
});
return;
const proAnnualOpts = [
{
feature_id: TestFeature.Users,

View File

@@ -7,20 +7,8 @@ import { DrizzleCli } from "@/db/initDrizzle.js";
import { setupBefore } from "tests/before.js";
import { createProducts } from "tests/utils/productUtils.js";
import { addPrefixToProducts, runAttachTest } from "../utils.js";
import {
constructArrearItem,
constructArrearProratedItem,
} from "@/utils/scriptUtils/constructItem.js";
import { TestFeature } from "tests/setup/v2Features.js";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
import { expectAutumnError } from "tests/utils/expectUtils/expectErrUtils.js";
import { attachFailedPaymentMethod } from "@/external/stripe/stripeCusUtils.js";
import { CusService } from "@/internal/customers/CusService.js";
import { timeout } from "@/utils/genUtils.js";
import { expectProductAttached } from "tests/utils/expectUtils/expectProductAttached.js";
import { expectSubItemsCorrect } from "tests/utils/expectUtils/expectSubUtils.js";
import { expectFeaturesCorrect } from "tests/utils/expectUtils/expectFeaturesCorrect.js";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
const testCase = "upgrade7";

View File

@@ -65,8 +65,9 @@ export const expectInvoicesCorrect = ({
if (first) {
try {
expect(invoices![0].total).to.equal(
expect(invoices![0].total).to.approximately(
first.total,
0.01,
`invoice total is correct: ${first.total}`,
);

View File

@@ -10,7 +10,13 @@ export const getAttachTotal = ({
options?: any;
}) => {
const dueToday = preview?.due_today;
let total = new Decimal(dueToday?.total || 0);
let dueTodayTotal =
dueToday?.line_items.reduce((acc: any, item: any) => {
if (item.amount) {
return acc.plus(item.amount);
}
return acc;
}, new Decimal(0)) || new Decimal(0);
for (const option of options || []) {
let previewOption = preview?.options.find(
@@ -26,8 +32,8 @@ export const getAttachTotal = ({
.times(option.quantity)
.dividedBy(previewOption.billing_units);
total = total.plus(prepaidAmt);
dueTodayTotal = dueTodayTotal.plus(prepaidAmt);
}
return total.toDecimalPlaces(2).toNumber();
return dueTodayTotal.toDecimalPlaces(2).toNumber();
};

View File

@@ -0,0 +1,292 @@
"use client";
import React, { useEffect, useState } from "react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { ArrowRight, Loader2 } from "lucide-react";
import { type CheckProductPreview } from "autumn-js";
import {
Dialog,
DialogContent,
DialogFooter,
DialogTitle,
} from "@/components/ui/dialog";
import { getAttachContent } from "@/lib/autumn/attach-content";
import { useCustomer } from "autumn-js/react";
export interface AttachDialogProps {
open: boolean;
setOpen: (open: boolean) => void;
preview: CheckProductPreview;
onClick: (options?: any) => Promise<void>;
}
export default function AttachDialog(params?: AttachDialogProps) {
const { attach } = useCustomer();
const [loading, setLoading] = useState(false);
const [optionsInput, setOptionsInput] = useState<FeatureOption[]>(
params?.preview?.options || []
);
const getTotalPrice = () => {
let sum = due_today?.price || 0;
optionsInput.forEach((option) => {
if (option.price && option.quantity) {
sum += option.price * (option.quantity / option.billing_units);
}
});
return sum;
};
useEffect(() => {
setOptionsInput(params?.preview?.options || []);
}, [params?.preview?.options]);
if (!params || !params.preview) {
return <></>;
}
const { open, setOpen, preview } = params;
const { items, due_today } = preview;
const { title, message } = getAttachContent(preview);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent
className={cn(
"p-0 pt-4 gap-0 text-foreground overflow-hidden text-sm"
)}
>
<DialogTitle className={cn("px-6 mb-1 ")}>{title}</DialogTitle>
<div className={cn("px-6 mt-1 mb-4 text-muted-foreground")}>
{message}
</div>
{(items || optionsInput.length > 0) && (
<div className="mb-6 px-6">
{items?.map((item) => (
<PriceItem key={item.description}>
<span className="truncate flex-1">
{item.description}
</span>
<span>{item.price}</span>
</PriceItem>
))}
{optionsInput?.map((option, index) => {
return (
<OptionsInput
key={option.feature_name}
option={option as FeatureOptionWithRequiredPrice}
optionsInput={optionsInput}
setOptionsInput={setOptionsInput}
index={index}
/>
);
})}
</div>
)}
<DialogFooter className="flex flex-col sm:flex-row justify-between gap-x-4 py-2 pl-6 pr-3 bg-secondary border-t shadow-inner">
{due_today && (
<TotalPrice>
<span>Due Today</span>
<span>
{new Intl.NumberFormat("en-US", {
style: "currency",
currency: due_today.currency,
}).format(getTotalPrice())}
</span>
</TotalPrice>
)}
<Button
size="sm"
onClick={async () => {
setLoading(true);
await attach({
productId: preview.product_id,
options: optionsInput.map((option) => ({
featureId: option.feature_id,
quantity: option.quantity || 0,
})),
});
setOpen(false);
setLoading(false);
}}
disabled={loading}
className="min-w-16 flex items-center gap-2"
>
{loading ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<>
<span className="whitespace-nowrap flex gap-1">
Confirm
</span>
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export const PriceItem = ({
children,
className,
...props
}: {
children: React.ReactNode;
className?: string;
} & React.HTMLAttributes<HTMLDivElement>) => {
return (
<div
className={cn(
"flex flex-col pb-4 sm:pb-0 gap-1 sm:flex-row justify-between sm:h-7 sm:gap-2 sm:items-center",
className
)}
{...props}
>
{children}
</div>
);
};
interface FeatureOption {
feature_id: string;
feature_name: string;
billing_units: number;
price?: number;
quantity?: number;
}
interface FeatureOptionWithRequiredPrice
extends Omit<FeatureOption, "price" | "quantity"> {
price: number;
quantity: number;
}
export const OptionsInput = ({
className,
option,
optionsInput,
setOptionsInput,
index,
...props
}: {
className?: string;
option: FeatureOptionWithRequiredPrice;
optionsInput: FeatureOption[];
setOptionsInput: (options: FeatureOption[]) => void;
index: number;
} & React.HTMLAttributes<HTMLDivElement>) => {
const { feature_name, billing_units, quantity, price } = option;
return (
<PriceItem key={feature_name}>
<span>{feature_name}</span>
<QuantityInput
key={feature_name}
value={quantity ? quantity / billing_units : ""}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
const newOptions = [...optionsInput];
newOptions[index].quantity = parseInt(e.target.value) * billing_units;
setOptionsInput(newOptions);
}}
>
<span className="">
× ${price} per {billing_units === 1 ? " " : billing_units}{" "}
{feature_name}
</span>
</QuantityInput>
</PriceItem>
);
};
export const QuantityInput = ({
children,
onChange,
value,
className,
...props
}: {
children: React.ReactNode;
value: string | number;
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
className?: string;
} & React.HTMLAttributes<HTMLDivElement>) => {
const currentValue = Number(value) || 0;
const handleValueChange = (newValue: number) => {
const syntheticEvent = {
target: { value: String(newValue) },
} as React.ChangeEvent<HTMLInputElement>;
onChange(syntheticEvent);
};
return (
<div
className={cn(className, "flex flex-row items-center gap-4")}
{...props}
>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="icon"
onClick={() =>
currentValue > 0 && handleValueChange(currentValue - 1)
}
disabled={currentValue <= 0}
className="h-6 w-6 pb-0.5"
>
-
</Button>
<span className="w-8 text-center text-foreground">
{currentValue}
</span>
<Button
variant="outline"
size="icon"
onClick={() => handleValueChange(currentValue + 1)}
className="h-6 w-6 pb-0.5"
>
+
</Button>
</div>
{children}
</div>
);
};
export const TotalPrice = ({ children }: { children: React.ReactNode }) => {
return (
<div className="w-full font-semibold flex justify-between items-center">
{children}
</div>
);
};
export const PricingDialogButton = ({
children,
size,
onClick,
disabled,
className,
}: {
children: React.ReactNode;
size?: "sm" | "lg" | "default" | "icon";
onClick: () => void;
disabled?: boolean;
className?: string;
}) => {
return (
<Button
onClick={onClick}
disabled={disabled}
size={size}
className={cn(className, "shadow-sm shadow-stone-400")}
>
{children}
<ArrowRight className="!h-3" />
</Button>
);
};

View File

@@ -1,19 +1,22 @@
import {
PricingCard,
PricingTable as PricecnPricingTable,
} from "@/components/pricing/pricing-table";
import { Loader2 } from "lucide-react";
import React from "react";
import { useCustomer, usePricingTable } from "autumn-js/react";
import { createContext, useContext, useState } from "react";
import { cn } from "@/lib/utils";
import { Switch } from "@/components/ui/switch";
import { Button } from "@/components/ui/button";
import { Check, Loader2 } from "lucide-react";
import AttachDialog from "@/components/autumn/attach-dialog";
import { getPricingTableContent } from "@/lib/autumn/pricing-table-content";
import { Product, ProductItem } from "autumn-js";
import { useAutumn, usePricingTable } from "autumn-js/react";
import { useEnv } from "@/utils/envUtils";
import { useAxiosInstance } from "@/services/useAxiosInstance";
import ProductChangeDialog from "./product-change-dialog";
import { toast } from "sonner";
export const PricingTable = () => {
const { attach } = useAutumn();
const { products, isLoading, error } = usePricingTable();
const axiosInstance = useAxiosInstance();
export default function PricingTable({
productDetails,
}: {
productDetails?: any;
}) {
const { attach } = useCustomer();
const [isAnnual, setIsAnnual] = useState(false);
const { products, isLoading, error } = usePricingTable({ productDetails });
if (isLoading) {
return (
@@ -27,29 +30,367 @@ export const PricingTable = () => {
return <div> Something went wrong...</div>;
}
const intervals = Array.from(
new Set(
products?.map((p) => p.properties?.interval_group).filter((i) => !!i),
),
);
const multiInterval = intervals.length > 1;
const intervalFilter = (product: any) => {
if (!product.properties?.interval_group) {
return true;
}
if (multiInterval) {
if (isAnnual) {
return product.properties?.interval_group === "year";
} else {
return product.properties?.interval_group === "month";
}
}
return true;
};
return (
<div>
<div className={cn("root")}>
{products && (
<PricecnPricingTable products={products}>
{products.map((product) => (
<PricingTableContainer
products={products as any}
isAnnualToggle={isAnnual}
setIsAnnualToggle={setIsAnnual}
multiInterval={multiInterval}
>
{products.filter(intervalFilter).map((product, index) => (
<PricingCard
key={index}
productId={product.id}
key={product.id}
buttonProps={{
disabled:
product.scenario === "active" ||
product.scenario === "scheduled",
onClick: async () => {
const { data, error } = await attach({
productId: product.id,
dialog: ProductChangeDialog,
});
if (error) {
toast.error(error.message);
if (product.id) {
await attach({
productId: product.id,
dialog: AttachDialog,
openInNewTab: true,
successUrl: window.location.href,
});
} else if (product.display?.button_url) {
window.open(product.display?.button_url, "_blank");
}
},
}}
/>
))}
</PricecnPricingTable>
</PricingTableContainer>
)}
</div>
);
}
const PricingTableContext = createContext<{
isAnnualToggle: boolean;
setIsAnnualToggle: (isAnnual: boolean) => void;
products: Product[];
showFeatures: boolean;
}>({
isAnnualToggle: false,
setIsAnnualToggle: () => {},
products: [],
showFeatures: true,
});
export const usePricingTableContext = (componentName: string) => {
const context = useContext(PricingTableContext);
if (context === undefined) {
throw new Error(`${componentName} must be used within <PricingTable />`);
}
return context;
};
export const PricingTableContainer = ({
children,
products,
showFeatures = true,
className,
isAnnualToggle,
setIsAnnualToggle,
multiInterval,
}: {
children?: React.ReactNode;
products?: Product[];
showFeatures?: boolean;
className?: string;
isAnnualToggle: boolean;
setIsAnnualToggle: (isAnnual: boolean) => void;
multiInterval: boolean;
}) => {
if (!products) {
throw new Error("products is required in <PricingTable />");
}
if (products.length === 0) {
return <></>;
}
const hasRecommended = products?.some((p) => p.display?.recommend_text);
return (
<PricingTableContext.Provider
value={{ isAnnualToggle, setIsAnnualToggle, products, showFeatures }}
>
<div
className={cn("flex items-center flex-col", hasRecommended && "!py-10")}
>
{multiInterval && (
<div
className={cn(
products.some((p) => p.display?.recommend_text) && "mb-8",
)}
>
<AnnualSwitch
isAnnualToggle={isAnnualToggle}
setIsAnnualToggle={setIsAnnualToggle}
/>
</div>
)}
<div
className={cn(
"grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-[repeat(auto-fit,minmax(200px,1fr))] w-full gap-2",
className,
)}
>
{children}
</div>
</div>
</PricingTableContext.Provider>
);
};
interface PricingCardProps {
productId: string;
showFeatures?: boolean;
className?: string;
onButtonClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
buttonProps?: React.ComponentProps<"button">;
}
export const PricingCard = ({
productId,
className,
buttonProps,
}: PricingCardProps) => {
const { products, showFeatures } = usePricingTableContext("PricingCard");
const product = products.find((p) => p.id === productId);
if (!product) {
throw new Error(`Product with id ${productId} not found`);
}
const { name, display: productDisplay, items } = product;
const { buttonText } = getPricingTableContent(product);
const isRecommended = productDisplay?.recommend_text ? true : false;
const mainPriceDisplay = product.properties?.is_free
? {
primary_text: "Free",
}
: product.items[0].display;
const featureItems = product.properties?.is_free
? product.items
: product.items.slice(1);
return (
<div
className={cn(
" w-full h-full py-6 text-foreground border rounded-lg shadow-sm max-w-xl",
isRecommended &&
"lg:-translate-y-6 lg:shadow-lg dark:shadow-zinc-800/80 lg:h-[calc(100%+48px)] bg-secondary/40",
className,
)}
>
{productDisplay?.recommend_text && (
<RecommendedBadge recommended={productDisplay?.recommend_text} />
)}
<div
className={cn(
"flex flex-col h-full flex-grow",
isRecommended && "lg:translate-y-6",
)}
>
<div className="h-full">
<div className="flex flex-col">
<div className="pb-4">
<h2 className="text-2xl font-semibold px-6 truncate">
{productDisplay?.name || name}
</h2>
{productDisplay?.description && (
<div className="text-sm text-muted-foreground px-6 h-8">
<p className="line-clamp-2">{productDisplay?.description}</p>
</div>
)}
</div>
<div className="mb-2">
<h3 className="font-semibold h-16 flex px-6 items-center border-y mb-4 bg-secondary/40">
<div className="line-clamp-2">
{mainPriceDisplay?.primary_text}{" "}
{mainPriceDisplay?.secondary_text && (
<span className="font-normal text-muted-foreground mt-1">
{mainPriceDisplay?.secondary_text}
</span>
)}
</div>
</h3>
</div>
</div>
{showFeatures && featureItems.length > 0 && (
<div className="flex-grow px-6 mb-6">
<PricingFeatureList
items={featureItems}
showIcon={true}
everythingFrom={product.display?.everything_from}
/>
</div>
)}
</div>
<div className={cn(" px-6 ", isRecommended && "lg:-translate-y-12")}>
<PricingCardButton
recommended={productDisplay?.recommend_text ? true : false}
{...buttonProps}
>
{buttonText}
</PricingCardButton>
</div>
</div>
</div>
);
};
// Pricing Feature List
export const PricingFeatureList = ({
items,
showIcon = true,
everythingFrom,
className,
}: {
items: ProductItem[];
showIcon?: boolean;
everythingFrom?: string;
className?: string;
}) => {
return (
<div className={cn("flex-grow", className)}>
{everythingFrom && (
<p className="text-sm mb-4">Everything from {everythingFrom}, plus:</p>
)}
<div className="space-y-3">
{items.map((item, index) => (
<div key={index} className="flex items-start gap-2 text-sm">
{showIcon && (
<Check className="h-4 w-4 text-primary flex-shrink-0 mt-0.5" />
)}
<div className="flex flex-col">
<span>{item.display?.primary_text}</span>
{item.display?.secondary_text && (
<span className="text-sm text-muted-foreground">
{item.display?.secondary_text}
</span>
)}
</div>
</div>
))}
</div>
</div>
);
};
// Pricing Card Button
export interface PricingCardButtonProps extends React.ComponentProps<"button"> {
recommended?: boolean;
buttonUrl?: string;
}
export const PricingCardButton = React.forwardRef<
HTMLButtonElement,
PricingCardButtonProps
>(({ recommended, children, className, onClick, ...props }, ref) => {
const [loading, setLoading] = useState(false);
const handleClick = async (e: React.MouseEvent<HTMLButtonElement>) => {
setLoading(true);
try {
await onClick?.(e);
} catch (error) {
console.error(error);
} finally {
setLoading(false);
}
};
return (
<Button
className={cn(
"w-full py-3 px-4 group overflow-hidden relative transition-all duration-300 hover:brightness-90 border rounded-lg",
className,
)}
{...props}
variant={recommended ? "default" : "secondary"}
ref={ref}
disabled={loading || props.disabled}
onClick={handleClick}
>
{loading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
<div className="flex items-center justify-between w-full transition-transform duration-300 group-hover:translate-y-[-130%]">
<span>{children}</span>
<span className="text-sm"></span>
</div>
<div className="flex items-center justify-between w-full absolute px-4 translate-y-[130%] transition-transform duration-300 group-hover:translate-y-0 mt-2 group-hover:mt-0">
<span>{children}</span>
<span className="text-sm"></span>
</div>
</>
)}
</Button>
);
});
PricingCardButton.displayName = "PricingCardButton";
// Annual Switch
export const AnnualSwitch = ({
isAnnualToggle,
setIsAnnualToggle,
}: {
isAnnualToggle: boolean;
setIsAnnualToggle: (isAnnual: boolean) => void;
}) => {
return (
<div className="flex items-center space-x-2 mb-4">
<span className="text-sm text-muted-foreground">Monthly</span>
<Switch
id="annual-billing"
checked={isAnnualToggle}
onCheckedChange={setIsAnnualToggle}
/>
<span className="text-sm text-muted-foreground">Annual</span>
</div>
);
};
export const RecommendedBadge = ({ recommended }: { recommended: string }) => {
return (
<div className="bg-secondary absolute border text-muted-foreground text-sm font-medium lg:rounded-full px-3 lg:py-0.5 lg:top-4 lg:right-4 top-[-1px] right-[-1px] rounded-bl-lg">
{recommended}
</div>
);
};

View File

@@ -1,330 +0,0 @@
"use client";
import React from "react";
import { createContext, useContext, useState } from "react";
import { cn } from "@/lib/utils";
import { Switch } from "@/components/ui/switch";
import { Button } from "@/components/ui/button";
import { Check, Loader2 } from "lucide-react";
// Update Product interface to match dev/classic
export interface Product {
id: string;
name: string;
description?: string;
everythingFrom?: string;
buttonText?: string;
buttonUrl?: string;
recommendedText?: string;
price: {
primaryText: string;
secondaryText?: string;
};
priceAnnual?: {
primaryText: string;
secondaryText?: string;
};
items: {
primaryText: string;
secondaryText?: string;
}[];
}
// Update context to include showFeatures
const PricingTableContext = createContext<{
isAnnual: boolean;
setIsAnnual: (isAnnual: boolean) => void;
products: Product[];
showFeatures: boolean;
uniform: boolean;
}>({
isAnnual: false,
setIsAnnual: () => {},
products: [],
showFeatures: true,
uniform: false,
});
export const usePricingTableContext = (componentName: string) => {
const context = useContext(PricingTableContext);
if (context === undefined) {
throw new Error(`${componentName} must be used within <PricingTable />`);
}
return context;
};
export const PricingTable = ({
children,
products,
showFeatures = true,
className,
uniform = false,
}: {
children?: React.ReactNode;
products?: Product[];
showFeatures?: boolean;
className?: string;
uniform?: boolean;
}) => {
const [isAnnual, setIsAnnual] = useState(false);
if (!products) {
throw new Error("products is required in <PricingTable />");
}
return (
<PricingTableContext.Provider
value={{ isAnnual, setIsAnnual, products, showFeatures, uniform }}
>
<div className={cn("flex items-center flex-col")}>
{products.some((p) => p.priceAnnual) && (
<div
className={cn(
products.some((p) => p.recommendedText) && !uniform && "mb-8",
)}
>
<AnnualSwitch isAnnual={isAnnual} setIsAnnual={setIsAnnual} />
</div>
)}
<div
className={cn(
"grid grid-cols-[repeat(auto-fit,minmax(200px,1fr))] auto-rows-fr gap-4 w-full",
className,
)}
>
{children}
</div>
</div>
</PricingTableContext.Provider>
);
};
interface PricingCardProps {
productId: string;
className?: string;
onButtonClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
buttonProps?: React.ComponentProps<"button">;
}
export const PricingCard = ({
productId,
className,
onButtonClick,
buttonProps,
}: PricingCardProps) => {
const { isAnnual, products, showFeatures, uniform } =
usePricingTableContext("PricingCard");
const product = products.find((p) => p.id === productId);
if (!product) {
throw new Error(`Product with id ${productId} not found`);
}
const {
name,
price,
priceAnnual,
recommendedText,
buttonText,
items,
description,
buttonUrl,
everythingFrom,
} = product;
const isRecommended = recommendedText ? true : false;
return (
<div
className={cn(
"border rounded-md bg-background w-full h-full pt-6 text-foreground",
recommendedText &&
!uniform &&
"shadow-xl border-primary/30 lg:-translate-y-6 lg:h-[calc(100%+48px)] relative",
className,
)}
>
{recommendedText && !uniform && (
<RecommendedBadge recommended={recommendedText} />
)}
<div
className={cn(
"px-6",
recommendedText && !uniform && "lg:translate-y-6",
)}
>
<div className="flex flex-col gap-2 ">
<h2 className="text-sm font-medium uppercase">{name}</h2>
{description && (
<span className="text-sm h-14 line-clamp-3">{description}</span>
)}
<div className="flex flex-col">
<h3 className="font-semibold flex items-center text-3xl mb-1 ">
{isAnnual && priceAnnual
? priceAnnual?.primaryText
: price.primaryText}{" "}
</h3>
{price.secondaryText && (
<span className="font-normal text-muted-foreground text-sm pb-4 h-10 line-clamp-2">
{isAnnual && priceAnnual
? priceAnnual?.secondaryText
: price.secondaryText}
</span>
)}
</div>
<div className={cn(" mb-6 ")}>
<PricingCardButton
recommended={isRecommended}
buttonUrl={buttonUrl}
onClick={onButtonClick}
{...buttonProps}
>
{buttonText}
</PricingCardButton>
</div>
</div>
{showFeatures && items.length > 0 && (
<div className="flex-grow">
<PricingFeatureList
items={items}
showIcon={true}
everythingFrom={everythingFrom}
/>
</div>
)}
</div>
</div>
);
};
// Pricing Feature List
export const PricingFeatureList = ({
items,
showIcon = true,
everythingFrom,
className,
}: {
items: {
primaryText: string;
secondaryText?: string;
}[];
showIcon?: boolean;
everythingFrom?: string;
className?: string;
}) => {
return (
<div className={cn("pb-6 flex-grow", className)}>
{everythingFrom && (
<p className="text-sm mb-4">Everything from {everythingFrom}, plus:</p>
)}
<div className="space-y-3">
{items.map((item, index) => (
<div key={index} className="flex items-start gap-2 text-sm">
{showIcon && (
<Check className="h-4 w-4 text-primary flex-shrink-0 mt-0.5" />
)}
<div className="flex flex-col">
<span>{item.primaryText}</span>
{item.secondaryText && (
<span className="text-sm text-muted-foreground">
{item.secondaryText}
</span>
)}
</div>
</div>
))}
</div>
</div>
);
};
// Pricing Card Button
export interface PricingCardButtonProps extends React.ComponentProps<"button"> {
recommended?: boolean;
buttonUrl?: string;
}
export const PricingCardButton = React.forwardRef<
HTMLButtonElement,
PricingCardButtonProps
>(({ recommended, children, buttonUrl, onClick, className, ...props }, ref) => {
const [loading, setLoading] = useState(false);
return (
<Button
className={cn(
"w-full py-3 px-4 rounded-md group overflow-hidden relative transition-all duration-300 hover:brightness-90",
className,
)}
variant={recommended ? "default" : "secondary"}
ref={ref}
disabled={loading}
onClick={async (e) => {
if (buttonUrl) {
window.open(buttonUrl, "_blank");
return;
}
if (onClick) {
setLoading(true);
await onClick(e);
setLoading(false);
}
}}
{...props}
>
{loading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
{" "}
<div className="flex items-center justify-between w-full transition-transform duration-300 group-hover:translate-y-[-130%]">
<span>{children}</span>
<span className="text-sm"></span>
</div>
<div className="flex items-center justify-between w-full absolute px-4 translate-y-[130%] transition-transform duration-300 group-hover:translate-y-0 mt-2 group-hover:mt-0">
<span>{children}</span>
<span className="text-sm"></span>
</div>
</>
)}
</Button>
);
});
PricingCardButton.displayName = "PricingCardButton";
// Annual Switch
export const AnnualSwitch = ({
isAnnual,
setIsAnnual,
}: {
isAnnual: boolean;
setIsAnnual: (isAnnual: boolean) => void;
}) => {
return (
<div className="flex items-center space-x-2 mb-4">
<span className="text-sm text-muted-foreground">Monthly</span>
<Switch
id="annual-billing"
checked={isAnnual}
onCheckedChange={setIsAnnual}
/>
<span className="text-sm text-muted-foreground">Annual</span>
</div>
);
};
export const RecommendedBadge = ({ recommended }: { recommended: string }) => {
return (
<div className="bg-primary absolute w-fit border text-primary-foreground flex items-center justify-center text-xs uppercase font-medium lg:rounded-full px-3 py-0.5 lg:top-3 lg:right-3 -top-[1px] -right-[1px] rounded-bl-md">
{recommended}
</div>
);
};

View File

@@ -0,0 +1,108 @@
import { type CheckProductPreview } from "autumn-js";
export const getAttachContent = (preview: CheckProductPreview) => {
const {
scenario,
product_name,
recurring,
current_product_name,
next_cycle_at,
} = preview;
const nextCycleAtStr = next_cycle_at
? new Date(next_cycle_at).toLocaleDateString()
: undefined;
switch (scenario) {
case "scheduled":
return {
title: <p>{product_name} product already scheduled</p>,
message: (
<p>
You are currently on product {current_product_name} and are
scheduled to start {product_name} on {nextCycleAtStr}.
</p>
),
};
case "active":
return {
title: <p>Product already active</p>,
message: <p>You are already subscribed to this product.</p>,
};
case "new":
if (recurring) {
return {
title: <p>Subscribe to {product_name}</p>,
message: (
<p>
By clicking confirm, you will be subscribed to {product_name} and
your card will be charged immediately.
</p>
),
};
} else {
return {
title: <p>Purchase {product_name}</p>,
message: (
<p>
By clicking confirm, you will purchase {product_name} and your
card will be charged immediately.
</p>
),
};
}
case "renew":
return {
title: <p>Renew</p>,
message: (
<p>
By clicking confirm, you will renew your subscription to{" "}
{product_name}.
</p>
),
};
case "upgrade":
return {
title: <p>Upgrade to {product_name}</p>,
message: (
<p>
By clicking confirm, you will upgrade to {product_name} and your
payment method will be charged immediately.
</p>
),
};
case "downgrade":
return {
title: <p>Downgrade to {product_name}</p>,
message: (
<p>
By clicking confirm, your current subscription to{" "}
{current_product_name} will be cancelled and a new subscription to{" "}
{product_name} will begin on {nextCycleAtStr}.
</p>
),
};
case "cancel":
return {
title: <p>Cancel</p>,
message: (
<p>
By clicking confirm, your subscription to {current_product_name}{" "}
will end on {nextCycleAtStr}.
</p>
),
};
default:
return {
title: <p>Change Subscription</p>,
message: <p>You are about to change your subscription.</p>,
};
}
};

View File

@@ -0,0 +1,59 @@
import { type CheckProductPreview } from "autumn-js";
export const getPricingTableContent = (product: any) => {
const { scenario, free_trial } = product;
if (free_trial && free_trial.trial_available) {
return {
buttonText: <p>Start Free Trial</p>,
};
}
switch (scenario) {
case "scheduled":
return {
buttonText: <p>Plan Scheduled</p>,
};
case "active":
return {
buttonText: <p>Current Plan</p>,
};
case "new":
if (product.properties?.is_one_off) {
return {
buttonText: <p>Purchase</p>,
};
} else {
return {
buttonText: <p>Get started</p>,
};
}
case "renew":
return {
buttonText: <p>Renew</p>,
};
case "upgrade":
return {
buttonText: <p>Upgrade</p>,
};
case "downgrade":
return {
buttonText: <p>Downgrade</p>,
};
case "cancel":
return {
buttonText: <p>Cancel Plan</p>,
};
default:
return {
buttonText: <p>Get Started</p>,
};
}
};

View File

@@ -67,7 +67,7 @@ export const columns: OrgColumnDef[] = [
const value = row.getValue("createdAt");
return (
<span className="w-30">
{format(new Date(value as string), "dd MMM hh:mm")}
{format(new Date(value as string), "dd MMM HH:mm")}
</span>
);
},

View File

@@ -67,9 +67,10 @@ export const columns: UserColumnDef[] = [
width: 150,
cell: ({ row }: { row: Row<User> }) => {
const value = row.getValue("createdAt");
return (
<span className="w-30">
{format(new Date(value as string), "dd MMM hh:mm")}
{format(new Date(value as string), "dd MMM HH:mm")}
</span>
);
},

View File

@@ -3,36 +3,19 @@
import ProductSidebar from "@/views/products/product/ProductSidebar";
import LoadingScreen from "@/views/general/LoadingScreen";
import { useState, useEffect, useRef } from "react";
import {
Customer,
Entity,
Feature,
FeatureOptions,
ProductItem,
ProductV2,
} from "@autumn/shared";
import { Customer, Entity, Feature, ProductItem } from "@autumn/shared";
import { useAxiosSWR } from "@/services/useAxiosSwr";
import { useAxiosInstance } from "@/services/useAxiosInstance";
import { CustomToaster } from "@/components/general/CustomToaster";
import { ManageProduct } from "@/views/products/product/ManageProduct";
import { ProductContext } from "@/views/products/product/ProductContext";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Link, useNavigate, useParams, useSearchParams } from "react-router";
import { Link, useParams, useSearchParams } from "react-router";
import ErrorScreen from "@/views/general/ErrorScreen";
import { ProductOptions } from "./ProductOptions";
import { keyToTitle } from "@/utils/formatUtils/formatTextUtils";
import { useEnv } from "@/utils/envUtils";
import { FeaturesContext } from "@/views/features/FeaturesContext";
import { CustomerProductBreadcrumbs } from "./components/CustomerProductBreadcrumbs";
import { FrontendProduct, useAttachState } from "./hooks/useAttachState";
import { sortProductItems } from "@/utils/productUtils";
interface OptionValue {
feature_id: string;
@@ -105,7 +88,10 @@ export default function CustomerProductView() {
const product = data.product;
setProduct(product);
initialProductRef.current = structuredClone(product);
initialProductRef.current = structuredClone({
...product,
items: sortProductItems(product.items),
});
setEntityFeatureIds(
Array.from(

View File

@@ -25,7 +25,11 @@ export const DueNextCycle = () => {
if (!preview.due_next_cycle) return null;
if (!preview.due_next_cycle.line_items?.length) return null;
if (
(!preview.due_next_cycle.line_items?.length && !preview.options?.length) ||
preview.options.every((option: any) => option.full_price == option.price)
)
return null;
return (
<div className="flex flex-col">

View File

@@ -23,7 +23,14 @@ export const DueToday = () => {
const branch = preview.branch;
const getTotalPrice = () => {
let total = preview?.due_today?.total || 0;
let total =
preview?.due_today?.line_items.reduce((acc: any, item: any) => {
if (item.amount) {
return acc.plus(item.amount);
}
return acc;
}, new Decimal(0)) || new Decimal(0);
total = total.toNumber();
options.forEach((option: any) => {
if (option.price && option.quantity) {

View File

@@ -109,14 +109,17 @@ export const useAttachState = ({
free_trial: initialProductRef.current?.free_trial || null,
});
console.log(
"Initial product ref",
JSON.stringify(initialProductRef.current, null, 2),
);
console.log("Sorted product", JSON.stringify(sortedProduct, null, 2));
setItemsChanged(hasItemsChanged);
}, [product]);
useEffect(() => {
console.log("Initial product ref", initialProductRef.current);
// // Reset the ref on component mount
// initialProductRef.current = null;
// setItemsChanged(false);
}, []);
const getButtonDisabled = () => {
@@ -158,6 +161,8 @@ export const useAttachState = ({
};
const getButtonText = () => {
console.log("Is prepaid:", flags.hasPrepaid);
console.log("Items changed:", itemsChanged);
if (product?.isActive && !itemsChanged) {
if (flags.isOneOff) {
return "Attach Product";

View File

@@ -16,7 +16,8 @@ import { toast } from "sonner";
import { useSearchParams } from "react-router";
import { useCustomer, PricingTable, CheckDialog } from "autumn-js/react";
import { useCustomer, CheckDialog } from "autumn-js/react";
import PricingTable from "@/components/autumn/pricing-table";
import {
Check,
Lock,