-
Notifications
You must be signed in to change notification settings - Fork 380
feat(cart): add cartGiftCardCodesAdd mutation #3401
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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']); | ||
| ``` |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
Comment on lines
312
to
318
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 is this something that we want? i could open a separate PR for that. Pros: fewer manual types to write, fewer mistakes, less maintenance "no" is a perfectly good answer to this See this comment inline on Graphite.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
|
|
@@ -345,6 +361,7 @@ CartForm.ACTIONS = { | |
| Create: 'Create', | ||
| DiscountCodesUpdate: 'DiscountCodesUpdate', | ||
| GiftCardCodesUpdate: 'GiftCardCodesUpdate', | ||
| GiftCardCodesAdd: 'GiftCardCodesAdd', | ||
| GiftCardCodesRemove: 'GiftCardCodesRemove', | ||
| LinesAdd: 'LinesAdd', | ||
| LinesRemove: 'LinesRemove', | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Re: line +376] See this comment inline on Graphite.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. adding this has revealed that we may have a typo in unless these are unrelated to
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
|
|
||
| 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); | ||
| }); | ||
| }); | ||
| }); |

There was a problem hiding this comment.
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
There was a problem hiding this comment.
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