@evenicanpm/storefront-core 2.3.1 → 2.4.1

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 (90) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/package.json +42 -9
  3. package/src/api-manager/datasources/d365/d365-address.datasource.ts +148 -61
  4. package/src/api-manager/datasources/d365/d365-cart.datasource.ts +23 -1
  5. package/src/api-manager/datasources/d365/d365-order.datasource.ts +6 -1
  6. package/src/api-manager/datasources/d365/d365-user.datasource.ts +109 -37
  7. package/src/api-manager/datasources/d365/utils/get-context-cookie.ts +44 -10
  8. package/src/api-manager/index.ts +2 -1
  9. package/src/api-manager/lib/get-graphql-client.ts +35 -7
  10. package/src/api-manager/schemas/product.schema.ts +1 -1
  11. package/src/api-manager/services/create-query.ts +36 -3
  12. package/src/api-manager/services/get-query-client.ts +10 -0
  13. package/src/auth/better-auth.ts +282 -15
  14. package/src/cms/blocks/block-manager.tsx +1 -1
  15. package/src/cms/blocks/components/product-section-fullwidth/index.tsx +3 -1
  16. package/src/cms/endpoints.ts +7 -2
  17. package/src/components/BazaarMenu.tsx +1 -1
  18. package/src/components/Typography.tsx +4 -2
  19. package/src/components/carousel-cards/carousel-card-1/CarouselCard1.stories.tsx +1 -1
  20. package/src/components/categories/category-list/category-list.tsx +9 -5
  21. package/src/components/categories/category-menu.tsx +1 -1
  22. package/src/components/countries-input.tsx +4 -0
  23. package/src/components/header/__tests__/user.test.tsx +5 -107
  24. package/src/components/header/components/user.tsx +72 -45
  25. package/src/components/header/sticky-header.tsx +0 -1
  26. package/src/components/navbar/mobile-menu/mobile-menu.test.tsx +1 -1
  27. package/src/components/navbar/utils/transform-nav.test.ts +1 -1
  28. package/src/components/navbar/utils/transform-nav.ts +1 -1
  29. package/src/components/product-cards/product-card/product-card.tsx +5 -2
  30. package/src/components/product-cards/product-card/styles/index.ts +1 -5
  31. package/src/components/products-view/compound/products-grid-view.tsx +5 -1
  32. package/src/components/products-view/compound/products-list-view.tsx +5 -1
  33. package/src/global.d.ts +3 -0
  34. package/src/hooks/use-nextauth-session.ts +0 -33
  35. package/src/lib/auth-client.ts +14 -0
  36. package/src/lib/auth.ts +4 -0
  37. package/src/lib/entra-native-auth.ts +138 -0
  38. package/src/lib/native-session.ts +434 -0
  39. package/src/pages/account/addresses/address-form.tsx +20 -2
  40. package/src/pages/account/profile/__tests__/profile-form.test.tsx +173 -0
  41. package/src/pages/account/profile/profile-form.tsx +285 -0
  42. package/src/pages/account/profile/profile-validation.ts +52 -0
  43. package/src/pages/account/wishlist/wishlist-item.tsx +1 -2
  44. package/src/pages/auth/auth-activate-page.tsx +509 -0
  45. package/src/pages/auth/auth-layout.tsx +73 -0
  46. package/src/pages/auth/auth-login-page.tsx +241 -0
  47. package/src/pages/auth/auth-reset-password-page.tsx +487 -0
  48. package/src/pages/auth/compound/auth-form.tsx +182 -0
  49. package/src/pages/auth/compound/auth-pages.tsx +21 -0
  50. package/src/pages/auth/lib/friendly-error.ts +70 -0
  51. package/src/pages/auth/lib/index.ts +2 -0
  52. package/src/pages/auth/lib/types.ts +5 -0
  53. package/src/pages/cart/cart-item.tsx +1 -7
  54. package/src/pages/checkout/checkout-alt-form/checkout-form.tsx +14 -3
  55. package/src/pages/checkout/checkout-alt-form/steps/address/address-card.tsx +5 -3
  56. package/src/pages/checkout/checkout-alt-form/steps/address/delivery-address.tsx +20 -6
  57. package/src/pages/checkout/checkout-alt-form/steps/address/new-address-form.tsx +1 -1
  58. package/src/pages/checkout/checkout-alt-form/steps/customer-info/customer-information.tsx +1 -1
  59. package/src/pages/confirmation/ordered-products.tsx +3 -1
  60. package/src/pages/product-details/bopis/find-in-store-modal.tsx +4 -4
  61. package/src/pages/product-details/bopis/pickup-option-select.tsx +2 -2
  62. package/src/pages/product-details/bopis/search-header.tsx +2 -2
  63. package/src/pages/product-details/product-intro/compound/context.ts +8 -3
  64. package/src/pages/product-details/product-intro/compound/product-info.tsx +25 -19
  65. package/src/pages/product-list/product-list-view.tsx +2 -1
  66. package/src/providers/nav-provider/index.tsx +1 -1
  67. package/src/providers/nav-provider/utils/createLink.ts +1 -1
  68. package/tsconfig.json +20 -15
  69. package/__mocks__/countries.ts +0 -11
  70. package/__mocks__/create-mutation.ts +0 -68
  71. package/__mocks__/create-query.ts +0 -94
  72. package/__mocks__/data/categories.json +0 -795
  73. package/__mocks__/get-product-by-id.ts +0 -6
  74. package/__mocks__/next-auth-react.ts +0 -9
  75. package/__mocks__/next-font.js +0 -4
  76. package/__mocks__/next-headers.js +0 -13
  77. package/__mocks__/next-image.tsx +0 -18
  78. package/__mocks__/next-link.js +0 -19
  79. package/__mocks__/next-navigation.ts +0 -29
  80. package/__mocks__/product-dimensions.ts +0 -635
  81. package/__mocks__/products.ts +0 -533
  82. package/__mocks__/query-client.ts +0 -3
  83. package/__mocks__/wishlists.json +0 -408
  84. package/src/auth/msal.ts +0 -68
  85. package/src/cms/blog.ts +0 -229
  86. package/src/cms/interfaces/navigation.ts +0 -52
  87. package/src/cms/interfaces/product-data.ts +0 -83
  88. package/src/cms/pages.ts +0 -149
  89. package/src/pages/account/profile/profile-button.test.tsx +0 -59
  90. package/src/pages/account/profile/profile-button.tsx +0 -51
@@ -46,12 +46,30 @@ import type {
46
46
  type IdTokenClaims = jwt.JwtPayload & {
47
47
  emails?: string[];
48
48
  email?: string;
49
+ preferred_username?: string;
50
+ upn?: string;
51
+ unique_name?: string;
49
52
  given_name?: string;
50
53
  family_name?: string;
51
54
  };
52
55
 
56
+ function extractEmailFromClaims(token: IdTokenClaims): string | undefined {
57
+ return [
58
+ Array.isArray(token.emails) ? token.emails[0] : undefined,
59
+ token.email,
60
+ token.preferred_username,
61
+ token.upn,
62
+ token.unique_name,
63
+ ]
64
+ .map((v) => (typeof v === "string" ? v.trim() : ""))
65
+ .find((v) => v.includes("@"));
66
+ }
67
+
53
68
  function decodeIdTokenClaims(token: string): IdTokenClaims {
54
69
  const decoded = jwt.decode(token);
70
+ if (!decoded || typeof decoded !== "object") {
71
+ return {} as IdTokenClaims;
72
+ }
55
73
  return decoded as IdTokenClaims;
56
74
  }
57
75
 
@@ -63,39 +81,89 @@ class D365UserDatasource extends D365DatasourceBase implements UserApi {
63
81
  }
64
82
 
65
83
  async updateUser(user: User): Promise<User> {
66
- const context = this.context;
67
- const existing: Customer = await readAsync(
68
- this.getRetailContext(context),
69
- "",
70
- );
71
- existing.Addresses ??= [];
72
- existing.Addresses = existing.Addresses.map((addr) => ({
73
- ...addr,
74
- IsPrimary: addr.RecordId?.toString() === user.DefaultShippingAddressId,
75
- }));
76
- const propKey = "DefaultShippingAddressId";
77
- existing.ExtensionProperties = existing.ExtensionProperties ?? [];
78
- existing.ExtensionProperties = existing.ExtensionProperties.filter(
79
- (p: CommerceProperty) => p.Key !== propKey,
80
- );
81
- existing.ExtensionProperties.push({
82
- Key: propKey,
83
- Value: { StringValue: user.DefaultShippingAddressId } as NonNullable<
84
- CommerceProperty["Value"]
85
- >,
84
+ return tracer.startActiveSpan("d365.updateUser", async (span) => {
85
+ try {
86
+ const context = this.context;
87
+
88
+ let existing: Customer;
89
+ try {
90
+ existing = await readAsync(this.getRetailContext(context), "");
91
+ } catch {
92
+ const token = decodeIdTokenClaims(
93
+ context.requestContext.user.token || "",
94
+ );
95
+ const customerEmail = user.Email || extractEmailFromClaims(token);
96
+
97
+ if (!customerEmail) {
98
+ throw new Error(
99
+ "Cannot create D365 customer: no email provided in payload or token claims.",
100
+ );
101
+ }
102
+
103
+ existing = await createCustomerAsync(this.getRetailContext(context), {
104
+ AccountNumber: "",
105
+ Email: customerEmail,
106
+ FirstName: user.FirstName || token.given_name,
107
+ LastName: user.LastName || token.family_name || ".",
108
+ });
109
+ }
110
+
111
+ if (user.FirstName !== undefined) existing.FirstName = user.FirstName;
112
+ if (user.LastName !== undefined) existing.LastName = user.LastName;
113
+
114
+ if (user.DefaultShippingAddressId !== undefined) {
115
+ existing.Addresses ??= [];
116
+ existing.Addresses = existing.Addresses.map((addr) => ({
117
+ ...addr,
118
+ IsPrimary:
119
+ addr.RecordId?.toString() === user.DefaultShippingAddressId,
120
+ }));
121
+
122
+ const propKey = "DefaultShippingAddressId";
123
+ existing.ExtensionProperties = existing.ExtensionProperties ?? [];
124
+ existing.ExtensionProperties = existing.ExtensionProperties.filter(
125
+ (p: CommerceProperty) => p.Key !== propKey,
126
+ );
127
+ existing.ExtensionProperties.push({
128
+ Key: propKey,
129
+ Value: {
130
+ StringValue: user.DefaultShippingAddressId,
131
+ } as NonNullable<CommerceProperty["Value"]>,
132
+ });
133
+ }
134
+
135
+ const updated: Customer = await updateAsync(
136
+ this.getRetailContext(context),
137
+ existing,
138
+ );
139
+ const mappedAddresses = (updated.Addresses ?? []).map((a) => ({
140
+ ...a,
141
+ Id: a.RecordId?.toString() ?? "",
142
+ }));
143
+
144
+ span.setAttributes({
145
+ "d365.update_user.account_number": updated.AccountNumber ?? "",
146
+ "d365.update_user.first_name": updated.FirstName ?? "",
147
+ "d365.update_user.last_name": updated.LastName ?? "",
148
+ });
149
+ span.setStatus({ code: SpanStatusCode.OK });
150
+
151
+ return {
152
+ ...updated,
153
+ Addresses: mappedAddresses,
154
+ } as User;
155
+ } catch (error) {
156
+ const message =
157
+ error instanceof Error ? error.message : JSON.stringify(error);
158
+ span.setStatus({ code: SpanStatusCode.ERROR, message });
159
+ span.recordException(
160
+ error instanceof Error ? error : new Error(message),
161
+ );
162
+ throw new Error(`updateUser failed: ${message}`);
163
+ } finally {
164
+ span.end();
165
+ }
86
166
  });
87
- const updated: Customer = await updateAsync(
88
- this.getRetailContext(context),
89
- existing,
90
- );
91
- const mappedAddresses = (updated.Addresses ?? []).map((a) => ({
92
- ...a,
93
- Id: a.RecordId?.toString() ?? "",
94
- }));
95
- return {
96
- ...updated,
97
- Addresses: mappedAddresses,
98
- } as User;
99
167
  }
100
168
 
101
169
  async getUser(): Promise<User | null> {
@@ -115,7 +183,7 @@ class D365UserDatasource extends D365DatasourceBase implements UserApi {
115
183
  const token = decodeIdTokenClaims(context.requestContext.user.token);
116
184
  customer = await createCustomerAsync(this.getRetailContext(context), {
117
185
  AccountNumber: "",
118
- Email: Array.isArray(token.emails) ? token.emails[0] : token.email,
186
+ Email: extractEmailFromClaims(token),
119
187
  FirstName: token.given_name,
120
188
  LastName: token.family_name || ".",
121
189
  });
@@ -325,11 +393,15 @@ class D365UserDatasource extends D365DatasourceBase implements UserApi {
325
393
  async (span) => {
326
394
  const context = this.context;
327
395
  const id = input.Id;
328
- const catalogId = this.context.requestContext.apiSettings.catalogId;
396
+ // Get catalogId from apiSettings, with fallback to default catalog ID (1)
397
+ // D365 requires a valid CatalogId when multiple catalogs feature is enabled
398
+ const catalogId =
399
+ this.context.requestContext.apiSettings.catalogId || 1;
329
400
 
330
401
  const productListLines = input.ProductListLines.map((line) => ({
331
402
  ...line,
332
- CatalogId: catalogId,
403
+ // Always set CatalogId - use from input if present, otherwise use the resolved catalogId
404
+ CatalogId: line.CatalogId || catalogId,
333
405
  }));
334
406
  console.log(productListLines);
335
407
  try {
@@ -345,7 +417,7 @@ class D365UserDatasource extends D365DatasourceBase implements UserApi {
345
417
  };
346
418
  return wishlist;
347
419
  } catch (error) {
348
- throw new Error(`Error adding to wishlist: ${error}`);
420
+ throw new Error(`Error adding to wishlist: ${JSON.stringify(error)}`);
349
421
  } finally {
350
422
  span.end();
351
423
  }
@@ -364,7 +436,7 @@ class D365UserDatasource extends D365DatasourceBase implements UserApi {
364
436
  productListLines,
365
437
  );
366
438
  } catch (error) {
367
- throw new Error(`Error removing from wishlist: ${error}`);
439
+ throw new Error(`Error removing from wishlist: ${JSON.stringify(error)}`);
368
440
  }
369
441
  }
370
442
 
@@ -4,6 +4,29 @@ import { cookies, headers as getHeaders } from "next/headers";
4
4
  import { getLocale } from "next-intl/server";
5
5
  import { auth } from "@/auth/better-auth";
6
6
 
7
+ function isJwtStillValid(token?: string): boolean {
8
+ if (!token) return false;
9
+
10
+ try {
11
+ const parts = token.split(".");
12
+ if (parts.length !== 3) return false;
13
+
14
+ const payload = JSON.parse(
15
+ Buffer.from(parts[1] ?? "", "base64url").toString(),
16
+ ) as { exp?: number };
17
+
18
+ if (typeof payload.exp !== "number") {
19
+ return true;
20
+ }
21
+
22
+ const nowInSeconds = Math.floor(Date.now() / 1000);
23
+ // small skew buffer to avoid edge-of-expiration failures
24
+ return payload.exp > nowInSeconds + 30;
25
+ } catch {
26
+ return false;
27
+ }
28
+ }
29
+
7
30
  /**
8
31
  * Retrieves the D365 context from the "D365Context" cookie, enriches it with the current session token,
9
32
  * locale, and a new operation ID, and returns it as an `ICallerContext` object.
@@ -21,19 +44,30 @@ import { auth } from "@/auth/better-auth";
21
44
  export async function getContextFromCookie(): Promise<ICallerContext> {
22
45
  const cookieStore = await cookies();
23
46
  const d365Context = cookieStore.get("D365Context");
24
- let accessToken = "";
25
- try {
26
- const tokens = await auth.api.getAccessToken({
27
- headers: await getHeaders(),
28
- body: { providerId: "entra-external-id" },
29
- });
30
- accessToken = tokens?.idToken || tokens?.accessToken || "";
31
- } catch {
32
- // User may not be authenticated — continue with empty token
33
- }
47
+
34
48
  const locale = await getLocale();
35
49
  if (d365Context) {
36
50
  const context = JSON.parse(d365Context.value) as ICallerContext;
51
+ const existingToken = context.requestContext?.user?.token || "";
52
+ let sessionToken = "";
53
+
54
+ try {
55
+ const session = await auth.api.getSession({
56
+ headers: await getHeaders(),
57
+ });
58
+ sessionToken =
59
+ (session as { session?: { accessToken?: string } } | null)?.session
60
+ ?.accessToken || "";
61
+ } catch {
62
+ // Fall back to the context cookie token when session lookup is unavailable.
63
+ }
64
+
65
+ let accessToken = "";
66
+ if (isJwtStillValid(sessionToken)) {
67
+ accessToken = sessionToken;
68
+ } else if (isJwtStillValid(existingToken)) {
69
+ accessToken = existingToken;
70
+ }
37
71
  context.requestContext.operationId = crypto.randomUUID();
38
72
  context.requestContext.user = {
39
73
  isAuthenticated: !!accessToken,
@@ -29,9 +29,9 @@ async function createApiManager(): Promise<Datasource> {
29
29
  datasource = E4Datasource;
30
30
  }
31
31
 
32
+ // biome-ignore lint: api datasources should not be typed
32
33
  const storefrontOverrides: { [key: string]: any } = {};
33
34
 
34
- // @ts-expect-error
35
35
  function importAll(r) {
36
36
  r.keys().forEach((key: string) => {
37
37
  storefrontOverrides[key] = r(key);
@@ -63,6 +63,7 @@ async function createApiManager(): Promise<Datasource> {
63
63
  Object.assign(datasource[entityName] as object, entityOverrides);
64
64
  } else {
65
65
  // Non-core entity added entirely from overrides
66
+ // biome-ignore lint: api datasources should not be typed
66
67
  (datasource as any)[entityName] = entityOverrides;
67
68
  }
68
69
  }
@@ -5,6 +5,23 @@ import { GraphQLClient } from "graphql-request";
5
5
  import { cookies, headers as getHeaders } from "next/headers";
6
6
  import { auth } from "@/auth/better-auth";
7
7
 
8
+ const decodeJwtExpMs = (token?: string): number | null => {
9
+ if (!token) return null;
10
+ const parts = token.split(".");
11
+ if (parts.length !== 3 || !parts[1]) return null;
12
+ try {
13
+ const payload = JSON.parse(
14
+ Buffer.from(parts[1], "base64url").toString(),
15
+ ) as {
16
+ exp?: number;
17
+ };
18
+ if (typeof payload.exp !== "number") return null;
19
+ return payload.exp * 1000;
20
+ } catch {
21
+ return null;
22
+ }
23
+ };
24
+
8
25
  /**
9
26
  * Initializes and returns a cached GraphQL client
10
27
  * @param withAuth (optional) - If true, includes the authorization token
@@ -28,16 +45,27 @@ const getGraphQLClient = async (withAuth = false) => {
28
45
 
29
46
  if (withAuth) {
30
47
  try {
31
- const tokens = await auth.api.getAccessToken({
48
+ const session = await auth.api.getSession({
32
49
  headers: await getHeaders(),
33
- body: { providerId: "entra-external-id" },
34
50
  });
35
- const accessToken = tokens?.idToken || tokens?.accessToken;
36
- if (accessToken) {
37
- headers.Authorization = `Bearer ${accessToken}`;
51
+ const accessToken =
52
+ (session as { session?: { accessToken?: string } } | null)?.session
53
+ ?.accessToken || "";
54
+ if (!accessToken) {
55
+ throw new Error("Authentication required: provider token missing");
38
56
  }
39
- } catch {
40
- // User may not be authenticated — continue without auth header
57
+
58
+ const expMs = decodeJwtExpMs(accessToken);
59
+ if (typeof expMs === "number" && expMs <= Date.now()) {
60
+ throw new Error("Authentication required: provider token expired");
61
+ }
62
+
63
+ headers.Authorization = `Bearer ${accessToken}`;
64
+ } catch (error) {
65
+ // withAuth=true is an explicit contract that a valid token is required.
66
+ throw error instanceof Error
67
+ ? error
68
+ : new Error("Authentication required: unable to resolve access token");
41
69
  }
42
70
  }
43
71
 
@@ -41,7 +41,7 @@ export const productSchema = z.object({
41
41
  ItemId: z.string(), // sku?
42
42
  RecordId: z.number(),
43
43
  Name: z.string(),
44
- Price: z.number().optional(),
44
+ Price: z.number(),
45
45
  BasePrice: z.number().optional(),
46
46
  AdjustedPrice: z.number().optional(),
47
47
  Description: z.string().optional(),
@@ -11,6 +11,22 @@ import signOut from "@/auth/signout";
11
11
 
12
12
  const QUERY_ROUTE = "/api";
13
13
 
14
+ class ApiManagerRequestError extends Error {
15
+ status: number;
16
+ path: ApiPath;
17
+ payload?: unknown;
18
+
19
+ constructor(status: number, path: ApiPath, payload?: unknown) {
20
+ super(
21
+ `API request failed for ${JSON.stringify(path)} with status ${status}`,
22
+ );
23
+ this.name = "ApiManagerRequestError";
24
+ this.status = status;
25
+ this.path = path;
26
+ this.payload = payload;
27
+ }
28
+ }
29
+
14
30
  export type ApiPath<T extends keyof DatasourceApis = keyof DatasourceApis> = [
15
31
  T,
16
32
  keyof DatasourceApis[T],
@@ -33,10 +49,24 @@ const doFetch = async <TInput>(
33
49
  body: JSON.stringify({ input, apiManagerPath }),
34
50
  headers,
35
51
  });
52
+
53
+ let payload: unknown;
54
+ try {
55
+ payload = await result.json();
56
+ } catch {
57
+ payload = undefined;
58
+ }
59
+
36
60
  if (result.status === 401) {
37
61
  signOut();
62
+ throw new ApiManagerRequestError(result.status, apiManagerPath, payload);
38
63
  }
39
- return result.json();
64
+
65
+ if (!result.ok) {
66
+ throw new ApiManagerRequestError(result.status, apiManagerPath, payload);
67
+ }
68
+
69
+ return payload;
40
70
  };
41
71
 
42
72
  // --- Factory ---
@@ -58,10 +88,13 @@ export const createQuery = <TInput, TOutput>(
58
88
  queryFn: isServer
59
89
  ? async () => {
60
90
  const apiManager = await createApiManager();
61
- return getApiFromPath(apiManager, apiManagerPath)(input);
91
+ return getApiFromPath<TInput, TOutput>(
92
+ apiManager,
93
+ apiManagerPath,
94
+ )(input);
62
95
  }
63
96
  : async () => {
64
- return await doFetch(input, apiManagerPath);
97
+ return (await doFetch(input, apiManagerPath)) as TOutput;
65
98
  },
66
99
  enabled: config?.disableProperties
67
100
  ? config.disableProperties.every((prop) => !!input?.[prop])
@@ -10,6 +10,16 @@ function makeQueryClient() {
10
10
  defaultOptions: {
11
11
  queries: {
12
12
  staleTime: 60 * 1000,
13
+ refetchOnWindowFocus: false,
14
+ retry: (failureCount, error) => {
15
+ const status =
16
+ typeof error === "object" && error !== null && "status" in error
17
+ ? Number((error as { status?: number }).status)
18
+ : undefined;
19
+
20
+ if (status === 401) return false;
21
+ return failureCount < 1;
22
+ },
13
23
  },
14
24
  dehydrate: {
15
25
  serializeData: (data) => JSON.stringify(data),