feat: upgrade update subscription plan edit

This commit is contained in:
Charlie Lamb
2026-01-16 11:23:29 +00:00
parent 56a0b652af
commit 5df60c675e
15 changed files with 755 additions and 203 deletions

View File

@@ -0,0 +1,17 @@
interface CompactValueChangeProps {
oldValue: string | number | null;
newValue: string | number | null;
}
export function CompactValueChange({
oldValue,
newValue,
}: CompactValueChangeProps) {
return (
<span className="text-xs flex items-center gap-1">
<span className="text-red-500">{oldValue}</span>
<span className="text-t3"></span>
<span className="text-green-500">{newValue}</span>
</span>
);
}

View File

@@ -5,114 +5,55 @@ import type {
ProductV2,
} from "@autumn/shared";
import { featureToOptions, UsageModel } from "@autumn/shared";
import { InfoIcon, PencilSimpleIcon } from "@phosphor-icons/react";
import { PencilSimpleIcon } from "@phosphor-icons/react";
import { Button } from "@/components/v2/buttons/Button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/v2/selects/Select";
import { SheetSection } from "@/components/v2/sheets/SharedSheetComponents";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/v2/tooltips/Tooltip";
import { BasePriceDisplay } from "@/views/products/plan/components/plan-card/BasePriceDisplay";
import { PlanFeatureRow } from "@/views/products/plan/components/plan-card/PlanFeatureRow";
import type { UseUpdateSubscriptionForm } from "../hooks/useUpdateSubscriptionForm";
import { buildEditsForItem } from "../utils/buildEditsForItem";
import { SectionTitle } from "./SectionTitle";
import { SubscriptionItemRow } from "./SubscriptionItemRow";
interface EditPlanSectionProps {
hasCustomizations: boolean;
onEditPlan: () => void;
product?: ProductV2;
originalItems?: ProductItem[];
customerProduct?: FullCusProduct;
features?: Feature[];
form?: UseUpdateSubscriptionForm;
numVersions?: number;
currentVersion?: number;
}
function SectionTitle({
hasCustomizations,
form,
numVersions,
currentVersion,
}: {
hasCustomizations: boolean;
form?: UseUpdateSubscriptionForm;
numVersions?: number;
currentVersion?: number;
}) {
const showVersionSelector =
form && numVersions !== undefined && numVersions > 1;
const versionOptions = showVersionSelector
? Array.from(
{ length: numVersions },
(_, index) => numVersions - index,
).map((version) => ({
label: `Version ${version}`,
value: String(version),
}))
: [];
return (
<span className="flex items-center justify-between w-full gap-2">
<span className="flex items-center gap-1.5">
Plan Configuration
{hasCustomizations && (
<Tooltip>
<TooltipTrigger asChild>
<InfoIcon
size={14}
weight="fill"
className="text-amber-500 cursor-help"
/>
</TooltipTrigger>
<TooltipContent side="top">
This subscription's configuration was edited. See changes below.
</TooltipContent>
</Tooltip>
)}
</span>
{showVersionSelector && (
<form.AppField name="version">
{(field) => (
<Select
value={String(field.state.value ?? currentVersion)}
onValueChange={(value) => field.handleChange(Number(value))}
>
<SelectTrigger className="w-fit h-7 text-xs whitespace-nowrap">
<SelectValue />
</SelectTrigger>
<SelectContent>
{versionOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</form.AppField>
)}
</span>
);
prepaidOptions?: Record<string, number>;
initialPrepaidOptions?: Record<string, number>;
}
export function EditPlanSection({
hasCustomizations,
onEditPlan,
product,
originalItems,
customerProduct,
features,
form,
numVersions,
currentVersion,
prepaidOptions = {},
initialPrepaidOptions = {},
}: EditPlanSectionProps) {
const originalItemsMap = new Map(
originalItems?.filter((i) => i.feature_id).map((i) => [i.feature_id, i]) ??
[],
);
const currentFeatureIds = new Set(
product?.items?.map((i) => i.feature_id).filter(Boolean) ?? [],
);
const deletedItems =
originalItems?.filter(
(i) => i.feature_id && !currentFeatureIds.has(i.feature_id),
) ?? [];
return (
<SheetSection
title={
@@ -125,39 +66,64 @@ export function EditPlanSection({
}
withSeparator
>
{product?.items && product.items.length > 0 && (
{(product?.items?.length ?? 0) > 0 || deletedItems.length > 0 ? (
<>
<div className="flex gap-2 justify-between items-center h-6 mb-3">
<BasePriceDisplay product={product} readOnly={true} />
</div>
<div className="space-y-2 mb-4">
{product.items.map((item: ProductItem, index: number) => {
{product?.items?.map((item: ProductItem, index: number) => {
if (!item.feature_id) return null;
const feature = features?.find((f) => f.id === item.feature_id);
const featureId = item.feature_id;
const featureForOptions = features?.find(
(f) => f.id === featureId,
);
const prepaidOption = featureToOptions({
feature,
feature: featureForOptions,
options: customerProduct?.options,
});
const prepaidQuantity =
item.usage_model === UsageModel.Prepaid
? prepaidOption?.quantity
: null;
const isPrepaid = item.usage_model === UsageModel.Prepaid;
const currentPrepaidQuantity = isPrepaid
? prepaidOptions[featureId]
: prepaidOption?.quantity;
const initialPrepaidQuantity = isPrepaid
? initialPrepaidOptions[featureId]
: undefined;
const originalItem = originalItemsMap.get(featureId);
const isCreated =
!originalItem && originalItems && originalItems.length > 0;
const edits = buildEditsForItem({
item,
originalItem,
prepaidQuantity: currentPrepaidQuantity,
initialPrepaidQuantity,
});
return (
<PlanFeatureRow
key={item.feature_id || item.price_id || index}
<SubscriptionItemRow
key={featureId || item.price_id || index}
item={item}
index={index}
readOnly={true}
prepaidQuantity={prepaidQuantity}
edits={edits}
prepaidQuantity={currentPrepaidQuantity}
form={form}
featureId={featureId}
isCreated={isCreated}
/>
);
})}
{deletedItems.map((item: ProductItem, index: number) => (
<SubscriptionItemRow
key={`deleted-${item.feature_id || index}`}
item={item}
isDeleted
/>
))}
</div>
</>
)}
) : null}
<Button variant="secondary" onClick={onEditPlan} className="w-full">
<PencilSimpleIcon size={14} className="mr-1" />
Edit Plan Items

View File

@@ -0,0 +1,84 @@
import { InfoIcon } from "@phosphor-icons/react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/v2/selects/Select";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/v2/tooltips/Tooltip";
import type { UseUpdateSubscriptionForm } from "../hooks/useUpdateSubscriptionForm";
interface SectionTitleProps {
hasCustomizations: boolean;
form?: UseUpdateSubscriptionForm;
numVersions?: number;
currentVersion?: number;
}
export function SectionTitle({
hasCustomizations,
form,
numVersions,
currentVersion,
}: SectionTitleProps) {
const showVersionSelector =
form && numVersions !== undefined && numVersions > 1;
const versionOptions = showVersionSelector
? Array.from(
{ length: numVersions },
(_, index) => numVersions - index,
).map((version) => ({
label: `Version ${version}`,
value: String(version),
}))
: [];
return (
<span className="flex items-center justify-between w-full gap-2">
<span className="flex items-center gap-1.5">
Plan Configuration
{hasCustomizations && (
<Tooltip>
<TooltipTrigger asChild>
<InfoIcon
size={14}
weight="fill"
className="text-amber-500 cursor-help"
/>
</TooltipTrigger>
<TooltipContent side="top">
This subscription's configuration was edited. See changes below.
</TooltipContent>
</Tooltip>
)}
</span>
{showVersionSelector && (
<form.AppField name="version">
{(field) => (
<Select
value={String(field.state.value ?? currentVersion)}
onValueChange={(value) => field.handleChange(Number(value))}
>
<SelectTrigger className="w-fit h-7 text-xs whitespace-nowrap">
<SelectValue />
</SelectTrigger>
<SelectContent>
{versionOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</form.AppField>
)}
</span>
);
}

View File

@@ -0,0 +1,26 @@
import { cn } from "@/lib/utils";
type StatusBadgeVariant = "created" | "removed";
const variantStyles: Record<StatusBadgeVariant, string> = {
created: "bg-green-500/20 text-green-500",
removed: "bg-red-500/20 text-red-500",
};
interface StatusBadgeProps {
variant: StatusBadgeVariant;
children: React.ReactNode;
}
export function StatusBadge({ variant, children }: StatusBadgeProps) {
return (
<span
className={cn(
"px-2 py-0.5 rounded-md text-xs font-medium",
variantStyles[variant],
)}
>
{children}
</span>
);
}

View File

@@ -0,0 +1,284 @@
import {
getProductItemDisplay,
type ProductItem,
UsageModel,
} from "@autumn/shared";
import {
CaretDownIcon,
CheckIcon,
PencilSimpleIcon,
} from "@phosphor-icons/react";
import { useState } from "react";
import { IconButton } from "@/components/v2/buttons/IconButton";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/v2/tooltips/Tooltip";
import { useOrg } from "@/hooks/common/useOrg";
import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery";
import { cn } from "@/lib/utils";
import { PlanFeatureIcon } from "@/views/products/plan/components/plan-card/PlanFeatureIcon";
import { CustomDotIcon } from "@/views/products/plan/components/plan-card/PlanFeatureRow";
import type { UseUpdateSubscriptionForm } from "../hooks/useUpdateSubscriptionForm";
import type { ItemEdit } from "../types/summary";
import { getEditIcon } from "../utils/getEditIcon";
import { CompactValueChange } from "./CompactValueChange";
import { StatusBadge } from "./StatusBadge";
interface SubscriptionItemRowProps {
item: ProductItem;
edits?: ItemEdit[];
form?: UseUpdateSubscriptionForm;
featureId?: string;
prepaidQuantity?: number | null;
isDeleted?: boolean;
isCreated?: boolean;
}
function EditRow({
edit,
showRing = true,
}: {
edit: ItemEdit;
showRing?: boolean;
}) {
const ringClass = edit.isUpgrade
? "ring-1 ring-inset ring-green-500/50"
: "ring-1 ring-inset ring-red-500/50";
const renderDescription = () => {
const match = edit.description.match(
/^(.+\bfrom\s+)(\S+)(\s+to\s+)(\S+)(.*)$/,
);
if (match) {
const [, prefix, oldVal, middle, newVal, suffix] = match;
return (
<span className="text-xs text-t3">
{prefix}
<span className="text-red-500 font-medium">{oldVal}</span>
{middle}
<span className="text-green-500 font-medium">{newVal}</span>
<span className="text-t4">{suffix}</span>
</span>
);
}
return <span className="text-xs text-t3">{edit.description}</span>;
};
return (
<div
className={cn(
"flex items-center gap-2 w-full h-9 px-3 rounded-xl",
showRing && "input-base",
showRing && ringClass,
)}
>
{showRing && getEditIcon(edit.icon, edit.isUpgrade)}
{showRing ? (
renderDescription()
) : (
<CompactValueChange oldValue={edit.oldValue} newValue={edit.newValue} />
)}
</div>
);
}
export function SubscriptionItemRow({
item,
edits = [],
form,
featureId,
prepaidQuantity,
isDeleted = false,
isCreated = false,
}: SubscriptionItemRowProps) {
const { org } = useOrg();
const { features } = useFeaturesQuery();
const [isAccordionOpen, setIsAccordionOpen] = useState(false);
const [isEditingQuantity, setIsEditingQuantity] = useState(false);
const display = getProductItemDisplay({
item,
features,
currency: org?.default_currency || "USD",
fullDisplay: true,
amountFormatOptions: { currencyDisplay: "narrowSymbol" },
});
const feature = features.find((f) => f.id === item.feature_id);
const hasFeatureName = feature?.name && feature.name.trim() !== "";
const displayText = hasFeatureName
? display.primary_text
: "Name your feature";
const isPrepaid = item.usage_model === UsageModel.Prepaid;
const hasMultipleEdits = edits.length > 1;
const singleEdit = edits.length === 1 ? edits[0] : null;
const hasEditableEdit = edits.some((e) => e.editable);
const getRowRingClass = () => {
if (isDeleted) return "ring-1 ring-inset ring-red-500/50 opacity-60";
if (isCreated) return "ring-1 ring-inset ring-green-500/50";
if (edits.length === 0) return "";
return "ring-1 ring-inset ring-amber-500/50";
};
const renderRowIndicator = () => {
if (isDeleted) return <StatusBadge variant="removed">Removed</StatusBadge>;
if (isCreated) return <StatusBadge variant="created">Created</StatusBadge>;
if (hasMultipleEdits) {
return (
<button
type="button"
className="flex items-center gap-1 text-xs text-t3 hover:text-t1"
>
{edits.length} Changes
<CaretDownIcon
size={14}
className={cn(
"transition-transform duration-200",
isAccordionOpen && "rotate-180",
)}
/>
</button>
);
}
if (singleEdit?.editable) {
return <EditRow edit={singleEdit} showRing={false} />;
}
if (singleEdit) {
return (
<CompactValueChange
oldValue={singleEdit.oldValue}
newValue={singleEdit.newValue}
/>
);
}
if (!isPrepaid && prepaidQuantity && edits.length === 0) {
return (
<span className="bg-muted px-1.5 py-0.5 rounded-md text-xs">
x{parseFloat(Number(prepaidQuantity).toFixed(2))}
</span>
);
}
return null;
};
const showPrepaidOutside =
isPrepaid && form && featureId && edits.length === 0;
const handleRowClick = () => {
if (hasMultipleEdits) setIsAccordionOpen(!isAccordionOpen);
};
const handleRowKeyDown = (e: React.KeyboardEvent) => {
if (hasMultipleEdits && (e.key === "Enter" || e.key === " ")) {
e.preventDefault();
setIsAccordionOpen(!isAccordionOpen);
}
};
return (
<div className="flex flex-col">
<div className="flex items-center gap-2">
<div
role={hasMultipleEdits ? "button" : undefined}
tabIndex={hasMultipleEdits ? 0 : undefined}
className={cn(
"flex items-center flex-1 min-w-0 h-10 px-3 rounded-xl input-base",
getRowRingClass(),
hasMultipleEdits && "cursor-pointer",
)}
onClick={handleRowClick}
onKeyDown={handleRowKeyDown}
>
<div className="flex flex-row items-center flex-1 gap-2 min-w-0 overflow-hidden">
<div className="flex flex-row items-center gap-1 shrink-0">
<PlanFeatureIcon item={item} position="left" />
<CustomDotIcon />
<PlanFeatureIcon item={item} position="right" />
</div>
<p className="whitespace-nowrap truncate flex-1 min-w-0">
<span className={cn("text-body", !hasFeatureName && "text-t4!")}>
{displayText}
</span>
<span className="text-body-secondary">
{" "}
{display.secondary_text}
</span>
</p>
</div>
<div className="flex items-center gap-2 shrink-0">
{renderRowIndicator()}
</div>
</div>
{!isDeleted &&
(showPrepaidOutside || hasEditableEdit) &&
form &&
featureId && (
<div className="flex items-center h-10 px-3 rounded-xl input-base w-fit shrink-0 gap-2">
{isEditingQuantity ? (
<>
<form.AppField name={`prepaidOptions.${featureId}`}>
{(field) => (
<field.QuantityField label="" min={0} hideFieldInfo />
)}
</form.AppField>
<IconButton
icon={<CheckIcon size={14} />}
variant="skeleton"
size="sm"
className="text-t4 hover:text-t2 hover:bg-muted"
onClick={() => setIsEditingQuantity(false)}
/>
</>
) : (
<>
<span className="text-sm tabular-nums text-t3">
x{prepaidQuantity}
</span>
<Tooltip>
<TooltipTrigger asChild>
<IconButton
icon={<PencilSimpleIcon size={14} />}
variant="skeleton"
size="sm"
className="text-t4 hover:text-t2 hover:bg-muted"
onClick={() => setIsEditingQuantity(true)}
/>
</TooltipTrigger>
<TooltipContent>Update prepaid quantity</TooltipContent>
</Tooltip>
</>
)}
</div>
)}
</div>
{!isDeleted && hasMultipleEdits && (
<div
className={cn(
"grid transition-[grid-template-rows] duration-200 ease-out",
isAccordionOpen ? "grid-rows-[1fr]" : "grid-rows-[0fr]",
)}
>
<div className="overflow-hidden">
<div className="flex flex-col gap-2 pt-2 pl-8">
{edits.map((edit) => (
<EditRow key={edit.id} edit={edit} />
))}
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -1,4 +1,5 @@
import type { PreviewUpdateSubscriptionResponse } from "@autumn/shared";
import { format } from "date-fns";
import { AnimatePresence, motion } from "motion/react";
import { LineItemsPreview } from "@/components/v2/LineItemsPreview";
import { SheetSection } from "@/components/v2/sheets/SharedSheetComponents";
@@ -32,6 +33,9 @@ export function UpdateSubscriptionPreviewSection({
label: "Next Cycle",
amount: previewData.next_cycle.total,
variant: "secondary" as const,
badge: previewData.next_cycle.starts_at
? format(new Date(previewData.next_cycle.starts_at), "MMM d, yyyy")
: undefined,
});
}
}

View File

@@ -1,39 +1,27 @@
import type { FullCusProduct, ProductItem } from "@autumn/shared";
import type { FullCusProduct } from "@autumn/shared";
import { useStore } from "@tanstack/react-form";
import { useMemo } from "react";
import { SheetSection } from "@/components/v2/sheets/SharedSheetComponents";
import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery";
import type { PrepaidItemWithFeature } from "@/hooks/stores/useProductStore";
import type { UseUpdateSubscriptionForm } from "../hooks/useUpdateSubscriptionForm";
import type { SummaryItem } from "../types/summary";
import { generateItemChanges } from "../utils/generateItemChanges";
import { generatePrepaidChanges } from "../utils/generatePrepaidChanges";
import { generateTrialChanges } from "../utils/generateTrialChanges";
import { generateVersionChanges } from "../utils/generateVersionChanges";
import { SummaryItemRow } from "./SummaryItemRow";
interface UpdateSubscriptionSummaryProps {
form: UseUpdateSubscriptionForm;
prepaidItems: PrepaidItemWithFeature[];
customerProduct: FullCusProduct;
currentVersion: number;
currency?: string;
originalItems?: ProductItem[];
}
export function UpdateSubscriptionSummary({
form,
prepaidItems,
customerProduct,
currentVersion,
currency = "usd",
originalItems,
}: UpdateSubscriptionSummaryProps) {
const formValues = useStore(form.store, (state) => state.values);
const { features } = useFeaturesQuery();
const defaultValues = form.options.defaultValues;
const initialPrepaidOptions = defaultValues?.prepaidOptions ?? {};
const changes = useMemo((): SummaryItem[] => {
const trialChanges = generateTrialChanges({
@@ -48,52 +36,14 @@ export function UpdateSubscriptionSummary({
selectedVersion: formValues.version,
});
const itemChanges = generateItemChanges({
originalItems,
customizedItems: formValues.items,
features,
prepaidOptions: formValues.prepaidOptions,
});
// Get set of newly added feature IDs to filter out their prepaid changes
// (prepaid info is already included in the item addition description)
const newlyAddedFeatureIds = new Set(
itemChanges
.filter((change) => change.id.startsWith("item-added-"))
.map((change) => change.id.replace("item-added-", "")),
);
const prepaidChanges = generatePrepaidChanges({
prepaidItems,
currentOptions: formValues.prepaidOptions,
initialOptions: initialPrepaidOptions,
currency,
}).filter((change) => {
// Filter out prepaid changes for newly added items
const featureId = change.id.replace("prepaid-", "");
return !newlyAddedFeatureIds.has(featureId);
});
return [
...versionChanges,
...itemChanges,
...prepaidChanges,
...trialChanges,
];
return [...versionChanges, ...trialChanges];
}, [
prepaidItems,
formValues.prepaidOptions,
formValues.removeTrial,
formValues.trialLength,
formValues.trialDuration,
formValues.version,
formValues.items,
initialPrepaidOptions,
customerProduct,
currentVersion,
currency,
features,
originalItems,
]);
if (changes.length === 0) return null;

View File

@@ -3,8 +3,8 @@
// Components
export * from "./components/EditPlanSection";
export * from "./components/FreeTrialSection";
export * from "./components/PrepaidQuantitySection";
export * from "./components/PreviewErrorDisplay";
export * from "./components/SubscriptionItemRow";
export * from "./components/UpdateSubscriptionFooter";
export * from "./components/UpdateSubscriptionPreviewSection";
export * from "./components/UpdateSubscriptionSummary";

View File

@@ -11,3 +11,21 @@ export interface SummaryItem {
/** The product item for rendering icons (prepaid and item changes) */
productItem?: ProductItem;
}
export type EditIconType = "price" | "tier" | "usage" | "units" | "prepaid";
export interface ItemEdit {
id: string;
type: "config" | "prepaid";
label: string;
/** Icon type for the edit */
icon: EditIconType;
/** Full sentence description for accordion display */
description: string;
oldValue: string | number | null;
newValue: string | number | null;
/** Whether this is an upgrade (true) or downgrade (false) */
isUpgrade: boolean;
/** Whether this edit has an inline editor (prepaid quantity) */
editable?: boolean;
}

View File

@@ -0,0 +1,187 @@
import { type ProductItem, UsageModel } from "@autumn/shared";
import type { ItemEdit } from "../types/summary";
/** Format tier threshold value for display */
function formatTierValue(value: string | number | "inf"): string {
return value === "inf" ? "Infinite" : String(value);
}
export function buildEditsForItem({
item,
originalItem,
prepaidQuantity,
initialPrepaidQuantity,
}: {
item: ProductItem;
originalItem?: ProductItem;
prepaidQuantity?: number;
initialPrepaidQuantity?: number;
}): ItemEdit[] {
const edits: ItemEdit[] = [];
const isPrepaid = item.usage_model === UsageModel.Prepaid;
if (originalItem) {
if (
originalItem.price !== item.price &&
originalItem.price !== null &&
originalItem.price !== undefined &&
item.price !== null &&
item.price !== undefined
) {
const oldPrice = originalItem.price;
const newPrice = item.price;
const isUpgrade = newPrice < oldPrice;
edits.push({
id: `price-${item.feature_id}`,
type: "config",
icon: "price",
label: "Price",
description: isUpgrade
? `Price decreased from $${oldPrice} to $${newPrice}`
: `Price increased from $${oldPrice} to $${newPrice}`,
oldValue: `$${oldPrice}`,
newValue: `$${newPrice}`,
isUpgrade,
});
}
const oldTiers = originalItem.tiers ?? [];
const newTiers = item.tiers ?? [];
for (let i = 0; i < newTiers.length; i++) {
const newTier = newTiers[i];
const oldTier = oldTiers[i];
const tierLabel = `${formatTierValue(newTier.to)} units`;
if (!oldTier) {
const prevTierTo = i === 0 ? "0" : (oldTiers[i - 1]?.to ?? "0");
const prevLabel = formatTierValue(prevTierTo);
edits.push({
id: `tier-${item.feature_id}-${i}`,
type: "config",
icon: "tier",
label: "Pricing Tier",
description: `Added tier: after ${prevLabel} units costs $${newTier.amount}`,
oldValue: null,
newValue: `$${newTier.amount}`,
isUpgrade: true,
});
} else if (oldTier.amount !== newTier.amount) {
const isUpgrade = newTier.amount < oldTier.amount;
edits.push({
id: `tier-${item.feature_id}-${i}`,
type: "config",
icon: "tier",
label: "Pricing Tier",
description: isUpgrade
? `Tier price decreased from $${oldTier.amount} to $${newTier.amount} (up to ${tierLabel})`
: `Tier price increased from $${oldTier.amount} to $${newTier.amount} (up to ${tierLabel})`,
oldValue: `$${oldTier.amount}`,
newValue: `$${newTier.amount}`,
isUpgrade,
});
} else if (oldTier.to !== newTier.to) {
const oldLabel = formatTierValue(oldTier.to);
const newLabel = formatTierValue(newTier.to);
const isUpgrade =
newTier.to === "inf" ||
(oldTier.to !== "inf" && Number(newTier.to) > Number(oldTier.to));
edits.push({
id: `tier-${item.feature_id}-${i}-threshold`,
type: "config",
icon: "tier",
label: "Pricing Tier",
description: isUpgrade
? `Tier threshold increased from ${oldLabel} to ${newLabel} units`
: `Tier threshold decreased from ${oldLabel} to ${newLabel} units`,
oldValue: oldLabel,
newValue: newLabel,
isUpgrade,
});
}
}
for (let i = newTiers.length; i < oldTiers.length; i++) {
const oldTier = oldTiers[i];
const tierLabel = formatTierValue(oldTier.to);
edits.push({
id: `tier-${item.feature_id}-${i}-removed`,
type: "config",
icon: "tier",
label: "Pricing Tier",
description: `Removed tier: up to ${tierLabel} units at $${oldTier.amount}`,
oldValue: `$${oldTier.amount}`,
newValue: null,
isUpgrade: true,
});
}
const oldUsage = originalItem.included_usage ?? 0;
const newUsage = item.included_usage ?? 0;
if (oldUsage !== newUsage) {
const formatUsageValue = (val: string | number) =>
val === "inf" ? "unlimited" : String(val);
const formatUsageDisplay = (val: string | number) =>
val === "inf" ? "Unlimited" : `${val} Included`;
const oldNum =
oldUsage === "inf" ? Number.POSITIVE_INFINITY : Number(oldUsage);
const newNum =
newUsage === "inf" ? Number.POSITIVE_INFINITY : Number(newUsage);
const isUpgrade = newNum > oldNum;
edits.push({
id: `usage-${item.feature_id}`,
type: "config",
icon: "usage",
label: "Included Usage",
description: isUpgrade
? `Included usage increased from ${formatUsageValue(oldUsage)} to ${formatUsageValue(newUsage)}`
: `Included usage decreased from ${formatUsageValue(oldUsage)} to ${formatUsageValue(newUsage)}`,
oldValue: formatUsageDisplay(oldUsage),
newValue: formatUsageDisplay(newUsage),
isUpgrade,
});
}
const oldUnits = originalItem.billing_units ?? 1;
const newUnits = item.billing_units ?? 1;
if (oldUnits !== newUnits) {
const isUpgrade = newUnits > oldUnits;
edits.push({
id: `units-${item.feature_id}`,
type: "config",
icon: "units",
label: "Billing Units",
description: isUpgrade
? `Billing units increased from ${oldUnits} to ${newUnits}`
: `Billing units decreased from ${oldUnits} to ${newUnits}`,
oldValue: `${oldUnits} units`,
newValue: `${newUnits} units`,
isUpgrade,
});
}
}
if (
isPrepaid &&
initialPrepaidQuantity !== undefined &&
prepaidQuantity !== undefined &&
prepaidQuantity !== initialPrepaidQuantity
) {
const isUpgrade = prepaidQuantity > initialPrepaidQuantity;
edits.push({
id: `prepaid-${item.feature_id}`,
type: "prepaid",
icon: "prepaid",
label: "Prepaid Quantity",
description: isUpgrade
? `Prepaid quantity increased from ${initialPrepaidQuantity} to ${prepaidQuantity}`
: `Prepaid quantity decreased from ${initialPrepaidQuantity} to ${prepaidQuantity}`,
oldValue: `${initialPrepaidQuantity} Prepaid`,
newValue: `${prepaidQuantity} Prepaid`,
isUpgrade,
editable: true,
});
}
return edits;
}

View File

@@ -0,0 +1,30 @@
import {
CurrencyDollarIcon,
HashIcon,
PackageIcon,
StackIcon,
TagIcon,
} from "@phosphor-icons/react";
import { cn } from "@/lib/utils";
import type { EditIconType } from "../types/summary";
export function getEditIcon(iconType: EditIconType, isUpgrade: boolean) {
const iconProps = {
size: 14,
className: cn("shrink-0", isUpgrade ? "text-green-500" : "text-red-500"),
};
switch (iconType) {
case "price":
return <CurrencyDollarIcon {...iconProps} />;
case "tier":
return <StackIcon {...iconProps} />;
case "usage":
return <HashIcon {...iconProps} />;
case "units":
return <TagIcon {...iconProps} />;
case "prepaid":
return <PackageIcon {...iconProps} />;
default:
return null;
}
}

View File

@@ -4,6 +4,7 @@ import { Label } from "@/components/ui/label";
import { Button } from "@/components/v2/buttons/Button";
import { Input } from "@/components/v2/inputs/Input";
import { useFieldContext } from "@/hooks/form/form-context";
import { cn } from "@/lib/utils";
import SmallSpinner from "../../SmallSpinner";
export function QuantityField({
@@ -14,6 +15,7 @@ export function QuantityField({
max,
className,
hideFieldInfo,
compact,
}: {
label: string;
placeholder?: string;
@@ -22,6 +24,7 @@ export function QuantityField({
max?: number;
className?: string;
hideFieldInfo?: boolean;
compact?: boolean;
}) {
const field = useFieldContext<number>();
@@ -66,11 +69,18 @@ export function QuantityField({
<div className={className}>
{label && <Label>{label}</Label>}
<div className="relative flex items-center">
<div className="inline-flex rounded-lg overflow-hidden border border-border w-fit h-6 items-center">
<div
className={cn(
"inline-flex rounded-lg overflow-hidden border border-border w-fit h-6 items-center",
)}
>
<Button
type="button"
aria-label="Decrease quantity"
className="disabled:pointer-events-none disabled:opacity-50 rounded-none border-none h-input px-3"
className={cn(
"disabled:pointer-events-none disabled:opacity-50 rounded-none border-none h-input",
compact ? "px-2" : "px-3",
)}
disabled={field.state.value <= min}
onClick={handleDecrement}
size="sm"
@@ -82,9 +92,11 @@ export function QuantityField({
<div className="relative border-x border-border">
<Input
variant="headless"
className="text-sm text-center w-16 h-input p-2"
className={cn(
"text-sm text-center h-input p-2",
compact ? "w-10" : "w-16",
)}
onChange={handleInputChange}
// placeholder={placeholder}
type="number"
value={field.state.value ?? ""}
min={min}
@@ -100,7 +112,10 @@ export function QuantityField({
<Button
type="button"
aria-label="Increase quantity"
className="disabled:pointer-events-none disabled:opacity-50 rounded-none border-none h-input px-3"
className={cn(
"disabled:pointer-events-none disabled:opacity-50 rounded-none border-none h-input",
compact ? "px-2" : "px-3",
)}
disabled={max !== undefined && field.state.value >= max}
onClick={handleIncrement}
size="sm"

View File

@@ -6,6 +6,7 @@ import {
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Badge } from "@/components/v2/badges/badge";
import { LoadingShimmerText } from "@/components/v2/LoadingShimmerText";
import { SheetSection } from "@/components/v2/sheets/SharedSheetComponents";
import { cn } from "@/lib/utils";
@@ -33,6 +34,7 @@ export interface LineItemsPreviewProps<T extends BillingLineItem> {
label: string;
amount: number;
variant?: "primary" | "secondary";
badge?: string;
}[];
/** Accordion title for line items */
accordionTitle?: string;
@@ -81,9 +83,9 @@ export function LineItemsPreview<T extends BillingLineItem>({
</AccordionTrigger>
<AccordionContent className="pb-2 pt-1">
<div className="space-y-2">
{filteredItems.map((item, index) => (
{filteredItems.map((item) => (
<div
key={index}
key={item.description}
className="flex items-center justify-between"
>
<span className="text-sm truncate max-w-75 text-t3">
@@ -112,16 +114,21 @@ export function LineItemsPreview<T extends BillingLineItem>({
{/* Totals */}
{totals.length > 0 && (
<div className="space-y-1 text-sm">
{totals.map((total, index) => (
<div key={index} className="flex items-center justify-between">
{totals.map((total) => (
<div
key={total.label}
className="flex items-center justify-between"
>
<span
className={
className={cn(
"font-medium flex items-center gap-2",
total.variant === "secondary"
? "font-medium text-t4"
: "font-medium text-foreground"
}
? "text-t4"
: "text-foreground",
)}
>
{total.label}
{total.badge && <Badge variant="muted">{total.badge}</Badge>}
</span>
<span
className={

View File

@@ -1,11 +1,9 @@
import {
type FrontendProduct,
type FullCusProduct,
getProductItemDisplay,
type ProductItem,
type ProductV2,
productV2ToFrontendProduct,
UsageModel,
} from "@autumn/shared";
import { useStore } from "@tanstack/react-form";
import { useQuery } from "@tanstack/react-query";
@@ -17,7 +15,6 @@ import {
EditPlanSection,
FreeTrialSection,
getFreeTrial,
PrepaidQuantitySection,
UpdateSubscriptionFooter,
type UpdateSubscriptionFormContext,
UpdateSubscriptionPreviewSection,
@@ -42,7 +39,7 @@ import { useAxiosInstance } from "@/services/useAxiosInstance";
import { useEnv } from "@/utils/envUtils";
import { getBackendErr } from "@/utils/genUtils";
import { getStripeInvoiceLink } from "@/utils/linkUtils";
import { itemToFeature } from "@/utils/product/productItemUtils/convertItem";
import { useCusQuery } from "@/views/customers/customer/hooks/useCusQuery";
import { useCustomerContext } from "@/views/customers2/customer/CustomerContext";
import { InfoBox } from "@/views/onboarding2/integrate/components/InfoBox";
@@ -98,38 +95,6 @@ function SheetContent({
return Object.keys(changed).length > 0 ? changed : undefined;
}, [prepaidOptions, initialPrepaidOptions]);
// Compute extended prepaid items that includes both original prepaid items
// and any new prepaid items added through the inline editor
const extendedPrepaidItems = useMemo(() => {
if (!formValues.items) return prepaidItems;
// Get IDs of original prepaid items
const originalPrepaidIds = new Set(
prepaidItems.map((item) => item.feature_id),
);
// Find new prepaid items from form that aren't in the original list
const newPrepaidItems = formValues.items.filter(
(item) =>
item.usage_model === UsageModel.Prepaid &&
item.feature_id &&
!originalPrepaidIds.has(item.feature_id),
);
// Enrich new prepaid items with feature info
const enrichedNewItems = newPrepaidItems.map((item) => {
const feature = itemToFeature({ item, features });
const display = getProductItemDisplay({
item,
features,
currency: "usd",
});
return { ...item, feature, display };
});
return [...prepaidItems, ...enrichedNewItems];
}, [prepaidItems, formValues.items, features]);
const productWithFormItems = useMemo((): FrontendProduct | undefined => {
if (!product) return undefined;
@@ -277,25 +242,24 @@ function SheetContent({
<EditPlanSection
hasCustomizations={formValues.items !== null}
onEditPlan={handleEditPlan}
product={product}
product={productWithFormItems as ProductV2 | undefined}
originalItems={originalItems}
customerProduct={customerProduct}
features={features}
form={form}
numVersions={numVersions}
currentVersion={currentVersion}
prepaidOptions={prepaidOptions}
initialPrepaidOptions={initialPrepaidOptions}
/>
<PrepaidQuantitySection form={form} prepaidItems={extendedPrepaidItems} />
<FreeTrialSection form={form} customerProduct={customerProduct} />
<UpdateSubscriptionSummary
form={form}
prepaidItems={prepaidItems}
customerProduct={customerProduct}
currentVersion={currentVersion}
currency={previewQuery.data?.currency}
originalItems={originalItems}
/>
<UpdateSubscriptionPreviewSection