chore: clean up item change text methods

This commit is contained in:
Charlie Lamb
2026-01-16 12:07:46 +00:00
parent 5df60c675e
commit 698f7b7251
22 changed files with 395 additions and 310 deletions

View File

@@ -197,8 +197,14 @@ export * from "./utils/productDisplayUtils.js";
export * from "./utils/productUtils/convertProductUtils.js";
export * from "./utils/productUtils/priceToInvoiceAmount.js";
export * from "./utils/productUtils/productUtils.js";
export * from "./utils/productV2Utils/compareProductUtils.ts/compareItemUtils.js";
export * from "./utils/productV2Utils/compareProductUtils.ts/compareProductUtils.js";
export * from "./utils/productV2Utils/compareProductUtils/buildEditsForItem.js";
export * from "./utils/productV2Utils/compareProductUtils/compareItemUtils.js";
export * from "./utils/productV2Utils/compareProductUtils/compareProductUtils.js";
export * from "./utils/productV2Utils/compareProductUtils/generateItemChanges.js";
export * from "./utils/productV2Utils/compareProductUtils/generatePrepaidChanges.js";
export * from "./utils/productV2Utils/compareProductUtils/generateTrialChanges.js";
export * from "./utils/productV2Utils/compareProductUtils/generateVersionChanges.js";
export * from "./utils/productV2Utils/compareProductUtils/itemEditTypes.js";
export * from "./utils/productV2Utils/productItemUtils/convertItemUtils.js";
export * from "./utils/productV2Utils/productItemUtils/getProductItemRes.js";
export * from "./utils/productV2Utils/productItemUtils/itemIntervalUtils.js";

View File

@@ -1,44 +1,47 @@
import { type ProductItem, UsageModel } from "@autumn/shared";
import type { ItemEdit } from "../types/summary";
import {
type ProductItem,
UsageModel,
} from "../../../models/productV2Models/productItemModels/productItemModels.js";
import type { ItemEdit } from "./itemEditTypes.js";
/** Format tier threshold value for display */
function formatTierValue(value: string | number | "inf"): string {
return value === "inf" ? "Infinite" : String(value);
}
/** Builds a list of granular edits for a single product item comparison */
export function buildEditsForItem({
item,
originalItem,
prepaidQuantity,
initialPrepaidQuantity,
updatedItem,
originalPrepaidQuantity,
updatedPrepaidQuantity,
}: {
item: ProductItem;
originalItem?: ProductItem;
prepaidQuantity?: number;
initialPrepaidQuantity?: number;
updatedItem: ProductItem;
originalPrepaidQuantity?: number;
updatedPrepaidQuantity?: number;
}): ItemEdit[] {
const edits: ItemEdit[] = [];
const isPrepaid = item.usage_model === UsageModel.Prepaid;
const isPrepaid = updatedItem.usage_model === UsageModel.Prepaid;
if (originalItem) {
if (
originalItem.price !== item.price &&
originalItem.price !== updatedItem.price &&
originalItem.price !== null &&
originalItem.price !== undefined &&
item.price !== null &&
item.price !== undefined
updatedItem.price !== null &&
updatedItem.price !== undefined
) {
const oldPrice = originalItem.price;
const newPrice = item.price;
const isUpgrade = newPrice < oldPrice;
const newPrice = updatedItem.price;
const isUpgrade = newPrice > oldPrice;
edits.push({
id: `price-${item.feature_id}`,
id: `price-${updatedItem.feature_id}`,
type: "config",
icon: "price",
label: "Price",
description: isUpgrade
? `Price decreased from $${oldPrice} to $${newPrice}`
: `Price increased from $${oldPrice} to $${newPrice}`,
? `Price increased from $${oldPrice} to $${newPrice}`
: `Price decreased from $${oldPrice} to $${newPrice}`,
oldValue: `$${oldPrice}`,
newValue: `$${newPrice}`,
isUpgrade,
@@ -46,7 +49,7 @@ export function buildEditsForItem({
}
const oldTiers = originalItem.tiers ?? [];
const newTiers = item.tiers ?? [];
const newTiers = updatedItem.tiers ?? [];
for (let i = 0; i < newTiers.length; i++) {
const newTier = newTiers[i];
@@ -57,7 +60,7 @@ export function buildEditsForItem({
const prevTierTo = i === 0 ? "0" : (oldTiers[i - 1]?.to ?? "0");
const prevLabel = formatTierValue(prevTierTo);
edits.push({
id: `tier-${item.feature_id}-${i}`,
id: `tier-${updatedItem.feature_id}-${i}`,
type: "config",
icon: "tier",
label: "Pricing Tier",
@@ -67,15 +70,15 @@ export function buildEditsForItem({
isUpgrade: true,
});
} else if (oldTier.amount !== newTier.amount) {
const isUpgrade = newTier.amount < oldTier.amount;
const isUpgrade = newTier.amount > oldTier.amount;
edits.push({
id: `tier-${item.feature_id}-${i}`,
id: `tier-${updatedItem.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})`,
? `Tier price increased from $${oldTier.amount} to $${newTier.amount} (up to ${tierLabel})`
: `Tier price decreased from $${oldTier.amount} to $${newTier.amount} (up to ${tierLabel})`,
oldValue: `$${oldTier.amount}`,
newValue: `$${newTier.amount}`,
isUpgrade,
@@ -87,7 +90,7 @@ export function buildEditsForItem({
newTier.to === "inf" ||
(oldTier.to !== "inf" && Number(newTier.to) > Number(oldTier.to));
edits.push({
id: `tier-${item.feature_id}-${i}-threshold`,
id: `tier-${updatedItem.feature_id}-${i}-threshold`,
type: "config",
icon: "tier",
label: "Pricing Tier",
@@ -105,7 +108,7 @@ export function buildEditsForItem({
const oldTier = oldTiers[i];
const tierLabel = formatTierValue(oldTier.to);
edits.push({
id: `tier-${item.feature_id}-${i}-removed`,
id: `tier-${updatedItem.feature_id}-${i}-removed`,
type: "config",
icon: "tier",
label: "Pricing Tier",
@@ -117,7 +120,7 @@ export function buildEditsForItem({
}
const oldUsage = originalItem.included_usage ?? 0;
const newUsage = item.included_usage ?? 0;
const newUsage = updatedItem.included_usage ?? 0;
if (oldUsage !== newUsage) {
const formatUsageValue = (val: string | number) =>
val === "inf" ? "unlimited" : String(val);
@@ -129,7 +132,7 @@ export function buildEditsForItem({
newUsage === "inf" ? Number.POSITIVE_INFINITY : Number(newUsage);
const isUpgrade = newNum > oldNum;
edits.push({
id: `usage-${item.feature_id}`,
id: `usage-${updatedItem.feature_id}`,
type: "config",
icon: "usage",
label: "Included Usage",
@@ -143,11 +146,11 @@ export function buildEditsForItem({
}
const oldUnits = originalItem.billing_units ?? 1;
const newUnits = item.billing_units ?? 1;
const newUnits = updatedItem.billing_units ?? 1;
if (oldUnits !== newUnits) {
const isUpgrade = newUnits > oldUnits;
edits.push({
id: `units-${item.feature_id}`,
id: `units-${updatedItem.feature_id}`,
type: "config",
icon: "units",
label: "Billing Units",
@@ -163,21 +166,21 @@ export function buildEditsForItem({
if (
isPrepaid &&
initialPrepaidQuantity !== undefined &&
prepaidQuantity !== undefined &&
prepaidQuantity !== initialPrepaidQuantity
originalPrepaidQuantity !== undefined &&
updatedPrepaidQuantity !== undefined &&
updatedPrepaidQuantity !== originalPrepaidQuantity
) {
const isUpgrade = prepaidQuantity > initialPrepaidQuantity;
const isUpgrade = updatedPrepaidQuantity > originalPrepaidQuantity;
edits.push({
id: `prepaid-${item.feature_id}`,
id: `prepaid-${updatedItem.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`,
? `Prepaid quantity increased from ${originalPrepaidQuantity} to ${updatedPrepaidQuantity}`
: `Prepaid quantity decreased from ${originalPrepaidQuantity} to ${updatedPrepaidQuantity}`,
oldValue: `${originalPrepaidQuantity} Prepaid`,
newValue: `${updatedPrepaidQuantity} Prepaid`,
isUpgrade,
editable: true,
});

View File

@@ -1,10 +1,10 @@
import type { Feature } from "../../../models/featureModels/featureModels.js";
import type { PriceTier } from "../../../models/productV2Models/productItemModels/productItemModels.js";
import {
type Feature,
type PriceTier,
type ProductItem,
UsageModel,
} from "@autumn/shared";
import type { SummaryItem } from "../types/summary";
} from "../../../models/productV2Models/productItemModels/productItemModels.js";
import type { ItemEdit } from "./itemEditTypes.js";
function hasValue<T>(value: T | null | undefined): value is T {
return value !== null && value !== undefined;
@@ -37,18 +37,19 @@ function formatTierPricing({
: `$${firstPricedTier.amount} per unit`;
}
/** Generates edit items for product item additions, removals, and modifications */
export function generateItemChanges({
originalItems,
customizedItems,
updatedItems,
features,
prepaidOptions,
}: {
originalItems: ProductItem[] | undefined;
customizedItems: ProductItem[] | null;
updatedItems: ProductItem[] | null;
features?: Feature[];
prepaidOptions?: Record<string, number>;
}): SummaryItem[] {
if (!customizedItems || !originalItems) return [];
}): ItemEdit[] {
if (!updatedItems || !originalItems) return [];
const featureNameMap = new Map<string, string>();
if (features) {
@@ -65,103 +66,120 @@ export function generateItemChanges({
return item.feature_id ?? "Item";
};
const changes: SummaryItem[] = [];
const changes: ItemEdit[] = [];
const originalFeatureMap = new Map(
originalItems
.filter((item) => item.feature_id)
.map((item) => [item.feature_id, item]),
);
const customizedFeatureMap = new Map(
customizedItems
const updatedFeatureMap = new Map(
updatedItems
.filter((item) => item.feature_id)
.map((item) => [item.feature_id, item]),
);
for (const [featureId, original] of originalFeatureMap) {
const customized = customizedFeatureMap.get(featureId);
const updated = updatedFeatureMap.get(featureId);
if (!customized) {
if (!updated) {
const oldFormatted = formatItemValue(original);
changes.push({
id: `item-removed-${featureId}`,
type: "item",
label: getFeatureName(original),
oldValue: formatItemValue(original),
icon: "item",
description: `${getFeatureName(original)} removed (was ${oldFormatted})`,
oldValue: oldFormatted,
newValue: null,
productItem: original,
isUpgrade: false,
});
} else if (hasItemChanged(original, customized)) {
} else if (
hasItemChanged({ originalItem: original, updatedItem: updated })
) {
const oldValueFormatted = formatChangedItemValue({
item: original,
original,
customized,
originalItem: original,
updatedItem: updated,
});
const newValueFormatted = formatChangedItemValue({
item: customized,
original,
customized,
item: updated,
originalItem: original,
updatedItem: updated,
});
changes.push({
id: `item-modified-${featureId}`,
type: "item",
label: getFeatureName(original),
icon: "item",
description: `${getFeatureName(original)} changed from ${oldValueFormatted} to ${newValueFormatted}`,
oldValue: oldValueFormatted,
newValue: newValueFormatted,
productItem: customized,
isUpgrade: true,
});
}
}
for (const [featureId, customized] of customizedFeatureMap) {
for (const [featureId, updated] of updatedFeatureMap) {
if (!originalFeatureMap.has(featureId)) {
const prepaidQuantity =
featureId && prepaidOptions ? prepaidOptions[featureId] : undefined;
const isPrepaidWithQuantity =
customized.usage_model === UsageModel.Prepaid &&
updated.usage_model === UsageModel.Prepaid &&
hasValue(prepaidQuantity) &&
prepaidQuantity > 0;
const billingUnits = customized.billing_units ?? 1;
const billingUnits = updated.billing_units ?? 1;
const displayQuantity = prepaidQuantity
? prepaidQuantity * billingUnits
: 0;
const newValue = isPrepaidWithQuantity
? displayQuantity
: formatItemValue(updated);
changes.push({
id: `item-added-${featureId}`,
type: "item",
label: getFeatureName(customized),
label: getFeatureName(updated),
icon: "item",
description: `${getFeatureName(updated)} added (${newValue})`,
oldValue: isPrepaidWithQuantity ? 0 : null,
newValue: isPrepaidWithQuantity
? displayQuantity
: formatItemValue(customized),
productItem: customized,
newValue,
isUpgrade: true,
});
}
}
const originalPriceItems = originalItems.filter((item) => !item.feature_id);
const customizedPriceItems = customizedItems.filter(
(item) => !item.feature_id,
);
const updatedPriceItems = updatedItems.filter((item) => !item.feature_id);
if (originalPriceItems.length !== customizedPriceItems.length) {
const priceDiff = customizedPriceItems.length - originalPriceItems.length;
if (originalPriceItems.length !== updatedPriceItems.length) {
const priceDiff = updatedPriceItems.length - originalPriceItems.length;
if (priceDiff > 0) {
const label = `${priceDiff} Price Item${priceDiff > 1 ? "s" : ""}`;
changes.push({
id: "price-items-added",
type: "item",
label: `${priceDiff} Price Item${priceDiff > 1 ? "s" : ""}`,
label,
icon: "price",
description: `${label} added`,
oldValue: null,
newValue: String(priceDiff),
isUpgrade: true,
});
} else {
const label = `${Math.abs(priceDiff)} Price Item${Math.abs(priceDiff) > 1 ? "s" : ""}`;
changes.push({
id: "price-items-removed",
type: "item",
label: `${Math.abs(priceDiff)} Price Item${Math.abs(priceDiff) > 1 ? "s" : ""}`,
label,
icon: "price",
description: `${label} removed`,
oldValue: String(Math.abs(priceDiff)),
newValue: null,
isUpgrade: false,
});
}
}
@@ -169,16 +187,19 @@ export function generateItemChanges({
return changes;
}
function hasItemChanged(
original: ProductItem,
customized: ProductItem,
): boolean {
function hasItemChanged({
originalItem,
updatedItem,
}: {
originalItem: ProductItem;
updatedItem: ProductItem;
}): boolean {
return (
original.price !== customized.price ||
original.included_usage !== customized.included_usage ||
JSON.stringify(original.tiers) !== JSON.stringify(customized.tiers) ||
original.billing_units !== customized.billing_units ||
original.interval !== customized.interval
originalItem.price !== updatedItem.price ||
originalItem.included_usage !== updatedItem.included_usage ||
JSON.stringify(originalItem.tiers) !== JSON.stringify(updatedItem.tiers) ||
originalItem.billing_units !== updatedItem.billing_units ||
originalItem.interval !== updatedItem.interval
);
}
@@ -209,28 +230,23 @@ function formatItemValue(item: ProductItem): string {
return `$${item.price}`;
}
// Boolean features have no price/tiers/usage - just "enabled" when present
if (parts.length === 0) return "Enabled";
return parts.join(" + ");
}
/**
* Format only the parts of an item that changed between original and customized.
* Avoids showing unchanged values like "+ $10 per unit" when only included_usage changed.
*/
function formatChangedItemValue({
item,
original,
customized,
originalItem,
updatedItem,
}: {
item: ProductItem;
original: ProductItem;
customized: ProductItem;
originalItem: ProductItem;
updatedItem: ProductItem;
}): string {
const parts: string[] = [];
const includedUsageChanged =
original.included_usage !== customized.included_usage;
originalItem.included_usage !== updatedItem.included_usage;
if (includedUsageChanged) {
if (item.included_usage === "inf") {
parts.push("Unlimited");
@@ -239,32 +255,31 @@ function formatChangedItemValue({
}
}
const priceChanged = original.price !== customized.price;
const priceChanged = originalItem.price !== updatedItem.price;
if (priceChanged && hasValue(item.price)) {
const billingUnits = item.billing_units ?? 1;
parts.push(formatPriceWithUnits(item.price, billingUnits));
}
const tiersChanged =
JSON.stringify(original.tiers) !== JSON.stringify(customized.tiers);
JSON.stringify(originalItem.tiers) !== JSON.stringify(updatedItem.tiers);
if (tiersChanged && item.tiers?.length) {
const billingUnits = item.billing_units ?? 1;
parts.push(formatTierPricing({ tiers: item.tiers, billingUnits }));
}
const billingUnitsChanged =
original.billing_units !== customized.billing_units;
originalItem.billing_units !== updatedItem.billing_units;
if (billingUnitsChanged && !priceChanged && hasValue(item.price)) {
const billingUnits = item.billing_units ?? 1;
parts.push(formatPriceWithUnits(item.price, billingUnits));
}
const intervalChanged = original.interval !== customized.interval;
const intervalChanged = originalItem.interval !== updatedItem.interval;
if (intervalChanged && item.interval) {
parts.push(`${item.interval}`);
}
// Boolean features or edge cases (null vs undefined) - just "enabled" when present
if (parts.length === 0) return "Enabled";
return parts.join(" + ");
}

View File

@@ -0,0 +1,59 @@
import type { ProductItem } from "../../../models/productV2Models/productItemModels/productItemModels.js";
import { formatAmount } from "../../common/formatUtils/formatAmount.js";
import type { ItemEdit } from "./itemEditTypes.js";
/** Generates edit items for prepaid quantity changes */
export function generatePrepaidChanges({
prepaidItems,
originalOptions,
updatedOptions,
currency = "usd",
}: {
prepaidItems: ProductItem[];
originalOptions: Record<string, number>;
updatedOptions: Record<string, number>;
currency?: string;
}): ItemEdit[] {
return prepaidItems
.map((item) => {
const featureId = item.feature_id ?? "";
const oldQuantity = originalOptions[featureId] ?? 0;
const newQuantity = updatedOptions[featureId] ?? 0;
if (oldQuantity === newQuantity) return null;
const billingUnits = item.billing_units ?? 1;
const oldDisplayQuantity = oldQuantity * billingUnits;
const newDisplayQuantity = newQuantity * billingUnits;
const unitPrice = item.price ?? null;
const costDelta =
unitPrice !== null ? (newQuantity - oldQuantity) * unitPrice : null;
const featureName = item.feature?.name ?? "Items";
const isUpgrade = newQuantity > oldQuantity;
let description = `Prepaid quantity changed from ${oldDisplayQuantity} to ${newDisplayQuantity}`;
if (costDelta !== null && costDelta !== 0) {
const formattedCost = formatAmount({
amount: Math.abs(costDelta),
currency,
minFractionDigits: 2,
amountFormatOptions: { currencyDisplay: "narrowSymbol" },
});
description += ` (${costDelta > 0 ? "+" : "-"}${formattedCost})`;
}
return {
id: `prepaid-${featureId}`,
type: "prepaid" as const,
label: featureName,
icon: "prepaid" as const,
description,
oldValue: oldDisplayQuantity,
newValue: newDisplayQuantity,
isUpgrade,
};
})
.filter(Boolean) as ItemEdit[];
}

View File

@@ -1,12 +1,12 @@
import { addDays, formatDuration, intervalToDuration } from "date-fns";
import type { FullCusProduct } from "../../../models/cusProductModels/cusProductModels.js";
import type { FreeTrialDuration } from "../../../models/productModels/freeTrialModels/freeTrialEnums.js";
import { isCustomerProductTrialing } from "../../cusProductUtils/classifyCustomerProduct/classifyCustomerProduct.js";
import {
type FreeTrialDuration,
type FullCusProduct,
getRemainingTrialDays,
getTrialLengthInDays,
isCustomerProductTrialing,
} from "@autumn/shared";
import { addDays, formatDuration, intervalToDuration } from "date-fns";
import type { SummaryItem } from "../types/summary";
} from "../../productUtils/freeTrialUtils.js";
import type { ItemEdit } from "./itemEditTypes.js";
function formatDaysAsReadable(totalDays: number): string {
if (totalDays <= 0) return "0 days";
@@ -21,6 +21,7 @@ function formatDaysAsReadable(totalDays: number): string {
});
}
/** Generates edit items for trial period changes */
export function generateTrialChanges({
customerProduct,
removeTrial,
@@ -31,20 +32,23 @@ export function generateTrialChanges({
removeTrial: boolean;
trialLength: number | null;
trialDuration: FreeTrialDuration;
}): SummaryItem[] {
}): ItemEdit[] {
const isCurrentlyTrialing = isCustomerProductTrialing(customerProduct);
const remainingDays = getRemainingTrialDays({
trialEndsAt: customerProduct.trial_ends_at,
});
const changes: SummaryItem[] = [];
const changes: ItemEdit[] = [];
if (removeTrial && isCurrentlyTrialing) {
changes.push({
id: "trial-remove",
type: "trial",
label: "End Trial",
icon: "trial",
description: "Trial ended",
oldValue: "Active",
newValue: null,
isUpgrade: false,
});
return changes;
}
@@ -53,25 +57,35 @@ export function generateTrialChanges({
const newTrialDays = getTrialLengthInDays({ trialLength, trialDuration });
if (isCurrentlyTrialing && remainingDays !== null) {
// Skip if no actual change (same number of days)
if (newTrialDays === remainingDays) return changes;
const isExtending = newTrialDays > remainingDays;
const oldFormatted = formatDaysAsReadable(remainingDays);
const newFormatted = formatDaysAsReadable(newTrialDays);
changes.push({
id: isExtending ? "trial-extend" : "trial-shorten",
type: "trial",
label: isExtending ? "Extend Trial" : "Shorten Trial",
oldValue: formatDaysAsReadable(remainingDays),
newValue: formatDaysAsReadable(newTrialDays),
icon: "trial",
description: isExtending
? `Trial extended from ${oldFormatted} to ${newFormatted}`
: `Trial shortened from ${oldFormatted} to ${newFormatted}`,
oldValue: oldFormatted,
newValue: newFormatted,
isUpgrade: isExtending,
});
} else {
// Not currently trialing - adding new trial
const newFormatted = formatDaysAsReadable(newTrialDays);
changes.push({
id: "trial-add",
type: "trial",
label: "Free Trial",
icon: "trial",
description: `Free trial added for ${newFormatted}`,
oldValue: null,
newValue: formatDaysAsReadable(newTrialDays),
newValue: newFormatted,
isUpgrade: true,
});
}
}

View File

@@ -0,0 +1,31 @@
import type { ItemEdit } from "./itemEditTypes.js";
/** Generates edit items for plan version changes */
export function generateVersionChanges({
originalVersion,
updatedVersion,
}: {
originalVersion: number;
updatedVersion: number;
}): ItemEdit[] {
if (updatedVersion === originalVersion) {
return [];
}
const isUpgrade = updatedVersion > originalVersion;
return [
{
id: "version-change",
type: "version",
label: "Plan Version",
icon: "version",
description: isUpgrade
? `Plan version upgraded from v${originalVersion} to v${updatedVersion}`
: `Plan version downgraded from v${originalVersion} to v${updatedVersion}`,
oldValue: originalVersion,
newValue: updatedVersion,
isUpgrade,
},
];
}

View File

@@ -0,0 +1,30 @@
/** Type of change being made */
export type EditType = "config" | "prepaid" | "trial" | "version" | "item";
/** Icon type for visual representation of the edit */
export type EditIconType =
| "price"
| "tier"
| "usage"
| "units"
| "prepaid"
| "trial"
| "version"
| "item";
/** Represents a single edit/change to a subscription or product item */
export interface ItemEdit {
id: string;
type: EditType;
label: string;
/** Icon type for the edit */
icon: EditIconType;
/** Full sentence description for 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

@@ -1,17 +1,25 @@
import { cn } from "@/lib/utils";
interface CompactValueChangeProps {
oldValue: string | number | null;
newValue: string | number | null;
isUpgrade?: boolean;
}
export function CompactValueChange({
oldValue,
newValue,
isUpgrade = true,
}: CompactValueChangeProps) {
return (
<span className="text-xs flex items-center gap-1">
<span className="text-red-500">{oldValue}</span>
<span className={cn(isUpgrade ? "text-red-500" : "text-green-500")}>
{oldValue}
</span>
<span className="text-t3"></span>
<span className="text-green-500">{newValue}</span>
<span className={cn(isUpgrade ? "text-green-500" : "text-red-500")}>
{newValue}
</span>
</span>
);
}

View File

@@ -4,13 +4,16 @@ import type {
ProductItem,
ProductV2,
} from "@autumn/shared";
import { featureToOptions, UsageModel } from "@autumn/shared";
import {
buildEditsForItem,
featureToOptions,
UsageModel,
} from "@autumn/shared";
import { PencilSimpleIcon } from "@phosphor-icons/react";
import { Button } from "@/components/v2/buttons/Button";
import { SheetSection } from "@/components/v2/sheets/SharedSheetComponents";
import { BasePriceDisplay } from "@/views/products/plan/components/plan-card/BasePriceDisplay";
import type { UseUpdateSubscriptionForm } from "../hooks/useUpdateSubscriptionForm";
import { buildEditsForItem } from "../utils/buildEditsForItem";
import { SectionTitle } from "./SectionTitle";
import { SubscriptionItemRow } from "./SubscriptionItemRow";
@@ -96,10 +99,10 @@ export function EditPlanSection({
const isCreated =
!originalItem && originalItems && originalItems.length > 0;
const edits = buildEditsForItem({
item,
updatedItem: item,
originalItem,
prepaidQuantity: currentPrepaidQuantity,
initialPrepaidQuantity,
updatedPrepaidQuantity: currentPrepaidQuantity,
originalPrepaidQuantity: initialPrepaidQuantity,
});
return (

View File

@@ -1,5 +1,6 @@
import {
getProductItemDisplay,
type ItemEdit,
type ProductItem,
UsageModel,
} from "@autumn/shared";
@@ -21,7 +22,6 @@ 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";
@@ -56,9 +56,23 @@ function EditRow({
return (
<span className="text-xs text-t3">
{prefix}
<span className="text-red-500 font-medium">{oldVal}</span>
<span
className={cn(
"font-medium",
edit.isUpgrade ? "text-red-500" : "text-green-500",
)}
>
{oldVal}
</span>
{middle}
<span className="text-green-500 font-medium">{newVal}</span>
<span
className={cn(
"font-medium",
edit.isUpgrade ? "text-green-500" : "text-red-500",
)}
>
{newVal}
</span>
<span className="text-t4">{suffix}</span>
</span>
);
@@ -78,7 +92,11 @@ function EditRow({
{showRing ? (
renderDescription()
) : (
<CompactValueChange oldValue={edit.oldValue} newValue={edit.newValue} />
<CompactValueChange
oldValue={edit.oldValue}
newValue={edit.newValue}
isUpgrade={edit.isUpgrade}
/>
)}
</div>
);
@@ -155,6 +173,7 @@ export function SubscriptionItemRow({
<CompactValueChange
oldValue={singleEdit.oldValue}
newValue={singleEdit.newValue}
isUpgrade={singleEdit.isUpgrade}
/>
);
}

View File

@@ -1,55 +1,72 @@
import { formatAmount } from "@autumn/shared";
import { CalendarIcon, GitBranchIcon, WrenchIcon } from "@phosphor-icons/react";
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 { SummaryItem } from "../types/summary";
import type { EditIconType, ItemEdit } from "@autumn/shared";
import {
CalendarIcon,
CurrencyDollarIcon,
GitBranchIcon,
HashIcon,
PackageIcon,
StackIcon,
TagIcon,
WrenchIcon,
} from "@phosphor-icons/react";
export function SummaryItemRow({
item,
currency,
}: {
item: SummaryItem;
currency: string;
}) {
const renderIcons = () => {
if (item.type === "prepaid" && item.productItem) {
return (
<div className="flex flex-row items-center gap-1 shrink-0">
<PlanFeatureIcon item={item.productItem} position="left" />
<CustomDotIcon />
<PlanFeatureIcon item={item.productItem} position="right" />
</div>
);
}
if (item.type === "trial") {
function getIcon(iconType: EditIconType) {
const iconProps = { size: 16, weight: "duotone" as const };
switch (iconType) {
case "trial":
return (
<div className="text-blue-500">
<CalendarIcon size={16} weight="duotone" />
<CalendarIcon {...iconProps} />
</div>
);
}
if (item.type === "version") {
case "version":
return (
<div className="text-purple-500">
<GitBranchIcon size={16} weight="duotone" />
<GitBranchIcon {...iconProps} />
</div>
);
}
if (item.type === "item") {
case "item":
return (
<div className="text-amber-500">
<WrenchIcon size={16} weight="duotone" />
<WrenchIcon {...iconProps} />
</div>
);
}
return null;
};
case "prepaid":
return (
<div className="text-cyan-500">
<PackageIcon {...iconProps} />
</div>
);
case "price":
return (
<div className="text-green-500">
<CurrencyDollarIcon {...iconProps} />
</div>
);
case "tier":
return (
<div className="text-orange-500">
<StackIcon {...iconProps} />
</div>
);
case "usage":
return (
<div className="text-indigo-500">
<HashIcon {...iconProps} />
</div>
);
case "units":
return (
<div className="text-pink-500">
<TagIcon {...iconProps} />
</div>
);
default:
return null;
}
}
export function SummaryItemRow({ item }: { item: ItemEdit }) {
const renderChangeIndicator = () => {
if (item.newValue === null) {
return (
@@ -87,7 +104,7 @@ export function SummaryItemRow({
return (
<div className="flex items-center w-full h-10 px-3 rounded-xl input-base">
<div className="flex flex-row items-center flex-1 gap-2 min-w-0 overflow-hidden">
{renderIcons()}
{getIcon(item.icon)}
<p className="whitespace-nowrap truncate flex-1 min-w-0 text-body">
{item.label}
@@ -96,23 +113,6 @@ export function SummaryItemRow({
<div className="flex items-center gap-2 shrink-0">
{renderChangeIndicator()}
{item.costDelta !== undefined && item.costDelta !== 0 && (
<span
className={cn(
"text-xs",
item.costDelta > 0 ? "text-t2" : "text-green-600",
)}
>
{item.costDelta > 0 ? "+" : ""}
{formatAmount({
amount: item.costDelta,
currency,
minFractionDigits: 2,
amountFormatOptions: { currencyDisplay: "narrowSymbol" },
})}
</span>
)}
</div>
</div>
);

View File

@@ -1,29 +1,29 @@
import type { FullCusProduct } from "@autumn/shared";
import {
type FullCusProduct,
generateTrialChanges,
generateVersionChanges,
type ItemEdit,
} from "@autumn/shared";
import { useStore } from "@tanstack/react-form";
import { useMemo } from "react";
import { SheetSection } from "@/components/v2/sheets/SharedSheetComponents";
import type { UseUpdateSubscriptionForm } from "../hooks/useUpdateSubscriptionForm";
import type { SummaryItem } from "../types/summary";
import { generateTrialChanges } from "../utils/generateTrialChanges";
import { generateVersionChanges } from "../utils/generateVersionChanges";
import { SummaryItemRow } from "./SummaryItemRow";
interface UpdateSubscriptionSummaryProps {
form: UseUpdateSubscriptionForm;
customerProduct: FullCusProduct;
currentVersion: number;
currency?: string;
}
export function UpdateSubscriptionSummary({
form,
customerProduct,
currentVersion,
currency = "usd",
}: UpdateSubscriptionSummaryProps) {
const formValues = useStore(form.store, (state) => state.values);
const changes = useMemo((): SummaryItem[] => {
const changes = useMemo((): ItemEdit[] => {
const trialChanges = generateTrialChanges({
customerProduct,
removeTrial: formValues.removeTrial,
@@ -32,8 +32,8 @@ export function UpdateSubscriptionSummary({
});
const versionChanges = generateVersionChanges({
currentVersion,
selectedVersion: formValues.version,
originalVersion: currentVersion,
updatedVersion: formValues.version,
});
return [...versionChanges, ...trialChanges];
@@ -52,7 +52,7 @@ export function UpdateSubscriptionSummary({
<SheetSection title="Changes" withSeparator>
<div className="space-y-2">
{changes.map((change) => (
<SummaryItemRow key={change.id} item={change} currency={currency} />
<SummaryItemRow key={change.id} item={change} />
))}
</div>
</SheetSection>

View File

@@ -1,11 +1,15 @@
import type { Feature, FullCusProduct, ProductItem } from "@autumn/shared";
import {
type Feature,
type FullCusProduct,
generateItemChanges,
generatePrepaidChanges,
generateTrialChanges,
generateVersionChanges,
type ProductItem,
} from "@autumn/shared";
import { useMemo } from "react";
import type { PrepaidItemWithFeature } from "@/hooks/stores/useProductStore";
import type { UpdateSubscriptionForm } from "../updateSubscriptionFormSchema";
import { generateItemChanges } from "../utils/generateItemChanges";
import { generatePrepaidChanges } from "../utils/generatePrepaidChanges";
import { generateTrialChanges } from "../utils/generateTrialChanges";
import { generateVersionChanges } from "../utils/generateVersionChanges";
export function useHasSubscriptionChanges({
formValues,
@@ -35,15 +39,15 @@ export function useHasSubscriptionChanges({
if (trialChanges.length > 0) return true;
const versionChanges = generateVersionChanges({
currentVersion,
selectedVersion: formValues.version,
originalVersion: currentVersion,
updatedVersion: formValues.version,
});
if (versionChanges.length > 0) return true;
const itemChanges = generateItemChanges({
originalItems,
customizedItems: formValues.items,
updatedItems: formValues.items,
features,
prepaidOptions: formValues.prepaidOptions,
});
@@ -58,8 +62,8 @@ export function useHasSubscriptionChanges({
const prepaidChanges = generatePrepaidChanges({
prepaidItems,
currentOptions: formValues.prepaidOptions,
initialOptions: initialPrepaidOptions,
updatedOptions: formValues.prepaidOptions,
originalOptions: initialPrepaidOptions,
}).filter((change) => {
const featureId = change.id.replace("prepaid-", "");
return !newlyAddedFeatureIds.has(featureId);

View File

@@ -1,5 +1,3 @@
// Context
// Components
export * from "./components/EditPlanSection";
export * from "./components/FreeTrialSection";
@@ -17,10 +15,7 @@ export * from "./hooks/useUpdateSubscriptionMutation";
export * from "./hooks/useUpdateSubscriptionRequestBody";
// Types
export * from "./types/summary";
export * from "./updateSubscriptionFormSchema";
// Utils
export * from "./utils/generateItemChanges";
export * from "./utils/generateVersionChanges";
export * from "./utils/getFreeTrial";

View File

@@ -1,31 +0,0 @@
import type { ProductItem } from "@autumn/shared";
export interface SummaryItem {
id: string;
type: "prepaid" | "trial" | "version" | "item";
label: string;
oldValue: string | number | null;
newValue: string | number | null;
costDelta?: number;
currency?: string;
/** 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

@@ -1,47 +0,0 @@
import type { PrepaidItemWithFeature } from "@/hooks/stores/useProductStore";
import type { SummaryItem } from "../types/summary";
export function generatePrepaidChanges({
prepaidItems,
currentOptions,
initialOptions,
currency,
}: {
prepaidItems: PrepaidItemWithFeature[];
currentOptions: Record<string, number>;
initialOptions: Record<string, number>;
currency?: string;
}): SummaryItem[] {
return prepaidItems
.map((item) => {
const featureId = item.feature_id ?? "";
const oldQuantity = initialOptions[featureId] ?? 0;
const newQuantity = currentOptions[featureId] ?? 0;
if (oldQuantity === newQuantity) return null;
const billingUnits = item.billing_units ?? 1;
const oldDisplayQuantity = oldQuantity * billingUnits;
const newDisplayQuantity = newQuantity * billingUnits;
const unitPrice = item.price ?? null;
const costDelta =
unitPrice !== null
? (newQuantity - oldQuantity) * unitPrice
: undefined;
const featureName = item.feature?.name ?? "Items";
return {
id: `prepaid-${featureId}`,
type: "prepaid" as const,
label: featureName,
oldValue: oldDisplayQuantity,
newValue: newDisplayQuantity,
costDelta,
currency,
productItem: item,
};
})
.filter(Boolean) as SummaryItem[];
}

View File

@@ -1,23 +0,0 @@
import type { SummaryItem } from "../types/summary";
export function generateVersionChanges({
currentVersion,
selectedVersion,
}: {
currentVersion: number;
selectedVersion: number;
}): SummaryItem[] {
if (selectedVersion === currentVersion) {
return [];
}
return [
{
id: "version-change",
type: "version",
label: "Plan Version",
oldValue: currentVersion,
newValue: selectedVersion,
},
];
}

View File

@@ -1,3 +1,4 @@
import type { EditIconType } from "@autumn/shared";
import {
CurrencyDollarIcon,
HashIcon,
@@ -6,7 +7,6 @@ import {
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 = {

View File

@@ -259,7 +259,6 @@ function SheetContent({
form={form}
customerProduct={customerProduct}
currentVersion={currentVersion}
currency={previewQuery.data?.currency}
/>
<UpdateSubscriptionPreviewSection