Skip to content
Merged
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
15 changes: 15 additions & 0 deletions .changeset/tender-toys-heal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
"@bigcommerce/catalyst-core": minor
---

Add the following messages to each line item on cart page based on store inventory settings:
- Fully/partially out-of-stock message if enabled on the store and the line item is currently out of stock
- Ready-to-ship quantity if enabled on the store
- Backordered quantity if enabled on the store

## Migration
For existing Catalyst stores, to get the newly added feature, simply rebase the existing code with the new release code. The files to be rebased for this change to be applied are:
- core/app/[locale]/(default)/cart/page-data.ts
- core/app/[locale]/(default)/cart/page.tsx
- core/messages/en.json
- core/vibes/soul/sections/cart/client.tsx
13 changes: 13 additions & 0 deletions core/app/[locale]/(default)/cart/page-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,12 @@ export const PhysicalItemFragment = graphql(`
}
}
url
stockPosition {
backorderMessage
quantityOnHand
quantityBackordered
quantityOutOfStock
}
}
`);

Expand Down Expand Up @@ -206,6 +212,13 @@ const CartPageQuery = graphql(
query CartPageQuery($cartId: String, $currencyCode: currencyCode) {
site {
settings {
inventory {
defaultOutOfStockMessage
showOutOfStockMessage
showBackorderMessage
Comment on lines +216 to +218
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Out of curiosity, are these messages translated?

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Yes, the admin setup is still in progress. As part of the final definition of done, the messages will be translated in the inventory service via the unified translations API.

showQuantityOnBackorder
showQuantityOnHand
}
url {
checkoutUrl
}
Expand Down
42 changes: 42 additions & 0 deletions core/app/[locale]/(default)/cart/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,47 @@ export default async function Cart({ params }: Props) {
};
}

let inventoryMessages;

if (item.__typename === 'CartPhysicalItem') {
if (item.stockPosition?.quantityOutOfStock === item.quantity) {
inventoryMessages = {
outOfStockMessage: data.site.settings?.inventory?.showOutOfStockMessage
? data.site.settings.inventory.defaultOutOfStockMessage
: undefined,
};
} else {
inventoryMessages = {
quantityReadyToShipMessage:
data.site.settings?.inventory?.showQuantityOnHand &&
!!item.stockPosition?.quantityOnHand
? t('quantityReadyToShip', {
quantity: Number(item.stockPosition.quantityOnHand),
})
: undefined,
quantityBackorderedMessage:
data.site.settings?.inventory?.showQuantityOnBackorder &&
!!item.stockPosition?.quantityBackordered
? t('quantityOnBackorder', {
quantity: Number(item.stockPosition.quantityBackordered),
})
: undefined,
quantityOutOfStockMessage:
data.site.settings?.inventory?.showOutOfStockMessage &&
!!item.stockPosition?.quantityOutOfStock
? t('partiallyAvailable', {
quantity: item.quantity - Number(item.stockPosition.quantityOutOfStock),
})
: undefined,
backorderMessage:
data.site.settings?.inventory?.showBackorderMessage &&
!!item.stockPosition?.quantityBackordered
? (item.stockPosition.backorderMessage ?? undefined)
: undefined,
};
}
}

return {
typename: item.__typename,
id: item.entityId,
Expand Down Expand Up @@ -165,6 +206,7 @@ export default async function Cart({ params }: Props) {
selectedOptions: item.selectedOptions,
productEntityId: item.productEntityId,
variantEntityId: item.variantEntityId,
inventoryMessages,
};
});

Expand Down
3 changes: 3 additions & 0 deletions core/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,9 @@
"cartUpdateInProgress": "You have a cart update in progress. Are you sure you want to leave this page? Your changes may be lost.",
"originalPrice": "Original price was {price}.",
"currentPrice": "Current price is {price}.",
"quantityReadyToShip": "{quantity, number} ready to ship",
"quantityOnBackorder": "{quantity, number} will be backordered",
"partiallyAvailable": "Only {quantity, number} available",
"CheckoutSummary": {
"title": "Summary",
"subTotal": "Subtotal",
Expand Down
157 changes: 101 additions & 56 deletions core/vibes/soul/sections/cart/client.tsx
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The only question I have left here is related to the screenshot from your PR desc:
Screenshot 2025-12-17 at 1 33 03 PM

If I have 4 in my cart, but only 3 are available in the store (where 1 is ready to ship, and 2 will be on backorder), what happens to the 4th item unaccounted for in the backorder messaging? Is the intent that clicking "Proceed to checkout" will return an error?

Copy link
Copy Markdown
Contributor Author

@Tharaae Tharaae Dec 29, 2025

Choose a reason for hiding this comment

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

Is the intent that clicking "Proceed to checkout" will return an error?

Yes. This is highlighting the error for the shopper to fix before "proceed to checkout". If they didn't fix the quantities, they will get the error of they attempt to checkout. I also changed the counter border color in case of quantity error to highlight the error (as in the updated screenshot in the PR description).

Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,14 @@ import { CartEmptyState } from '.';

type Action<State, Payload> = (state: Awaited<State>, payload: Payload) => State | Promise<State>;

interface CartLineIteminventoryMessages {
outOfStockMessage?: string;
quantityReadyToShipMessage?: string;
quantityBackorderedMessage?: string;
quantityOutOfStockMessage?: string;
backorderMessage?: string;
}

export interface CartLineItem {
typename: string;
id: string;
Expand All @@ -44,6 +52,7 @@ export interface CartLineItem {
price: string;
salePrice?: string;
href?: string;
inventoryMessages?: CartLineIteminventoryMessages;
}

export interface CartGiftCertificateLineItem extends CartLineItem {
Expand Down Expand Up @@ -563,7 +572,7 @@ function CounterForm({
<input {...getInputProps(fields.id, { type: 'hidden' })} key={fields.id.id} />
<div className="flex w-full flex-wrap items-center gap-x-5 gap-y-2">
{lineItem.salePrice && lineItem.salePrice !== lineItem.price ? (
<span className="font-medium @xl:ml-auto">
<span className="mt-3 self-start font-medium @xl:ml-auto">
<span className="sr-only">{t('originalPrice', { price: lineItem.price })}</span>
<span aria-hidden="true" className="line-through">
{lineItem.price}
Expand All @@ -572,65 +581,101 @@ function CounterForm({
<span aria-hidden="true">{lineItem.salePrice}</span>
</span>
) : (
<span className="font-medium @xl:ml-auto">{lineItem.price}</span>
<span className="mt-3 self-start font-medium @xl:ml-auto">{lineItem.price}</span>
)}
{/* Counter */}
<div className="flex items-center rounded-lg border border-[var(--cart-counter-border,hsl(var(--contrast-100)))]">
<button
aria-label={decrementLabel}
className={clsx(
'group rounded-l-lg bg-[var(--cart-counter-background,hsl(var(--background)))] p-3 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--cart-focus,hsl(var(--primary)))] disabled:cursor-not-allowed',
lineItem.quantity === 1
? 'opacity-50'
: 'hover:bg-[var(--cart-counter-background-hover,hsl(var(--contrast-100)/50%))]',
)}
disabled={lineItem.quantity === 1}
name="intent"
type="submit"
value="decrement"
>
<Minus
<div className="flex size-min flex-col gap-y-0">
<div className="mb-1 mt-1 flex items-center gap-x-5">
{/* Counter */}
<div
className={clsx(
'text-[var(--cart-counter-icon,hsl(var(--contrast-300)))] transition-colors duration-300',
lineItem.quantity !== 1 &&
'group-hover:text-[var(--cart-counter-icon-hover,hsl(var(--foreground)))]',
'flex items-center rounded-lg border border-[var(--cart-counter-border,hsl(var(--contrast-100)))]',
(lineItem.inventoryMessages?.outOfStockMessage != null ||
lineItem.inventoryMessages?.quantityOutOfStockMessage != null) &&
'border-red-500',
)}
size={18}
strokeWidth={1.5}
/>
</button>
<span className="flex w-8 select-none justify-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--cart-focus,hsl(var(--primary)))]">
{lineItem.quantity}
</span>
<button
aria-label={incrementLabel}
className={clsx(
'group rounded-r-lg bg-[var(--cart-counter-background,hsl(var(--background)))] p-3 transition-colors duration-300 hover:bg-[var(--cart-counter-background-hover,hsl(var(--contrast-100)/50%))] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--cart-focus,hsl(var(--primary)))] disabled:cursor-not-allowed',
)}
name="intent"
type="submit"
value="increment"
>
<Plus
className="text-[var(--cart-counter-icon,hsl(var(--contrast-300)))] transition-colors duration-300 group-hover:text-[var(--cart-counter-icon-hover,hsl(var(--foreground)))]"
size={18}
strokeWidth={1.5}
/>
</button>
>
<button
aria-label={decrementLabel}
className={clsx(
'group rounded-l-lg bg-[var(--cart-counter-background,hsl(var(--background)))] p-3 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--cart-focus,hsl(var(--primary)))] disabled:cursor-not-allowed',
lineItem.quantity === 1
? 'opacity-50'
: 'hover:bg-[var(--cart-counter-background-hover,hsl(var(--contrast-100)/50%))]',
)}
disabled={lineItem.quantity === 1}
name="intent"
type="submit"
value="decrement"
>
<Minus
className={clsx(
'text-[var(--cart-counter-icon,hsl(var(--contrast-300)))] transition-colors duration-300',
lineItem.quantity !== 1 &&
'group-hover:text-[var(--cart-counter-icon-hover,hsl(var(--foreground)))]',
)}
size={18}
strokeWidth={1.5}
/>
</button>
<span className="flex w-8 select-none justify-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--cart-focus,hsl(var(--primary)))]">
{lineItem.quantity}
</span>
<button
aria-label={incrementLabel}
className={clsx(
'group rounded-r-lg bg-[var(--cart-counter-background,hsl(var(--background)))] p-3 transition-colors duration-300 hover:bg-[var(--cart-counter-background-hover,hsl(var(--contrast-100)/50%))] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--cart-focus,hsl(var(--primary)))] disabled:cursor-not-allowed',
)}
name="intent"
type="submit"
value="increment"
>
<Plus
className="text-[var(--cart-counter-icon,hsl(var(--contrast-300)))] transition-colors duration-300 group-hover:text-[var(--cart-counter-icon-hover,hsl(var(--foreground)))]"
size={18}
strokeWidth={1.5}
/>
</button>
</div>
<button
aria-label={deleteLabel}
className="group -ml-1 mt-1.5 flex h-8 w-8 shrink-0 items-center justify-center self-start rounded-full transition-colors duration-300 hover:bg-[var(--cart-button-background,hsl(var(--contrast-100)))] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--cart-focus,hsl(var(--primary)))] focus-visible:ring-offset-4"
name="intent"
type="submit"
value="delete"
>
<Trash2
className="text-[var(--cart-icon,hsl(var(--contrast-300)))] group-hover:text-[var(--cart-icon-hover,hsl(var(--foreground)))]"
size={20}
strokeWidth={1}
/>
</button>
</div>
{lineItem.inventoryMessages?.outOfStockMessage != null && (
<span className="text-xs/5 font-light text-red-500">
{lineItem.inventoryMessages.outOfStockMessage}
</span>
)}
{lineItem.inventoryMessages?.quantityOutOfStockMessage != null && (
<span className="mb-3 text-xs/5 font-light text-red-500">
{lineItem.inventoryMessages.quantityOutOfStockMessage}
</span>
)}
{lineItem.inventoryMessages?.quantityReadyToShipMessage != null && (
<span className="text-xs/5 font-light">
{lineItem.inventoryMessages.quantityReadyToShipMessage}
</span>
)}
{lineItem.inventoryMessages?.quantityBackorderedMessage != null && (
<span className="text-xs/5 font-light">
{lineItem.inventoryMessages.quantityBackorderedMessage}
</span>
)}
{lineItem.inventoryMessages?.backorderMessage != null && (
<span className="text-xs/5 font-light">
{lineItem.inventoryMessages.backorderMessage}
</span>
)}
</div>
<button
aria-label={deleteLabel}
className="group -ml-1 flex h-8 w-8 shrink-0 items-center justify-center rounded-full transition-colors duration-300 hover:bg-[var(--cart-button-background,hsl(var(--contrast-100)))] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--cart-focus,hsl(var(--primary)))] focus-visible:ring-offset-4"
name="intent"
type="submit"
value="delete"
>
<Trash2
className="text-[var(--cart-icon,hsl(var(--contrast-300)))] group-hover:text-[var(--cart-icon-hover,hsl(var(--foreground)))]"
size={20}
strokeWidth={1}
/>
</button>
</div>
</form>
);
Expand Down