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
14 changes: 14 additions & 0 deletions .changeset/large-flowers-attack.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
"@bigcommerce/catalyst-core": patch
---

Add visual queues when the cart state is being updated in the Cart page. Will also warn about pending state when trying to navigate away from page.

## Migration

1. Update `/core/vibes/soul/sections/cart/client.tsx` to include latest changes:
- Use `isLineItemActionPending` to track when we need to disable checkout button and add a loading state.
- Add skeletons to checkout summary fields that will update when the pending state is complete.
- Add side effects to handle when a user `beforeunload` and when user tries to navigate using a link.
- Add prop to `lineItemActionPendingLabel` to be able to pass in a translatable label to the window alert.
2. Add label to dictionary of choice.
1 change: 1 addition & 0 deletions core/app/[locale]/(default)/cart/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,7 @@ export default async function Cart({ params }: Props) {
incrementLineItemLabel={t('increment')}
key={`${cart.entityId}-${cart.version}`}
lineItemAction={updateLineItem}
lineItemActionPendingLabel={t('cartUpdateInProgress')}
shipping={{
action: updateShippingInfo,
countries,
Expand Down
1 change: 1 addition & 0 deletions core/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,7 @@
"removeItem": "Remove item",
"cartCombined": "We noticed you had items saved in a previous cart, so we've added them to your current cart for you.",
"cartRestored": "You started a cart on another device, and we've restored it here so you can pick up where you left off.",
"cartUpdateInProgress": "You have a cart update in progress. Are you sure you want to leave this page? Your changes may be lost.",
"CheckoutSummary": {
"title": "Summary",
"subTotal": "Subtotal",
Expand Down
133 changes: 122 additions & 11 deletions core/vibes/soul/sections/cart/client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,13 @@ import {
startTransition,
useActionState,
useEffect,
useMemo,
useOptimistic,
} from 'react';
import { useFormStatus } from 'react-dom';

import { Button } from '@/vibes/soul/primitives/button';
import * as Skeleton from '@/vibes/soul/primitives/skeleton';
import { toast } from '@/vibes/soul/primitives/toaster';
import { StickySidebarLayout } from '@/vibes/soul/sections/sticky-sidebar-layout';
import { useEvents } from '~/components/analytics/events';
Expand Down Expand Up @@ -127,6 +129,7 @@ export interface CartProps<LineItem extends CartLineItem> {
cart: Cart<LineItem>;
couponCode?: CouponCode;
shipping?: Shipping;
lineItemActionPendingLabel?: string;
}

const defaultEmptyState = {
Expand Down Expand Up @@ -168,14 +171,15 @@ export function CartClient<LineItem extends CartLineItem>({
incrementLineItemLabel,
deleteLineItemLabel,
lineItemAction,
lineItemActionPendingLabel = 'You have a cart update in progress. Are you sure you want to leave this page? Your changes may be lost.',
checkoutAction,
checkoutLabel = 'Checkout',
emptyState = defaultEmptyState,
summaryTitle,
shipping,
}: CartProps<LineItem>) {
const events = useEvents();
const [state, formAction] = useActionState(lineItemAction, {
const [state, formAction, isLineItemActionPending] = useActionState(lineItemAction, {
lineItems: cart.lineItems,
lastResult: null,
});
Expand All @@ -190,6 +194,86 @@ export function CartClient<LineItem extends CartLineItem>({
}
}, [form.errors]);

// Prevent page unload when line item action is pending
useEffect(() => {
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
if (isLineItemActionPending) {
event.preventDefault();
// eslint-disable-next-line @typescript-eslint/no-deprecated
event.returnValue = ''; // Chrome requires returnValue to be set

return ''; // For older browsers
}
};

if (isLineItemActionPending) {
window.addEventListener('beforeunload', handleBeforeUnload);
}

return () => {
window.removeEventListener('beforeunload', handleBeforeUnload);
};
}, [isLineItemActionPending]);

// Prevent client-side navigation when line item action is pending
useEffect(() => {
const handleClick = (event: MouseEvent) => {
if (isLineItemActionPending && event.target instanceof HTMLElement) {
const link = event.target.closest('a[href]');

if (
link instanceof HTMLAnchorElement &&
link.href &&
!link.href.startsWith('mailto:') &&
!link.href.startsWith('tel:')
) {
// eslint-disable-next-line no-alert
const shouldNavigate = window.confirm(lineItemActionPendingLabel);

if (!shouldNavigate) {
event.preventDefault();
event.stopPropagation();
}
}
}
};

const handleKeyDown = (event: KeyboardEvent) => {
if (
isLineItemActionPending &&
(event.key === 'Enter' || event.key === ' ') &&
event.target instanceof HTMLElement
) {
const link = event.target.closest('a[href]');

if (
link instanceof HTMLAnchorElement &&
link.href &&
!link.href.startsWith('mailto:') &&
!link.href.startsWith('tel:')
) {
// eslint-disable-next-line no-alert
const shouldNavigate = window.confirm(lineItemActionPendingLabel);

if (!shouldNavigate) {
event.preventDefault();
event.stopPropagation();
}
}
}
};

if (isLineItemActionPending) {
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.

🍹Is this needed if the handlers also check for it?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Sorry, I missed this comment. I think so, we only want to add the event listeners if the action is pending. Now, this listener will only cleanup on the unmount, so we still need to check if the action is pending inside the callback.

document.addEventListener('click', handleClick, true);
document.addEventListener('keydown', handleKeyDown, true);
}

return () => {
document.removeEventListener('click', handleClick, true);
document.removeEventListener('keydown', handleKeyDown, true);
};
}, [isLineItemActionPending, lineItemActionPendingLabel]);

const [optimisticLineItems, setOptimisticLineItems] = useOptimistic<CartLineItem[], FormData>(
state.lineItems,
(prevState, formData) => {
Expand Down Expand Up @@ -226,7 +310,10 @@ export function CartClient<LineItem extends CartLineItem>({
},
);

const optimisticQuantity = optimisticLineItems.reduce((total, item) => total + item.quantity, 0);
const optimisticQuantity = useMemo(
() => optimisticLineItems.reduce((total, item) => total + item.quantity, 0),
[optimisticLineItems],
);

if (optimisticQuantity === 0) {
return <CartEmptyState {...emptyState} />;
Expand All @@ -245,7 +332,11 @@ export function CartClient<LineItem extends CartLineItem>({
{cart.summaryItems.map((summaryItem, index) => (
<div className="flex justify-between py-4" key={index}>
<dt>{summaryItem.label}</dt>
<dd>{summaryItem.value}</dd>
{isLineItemActionPending ? (
<Skeleton.Text characterCount={8} className="animate-pulse rounded-md" />
) : (
<dd>{summaryItem.value}</dd>
)}
</div>
))}

Expand All @@ -264,10 +355,18 @@ export function CartClient<LineItem extends CartLineItem>({
)}
<div className="flex justify-between border-t border-[var(--cart-border,hsl(var(--contrast-100)))] py-6 text-xl font-bold">
<dt>{cart.totalLabel ?? 'Total'}</dt>
<dl>{cart.total}</dl>
{isLineItemActionPending ? (
<Skeleton.Text characterCount={8} className="animate-pulse rounded-md" />
) : (
<dd>{cart.total}</dd>
)}
</div>
</dl>
<CheckoutButton action={checkoutAction} className="mt-4 w-full">
<CheckoutButton
action={checkoutAction}
className="mt-4 w-full"
isCartUpdatePending={isLineItemActionPending}
>
{checkoutLabel}
<ArrowRight size={20} strokeWidth={1} />
</CheckoutButton>
Expand Down Expand Up @@ -448,10 +547,12 @@ function CounterForm({

function CheckoutButton({
action,
isCartUpdatePending,
...props
}: { action: Action<SubmissionResult | null, FormData> } & ComponentPropsWithoutRef<
typeof Button
>) {
}: {
action: Action<SubmissionResult | null, FormData>;
isCartUpdatePending: boolean;
} & ComponentPropsWithoutRef<typeof Button>) {
const [lastResult, formAction] = useActionState(action, null);

const [form] = useForm({ lastResult });
Expand All @@ -466,13 +567,23 @@ function CheckoutButton({

return (
<form action={formAction}>
<SubmitButton {...props} />
<SubmitButton {...props} isCartUpdatePending={isCartUpdatePending} />
</form>
);
}

function SubmitButton(props: ComponentPropsWithoutRef<typeof Button>) {
function SubmitButton({
isCartUpdatePending,
...props
}: { isCartUpdatePending: boolean } & ComponentPropsWithoutRef<typeof Button>) {
const { pending } = useFormStatus();

return <Button {...props} disabled={pending} loading={pending} type="submit" />;
return (
<Button
{...props}
disabled={pending || isCartUpdatePending}
loading={pending || isCartUpdatePending}
type="submit"
/>
);
}
Loading