352 lines
9.5 KiB
TypeScript
352 lines
9.5 KiB
TypeScript
import type {CartApiQueryFragment} from 'storefrontapi.generated';
|
|
import type {CartLayout} from '~/components/CartMain';
|
|
import {CartForm, Money, type OptimisticCart} from '@shopify/hydrogen';
|
|
import {useEffect, useId, useRef, useState} from 'react';
|
|
import {Link} from 'react-router';
|
|
import {useFetcher} from 'react-router';
|
|
import {useAside} from '~/components/Aside';
|
|
|
|
type CartSummaryProps = {
|
|
cart: OptimisticCart<CartApiQueryFragment | null>;
|
|
layout: CartLayout;
|
|
};
|
|
|
|
export function CartSummary({cart, layout}: CartSummaryProps) {
|
|
const className =
|
|
layout === 'page' ? 'cart-summary-page' : 'cart-summary-aside';
|
|
const summaryId = useId();
|
|
const discountsHeadingId = useId();
|
|
const discountCodeInputId = useId();
|
|
const giftCardHeadingId = useId();
|
|
const giftCardInputId = useId();
|
|
|
|
if (layout === 'aside') {
|
|
return <CartSummaryAside cart={cart} />;
|
|
}
|
|
|
|
return (
|
|
<div aria-labelledby={summaryId} className={className}>
|
|
<h4 id={summaryId}>Totals</h4>
|
|
<dl role="group" className="cart-subtotal">
|
|
<dt>Subtotal</dt>
|
|
<dd>
|
|
{cart?.cost?.subtotalAmount?.amount ? (
|
|
<Money data={cart?.cost?.subtotalAmount} />
|
|
) : (
|
|
'-'
|
|
)}
|
|
</dd>
|
|
</dl>
|
|
<CartDiscounts
|
|
discountCodes={cart?.discountCodes}
|
|
discountsHeadingId={discountsHeadingId}
|
|
discountCodeInputId={discountCodeInputId}
|
|
/>
|
|
<CartGiftCard
|
|
giftCardCodes={cart?.appliedGiftCards}
|
|
giftCardHeadingId={giftCardHeadingId}
|
|
giftCardInputId={giftCardInputId}
|
|
/>
|
|
<CartCheckoutActions checkoutUrl={cart?.checkoutUrl} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Figma cart drawer footer — node 123:13655 */
|
|
function CartSummaryAside({cart}: {cart: OptimisticCart<CartApiQueryFragment | null>}) {
|
|
const {close} = useAside();
|
|
const checkoutUrl = cart?.checkoutUrl;
|
|
const subtotal = cart?.cost?.subtotalAmount;
|
|
|
|
return (
|
|
<div className="cart-summary-aside cart-summary-aside--abiir">
|
|
<div className="cart-summary-aside__rows">
|
|
<div className="cart-summary-aside__row">
|
|
<span className="cart-summary-aside__label">Subtotal</span>
|
|
<span className="cart-summary-aside__value">
|
|
{subtotal?.amount ?
|
|
<Money data={subtotal} />
|
|
: '—'}
|
|
</span>
|
|
</div>
|
|
<div className="cart-summary-aside__row">
|
|
<span className="cart-summary-aside__label">Shipping</span>
|
|
<span className="cart-summary-aside__value cart-summary-aside__value--muted">
|
|
Calculated at checkout
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{checkoutUrl ?
|
|
<a href={checkoutUrl} target="_self" className="cart-summary-aside__checkout">
|
|
<span className="cart-summary-aside__checkout-label">Checkout</span>
|
|
<span className="cart-summary-aside__checkout-arrow" aria-hidden>
|
|
→
|
|
</span>
|
|
</a>
|
|
: null}
|
|
|
|
<Link to="/collections/all" prefetch="viewport" onClick={close} className="cart-summary-aside__continue">
|
|
Continue shopping
|
|
</Link>
|
|
|
|
<div className="cart-summary-aside__trust" aria-label="Checkout assurances">
|
|
<span>Secure checkout</span>
|
|
<span>Free returns</span>
|
|
<span>Encrypted</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function CartCheckoutActions({checkoutUrl}: {checkoutUrl?: string}) {
|
|
if (!checkoutUrl) return null;
|
|
|
|
return (
|
|
<div>
|
|
<a href={checkoutUrl} target="_self">
|
|
<p>Continue to Checkout →</p>
|
|
</a>
|
|
<br />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function CartDiscounts({
|
|
discountCodes,
|
|
discountsHeadingId,
|
|
discountCodeInputId,
|
|
}: {
|
|
discountCodes?: CartApiQueryFragment['discountCodes'];
|
|
discountsHeadingId: string;
|
|
discountCodeInputId: string;
|
|
}) {
|
|
const codes: string[] =
|
|
discountCodes
|
|
?.filter((discount) => discount.applicable)
|
|
?.map(({code}) => code) || [];
|
|
|
|
return (
|
|
<section aria-label="Discounts">
|
|
<dl hidden={!codes.length}>
|
|
<div>
|
|
<dt id={discountsHeadingId}>Discounts</dt>
|
|
<UpdateDiscountForm>
|
|
<div
|
|
className="cart-discount"
|
|
role="group"
|
|
aria-labelledby={discountsHeadingId}
|
|
>
|
|
<code>{codes?.join(', ')}</code>
|
|
|
|
<button type="submit" aria-label="Remove discount">
|
|
Remove
|
|
</button>
|
|
</div>
|
|
</UpdateDiscountForm>
|
|
</div>
|
|
</dl>
|
|
|
|
<UpdateDiscountForm discountCodes={codes}>
|
|
<div>
|
|
<label htmlFor={discountCodeInputId} className="sr-only">
|
|
Discount code
|
|
</label>
|
|
<input
|
|
id={discountCodeInputId}
|
|
type="text"
|
|
name="discountCode"
|
|
placeholder="Discount code"
|
|
/>
|
|
|
|
<button type="submit" aria-label="Apply discount code">
|
|
Apply
|
|
</button>
|
|
</div>
|
|
</UpdateDiscountForm>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function UpdateDiscountForm({
|
|
discountCodes,
|
|
children,
|
|
}: {
|
|
discountCodes?: string[];
|
|
children: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<CartForm
|
|
route="/cart"
|
|
action={CartForm.ACTIONS.DiscountCodesUpdate}
|
|
inputs={{
|
|
discountCodes: discountCodes || [],
|
|
}}
|
|
>
|
|
{children}
|
|
</CartForm>
|
|
);
|
|
}
|
|
|
|
function CartGiftCard({
|
|
giftCardCodes,
|
|
giftCardHeadingId,
|
|
giftCardInputId,
|
|
}: {
|
|
giftCardCodes: CartApiQueryFragment['appliedGiftCards'] | undefined;
|
|
giftCardHeadingId: string;
|
|
giftCardInputId: string;
|
|
}) {
|
|
const giftCardCodeInput = useRef<HTMLInputElement>(null);
|
|
const removeButtonRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
|
|
const previousCardIdsRef = useRef<string[]>([]);
|
|
const giftCardAddFetcher = useFetcher({key: 'gift-card-add'});
|
|
const [removedCardIndex, setRemovedCardIndex] = useState<number | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (giftCardAddFetcher.data) {
|
|
if (giftCardCodeInput.current !== null) {
|
|
giftCardCodeInput.current.value = '';
|
|
}
|
|
}
|
|
}, [giftCardAddFetcher.data]);
|
|
|
|
useEffect(() => {
|
|
const currentCardIds = giftCardCodes?.map((card) => card.id) || [];
|
|
|
|
if (removedCardIndex !== null && giftCardCodes) {
|
|
const focusTargetIndex = Math.min(
|
|
removedCardIndex,
|
|
giftCardCodes.length - 1,
|
|
);
|
|
const focusTargetCard = giftCardCodes[focusTargetIndex];
|
|
const focusButton = focusTargetCard
|
|
? removeButtonRefs.current.get(focusTargetCard.id)
|
|
: null;
|
|
|
|
if (focusButton) {
|
|
focusButton.focus();
|
|
} else if (giftCardCodeInput.current) {
|
|
giftCardCodeInput.current.focus();
|
|
}
|
|
|
|
setRemovedCardIndex(null);
|
|
}
|
|
|
|
previousCardIdsRef.current = currentCardIds;
|
|
}, [giftCardCodes, removedCardIndex]);
|
|
|
|
const handleRemoveClick = (cardId: string) => {
|
|
const index = previousCardIdsRef.current.indexOf(cardId);
|
|
if (index !== -1) {
|
|
setRemovedCardIndex(index);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<section aria-label="Gift cards">
|
|
{giftCardCodes && giftCardCodes.length > 0 && (
|
|
<dl>
|
|
<dt id={giftCardHeadingId}>Applied Gift Card(s)</dt>
|
|
{giftCardCodes.map((giftCard) => (
|
|
<dd key={giftCard.id} className="cart-discount">
|
|
<RemoveGiftCardForm
|
|
giftCardId={giftCard.id}
|
|
lastCharacters={giftCard.lastCharacters}
|
|
onRemoveClick={() => handleRemoveClick(giftCard.id)}
|
|
buttonRef={(el: HTMLButtonElement | null) => {
|
|
if (el) {
|
|
removeButtonRefs.current.set(giftCard.id, el);
|
|
} else {
|
|
removeButtonRefs.current.delete(giftCard.id);
|
|
}
|
|
}}
|
|
>
|
|
<code>***{giftCard.lastCharacters}</code>
|
|
|
|
<Money data={giftCard.amountUsed} />
|
|
</RemoveGiftCardForm>
|
|
</dd>
|
|
))}
|
|
</dl>
|
|
)}
|
|
|
|
<AddGiftCardForm fetcherKey="gift-card-add">
|
|
<div>
|
|
<label htmlFor={giftCardInputId} className="sr-only">
|
|
Gift card code
|
|
</label>
|
|
<input
|
|
id={giftCardInputId}
|
|
type="text"
|
|
name="giftCardCode"
|
|
placeholder="Gift card code"
|
|
ref={giftCardCodeInput}
|
|
/>
|
|
|
|
<button
|
|
type="submit"
|
|
disabled={giftCardAddFetcher.state !== 'idle'}
|
|
aria-label="Apply gift card code"
|
|
>
|
|
Apply
|
|
</button>
|
|
</div>
|
|
</AddGiftCardForm>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function AddGiftCardForm({
|
|
fetcherKey,
|
|
children,
|
|
}: {
|
|
fetcherKey?: string;
|
|
children: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<CartForm
|
|
fetcherKey={fetcherKey}
|
|
route="/cart"
|
|
action={CartForm.ACTIONS.GiftCardCodesAdd}
|
|
>
|
|
{children}
|
|
</CartForm>
|
|
);
|
|
}
|
|
|
|
function RemoveGiftCardForm({
|
|
giftCardId,
|
|
lastCharacters,
|
|
children,
|
|
onRemoveClick,
|
|
buttonRef,
|
|
}: {
|
|
giftCardId: string;
|
|
lastCharacters: string;
|
|
children: React.ReactNode;
|
|
onRemoveClick?: () => void;
|
|
buttonRef?: (el: HTMLButtonElement | null) => void;
|
|
}) {
|
|
return (
|
|
<CartForm
|
|
route="/cart"
|
|
action={CartForm.ACTIONS.GiftCardCodesRemove}
|
|
inputs={{
|
|
giftCardCodes: [giftCardId],
|
|
}}
|
|
>
|
|
{children}
|
|
|
|
<button
|
|
type="submit"
|
|
aria-label={`Remove gift card ending in ${lastCharacters}`}
|
|
onClick={onRemoveClick}
|
|
ref={buttonRef}
|
|
>
|
|
Remove
|
|
</button>
|
|
</CartForm>
|
|
);
|
|
}
|