Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/tangy-facts-tell.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@bigcommerce/catalyst-core": minor
---

Adding picklist (bundle) support in cart display. Child items from picklist products are displayed nested under their parent product, matching Stencil behavior.
2 changes: 2 additions & 0 deletions core/app/[locale]/(default)/cart/page-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export const PhysicalItemFragment = graphql(`
url: urlTemplate(lossy: true)
}
entityId
parentEntityId
quantity
productEntityId
variantEntityId
Expand Down Expand Up @@ -76,6 +77,7 @@ export const DigitalItemFragment = graphql(`
url: urlTemplate(lossy: true)
}
entityId
parentEntityId
quantity
productEntityId
variantEntityId
Expand Down
112 changes: 107 additions & 5 deletions core/app/[locale]/(default)/cart/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,21 @@ const getAnalyticsData = async (cartId: string) => {

const lineItems = [...cart.lineItems.physicalItems, ...cart.lineItems.digitalItems];

return lineItems.map((item) => {
// Filter out picklist children to avoid double-counting in analytics
const itemMap = new Map(lineItems.map((item) => [item.entityId, item]));
const parentItems = lineItems.filter((item) => {
// Exclude items that are children (have parentEntityId and parent exists)
if (
'parentEntityId' in item &&
item.parentEntityId != null &&
itemMap.has(item.parentEntityId)
) {
return false;
}
return true;
});

return parentItems.map((item) => {
return {
entityId: item.entityId,
id: item.productEntityId,
Expand Down Expand Up @@ -100,11 +114,44 @@ export default async function Cart({ params }: Props) {
...cart.lineItems.digitalItems,
];

const formattedLineItems = lineItems.map((item) => {
// Group picklist children under their parents
// Create a map of all items by their entityId
const itemMap = new Map(lineItems.map((item) => [item.entityId, item]));

// Separate parents from children
// Gift certificates are always parents (they don't have parentEntityId)
const parentItems: typeof lineItems = [];
for (const item of lineItems) {
// If this is a child item (has parentEntityId and parent exists), skip adding it as a root
if (
'parentEntityId' in item &&
item.parentEntityId != null &&
itemMap.has(item.parentEntityId)
) {
continue;
}
// If this is a parent item, add it
parentItems.push(item);
}

// Format parent items and attach children
const formattedLineItems = parentItems.map((item) => {
// Find children for this parent (only physical/digital items can have children)
const children =
item.__typename !== 'CartGiftCertificate'
? lineItems.filter(
(child) =>
child.__typename !== 'CartGiftCertificate' &&
'parentEntityId' in child &&
child.parentEntityId === item.entityId &&
child.entityId !== item.entityId,
)
: [];

if (item.__typename === 'CartGiftCertificate') {
return {
typename: item.__typename,
id: item.entityId,
id: item.entityId.toString(),
title: item.name,
subtitle: `${t('GiftCertificate.to')}: ${item.recipient.name} (${item.recipient.email})${item.message ? `, ${t('GiftCertificate.message')}: ${item.message}` : ''}`,
quantity: 1,
Expand All @@ -122,9 +169,62 @@ export default async function Cart({ params }: Props) {
};
}

// Format children for physical/digital items
// Children can only be physical or digital items (not gift certificates)
const formattedChildren =
children.length > 0
? children
.filter(
(child): child is
| typeof cart.lineItems.physicalItems[number]
| typeof cart.lineItems.digitalItems[number] =>
child.__typename === 'CartPhysicalItem' ||
child.__typename === 'CartDigitalItem',
)
.map((child) => ({
typename: child.__typename,
id: child.entityId.toString(),
quantity: child.quantity,
price: format.number(child.listPrice.value, {
style: 'currency',
currency: child.listPrice.currencyCode,
}),
subtitle: child.selectedOptions
.map((option) => {
switch (option.__typename) {
case 'CartSelectedMultipleChoiceOption':
case 'CartSelectedCheckboxOption':
return `${option.name}: ${option.value}`;

case 'CartSelectedNumberFieldOption':
return `${option.name}: ${option.number}`;

case 'CartSelectedMultiLineTextFieldOption':
case 'CartSelectedTextFieldOption':
return `${option.name}: ${option.text}`;

case 'CartSelectedDateFieldOption':
return `${option.name}: ${format.dateTime(new Date(option.date.utc))}`;

default:
return '';
}
})
.join(', '),
title: child.name,
image: child.image?.url
? { src: child.image.url, alt: child.name }
: undefined,
href: new URL(child.url).pathname,
selectedOptions: child.selectedOptions,
productEntityId: child.productEntityId,
variantEntityId: child.variantEntityId,
}))
: [];

return {
typename: item.__typename,
id: item.entityId,
id: item.entityId.toString(),
quantity: item.quantity,
price: format.number(item.listPrice.value, {
style: 'currency',
Expand Down Expand Up @@ -158,6 +258,8 @@ export default async function Cart({ params }: Props) {
selectedOptions: item.selectedOptions,
productEntityId: item.productEntityId,
variantEntityId: item.variantEntityId,
children:
formattedChildren.length > 0 ? formattedChildren : undefined,
};
});

Comment on lines 258 to 265
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: The CartViewed component and getAnalyticsData() send unfiltered lineItems (including picklist child items) to analytics, causing double-counting of products.
Severity: CRITICAL | Confidence: 0.95

🔍 Detailed Analysis

The lineItems array passed to the CartViewed component and used in getAnalyticsData() includes both parent and child items from picklist products. This unfiltered array is then sent to analytics via analytics?.cart.cartViewed(), causing child items to be counted as separate products. This leads to inflated item counts and double-counting of products in analytics events when a cart contains picklist products.

💡 Suggested Fix

Filter child items from the lineItems array before passing it to the CartViewed component and getAnalyticsData(). Utilize the already filtered parentItems list for analytics data.

🤖 Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location: core/app/[locale]/(default)/cart/page.tsx#L244-L251

Potential issue: The `lineItems` array passed to the `CartViewed` component and used in
`getAnalyticsData()` includes both parent and child items from picklist products. This
unfiltered array is then sent to analytics via `analytics?.cart.cartViewed()`, causing
child items to be counted as separate products. This leads to inflated item counts and
double-counting of products in analytics events when a cart contains picklist products.

Did we get this right? 👍 / 👎 to inform future reviews.
Reference_id: 2790785

Expand Down Expand Up @@ -347,7 +449,7 @@ export default async function Cart({ params }: Props) {
</CartAnalyticsProvider>
<CartViewed
currencyCode={cart.currencyCode}
lineItems={lineItems}
lineItems={parentItems}
subtotal={checkout?.subtotal?.value}
/>
</>
Expand Down
20 changes: 20 additions & 0 deletions core/vibes/soul/sections/cart/client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export interface CartLineItem {
quantity: number;
price: string;
href?: string;
children?: CartLineItem[];
}

export interface CartGiftCertificateLineItem extends CartLineItem {
Expand Down Expand Up @@ -453,6 +454,25 @@ export function CartClient<LineItem extends CartLineItem>({
<span className="text-[var(--cart-subtext-text,hsl(var(--contrast-300)))] contrast-more:text-[var(--cart-subtitle-text,hsl(var(--contrast-500)))]">
{lineItem.subtitle}
</span>
{lineItem.children && lineItem.children.length > 0 && (
<ul
className="mt-3 ml-4 space-y-2 border-l-2 border-[var(--cart-border,hsl(var(--contrast-100)))] pl-4"
aria-label="Included items"
>
{lineItem.children.map((child) => (
<li key={child.id} aria-label="Included item">
<div className="text-sm text-[var(--cart-text,hsl(var(--foreground)))]">
{child.title} × {child.quantity}
</div>
{child.subtitle && (
<div className="text-xs text-[var(--cart-subtext-text,hsl(var(--contrast-300)))] contrast-more:text-[var(--cart-subtitle-text,hsl(var(--contrast-500)))]">
{child.subtitle}
</div>
)}
</li>
))}
</ul>
)}
</div>
<CounterForm
action={formAction}
Expand Down
Loading