Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions .changeset/gift-card-add-mutation.md
Original file line number Diff line number Diff line change
@@ -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';

<CartForm action={CartForm.ACTIONS.GiftCardCodesAdd} inputs={{giftCardCodes: ['CODE1', 'CODE2']}}>
<button>Add Gift Cards</button>
</CartForm>
```

Or with createCartHandler:

```typescript
const cart = createCartHandler({storefront, getCartId, setCartId});
await cart.addGiftCardCodes(['SUMMER2025', 'WELCOME10']);
```
1 change: 1 addition & 0 deletions packages/hydrogen/src/cart/CartForm.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ describe('<CartForm />', () => {
DeliveryAddressesRemove: 'DeliveryAddressesRemove',
DiscountCodesUpdate: 'DiscountCodesUpdate',
GiftCardCodesUpdate: 'GiftCardCodesUpdate',
GiftCardCodesAdd: 'GiftCardCodesAdd',
GiftCardCodesRemove: 'GiftCardCodesRemove',
LinesAdd: 'LinesAdd',
LinesUpdate: 'LinesUpdate',
Expand Down
17 changes: 17 additions & 0 deletions packages/hydrogen/src/cart/CartForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,20 @@ type CartGiftCardCodesUpdateRequire = {
} & OtherFormData;
};

type CartGiftCardCodesAddProps = {
action: 'GiftCardCodesAdd';
inputs?: {
giftCardCodes: string[];
} & OtherFormData;
};

type CartGiftCardCodesAddRequire = {
action: 'GiftCardCodesAdd';
inputs: {
giftCardCodes: string[];
} & OtherFormData;
};
Comment on lines +97 to +102
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for the future we can probably reduce these types by half with a util like

type ActionWithRequiredInputs<T extends {inputs?: unknown}> = T & {inputs: T['inputs']};

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah this is something I'd love to do in the future! Added it to the list. I think that's out of scope for this PR (and the overall 2025-10 API change) though


type CartGiftCardCodesRemoveProps = {
action: 'GiftCardCodesRemove';
inputs?: {
Expand Down Expand Up @@ -278,6 +292,7 @@ type CartActionInputProps =
| CartCreateProps
| CartDiscountCodesUpdateProps
| CartGiftCardCodesUpdateProps
| CartGiftCardCodesAddProps
| CartGiftCardCodesRemoveProps
| CartLinesAddProps
| CartLinesUpdateProps
Expand All @@ -297,6 +312,7 @@ export type CartActionInput =
| CartCreateRequire
| CartDiscountCodesUpdateRequire
| CartGiftCardCodesUpdateRequire
| CartGiftCardCodesAddRequire
| CartGiftCardCodesRemoveRequire
| CartLinesAddRequire
| CartLinesUpdateRequire
Comment on lines 312 to 318
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Re: lines +309 to +327]

did not test LSP for performance benchmarks BUT

we can delete all the ___Require types and replace this type with

type WithRequiredInputsDistributive<T extends {inputs?: unknown}> = T extends unknown ? T & {inputs: NonNullable<T['inputs']>} : never;
export type CartActionInput = WithRequiredInputsDistributive<CartActionInputProps>

is this something that we want? i could open a separate PR for that.

Pros: fewer manual types to write, fewer mistakes, less maintenance
Cons: LSP may be a liltle bit slower

"no" is a perfectly good answer to this

See this comment inline on Graphite.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Expand Down Expand Up @@ -345,6 +361,7 @@ CartForm.ACTIONS = {
Create: 'Create',
DiscountCodesUpdate: 'DiscountCodesUpdate',
GiftCardCodesUpdate: 'GiftCardCodesUpdate',
GiftCardCodesAdd: 'GiftCardCodesAdd',
GiftCardCodesRemove: 'GiftCardCodesRemove',
LinesAdd: 'LinesAdd',
LinesRemove: 'LinesRemove',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Re: line +376]

} as const  satisfies Record<CartActionInput['action'], CartActionInput['action']>;

See this comment inline on Graphite.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

adding this has revealed that we may have a typo in MetaFieldDelete (it’s actually MetaFieldsDelete

unless these are unrelated to CartActionInput

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm seeing MetafieldDelete in the schema
image

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah but this has to agree with the cart actions not the graphql API, no?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay I looked into this a bit more and it looks like for this one it's a typo in terms of it being inconsistent with the other entries, but nothing seems to actually be broken. Wouldn't hurt to fix if this means no breaking changes for consumers, but that's out of scope for this PR

Expand Down
7 changes: 4 additions & 3 deletions packages/hydrogen/src/cart/createCartHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ describe('createCartHandler', () => {
const cart = getCartHandler();

expectTypeOf(cart).toEqualTypeOf<HydrogenCart>;
expect(Object.keys(cart)).toHaveLength(19);
expect(Object.keys(cart)).toHaveLength(20);
expect(cart).toHaveProperty('get');
expect(cart).toHaveProperty('getCartId');
expect(cart).toHaveProperty('setCartId');
Expand All @@ -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');
Expand All @@ -72,7 +73,7 @@ describe('createCartHandler', () => {
});

expectTypeOf(cart).toEqualTypeOf<HydrogenCartCustom<{foo: () => 'bar'}>>;
expect(Object.keys(cart)).toHaveLength(20);
expect(Object.keys(cart)).toHaveLength(21);
expect(cart.foo()).toBe('bar');
});

Expand All @@ -86,7 +87,7 @@ describe('createCartHandler', () => {
});

expectTypeOf(cart).toEqualTypeOf<HydrogenCart>;
expect(Object.keys(cart)).toHaveLength(19);
expect(Object.keys(cart)).toHaveLength(20);
expect(await cart.get()).toBe('bar');
});

Expand Down
10 changes: 10 additions & 0 deletions packages/hydrogen/src/cart/createCartHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ import {
type CartGiftCardCodesUpdateFunction,
cartGiftCardCodesUpdateDefault,
} from './queries/cartGiftCardCodeUpdateDefault';
import {
type CartGiftCardCodesAddFunction,
cartGiftCardCodesAddDefault,
} from './queries/cartGiftCardCodesAddDefault';
import {
type CartGiftCardCodesRemoveFunction,
cartGiftCardCodesRemoveDefault,
Expand Down Expand Up @@ -94,6 +98,7 @@ export type HydrogenCart = {
removeLines: ReturnType<typeof cartLinesRemoveDefault>;
updateDiscountCodes: ReturnType<typeof cartDiscountCodesUpdateDefault>;
updateGiftCardCodes: ReturnType<typeof cartGiftCardCodesUpdateDefault>;
addGiftCardCodes: ReturnType<typeof cartGiftCardCodesAddDefault>;
removeGiftCardCodes: ReturnType<typeof cartGiftCardCodesRemoveDefault>;
updateBuyerIdentity: ReturnType<typeof cartBuyerIdentityUpdateDefault>;
updateNote: ReturnType<typeof cartNoteUpdateDefault>;
Expand Down Expand Up @@ -274,6 +279,7 @@ export function createCartHandler<TCustomMethods extends CustomMethodsBase>(
)
: await cartCreate({giftCardCodes}, optionalParams);
},
addGiftCardCodes: cartGiftCardCodesAddDefault(mutateOptions),
removeGiftCardCodes: cartGiftCardCodesRemoveDefault(mutateOptions),
updateBuyerIdentity: async (buyerIdentity, optionalParams) => {
return cartId || optionalParams?.cartId
Expand Down Expand Up @@ -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.
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
Loading
Loading