← Files WixARCHIVED FILE
skills/wix-headless-templates/storefront/app/components/storefront/CartDrawer.tsx
11.3 KB · Oct 8, 2026 · 12:02 UTC
// Slide-over cart with quantity stepper (capped at the stock left), remove, struck line prices
// on discounted lines, per-line availability, a coupon field, a note to the merchant, the totals
// breakdown from the cart estimate (subtotal, named discounts, fees, taxes when prices exclude
// them, the total before shipping), and Wix-hosted checkout. Mount ONCE per page, as-is; opens via
// useCart().openCart() / CartButton. Styled from the @theme tokens.
import { useEffect, useRef, useState } from "react";
import { useCart } from "../../hooks/storefront/useCart";
import type { CartLine } from "../../wix/storefront/types";
// Wix's line statuses in buyer words; IN_STOCK says nothing.
function lineStatusCopy(line: CartLine): string | null {
switch (line.status) {
case "IN_STOCK": return null;
case "PARTIALLY_IN_STOCK": return line.availableQuantity !== null ? `Only ${line.availableQuantity} available — lower the quantity` : "Only part of this quantity is available";
case "REMOVED_FROM_CATALOG": return "No longer sold";
case "OUT_OF_STOCK": return "Out of stock";
default: return "No longer available at this quantity";
}
}
export default function CartDrawer() {
const { cart, open, closeCart, busy, error, updateQuantity, removeLine, applyCoupon, removeCoupon, setNote, checkout } = useCart();
const [code, setCode] = useState("");
const [codeOpen, setCodeOpen] = useState(false);
const [note, setNoteDraft] = useState<string | null>(null); // null = not edited: show the cart's
// The overlay contract, done here rather than assumed from CSS: Escape closes (the backdrop
// click is pointer-only), the page behind stops scrolling, focus moves into the panel on open
// and returns to whatever opened it on close. Hooks run before the early return below.
const panelRef = useRef<HTMLElement>(null);
useEffect(() => {
if (!open) return;
const opener = document.activeElement;
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
panelRef.current?.focus();
const onKey = (e: KeyboardEvent) => e.key === "Escape" && closeCart();
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("keydown", onKey);
document.body.style.overflow = previousOverflow;
if (opener instanceof HTMLElement) opener.focus();
};
}, [open, closeCart]);
if (!open) return null;
const lines = cart?.lines ?? [];
const row = "flex justify-between gap-3 text-sm";
return (
<div
className="fixed inset-0 z-50 flex justify-end bg-foreground/40 backdrop-blur-[2px]"
onClick={closeCart}
>
<aside
ref={panelRef}
tabIndex={-1}
role="dialog"
aria-modal="true"
aria-label="Cart"
onClick={(e) => e.stopPropagation()}
className="flex h-full w-full max-w-md flex-col bg-background text-foreground shadow-2xl outline-none"
>
<div className="flex items-center justify-between border-b border-border px-6 py-5">
<span className="text-base font-semibold">
Cart{cart && cart.itemCount > 0 ? ` (${cart.itemCount})` : ""}
</span>
<button
type="button"
aria-label="Close cart"
onClick={closeCart}
className="text-xl leading-none text-muted-foreground transition-colors hover:text-foreground"
>
×
</button>
</div>
<div className="flex-1 overflow-y-auto px-6 py-5">
{lines.length === 0 && (
<p className="py-16 text-center text-sm text-muted-foreground">Your cart is empty.</p>
)}
{lines.map((line) => {
const status = lineStatusCopy(line);
const atMax = line.availableQuantity !== null && line.quantity >= line.availableQuantity;
return (
<div className="mb-6 flex gap-4" key={line.lineItemId}>
{line.imageUrl ? (
<img
src={line.imageUrl}
alt=""
loading="lazy"
className="h-16 w-16 flex-shrink-0 rounded-md bg-secondary object-cover"
/>
) : (
<div aria-hidden="true" className="h-16 w-16 flex-shrink-0 rounded-md bg-secondary" />
)}
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">{line.productName}</p>
{line.descriptionLines.map((d) => (
<p className="mt-0.5 text-xs text-muted-foreground" key={d}>
{d}
</p>
))}
{line.subscription && (
<p className="mt-0.5 text-xs text-muted-foreground">Subscription: {line.subscription}</p>
)}
{status && <p className="mt-1 text-xs text-destructive">{status}</p>}
<div className="mt-2 flex items-center">
<div className="inline-flex items-center gap-3 rounded-control border border-border px-2 py-0.5">
<button
type="button"
aria-label="Decrease quantity"
disabled={busy || line.quantity <= 1}
onClick={() => updateQuantity(line.lineItemId, line.quantity - 1).catch(() => {})}
className="px-1 text-base disabled:opacity-40"
>
−
</button>
<span className="text-sm tabular-nums">{line.quantity}</span>
<button
type="button"
aria-label="Increase quantity"
disabled={busy || atMax}
onClick={() => updateQuantity(line.lineItemId, line.quantity + 1).catch(() => {})}
className="px-1 text-base disabled:opacity-40"
>
+
</button>
</div>
<button
type="button"
disabled={busy}
onClick={() => removeLine(line.lineItemId).catch(() => {})}
className="ml-3 text-xs text-muted-foreground underline transition-colors hover:text-foreground disabled:opacity-40"
>
Remove
</button>
</div>
</div>
<div className="text-right">
<p className="text-sm font-medium">{line.linePrice}</p>
{line.compareAtLinePrice && <p className="text-xs text-muted-foreground line-through">{line.compareAtLinePrice}</p>}
</div>
</div>
);
})}
{lines.length > 0 && (
<div className="mt-2 flex flex-col gap-4 border-t border-border pt-4">
{/* Coupon */}
{cart?.coupon ? (
<div className={row}>
<span className="text-muted-foreground">Code <span className="font-medium text-foreground">{cart.coupon.code}</span></span>
<button type="button" disabled={busy} onClick={() => removeCoupon().catch(() => {})} className="text-xs underline disabled:opacity-40">Remove</button>
</div>
) : codeOpen ? (
<form
className="flex gap-2"
onSubmit={(e) => {
e.preventDefault();
applyCoupon(code).then(() => setCode("")).catch(() => {});
}}
>
<label className="sr-only" htmlFor="cart-coupon">Promo code</label>
<input id="cart-coupon" value={code} onChange={(e) => setCode(e.target.value)} placeholder="Promo code" autoFocus
className="min-w-0 flex-1 rounded-md border border-border bg-background px-3 py-2 text-sm" />
<button type="submit" disabled={busy || !code.trim()} className="rounded-control border border-foreground px-4 py-2 text-sm font-medium disabled:opacity-40">Apply</button>
</form>
) : (
<button type="button" onClick={() => setCodeOpen(true)} className="self-start text-sm underline">Have a promo code?</button>
)}
{/* Note to the merchant — saved on blur */}
<label className="flex flex-col gap-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Note to seller
<textarea
rows={2}
value={note ?? cart?.note ?? ""}
onChange={(e) => setNoteDraft(e.target.value)}
onBlur={() => { if (note !== null) setNote(note).then(() => setNoteDraft(null)).catch(() => {}); }}
className="rounded-md border border-border bg-background px-3 py-2 text-sm normal-case tracking-normal text-foreground"
/>
</label>
</div>
)}
</div>
{lines.length > 0 && (
<div className="border-t border-border px-6 py-5">
<div className="mb-3 flex flex-col gap-1.5">
{cart?.subtotal && (
<div className={row}>
<span className="text-muted-foreground">Subtotal</span>
<span>{cart.subtotal}</span>
</div>
)}
{(cart?.discounts ?? []).map((d, i) => (
<div className={row} key={`d${i}`}>
<span className="text-muted-foreground">{d.name || "Discount"}</span>
<span>−{d.amount}</span>
</div>
))}
{(cart?.fees ?? []).map((f, i) => (
<div className={row} key={`f${i}`}>
<span className="text-muted-foreground">{f.name || "Fee"}</span>
<span>{f.amount}</span>
</div>
))}
{!cart?.pricesIncludeTax && (cart?.taxes ?? []).map((t, i) => (
<div className={row} key={`t${i}`}>
<span className="text-muted-foreground">{t.name || "Tax"}</span>
<span>{t.amount}</span>
</div>
))}
{cart?.total && (
<div className={`${row} mt-1 border-t border-border pt-2`}>
<span className="font-semibold">Total</span>
<strong className="font-semibold">{cart.total}</strong>
</div>
)}
</div>
<p className="mb-4 text-xs text-muted-foreground">
{cart?.pricesIncludeTax ? "Prices include tax. Shipping is calculated at checkout." : cart?.taxes.length ? "Shipping is calculated at checkout." : "Shipping and taxes are calculated at checkout."}
</p>
{error && <p className="mb-3 text-xs text-destructive">{error}</p>}
<button
type="button"
disabled={busy}
onClick={() => checkout().catch(() => {})}
className="w-full rounded-control bg-primary py-3.5 text-sm font-semibold text-primary-foreground transition-opacity hover:opacity-90 disabled:opacity-50"
>
{busy ? "One moment…" : "Continue to secure checkout"}
</button>
</div>
)}
</aside>
</div>
);
}
SHA-256: 6ef347b9aea273a6e8c1f636ec0039d6136424b21566235eae0b3c36c8549b28