chore: improve plan items sorting, collapsible booleans, and product comparison utils
Made-with: Cursor
This commit is contained in:
@@ -53,6 +53,7 @@ export * from "./productUtils/priceUtils";
|
||||
export * from "./productV2Utils/mapToProductV2";
|
||||
export * from "./productV2Utils/productItemUtils/classifyItemUtils";
|
||||
export * from "./productV2Utils/productItemUtils/getItemType";
|
||||
export * from "./productV2Utils/productItemUtils/sortPlanItems";
|
||||
// Item utils
|
||||
export * from "./productV2Utils/productItemUtils/mapToItem";
|
||||
export * from "./productV2Utils/productItemUtils/productItemUtils";
|
||||
|
||||
@@ -34,6 +34,7 @@ export const findSimilarItem = ({
|
||||
if (isFeatureItem(item)) {
|
||||
return items.find(
|
||||
(i) =>
|
||||
isFeatureItem(i) &&
|
||||
i.feature_id === item.feature_id &&
|
||||
entIntervalsSame({
|
||||
intervalA: {
|
||||
@@ -51,6 +52,7 @@ export const findSimilarItem = ({
|
||||
if (isFeaturePriceItem(item)) {
|
||||
return items.find(
|
||||
(i) =>
|
||||
isFeaturePriceItem(i) &&
|
||||
i.feature_id === item.feature_id &&
|
||||
intervalsSame({
|
||||
intervalA: {
|
||||
|
||||
@@ -164,11 +164,7 @@ export const productsAreSame = ({
|
||||
|
||||
if (items1.length !== items2.length) itemsSame = false;
|
||||
|
||||
// console.log(`items1: `, items1);
|
||||
// console.log(`items2: `, items2);
|
||||
|
||||
for (const item of items1) {
|
||||
// console.log(`base item: `, formatItem({ item, features }));
|
||||
const similarItem = findSimilarItem({
|
||||
item,
|
||||
items: items2,
|
||||
@@ -184,7 +180,6 @@ export const productsAreSame = ({
|
||||
|
||||
continue;
|
||||
}
|
||||
// console.log(`similar item: `, formatItem({ item: similarItem, features }));
|
||||
|
||||
const { same, pricesChanged: pricesChanged_ } = itemsAreSame({
|
||||
item1: item,
|
||||
|
||||
109
shared/utils/productV2Utils/productItemUtils/sortPlanItems.ts
Normal file
109
shared/utils/productV2Utils/productItemUtils/sortPlanItems.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import type { ProductItem } from "../../../models/productV2Models/productItemModels/productItemModels.js";
|
||||
import { UsageModel } from "../../../models/productV2Models/productItemModels/productItemModels.js";
|
||||
import { notNullish } from "../../utils.js";
|
||||
import { isBooleanFeatureItem, isFeaturePriceItem } from "./getItemType.js";
|
||||
|
||||
const BOOLEAN_COLLAPSE_THRESHOLD = 5;
|
||||
|
||||
/**
|
||||
* Priority bucket for a plan item within a category group.
|
||||
* Lower number = rendered first.
|
||||
*/
|
||||
function getItemPriority(item: ProductItem): number {
|
||||
if (isFeaturePriceItem(item)) {
|
||||
return item.usage_model === UsageModel.Prepaid ? 0 : 1;
|
||||
}
|
||||
if (isBooleanFeatureItem(item)) return 3;
|
||||
// Metered feature without pricing (has included_usage or interval)
|
||||
return 2;
|
||||
}
|
||||
|
||||
function compareItems(a: ProductItem, b: ProductItem): number {
|
||||
const priorityA = getItemPriority(a);
|
||||
const priorityB = getItemPriority(b);
|
||||
if (priorityA !== priorityB) return priorityA - priorityB;
|
||||
|
||||
// Within the same priority, group by feature_id so duplicates stay adjacent
|
||||
const featureA = a.feature_id ?? "";
|
||||
const featureB = b.feature_id ?? "";
|
||||
return featureA.localeCompare(featureB);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort plan items into a consistent display order:
|
||||
* 1. Non-entity items first (no entity_feature_id)
|
||||
* 2. Entity-scoped items last, grouped by entity_feature_id
|
||||
*
|
||||
* Within each group the sub-order is:
|
||||
* a. Priced features (prepaid before pay-per-use)
|
||||
* b. Metered features without pricing
|
||||
* c. Boolean features
|
||||
*
|
||||
* Items sharing the same feature_id are kept adjacent.
|
||||
* Does not mutate the input array.
|
||||
*/
|
||||
export function sortPlanItems({
|
||||
items,
|
||||
}: {
|
||||
items: ProductItem[];
|
||||
}): ProductItem[] {
|
||||
const nonEntity: ProductItem[] = [];
|
||||
const entityGroups = new Map<string, ProductItem[]>();
|
||||
|
||||
for (const item of items) {
|
||||
if (notNullish(item.entity_feature_id)) {
|
||||
const group = entityGroups.get(item.entity_feature_id) ?? [];
|
||||
group.push(item);
|
||||
entityGroups.set(item.entity_feature_id, group);
|
||||
} else {
|
||||
nonEntity.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
nonEntity.sort(compareItems);
|
||||
|
||||
const sortedEntityKeys = [...entityGroups.keys()].sort((a, b) =>
|
||||
a.localeCompare(b),
|
||||
);
|
||||
|
||||
const sortedEntityItems: ProductItem[] = [];
|
||||
for (const key of sortedEntityKeys) {
|
||||
const group = entityGroups.get(key);
|
||||
if (!group) continue;
|
||||
group.sort(compareItems);
|
||||
sortedEntityItems.push(...group);
|
||||
}
|
||||
|
||||
return [...nonEntity, ...sortedEntityItems];
|
||||
}
|
||||
|
||||
/**
|
||||
* Split already-sorted items into those rendered inline and boolean
|
||||
* overflow items that should be collapsed behind an accordion.
|
||||
*
|
||||
* The first `BOOLEAN_COLLAPSE_THRESHOLD` boolean items stay visible;
|
||||
* any beyond that are returned in `collapsedBooleanItems`.
|
||||
*/
|
||||
export function splitBooleanItems({ items }: { items: ProductItem[] }): {
|
||||
visibleItems: ProductItem[];
|
||||
collapsedBooleanItems: ProductItem[];
|
||||
} {
|
||||
let booleanCount = 0;
|
||||
const visibleItems: ProductItem[] = [];
|
||||
const collapsedBooleanItems: ProductItem[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
if (isBooleanFeatureItem(item)) {
|
||||
booleanCount++;
|
||||
if (booleanCount <= BOOLEAN_COLLAPSE_THRESHOLD) {
|
||||
visibleItems.push(item);
|
||||
} else {
|
||||
collapsedBooleanItems.push(item);
|
||||
}
|
||||
} else {
|
||||
visibleItems.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
return { visibleItems, collapsedBooleanItems };
|
||||
}
|
||||
@@ -4,12 +4,15 @@ import type {
|
||||
FrontendProduct,
|
||||
ProductItem,
|
||||
} from "@autumn/shared";
|
||||
import { sortPlanItems, splitBooleanItems } from "@autumn/shared";
|
||||
import { PencilSimpleIcon } from "@phosphor-icons/react";
|
||||
import { LayoutGroup, motion } from "motion/react";
|
||||
import { useMemo } from "react";
|
||||
import type { UseAttachForm } from "@/components/forms/attach-v2/hooks/useAttachForm";
|
||||
import type { UseUpdateSubscriptionForm } from "@/components/forms/update-subscription-v2/hooks/useUpdateSubscriptionForm";
|
||||
import { Button } from "@/components/v2/buttons/Button";
|
||||
import { LAYOUT_TRANSITION } from "@/components/v2/sheets/SharedSheetComponents";
|
||||
import { CollapsedBooleanItems } from "./plan-items/CollapsedBooleanItems";
|
||||
import { DeletedItemRow } from "./plan-items/DeletedItemRow";
|
||||
import { PlanEditButton } from "./plan-items/PlanEditButton";
|
||||
import { PlanItemRow } from "./plan-items/PlanItemRow";
|
||||
@@ -94,6 +97,15 @@ export function PlanItemsSection({
|
||||
(i) => i.feature_id && !currentFeatureIds.has(i.feature_id),
|
||||
) ?? []);
|
||||
|
||||
const sortedItems = useMemo(
|
||||
() => sortPlanItems({ items: product?.items ?? [] }),
|
||||
[product?.items],
|
||||
);
|
||||
const { visibleItems, collapsedBooleanItems } = useMemo(
|
||||
() => splitBooleanItems({ items: sortedItems }),
|
||||
[sortedItems],
|
||||
);
|
||||
|
||||
const hasItems = (product?.items?.length ?? 0) > 0 || deletedItems.length > 0;
|
||||
|
||||
if (!hasItems) {
|
||||
@@ -117,6 +129,9 @@ export function PlanItemsSection({
|
||||
readOnly,
|
||||
};
|
||||
|
||||
const itemKey = (item: ProductItem) =>
|
||||
`${item.feature_id ?? ""}-${item.price_id ?? ""}-${item.interval ?? ""}-${item.interval_count ?? ""}`;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PlanPriceHeader
|
||||
@@ -130,17 +145,30 @@ export function PlanItemsSection({
|
||||
layout="position"
|
||||
transition={{ layout: LAYOUT_TRANSITION }}
|
||||
>
|
||||
{product?.items?.map((item, index) => (
|
||||
{visibleItems.map((item, index) => (
|
||||
<PlanItemRow
|
||||
key={`${item.feature_id ?? ""}-${item.price_id ?? ""}-${item.interval ?? ""}-${item.interval_count ?? ""}`}
|
||||
key={itemKey(item)}
|
||||
item={item}
|
||||
index={index}
|
||||
{...itemRowProps}
|
||||
/>
|
||||
))}
|
||||
{collapsedBooleanItems.length > 0 && (
|
||||
<CollapsedBooleanItems
|
||||
items={collapsedBooleanItems}
|
||||
renderItem={(item, index) => (
|
||||
<PlanItemRow
|
||||
key={itemKey(item)}
|
||||
item={item}
|
||||
index={visibleItems.length + index}
|
||||
{...itemRowProps}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{deletedItems.map((item, index) => (
|
||||
<DeletedItemRow
|
||||
key={`deleted-${item.feature_id ?? ""}-${item.price_id ?? ""}-${item.interval ?? ""}-${item.interval_count ?? ""}`}
|
||||
key={`deleted-${itemKey(item)}`}
|
||||
item={item}
|
||||
index={index}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { ProductItem } from "@autumn/shared";
|
||||
import { type ReactNode, useState } from "react";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { LAYOUT_TRANSITION } from "@/components/v2/sheets/SharedSheetComponents";
|
||||
import { motion } from "motion/react";
|
||||
|
||||
interface CollapsedBooleanItemsProps {
|
||||
items: ProductItem[];
|
||||
renderItem: (item: ProductItem, index: number) => ReactNode;
|
||||
}
|
||||
|
||||
export function CollapsedBooleanItems({
|
||||
items,
|
||||
renderItem,
|
||||
}: CollapsedBooleanItemsProps) {
|
||||
const [value, setValue] = useState("");
|
||||
|
||||
if (items.length === 0) return null;
|
||||
|
||||
const isExpanded = value === "boolean-flags";
|
||||
const label = isExpanded
|
||||
? "Hide"
|
||||
: `${items.length} more`;
|
||||
|
||||
return (
|
||||
<Accordion
|
||||
type="single"
|
||||
collapsible
|
||||
value={value}
|
||||
onValueChange={setValue}
|
||||
className="w-full"
|
||||
>
|
||||
<AccordionItem value="boolean-flags" className="border-none">
|
||||
<AccordionTrigger className="py-2 px-3 rounded-xl text-t3 hover:bg-interative-secondary hover:no-underline">
|
||||
<span className="text-sm font-normal">
|
||||
{label} boolean flag{items.length === 1 ? "" : "s"}
|
||||
</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pb-1.5 pt-1.5 px-0">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{items.map((item, index) => (
|
||||
<motion.div
|
||||
key={item.feature_id ?? index}
|
||||
layout="position"
|
||||
transition={{ layout: LAYOUT_TRANSITION }}
|
||||
>
|
||||
{renderItem(item, index)}
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { FrontendProduct, ProductItem } from "@autumn/shared";
|
||||
import { sortPlanItems } from "@autumn/shared";
|
||||
import { type ReactNode, useCallback, useMemo, useState } from "react";
|
||||
import { useItemDraftController } from "@/hooks/inline-editor/useItemDraftController";
|
||||
import { ProductProvider } from "./PlanEditorContext";
|
||||
@@ -22,8 +23,16 @@ interface InlineEditorProviderProps {
|
||||
*/
|
||||
export function InlineEditorProvider({
|
||||
children,
|
||||
initialProduct,
|
||||
initialProduct: initialProductProp,
|
||||
}: InlineEditorProviderProps) {
|
||||
const initialProduct = useMemo<FrontendProduct>(
|
||||
() => ({
|
||||
...initialProductProp,
|
||||
items: sortPlanItems({ items: initialProductProp.items }),
|
||||
}),
|
||||
[initialProductProp],
|
||||
);
|
||||
|
||||
const [sheetType, setSheetType] = useState<SheetType>(null);
|
||||
const [itemId, setItemId] = useState<string | null>(null);
|
||||
const [initialItem, setInitialItemState] = useState<ProductItem | null>(null);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { FrontendProduct } from "@autumn/shared";
|
||||
import { type FrontendProduct, sortPlanItems } from "@autumn/shared";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Button } from "@/components/v2/buttons/Button";
|
||||
@@ -88,7 +88,12 @@ function InlinePlanEditorContent({
|
||||
{hasPlanChanges && (
|
||||
<ShortcutButton
|
||||
metaShortcut="s"
|
||||
onClick={() => onSave(product)}
|
||||
onClick={() =>
|
||||
onSave({
|
||||
...product,
|
||||
items: sortPlanItems({ items: product.items }),
|
||||
})
|
||||
}
|
||||
>
|
||||
Save Changes
|
||||
</ShortcutButton>
|
||||
|
||||
@@ -78,11 +78,12 @@ export const useHasChanges = () => {
|
||||
features,
|
||||
});
|
||||
|
||||
return (
|
||||
const hasChanges =
|
||||
!comparison.itemsSame ||
|
||||
!comparison.detailsSame ||
|
||||
!comparison.freeTrialsSame
|
||||
);
|
||||
!comparison.freeTrialsSame;
|
||||
|
||||
return hasChanges;
|
||||
}, [product, baseProduct, features]);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { FrontendProduct, ProductV2 } from "@autumn/shared";
|
||||
import { productV2ToFrontendProduct } from "@autumn/shared";
|
||||
import { productV2ToFrontendProduct, sortPlanItems } from "@autumn/shared";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useProductStore } from "./useProductStore";
|
||||
|
||||
@@ -29,8 +29,11 @@ export const useProductSync = ({
|
||||
if (isNewProduct || isProductUpdated) {
|
||||
lastProductRef.current = product;
|
||||
|
||||
// Convert ProductV2 to FrontendProduct
|
||||
const frontendProduct = productV2ToFrontendProduct({ product });
|
||||
const converted = productV2ToFrontendProduct({ product });
|
||||
const frontendProduct: FrontendProduct = {
|
||||
...converted,
|
||||
items: sortPlanItems({ items: converted.items }),
|
||||
};
|
||||
|
||||
// Always update baseProduct to reflect backend state
|
||||
setBaseProduct(frontendProduct);
|
||||
|
||||
@@ -315,7 +315,7 @@ function SelectContent() {
|
||||
|
||||
{entityId ? (
|
||||
<div className="pt-2">
|
||||
<InfoBox variant="info">
|
||||
<InfoBox variant="note">
|
||||
Attaching plan to entity{" "}
|
||||
<span className="font-semibold">
|
||||
{fullEntity?.name || fullEntity?.id}
|
||||
@@ -324,7 +324,7 @@ function SelectContent() {
|
||||
</div>
|
||||
) : entities.length > 0 ? (
|
||||
<div className="pt-2">
|
||||
<InfoBox variant="info">
|
||||
<InfoBox variant="note">
|
||||
Attaching plan to customer - all entities will get access
|
||||
</InfoBox>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import {
|
||||
CusProductStatus,
|
||||
type Entity,
|
||||
type FrontendProduct,
|
||||
isCustomerProductTrialing,
|
||||
type ProductItem,
|
||||
sortPlanItems,
|
||||
splitBooleanItems,
|
||||
UsageModel,
|
||||
} from "@autumn/shared";
|
||||
import {
|
||||
@@ -20,6 +23,8 @@ import {
|
||||
XCircle,
|
||||
} from "@phosphor-icons/react";
|
||||
import { format } from "date-fns";
|
||||
import { useMemo } from "react";
|
||||
import { CollapsedBooleanItems } from "@/components/forms/shared/plan-items/CollapsedBooleanItems";
|
||||
import { Button } from "@/components/v2/buttons/Button";
|
||||
import { MiniCopyButton } from "@/components/v2/buttons/CopyButton";
|
||||
import { IconButton } from "@/components/v2/buttons/IconButton";
|
||||
@@ -39,6 +44,60 @@ import { BasePriceDisplay } from "@/views/products/plan/components/plan-card/Bas
|
||||
import { PlanFeatureRow } from "@/views/products/plan/components/plan-card/PlanFeatureRow";
|
||||
import { CustomerProductsStatus } from "../table/customer-products/CustomerProductsStatus";
|
||||
|
||||
function SubscriptionDetailItems({
|
||||
items,
|
||||
product,
|
||||
prepaidDisplayQuantities,
|
||||
}: {
|
||||
items: ProductItem[];
|
||||
product: FrontendProduct;
|
||||
prepaidDisplayQuantities: Record<string, number>;
|
||||
}) {
|
||||
const sortedItems = useMemo(() => sortPlanItems({ items }), [items]);
|
||||
const { visibleItems, collapsedBooleanItems } = useMemo(
|
||||
() => splitBooleanItems({ items: sortedItems }),
|
||||
[sortedItems],
|
||||
);
|
||||
|
||||
const renderRow = (item: ProductItem, index: number) => {
|
||||
if (!item.feature_id) return null;
|
||||
const prepaidQuantity =
|
||||
item.usage_model === UsageModel.Prepaid
|
||||
? (prepaidDisplayQuantities[item.feature_id] ?? null)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<PlanFeatureRow
|
||||
key={item.feature_id || item.price_id || index}
|
||||
item={item}
|
||||
index={index}
|
||||
readOnly={true}
|
||||
prepaidQuantity={prepaidQuantity}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<SheetSection>
|
||||
<div className="flex gap-2 justify-between items-center h-6 mb-3">
|
||||
<BasePriceDisplay product={product} readOnly={true} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{visibleItems.map((item, index) => renderRow(item, index))}
|
||||
{collapsedBooleanItems.length > 0 && (
|
||||
<CollapsedBooleanItems
|
||||
items={collapsedBooleanItems}
|
||||
renderItem={(item, index) =>
|
||||
renderRow(item, visibleItems.length + index)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</SheetSection>
|
||||
);
|
||||
}
|
||||
|
||||
export function SubscriptionDetailSheet() {
|
||||
const { customer } = useCusQuery();
|
||||
const { stripeAccount } = useOrgStripeQuery();
|
||||
@@ -121,33 +180,11 @@ export function SubscriptionDetailSheet() {
|
||||
/>
|
||||
|
||||
{productV2?.items && productV2.items.length > 0 && (
|
||||
<SheetSection>
|
||||
{productV2 && (
|
||||
<div className="flex gap-2 justify-between items-center h-6 mb-3">
|
||||
<BasePriceDisplay product={productV2} readOnly={true} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{productV2.items.map((item: ProductItem, index: number) => {
|
||||
if (!item.feature_id) return null;
|
||||
const prepaidQuantity =
|
||||
item.usage_model === UsageModel.Prepaid
|
||||
? (prepaidDisplayQuantities[item.feature_id] ?? null)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<PlanFeatureRow
|
||||
key={item.feature_id || item.price_id || index}
|
||||
item={item}
|
||||
index={index}
|
||||
readOnly={true}
|
||||
prepaidQuantity={prepaidQuantity}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</SheetSection>
|
||||
<SubscriptionDetailItems
|
||||
items={productV2.items}
|
||||
product={productV2}
|
||||
prepaidDisplayQuantities={prepaidDisplayQuantities}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SheetSection withSeparator={true}>
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { type ProductItem, productV2ToFeatureItems } from "@autumn/shared";
|
||||
import {
|
||||
type ProductItem,
|
||||
productV2ToFeatureItems,
|
||||
sortPlanItems,
|
||||
splitBooleanItems,
|
||||
} from "@autumn/shared";
|
||||
import { useMemo } from "react";
|
||||
import { CollapsedBooleanItems } from "@/components/forms/shared/plan-items/CollapsedBooleanItems";
|
||||
import {
|
||||
useProduct,
|
||||
useSheet,
|
||||
@@ -9,6 +16,16 @@ import { AddFeatureRow } from "./AddFeatureRow";
|
||||
import { DummyPlanFeatureRow } from "./DummyPlanFeatureRow";
|
||||
import { PlanFeatureRow } from "./PlanFeatureRow";
|
||||
|
||||
function EntityGroupHeader({ entityFeatureId }: { entityFeatureId: string }) {
|
||||
const { features } = useFeaturesQuery();
|
||||
const feature = features.find((f) => f.id === entityFeatureId);
|
||||
return (
|
||||
<div className="text-sm font-medium text-body-secondary px-2 pt-2">
|
||||
{feature?.name || entityFeatureId}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const PlanFeatureList = ({
|
||||
allowAddFeature = true,
|
||||
}: {
|
||||
@@ -16,25 +33,27 @@ export const PlanFeatureList = ({
|
||||
}) => {
|
||||
const { product, setProduct } = useProduct();
|
||||
const { sheetType, itemId, setSheet } = useSheet();
|
||||
const { features } = useFeaturesQuery();
|
||||
|
||||
const isCreatingFeature = sheetType === "new-feature" || itemId === "new";
|
||||
const isAddButtonDisabled =
|
||||
isCreatingFeature || sheetType === "select-feature";
|
||||
|
||||
const filteredItems = useMemo(
|
||||
() => (product ? productV2ToFeatureItems({ items: product.items }) : []),
|
||||
[product],
|
||||
);
|
||||
const sortedItems = useMemo(
|
||||
() => sortPlanItems({ items: filteredItems }),
|
||||
[filteredItems],
|
||||
);
|
||||
const { visibleItems, collapsedBooleanItems } = useMemo(
|
||||
() => splitBooleanItems({ items: sortedItems }),
|
||||
[sortedItems],
|
||||
);
|
||||
|
||||
if (!product) return null;
|
||||
|
||||
const filteredItems = productV2ToFeatureItems({ items: product.items });
|
||||
|
||||
const groupedItems = filteredItems.reduce(
|
||||
(acc, item) => {
|
||||
const key = item.entity_feature_id || "no_entity";
|
||||
if (!acc[key]) acc[key] = [];
|
||||
acc[key].push(item);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, ProductItem[]>,
|
||||
);
|
||||
const hasEntityItems = sortedItems.some((i) => i.entity_feature_id);
|
||||
|
||||
const handleDelete = (item: ProductItem) => {
|
||||
if (!product.items) return;
|
||||
@@ -73,41 +92,52 @@ export const PlanFeatureList = ({
|
||||
);
|
||||
}
|
||||
|
||||
const groups = Object.entries(groupedItems).sort(([keyA], [keyB]) => {
|
||||
if (keyA === "no_entity") return -1;
|
||||
if (keyB === "no_entity") return 1;
|
||||
return 0;
|
||||
});
|
||||
const hasEntityFeatureIds = groups.some(([key]) => key !== "no_entity");
|
||||
const renderFeatureRow = (item: ProductItem) => {
|
||||
const itemIndex = product.items?.indexOf(item) ?? -1;
|
||||
return (
|
||||
<PlanFeatureRow
|
||||
key={item.entitlement_id || item.price_id || itemIndex}
|
||||
item={item}
|
||||
index={itemIndex}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const renderVisibleItems = () => {
|
||||
const elements: React.ReactNode[] = [];
|
||||
let lastEntityId: string | null | undefined;
|
||||
|
||||
for (const item of visibleItems) {
|
||||
if (
|
||||
hasEntityItems &&
|
||||
item.entity_feature_id &&
|
||||
item.entity_feature_id !== lastEntityId
|
||||
) {
|
||||
elements.push(
|
||||
<EntityGroupHeader
|
||||
key={`header-${item.entity_feature_id}`}
|
||||
entityFeatureId={item.entity_feature_id}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
lastEntityId = item.entity_feature_id;
|
||||
elements.push(renderFeatureRow(item));
|
||||
}
|
||||
|
||||
return elements;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{groups.map(([entityFeatureId, items]) => {
|
||||
const feature = features.find((f) => f.id === entityFeatureId);
|
||||
const showHeader =
|
||||
hasEntityFeatureIds && entityFeatureId !== "no_entity";
|
||||
{renderVisibleItems()}
|
||||
|
||||
return (
|
||||
<div key={entityFeatureId} className="space-y-2">
|
||||
{showHeader && (
|
||||
<div className="text-sm font-medium text-body-secondary px-2 pt-2">
|
||||
{feature?.name || entityFeatureId}
|
||||
</div>
|
||||
)}
|
||||
{items.map((item: ProductItem) => {
|
||||
const itemIndex = product.items?.indexOf(item) ?? -1;
|
||||
return (
|
||||
<PlanFeatureRow
|
||||
key={item.entitlement_id || item.price_id || itemIndex}
|
||||
item={item}
|
||||
index={itemIndex}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{collapsedBooleanItems.length > 0 && (
|
||||
<CollapsedBooleanItems
|
||||
items={collapsedBooleanItems}
|
||||
renderItem={(item) => renderFeatureRow(item)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{allowAddFeature &&
|
||||
(isCreatingNewFeature ? (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
type FrontendProductItem,
|
||||
sortPlanItems,
|
||||
type UpdateProductV2Params,
|
||||
UpdateProductV2ParamsSchema,
|
||||
} from "@autumn/shared";
|
||||
@@ -30,9 +31,10 @@ export const updateProduct = async ({
|
||||
}
|
||||
|
||||
try {
|
||||
const sortedItems = sortPlanItems({ items: product.items });
|
||||
const updateData = UpdateProductV2ParamsSchema.parse({
|
||||
...product,
|
||||
items: product.items,
|
||||
items: sortedItems,
|
||||
free_trial: product.free_trial,
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user