@evenicanpm/storefront-core 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/.storybook/main.ts +2 -3
  2. package/.storybook/preview.tsx +0 -2
  3. package/__mocks__/create-mutation.ts +16 -8
  4. package/__mocks__/next-image.tsx +12 -3
  5. package/package.json +2 -2
  6. package/src/api-manager/datasources/d365/d365-categories.datasource.ts +2 -3
  7. package/src/api-manager/datasources/d365/d365-order.datasource.ts +4 -4
  8. package/src/api-manager/datasources/d365/d365-organization.datasource.ts +15 -8
  9. package/src/api-manager/datasources/d365/d365-product.datasource.ts +31 -19
  10. package/src/api-manager/datasources/d365/d365-user.datasource.ts +47 -19
  11. package/src/api-manager/datasources/e4/address/e4-address.remaps.ts +2 -2
  12. package/src/api-manager/datasources/e4/cart/e4-cart.remaps.ts +1 -1
  13. package/src/api-manager/datasources/e4/product/e4-product.remaps.ts +3 -3
  14. package/src/api-manager/datasources/e4/user/e4-user.datasource.ts +4 -8
  15. package/src/api-manager/index.ts +10 -127
  16. package/src/api-manager/lib/get-graphql-client.ts +2 -2
  17. package/src/api-manager/schemas/product.schema.ts +9 -0
  18. package/src/api-manager/services/create-extension-query.ts +1 -1
  19. package/src/api-manager/services/create-query.ts +106 -266
  20. package/src/api-manager/types/Datasource.ts +0 -3
  21. package/src/cms/blocks/components/category-card.tsx +0 -1
  22. package/src/cms/blocks/components/category-carousel/index.tsx +0 -1
  23. package/src/cms/blocks/components/featured-categories.tsx +1 -2
  24. package/src/cms/blocks/components/featured-products.tsx +1 -2
  25. package/src/cms/blocks/components/footer/index.tsx +0 -1
  26. package/src/cms/blocks/components/footer/sections/footer-app-store.tsx +1 -1
  27. package/src/cms/blocks/components/footer/sections/footer-contact.tsx +0 -1
  28. package/src/cms/blocks/components/footer/sections/footer-links.tsx +1 -1
  29. package/src/cms/blocks/components/footer/sections/footer-logo.tsx +0 -1
  30. package/src/cms/blocks/components/footer/sections/footer-social-links.tsx +0 -1
  31. package/src/cms/blocks/components/footer.tsx +0 -1
  32. package/src/cms/blocks/components/hero-carousel/index.tsx +0 -1
  33. package/src/cms/blocks/components/hero.tsx +0 -1
  34. package/src/cms/blocks/components/image.tsx +0 -1
  35. package/src/cms/blocks/components/media-card.tsx +0 -1
  36. package/src/cms/blocks/components/product-carousel/index.tsx +0 -1
  37. package/src/cms/blocks/components/product-section-fullwidth/index.tsx +0 -1
  38. package/src/cms/blocks/components/rich-text.tsx +0 -1
  39. package/src/cms/blocks/components/seo/index.tsx +1 -2
  40. package/src/cms/blocks/components/services/index.tsx +0 -1
  41. package/src/cms/blocks/components/services/service-card.tsx +0 -1
  42. package/src/cms/blocks/components/shared/featured-product-card.tsx +0 -1
  43. package/src/cms/blocks/components/shared/product-category-item.tsx +1 -1
  44. package/src/cms/blocks/components/shared/product-grid.tsx +0 -1
  45. package/src/cms/blocks/components/shared/top-categories-card.tsx +0 -1
  46. package/src/cms/blocks/components/shared/top-rating-product-card.tsx +0 -1
  47. package/src/cms/blocks/icons/components/category.tsx +0 -1
  48. package/src/cms/blocks/icons/components/dotted-star.tsx +0 -1
  49. package/src/cms/blocks/icons/components/fallback-icon.tsx +0 -1
  50. package/src/cms/blocks/icons/components/gift-box.tsx +0 -1
  51. package/src/cms/blocks/icons/components/light.tsx +0 -1
  52. package/src/cms/blocks/icons/components/new-arrival.tsx +0 -1
  53. package/src/cms/blocks/icons/components/rank-badge.tsx +0 -1
  54. package/src/components/BazaarTextField.tsx +7 -7
  55. package/src/components/CountryInput.stories.tsx +4 -2
  56. package/src/components/T/__tests__/T.test.tsx +9 -13
  57. package/src/components/T/index.tsx +0 -1
  58. package/src/components/Typography.tsx +0 -1
  59. package/src/components/_tests_/site-logo.test.tsx +4 -5
  60. package/src/components/categories/category-list/category-list.tsx +1 -1
  61. package/src/components/countries-input.tsx +1 -1
  62. package/src/components/nav-link/nav-link.tsx +2 -8
  63. package/src/components/navbar/mega-menu/mega-menu.tsx +7 -1
  64. package/src/components/navbar/mobile-menu/mobile-menu.test.tsx +2 -1
  65. package/src/components/navbar/nav-list/cms-nav.test.tsx +0 -1
  66. package/src/components/no-records.tsx +0 -1
  67. package/src/components/product-cards/favorite-button.tsx +0 -1
  68. package/src/components/product-cards/product-card/styles/index.ts +2 -2
  69. package/src/components/product-cards/product-card-plp-list/ProductCardPlpList.stories.tsx +0 -1
  70. package/src/components/product-cards/product-card-search/ProductCardSearch.stories.tsx +0 -1
  71. package/src/components/product-dimensions/Dimensions.stories.tsx +2 -4
  72. package/src/components/product-dimensions/compound/dimensions-group-list-chip.tsx +1 -1
  73. package/src/components/product-dimensions/compound/dimensions.tsx +1 -1
  74. package/src/components/product-quantity-buttons/ProductQuantity.stories.tsx +1 -3
  75. package/src/components/product-quantity-buttons/__tests__/product-quantity-buttons.test.tsx +3 -2
  76. package/src/components/product-quantity-buttons/compound/quantity-buttons-root.tsx +1 -1
  77. package/src/components/product-quantity-buttons/index.tsx +0 -1
  78. package/src/components/product-quantity-variants/ProductQuantity.stories.tsx +1 -3
  79. package/src/components/products-view/ProductsGrid.stories.tsx +1 -3
  80. package/src/components/products-view/ProductsList.stories.tsx +1 -3
  81. package/src/components/products-view/__tests__/products-view.test.tsx +20 -9
  82. package/src/components/products-view/index.tsx +1 -1
  83. package/src/components/scrollbar/Scrollbar.stories.tsx +1 -3
  84. package/src/components/scrollbar/__tests__/scrollbar.test.tsx +7 -1
  85. package/src/components/search-bar/SearchBarLarge.stories.tsx +1 -3
  86. package/src/components/search-bar/compound/results-item-lg.tsx +0 -1
  87. package/src/components/search-bar/compound/results-item-sm.tsx +0 -1
  88. package/src/components/search-bar/compound/textfield-adornment-category.tsx +0 -1
  89. package/src/components/search-bar/compound/textfield.tsx +0 -1
  90. package/src/components/search-bar/utils/findCategoryNameById.ts +3 -3
  91. package/src/components/section/__tests__/section.test.tsx +14 -7
  92. package/src/components/side-nav/SideNav.stories.tsx +1 -1
  93. package/src/components/site-logo.tsx +8 -1
  94. package/src/components/wishlist-dialogs/add-to-wishlist/add-to-wishlist-button.tsx +1 -1
  95. package/src/components/wishlist-dialogs/add-to-wishlist/compound/add-to-wishlist-bottom-ui.tsx +1 -1
  96. package/src/components/wishlist-dialogs/add-to-wishlist/compound/wishlist-dialog-header.tsx +0 -1
  97. package/src/components/wishlist-dialogs/create-wishlist/compound/create-wishlist-bottom-ui.tsx +1 -1
  98. package/src/hooks/use-nextauth-session.ts +4 -1
  99. package/src/lib/lib.ts +1 -1
  100. package/src/lib/page-slugs.ts +0 -2
  101. package/src/lib/refiner-utils.ts +3 -3
  102. package/src/pages/account/account-navigation.tsx +17 -20
  103. package/src/pages/account/addresses/address-form.tsx +15 -7
  104. package/src/pages/account/dashboard-header.tsx +1 -1
  105. package/src/pages/cart/checkout-form.tsx +1 -1
  106. package/src/pages/checkout/checkout-alt-form/checkout-step.tsx +1 -1
  107. package/src/pages/checkout/checkout-alt-form/steps/address/new-address-form.tsx +1 -1
  108. package/src/pages/checkout/checkout-alt-form/steps/payment/payment-details.tsx +5 -2
  109. package/src/pages/checkout/checkout-alt-summary/cart-item.tsx +1 -1
  110. package/src/pages/checkout/checkout-alt-summary/checkout-alt-summary.tsx +1 -1
  111. package/src/pages/confirmation/address.tsx +1 -1
  112. package/src/pages/confirmation/confirmation-summary.tsx +1 -1
  113. package/src/pages/product-details/currency.ts +1 -1
  114. package/src/pages/product-details/product-intro/compound/thumbnail-with-skeleton.tsx +4 -1
  115. package/src/pages/product-list/breadcrumbs.tsx +0 -1
  116. package/src/pages/product-list/list-filter.tsx +0 -1
  117. package/src/pages/product-list/product-list-context.tsx +1 -1
  118. package/src/pages/product-list/product-list-view.tsx +2 -2
  119. package/src/pages/product-list/selected-facets.tsx +1 -1
  120. package/src/pages/quickorder/context.ts +8 -2
  121. package/src/pages/quickorder/order-upload.tsx +26 -8
  122. package/src/pages/quickorder/provider.tsx +8 -9
  123. package/src/pages/quickorder/quick-order.tsx +17 -11
  124. package/src/providers/nav-provider/index.tsx +1 -1
  125. package/tsconfig.json +0 -1
@@ -1,140 +1,23 @@
1
- // @ts-expect-error - this will be picked up by webpack
2
- import rawConfig from "@api-manager-config";
3
1
  import { D365Datasource } from "@evenicanpm/storefront-core/src/api-manager/datasources/d365/index";
4
2
  import { getContextFromCookie } from "@evenicanpm/storefront-core/src/api-manager/datasources/d365/utils/get-context-cookie";
5
3
  import E4Datasource from "@evenicanpm/storefront-core/src/api-manager/datasources/e4/e4.datasource";
6
- import type {
7
- Datasource,
8
- DatasourceApis,
9
- } from "@evenicanpm/storefront-core/src/api-manager/types/Datasource";
10
- import { z } from "zod";
11
-
12
- type EntityNames = keyof DatasourceApis;
13
-
14
- interface Config {
15
- primary: string;
16
- secondary: string[];
17
- overrides?: Partial<Record<EntityNames, string>>;
18
- }
19
-
20
- const Config = z.object({
21
- primary: z.string(),
22
- secondary: z.array(z.string()),
23
- overrides: z.record(z.string(), z.string()),
24
- });
25
-
26
- const OVERRIDES_FILE = "./overrides.datasource.ts";
27
- const CORE_DATASOURCES = [
28
- "user",
29
- "session",
30
- "cart",
31
- "product",
32
- "categories",
33
- "order",
34
- "address",
35
- "organization",
36
- ];
4
+ import type { Datasource } from "@evenicanpm/storefront-core/src/api-manager/types/Datasource";
37
5
 
38
6
  /**
39
- * Loads up all specified apis/datasources based on parsed config
40
- * and returns the ApiManager object. Also handles "project" overrides
41
- * by checking inside a specific folder for API's. Will grab default export
42
- * from specified folder, validate the API schema and then prioritize returning
43
- * that from api-manager.
7
+ * Returns the active commerce datasource based on the API_MANAGER_PRIMARY
8
+ * environment variable. Defaults to "e4" when the variable is not set.
44
9
  *
45
- * In this way, we have two levels of configuration of the api-manager.
46
- * 1. actual JSON config
47
- * 2. overrides from storefront
48
- *
49
- * @returns ApiManager object.
10
+ * Supported values: "e4" | "d365"
50
11
  */
51
- async function createApiManager() {
52
- try {
53
- Config.parse(rawConfig);
54
- } catch (error) {
55
- if (error instanceof z.ZodError) {
56
- console.error("Config validation failed:", error.issues);
57
- }
58
- }
12
+ async function createApiManager(): Promise<Datasource> {
13
+ const primary = process.env.API_MANAGER_PRIMARY ?? "e4";
59
14
 
60
- const storefrontOverrides: { [key: string]: any } = {};
61
-
62
- // @ts-expect-error
63
- function importAll(r) {
64
- r.keys().forEach((key: keyof typeof storefrontOverrides) => {
65
- storefrontOverrides[key] = r(key);
66
- });
15
+ if (primary === "d365") {
16
+ const context = await getContextFromCookie();
17
+ return new D365Datasource("d365", context);
67
18
  }
68
19
 
69
- // Arguments must be literals not variables.
70
- importAll(
71
- // @ts-expect-error
72
- require.context(
73
- "@storefront/extensions/api-manager-overrides",
74
- true,
75
- /overrides\.datasource\.ts/,
76
- ),
77
- );
78
-
79
- const context = await getContextFromCookie();
80
- const datasourceMap: Record<string, Datasource> = {
81
- d365: new D365Datasource("d365", context),
82
- e4: E4Datasource,
83
- // Add more datasources here as needed
84
- };
85
-
86
- const config = rawConfig as Config;
87
- const primaryDatasource = datasourceMap[config.primary];
88
- const secondaryDatasources = config.secondary.map(
89
- (name) => datasourceMap[name],
90
- );
91
-
92
- const getDatasource = (entityName: EntityNames) => {
93
- // Project-Level Overrides
94
- let projectOverrides: { [key: string]: any } = {};
95
- if (storefrontOverrides?.[OVERRIDES_FILE]?.["default"]?.[entityName]) {
96
- projectOverrides =
97
- storefrontOverrides[OVERRIDES_FILE]["default"][entityName];
98
- }
99
-
100
- // Config-Level Overrides
101
- const override = config?.overrides?.[entityName];
102
-
103
- // Build the base entity from api-manager config settings
104
- let baseEntity: { [key: string]: any } = {};
105
- if (override) {
106
- baseEntity =
107
- secondaryDatasources.find((ds) => ds.name === override) ??
108
- primaryDatasource;
109
- } else {
110
- baseEntity = primaryDatasource;
111
- }
112
-
113
- // Attach Project Overrides to Entity
114
- for (const key in projectOverrides) {
115
- baseEntity[entityName][key] = projectOverrides[key];
116
- }
117
-
118
- return baseEntity;
119
- };
120
-
121
- const datasources: { [key: string]: any } = {};
122
-
123
- CORE_DATASOURCES.forEach((entityName) => {
124
- const datasource = getDatasource(entityName as EntityNames);
125
- datasources[entityName] = datasource[entityName as keyof Datasource];
126
- });
127
-
128
- storefrontOverrides[OVERRIDES_FILE]?.default &&
129
- Object.keys(storefrontOverrides[OVERRIDES_FILE]["default"]).forEach(
130
- (key) => {
131
- if (!CORE_DATASOURCES.includes(key)) {
132
- datasources[key as keyof Datasource] =
133
- storefrontOverrides[OVERRIDES_FILE]["default"][key];
134
- }
135
- },
136
- );
137
- return datasources;
20
+ return E4Datasource;
138
21
  }
139
22
 
140
23
  export default createApiManager;
@@ -24,13 +24,13 @@ const getGraphQLClient = async (withAuth = false) => {
24
24
  // This session cookie solution only works when all api calls are through server actions.
25
25
  // If not we need to route all e4 client api calls through an custom api route.
26
26
  if (sessionCookie) {
27
- headers["Cookie"] = `storefront=${sessionCookie.value}`;
27
+ headers.Cookie = `storefront=${sessionCookie.value}`;
28
28
  }
29
29
 
30
30
  if (withAuth) {
31
31
  const session = await getServerSession(authOptions());
32
32
  if (session?.accessToken) {
33
- headers["Authorization"] = `Bearer ${session.accessToken}`;
33
+ headers.Authorization = `Bearer ${session.accessToken}`;
34
34
  }
35
35
  }
36
36
 
@@ -43,6 +43,7 @@ export const productSchema = z.object({
43
43
  Name: z.string(),
44
44
  Price: z.number().optional(),
45
45
  BasePrice: z.number().optional(),
46
+ AdjustedPrice: z.number().optional(),
46
47
  Description: z.string().optional(),
47
48
  PrimaryImageUrl: z.string().or(z.null()).optional(), // adding a null for nextjs/image component
48
49
  Variants: z.array(z.any()).optional(),
@@ -52,6 +53,14 @@ export const productSchema = z.object({
52
53
  Reviews: z.array(reviewSchema).optional(),
53
54
  categoryFilter: z.string().optional(),
54
55
  ExtensionProperties: z.array(commercePropertySchema).optional(),
56
+ /** From merge(productData, productDetailsMap); must be kept through .parse() or Zod strips unknown keys. */
57
+ MasterProductId: z.union([z.number(), z.string()]).optional(),
58
+ Dimensions: z.array(z.any()).default([]),
59
+ DimensionValues: z
60
+ .array(z.array(z.custom<ProductDimensionValueInventoryAvailability>()))
61
+ .default([]),
62
+ Images: z.array(z.string()).optional(),
63
+ Attributes: z.array(z.custom<AttributeValue>()).default([]),
55
64
  });
56
65
 
57
66
  export const productWCategorySchema = productSchema.extend({
@@ -15,7 +15,7 @@ import type { QueryFunction } from "@tanstack/react-query";
15
15
  * @param queryFn
16
16
  * @returns
17
17
  */
18
- const buildQueryData = <TQueryFnData = unknown, TQueryFnInput = unknown>(
18
+ const buildQueryData = <TQueryFnData = unknown, _TQueryFnInput = unknown>(
19
19
  queryKey: string,
20
20
  queryFn: QueryFunction<TQueryFnData>,
21
21
  ) => {
@@ -1,5 +1,3 @@
1
- /* eslint-disable @typescript-eslint/no-explicit-any */
2
- // TODO: use api manager types to remove type any
3
1
  import createApiManager from "@evenicanpm/storefront-core/src/api-manager/index";
4
2
  import type { DatasourceApis } from "@evenicanpm/storefront-core/src/api-manager/types/Datasource";
5
3
  import { isServer, type QueryClient } from "@tanstack/query-core";
@@ -18,277 +16,130 @@ import signOut from "@/auth/signout";
18
16
  const QUERY_ROUTE = "/api";
19
17
  const mutex = new Mutex();
20
18
 
21
- /**
22
- * Creates a react-query query with all data access methods exported.
23
- * Handles switching between direct api-manager calls and accessing api manager through
24
- * route handler. If no query key provided, use api manager path as cache key prefix.
25
- * @param queryKey a prefix to add to cache key
26
- * @param route the route to the corresponding route handler
27
- * @param apiManagerPath a tuple of two items, entity name and method
28
- * @param options.disableProperty if these input properties are undefined then disable query
29
- * @param options.enabled raw enabled for certain use cases
30
- * @returns
31
- */
32
- export const createQuery = <TInput, TOutput>(
33
- apiManagerPath: ApiPath<keyof DatasourceApis>,
34
- config?: {
35
- queryKey?: string;
36
- disableProperties?: Array<keyof TInput>;
37
- },
38
- ) => {
39
- /**
40
- * Generates the configuration object for a query, including the query function, cache key, and enabled state.
41
- *
42
- * The query function (`queryFn`) is determined based on the execution environment (server or client):
43
- * - On the server, it checks for token expiry, refreshes the token if needed, creates an API manager, and invokes the appropriate API function.
44
- * - On the client, it checks for token expiry and either refreshes the token or performs a fetch operation.
45
- *
46
- * The `enabled` property is computed based on the presence and truthiness of specified properties in the input, if `disableProperties` are provided in options.
47
- *
48
- * @param input - Optional input parameters for the query and API function.
49
- * @returns An object containing:
50
- * - `queryKey`: The cache key for the query, built from the input.
51
- * - `queryFn`: The asynchronous function to execute for the query.
52
- * - `enabled`: A boolean indicating whether the query should be enabled.
53
- */
54
- const getQueryData = (input?: TInput) => {
55
- const queryFn = isServer
56
- ? /// Server ///
57
- async () => {
58
- const apiManager = await createApiManager();
59
- const apiFunction = getApiFromPath(apiManager, apiManagerPath);
60
- const result = await apiFunction(input);
61
- return result;
62
- }
63
- : /// Client ///
64
- async () => {
65
- // Check if token is expired
66
- const shouldRefreshToken = await checkTokenExpiry();
67
- if (shouldRefreshToken) {
68
- return await handleTokenRefreshClient(input);
69
- } else {
70
- return await doFetch(input, apiManagerPath);
71
- }
72
- };
73
-
74
- // if disable props specified then use them to compute enabled
75
- const enabled: boolean = config?.disableProperties
76
- ? config.disableProperties.every((prop) => !!input?.[prop])
77
- : true;
78
-
79
- const data = {
80
- queryKey: buildCacheKey(input),
81
- queryFn,
82
- enabled,
83
- };
84
- return data;
85
- };
86
-
87
- /**
88
- * Sends a POST request to the specified route with the provided input and API manager path.
89
- *
90
- * @template TInput - The type of the input parameter.
91
- * @param input - The input data to be sent in the request body. Can be undefined.
92
- * @param route - The endpoint URL to which the request is sent.
93
- * @param apiManagerPath - The API manager path used to identify the API endpoint.
94
- * @returns A promise that resolves to the response of the fetch request.
95
- */
96
- const doFetch = async (
97
- input: TInput | undefined,
98
- apiManagerPath: ApiPath<keyof DatasourceApis>,
99
- additionalHeaders?: HeadersInit,
100
- ) => {
101
- // Merge headers
102
- const headers = new Headers(additionalHeaders);
103
- // generic route handler (new) - will refactor all queries to use this
104
- const result = await fetch(QUERY_ROUTE, {
105
- method: "POST",
106
- body: JSON.stringify({ input, apiManagerPath }),
107
- headers,
108
- });
109
-
110
- if (result.status === 401) {
111
- // If unauthenticated , sign out the user
112
- signOut();
113
- }
114
- return await result.json();
115
- };
19
+ export type ApiPath<T extends keyof DatasourceApis = keyof DatasourceApis> = [
20
+ T,
21
+ keyof DatasourceApis[T],
22
+ ];
116
23
 
117
- /**
118
- * Client-side hook to call data from react query cache
119
- * @returns
120
- */
121
- const useData = (
122
- input?: TInput,
123
- options?: { enabled: boolean },
124
- ): UseQueryResult<TOutput> => {
125
- return useQuery({ ...getQueryData(input), enabled: options?.enabled });
126
- };
24
+ export type Query<TInput, TOutput> = ReturnType<
25
+ typeof createQuery<TInput, TOutput>
26
+ >;
127
27
 
128
- /**
129
- * Client-side hook to call data from react query cache,
130
- * used with suspense for suspense loading state handling
131
- * @returns
132
- */
133
- const useSuspenseData = (input?: TInput): UseSuspenseQueryResult<TOutput> => {
134
- return useSuspenseQuery(getQueryData(input));
135
- };
28
+ // --- Shared helpers (no closure dependencies) ---
136
29
 
137
- /**
138
- * Called on server to fetch query and load cache
139
- * @param queryClient
140
- * @returns
141
- */
142
- const fetchData = async (queryClient: QueryClient, input?: TInput) => {
143
- return await queryClient.fetchQuery(getQueryData(input));
144
- };
30
+ const checkTokenExpiry = async (): Promise<boolean> => {
31
+ let token: string | undefined;
32
+ if (isServer) {
33
+ const session = (await manuallyRetrieveSessionCookie()) ?? undefined;
34
+ token = session?.accessToken;
35
+ } else {
36
+ const session = await getSession();
37
+ token = session?.accessToken;
38
+ }
39
+ const decoded = token && (jwt.decode(token) as { exp?: number } | null);
40
+ const currentTime = Math.floor(Date.now() / 1000);
41
+ return (
42
+ !!decoded && typeof decoded.exp === "number" && decoded.exp < currentTime
43
+ );
44
+ };
145
45
 
146
- /**
147
- * Called on server to fetch query and load cache in anticipation
148
- * of client using data
149
- * @param queryClient
150
- */
151
- const prefetchData = async (queryClient: QueryClient, input?: TInput) => {
152
- return await queryClient.prefetchQuery(getQueryData(input));
153
- };
46
+ const doFetch = async <TInput>(
47
+ input: TInput | undefined,
48
+ apiManagerPath: ApiPath,
49
+ additionalHeaders?: HeadersInit,
50
+ ) => {
51
+ const headers = new Headers(additionalHeaders);
52
+ const result = await fetch(QUERY_ROUTE, {
53
+ method: "POST",
54
+ body: JSON.stringify({ input, apiManagerPath }),
55
+ headers,
56
+ });
57
+ if (result.status === 401) {
58
+ signOut();
59
+ }
60
+ return result.json();
61
+ };
154
62
 
155
- /**
156
- * Wrapper on getQueryData
157
- * TODO: need to fix this naming so we're properly shadowing
158
- * the names of tanstack-query
159
- */
160
- const getData = async (
161
- queryClient: QueryClient,
162
- ): Promise<TOutput | undefined> => {
163
- const cacheKey = buildCacheKey();
164
- return await queryClient.getQueryData(cacheKey);
165
- };
63
+ // Acquires a mutex before fetching so concurrent requests don't each trigger
64
+ // a token refresh simultaneously (double-check pattern after acquiring lock).
65
+ const withTokenRefresh = async <TInput>(
66
+ input: TInput | undefined,
67
+ apiManagerPath: ApiPath,
68
+ ) => {
69
+ const release = await mutex.acquire();
70
+ try {
71
+ const headers = new Headers();
72
+ if (await checkTokenExpiry()) {
73
+ headers.append("should-refresh-token", "true");
74
+ }
75
+ return await doFetch(input, apiManagerPath, headers);
76
+ } catch (error) {
77
+ console.error("Error fetching data:", error);
78
+ throw new Error("Failed to fetch data");
79
+ } finally {
80
+ release();
81
+ }
82
+ };
166
83
 
167
- /**
168
- *
169
- * @param queryClient react-query client
170
- * @param data the data to set the query to
171
- * @param input the input of the query that's used to generate cache key
172
- * returns a promise of QueryResult for query
173
- */
174
- const setQueryData = async (
175
- queryClient: QueryClient,
176
- data: TOutput,
177
- input?: TInput,
178
- ) => {
179
- const key = buildCacheKey(input);
180
- return await queryClient.setQueryData(key, data);
181
- };
84
+ // --- Factory ---
182
85
 
183
- /**
184
- *
185
- * @param input the input for the query of TInput
186
- * @returns if no input, the query key, if input, query key and input in array
187
- */
86
+ export const createQuery = <TInput, TOutput>(
87
+ apiManagerPath: ApiPath<keyof DatasourceApis>,
88
+ config?: {
89
+ queryKey?: string;
90
+ disableProperties?: Array<keyof TInput>;
91
+ },
92
+ ) => {
188
93
  const buildCacheKey = (input?: TInput) => {
189
94
  const prefix = config?.queryKey ?? apiManagerPath;
190
95
  return input ? [prefix, input] : [prefix];
191
96
  };
192
97
 
193
- /**
194
- * Asynchronously checks whether the current session's access token has expired.
195
- *
196
- * Retrieves the current session and decodes the access token to extract its expiration time.
197
- * Compares the token's expiration (`exp`) claim with the current time.
198
- *
199
- * @returns A promise that resolves to `true` if the token is expired, or `false` otherwise.
200
- */
201
- const checkTokenExpiry = async () => {
202
- let token: string | undefined;
203
- if (isServer) {
204
- // Server-side: retrieve session from cookies
205
- const session = (await manuallyRetrieveSessionCookie()) ?? undefined;
206
- token = session?.accessToken;
207
- } else {
208
- const session = await getSession();
209
- token = session?.accessToken;
210
- }
211
-
212
- const decoded = token && (jwt.decode(token) as { exp?: number } | null);
213
-
214
- const currentTime = Math.floor(Date.now() / 1000);
215
-
216
- // Use this for testing - expires after 60 seconds (1 min) from issued-at time
217
- // const isExpired = !!decoded && typeof decoded.iat === "number" && currentTime > decoded.iat + 60;
218
-
219
- const isExpired =
220
- !!decoded && typeof decoded.exp === "number" && decoded.exp < currentTime;
221
-
222
- return isExpired;
223
- };
224
-
225
- /**
226
- * Handles fetching data with token refresh logic in a thread-safe manner.
227
- *
228
- * Acquires a mutex lock to ensure only one token refresh operation occurs at a time.
229
- * Checks if the token is expired and, if so, adds a header to indicate that the token should be refreshed.
230
- * Performs the fetch operation with the appropriate headers and returns the parsed JSON response.
231
- * Releases the mutex lock after the operation completes, regardless of success or failure.
232
- *
233
- * @param input - The input data for the fetch operation, which may be undefined.
234
- * @returns A promise that resolves to the parsed JSON response from the fetch operation.
235
- * @throws An error if the fetch operation fails.
236
- */
237
- const handleTokenRefreshClient = async (input: TInput | undefined) => {
238
- const release = await mutex.acquire();
239
- try {
240
- // Check if token is expired again
241
- const stillShouldRefreshToken = await checkTokenExpiry();
242
- const headers = new Headers();
243
- if (stillShouldRefreshToken) {
244
- // Add refresh token header
245
- headers.append("should-refresh-token", "true");
246
- }
247
- // Call do fetch with the headers
248
- return await doFetch(input, apiManagerPath, headers);
249
- } catch (error) {
250
- // Handle API Error
251
- console.error("Error fetching data:", error);
252
- throw new Error("Failed to fetch data");
253
- } finally {
254
- release();
255
- }
256
- };
98
+ const getQueryOptions = (input?: TInput) => ({
99
+ queryKey: buildCacheKey(input),
100
+ queryFn: isServer
101
+ ? async () => {
102
+ const apiManager = await createApiManager();
103
+ return getApiFromPath(apiManager, apiManagerPath)(input);
104
+ }
105
+ : async () => {
106
+ const isExpired = await checkTokenExpiry();
107
+ return isExpired
108
+ ? withTokenRefresh(input, apiManagerPath)
109
+ : doFetch(input, apiManagerPath);
110
+ },
111
+ enabled: config?.disableProperties
112
+ ? config.disableProperties.every((prop) => !!input?.[prop])
113
+ : true,
114
+ });
257
115
 
258
116
  return {
259
- useData,
260
- useSuspenseData,
261
- setQueryData,
262
- fetchData,
263
- prefetchData,
117
+ useData: (
118
+ input?: TInput,
119
+ options?: { enabled: boolean },
120
+ ): UseQueryResult<TOutput> =>
121
+ useQuery({ ...getQueryOptions(input), enabled: options?.enabled }),
122
+ useSuspenseData: (input?: TInput): UseSuspenseQueryResult<TOutput> =>
123
+ useSuspenseQuery(getQueryOptions(input)),
124
+ fetchData: (queryClient: QueryClient, input?: TInput) =>
125
+ queryClient.fetchQuery(getQueryOptions(input)),
126
+ prefetchData: (queryClient: QueryClient, input?: TInput) =>
127
+ queryClient.prefetchQuery(getQueryOptions(input)),
128
+ getData: (queryClient: QueryClient): TOutput | undefined =>
129
+ queryClient.getQueryData(buildCacheKey()),
130
+ setQueryData: (queryClient: QueryClient, data: TOutput, input?: TInput) =>
131
+ queryClient.setQueryData(buildCacheKey(input), data),
264
132
  buildCacheKey,
265
- getData,
266
133
  };
267
134
  };
268
135
 
269
- /**
270
- * A tuple type where api manager entity is first item and second item is method
271
- * of first item
272
- */
273
- export type ApiPath<T extends keyof DatasourceApis = keyof DatasourceApis> = [
274
- T,
275
- keyof DatasourceApis[T],
276
- ];
136
+ // --- Utilities ---
277
137
 
278
- /// Utilities ///
279
- /**
280
- * Takes an apiManager and reaches into nested sub api's based on array path
281
- * @param apiManager
282
- * @param path array of path steps
283
- * @returns the function at the specified path
284
- */
285
138
  export function getApiFromPath<TInput, TOutput>(
286
- apiManager: any,
139
+ apiManager: Awaited<ReturnType<typeof createApiManager>>,
287
140
  path: ApiPath,
288
141
  ): (input?: TInput) => Promise<TOutput> {
289
142
  const [entity, method] = path;
290
- // Typescript is doing heavy lifting to ensure correct api path
291
- // so safe to call method directly
292
143
  const result = apiManager[entity][method];
293
144
 
294
145
  if (process.env.NODE_ENV === "development") {
@@ -300,30 +151,19 @@ export function getApiFromPath<TInput, TOutput>(
300
151
  });
301
152
  }
302
153
 
303
- // Function reference loses 'this' context.
304
- // Re-bind for d365 classes.
154
+ // Function reference loses 'this' context — re-bind for d365 classes.
305
155
  if (typeof result === "function") {
306
- return result.bind(apiManager[entity]);
156
+ return (result as (input?: TInput) => Promise<TOutput>).bind(
157
+ apiManager[entity],
158
+ );
307
159
  }
308
160
  return result;
309
161
  }
310
162
 
311
- /**
312
- *
313
- * @param key
314
- * @param method
315
- * @returns
316
- */
317
163
  export function createApiPath<T extends keyof DatasourceApis>(
318
164
  key: T,
319
165
  method: keyof DatasourceApis[T],
320
166
  ) {
321
- // have to re-cast to general keyof DatasourceApi after
322
- // using stricter type for type narrowing and intellisense
323
- console.log([key, method]);
167
+ // re-cast to general ApiPath after using stricter type for intellisense
324
168
  return [key, method] as ApiPath<keyof DatasourceApis>;
325
169
  }
326
-
327
- export type Query<TInput, TOutput> = ReturnType<
328
- typeof createQuery<TInput, TOutput>
329
- >;
@@ -111,9 +111,6 @@ export interface UserApi {
111
111
  copyCartToWishlist(
112
112
  input: userSchemas.CopyCartToWishlistInput,
113
113
  ): Promise<userSchemas.Wishlist>;
114
- updateUser(user: userSchemas.User): Promise<userSchemas.User>;
115
- getUser(): Promise<userSchemas.User | null>;
116
- getUserCounts(userId: string): Promise<userSchemas.UserCounts>;
117
114
  }
118
115
 
119
116
  /**
@@ -2,7 +2,6 @@ import Box from "@mui/material/Box";
2
2
  import Button from "@mui/material/Button";
3
3
  import Card from "@mui/material/Card";
4
4
  import Image from "next/image";
5
- import React from "react";
6
5
 
7
6
  import cmsEndPoints from "@/cms/endpoints";
8
7
 
@@ -5,7 +5,6 @@ import BazaarCard from "@evenicanpm/storefront-core/src/components/BazaarCard";
5
5
  import { Carousel } from "@evenicanpm/storefront-core/src/components/carousel/index";
6
6
  import { Section } from "@evenicanpm/storefront-core/src/components/section";
7
7
  import NextLink from "next/link";
8
- import React from "react";
9
8
  // LOCAL CUSTOM COMPONENT
10
9
  import TopCategoriesCard from "@/cms/blocks/components/shared/top-categories-card";
11
10
  // CUSTOM ICON COMPONENT
@@ -1,6 +1,5 @@
1
1
  import { Box, Container } from "@mui/material";
2
2
  import Grid from "@mui/material/Grid2";
3
- import React from "react";
4
3
 
5
4
  // Local components
6
5
  import CategoryCard from "@/cms/blocks/components/category-card";
@@ -26,7 +25,7 @@ const FeaturedCategories = (categoriesData: FeaturedCategoriesBlock) => {
26
25
  size={3}
27
26
  display="flex"
28
27
  justifyContent="center"
29
- key={"category_index_" + i}
28
+ key={`category_index_${i}`}
30
29
  >
31
30
  <CategoryCard
32
31
  name={category.name}