diff --git a/.changeset/gift-card-add-mutation.md b/.changeset/gift-card-add-mutation.md new file mode 100644 index 0000000000..9dc0f8fff1 --- /dev/null +++ b/.changeset/gift-card-add-mutation.md @@ -0,0 +1,70 @@ +--- +'@shopify/hydrogen': minor +'@shopify/cli-hydrogen': patch +'@shopify/create-hydrogen': patch +--- + +Add `cartGiftCardCodesAdd` mutation + +## New Feature: cartGiftCardCodesAdd + +Adds gift card codes without replacing existing ones. + +**Before (2025-07):** +```typescript +const codes = ['EXISTING1', 'EXISTING2']; +await cart.updateGiftCardCodes(['EXISTING1', 'EXISTING2', 'NEW_CODE']); +``` + +**After (2025-10):** +```typescript +await cart.addGiftCardCodes(['NEW_CODE']); +``` + +## Verified API Behavior + +| Scenario | Behavior | +|----------|----------| +| Valid gift card code | Applied successfully | +| UPPERCASE code | Works (API is case-insensitive) | +| Duplicate code in same call | Idempotent - applied once, no error | +| Re-applying already applied code | Idempotent - no error, no duplicate | +| Multiple different codes | All applied successfully | +| Invalid code | Silently rejected (no error surfaced) | +| Code with whitespace | Rejected (API does not trim whitespace) | +| Empty input | Graceful no-op | + +**Note:** The API handles duplicate gift card codes gracefully - submitting an already-applied code results in silent success (idempotent behavior), not an error. No `DUPLICATE_GIFT_CARD` error code exists. + +**Note on whitespace:** The API does NOT trim whitespace from codes. Ensure codes are trimmed before submission if accepting user input. + +## API Reference + +**New method:** +- `cart.addGiftCardCodes(codes)` - Appends codes to cart +- `CartForm.ACTIONS.GiftCardCodesAdd` - Form action + +## Skeleton Template Changes + +The skeleton template has been updated to use the new `cartGiftCardCodesAdd` mutation: +- Removed `UpdateGiftCardForm` component from `CartSummary.tsx` +- Added `AddGiftCardForm` component using `CartForm.ACTIONS.GiftCardCodesAdd` + +If you customized the gift card form in your project, you may want to migrate to the new `Add` action for simpler code. + +## Usage + +```typescript +import {CartForm} from '@shopify/hydrogen'; + + + + +``` + +Or with createCartHandler: + +```typescript +const cart = createCartHandler({storefront, getCartId, setCartId}); +await cart.addGiftCardCodes(['SUMMER2025', 'WELCOME10']); +``` diff --git a/packages/hydrogen/src/cart/CartForm.test.tsx b/packages/hydrogen/src/cart/CartForm.test.tsx index e581ce0114..c35831e885 100644 --- a/packages/hydrogen/src/cart/CartForm.test.tsx +++ b/packages/hydrogen/src/cart/CartForm.test.tsx @@ -87,6 +87,7 @@ describe('', () => { DeliveryAddressesRemove: 'DeliveryAddressesRemove', DiscountCodesUpdate: 'DiscountCodesUpdate', GiftCardCodesUpdate: 'GiftCardCodesUpdate', + GiftCardCodesAdd: 'GiftCardCodesAdd', GiftCardCodesRemove: 'GiftCardCodesRemove', LinesAdd: 'LinesAdd', LinesUpdate: 'LinesUpdate', diff --git a/packages/hydrogen/src/cart/CartForm.tsx b/packages/hydrogen/src/cart/CartForm.tsx index 5e5d92c985..6b03147fd6 100644 --- a/packages/hydrogen/src/cart/CartForm.tsx +++ b/packages/hydrogen/src/cart/CartForm.tsx @@ -87,6 +87,20 @@ type CartGiftCardCodesUpdateRequire = { } & OtherFormData; }; +type CartGiftCardCodesAddProps = { + action: 'GiftCardCodesAdd'; + inputs?: { + giftCardCodes: string[]; + } & OtherFormData; +}; + +type CartGiftCardCodesAddRequire = { + action: 'GiftCardCodesAdd'; + inputs: { + giftCardCodes: string[]; + } & OtherFormData; +}; + type CartGiftCardCodesRemoveProps = { action: 'GiftCardCodesRemove'; inputs?: { @@ -278,6 +292,7 @@ type CartActionInputProps = | CartCreateProps | CartDiscountCodesUpdateProps | CartGiftCardCodesUpdateProps + | CartGiftCardCodesAddProps | CartGiftCardCodesRemoveProps | CartLinesAddProps | CartLinesUpdateProps @@ -297,6 +312,7 @@ export type CartActionInput = | CartCreateRequire | CartDiscountCodesUpdateRequire | CartGiftCardCodesUpdateRequire + | CartGiftCardCodesAddRequire | CartGiftCardCodesRemoveRequire | CartLinesAddRequire | CartLinesUpdateRequire @@ -345,6 +361,7 @@ CartForm.ACTIONS = { Create: 'Create', DiscountCodesUpdate: 'DiscountCodesUpdate', GiftCardCodesUpdate: 'GiftCardCodesUpdate', + GiftCardCodesAdd: 'GiftCardCodesAdd', GiftCardCodesRemove: 'GiftCardCodesRemove', LinesAdd: 'LinesAdd', LinesRemove: 'LinesRemove', diff --git a/packages/hydrogen/src/cart/createCartHandler.test.ts b/packages/hydrogen/src/cart/createCartHandler.test.ts index 1d3a0318bb..f0f932c1a7 100644 --- a/packages/hydrogen/src/cart/createCartHandler.test.ts +++ b/packages/hydrogen/src/cart/createCartHandler.test.ts @@ -37,7 +37,7 @@ describe('createCartHandler', () => { const cart = getCartHandler(); expectTypeOf(cart).toEqualTypeOf; - expect(Object.keys(cart)).toHaveLength(19); + expect(Object.keys(cart)).toHaveLength(20); expect(cart).toHaveProperty('get'); expect(cart).toHaveProperty('getCartId'); expect(cart).toHaveProperty('setCartId'); @@ -47,6 +47,7 @@ describe('createCartHandler', () => { expect(cart).toHaveProperty('removeLines'); expect(cart).toHaveProperty('updateDiscountCodes'); expect(cart).toHaveProperty('updateGiftCardCodes'); + expect(cart).toHaveProperty('addGiftCardCodes'); expect(cart).toHaveProperty('removeGiftCardCodes'); expect(cart).toHaveProperty('updateBuyerIdentity'); expect(cart).toHaveProperty('updateNote'); @@ -72,7 +73,7 @@ describe('createCartHandler', () => { }); expectTypeOf(cart).toEqualTypeOf 'bar'}>>; - expect(Object.keys(cart)).toHaveLength(20); + expect(Object.keys(cart)).toHaveLength(21); expect(cart.foo()).toBe('bar'); }); @@ -86,7 +87,7 @@ describe('createCartHandler', () => { }); expectTypeOf(cart).toEqualTypeOf; - expect(Object.keys(cart)).toHaveLength(19); + expect(Object.keys(cart)).toHaveLength(20); expect(await cart.get()).toBe('bar'); }); diff --git a/packages/hydrogen/src/cart/createCartHandler.ts b/packages/hydrogen/src/cart/createCartHandler.ts index b3a3954765..4f653f4239 100644 --- a/packages/hydrogen/src/cart/createCartHandler.ts +++ b/packages/hydrogen/src/cart/createCartHandler.ts @@ -49,6 +49,10 @@ import { type CartGiftCardCodesUpdateFunction, cartGiftCardCodesUpdateDefault, } from './queries/cartGiftCardCodeUpdateDefault'; +import { + type CartGiftCardCodesAddFunction, + cartGiftCardCodesAddDefault, +} from './queries/cartGiftCardCodesAddDefault'; import { type CartGiftCardCodesRemoveFunction, cartGiftCardCodesRemoveDefault, @@ -94,6 +98,7 @@ export type HydrogenCart = { removeLines: ReturnType; updateDiscountCodes: ReturnType; updateGiftCardCodes: ReturnType; + addGiftCardCodes: ReturnType; removeGiftCardCodes: ReturnType; updateBuyerIdentity: ReturnType; updateNote: ReturnType; @@ -274,6 +279,7 @@ export function createCartHandler( ) : await cartCreate({giftCardCodes}, optionalParams); }, + addGiftCardCodes: cartGiftCardCodesAddDefault(mutateOptions), removeGiftCardCodes: cartGiftCardCodesRemoveDefault(mutateOptions), updateBuyerIdentity: async (buyerIdentity, optionalParams) => { return cartId || optionalParams?.cartId @@ -425,6 +431,10 @@ export type HydrogenCartForDocs = { * Updates gift card codes in the cart. */ updateGiftCardCodes?: CartGiftCardCodesUpdateFunction; + /** + * Adds gift card codes to the cart without replacing existing ones. + */ + addGiftCardCodes?: CartGiftCardCodesAddFunction; /** * Removes gift card codes from the cart. */ diff --git a/packages/hydrogen/src/cart/queries/cartGiftCardCodesAddDefault.doc.ts b/packages/hydrogen/src/cart/queries/cartGiftCardCodesAddDefault.doc.ts new file mode 100644 index 0000000000..aa13f98609 --- /dev/null +++ b/packages/hydrogen/src/cart/queries/cartGiftCardCodesAddDefault.doc.ts @@ -0,0 +1,56 @@ +import {ReferenceEntityTemplateSchema} from '@shopify/generate-docs'; + +const data: ReferenceEntityTemplateSchema = { + name: 'cartGiftCardCodesAddDefault', + category: 'utilities', + subCategory: 'cart', + isVisualComponent: false, + related: [ + { + name: 'cartGiftCardCodesUpdateDefault', + type: 'utilities', + url: '/docs/api/hydrogen/utilities/cartgiftcardcodesupdatedefault', + }, + { + name: 'cartGiftCardCodesRemoveDefault', + type: 'utilities', + url: '/docs/api/hydrogen/utilities/cartgiftcardcodesremovedefault', + }, + { + name: 'createCartHandler', + type: 'utilities', + url: '/docs/api/hydrogen/utilities/createcarthandler', + }, + ], + description: + 'Creates a function that adds gift card codes to a cart without replacing existing ones', + type: 'utility', + defaultExample: { + description: + 'Add gift card codes to a cart using the default cart fragment', + codeblock: { + tabs: [ + { + title: 'JavaScript', + code: './cartGiftCardCodesAddDefault.example.js', + language: 'js', + }, + { + title: 'TypeScript', + code: './cartGiftCardCodesAddDefault.example.ts', + language: 'ts', + }, + ], + title: 'example', + }, + }, + definitions: [ + { + title: 'cartGiftCardCodesAddDefault', + type: 'CartGiftCardCodesAddDefaultGeneratedType', + description: '', + }, + ], +}; + +export default data; diff --git a/packages/hydrogen/src/cart/queries/cartGiftCardCodesAddDefault.example.js b/packages/hydrogen/src/cart/queries/cartGiftCardCodesAddDefault.example.js new file mode 100644 index 0000000000..6a1bf78ebd --- /dev/null +++ b/packages/hydrogen/src/cart/queries/cartGiftCardCodesAddDefault.example.js @@ -0,0 +1,11 @@ +import {cartGiftCardCodesAddDefault} from '@shopify/hydrogen'; + +export async function action({context}) { + const cartAddGiftCardCodes = cartGiftCardCodesAddDefault({ + storefront: context.storefront, + getCartId: () => context.cart.getCartId(), + }); + + const result = await cartAddGiftCardCodes(['SUMMER2025', 'WELCOME10']); + return result; +} diff --git a/packages/hydrogen/src/cart/queries/cartGiftCardCodesAddDefault.example.ts b/packages/hydrogen/src/cart/queries/cartGiftCardCodesAddDefault.example.ts new file mode 100644 index 0000000000..dfd16595f1 --- /dev/null +++ b/packages/hydrogen/src/cart/queries/cartGiftCardCodesAddDefault.example.ts @@ -0,0 +1,16 @@ +import { + cartGiftCardCodesAddDefault, + type HydrogenCart, + type CartQueryOptions, +} from '@shopify/hydrogen'; + +export async function action({context}: {context: CartQueryOptions}) { + const cartAddGiftCardCodes: HydrogenCart['addGiftCardCodes'] = + cartGiftCardCodesAddDefault({ + storefront: context.storefront, + getCartId: context.getCartId, + }); + + const result = await cartAddGiftCardCodes(['SUMMER2025', 'WELCOME10']); + return result; +} diff --git a/packages/hydrogen/src/cart/queries/cartGiftCardCodesAddDefault.test.ts b/packages/hydrogen/src/cart/queries/cartGiftCardCodesAddDefault.test.ts new file mode 100644 index 0000000000..eaa02bbb51 --- /dev/null +++ b/packages/hydrogen/src/cart/queries/cartGiftCardCodesAddDefault.test.ts @@ -0,0 +1,102 @@ +/** + * TODO: These tests are placeholders that verify mock returns, not actual API behavior. + * They should be improved in a follow-up PR to test real integration scenarios. + * See PR #3284 for context. + */ +import {describe, it, expect} from 'vitest'; +import {CART_ID, mockCreateStorefrontClient} from '../cart-test-helper'; +import { + cartGiftCardCodesAddDefault, + CART_GIFT_CARD_CODES_ADD_MUTATION, +} from './cartGiftCardCodesAddDefault'; + +describe('cartGiftCardCodesAddDefault', () => { + describe('basic functionality', () => { + it('should add gift card codes to cart without replacing existing ones', async () => { + const addGiftCardCodes = cartGiftCardCodesAddDefault({ + storefront: mockCreateStorefrontClient(), + getCartId: () => CART_ID, + }); + + const result = await addGiftCardCodes(['SUMMER2025']); + + expect(result.cart).toHaveProperty('id', CART_ID); + }); + + it('should handle multiple gift card codes in single call', async () => { + const addGiftCardCodes = cartGiftCardCodesAddDefault({ + storefront: mockCreateStorefrontClient(), + getCartId: () => CART_ID, + }); + + const result = await addGiftCardCodes([ + 'GIFT123', + 'GIFT456', + 'WELCOME25', + ]); + + expect(result.cart).toHaveProperty('id', CART_ID); + }); + + it('should handle empty array', async () => { + const addGiftCardCodes = cartGiftCardCodesAddDefault({ + storefront: mockCreateStorefrontClient(), + getCartId: () => CART_ID, + }); + + const result = await addGiftCardCodes([]); + + expect(result.cart).toHaveProperty('id', CART_ID); + }); + }); + + describe('cartFragment override', () => { + it('can override cartFragment for custom query fields', async () => { + const cartFragment = 'cartFragmentOverride'; + const addGiftCardCodes = cartGiftCardCodesAddDefault({ + storefront: mockCreateStorefrontClient(), + getCartId: () => CART_ID, + cartFragment, + }); + + const result = await addGiftCardCodes(['TESTCODE']); + + expect(result.cart).toHaveProperty('id', CART_ID); + expect(result.userErrors?.[0]).toContain(cartFragment); + }); + }); + + describe('mutation structure', () => { + it('should include required mutation fields for error and warning handling', () => { + const mutation = CART_GIFT_CARD_CODES_ADD_MUTATION(); + + expect(mutation).toContain('cartGiftCardCodesAdd'); + expect(mutation).toContain('userErrors'); + expect(mutation).toContain('warnings'); + expect(mutation).toContain('CartApiError'); + expect(mutation).toContain('CartApiWarning'); + }); + + it('should include @inContext directive for internationalization', () => { + const mutation = CART_GIFT_CARD_CODES_ADD_MUTATION(); + + expect(mutation).toContain('@inContext'); + expect(mutation).toContain('$country'); + expect(mutation).toContain('$language'); + }); + }); + + describe('no duplicate filtering', () => { + it('should pass duplicate codes to API without filtering', async () => { + const addGiftCardCodes = cartGiftCardCodesAddDefault({ + storefront: mockCreateStorefrontClient(), + getCartId: () => CART_ID, + }); + + const codesWithDuplicates = ['GIFT123', 'GIFT123', 'GIFT456']; + const result = await addGiftCardCodes(codesWithDuplicates); + + expect(result.cart).toHaveProperty('id', CART_ID); + }); + }); +}); diff --git a/packages/hydrogen/src/cart/queries/cartGiftCardCodesAddDefault.ts b/packages/hydrogen/src/cart/queries/cartGiftCardCodesAddDefault.ts new file mode 100644 index 0000000000..fecb9b85be --- /dev/null +++ b/packages/hydrogen/src/cart/queries/cartGiftCardCodesAddDefault.ts @@ -0,0 +1,75 @@ +import {StorefrontApiErrors, formatAPIResult} from '../../storefront'; +import { + CART_WARNING_FRAGMENT, + MINIMAL_CART_FRAGMENT, + USER_ERROR_FRAGMENT, +} from './cart-fragments'; +import type { + CartOptionalInput, + CartQueryData, + CartQueryDataReturn, + CartQueryOptions, +} from './cart-types'; + +export type CartGiftCardCodesAddFunction = ( + giftCardCodes: string[], + optionalParams?: CartOptionalInput, +) => Promise; + +/** + * Adds gift card codes to the cart without replacing existing ones. + * + * This function sends a mutation to the Storefront API to add one or more gift card codes to the cart. + * Unlike `cartGiftCardCodesUpdate` which replaces all codes, this mutation appends new codes to existing ones. + * + * @param {CartQueryOptions} options - The options for the cart query, including the storefront API client and cart fragment. + * @returns {CartGiftCardCodesAddFunction} - A function that takes an array of gift card codes and optional parameters, and returns the result of the API call. + * + * @example Add gift card codes + * const addGiftCardCodes = cartGiftCardCodesAddDefault({ storefront, getCartId }); + * await addGiftCardCodes(['SUMMER2025', 'WELCOME10']); + */ +export function cartGiftCardCodesAddDefault( + options: CartQueryOptions, +): CartGiftCardCodesAddFunction { + return async (giftCardCodes, optionalParams) => { + const {cartGiftCardCodesAdd, errors} = await options.storefront.mutate<{ + cartGiftCardCodesAdd: CartQueryData; + errors: StorefrontApiErrors; + }>(CART_GIFT_CARD_CODES_ADD_MUTATION(options.cartFragment), { + variables: { + cartId: options.getCartId(), + giftCardCodes, + ...optionalParams, + }, + }); + return formatAPIResult(cartGiftCardCodesAdd, errors); + }; +} + +//! @see https://shopify.dev/docs/api/storefront/latest/mutations/cartGiftCardCodesAdd +export const CART_GIFT_CARD_CODES_ADD_MUTATION = ( + cartFragment = MINIMAL_CART_FRAGMENT, +) => `#graphql + mutation cartGiftCardCodesAdd( + $cartId: ID! + $giftCardCodes: [String!]! + $language: LanguageCode + $country: CountryCode + ) @inContext(country: $country, language: $language) { + cartGiftCardCodesAdd(cartId: $cartId, giftCardCodes: $giftCardCodes) { + cart { + ...CartApiMutation + } + userErrors { + ...CartApiError + } + warnings { + ...CartApiWarning + } + } + } + ${cartFragment} + ${USER_ERROR_FRAGMENT} + ${CART_WARNING_FRAGMENT} +`; diff --git a/packages/hydrogen/src/index.ts b/packages/hydrogen/src/index.ts index 2b14a01f54..5dadfadecb 100644 --- a/packages/hydrogen/src/index.ts +++ b/packages/hydrogen/src/index.ts @@ -58,6 +58,7 @@ export {cartBuyerIdentityUpdateDefault} from './cart/queries/cartBuyerIdentityUp export {cartCreateDefault} from './cart/queries/cartCreateDefault'; export {cartDiscountCodesUpdateDefault} from './cart/queries/cartDiscountCodesUpdateDefault'; export {cartGetDefault} from './cart/queries/cartGetDefault'; +export {cartGiftCardCodesAddDefault} from './cart/queries/cartGiftCardCodesAddDefault'; export {cartGiftCardCodesRemoveDefault} from './cart/queries/cartGiftCardCodesRemoveDefault'; export {cartGiftCardCodesUpdateDefault} from './cart/queries/cartGiftCardCodeUpdateDefault'; export {cartLinesAddDefault} from './cart/queries/cartLinesAddDefault'; diff --git a/templates/skeleton/app/components/CartSummary.tsx b/templates/skeleton/app/components/CartSummary.tsx index 541f2c819d..408fb9895c 100644 --- a/templates/skeleton/app/components/CartSummary.tsx +++ b/templates/skeleton/app/components/CartSummary.tsx @@ -149,8 +149,7 @@ function CartGiftCard({ )} {/* Show an input to apply a gift card */} - @@ -166,18 +165,16 @@ function CartGiftCard({ Apply - + ); } -function UpdateGiftCardForm({ - giftCardCodes, +function AddGiftCardForm({ saveAppliedCode, fetcherKey, children, }: { - giftCardCodes?: string[]; saveAppliedCode?: (code: string) => void; fetcherKey?: string; children: React.ReactNode; @@ -186,10 +183,7 @@ function UpdateGiftCardForm({ {(fetcher: FetcherWithComponents) => { const code = fetcher.formData?.get('giftCardCode'); diff --git a/templates/skeleton/app/routes/cart.tsx b/templates/skeleton/app/routes/cart.tsx index f82d683fdb..bb143b40d9 100644 --- a/templates/skeleton/app/routes/cart.tsx +++ b/templates/skeleton/app/routes/cart.tsx @@ -52,18 +52,14 @@ export async function action({request, context}: Route.ActionArgs) { result = await cart.updateDiscountCodes(discountCodes); break; } - case CartForm.ACTIONS.GiftCardCodesUpdate: { + case CartForm.ACTIONS.GiftCardCodesAdd: { const formGiftCardCode = inputs.giftCardCode; - // User inputted gift card code const giftCardCodes = ( formGiftCardCode ? [formGiftCardCode] : [] ) as string[]; - // Combine gift card codes already applied on cart - giftCardCodes.push(...inputs.giftCardCodes); - - result = await cart.updateGiftCardCodes(giftCardCodes); + result = await cart.addGiftCardCodes(giftCardCodes); break; } case CartForm.ACTIONS.GiftCardCodesRemove: {