diff --git a/packages/commerce-sdk-react/src/constant.ts b/packages/commerce-sdk-react/src/constant.ts index 2e6b332995..a11b3c0237 100644 --- a/packages/commerce-sdk-react/src/constant.ts +++ b/packages/commerce-sdk-react/src/constant.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023, Salesforce, Inc. + * Copyright (c) 2025, Salesforce, Inc. * All rights reserved. * SPDX-License-Identifier: BSD-3-Clause * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause @@ -46,6 +46,7 @@ export const SERVER_AFFINITY_HEADER_KEY = 'sfdc_dwsid' export const CLIENT_KEYS = { SHOPPER_BASKETS: 'shopperBaskets', + SHOPPER_CONSENTS: 'shopperConsents', SHOPPER_CONTEXTS: 'shopperContexts', SHOPPER_CUSTOMERS: 'shopperCustomers', SHOPPER_EXPERIENCE: 'shopperExperience', diff --git a/packages/commerce-sdk-react/src/hooks/ShopperConsents/cache.ts b/packages/commerce-sdk-react/src/hooks/ShopperConsents/cache.ts new file mode 100644 index 0000000000..b0323cf46a --- /dev/null +++ b/packages/commerce-sdk-react/src/hooks/ShopperConsents/cache.ts @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ +import {ApiClients, CacheUpdateMatrix} from '../types' +import {getSubscriptions} from './queryKeyHelpers' +import {CLIENT_KEYS} from '../../constant' + +const CLIENT_KEY = CLIENT_KEYS.SHOPPER_CONSENTS +type Client = NonNullable + +export const cacheUpdateMatrix: CacheUpdateMatrix = { + updateSubscription(customerId, {parameters}, response) { + // When a subscription is updated, we invalidate the getSubscriptions query + // to ensure the UI reflects the latest subscription state + return { + invalidate: [{queryKey: getSubscriptions.queryKey(parameters)}] + } + }, + updateSubscriptions(customerId, {parameters}, response) { + // When multiple subscriptions are updated, we invalidate the getSubscriptions query + // to ensure the UI reflects the latest subscription states + return { + invalidate: [{queryKey: getSubscriptions.queryKey(parameters)}] + } + } +} diff --git a/packages/commerce-sdk-react/src/hooks/ShopperConsents/index.test.ts b/packages/commerce-sdk-react/src/hooks/ShopperConsents/index.test.ts new file mode 100644 index 0000000000..c46e5bd8df --- /dev/null +++ b/packages/commerce-sdk-react/src/hooks/ShopperConsents/index.test.ts @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ +import {ShopperConsents} from 'commerce-sdk-isomorphic' +import {getUnimplementedEndpoints} from '../../test-utils' +import {cacheUpdateMatrix} from './cache' +import {ShopperConsentsMutations as mutations} from './mutation' +import * as queries from './query' + +describe('Shopper Consents hooks', () => { + test('all endpoints have hooks', () => { + // unimplemented = SDK method exists, but no query hook or value in mutations enum + const unimplemented = getUnimplementedEndpoints(ShopperConsents, queries, mutations) + // If this test fails: create a new query hook, add the endpoint to the mutations enum, + // or add it to the `expected` array with a comment explaining "TODO" or "never" (and why). + expect(unimplemented).toEqual([]) + }) + test('all mutations have cache update logic', () => { + // unimplemented = value in mutations enum, but no method in cache update matrix + const unimplemented = new Set(Object.values(mutations)) + Object.entries(cacheUpdateMatrix).forEach(([method, implementation]) => { + if (implementation) unimplemented.delete(method) + }) + // If this test fails: add cache update logic, remove the endpoint from the mutations enum, + // or add it to the `expected` array to indicate that it is still a TODO. + expect([...unimplemented]).toEqual([]) + }) +}) diff --git a/packages/commerce-sdk-react/src/hooks/ShopperConsents/index.ts b/packages/commerce-sdk-react/src/hooks/ShopperConsents/index.ts new file mode 100644 index 0000000000..0d56f4873c --- /dev/null +++ b/packages/commerce-sdk-react/src/hooks/ShopperConsents/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ +export * from './mutation' +export * from './query' diff --git a/packages/commerce-sdk-react/src/hooks/ShopperConsents/mutation.test.ts b/packages/commerce-sdk-react/src/hooks/ShopperConsents/mutation.test.ts new file mode 100644 index 0000000000..820823b972 --- /dev/null +++ b/packages/commerce-sdk-react/src/hooks/ShopperConsents/mutation.test.ts @@ -0,0 +1,269 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ +import {act} from '@testing-library/react' +import {ShopperConsentsTypes} from 'commerce-sdk-isomorphic' +import nock from 'nock' +import { + assertInvalidateQuery, + mockMutationEndpoints, + mockQueryEndpoint, + renderHookWithProviders, + waitAndExpectSuccess +} from '../../test-utils' +import {ShopperConsentsMutation, useShopperConsentsMutation} from '../ShopperConsents' +import {ApiClients, Argument} from '../types' +import * as queries from './query' +import {CLIENT_KEYS} from '../../constant' + +jest.mock('../../auth/index.ts', () => { + const {default: mockAuth} = jest.requireActual('../../auth/index.ts') + mockAuth.prototype.ready = jest.fn().mockResolvedValue({access_token: 'access_token'}) + return mockAuth +}) + +const CLIENT_KEY = CLIENT_KEYS.SHOPPER_CONSENTS +type Client = NonNullable +const consentsEndpoint = '/shopper/shopper-consents/' +/** All Shopper Consents parameters. Can be used for all endpoints, as unused params are ignored. */ +const PARAMETERS = { + siteId: 'RefArch' +} as const +/** Options object that can be used for all query endpoints. */ +const queryOptions = {parameters: PARAMETERS} + +const baseSubscriptionResponse: ShopperConsentsTypes.ConsentSubscriptionResponse = { + data: [ + { + subscriptionId: 'test-subscription', + channels: ['email' as ShopperConsentsTypes.ChannelType], + contactPointValue: 'test@example.com' + } + ] +} + +type Mutation = ShopperConsentsMutation +/** Map of mutation method name to test options. */ +type TestMap = {[Mut in Mutation]?: Argument} +const testMap: TestMap = { + updateSubscription: { + parameters: PARAMETERS, + body: { + subscriptionId: 'test-subscription', + channel: 'email', + status: 'opt_in', + contactPointValue: 'test@example.com' + } + }, + updateSubscriptions: { + parameters: PARAMETERS, + body: { + subscriptions: [ + { + subscriptionId: 'test-subscription', + channel: 'email' as ShopperConsentsTypes.ChannelType, + status: 'opt_in' as ShopperConsentsTypes.ConsentStatus, + contactPointValue: 'test@example.com' + } + ] + } + } +} +// Type assertion is necessary because `Object.entries` is limited +type TestCase = [Mutation, NonNullable] +const testCases = Object.entries(testMap) as TestCase[] + +describe('Shopper Consents mutations', () => { + beforeEach(() => nock.cleanAll()) + test.each(testCases)('`%s` returns data on success', async (mutationName, options) => { + mockMutationEndpoints(consentsEndpoint, {}) + const {result} = renderHookWithProviders(() => { + return useShopperConsentsMutation(mutationName) + }) + act(() => result.current.mutate(options)) + await waitAndExpectSuccess(() => result.current) + }) +}) + +describe('Cache update behavior', () => { + describe('updateSubscription', () => { + beforeEach(() => nock.cleanAll()) + + test('invalidates `getSubscriptions` query', async () => { + mockQueryEndpoint(consentsEndpoint, baseSubscriptionResponse) + mockMutationEndpoints(consentsEndpoint, {}) + const {result} = renderHookWithProviders(() => { + return { + query: queries.useSubscriptions(queryOptions), + mutation: useShopperConsentsMutation('updateSubscription') + } + }) + await waitAndExpectSuccess(() => result.current.query) + const oldData = result.current.query.data + + act(() => + result.current.mutation.mutate({ + parameters: PARAMETERS, + body: { + subscriptionId: 'test-subscription', + channel: 'email', + status: 'opt_out', + contactPointValue: 'test@example.com' + } + }) + ) + await waitAndExpectSuccess(() => result.current.mutation) + assertInvalidateQuery(result.current.query, oldData) + }) + + test('triggers refetch after cache invalidation', async () => { + const updatedResponse: ShopperConsentsTypes.ConsentSubscriptionResponse = { + data: [ + { + subscriptionId: 'test-subscription', + channels: ['email' as ShopperConsentsTypes.ChannelType], + contactPointValue: 'test@example.com' + } + ] + } + + mockQueryEndpoint(consentsEndpoint, baseSubscriptionResponse) + mockMutationEndpoints(consentsEndpoint, {}) + mockQueryEndpoint(consentsEndpoint, updatedResponse) // Refetch returns updated data + + const {result} = renderHookWithProviders(() => { + return { + query: queries.useSubscriptions(queryOptions), + mutation: useShopperConsentsMutation('updateSubscription') + } + }) + await waitAndExpectSuccess(() => result.current.query) + expect(result.current.query.data).toEqual(baseSubscriptionResponse) + + act(() => + result.current.mutation.mutate({ + parameters: PARAMETERS, + body: { + subscriptionId: 'test-subscription', + channel: 'email', + status: 'opt_out', + contactPointValue: 'test@example.com' + } + }) + ) + await waitAndExpectSuccess(() => result.current.mutation) + + // Wait for refetch to complete + await waitAndExpectSuccess(() => result.current.query) + // After refetch, data should be updated + expect(result.current.query.data).toEqual(updatedResponse) + }) + }) + + describe('updateSubscriptions', () => { + beforeEach(() => nock.cleanAll()) + + test('invalidates `getSubscriptions` query', async () => { + mockQueryEndpoint(consentsEndpoint, baseSubscriptionResponse) + mockMutationEndpoints(consentsEndpoint, {}) + const {result} = renderHookWithProviders(() => { + return { + query: queries.useSubscriptions(queryOptions), + mutation: useShopperConsentsMutation('updateSubscriptions') + } + }) + await waitAndExpectSuccess(() => result.current.query) + const oldData = result.current.query.data + + act(() => + result.current.mutation.mutate({ + parameters: PARAMETERS, + body: { + subscriptions: [ + { + subscriptionId: 'test-subscription', + channel: 'email' as ShopperConsentsTypes.ChannelType, + status: 'opt_out' as ShopperConsentsTypes.ConsentStatus, + contactPointValue: 'test@example.com' + }, + { + subscriptionId: 'test-subscription-2', + channel: 'sms' as ShopperConsentsTypes.ChannelType, + status: 'opt_in' as ShopperConsentsTypes.ConsentStatus, + contactPointValue: '+15551234567' + } + ] + } + }) + ) + await waitAndExpectSuccess(() => result.current.mutation) + assertInvalidateQuery(result.current.query, oldData) + }) + + test('ensures stale cache is not served after mutation', async () => { + const staleResponse = baseSubscriptionResponse + const freshResponse: ShopperConsentsTypes.ConsentSubscriptionResponse = { + data: [ + { + subscriptionId: 'test-subscription', + channels: ['email' as ShopperConsentsTypes.ChannelType], + contactPointValue: 'test@example.com' + }, + { + subscriptionId: 'test-subscription-2', + channels: ['sms' as ShopperConsentsTypes.ChannelType], + contactPointValue: '+15551234567' + } + ] + } + + mockQueryEndpoint(consentsEndpoint, staleResponse) + mockMutationEndpoints(consentsEndpoint, {}) + mockQueryEndpoint(consentsEndpoint, freshResponse) // Refetch returns fresh data + + const {result} = renderHookWithProviders(() => { + return { + query: queries.useSubscriptions(queryOptions), + mutation: useShopperConsentsMutation('updateSubscriptions') + } + }) + + // Initial fetch - get stale data + await waitAndExpectSuccess(() => result.current.query) + expect(result.current.query.data?.data).toHaveLength(1) + + // Perform mutation + act(() => + result.current.mutation.mutate({ + parameters: PARAMETERS, + body: { + subscriptions: [ + { + subscriptionId: 'test-subscription', + channel: 'email' as ShopperConsentsTypes.ChannelType, + status: 'opt_out' as ShopperConsentsTypes.ConsentStatus, + contactPointValue: 'test@example.com' + }, + { + subscriptionId: 'test-subscription-2', + channel: 'sms' as ShopperConsentsTypes.ChannelType, + status: 'opt_in' as ShopperConsentsTypes.ConsentStatus, + contactPointValue: '+15551234567' + } + ] + } + }) + ) + await waitAndExpectSuccess(() => result.current.mutation) + + // Wait for automatic refetch + await waitAndExpectSuccess(() => result.current.query) + // Should have fresh data, not stale + expect(result.current.query.data?.data).toHaveLength(2) + expect(result.current.query.data).toEqual(freshResponse) + }) + }) +}) diff --git a/packages/commerce-sdk-react/src/hooks/ShopperConsents/mutation.ts b/packages/commerce-sdk-react/src/hooks/ShopperConsents/mutation.ts new file mode 100644 index 0000000000..22cef56a52 --- /dev/null +++ b/packages/commerce-sdk-react/src/hooks/ShopperConsents/mutation.ts @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ +import {ApiClients, ApiMethod, Argument, CacheUpdateGetter, DataType, MergedOptions} from '../types' +import {useMutation} from '../useMutation' +import {UseMutationResult} from '@tanstack/react-query' +import {NotImplementedError} from '../utils' +import {cacheUpdateMatrix} from './cache' +import {CLIENT_KEYS} from '../../constant' +import useCommerceApi from '../useCommerceApi' + +const CLIENT_KEY = CLIENT_KEYS.SHOPPER_CONSENTS +type Client = NonNullable + +/** + * Mutations available for Shopper Consents. + * @group ShopperConsents + * @category Mutation + * @enum + */ +export const ShopperConsentsMutations = { + /** + * Updates the customer's consent subscription for a specific channel. + * @returns A TanStack Query mutation hook for interacting with the Shopper Consents `updateSubscription` endpoint. + */ + UpdateSubscription: 'updateSubscription', + /** + * Updates multiple customer consent subscriptions. + * @returns A TanStack Query mutation hook for interacting with the Shopper Consents `updateSubscriptions` endpoint. + */ + UpdateSubscriptions: 'updateSubscriptions' +} as const + +/** + * Mutation for Shopper Consents. + * @group ShopperConsents + * @category Mutation + */ +export type ShopperConsentsMutation = + (typeof ShopperConsentsMutations)[keyof typeof ShopperConsentsMutations] + +/** + * Mutation hook for Shopper Consents. + * @group ShopperConsents + * @category Mutation + */ +export function useShopperConsentsMutation( + mutation: Mutation +): UseMutationResult, unknown, Argument> { + const getCacheUpdates = cacheUpdateMatrix[mutation] + // TODO: Remove this check when all mutations are implemented. + if (!getCacheUpdates) throw new NotImplementedError(`The '${mutation}' mutation`) + + // The `Options` and `Data` types for each mutation are similar, but distinct, and the union + // type generated from `Client[Mutation]` seems to be too complex for TypeScript to handle. + // I'm not sure if there's a way to avoid the type assertions in here for the methods that + // use them. However, I'm fairly confident that they are safe to do, as they seem to be simply + // re-asserting what we already have. + const client = useCommerceApi(CLIENT_KEY) + type Options = Argument + type Data = DataType + return useMutation({ + client, + method: (opts: Options) => (client[mutation] as ApiMethod)(opts), + getCacheUpdates: getCacheUpdates as unknown as CacheUpdateGetter< + MergedOptions, + Data + > + }) +} diff --git a/packages/commerce-sdk-react/src/hooks/ShopperConsents/query.test.ts b/packages/commerce-sdk-react/src/hooks/ShopperConsents/query.test.ts new file mode 100644 index 0000000000..588cef455e --- /dev/null +++ b/packages/commerce-sdk-react/src/hooks/ShopperConsents/query.test.ts @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ +import {ShopperConsentsTypes} from 'commerce-sdk-isomorphic' +import nock from 'nock' +import { + mockQueryEndpoint, + renderHookWithProviders, + waitAndExpectError, + waitAndExpectSuccess, + createQueryClient +} from '../../test-utils' +import * as queries from './query' + +jest.mock('../../auth/index.ts', () => { + const {default: mockAuth} = jest.requireActual('../../auth/index.ts') + mockAuth.prototype.ready = jest.fn().mockResolvedValue({access_token: 'access_token'}) + return mockAuth +}) +type Queries = typeof queries +const consentsEndpoint = '/shopper/shopper-consents/' +// Not all endpoints use all parameters, but unused parameters are safely discarded +const OPTIONS = {parameters: {siteId: 'RefArch'}} + +/** Map of query name to returned data type */ +type TestMap = {[K in keyof Queries]: NonNullable['data']>} +// This is an object rather than an array to more easily ensure we cover all hooks +const testMap: TestMap = { + // Type assertion so that we don't have to implement the full type + useSubscriptions: {data: []} as ShopperConsentsTypes.ConsentSubscriptionResponse +} +// Type assertion is necessary because `Object.entries` is limited +const testCases = Object.entries(testMap) as Array<[keyof TestMap, TestMap[keyof TestMap]]> +describe('Shopper Consents query hooks', () => { + beforeEach(() => nock.cleanAll()) + afterEach(() => { + expect(nock.pendingMocks()).toHaveLength(0) + }) + test.each(testCases)('`%s` returns data on success', async (queryName, data) => { + mockQueryEndpoint(consentsEndpoint, data) + const {result} = renderHookWithProviders(() => { + return queries[queryName](OPTIONS) + }) + await waitAndExpectSuccess(() => result.current) + expect(result.current.data).toEqual(data) + }) + + test.each(testCases)('`%s` has meta.displayName defined', async (queryName, data) => { + mockQueryEndpoint(consentsEndpoint, data) + const queryClient = createQueryClient() + const {result} = renderHookWithProviders( + () => { + return queries[queryName](OPTIONS) + }, + {queryClient} + ) + await waitAndExpectSuccess(() => result.current) + expect(queryClient.getQueryCache().getAll()[0].meta?.displayName).toBe(queryName) + }) + + test.each(testCases)('`%s` returns error on error', async (queryName) => { + mockQueryEndpoint(consentsEndpoint, {}, 400) + const {result} = renderHookWithProviders(() => { + return queries[queryName](OPTIONS) + }) + await waitAndExpectError(() => result.current) + }) +}) diff --git a/packages/commerce-sdk-react/src/hooks/ShopperConsents/query.ts b/packages/commerce-sdk-react/src/hooks/ShopperConsents/query.ts new file mode 100644 index 0000000000..0abb3ac154 --- /dev/null +++ b/packages/commerce-sdk-react/src/hooks/ShopperConsents/query.ts @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ +import {UseQueryResult} from '@tanstack/react-query' +import {ShopperConsents} from 'commerce-sdk-isomorphic' +import {ApiClients, ApiQueryOptions, Argument, DataType, NullableParameters} from '../types' +import {useQuery} from '../useQuery' +import {mergeOptions, omitNullableParameters, pickValidParams} from '../utils' +import * as queryKeyHelpers from './queryKeyHelpers' +import {CLIENT_KEYS} from '../../constant' +import useCommerceApi from '../useCommerceApi' + +const CLIENT_KEY = CLIENT_KEYS.SHOPPER_CONSENTS +type Client = NonNullable + +/** + * Gets the customer's consents for receiving marketing emails, SMS, etc. + * @group ShopperConsents + * @category Query + * @parameter apiOptions - Options to pass through to `commerce-sdk-isomorphic`, with `null` accepted for unset API parameters. + * @parameter queryOptions - TanStack Query query options, with `enabled` by default set to check that all required API parameters have been set. + * @returns A TanStack Query query hook with data from the Shopper Consents `getSubscriptions` endpoint. + * @see {@link https://developer.salesforce.com/docs/commerce/commerce-api/references/shopper-consents?meta=getSubscriptions| Salesforce Developer Center} for more information about the API endpoint. + * @see {@link https://salesforcecommercecloud.github.io/commerce-sdk-isomorphic/classes/shopperconsents.shopperconsents-1.html#getsubscriptions | `commerce-sdk-isomorphic` documentation} for more information on the parameters and returned data type. + * @see {@link https://tanstack.com/query/latest/docs/react/reference/useQuery | TanStack Query `useQuery` reference} for more information about the return value. + */ +export const useSubscriptions = ( + apiOptions: NullableParameters>, + queryOptions: ApiQueryOptions = {} +): UseQueryResult> => { + type Options = Argument + type Data = DataType + const client = useCommerceApi(CLIENT_KEY) + const methodName = 'getSubscriptions' + const requiredParameters = ShopperConsents.paramKeys[`${methodName}Required`] + + // Parameters can be set in `apiOptions` or `client.clientConfig`; + // we must merge them in order to generate the correct query key. + const netOptions = omitNullableParameters(mergeOptions(client, apiOptions)) + const parameters = pickValidParams(netOptions.parameters, ShopperConsents.paramKeys[methodName]) + const queryKey = queryKeyHelpers[methodName].queryKey(netOptions.parameters) + + // We don't use `netOptions` here because we manipulate the options in `useQuery`. + const method = async (options: Options) => await client[methodName](options) + + queryOptions.meta = { + displayName: 'useSubscriptions', + ...queryOptions.meta + } + + // For some reason, if we don't explicitly set these generic parameters, the inferred type for + // `Data` sometimes, but not always, includes `Response`, which is incorrect. I don't know why. + return useQuery({...netOptions, parameters}, queryOptions, { + method, + queryKey, + requiredParameters + }) +} diff --git a/packages/commerce-sdk-react/src/hooks/ShopperConsents/queryKeyHelpers.ts b/packages/commerce-sdk-react/src/hooks/ShopperConsents/queryKeyHelpers.ts new file mode 100644 index 0000000000..4e8e8bbde0 --- /dev/null +++ b/packages/commerce-sdk-react/src/hooks/ShopperConsents/queryKeyHelpers.ts @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ +import {ShopperConsents} from 'commerce-sdk-isomorphic' +import {Argument, ExcludeTail} from '../types' +import {pickValidParams} from '../utils' + +// We must use a client with no parameters in order to have required/optional match the API spec +type Client = ShopperConsents<{shortCode: string}> +type Params = Partial['parameters']> +export type QueryKeys = { + getSubscriptions: [ + '/commerce-sdk-react', + '/organizations/', + string | undefined, + '/shopper-consents/subscriptions', + Params<'getSubscriptions'> + ] +} + +// This is defined here, rather than `types.ts`, because it relies on `Client` and `QueryKeys`, +// and making those generic would add too much complexity. +type QueryKeyHelper = { + /** Generates the path component of the query key for an endpoint. */ + path: (params: Params) => ExcludeTail + /** Generates the full query key for an endpoint. */ + queryKey: (params: Params) => QueryKeys[T] +} + +export const getSubscriptions: QueryKeyHelper<'getSubscriptions'> = { + path: (params) => [ + '/commerce-sdk-react', + '/organizations/', + params?.organizationId, + '/shopper-consents/subscriptions' + ], + queryKey: (params: Params<'getSubscriptions'>) => { + return [ + ...getSubscriptions.path(params), + pickValidParams(params || {}, ShopperConsents.paramKeys.getSubscriptions) + ] + } +} diff --git a/packages/commerce-sdk-react/src/hooks/index.ts b/packages/commerce-sdk-react/src/hooks/index.ts index c285391d0c..43cc7daaad 100644 --- a/packages/commerce-sdk-react/src/hooks/index.ts +++ b/packages/commerce-sdk-react/src/hooks/index.ts @@ -1,10 +1,11 @@ /* - * Copyright (c) 2024, Salesforce, Inc. + * Copyright (c) 2025, Salesforce, Inc. * All rights reserved. * SPDX-License-Identifier: BSD-3-Clause * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause */ export * from './ShopperBaskets' +export * from './ShopperConsents' export * from './ShopperContexts' export * from './ShopperCustomers' export * from './ShopperExperience' diff --git a/packages/commerce-sdk-react/src/hooks/types.ts b/packages/commerce-sdk-react/src/hooks/types.ts index 6390bd262f..a86ded5753 100644 --- a/packages/commerce-sdk-react/src/hooks/types.ts +++ b/packages/commerce-sdk-react/src/hooks/types.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023, Salesforce, Inc. + * Copyright (c) 2025, Salesforce, Inc. * All rights reserved. * SPDX-License-Identifier: BSD-3-Clause * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause @@ -8,6 +8,7 @@ import {InvalidateQueryFilters, QueryFilters, Updater, UseQueryOptions} from '@t import { ShopperBaskets, ShopperConfigurations, + ShopperConsents, ShopperContexts, ShopperCustomers, ShopperExperience, @@ -86,6 +87,7 @@ export type ApiClientConfigParams = { */ export interface ApiClients { shopperBaskets?: ShopperBaskets + shopperConsents?: ShopperConsents shopperContexts?: ShopperContexts shopperCustomers?: ShopperCustomers shopperExperience?: ShopperExperience diff --git a/packages/commerce-sdk-react/src/provider.tsx b/packages/commerce-sdk-react/src/provider.tsx index 11605f6e8a..bb4ce39f1d 100644 --- a/packages/commerce-sdk-react/src/provider.tsx +++ b/packages/commerce-sdk-react/src/provider.tsx @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022, salesforce.com, inc. + * Copyright (c) 2025, salesforce.com, inc. * All rights reserved. * SPDX-License-Identifier: BSD-3-Clause * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause @@ -11,6 +11,7 @@ import {Logger} from './types' import {DWSID_COOKIE_NAME, SERVER_AFFINITY_HEADER_KEY} from './constant' import { ShopperBaskets, + ShopperConsents, ShopperContexts, ShopperConfigurations, ShopperCustomers, @@ -257,6 +258,7 @@ const CommerceApiProvider = (props: CommerceApiProviderProps): ReactElement => { return { shopperBaskets: new ShopperBaskets(config), + shopperConsents: new ShopperConsents(config), shopperContexts: new ShopperContexts(config), shopperConfigurations: new ShopperConfigurations(config), shopperCustomers: new ShopperCustomers(config), diff --git a/packages/template-retail-react-app/app/components/footer/index.jsx b/packages/template-retail-react-app/app/components/footer/index.jsx index 9500aad913..daebaa5d3d 100644 --- a/packages/template-retail-react-app/app/components/footer/index.jsx +++ b/packages/template-retail-react-app/app/components/footer/index.jsx @@ -13,27 +13,23 @@ import { SimpleGrid, useMultiStyleConfig, Select as ChakraSelect, - Heading, - Input, - InputGroup, - InputRightElement, createStylesContext, - Button, FormControl } from '@salesforce/retail-react-app/app/components/shared/ui' import {useIntl} from 'react-intl' import LinksList from '@salesforce/retail-react-app/app/components/links-list' -import SocialIcons from '@salesforce/retail-react-app/app/components/social-icons' +import SubscribeMarketingConsent from '@salesforce/retail-react-app/app/components/subscription' import {HideOnDesktop, HideOnMobile} from '@salesforce/retail-react-app/app/components/responsive' import {getPathWithLocale} from '@salesforce/retail-react-app/app/utils/url' import LocaleText from '@salesforce/retail-react-app/app/components/locale-text' import useMultiSite from '@salesforce/retail-react-app/app/hooks/use-multi-site' import styled from '@emotion/styled' import {STORE_LOCATOR_IS_ENABLED} from '@salesforce/retail-react-app/app/constants' +import {CONSENT_TAGS} from '@salesforce/retail-react-app/app/constants/marketing-consent' import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config' -const [StylesProvider, useStyles] = createStylesContext('Footer') +const [StylesProvider] = createStylesContext('Footer') const Footer = ({...otherProps}) => { const styles = useMultiStyleConfig('Footer') const intl = useIntl() @@ -129,13 +125,13 @@ const Footer = ({...otherProps}) => { links={makeOurCompanyLinks()} /> - + - + {showLocaleSelector && ( @@ -202,56 +198,6 @@ const Footer = ({...otherProps}) => { export default Footer -const Subscribe = ({...otherProps}) => { - const styles = useStyles() - const intl = useIntl() - return ( - - - {intl.formatMessage({ - id: 'footer.subscribe.heading.first_to_know', - defaultMessage: 'Be the first to know' - })} - - - {intl.formatMessage({ - id: 'footer.subscribe.description.sign_up', - defaultMessage: 'Sign up to stay in the loop about the hottest deals' - })} - - - - - {/* Had to swap the following InputRightElement and Input - to avoid the hydration error due to mismatched html between server and client side. - This is a workaround for Lastpass plugin that automatically injects its icon for input fields. - */} - - - - - - - - - - ) -} - const LegalLinks = ({variant}) => { const intl = useIntl() return ( diff --git a/packages/template-retail-react-app/app/components/footer/index.test.js b/packages/template-retail-react-app/app/components/footer/index.test.js index 335656bcbf..8fc63383ec 100644 --- a/packages/template-retail-react-app/app/components/footer/index.test.js +++ b/packages/template-retail-react-app/app/components/footer/index.test.js @@ -12,7 +12,8 @@ import {renderWithProviders} from '@salesforce/retail-react-app/app/utils/test-u test('renders component', () => { renderWithProviders(