From 0c1fccae21adc5a8fd24bcc26a27c87efbd4a4ca Mon Sep 17 00:00:00 2001 From: Evander Oliveira Date: Tue, 11 Aug 2026 12:19:01 -0300 Subject: [PATCH 1/2] feat: add ValidateCartMutation extension points --- .../api/src/platforms/vtex/resolvers/root.ts | 9 ++++ .../platforms/vtex/resolvers/validateCart.ts | 4 ++ packages/core/@generated/gql.ts | 12 +++-- packages/core/@generated/graphql.ts | 24 ++++++++- packages/core/README.md | 52 +++++++++++++++++++ .../src/fragments/ValidateCartMutation.ts | 21 ++++++++ packages/core/src/sdk/cart/index.ts | 2 + 7 files changed, 119 insertions(+), 5 deletions(-) create mode 100644 packages/core/src/customizations/src/fragments/ValidateCartMutation.ts diff --git a/packages/api/src/platforms/vtex/resolvers/root.ts b/packages/api/src/platforms/vtex/resolvers/root.ts index 11e512c735..6e8c9db63a 100644 --- a/packages/api/src/platforms/vtex/resolvers/root.ts +++ b/packages/api/src/platforms/vtex/resolvers/root.ts @@ -10,3 +10,12 @@ export type { Root as StoreSkuVariationsRoot } from './skuVariations' export type { Root as StoreProductGroupRoot } from './productGroup' export type { Root as StoreProductRoot } from './product' export type { Root as StoreOrganizationRoot } from './organization' + +export type StoreCartRoot = { + /** + * The VTEX orderForm used to build the StoreCart response. This is an + * internal resolver root field and is not exposed by the GraphQL schema. + * Store API extensions can use it to resolve StoreCart fields. + */ + __orderForm: import('../clients/commerce/types/OrderForm').OrderForm +} diff --git a/packages/api/src/platforms/vtex/resolvers/validateCart.ts b/packages/api/src/platforms/vtex/resolvers/validateCart.ts index 8d0b570ee3..0f77903520 100644 --- a/packages/api/src/platforms/vtex/resolvers/validateCart.ts +++ b/packages/api/src/platforms/vtex/resolvers/validateCart.ts @@ -178,6 +178,10 @@ const orderFormToCart = async ( shouldSplitItem?: boolean | null ) => { return { + // Keep the source orderForm available to StoreCart extension resolvers. + // The property is not part of the GraphQL schema and is never serialized + // unless a custom resolver explicitly uses it. + __orderForm: form, order: { orderNumber: form.orderFormId, acceptedOffer: form.items.map(async (item) => ({ diff --git a/packages/core/@generated/gql.ts b/packages/core/@generated/gql.ts index c18bc3146c..a2f76c642b 100644 --- a/packages/core/@generated/gql.ts +++ b/packages/core/@generated/gql.ts @@ -29,6 +29,7 @@ type Documents = { "\n fragment ClientTopSearchSuggestions on Query {\n search(first: 5, term: $term, selectedFacets: $selectedFacets) {\n suggestions {\n terms {\n value\n }\n }\n }\n }\n": typeof types.ClientTopSearchSuggestionsFragmentDoc, "\n fragment ServerCollectionPage on Query {\n collection(slug: $slug) {\n id\n }\n }\n": typeof types.ServerCollectionPageFragmentDoc, "\n fragment ServerProduct on Query {\n product(locator: $locator) {\n id: productID\n }\n }\n": typeof types.ServerProductFragmentDoc, + "\n fragment CartItemAdditional on StoreOffer {\n quantity\n }\n\n fragment StoreCartAdditional on StoreCart {\n order {\n orderNumber\n }\n }\n": typeof types.CartItemAdditionalFragmentDoc, "\n query ServerAccountPageQuery {\n accountProfile {\n name\n }\n }\n": typeof types.ServerAccountPageQueryDocument, "\n query ServerCollectionPageQuery($slug: String!) {\n ...ServerCollectionPage\n collection(slug: $slug) {\n seo {\n title\n description\n }\n breadcrumbList {\n itemListElement {\n item\n name\n position\n }\n }\n meta {\n selectedFacets {\n key\n value\n }\n }\n otherLocales {\n locale\n slug\n }\n }\n }\n": typeof types.ServerCollectionPageQueryDocument, "\n query ServerProductQuery($locator: [IStoreSelectedFacet!]!) {\n ...ServerProduct\n product(locator: $locator) {\n id: productID\n\n seo {\n title\n description\n canonical\n }\n\n brand {\n name\n }\n\n sku\n gtin\n mpn\n name\n description\n releaseDate\n\n breadcrumbList {\n itemListElement {\n item\n name\n position\n }\n }\n\n image {\n url\n alternateName\n }\n\n offers {\n lowPrice\n highPrice\n lowPriceWithTaxes\n priceCurrency\n offers {\n availability\n price\n priceValidUntil\n priceCurrency\n itemCondition\n priceToken\n seller {\n identifier\n }\n }\n }\n\n isVariantOf {\n productGroupID\n }\n\n otherLocales {\n locale\n slug\n }\n\n ...ProductDetailsFragment_product\n }\n }\n": typeof types.ServerProductQueryDocument, @@ -45,7 +46,7 @@ type Documents = { "\n mutation ProcessOrderAuthorizationMutation($data: IProcessOrderAuthorization!) {\n processOrderAuthorization(data: $data) {\n isPendingForOtherAuthorizer\n ruleForAuthorization {\n orderAuthorizationId\n dimensionId\n rule {\n id\n name\n status\n doId\n authorizedEmails\n priority\n trigger {\n condition {\n conditionType\n description\n lessThan\n greatherThan\n expression\n }\n effect {\n description\n effectType\n funcPath\n }\n }\n timeout\n notification\n scoreInterval {\n accept\n deny\n }\n authorizationData {\n requireAllApprovals\n authorizers {\n id\n email\n type\n authorizationDate\n }\n }\n isUserAuthorized\n isUserNextAuthorizer\n }\n }\n }\n }\n": typeof types.ProcessOrderAuthorizationMutationDocument, "\n query ValidateUser {\n validateUser {\n isValid\n }\n }\n": typeof types.ValidateUserDocument, "\n mutation StartRecommendationSession {\n startRecommendationSession\n }\n": typeof types.StartRecommendationSessionDocument, - "\n mutation ValidateCartMutation($cart: IStoreCart!, $session: IStoreSession!) {\n validateCart(cart: $cart, session: $session) {\n order {\n orderNumber\n acceptedOffer {\n ...CartItem\n }\n shouldSplitItem\n }\n messages {\n ...CartMessage\n }\n }\n }\n\n fragment CartMessage on StoreCartMessage {\n text\n status\n }\n\n fragment CartItem on StoreOffer {\n seller {\n identifier\n }\n quantity\n price\n priceWithTaxes\n listPrice\n listPriceWithTaxes\n isGift\n priceToken\n itemOffered {\n ...CartProductItem\n }\n }\n\n fragment CartProductItem on StoreProduct {\n sku\n name\n unitMultiplier\n image {\n url\n alternateName\n }\n brand {\n name\n }\n isVariantOf {\n productGroupID\n name\n skuVariants {\n activeVariations\n slugsMap\n availableVariations\n }\n }\n gtin\n additionalProperty {\n propertyID\n name\n value\n valueReference\n }\n }\n": typeof types.ValidateCartMutationDocument, + "\n mutation ValidateCartMutation($cart: IStoreCart!, $session: IStoreSession!) {\n validateCart(cart: $cart, session: $session) {\n ...StoreCartAdditional\n order {\n orderNumber\n acceptedOffer {\n ...CartItem\n }\n shouldSplitItem\n }\n messages {\n ...CartMessage\n }\n }\n }\n\n fragment CartMessage on StoreCartMessage {\n text\n status\n }\n\n fragment CartItem on StoreOffer {\n ...CartItemAdditional\n seller {\n identifier\n }\n quantity\n price\n priceWithTaxes\n listPrice\n listPriceWithTaxes\n isGift\n priceToken\n itemOffered {\n ...CartProductItem\n }\n }\n\n fragment CartProductItem on StoreProduct {\n sku\n name\n unitMultiplier\n image {\n url\n alternateName\n }\n brand {\n name\n }\n isVariantOf {\n productGroupID\n name\n skuVariants {\n activeVariations\n slugsMap\n availableVariations\n }\n }\n gtin\n additionalProperty {\n propertyID\n name\n value\n valueReference\n }\n }\n": typeof types.ValidateCartMutationDocument, "\n query ClientPickupPointsQuery(\n $geoCoordinates: IStoreGeoCoordinates\n ) {\n pickupPoints(geoCoordinates: $geoCoordinates) {\n pickupPointDistances {\n pickupId\n distance\n pickupName\n isActive\n address {\n city\n state\n number\n postalCode\n street\n }\n }\n }\n }\n": typeof types.ClientPickupPointsQueryDocument, "\n mutation SubscribeToNewsletter($data: IPersonNewsletter!) {\n subscribeToNewsletter(data: $data) {\n id\n }\n }\n": typeof types.SubscribeToNewsletterDocument, "\n mutation StartOrderEntryOperationMutation($data: IOrderEntryOperation!) {\n startOrderEntryOperation(data: $data) {\n operationId\n }\n }\n": typeof types.StartOrderEntryOperationMutationDocument, @@ -81,6 +82,7 @@ const documents: Documents = { "\n fragment ClientTopSearchSuggestions on Query {\n search(first: 5, term: $term, selectedFacets: $selectedFacets) {\n suggestions {\n terms {\n value\n }\n }\n }\n }\n": types.ClientTopSearchSuggestionsFragmentDoc, "\n fragment ServerCollectionPage on Query {\n collection(slug: $slug) {\n id\n }\n }\n": types.ServerCollectionPageFragmentDoc, "\n fragment ServerProduct on Query {\n product(locator: $locator) {\n id: productID\n }\n }\n": types.ServerProductFragmentDoc, + "\n fragment CartItemAdditional on StoreOffer {\n quantity\n }\n\n fragment StoreCartAdditional on StoreCart {\n order {\n orderNumber\n }\n }\n": types.CartItemAdditionalFragmentDoc, "\n query ServerAccountPageQuery {\n accountProfile {\n name\n }\n }\n": types.ServerAccountPageQueryDocument, "\n query ServerCollectionPageQuery($slug: String!) {\n ...ServerCollectionPage\n collection(slug: $slug) {\n seo {\n title\n description\n }\n breadcrumbList {\n itemListElement {\n item\n name\n position\n }\n }\n meta {\n selectedFacets {\n key\n value\n }\n }\n otherLocales {\n locale\n slug\n }\n }\n }\n": types.ServerCollectionPageQueryDocument, "\n query ServerProductQuery($locator: [IStoreSelectedFacet!]!) {\n ...ServerProduct\n product(locator: $locator) {\n id: productID\n\n seo {\n title\n description\n canonical\n }\n\n brand {\n name\n }\n\n sku\n gtin\n mpn\n name\n description\n releaseDate\n\n breadcrumbList {\n itemListElement {\n item\n name\n position\n }\n }\n\n image {\n url\n alternateName\n }\n\n offers {\n lowPrice\n highPrice\n lowPriceWithTaxes\n priceCurrency\n offers {\n availability\n price\n priceValidUntil\n priceCurrency\n itemCondition\n priceToken\n seller {\n identifier\n }\n }\n }\n\n isVariantOf {\n productGroupID\n }\n\n otherLocales {\n locale\n slug\n }\n\n ...ProductDetailsFragment_product\n }\n }\n": types.ServerProductQueryDocument, @@ -97,7 +99,7 @@ const documents: Documents = { "\n mutation ProcessOrderAuthorizationMutation($data: IProcessOrderAuthorization!) {\n processOrderAuthorization(data: $data) {\n isPendingForOtherAuthorizer\n ruleForAuthorization {\n orderAuthorizationId\n dimensionId\n rule {\n id\n name\n status\n doId\n authorizedEmails\n priority\n trigger {\n condition {\n conditionType\n description\n lessThan\n greatherThan\n expression\n }\n effect {\n description\n effectType\n funcPath\n }\n }\n timeout\n notification\n scoreInterval {\n accept\n deny\n }\n authorizationData {\n requireAllApprovals\n authorizers {\n id\n email\n type\n authorizationDate\n }\n }\n isUserAuthorized\n isUserNextAuthorizer\n }\n }\n }\n }\n": types.ProcessOrderAuthorizationMutationDocument, "\n query ValidateUser {\n validateUser {\n isValid\n }\n }\n": types.ValidateUserDocument, "\n mutation StartRecommendationSession {\n startRecommendationSession\n }\n": types.StartRecommendationSessionDocument, - "\n mutation ValidateCartMutation($cart: IStoreCart!, $session: IStoreSession!) {\n validateCart(cart: $cart, session: $session) {\n order {\n orderNumber\n acceptedOffer {\n ...CartItem\n }\n shouldSplitItem\n }\n messages {\n ...CartMessage\n }\n }\n }\n\n fragment CartMessage on StoreCartMessage {\n text\n status\n }\n\n fragment CartItem on StoreOffer {\n seller {\n identifier\n }\n quantity\n price\n priceWithTaxes\n listPrice\n listPriceWithTaxes\n isGift\n priceToken\n itemOffered {\n ...CartProductItem\n }\n }\n\n fragment CartProductItem on StoreProduct {\n sku\n name\n unitMultiplier\n image {\n url\n alternateName\n }\n brand {\n name\n }\n isVariantOf {\n productGroupID\n name\n skuVariants {\n activeVariations\n slugsMap\n availableVariations\n }\n }\n gtin\n additionalProperty {\n propertyID\n name\n value\n valueReference\n }\n }\n": types.ValidateCartMutationDocument, + "\n mutation ValidateCartMutation($cart: IStoreCart!, $session: IStoreSession!) {\n validateCart(cart: $cart, session: $session) {\n ...StoreCartAdditional\n order {\n orderNumber\n acceptedOffer {\n ...CartItem\n }\n shouldSplitItem\n }\n messages {\n ...CartMessage\n }\n }\n }\n\n fragment CartMessage on StoreCartMessage {\n text\n status\n }\n\n fragment CartItem on StoreOffer {\n ...CartItemAdditional\n seller {\n identifier\n }\n quantity\n price\n priceWithTaxes\n listPrice\n listPriceWithTaxes\n isGift\n priceToken\n itemOffered {\n ...CartProductItem\n }\n }\n\n fragment CartProductItem on StoreProduct {\n sku\n name\n unitMultiplier\n image {\n url\n alternateName\n }\n brand {\n name\n }\n isVariantOf {\n productGroupID\n name\n skuVariants {\n activeVariations\n slugsMap\n availableVariations\n }\n }\n gtin\n additionalProperty {\n propertyID\n name\n value\n valueReference\n }\n }\n": types.ValidateCartMutationDocument, "\n query ClientPickupPointsQuery(\n $geoCoordinates: IStoreGeoCoordinates\n ) {\n pickupPoints(geoCoordinates: $geoCoordinates) {\n pickupPointDistances {\n pickupId\n distance\n pickupName\n isActive\n address {\n city\n state\n number\n postalCode\n street\n }\n }\n }\n }\n": types.ClientPickupPointsQueryDocument, "\n mutation SubscribeToNewsletter($data: IPersonNewsletter!) {\n subscribeToNewsletter(data: $data) {\n id\n }\n }\n": types.SubscribeToNewsletterDocument, "\n mutation StartOrderEntryOperationMutation($data: IOrderEntryOperation!) {\n startOrderEntryOperation(data: $data) {\n operationId\n }\n }\n": types.StartOrderEntryOperationMutationDocument, @@ -175,6 +177,10 @@ export function gql(source: "\n fragment ServerCollectionPage on Query {\n c * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ export function gql(source: "\n fragment ServerProduct on Query {\n product(locator: $locator) {\n id: productID\n }\n }\n"): typeof import('./graphql').ServerProductFragmentDoc; +/** + * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function gql(source: "\n fragment CartItemAdditional on StoreOffer {\n quantity\n }\n\n fragment StoreCartAdditional on StoreCart {\n order {\n orderNumber\n }\n }\n"): typeof import('./graphql').CartItemAdditionalFragmentDoc; /** * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ @@ -242,7 +248,7 @@ export function gql(source: "\n mutation StartRecommendationSession {\n star /** * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ -export function gql(source: "\n mutation ValidateCartMutation($cart: IStoreCart!, $session: IStoreSession!) {\n validateCart(cart: $cart, session: $session) {\n order {\n orderNumber\n acceptedOffer {\n ...CartItem\n }\n shouldSplitItem\n }\n messages {\n ...CartMessage\n }\n }\n }\n\n fragment CartMessage on StoreCartMessage {\n text\n status\n }\n\n fragment CartItem on StoreOffer {\n seller {\n identifier\n }\n quantity\n price\n priceWithTaxes\n listPrice\n listPriceWithTaxes\n isGift\n priceToken\n itemOffered {\n ...CartProductItem\n }\n }\n\n fragment CartProductItem on StoreProduct {\n sku\n name\n unitMultiplier\n image {\n url\n alternateName\n }\n brand {\n name\n }\n isVariantOf {\n productGroupID\n name\n skuVariants {\n activeVariations\n slugsMap\n availableVariations\n }\n }\n gtin\n additionalProperty {\n propertyID\n name\n value\n valueReference\n }\n }\n"): typeof import('./graphql').ValidateCartMutationDocument; +export function gql(source: "\n mutation ValidateCartMutation($cart: IStoreCart!, $session: IStoreSession!) {\n validateCart(cart: $cart, session: $session) {\n ...StoreCartAdditional\n order {\n orderNumber\n acceptedOffer {\n ...CartItem\n }\n shouldSplitItem\n }\n messages {\n ...CartMessage\n }\n }\n }\n\n fragment CartMessage on StoreCartMessage {\n text\n status\n }\n\n fragment CartItem on StoreOffer {\n ...CartItemAdditional\n seller {\n identifier\n }\n quantity\n price\n priceWithTaxes\n listPrice\n listPriceWithTaxes\n isGift\n priceToken\n itemOffered {\n ...CartProductItem\n }\n }\n\n fragment CartProductItem on StoreProduct {\n sku\n name\n unitMultiplier\n image {\n url\n alternateName\n }\n brand {\n name\n }\n isVariantOf {\n productGroupID\n name\n skuVariants {\n activeVariations\n slugsMap\n availableVariations\n }\n }\n gtin\n additionalProperty {\n propertyID\n name\n value\n valueReference\n }\n }\n"): typeof import('./graphql').ValidateCartMutationDocument; /** * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ diff --git a/packages/core/@generated/graphql.ts b/packages/core/@generated/graphql.ts index 27d861e8fd..42d859bc8f 100644 --- a/packages/core/@generated/graphql.ts +++ b/packages/core/@generated/graphql.ts @@ -2750,6 +2750,10 @@ export type ServerCollectionPageFragment = { collection: { id: string } }; export type ServerProductFragment = { product: { id: string } }; +export type CartItemAdditionalFragment = { quantity: number }; + +export type StoreCartAdditionalFragment = { order: { orderNumber: string } }; + export type ServerAccountPageQueryQueryVariables = Exact<{ [key: string]: never; }>; @@ -3478,6 +3482,13 @@ export const ServerProductFragmentDoc = new TypedDocumentString(` } } `, {"fragmentName":"ServerProduct"}) as unknown as TypedDocumentString; +export const StoreCartAdditionalFragmentDoc = new TypedDocumentString(` + fragment StoreCartAdditional on StoreCart { + order { + orderNumber + } +} + `, {"fragmentName":"StoreCartAdditional"}) as unknown as TypedDocumentString; export const UserOrderItemsFragmentFragmentDoc = new TypedDocumentString(` fragment UserOrderItemsFragment on UserOrderItems { id @@ -3498,8 +3509,14 @@ export const CartMessageFragmentDoc = new TypedDocumentString(` status } `, {"fragmentName":"CartMessage"}) as unknown as TypedDocumentString; +export const CartItemAdditionalFragmentDoc = new TypedDocumentString(` + fragment CartItemAdditional on StoreOffer { + quantity +} + `, {"fragmentName":"CartItemAdditional"}) as unknown as TypedDocumentString; export const CartItemFragmentDoc = new TypedDocumentString(` fragment CartItem on StoreOffer { + ...CartItemAdditional seller { identifier } @@ -3514,7 +3531,10 @@ export const CartItemFragmentDoc = new TypedDocumentString(` ...CartProductItem } } - fragment CartProductItem on StoreProduct { + fragment CartItemAdditional on StoreOffer { + quantity +} +fragment CartProductItem on StoreProduct { sku name unitMultiplier @@ -3565,7 +3585,7 @@ export const CancelOrderMutationDocument = {"__meta__":{"operationName":"CancelO export const ProcessOrderAuthorizationMutationDocument = {"__meta__":{"operationName":"ProcessOrderAuthorizationMutation","operationHash":"8c25d37c8d6e7c20ab21bb8a4f4e6a2fe320ea8d"}} as unknown as TypedDocumentString; export const ValidateUserDocument = {"__meta__":{"operationName":"ValidateUser","operationHash":"32f99c73c3de958b64d6bece1afe800469f54548"}} as unknown as TypedDocumentString; export const StartRecommendationSessionDocument = {"__meta__":{"operationName":"StartRecommendationSession","operationHash":"1def6438c0cd87b85002411ac7326c221f192583"}} as unknown as TypedDocumentString; -export const ValidateCartMutationDocument = {"__meta__":{"operationName":"ValidateCartMutation","operationHash":"8199d03debb0a6751ec53d8ee8987b78d0292acb"}} as unknown as TypedDocumentString; +export const ValidateCartMutationDocument = {"__meta__":{"operationName":"ValidateCartMutation","operationHash":"42f1ac3788154b0097725cd3532fdc53abdce689"}} as unknown as TypedDocumentString; export const ClientPickupPointsQueryDocument = {"__meta__":{"operationName":"ClientPickupPointsQuery","operationHash":"3fa04e88c811fcb5ece7206fd5aa745bdbc143a8"}} as unknown as TypedDocumentString; export const SubscribeToNewsletterDocument = {"__meta__":{"operationName":"SubscribeToNewsletter","operationHash":"feb7005103a859e2bc8cf2360d568806fd88deba"}} as unknown as TypedDocumentString; export const StartOrderEntryOperationMutationDocument = {"__meta__":{"operationName":"StartOrderEntryOperationMutation","operationHash":"78c50fbf9b85d03dbeac9b05b06405217f2ec440"}} as unknown as TypedDocumentString; diff --git a/packages/core/README.md b/packages/core/README.md index 471554895f..a1ffc35b6c 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -83,6 +83,58 @@ Store-level UI components (not intended for the shared library) live in `src/com > Never edit files inside `@generated/` manually — they are overwritten on every `pnpm generate` run. +### Extending `ValidateCartMutation` + +`ValidateCartMutation` keeps the native cart fields in `@faststore/core` and +includes two extension fragments from the customization layer: + +- `CartItemAdditional` for fields on cart items (`StoreOffer`) +- `StoreCartAdditional` for fields on the validated cart (`StoreCart`) + +To add fields, extend the native GraphQL types under `src/graphql/vtex` and add +those fields to `src/customizations/src/fragments/ValidateCartMutation.ts`. +Resolvers belong in `src/customizations/src/graphql/vtex/resolvers`. + +For example: + +```graphql +extend type StoreOffer { + customLabel: String +} + +extend type StoreCart { + total: Float + coupon: String +} +``` + +```graphql +fragment CartItemAdditional on StoreOffer { + customLabel +} + +fragment StoreCartAdditional on StoreCart { + total + coupon +} +``` + +```ts +import type { StoreCartRoot } from '@faststore/api' + +export default { + StoreCart: { + total: (root: StoreCartRoot) => root.__orderForm.value / 100, + coupon: (root: StoreCartRoot) => + root.__orderForm.marketingData?.coupon ?? null, + }, +} +``` + +The native `validateCart` resolver remains the source of truth. Cart +extensions can read the original VTEX `orderForm` through `StoreCartRoot` from +`@faststore/api`; the internal order form is not exposed as a GraphQL field. + ### Managing SVG icons Icons are loaded from a single sprite at `public/icons.svg` via the `Icon` component from `@faststore/ui`. diff --git a/packages/core/src/customizations/src/fragments/ValidateCartMutation.ts b/packages/core/src/customizations/src/fragments/ValidateCartMutation.ts new file mode 100644 index 0000000000..0819fde208 --- /dev/null +++ b/packages/core/src/customizations/src/fragments/ValidateCartMutation.ts @@ -0,0 +1,21 @@ +import { gql } from '@generated' + +/** + * Extension points for the native ValidateCartMutation. + * + * Store projects can extend StoreOffer and StoreCart in + * `src/graphql/vtex/typeDefs` and add the corresponding fields to these + * fragments. The native cart fields remain defined and selected by the core + * mutation. + */ +export const fragment = gql(` + fragment CartItemAdditional on StoreOffer { + quantity + } + + fragment StoreCartAdditional on StoreCart { + order { + orderNumber + } + } +`) diff --git a/packages/core/src/sdk/cart/index.ts b/packages/core/src/sdk/cart/index.ts index c23939cc3d..b62e55b1cd 100644 --- a/packages/core/src/sdk/cart/index.ts +++ b/packages/core/src/sdk/cart/index.ts @@ -31,6 +31,7 @@ export interface Cart extends SDKCart { export const ValidateCartMutation = gql(` mutation ValidateCartMutation($cart: IStoreCart!, $session: IStoreSession!) { validateCart(cart: $cart, session: $session) { + ...StoreCartAdditional order { orderNumber acceptedOffer { @@ -50,6 +51,7 @@ export const ValidateCartMutation = gql(` } fragment CartItem on StoreOffer { + ...CartItemAdditional seller { identifier } From 8d70424de9e18833b14b2b58886e0ab6bbe3b1bd Mon Sep 17 00:00:00 2001 From: Evander Oliveira Date: Tue, 11 Aug 2026 12:46:28 -0300 Subject: [PATCH 2/2] fix(core): preserve cart extension fields after validation --- packages/core/src/sdk/account/useReorder.ts | 29 ++------- packages/core/src/sdk/cart/index.ts | 44 ++++++++----- packages/core/test/sdk/cart/index.test.ts | 71 +++++++++++++++++++++ 3 files changed, 105 insertions(+), 39 deletions(-) create mode 100644 packages/core/test/sdk/cart/index.test.ts diff --git a/packages/core/src/sdk/account/useReorder.ts b/packages/core/src/sdk/account/useReorder.ts index 0bc4c2ecd8..a88304e17e 100644 --- a/packages/core/src/sdk/account/useReorder.ts +++ b/packages/core/src/sdk/account/useReorder.ts @@ -1,6 +1,10 @@ import { useState } from 'react' import type { ServerOrderDetailsQueryQuery } from '@generated/graphql' -import { cartStore, ValidateCartMutation } from '../cart' +import { + cartStore, + getCartFromValidatedCart, + ValidateCartMutation, +} from '../cart' import type { IStoreOffer, ValidateCartMutationMutation, @@ -20,19 +24,6 @@ type Order = ServerOrderDetailsQueryQuery['userOrder'] type AdditionalProperties = CartItemFragment['itemOffered']['additionalProperty'] -const getItemId = (item: CartItemFragment) => { - const propertyIds = - item.itemOffered.additionalProperty - ?.map((prop) => prop.propertyID) - .join('-') ?? '' - - const sellerId = item.seller?.identifier ?? '' - - return [item.itemOffered.sku, sellerId, propertyIds] - .filter(Boolean) - .join('::') -} - export const useReorder = () => { const [loading, setLoading] = useState(false) const [error, setError] = useState(null) @@ -128,15 +119,7 @@ export const useReorder = () => { throw new ReorderError('Failed to add items to cart') } - const updatedCart = { - id: validated.order.orderNumber, - items: validated.order.acceptedOffer.map((item) => ({ - ...item, - id: getItemId(item), - })), - messages: validated.messages, - shouldSplitItem: validated.order.shouldSplitItem ?? undefined, - } + const updatedCart = getCartFromValidatedCart(validated) cartStore.set(updatedCart) diff --git a/packages/core/src/sdk/cart/index.ts b/packages/core/src/sdk/cart/index.ts index b62e55b1cd..851e14b70b 100644 --- a/packages/core/src/sdk/cart/index.ts +++ b/packages/core/src/sdk/cart/index.ts @@ -7,6 +7,7 @@ import type { CartItemFragment, CartMessageFragment, IStoreOffer, + StoreCartAdditionalFragment, ValidateCartMutationMutation, ValidateCartMutationMutationVariables, } from '@generated/graphql' @@ -23,10 +24,11 @@ export interface CartItem priceToken?: string | null } -export interface Cart extends SDKCart { - messages?: CartMessageFragment[] - shouldSplitItem?: boolean -} +export type Cart = SDKCart & + Partial> & { + messages?: CartMessageFragment[] + shouldSplitItem?: boolean + } export const ValidateCartMutation = gql(` mutation ValidateCartMutation($cart: IStoreCart!, $session: IStoreSession!) { @@ -115,7 +117,27 @@ const getItemId = (item: Pick) => .filter(Boolean) .join('::') -const validateCart = async (cart: Cart): Promise => { +type ValidatedCart = NonNullable & + StoreCartAdditionalFragment + +export const getCartFromValidatedCart = ( + validated: NonNullable +): Cart => { + const { order, messages, ...customCartFields } = validated as ValidatedCart + + return { + ...customCartFields, + id: order.orderNumber, + items: order.acceptedOffer.map((item) => ({ + ...item, + id: getItemId(item), + })), + messages, + shouldSplitItem: order.shouldSplitItem, + } +} + +export const validateCart = async (cart: Cart): Promise => { const { validateCart: validated = null } = await request< ValidateCartMutationMutation, ValidateCartMutationMutationVariables @@ -153,17 +175,7 @@ const validateCart = async (cart: Cart): Promise => { }, }) - return ( - validated && { - id: validated.order.orderNumber, - items: validated.order.acceptedOffer.map((item) => ({ - ...item, - id: getItemId(item), - })), - messages: validated.messages, - shouldSplitItem: validated.order.shouldSplitItem, - } - ) + return validated ? getCartFromValidatedCart(validated) : null } const [validationStore, onValidate] = createValidationStore(validateCart) diff --git a/packages/core/test/sdk/cart/index.test.ts b/packages/core/test/sdk/cart/index.test.ts new file mode 100644 index 0000000000..d3609e79e5 --- /dev/null +++ b/packages/core/test/sdk/cart/index.test.ts @@ -0,0 +1,71 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +const mockRequest = vi.hoisted(() => vi.fn()) +vi.mock('src/sdk/graphql/request', () => ({ request: mockRequest })) + +import type { Cart } from '../../../src/sdk/cart' +import { validateCart } from '../../../src/sdk/cart' + +afterEach(() => { + vi.clearAllMocks() +}) + +describe('validateCart', () => { + it('preserves root-level cart extension fields', async () => { + mockRequest.mockResolvedValueOnce({ + validateCart: { + total: 123, + order: { + orderNumber: 'cart-123', + shouldSplitItem: false, + acceptedOffer: [ + { + quantity: 1, + price: 123, + priceWithTaxes: 123, + listPrice: 123, + listPriceWithTaxes: 123, + isGift: false, + priceToken: null, + seller: { identifier: '1' }, + itemOffered: { + sku: 'sku-123', + additionalProperty: [], + }, + }, + ], + }, + messages: [], + }, + }) + + const cart = await validateCart({ + id: 'cart-123', + items: [ + { + id: 'sku-123::1', + quantity: 1, + price: 123, + listPrice: 123, + priceToken: null, + seller: { identifier: '1' }, + itemOffered: { + sku: 'sku-123', + image: [], + name: 'Product', + additionalProperty: [], + }, + }, + ], + } as Cart) + + expect(cart).toMatchObject({ + id: 'cart-123', + total: 123, + }) + expect(cart).not.toHaveProperty('order') + + const variables = mockRequest.mock.calls[0][1] + expect(variables.cart).not.toHaveProperty('total') + }) +})