Initial commit

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F22Ms5Cxam5UHyxaCrtZzu
This commit is contained in:
Rami Bitar
2026-07-29 11:02:40 -04:00
co-authored by Claude Fable 5
commit f23332e42c
88 changed files with 7947 additions and 0 deletions
+132
View File
@@ -0,0 +1,132 @@
"use client";
import { useCart, useCartForm } from "@/lib/cart";
import { shopifyImageUrl } from "@/lib/image";
import { formatPrice } from "@/lib/money";
/**
* A cart line, derived from the typed `useCart` binding (F3: consume typed data,
* no hand-rolled parallel shape). Narrowed field access stays tolerant of the
* gql.tada-inferred merchandise union via optional chaining
* (`hydrogen-cart-ui` / `references/react.md`).
*/
type CartState = Parameters<Parameters<typeof useCart>[0]>[0];
type CartLine = CartState["data"]["lines"]["nodes"][number];
/**
* Shared cart line-item form — used by both the cart drawer and the `/cart`
* page (`hydrogen-cart-ui` / `hydrogen-cart-drawer`). Preserves the
* progressive-enhancement form contract: hidden `register("set")`, scoped
* `register("lineId", { value })`, and a real editable quantity input from
* `register("quantity", { value, interactive: true })`. Increase/decrease/remove
* are additional submit controls, not replacements.
*/
export function CartLineItem({ line }: { line: CartLine }) {
const { formProps, register } = useCartForm();
const pendingLines = useCart((state) => state.pending.lines);
const isPending = pendingLines.has(line.id);
const merchandise = line.merchandise;
const productTitle = merchandise?.product?.title ?? merchandise?.title ?? "Product";
const productHandle = merchandise?.product?.handle;
const selectedOptions = merchandise?.selectedOptions ?? [];
const variantSubtitle = selectedOptions.map((option) => option.value).join(" / ");
const totalAmount = line.cost.totalAmount;
const compareAt = line.cost.compareAtAmountPerQuantity ?? null;
const onSale = compareAt && Number(compareAt.amount) > Number(totalAmount.amount);
return (
<div
className="grid grid-cols-[var(--spacing-cart-line-thumbnail-width)_1fr_auto] items-stretch gap-3 py-4"
{...(isPending ? { "aria-busy": "true" } : {})}
>
<div className="bg-surface-secondary h-full w-full overflow-hidden">
{merchandise?.image ? (
<img
src={shopifyImageUrl(merchandise.image.url, { width: 128 })}
alt={merchandise.image.altText ?? `${productTitle} in ${variantSubtitle}`}
className="h-full w-full object-cover"
/>
) : null}
</div>
<div className="min-w-0">
{productHandle ? (
<a
href={`/products/${productHandle}`}
className="type-body-sm text-on-surface font-medium no-underline hover:opacity-70"
>
{productTitle}
</a>
) : (
<p className="type-body-sm text-on-surface font-medium">{productTitle}</p>
)}
{variantSubtitle ? (
<p className="text-on-surface-secondary mt-1 text-xs">{variantSubtitle}</p>
) : null}
<p className="text-on-surface mt-2 text-sm">
{onSale ? (
<span className="text-sale font-medium">{formatPrice(totalAmount)}</span>
) : (
formatPrice(totalAmount)
)}
{onSale && compareAt ? (
<>
{" "}
<s className="text-compare text-sm">{formatPrice(compareAt)}</s>
</>
) : null}
</p>
<form {...formProps()} className="mt-3">
<button {...register("set")} className="sr-only" tabIndex={-1} />
<input type="hidden" {...register("lineId", { value: line.id })} />
<div className="quantity-selector-outlined inline-flex items-center rounded">
<button
type="submit"
{...register("decrease")}
disabled={line.quantity <= 1}
className="button-icon h-8 w-9 rounded-s disabled:opacity-40"
aria-label={`Decrease quantity: ${productTitle}`}
>
–
</button>
<input
{...register("quantity", { value: line.quantity, interactive: true })}
className="number-reset h-8 w-12 text-center text-sm outline-none focus:outline-none"
aria-label={`Quantity: ${productTitle}`}
/>
<button
type="submit"
{...register("increase")}
className="button-icon h-8 w-9 rounded-e"
aria-label={`Increase quantity: ${productTitle}`}
>
+
</button>
</div>
</form>
</div>
<form {...formProps()}>
<input type="hidden" {...register("lineId", { value: line.id })} />
<button
type="submit"
{...register("remove")}
className="button-icon self-start rounded"
aria-label={`Remove: ${productTitle}`}
>
<img
src="/icons/icon-trash.svg"
width="20"
height="20"
alt=""
className="size-5"
aria-hidden="true"
/>
</button>
</form>
</div>
);
}