From a0c982a61367b2626ea739bc3764e20c16d5a2c4 Mon Sep 17 00:00:00 2001 From: Rodrigo Tavares Date: Mon, 8 Jun 2026 11:00:00 -0300 Subject: [PATCH 01/14] feat: add B2B contract switcher to the FastStore account drawer Lets B2B buyers change the active contract within their Organization Unit from the account drawer (Phase 2, REQ-03..06). API (@faststore/api): - Add StoreContract type and the governed `availableContracts(orgUnitId)` query, resolved from the Org Unit scopes + MasterData corporate names, flagging the currently active contract. Per-contract lookups are isolated. Core (@faststore/core): - useAvailableContracts: on-demand fetch of the Org Unit's contracts. - useSwitchContract: full commercial-context change (ChangeToken seam -> session revalidation -> cart reset); keeps the previous contract on failure. - ContractSwitcher sub-view + a "Change" CTA in the drawer header, with loading / empty / error / active-indicator states per Figma (node 103-5434). The concrete VTEX ChangeToken endpoint remains an open integration point (see specs/contract-switcher.md, Decision 2) and is isolated in changeContractToken.ts. Tests: resolver governance/name-resolution/error handling; switch flow happy path + failure; switcher UI states. Spec: specs/contract-switcher.md Co-authored-by: Cursor --- packages/api/src/__generated__/schema.ts | 22 +++ .../api/src/platforms/vtex/resolvers/query.ts | 71 +++++++++ .../vtex/typeDefs/organization.graphql | 18 +++ .../src/platforms/vtex/typeDefs/query.graphql | 14 ++ .../vtex/resolvers/availableContracts.test.ts | 147 ++++++++++++++++++ packages/core/@generated/gql.ts | 6 + packages/core/@generated/graphql.ts | 29 ++++ .../ContractSwitcher/ContractSwitcher.tsx | 123 +++++++++++++++ .../ContractSwitcher/index.ts | 2 + .../OrganizationDrawer/OrganizationDrawer.tsx | 21 ++- .../OrganizationDrawerHeader.tsx | 17 +- .../OrganizationDrawer/index.ts | 1 + .../OrganizationDrawer/section.module.scss | 78 ++++++++++ .../src/sdk/account/changeContractToken.ts | 28 ++++ .../src/sdk/account/useAvailableContracts.ts | 49 ++++++ .../core/src/sdk/account/useSwitchContract.ts | 60 +++++++ .../account/ContractSwitcher.test.tsx | 108 +++++++++++++ .../sdk/account/useSwitchContract.test.ts | 81 ++++++++++ packages/core/test/server/index.test.ts | 1 + 19 files changed, 874 insertions(+), 2 deletions(-) create mode 100644 packages/api/test/unit/platforms/vtex/resolvers/availableContracts.test.ts create mode 100644 packages/core/src/components/account/MyAccountDrawer/OrganizationDrawer/ContractSwitcher/ContractSwitcher.tsx create mode 100644 packages/core/src/components/account/MyAccountDrawer/OrganizationDrawer/ContractSwitcher/index.ts create mode 100644 packages/core/src/sdk/account/changeContractToken.ts create mode 100644 packages/core/src/sdk/account/useAvailableContracts.ts create mode 100644 packages/core/src/sdk/account/useSwitchContract.ts create mode 100644 packages/core/test/components/account/ContractSwitcher.test.tsx create mode 100644 packages/core/test/sdk/account/useSwitchContract.test.ts diff --git a/packages/api/src/__generated__/schema.ts b/packages/api/src/__generated__/schema.ts index 8465492e83..e84297036d 100644 --- a/packages/api/src/__generated__/schema.ts +++ b/packages/api/src/__generated__/schema.ts @@ -861,6 +861,12 @@ export type Query = { allCollections: StoreCollectionConnection; /** Returns information about all products. */ allProducts: StoreProductConnection; + /** + * Lists the commercial contracts associated with the given Organization Unit, + * resolved to human-readable corporate names. Governed: only contracts associated + * with the authenticated buyer's Organization Unit are returned. + */ + availableContracts: Array; /** Returns the details of a collection based on the collection slug. */ collection: StoreCollection; /** Returns the list of Orders that the User can view. */ @@ -904,6 +910,11 @@ export type QueryAllProductsArgs = { }; +export type QueryAvailableContractsArgs = { + orgUnitId: Scalars['String']['input']; +}; + + export type QueryCollectionArgs = { slug: Scalars['String']['input']; }; @@ -1298,6 +1309,17 @@ export const enum StoreCollectionType { SubCategory = 'SubCategory' }; +/** A commercial contract available to a buyer's Organization Unit. */ +export type StoreContract = { + __typename?: 'StoreContract'; + /** Human-readable corporate name of the contract (resolved from MasterData). */ + corporateName: Scalars['String']['output']; + /** Contract identifier (the contract/scope ID associated with the Organization Unit). */ + id: Scalars['ID']['output']; + /** Indicates whether this contract is the one currently active in the session. */ + isActive: Scalars['Boolean']['output']; +}; + /** Currency information. */ export type StoreCurrency = { __typename?: 'StoreCurrency'; diff --git a/packages/api/src/platforms/vtex/resolvers/query.ts b/packages/api/src/platforms/vtex/resolvers/query.ts index 72cf5a66d6..84cf081d15 100644 --- a/packages/api/src/platforms/vtex/resolvers/query.ts +++ b/packages/api/src/platforms/vtex/resolvers/query.ts @@ -1,7 +1,9 @@ +import pLimit from 'p-limit' import type { ProcessOrderAuthorizationRule, QueryAllCollectionsArgs, QueryAllProductsArgs, + QueryAvailableContractsArgs, QueryCollectionArgs, QueryListUserOrdersArgs, QueryPickupPointsArgs, @@ -14,6 +16,7 @@ import type { QuerySellersArgs, QueryShippingArgs, QueryUserOrderArgs, + StoreContract, UserOrderFromList, } from '../../../__generated__/schema' import { @@ -44,6 +47,9 @@ import { SORT_MAP } from '../utils/sort' import { FACET_CROSS_SELLING_MAP } from './../utils/facets' import { StoreCollection } from './collection' +// Caps concurrent MasterData lookups while resolving contract corporate names. +const CONCURRENT_CONTRACT_REQUESTS_MAX = 5 + export const Query = { product: async ( _: unknown, @@ -656,6 +662,71 @@ export const Query = { // createdAt: '', } }, + // only b2b users + // Lists the contracts associated with the buyer's Organization Unit, resolved to + // human-readable corporate names. The list is governed: it is derived from the + // Org Unit's own scopes, so only contracts the unit is associated with are returned. + availableContracts: async ( + _: unknown, + { orgUnitId }: QueryAvailableContractsArgs, + ctx: GraphqlContext + ): Promise => { + if (!orgUnitId) { + throw new BadRequestError('Missing orgUnitId') + } + + const { + clients: { commerce }, + } = ctx + + // 1. Governed source: the contract IDs associated with this Org Unit. + const scopesByUnit = await commerce.units.getScopesByOrgUnit({ orgUnitId }) + + const contractIds = Array.from( + new Set((scopesByUnit?.scopes ?? []).flatMap((scope) => scope?.ids ?? [])) + ).filter(Boolean) + + if (contractIds.length === 0) { + return [] + } + + // 2. The currently active contract id. For B2B representatives the active + // contract is exposed as the session profile id (same mapping used by + // `accountProfile`). Failures here only affect the `isActive` flag. + const sessionData = await commerce.session('').catch(() => null) + const activeContractId = sessionData?.namespaces.profile?.id?.value ?? '' + + // 3. Resolve each contract id to its corporate name. Per-contract failures are + // isolated so a single bad lookup never breaks the whole list. + const limit = pLimit(CONCURRENT_CONTRACT_REQUESTS_MAX) + const resolved = await Promise.all( + contractIds.map((contractId) => + limit(async (): Promise => { + try { + const contract = await commerce.masterData.getContractById({ + contractId, + }) + + if (!contract?.corporateName) { + return null + } + + return { + id: contractId, + corporateName: contract.corporateName, + isActive: contractId === activeContractId, + } + } catch { + return null + } + }) + ) + ) + + return resolved.filter((contract): contract is StoreContract => + Boolean(contract) + ) + }, pickupPoints: async ( _: unknown, { geoCoordinates }: QueryPickupPointsArgs, diff --git a/packages/api/src/platforms/vtex/typeDefs/organization.graphql b/packages/api/src/platforms/vtex/typeDefs/organization.graphql index 69ec49463f..0707e88774 100644 --- a/packages/api/src/platforms/vtex/typeDefs/organization.graphql +++ b/packages/api/src/platforms/vtex/typeDefs/organization.graphql @@ -17,3 +17,21 @@ input IStoreOrganization { """ identifier: String! } + +""" +A commercial contract available to a buyer's Organization Unit. +""" +type StoreContract { + """ + Contract identifier (the contract/scope ID associated with the Organization Unit). + """ + id: ID! + """ + Human-readable corporate name of the contract (resolved from MasterData). + """ + corporateName: String! + """ + Indicates whether this contract is the one currently active in the session. + """ + isActive: Boolean! +} diff --git a/packages/api/src/platforms/vtex/typeDefs/query.graphql b/packages/api/src/platforms/vtex/typeDefs/query.graphql index 8e9b293d36..3fbb33f7dd 100644 --- a/packages/api/src/platforms/vtex/typeDefs/query.graphql +++ b/packages/api/src/platforms/vtex/typeDefs/query.graphql @@ -451,6 +451,20 @@ type Query { @auth @cacheControl(scope: "private", sMaxAge: 300, staleWhileRevalidate: 3600) + """ + Lists the commercial contracts associated with the given Organization Unit, + resolved to human-readable corporate names. Governed: only contracts associated + with the authenticated buyer's Organization Unit are returned. + """ + availableContracts( + """ + The Organization Unit identifier whose contracts will be listed. + """ + orgUnitId: String! + ): [StoreContract!]! + @auth + @cacheControl(scope: "private", sMaxAge: 300, staleWhileRevalidate: 3600) + """ Returns a list of pickup points near to the given geo coordinates. """ diff --git a/packages/api/test/unit/platforms/vtex/resolvers/availableContracts.test.ts b/packages/api/test/unit/platforms/vtex/resolvers/availableContracts.test.ts new file mode 100644 index 0000000000..6d8c301837 --- /dev/null +++ b/packages/api/test/unit/platforms/vtex/resolvers/availableContracts.test.ts @@ -0,0 +1,147 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { Query } from '../../../../../src/platforms/vtex/resolvers/query' + +const getScopesByOrgUnit = vi.fn() +const getContractById = vi.fn() +const session = vi.fn() + +const makeCtx = () => + ({ + clients: { + commerce: { + units: { getScopesByOrgUnit }, + masterData: { getContractById }, + session, + }, + }, + }) as any + +const availableContracts = (Query as any).availableContracts + +beforeEach(() => { + vi.clearAllMocks() + // Default: active contract id resolves to "b" via the session profile id. + session.mockResolvedValue({ + namespaces: { profile: { id: { value: 'b' } } }, + }) +}) + +describe('Query.availableContracts', () => { + it('throws when orgUnitId is missing', async () => { + await expect( + availableContracts(null, { orgUnitId: '' }, makeCtx()) + ).rejects.toThrow(/orgUnitId/i) + expect(getScopesByOrgUnit).not.toHaveBeenCalled() + }) + + it('lists the Org Unit contracts by corporate name and flags the active one', async () => { + getScopesByOrgUnit.mockResolvedValue({ + organizationUnitId: 'unit-1', + scopes: [{ scope: 'contract', ids: ['a', 'b', 'c'] }], + }) + getContractById.mockImplementation( + ({ contractId }: { contractId: string }) => + Promise.resolve({ corporateName: `Corp ${contractId.toUpperCase()}` }) + ) + + const result = await availableContracts( + null, + { orgUnitId: 'unit-1' }, + makeCtx() + ) + + expect(getScopesByOrgUnit).toHaveBeenCalledWith({ orgUnitId: 'unit-1' }) + // Governance + name resolution: corporate names, never raw IDs. + expect(result).toEqual([ + { id: 'a', corporateName: 'Corp A', isActive: false }, + { id: 'b', corporateName: 'Corp B', isActive: true }, + { id: 'c', corporateName: 'Corp C', isActive: false }, + ]) + }) + + it('returns an empty list when the Org Unit has no contracts', async () => { + getScopesByOrgUnit.mockResolvedValue({ + organizationUnitId: 'unit-1', + scopes: [], + }) + + const result = await availableContracts( + null, + { orgUnitId: 'unit-1' }, + makeCtx() + ) + + expect(result).toEqual([]) + expect(getContractById).not.toHaveBeenCalled() + }) + + it('isolates per-contract resolution failures', async () => { + getScopesByOrgUnit.mockResolvedValue({ + organizationUnitId: 'unit-1', + scopes: [{ scope: 'contract', ids: ['a', 'b'] }], + }) + getContractById.mockImplementation( + ({ contractId }: { contractId: string }) => + contractId === 'a' + ? Promise.reject(new Error('MasterData down')) + : Promise.resolve({ corporateName: 'Corp B' }) + ) + + const result = await availableContracts( + null, + { orgUnitId: 'unit-1' }, + makeCtx() + ) + + expect(result).toEqual([ + { id: 'b', corporateName: 'Corp B', isActive: true }, + ]) + }) + + it('skips contracts that have no corporate name', async () => { + getScopesByOrgUnit.mockResolvedValue({ + organizationUnitId: 'unit-1', + scopes: [{ scope: 'contract', ids: ['a', 'b'] }], + }) + getContractById.mockImplementation( + ({ contractId }: { contractId: string }) => + contractId === 'a' + ? Promise.resolve({ corporateName: '' }) + : Promise.resolve({ corporateName: 'Corp B' }) + ) + + const result = await availableContracts( + null, + { orgUnitId: 'unit-1' }, + makeCtx() + ) + + expect(result).toEqual([ + { id: 'b', corporateName: 'Corp B', isActive: true }, + ]) + }) + + it('deduplicates contract ids across scopes', async () => { + getScopesByOrgUnit.mockResolvedValue({ + organizationUnitId: 'unit-1', + scopes: [ + { scope: 'contract', ids: ['a', 'b'] }, + { scope: 'price', ids: ['b'] }, + ], + }) + getContractById.mockImplementation( + ({ contractId }: { contractId: string }) => + Promise.resolve({ corporateName: `Corp ${contractId.toUpperCase()}` }) + ) + + const result = await availableContracts( + null, + { orgUnitId: 'unit-1' }, + makeCtx() + ) + + expect(result).toHaveLength(2) + expect(getContractById).toHaveBeenCalledTimes(2) + }) +}) diff --git a/packages/core/@generated/gql.ts b/packages/core/@generated/gql.ts index ba48244dc0..8ff5c5c689 100644 --- a/packages/core/@generated/gql.ts +++ b/packages/core/@generated/gql.ts @@ -37,6 +37,7 @@ type Documents = { "\n query ServerProfileQuery {\n accountProfile {\n name\n email\n id\n }\n }\n": typeof types.ServerProfileQueryDocument, "\n query ServerSecurity {\n accountProfile {\n name\n }\n userDetails {\n email\n }\n }\n": typeof types.ServerSecurityDocument, "\n query ServerUserDetailsQuery {\n accountProfile {\n name\n }\n userDetails {\n username\n name\n email\n phone\n role\n orgUnit\n }\n }\n": typeof types.ServerUserDetailsQueryDocument, + "\n query AvailableContractsQuery($orgUnitId: String!) {\n availableContracts(orgUnitId: $orgUnitId) {\n id\n corporateName\n isActive\n }\n }\n": typeof types.AvailableContractsQueryDocument, "\n mutation CancelOrderMutation($data: IUserOrderCancel!) {\n cancelOrder(data: $data) {\n data\n }\n }\n": typeof types.CancelOrderMutationDocument, "\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, @@ -80,6 +81,7 @@ const documents: Documents = { "\n query ServerProfileQuery {\n accountProfile {\n name\n email\n id\n }\n }\n": types.ServerProfileQueryDocument, "\n query ServerSecurity {\n accountProfile {\n name\n }\n userDetails {\n email\n }\n }\n": types.ServerSecurityDocument, "\n query ServerUserDetailsQuery {\n accountProfile {\n name\n }\n userDetails {\n username\n name\n email\n phone\n role\n orgUnit\n }\n }\n": types.ServerUserDetailsQueryDocument, + "\n query AvailableContractsQuery($orgUnitId: String!) {\n availableContracts(orgUnitId: $orgUnitId) {\n id\n corporateName\n isActive\n }\n }\n": types.AvailableContractsQueryDocument, "\n mutation CancelOrderMutation($data: IUserOrderCancel!) {\n cancelOrder(data: $data) {\n data\n }\n }\n": types.CancelOrderMutationDocument, "\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, @@ -189,6 +191,10 @@ export function gql(source: "\n query ServerSecurity {\n accountProfile {\n * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ export function gql(source: "\n query ServerUserDetailsQuery {\n accountProfile {\n name\n }\n userDetails {\n username\n name\n email\n phone\n role\n orgUnit\n }\n }\n"): typeof import('./graphql').ServerUserDetailsQueryDocument; +/** + * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function gql(source: "\n query AvailableContractsQuery($orgUnitId: String!) {\n availableContracts(orgUnitId: $orgUnitId) {\n id\n corporateName\n isActive\n }\n }\n"): typeof import('./graphql').AvailableContractsQueryDocument; /** * 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 bb4af9884f..296de5aa20 100644 --- a/packages/core/@generated/graphql.ts +++ b/packages/core/@generated/graphql.ts @@ -830,6 +830,12 @@ export type Query = { allCollections: StoreCollectionConnection; /** Returns information about all products. */ allProducts: StoreProductConnection; + /** + * Lists the commercial contracts associated with the given Organization Unit, + * resolved to human-readable corporate names. Governed: only contracts associated + * with the authenticated buyer's Organization Unit are returned. + */ + availableContracts: Array; /** Returns the details of a collection based on the collection slug. */ collection: StoreCollection; /** Returns the list of Orders that the User can view. */ @@ -873,6 +879,11 @@ export type QueryAllProductsArgs = { }; +export type QueryAvailableContractsArgs = { + orgUnitId: Scalars['String']['input']; +}; + + export type QueryCollectionArgs = { slug: Scalars['String']['input']; }; @@ -1240,6 +1251,16 @@ export type StoreCollectionType = /** Third level of product categorization. */ | 'SubCategory'; +/** A commercial contract available to a buyer's Organization Unit. */ +export type StoreContract = { + /** Human-readable corporate name of the contract (resolved from MasterData). */ + corporateName: Scalars['String']['output']; + /** Contract identifier (the contract/scope ID associated with the Organization Unit). */ + id: Scalars['ID']['output']; + /** Indicates whether this contract is the one currently active in the session. */ + isActive: Scalars['Boolean']['output']; +}; + /** Currency information. */ export type StoreCurrency = { /** Currency code (e.g: USD). */ @@ -2566,6 +2587,13 @@ export type ServerUserDetailsQueryQueryVariables = Exact<{ [key: string]: never; export type ServerUserDetailsQueryQuery = { accountProfile: { name: string | null }, userDetails: { username: string | null, name: string | null, email: string | null, phone: string | null, role: Array | null, orgUnit: string | null } }; +export type AvailableContractsQueryQueryVariables = Exact<{ + orgUnitId: Scalars['String']['input']; +}>; + + +export type AvailableContractsQueryQuery = { availableContracts: Array<{ id: string, corporateName: string, isActive: boolean }> }; + export type CancelOrderMutationMutationVariables = Exact<{ data: IUserOrderCancel; }>; @@ -3252,6 +3280,7 @@ export const ServerListOrdersQueryDocument = {"__meta__":{"operationName":"Serve export const ServerProfileQueryDocument = {"__meta__":{"operationName":"ServerProfileQuery","operationHash":"672fe0f00b7b710b63fc6573c0a6b2ec54812b8f"}} as unknown as TypedDocumentString; export const ServerSecurityDocument = {"__meta__":{"operationName":"ServerSecurity","operationHash":"0890ba3456c40a426893b80b698df7a84cfdd6a1"}} as unknown as TypedDocumentString; export const ServerUserDetailsQueryDocument = {"__meta__":{"operationName":"ServerUserDetailsQuery","operationHash":"630ec1f47f2710ce3d7895e9131482641f30c837"}} as unknown as TypedDocumentString; +export const AvailableContractsQueryDocument = {"__meta__":{"operationName":"AvailableContractsQuery","operationHash":"011619bb1be93a21f6530fe4d193dec96a89e88a"}} as unknown as TypedDocumentString; export const CancelOrderMutationDocument = {"__meta__":{"operationName":"CancelOrderMutation","operationHash":"e2b06da6840614d3c72768e56579b9d3b8e80802"}} as unknown as TypedDocumentString; export const ProcessOrderAuthorizationMutationDocument = {"__meta__":{"operationName":"ProcessOrderAuthorizationMutation","operationHash":"8c25d37c8d6e7c20ab21bb8a4f4e6a2fe320ea8d"}} as unknown as TypedDocumentString; export const ValidateUserDocument = {"__meta__":{"operationName":"ValidateUser","operationHash":"32f99c73c3de958b64d6bece1afe800469f54548"}} as unknown as TypedDocumentString; diff --git a/packages/core/src/components/account/MyAccountDrawer/OrganizationDrawer/ContractSwitcher/ContractSwitcher.tsx b/packages/core/src/components/account/MyAccountDrawer/OrganizationDrawer/ContractSwitcher/ContractSwitcher.tsx new file mode 100644 index 0000000000..cf561b7be5 --- /dev/null +++ b/packages/core/src/components/account/MyAccountDrawer/OrganizationDrawer/ContractSwitcher/ContractSwitcher.tsx @@ -0,0 +1,123 @@ +import { Button, Icon, Loader } from '@faststore/ui' +import { useState } from 'react' + +import { useAvailableContracts } from 'src/sdk/account/useAvailableContracts' +import { useSwitchContract } from 'src/sdk/account/useSwitchContract' + +export type ContractSwitcherProps = { + /** Returns to the drawer menu view. */ + onBack: () => void + /** Called after a contract switch succeeds. */ + onSwitched?: () => void +} + +/** + * Sub-view of the OrganizationDrawer that lets a B2B buyer change the active + * contract within their Organization Unit (REQ-03..06). Handles loading, empty + * (single/no alternative), and error states. + */ +export const ContractSwitcher = ({ + onBack, + onSwitched, +}: ContractSwitcherProps) => { + const { contracts, loading, error } = useAvailableContracts(true) + const { + switchContract, + loading: switching, + error: switchError, + } = useSwitchContract() + const [pendingId, setPendingId] = useState(null) + + const hasAlternatives = contracts.length > 1 + + const handleSelect = async (contractId: string, isActive: boolean) => { + if (isActive || switching) { + return + } + + setPendingId(contractId) + const ok = await switchContract(contractId) + setPendingId(null) + + if (ok) { + onSwitched?.() + } + } + + return ( +
+
+ +
+ + {loading && ( +
+ +
+ )} + + {!loading && error && ( +
+

We couldn't load your contracts. Please try again.

+
+ )} + + {!loading && !error && !hasAlternatives && ( +
+

No other contracts are available for your organization.

+
+ )} + + {!loading && !error && hasAlternatives && ( +
    + {contracts.map((contract) => { + const isPending = pendingId === contract.id + + return ( +
  • + +
  • + ) + })} +
+ )} + + {switchError && ( +
+

+ We couldn't switch your contract. The previous contract is still + active. +

+
+ )} +
+ ) +} diff --git a/packages/core/src/components/account/MyAccountDrawer/OrganizationDrawer/ContractSwitcher/index.ts b/packages/core/src/components/account/MyAccountDrawer/OrganizationDrawer/ContractSwitcher/index.ts new file mode 100644 index 0000000000..06bb7f5c9a --- /dev/null +++ b/packages/core/src/components/account/MyAccountDrawer/OrganizationDrawer/ContractSwitcher/index.ts @@ -0,0 +1,2 @@ +export { ContractSwitcher } from './ContractSwitcher' +export type { ContractSwitcherProps } from './ContractSwitcher' diff --git a/packages/core/src/components/account/MyAccountDrawer/OrganizationDrawer/OrganizationDrawer.tsx b/packages/core/src/components/account/MyAccountDrawer/OrganizationDrawer/OrganizationDrawer.tsx index d138af0f49..15f9401f3b 100644 --- a/packages/core/src/components/account/MyAccountDrawer/OrganizationDrawer/OrganizationDrawer.tsx +++ b/packages/core/src/components/account/MyAccountDrawer/OrganizationDrawer/OrganizationDrawer.tsx @@ -1,4 +1,5 @@ import { SlideOver, useFadeEffect } from '@faststore/ui' +import { useState } from 'react' import { getStoreURL } from 'src/sdk/localization/useLocalizationConfig' import { useSession } from 'src/sdk/session' @@ -10,11 +11,14 @@ import { } from 'src/utils/clearCookies' import storeConfig from '../../../../../discovery.config' import { ProfileSummary } from '../ProfileSummary/ProfileSummary' +import { ContractSwitcher } from './ContractSwitcher' import { OrganizationDrawerBody } from './OrganizationDrawerBody' import { OrganizationDrawerHeader } from './OrganizationDrawerHeader' import { setReloadAfterLogoutReturn } from './useReloadAfterLogoutReturn' import styles from './section.module.scss' +type OrganizationDrawerView = 'menu' | 'switch' + type OrganizationDrawerProps = { isOpen: boolean closeDrawer: () => void @@ -137,6 +141,7 @@ export const OrganizationDrawer = ({ }: OrganizationDrawerProps) => { const { fade, fadeOut } = useFadeEffect() const { b2b, person } = useSession() + const [view, setView] = useState('menu') const contractName = b2b?.contractName ?? @@ -148,6 +153,8 @@ export const OrganizationDrawer = ({ : null const isOrganizationManager = b2b?.organizationManager || false + // The switcher is only meaningful for B2B buyers tied to an Organization Unit. + const canSwitchContract = Boolean(b2b?.unitId) return ( setView('switch') + : undefined + } /> - + {view === 'switch' ? ( + setView('menu')} + onSwitched={() => setView('menu')} + /> + ) : ( + + )}