From 5f7db60cd105f1ed79e5e6075aa4d0fb507b5f78 Mon Sep 17 00:00:00 2001 From: Andrew Nguyen Date: Fri, 31 Jul 2026 13:18:47 -0700 Subject: [PATCH 1/5] feat: initial cart attributes addition --- .../src/core/analytics/cart-tracker.test.ts | 1 + .../src/core/analytics/cart-tracker.ts | 2 +- .../hydrogen/src/core/cart/actions.test.ts | 62 ++++++++++++++++++- packages/hydrogen/src/core/cart/actions.ts | 50 ++++++++++++++- packages/hydrogen/src/core/cart/form.test.ts | 18 ++++++ packages/hydrogen/src/core/cart/form.ts | 9 +++ packages/hydrogen/src/core/cart/index.ts | 7 ++- .../hydrogen/src/core/cart/queries.test.ts | 8 +++ .../src/core/cart/queries.type-test.ts | 11 ++++ packages/hydrogen/src/core/cart/route.test.ts | 28 ++++++++- .../hydrogen/src/core/cart/server-handlers.ts | 7 +++ packages/hydrogen/src/core/cart/state.ts | 9 ++- packages/hydrogen/src/core/index.ts | 3 +- packages/hydrogen/src/react/cart-ssr.test.tsx | 1 + packages/hydrogen/src/react/cart.test.tsx | 24 ++++++- packages/hydrogen/src/vue/cart.test.ts | 2 + .../hydrogen/vendor/standard-actions.d.ts | 10 ++- 17 files changed, 238 insertions(+), 14 deletions(-) diff --git a/packages/hydrogen/src/core/analytics/cart-tracker.test.ts b/packages/hydrogen/src/core/analytics/cart-tracker.test.ts index 52d4e5013b..b31304b6a5 100644 --- a/packages/hydrogen/src/core/analytics/cart-tracker.test.ts +++ b/packages/hydrogen/src/core/analytics/cart-tracker.test.ts @@ -142,6 +142,7 @@ function createCartDataFromAnalyticsCart(cart: AnalyticsCart): CartData { totalQuantity: lines.reduce((total, line) => total + line.quantity, 0), updatedAt: cart.updatedAt, note: "", + attributes: [], discountCodes: [], cost: { subtotalAmount: { diff --git a/packages/hydrogen/src/core/analytics/cart-tracker.ts b/packages/hydrogen/src/core/analytics/cart-tracker.ts index 1b822fc2e5..7fb16667b5 100644 --- a/packages/hydrogen/src/core/analytics/cart-tracker.ts +++ b/packages/hydrogen/src/core/analytics/cart-tracker.ts @@ -183,7 +183,7 @@ function toAnalyticsCart(state: CartState): AnalyticsCart | null { function hasPendingCartWork({ pending, revalidating }: CartState): boolean { const hasPendingCost = pending.cost ?? (pending.lines.size > 0 || pending.discountCodes.size > 0); - return revalidating === true || hasPendingCost || pending.note; + return revalidating === true || hasPendingCost || pending.note || pending.attributes; } function getCartUpdatedAt(cart: CartData): string { diff --git a/packages/hydrogen/src/core/cart/actions.test.ts b/packages/hydrogen/src/core/cart/actions.test.ts index 0fea534d4c..f11bad0d08 100644 --- a/packages/hydrogen/src/core/cart/actions.test.ts +++ b/packages/hydrogen/src/core/cart/actions.test.ts @@ -149,7 +149,7 @@ describe("parseCartRequest", () => { }); }); - describe("JSON — discount and note payloads", () => { + describe("JSON — cart metadata payloads", () => { it("parses discountCodes array as discount-update", async () => { const { action } = await parseCartRequest(jsonRequest({ discountCodes: ["SAVE10", "BOGO"] })); expect(action).toEqual({ @@ -170,6 +170,27 @@ describe("parseCartRequest", () => { const { action } = await parseCartRequest(jsonRequest({ note: "" })); expect(action).toEqual({ intent: "note-update", note: "" }); }); + + it("parses attributes as a full attributes-update", async () => { + const { action } = await parseCartRequest( + jsonRequest({ attributes: [{ key: "gift-message", value: "Happy birthday!" }] }), + ); + expect(action).toEqual({ + intent: "attributes-update", + attributes: [{ key: "gift-message", value: "Happy birthday!" }], + }); + }); + + it("allows an empty attributes array to clear cart attributes", async () => { + const { action } = await parseCartRequest(jsonRequest({ attributes: [] })); + expect(action).toEqual({ intent: "attributes-update", attributes: [] }); + }); + + it("rejects invalid attribute entries", async () => { + await expect( + parseCartRequest(jsonRequest({ attributes: [{ key: "gift-message", value: null }] })), + ).rejects.toThrow('Expected "attributes[0].value" to be a string.'); + }); }); describe("JSON — cartId metadata", () => { @@ -552,6 +573,45 @@ describe("parseCartRequest", () => { }); }); + describe("FormData — attributes intent", () => { + it("pairs repeated attribute keys and values", async () => { + const body = new URLSearchParams([ + ["intent", "attributes-update"], + ["attributeKey", "gift-message"], + ["attributeValue", "Happy birthday!"], + ["attributeKey", "delivery-date"], + ["attributeValue", "Friday"], + ]); + const request = new Request("http://localhost/api/cart", { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body, + }); + + const { action } = await parseCartRequest(request); + expect(action).toEqual({ + intent: "attributes-update", + attributes: [ + { key: "gift-message", value: "Happy birthday!" }, + { key: "delivery-date", value: "Friday" }, + ], + }); + }); + + it("allows no fields to clear all attributes", async () => { + const { action } = await parseCartRequest(formRequest({ intent: "attributes-update" })); + expect(action).toEqual({ intent: "attributes-update", attributes: [] }); + }); + + it("rejects mismatched attribute fields", async () => { + await expect( + parseCartRequest( + formRequest({ intent: "attributes-update", attributeKey: "gift-message" }), + ), + ).rejects.toThrow(/one "attributeValue"/); + }); + }); + describe("FormData — validation errors", () => { it("rejects missing lineId with increase intent", async () => { await expect( diff --git a/packages/hydrogen/src/core/cart/actions.ts b/packages/hydrogen/src/core/cart/actions.ts index 0efe4e7353..45aa13c391 100644 --- a/packages/hydrogen/src/core/cart/actions.ts +++ b/packages/hydrogen/src/core/cart/actions.ts @@ -14,6 +14,8 @@ export type CartLineUpdateInput = { sellingPlanId?: string; }; +export type CartAttributeInput = { key: string; value: string }; + export type CartAction = | { intent: "add"; lines: CartLineAddInput[] } | { intent: "update"; lines: CartLineUpdateInput[] } @@ -21,6 +23,7 @@ export type CartAction = | { intent: "discount-update"; discountCodes: string[] } | { intent: "discount-apply"; code: string } | { intent: "discount-remove"; code: string } + | { intent: "attributes-update"; attributes: CartAttributeInput[] } | { intent: "note-update"; note: string }; type ParsedCartRequest = { @@ -74,8 +77,17 @@ function parseJsonBody(body: unknown): ParsedCartRequest { }; } + if ("attributes" in body && Array.isArray(body.attributes)) { + return { + action: { intent: "attributes-update", attributes: parseAttributes(body.attributes) }, + cartId, + }; + } + if (!("lines" in body) || !Array.isArray(body.lines)) { - throw new CartActionError('Request body must contain "lines", "discountCodes", or "note".'); + throw new CartActionError( + 'Request body must contain "lines", "discountCodes", "attributes", or "note".', + ); } const lines = body.lines as unknown[]; @@ -171,7 +183,7 @@ function extractOptionalLineFields( // Update qty: server does the math — form sends current qty, server increments/decrements // Remove line: explicit intent field (can't express "quantity=0 means remove" implicitly) // Discount bulk: no FormData equivalent — forms handle one code at a time -// Attributes: not supported in FormData (nested objects can't be expressed in flat fields) +// Cart attributes: parallel attributeKey/attributeValue fields preserve the ordered list // Selling plan: add only — no way to change selling plan on existing line via form // // Design decisions: @@ -216,8 +228,12 @@ function parseFormData(form: FormData): CartAction { return parseNoteIntent(form); } + if (intent === "attributes-update") { + return parseAttributesIntent(form); + } + throw new CartActionError( - `Unknown intent "${intent}". Expected one of: add, increase, decrease, remove, set, discount-apply, discount-remove, note-update.`, + `Unknown intent "${intent}". Expected one of: add, increase, decrease, remove, set, discount-apply, discount-remove, attributes-update, note-update.`, ); } @@ -283,6 +299,34 @@ function parseNoteIntent(form: FormData): CartAction { return { intent: "note-update", note: String(noteValue) }; } +function parseAttributesIntent(form: FormData): CartAction { + const keys = form.getAll("attributeKey"); + const values = form.getAll("attributeValue"); + + if (keys.length !== values.length) { + throw new CartActionError( + 'Intent "attributes-update" requires one "attributeValue" field for every "attributeKey" field.', + ); + } + + return { + intent: "attributes-update", + attributes: parseAttributes(keys.map((key, index) => ({ key, value: values[index] }))), + }; +} + +function parseAttributes(values: unknown[]): CartAttributeInput[] { + return values.map((value, index) => { + assertObject(value); + assertString(value.key, `attributes[${index}].key`); + assertString(value.value, `attributes[${index}].value`); + if (!value.key) { + throw new CartActionError(`Expected "attributes[${index}].key" not to be empty.`); + } + return { key: value.key, value: value.value }; + }); +} + // --- Assertion helpers --- function assertObject(value: unknown): asserts value is Record { diff --git a/packages/hydrogen/src/core/cart/form.test.ts b/packages/hydrogen/src/core/cart/form.test.ts index 722b3d54e4..dc588347ca 100644 --- a/packages/hydrogen/src/core/cart/form.test.ts +++ b/packages/hydrogen/src/core/cart/form.test.ts @@ -60,6 +60,17 @@ describe("createCartFormRegister", () => { expect(result).toEqual({ name: "note", defaultValue: "gift wrap" }); }); + it("attribute fields return repeatable key and value names", () => { + expect(register("attributeKey", { value: "gift-message" })).toEqual({ + name: "attributeKey", + value: "gift-message", + }); + expect(register("attributeValue", { defaultValue: "Happy birthday!" })).toEqual({ + name: "attributeValue", + defaultValue: "Happy birthday!", + }); + }); + it("sellingPlanId returns name and value", () => { const result = register("sellingPlanId", { value: "gid://shopify/SellingPlan/1", @@ -106,6 +117,13 @@ describe("createCartFormRegister", () => { }); }); + it("attributes-update returns intent", () => { + expect(register("attributes-update")).toEqual({ + name: "intent", + value: "attributes-update", + }); + }); + it("set returns hidden submit button attributes with data marker", () => { expect(register("set")).toEqual({ name: "intent", diff --git a/packages/hydrogen/src/core/cart/form.ts b/packages/hydrogen/src/core/cart/form.ts index ee4aa5d6c6..38dd66a2e5 100644 --- a/packages/hydrogen/src/core/cart/form.ts +++ b/packages/hydrogen/src/core/cart/form.ts @@ -31,6 +31,12 @@ export type CartFormRegister = { (field: "merchandiseId", opts: { value: string }): { name: "merchandiseId"; value: string }; (field: "note", opts: { value: string }): { name: "note"; value: string }; (field: "note", opts: { defaultValue: string }): { name: "note"; defaultValue: string }; + (field: "attributeKey", opts: { value: string }): { name: "attributeKey"; value: string }; + (field: "attributeValue", opts: { value: string }): { name: "attributeValue"; value: string }; + ( + field: "attributeValue", + opts: { defaultValue: string }, + ): { name: "attributeValue"; defaultValue: string }; (field: "sellingPlanId", opts: { value: string }): { name: "sellingPlanId"; value: string }; (action: "add"): { name: "intent"; value: "add" }; (action: "increase"): { name: "intent"; value: "increase" }; @@ -40,6 +46,7 @@ export type CartFormRegister = { (action: "discount-apply"): { name: "intent"; value: "discount-apply" }; (action: "discount-remove"): { name: "intent"; value: "discount-remove" }; (action: "note-update"): { name: "intent"; value: "note-update" }; + (action: "attributes-update"): { name: "intent"; value: "attributes-update" }; }; const FIELD_REGISTERS = new Set([ @@ -48,6 +55,8 @@ const FIELD_REGISTERS = new Set([ "discountCode", "merchandiseId", "note", + "attributeKey", + "attributeValue", "sellingPlanId", ]); diff --git a/packages/hydrogen/src/core/cart/index.ts b/packages/hydrogen/src/core/cart/index.ts index bf93fd1600..c2f55e280b 100644 --- a/packages/hydrogen/src/core/cart/index.ts +++ b/packages/hydrogen/src/core/cart/index.ts @@ -9,7 +9,12 @@ export { createCartFormRegister } from "./form"; export type { CartFormRegister, QuantityInputAttributes, SetButtonAttributes } from "./form"; export { attachQuantityInput } from "./attach-quantity-input"; export { parseCartRequest } from "./actions"; -export type { CartAction, CartLineAddInput, CartLineUpdateInput } from "./actions"; +export type { + CartAction, + CartAttributeInput, + CartLineAddInput, + CartLineUpdateInput, +} from "./actions"; export { cartQueries } from "./queries"; export { createCartCookie } from "./cookie"; export type { diff --git a/packages/hydrogen/src/core/cart/queries.test.ts b/packages/hydrogen/src/core/cart/queries.test.ts index abd873af8f..069a2b83c9 100644 --- a/packages/hydrogen/src/core/cart/queries.test.ts +++ b/packages/hydrogen/src/core/cart/queries.test.ts @@ -44,6 +44,14 @@ describe("cartQueries", () => { } }); + it("includes cart attributes in the minimum payload and exposes their mutation", () => { + for (const query of Object.values(cartQueries)) { + expect(query).toContain("attributes"); + } + expect(cartQueries.cartAttributesUpdate).toContain("cartAttributesUpdate"); + expect(cartQueries.cartAttributesUpdate).toContain("$attributes: [AttributeInput!]!"); + }); + it("adds custom cart fields to every cart document without removing the minimum payload", () => { const customQueries = makeCartQueries({ fragment: customCartFragment }); diff --git a/packages/hydrogen/src/core/cart/queries.type-test.ts b/packages/hydrogen/src/core/cart/queries.type-test.ts index f84323abef..51e3f14e6f 100644 --- a/packages/hydrogen/src/core/cart/queries.type-test.ts +++ b/packages/hydrogen/src/core/cart/queries.type-test.ts @@ -118,6 +118,11 @@ describe("cart query result types", () => { type R = ResultOf; expectTypeOf().toHaveProperty("cartNoteUpdate"); }); + + it("cartAttributesUpdate returns cartAttributesUpdate", () => { + type R = ResultOf; + expectTypeOf().toHaveProperty("cartAttributesUpdate"); + }); }); describe("cart query variable types", () => { @@ -163,6 +168,12 @@ describe("cart query variable types", () => { expectTypeOf().toHaveProperty("cartId"); expectTypeOf().toHaveProperty("note"); }); + + it("cartAttributesUpdate requires cartId and attributes", () => { + type V = VariablesOf; + expectTypeOf().toHaveProperty("cartId"); + expectTypeOf().toHaveProperty("attributes"); + }); }); describe("storefront client result type inference", () => { diff --git a/packages/hydrogen/src/core/cart/route.test.ts b/packages/hydrogen/src/core/cart/route.test.ts index a2252dff5b..3ec342ec7b 100644 --- a/packages/hydrogen/src/core/cart/route.test.ts +++ b/packages/hydrogen/src/core/cart/route.test.ts @@ -207,7 +207,7 @@ describe("createCartServerHandlers", () => { if (result.type !== "error") throw new Error("expected error result"); expect(result.error).toEqual({ code: "invalid_cart_request", - message: 'Request body must contain "lines", "discountCodes", or "note".', + message: 'Request body must contain "lines", "discountCodes", "attributes", or "note".', }); expect("status" in result).toBe(false); }); @@ -645,6 +645,30 @@ describe("createCartServerHandlers", () => { }); }); + describe("POST — JSON attributes", () => { + it("calls cartAttributesUpdate for attributes-update", async () => { + mockFetch.mockResolvedValueOnce( + mockGqlResponse({ cartAttributesUpdate: { cart: MOCK_CART, userErrors: [] } }), + ); + + const result = await handleCartRequest( + createJsonPostRequest( + { attributes: [{ key: "gift-message", value: "Happy birthday!" }] }, + "cart=123", + ), + defaultConfig, + ); + + assert(result, "expected a response"); + const body = await result.json(); + expect(body.userErrors).toEqual([]); + const [, init] = mockFetch.mock.calls[0]; + expect(JSON.parse(init.body).variables.attributes).toEqual([ + { key: "gift-message", value: "Happy birthday!" }, + ]); + }); + }); + describe("POST — JSON error handling", () => { it("returns 400 for invalid request body", async () => { const result = await handleCartRequest(createJsonPostRequest({}), defaultConfig); @@ -653,7 +677,7 @@ describe("createCartServerHandlers", () => { const body = await result.json(); expect(body.error).toEqual({ code: "invalid_cart_request", - message: 'Request body must contain "lines", "discountCodes", or "note".', + message: 'Request body must contain "lines", "discountCodes", "attributes", or "note".', }); }); diff --git a/packages/hydrogen/src/core/cart/server-handlers.ts b/packages/hydrogen/src/core/cart/server-handlers.ts index 5a23667e98..488a1015fa 100644 --- a/packages/hydrogen/src/core/cart/server-handlers.ts +++ b/packages/hydrogen/src/core/cart/server-handlers.ts @@ -303,6 +303,13 @@ async function executeMutation( const { cart, userErrors, warnings } = assertMutationData(result, "cartNoteUpdate"); return createMutationResult(cart, userErrors, warnings, result.headers); } + case "attributes-update": { + const result = await storefront.graphql(queries.cartAttributesUpdate, { + variables: { cartId, attributes: action.attributes }, + }); + const { cart, userErrors, warnings } = assertMutationData(result, "cartAttributesUpdate"); + return createMutationResult(cart, userErrors, warnings, result.headers); + } default: { const _exhaustive: never = action; throw new Error(`Unhandled cart action intent: ${(_exhaustive as CartAction).intent}`); diff --git a/packages/hydrogen/src/core/cart/state.ts b/packages/hydrogen/src/core/cart/state.ts index 675d0c3dea..3138b600a2 100644 --- a/packages/hydrogen/src/core/cart/state.ts +++ b/packages/hydrogen/src/core/cart/state.ts @@ -89,6 +89,7 @@ export interface Attribute { export interface CartPending { lines: Set; note: boolean; + attributes: boolean; discountCodes: Set; cost?: boolean; } @@ -97,12 +98,14 @@ export interface CartErrorState { cart: CartErrorGroup; lines: Map; note: CartErrorGroup; + attributes: Map; discountCodes: Map; network: CartNetworkEntry[]; lastUpdatedAt: number; cartUpdatedAt: number; linesUpdatedAt: number; noteUpdatedAt: number; + attributesUpdatedAt: number; discountCodesUpdatedAt: number; networkUpdatedAt: number; } @@ -113,6 +116,7 @@ export interface CartData { totalQuantity: number; cost: CartCost; note?: string | null; + attributes: Attribute[]; lines: CartLineConnection; discountCodes: DiscountCode[]; [key: string]: unknown; @@ -129,7 +133,7 @@ export interface CartState { } export function createEmptyPending(): CartPending { - return { lines: new Set(), note: false, discountCodes: new Set(), cost: false }; + return { lines: new Set(), note: false, attributes: false, discountCodes: new Set(), cost: false }; } export function createEmptyErrorGroup(): CartErrorGroup { @@ -141,12 +145,14 @@ export function createEmptyCartErrors(): CartErrorState { cart: createEmptyErrorGroup(), lines: new Map(), note: createEmptyErrorGroup(), + attributes: new Map(), discountCodes: new Map(), network: [], lastUpdatedAt: 0, cartUpdatedAt: 0, linesUpdatedAt: 0, noteUpdatedAt: 0, + attributesUpdatedAt: 0, discountCodesUpdatedAt: 0, networkUpdatedAt: 0, }; @@ -162,6 +168,7 @@ export const EMPTY_CART_DATA: CartData = Object.freeze({ checkoutChargeAmount: Object.freeze({ amount: "0", currencyCode: "" }), }), note: "", + attributes: [] as Attribute[], lines: Object.freeze({ nodes: [] as CartLine[] }), discountCodes: [] as DiscountCode[], }); diff --git a/packages/hydrogen/src/core/index.ts b/packages/hydrogen/src/core/index.ts index 081bc771d8..c9b9b5d5cc 100644 --- a/packages/hydrogen/src/core/index.ts +++ b/packages/hydrogen/src/core/index.ts @@ -82,7 +82,7 @@ export { createCartFormRegister } from "./cart"; export type { CartFormRegister, QuantityInputAttributes, SetButtonAttributes } from "./cart"; export { attachQuantityInput } from "./cart"; export { parseCartRequest } from "./cart"; -export type { CartAction, CartLineAddInput, CartLineUpdateInput } from "./cart"; +export type { CartAction, CartAttributeInput, CartLineAddInput, CartLineUpdateInput } from "./cart"; export { cartQueries, createCartCookie } from "./cart"; export { getCartId, getCart, createCartServerHandlers } from "./cart"; export type { @@ -113,6 +113,7 @@ export type { CartLineMerchandise, CartCost, DiscountCode, + Attribute, } from "./cart"; export { EMPTY_CART_DATA, diff --git a/packages/hydrogen/src/react/cart-ssr.test.tsx b/packages/hydrogen/src/react/cart-ssr.test.tsx index bfd9c7fb6c..3d446871bf 100644 --- a/packages/hydrogen/src/react/cart-ssr.test.tsx +++ b/packages/hydrogen/src/react/cart-ssr.test.tsx @@ -41,6 +41,7 @@ const MOCK_CART: CartData = { checkoutChargeAmount: { amount: "30.00", currencyCode: "USD" }, }, note: "", + attributes: [], lines: { nodes: [ { diff --git a/packages/hydrogen/src/react/cart.test.tsx b/packages/hydrogen/src/react/cart.test.tsx index c8fcc61045..edf60999be 100644 --- a/packages/hydrogen/src/react/cart.test.tsx +++ b/packages/hydrogen/src/react/cart.test.tsx @@ -642,7 +642,13 @@ describe("useCart pending state", () => { const mockStore = createMockStore(); mockStore.setState( makeCartState({ - pending: { lines: new Set(["line-1"]), note: false, discountCodes: new Set(), cost: true }, + pending: { + lines: new Set(["line-1"]), + note: false, + attributes: false, + discountCodes: new Set(), + cost: true, + }, }), ); vi.mocked(createCartStore).mockImplementation(() => mockStore); @@ -664,7 +670,13 @@ describe("useCart pending state", () => { const mockStore = createMockStore(); mockStore.setState( makeCartState({ - pending: { lines: new Set(), note: true, discountCodes: new Set(), cost: false }, + pending: { + lines: new Set(), + note: true, + attributes: false, + discountCodes: new Set(), + cost: false, + }, }), ); vi.mocked(createCartStore).mockImplementation(() => mockStore); @@ -682,7 +694,13 @@ describe("useCart pending state", () => { const mockStore = createMockStore(); mockStore.setState( makeCartState({ - pending: { lines: new Set(), note: false, discountCodes: new Set(["SAVE10"]), cost: true }, + pending: { + lines: new Set(), + note: false, + attributes: false, + discountCodes: new Set(["SAVE10"]), + cost: true, + }, }), ); vi.mocked(createCartStore).mockImplementation(() => mockStore); diff --git a/packages/hydrogen/src/vue/cart.test.ts b/packages/hydrogen/src/vue/cart.test.ts index 6b3a852108..aa7a8af5a2 100644 --- a/packages/hydrogen/src/vue/cart.test.ts +++ b/packages/hydrogen/src/vue/cart.test.ts @@ -522,6 +522,7 @@ describe("useCartForm", () => { pending: { lines: new Set(["line-1"]), note: false, + attributes: false, discountCodes: new Set(), cost: true, }, @@ -553,6 +554,7 @@ describe("useCartForm", () => { pending: { lines: new Set(["line-1"]), note: false, + attributes: false, discountCodes: new Set(), cost: true, }, diff --git a/packages/hydrogen/vendor/standard-actions.d.ts b/packages/hydrogen/vendor/standard-actions.d.ts index b090cd23a1..59d7a74727 100644 --- a/packages/hydrogen/vendor/standard-actions.d.ts +++ b/packages/hydrogen/vendor/standard-actions.d.ts @@ -75,12 +75,16 @@ export interface StorefrontCartSummary { cost: CartCost; lines: StorefrontCartLinesConnection; discountCodes: CartDiscountCode[]; + attributes?: Array<{ + key: string; + value: string; + }>; } export type UpdateCartEventTargetMeta = { type: "shopify:cart:lines-update"; action: CartUpdateAction; } | { - type: "shopify:cart:note-update" | "shopify:cart:discount-update" | "shopify:cart:error"; + type: "shopify:cart:note-update" | "shopify:cart:attributes-update" | "shopify:cart:discount-update" | "shopify:cart:error"; }; export interface EventTargetConfigurationMeta { /** @@ -115,6 +119,10 @@ export interface UpdateCartPayload { lines?: CartLineInput[]; note?: string; discountCodes?: string[]; + attributes?: Array<{ + key: string; + value: string; + }>; } export interface UpdateCartResult { cart: StorefrontCartSummary; From 006f155fd29dba09d227959d3a8da58cd0077314 Mon Sep 17 00:00:00 2001 From: Andrew Nguyen Date: Fri, 31 Jul 2026 13:49:34 -0700 Subject: [PATCH 2/5] feat: update skills and examples --- .../hydrogen/app/components/CartSummary.tsx | 40 +++++++++++++- examples/hydrogen/app/lib/analytics.tsx | 2 +- examples/hydrogen/app/styles/app.css | 14 ++++- .../skills/hydrogen-cart-drawer/SKILL.md | 4 +- .../hydrogen/skills/hydrogen-cart-ui/SKILL.md | 54 +++++++++++++------ .../hydrogen-cart-ui/references/react.md | 33 +++++++++++- .../skills/hydrogen-cart-ui/references/vue.md | 3 ++ .../skills/hydrogen-smoke-test/SKILL.md | 6 ++- templates/nextjs/lib/content.ts | 6 +++ 9 files changed, 137 insertions(+), 25 deletions(-) diff --git a/examples/hydrogen/app/components/CartSummary.tsx b/examples/hydrogen/app/components/CartSummary.tsx index ca9cef24e7..0c5440a300 100644 --- a/examples/hydrogen/app/components/CartSummary.tsx +++ b/examples/hydrogen/app/components/CartSummary.tsx @@ -2,7 +2,7 @@ import type { CartData, MoneyV2 } from "@shopify/hydrogen"; import { useId } from "react"; import type { CartLayout } from "~/components/CartMain"; -import { useCartForm } from "~/lib/cart"; +import { useCart, useCartForm } from "~/lib/cart"; import { formatMoney } from "~/lib/money"; type CartSummaryProps = { @@ -32,11 +32,49 @@ export function CartSummary({ cart, layout, totalsPending }: CartSummaryProps) { discountsHeadingId={discountsHeadingId} discountCodeInputId={discountCodeInputId} /> + ); } +const GIFT_MESSAGE_ATTRIBUTE = "gift-message"; + +function CartGiftMessage({ attributes }: { attributes: CartData["attributes"] }) { + const { formProps, register } = useCartForm(); + const attributesPending = useCart((state) => state.pending.attributes); + const giftMessageId = useId(); + const giftMessage = attributes.find(({ key }) => key === GIFT_MESSAGE_ATTRIBUTE)?.value ?? ""; + const preservedAttributes = attributes.filter(({ key }) => key !== GIFT_MESSAGE_ATTRIBUTE); + + return ( +
+ {preservedAttributes.map(({ key, value }) => ( +
+ + +
+ ))} + + +