Skip to content
Open
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
9 changes: 9 additions & 0 deletions packages/api/src/platforms/vtex/resolvers/root.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
4 changes: 4 additions & 0 deletions packages/api/src/platforms/vtex/resolvers/validateCart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => ({
Expand Down
12 changes: 9 additions & 3 deletions packages/core/@generated/gql.ts

Large diffs are not rendered by default.

24 changes: 22 additions & 2 deletions packages/core/@generated/graphql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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; }>;


Expand Down Expand Up @@ -3478,6 +3482,13 @@ export const ServerProductFragmentDoc = new TypedDocumentString(`
}
}
`, {"fragmentName":"ServerProduct"}) as unknown as TypedDocumentString<ServerProductFragment, unknown>;
export const StoreCartAdditionalFragmentDoc = new TypedDocumentString(`
fragment StoreCartAdditional on StoreCart {
order {
orderNumber
}
}
`, {"fragmentName":"StoreCartAdditional"}) as unknown as TypedDocumentString<StoreCartAdditionalFragment, unknown>;
export const UserOrderItemsFragmentFragmentDoc = new TypedDocumentString(`
fragment UserOrderItemsFragment on UserOrderItems {
id
Expand All @@ -3498,8 +3509,14 @@ export const CartMessageFragmentDoc = new TypedDocumentString(`
status
}
`, {"fragmentName":"CartMessage"}) as unknown as TypedDocumentString<CartMessageFragment, unknown>;
export const CartItemAdditionalFragmentDoc = new TypedDocumentString(`
fragment CartItemAdditional on StoreOffer {
quantity
}
`, {"fragmentName":"CartItemAdditional"}) as unknown as TypedDocumentString<CartItemAdditionalFragment, unknown>;
export const CartItemFragmentDoc = new TypedDocumentString(`
fragment CartItem on StoreOffer {
...CartItemAdditional
seller {
identifier
}
Expand All @@ -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
Expand Down Expand Up @@ -3565,7 +3585,7 @@ export const CancelOrderMutationDocument = {"__meta__":{"operationName":"CancelO
export const ProcessOrderAuthorizationMutationDocument = {"__meta__":{"operationName":"ProcessOrderAuthorizationMutation","operationHash":"8c25d37c8d6e7c20ab21bb8a4f4e6a2fe320ea8d"}} as unknown as TypedDocumentString<ProcessOrderAuthorizationMutationMutation, ProcessOrderAuthorizationMutationMutationVariables>;
export const ValidateUserDocument = {"__meta__":{"operationName":"ValidateUser","operationHash":"32f99c73c3de958b64d6bece1afe800469f54548"}} as unknown as TypedDocumentString<ValidateUserQuery, ValidateUserQueryVariables>;
export const StartRecommendationSessionDocument = {"__meta__":{"operationName":"StartRecommendationSession","operationHash":"1def6438c0cd87b85002411ac7326c221f192583"}} as unknown as TypedDocumentString<StartRecommendationSessionMutation, StartRecommendationSessionMutationVariables>;
export const ValidateCartMutationDocument = {"__meta__":{"operationName":"ValidateCartMutation","operationHash":"8199d03debb0a6751ec53d8ee8987b78d0292acb"}} as unknown as TypedDocumentString<ValidateCartMutationMutation, ValidateCartMutationMutationVariables>;
export const ValidateCartMutationDocument = {"__meta__":{"operationName":"ValidateCartMutation","operationHash":"42f1ac3788154b0097725cd3532fdc53abdce689"}} as unknown as TypedDocumentString<ValidateCartMutationMutation, ValidateCartMutationMutationVariables>;
export const ClientPickupPointsQueryDocument = {"__meta__":{"operationName":"ClientPickupPointsQuery","operationHash":"3fa04e88c811fcb5ece7206fd5aa745bdbc143a8"}} as unknown as TypedDocumentString<ClientPickupPointsQueryQuery, ClientPickupPointsQueryQueryVariables>;
export const SubscribeToNewsletterDocument = {"__meta__":{"operationName":"SubscribeToNewsletter","operationHash":"feb7005103a859e2bc8cf2360d568806fd88deba"}} as unknown as TypedDocumentString<SubscribeToNewsletterMutation, SubscribeToNewsletterMutationVariables>;
export const StartOrderEntryOperationMutationDocument = {"__meta__":{"operationName":"StartOrderEntryOperationMutation","operationHash":"78c50fbf9b85d03dbeac9b05b06405217f2ec440"}} as unknown as TypedDocumentString<StartOrderEntryOperationMutationMutation, StartOrderEntryOperationMutationMutationVariables>;
Expand Down
52 changes: 52 additions & 0 deletions packages/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
`)
29 changes: 6 additions & 23 deletions packages/core/src/sdk/account/useReorder.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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<Error | null>(null)
Expand Down Expand Up @@ -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)

Expand Down
46 changes: 30 additions & 16 deletions packages/core/src/sdk/cart/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
CartItemFragment,
CartMessageFragment,
IStoreOffer,
StoreCartAdditionalFragment,
ValidateCartMutationMutation,
ValidateCartMutationMutationVariables,
} from '@generated/graphql'
Expand All @@ -23,14 +24,16 @@ export interface CartItem
priceToken?: string | null
}

export interface Cart extends SDKCart<CartItem> {
messages?: CartMessageFragment[]
shouldSplitItem?: boolean
}
export type Cart = SDKCart<CartItem> &
Partial<Omit<StoreCartAdditionalFragment, 'order'>> & {
messages?: CartMessageFragment[]
shouldSplitItem?: boolean
}

export const ValidateCartMutation = gql(`
mutation ValidateCartMutation($cart: IStoreCart!, $session: IStoreSession!) {
validateCart(cart: $cart, session: $session) {
...StoreCartAdditional
Comment thread
evander-corebiz marked this conversation as resolved.
order {
orderNumber
acceptedOffer {
Expand All @@ -50,6 +53,7 @@ export const ValidateCartMutation = gql(`
}

fragment CartItem on StoreOffer {
...CartItemAdditional
seller {
identifier
}
Expand Down Expand Up @@ -113,7 +117,27 @@ const getItemId = (item: Pick<CartItem, 'itemOffered' | 'seller' | 'price'>) =>
.filter(Boolean)
.join('::')

const validateCart = async (cart: Cart): Promise<Cart | null> => {
type ValidatedCart = NonNullable<ValidateCartMutationMutation['validateCart']> &
StoreCartAdditionalFragment

export const getCartFromValidatedCart = (
validated: NonNullable<ValidateCartMutationMutation['validateCart']>
): 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<Cart | null> => {
const { validateCart: validated = null } = await request<
ValidateCartMutationMutation,
ValidateCartMutationMutationVariables
Expand Down Expand Up @@ -151,17 +175,7 @@ const validateCart = async (cart: Cart): Promise<Cart | null> => {
},
})

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)
Expand Down
71 changes: 71 additions & 0 deletions packages/core/test/sdk/cart/index.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
Loading