@evenicanpm/storefront-core 2.4.0 → 2.4.2

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 (58) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/package.json +90 -77
  3. package/src/api-manager/datasources/d365/d365-address.datasource.ts +142 -66
  4. package/src/api-manager/datasources/d365/d365-user.datasource.ts +109 -37
  5. package/src/api-manager/datasources/d365/utils/get-context-cookie.ts +44 -10
  6. package/src/api-manager/lib/get-graphql-client.ts +35 -7
  7. package/src/api-manager/services/create-query.ts +36 -3
  8. package/src/api-manager/services/get-query-client.ts +10 -0
  9. package/src/auth/better-auth.ts +282 -15
  10. package/src/cms/blocks/components/footer/data/index.ts +0 -0
  11. package/src/cms/blocks/components/footer/index.tsx +0 -0
  12. package/src/cms/blocks/components/footer/sections/footer-app-store.tsx +0 -0
  13. package/src/cms/blocks/components/footer/sections/footer-contact.tsx +0 -0
  14. package/src/cms/blocks/components/footer/sections/footer-links.tsx +0 -0
  15. package/src/cms/blocks/components/footer/sections/footer-logo.tsx +0 -0
  16. package/src/cms/blocks/components/footer/sections/footer-social-links.tsx +0 -0
  17. package/src/cms/blocks/components/footer/styles/index.ts +0 -0
  18. package/src/cms/blocks/components/hero-carousel/index.tsx +0 -0
  19. package/src/cms/blocks/components/product-carousel/index.tsx +0 -0
  20. package/src/cms/blocks/components/product-section-fullwidth/index.tsx +3 -1
  21. package/src/cms/blocks/components/shared/featured-product-card.tsx +0 -0
  22. package/src/cms/blocks/components/shared/product-category-item.tsx +0 -0
  23. package/src/cms/blocks/components/shared/product-grid.tsx +0 -0
  24. package/src/cms/blocks/components/shared/top-categories-card.tsx +0 -0
  25. package/src/cms/blocks/components/shared/top-rating-product-card.tsx +0 -0
  26. package/src/cms/blocks/icons/components/category.tsx +0 -0
  27. package/src/cms/blocks/icons/components/dotted-star.tsx +0 -0
  28. package/src/cms/blocks/icons/components/gift-box.tsx +0 -0
  29. package/src/cms/blocks/icons/components/light.tsx +0 -0
  30. package/src/cms/blocks/icons/components/new-arrival.tsx +0 -0
  31. package/src/cms/blocks/icons/components/rank-badge.tsx +0 -0
  32. package/src/components/categories/category-list/category-list.tsx +9 -5
  33. package/src/components/header/__tests__/user.test.tsx +5 -107
  34. package/src/components/header/components/user.tsx +72 -45
  35. package/src/hooks/use-nextauth-session.ts +0 -33
  36. package/src/lib/auth-client.ts +14 -0
  37. package/src/lib/auth.ts +4 -0
  38. package/src/lib/entra-native-auth.ts +138 -0
  39. package/src/lib/graphqlRequestSdk.ts +0 -0
  40. package/src/lib/native-session.ts +434 -0
  41. package/src/pages/account/profile/__tests__/profile-form.test.tsx +173 -0
  42. package/src/pages/account/profile/profile-form.tsx +285 -0
  43. package/src/pages/account/profile/profile-validation.ts +52 -0
  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/checkout/checkout-alt-form/steps/address/delivery-address.tsx +20 -6
  54. package/src/pages/checkout/checkout-alt-form/steps/customer-info/customer-information.tsx +1 -1
  55. package/tsconfig.json +20 -14
  56. package/src/auth/msal.ts +0 -65
  57. package/src/pages/account/profile/profile-button.test.tsx +0 -59
  58. package/src/pages/account/profile/profile-button.tsx +0 -51
package/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # @evenicanpm/storefront-core
2
2
 
3
+ ## 2.4.2
4
+
5
+ ### Patch Changes
6
+
7
+ - fix: bumping all versions and then re-publishing to properly strip out the workspace: deps.
8
+ - Updated dependencies
9
+ - @evenicanpm/cms@2.4.2
10
+ - @evenicanpm/ui@2.4.2
11
+
12
+ ## 2.4.1
13
+
14
+ ### Patch Changes
15
+
16
+ - fix: republish with resolved workspace dependencies
17
+ - Updated dependencies
18
+ - @evenicanpm/cms@2.4.1
19
+ - @evenicanpm/ui@2.4.1
20
+
3
21
  ## 2.4.0
4
22
 
5
23
  ### Patch Changes
package/package.json CHANGED
@@ -1,78 +1,91 @@
1
1
  {
2
- "name": "@evenicanpm/storefront-core",
3
- "version": "2.4.0",
4
- "description": "Core module for D365/e4 Headless Storefront",
5
- "type": "module",
6
- "scripts": {
7
- "test": "echo \"Error: no test specified\" && exit 1",
8
- "docs": "typedoc",
9
- "storybook": "storybook dev -p 6006",
10
- "build-storybook": "storybook build"
11
- },
12
- "exports": {
13
- ".": "./index.js",
14
- "./*": "./*"
15
- },
16
- "author": "Evenica",
17
- "license": "ISC",
18
- "dependencies": {
19
- "@evenicanpm/cms": "workspace:*",
20
- "@evenicanpm/ui": "workspace:*",
21
- "strapi-sdk-js": "^3.0.0",
22
- "clsx": "^2.1.1"
23
- },
24
- "devDependencies": {
25
- "@emotion/react": "catalog:",
26
- "@emotion/styled": "catalog:",
27
- "@mui/icons-material": "catalog:",
28
- "@mui/material": "catalog:",
29
- "@mui/types": "catalog:",
30
- "@mui/system": "catalog:",
31
- "@storybook/addon-docs": "^9.1.6",
32
- "@storybook/addon-webpack5-compiler-swc": "^4.0.1",
33
- "@storybook/nextjs": "^9.1.6",
34
- "@storybook/react": "^9.1.6",
35
- "@storybook/react-webpack5": "^9.1.6",
36
- "@types/jsonwebtoken": "^9.0.9",
37
- "@types/lodash": "^4.17.17",
38
- "@types/node": "^22.15.20",
39
- "@types/object-mapper": "^6.2.2",
40
- "@types/react": "catalog:",
41
- "@types/react-dom": "catalog:",
42
- "react": "catalog:",
43
- "react-dom": "catalog:",
44
- "storybook": "^9.1.6"
45
- },
46
- "peerDependencies": {
47
- "@emotion/react": "catalog:",
48
- "@emotion/styled": "catalog:",
49
- "@msdyn365-commerce/retail-proxy": "9.50",
50
- "@mui/icons-material": "catalog:",
51
- "@mui/material": "catalog:",
52
- "@mui/system": "catalog:",
53
- "@opentelemetry/api": "^1.9.1",
54
- "@strapi/blocks-react-renderer": "^1.0.1",
55
- "@tanstack/react-query": "catalog:",
56
- "@tanstack/query-core": "catalog:",
57
- "better-auth": "catalog:",
58
- "graphql": "^16.11.0",
59
- "graphql-request": "^7.1.2",
60
- "jsonwebtoken": "^9.0.2",
61
- "lodash": "^4.17.21",
62
- "next": "catalog:",
63
- "next-intl": "catalog:",
64
- "object-mapper": "^6.2.0",
65
- "react": "catalog:",
66
- "react-dom": "catalog:",
67
- "set-cookie-parser": "^2.7.1",
68
- "typedoc": "^0.28.9",
69
- "typedoc-plugin-merge-modules": "^7.0.0",
70
- "typescript": "^5.8.3",
71
- "zod": "^3.25.25"
72
- },
73
- "overrides": {
74
- "@storybook/builder-manager": {
75
- "esbuild": "0.19.11"
76
- }
77
- }
78
- }
2
+ "name": "@evenicanpm/storefront-core",
3
+ "version": "2.4.2",
4
+ "description": "Core module for D365/e4 Headless Storefront",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": "./index.js",
8
+ "./*": "./*"
9
+ },
10
+ "author": "Evenica",
11
+ "license": "ISC",
12
+ "dependencies": {
13
+ "react-dropzone": "^14.3.8",
14
+ "react-slick": "^0.30.1",
15
+ "slick-carousel": "^1.8.1",
16
+ "strapi-sdk-js": "^3.0.0",
17
+ "clsx": "^2.1.1",
18
+ "react-icons": "^5.4.0",
19
+ "simplebar-react": "^3.3.0",
20
+ "formik": "^2.4.6",
21
+ "formik-validator-zod": "^2.0.1",
22
+ "currency.js": "^2.0.4",
23
+ "@hookform/resolvers": "^3.9.0",
24
+ "react-hook-form": "^7.53.0",
25
+ "yup": "^1.3.3",
26
+ "nuqs": "^2.8.9",
27
+ "@evenicanpm/cms": "2.4.2",
28
+ "@evenicanpm/ui": "2.4.2"
29
+ },
30
+ "devDependencies": {
31
+ "@emotion/react": "^11.11.3",
32
+ "@emotion/styled": "^11.11.0",
33
+ "@mui/icons-material": "^6.1.7",
34
+ "@mui/material": "^6.1.7",
35
+ "@mui/types": "~7.2.24",
36
+ "@mui/system": "^6.3.1",
37
+ "@storybook/addon-docs": "^9.1.6",
38
+ "@storybook/addon-webpack5-compiler-swc": "^4.0.1",
39
+ "@storybook/nextjs": "^9.1.6",
40
+ "@storybook/react": "^9.1.6",
41
+ "@storybook/react-webpack5": "^9.1.6",
42
+ "@types/jsonwebtoken": "^9.0.9",
43
+ "@types/lodash": "^4.17.17",
44
+ "@types/node": "^22.15.20",
45
+ "@types/object-mapper": "^6.2.2",
46
+ "@types/react": "^19.2.2",
47
+ "@types/react-dom": "^19.2.2",
48
+ "react": "^19.2.7",
49
+ "react-dom": "^19.2.7",
50
+ "storybook": "^9.1.6"
51
+ },
52
+ "peerDependencies": {
53
+ "@emotion/react": "^11.11.3",
54
+ "@emotion/styled": "^11.11.0",
55
+ "@msdyn365-commerce/retail-proxy": "9.50",
56
+ "@mui/icons-material": "^6.1.7",
57
+ "@mui/material": "^6.1.7",
58
+ "@mui/system": "^6.3.1",
59
+ "@opentelemetry/api": "^1.9.1",
60
+ "@strapi/blocks-react-renderer": "^1.0.1",
61
+ "@tanstack/react-query": "^5.100.4",
62
+ "@tanstack/query-core": "^5.100.4",
63
+ "better-auth": "^1.6.13",
64
+ "graphql": "^16.11.0",
65
+ "graphql-request": "^7.1.2",
66
+ "jsonwebtoken": "^9.0.2",
67
+ "lodash": "^4.17.21",
68
+ "next": "^15.3.1",
69
+ "next-intl": "^4.3.4",
70
+ "object-mapper": "^6.2.0",
71
+ "pg": "^8.8.0",
72
+ "react": "^19.2.7",
73
+ "react-dom": "^19.2.7",
74
+ "set-cookie-parser": "^2.7.1",
75
+ "typedoc": "^0.28.9",
76
+ "typedoc-plugin-merge-modules": "^7.0.0",
77
+ "typescript": "^5.8.3",
78
+ "zod": "^3.25.25"
79
+ },
80
+ "overrides": {
81
+ "@storybook/builder-manager": {
82
+ "esbuild": "0.19.11"
83
+ }
84
+ },
85
+ "scripts": {
86
+ "test": "echo \"Error: no test specified\" && exit 1",
87
+ "docs": "typedoc",
88
+ "storybook": "storybook dev -p 6006",
89
+ "build-storybook": "storybook build"
90
+ }
91
+ }
@@ -7,10 +7,13 @@ import {
7
7
  type CountryRegionInfo,
8
8
  type DeliveryOption,
9
9
  FilterDeliveryModeOption,
10
+ type ICallerContext,
11
+ type IContext,
10
12
  type StateProvinceInfo,
11
13
  } from "@msdyn365-commerce/retail-proxy";
12
14
  import { getDeliveryOptionsAsync } from "@msdyn365-commerce/retail-proxy/dist/DataActions/CartsDataActions.g";
13
15
  import {
16
+ createCustomerAsync,
14
17
  readAsync,
15
18
  updateAsync,
16
19
  } from "@msdyn365-commerce/retail-proxy/dist/DataActions/CustomersDataActions.g";
@@ -18,6 +21,7 @@ import {
18
21
  getCountryRegionsByLanguageIdAsync,
19
22
  getStateProvincesAsync,
20
23
  } from "@msdyn365-commerce/retail-proxy/dist/DataActions/StoreOperationsDataActions.g";
24
+ import jwt from "jsonwebtoken";
21
25
  import type {
22
26
  Address,
23
27
  GetCountriesInput,
@@ -25,88 +29,160 @@ import type {
25
29
  GetStatesInput,
26
30
  } from "@/schemas/address.schema";
27
31
 
28
- class D365AddressDatasource extends D365DatasourceBase implements AddressApi {
29
- async createAddress(address: Address): Promise<Address> {
30
- const context = await getContextFromCookie();
31
- // biome-ignore lint: api datasources should not be typed
32
- const customer = await readAsync(context as any, "");
33
- const accountNumber = customer.AccountNumber;
32
+ type IdTokenClaims = jwt.JwtPayload & {
33
+ emails?: string[];
34
+ email?: string;
35
+ preferred_username?: string;
36
+ upn?: string;
37
+ unique_name?: string;
38
+ given_name?: string;
39
+ family_name?: string;
40
+ };
41
+
42
+ function extractEmailFromClaims(token: IdTokenClaims): string | undefined {
43
+ return [
44
+ Array.isArray(token.emails) ? token.emails[0] : undefined,
45
+ token.email,
46
+ token.preferred_username,
47
+ token.upn,
48
+ token.unique_name,
49
+ ]
50
+ .map((v) => (typeof v === "string" ? v.trim() : ""))
51
+ .find((v) => v.includes("@"));
52
+ }
34
53
 
35
- const prevAddresses = customer.Addresses ?? [];
36
- // biome-ignore lint: api datasources should not be typed
37
- customer.Addresses = [...prevAddresses, address as any];
54
+ function decodeIdTokenClaims(token: string): IdTokenClaims {
55
+ const decoded = jwt.decode(token);
56
+ if (!decoded || typeof decoded !== "object") {
57
+ return {} as IdTokenClaims;
58
+ }
59
+ return decoded;
60
+ }
38
61
 
39
- // biome-ignore lint: api datasources should not be typed
40
- const updatedCustomer = await updateAsync(context as any, {
41
- ...customer,
42
- AccountNumber: accountNumber,
43
- });
44
-
45
- const added = updatedCustomer.Addresses?.find((a) =>
46
- prevAddresses.every((old) => old.RecordId !== a.RecordId),
47
- );
48
- if (added?.RecordId == null) {
49
- throw new Error("Failed to create new address");
50
- }
62
+ class D365AddressDatasource extends D365DatasourceBase implements AddressApi {
63
+ private getRetailContext(context: ICallerContext): IContext {
64
+ return context as unknown as IContext;
65
+ }
51
66
 
52
- return {
53
- ...added,
54
- Id: added.RecordId.toString(),
55
- };
67
+ private async getOrCreateCustomer(context: ICallerContext) {
68
+ try {
69
+ return await readAsync(this.getRetailContext(context), "");
70
+ } catch {
71
+ const token = decodeIdTokenClaims(
72
+ context.requestContext?.user?.token || "",
73
+ );
74
+ const email = extractEmailFromClaims(token);
75
+ if (!email) {
76
+ throw new Error(
77
+ "Cannot resolve or create customer profile: missing email in token claims.",
78
+ );
79
+ }
80
+
81
+ return createCustomerAsync(this.getRetailContext(context), {
82
+ AccountNumber: "",
83
+ Email: email,
84
+ FirstName: token.given_name,
85
+ LastName: token.family_name || ".",
86
+ });
87
+ }
56
88
  }
57
89
 
58
- async updateAddress(address: Address): Promise<Address> {
59
- const context = await getContextFromCookie();
60
- // biome-ignore lint: api datasources should not be typed
61
- const customer = await readAsync(context as any, "");
62
- customer.Addresses = customer.Addresses ?? [];
90
+ async createAddress(address: Address): Promise<Address> {
91
+ try {
92
+ const context = await getContextFromCookie();
93
+ const customer = await this.getOrCreateCustomer(context);
94
+ const accountNumber = customer.AccountNumber;
63
95
 
64
- const idx = customer.Addresses.findIndex(
65
- (a) => a.RecordId?.toString() === address.Id,
66
- );
67
- // biome-ignore lint: api datasources should not be typed
68
- const updatedRecord = { ...(address as any) };
96
+ const prevAddresses = customer.Addresses ?? [];
97
+ // biome-ignore lint: api datasources should not be typed
98
+ customer.Addresses = [...prevAddresses, address as any];
69
99
 
70
- if (idx >= 0) {
71
- customer.Addresses[idx] = {
72
- ...customer.Addresses[idx],
73
- ...updatedRecord,
100
+ // biome-ignore lint: api datasources should not be typed
101
+ const updatedCustomer = await updateAsync(context as any, {
102
+ ...customer,
103
+ AccountNumber: accountNumber,
104
+ });
105
+
106
+ const added = updatedCustomer.Addresses?.find((a) =>
107
+ prevAddresses.every((old) => old.RecordId !== a.RecordId),
108
+ );
109
+ if (added?.RecordId == null) {
110
+ throw new Error("Failed to create new address");
111
+ }
112
+
113
+ return {
114
+ ...added,
115
+ Id: added.RecordId.toString(),
74
116
  };
75
- } else {
76
- customer.Addresses.push(updatedRecord);
117
+ } catch (error) {
118
+ const message =
119
+ error instanceof Error ? error.message : JSON.stringify(error);
120
+ throw new Error(`createAddress failed: ${message}`);
77
121
  }
122
+ }
78
123
 
79
- // biome-ignore lint: api datasources should not be typed
80
- const updatedCustomer = await updateAsync(context as any, customer);
81
- const rec = updatedCustomer?.Addresses?.[idx];
82
- if (rec?.RecordId == null) {
83
- throw new Error(
84
- `Failed to update address Id=${address.Id}. Updated address record with a matching id not found.`,
124
+ async updateAddress(address: Address): Promise<Address> {
125
+ try {
126
+ const context = await getContextFromCookie();
127
+ const customer = await this.getOrCreateCustomer(context);
128
+ customer.Addresses = customer.Addresses ?? [];
129
+
130
+ const idx = customer.Addresses.findIndex(
131
+ (a) => a.RecordId?.toString() === address.Id,
85
132
  );
86
- }
133
+ // biome-ignore lint: api datasources should not be typed
134
+ const updatedRecord = { ...(address as any) };
87
135
 
88
- return {
89
- ...rec,
90
- Id: rec.RecordId.toString(),
91
- };
92
- }
136
+ if (idx >= 0) {
137
+ customer.Addresses[idx] = {
138
+ ...customer.Addresses[idx],
139
+ ...updatedRecord,
140
+ };
141
+ } else {
142
+ customer.Addresses.push(updatedRecord);
143
+ }
93
144
 
94
- async deleteAddress(addressId: string): Promise<string> {
95
- const context = await getContextFromCookie();
96
- // biome-ignore lint: api datasources should not be typed
97
- const customer = await readAsync(context as any, "");
98
- customer.Addresses = customer.Addresses ?? [];
99
-
100
- const rec = customer.Addresses.find(
101
- (a) => a.RecordId?.toString() === addressId,
102
- );
103
- if (rec) {
104
- rec.Deactivate = true;
105
145
  // biome-ignore lint: api datasources should not be typed
106
- await updateAsync(context as any, customer);
146
+ const updatedCustomer = await updateAsync(context as any, customer);
147
+ const rec = updatedCustomer?.Addresses?.[idx];
148
+ if (rec?.RecordId == null) {
149
+ throw new Error(
150
+ `Failed to update address Id=${address.Id}. Updated address record with a matching id not found.`,
151
+ );
152
+ }
153
+
154
+ return {
155
+ ...rec,
156
+ Id: rec.RecordId.toString(),
157
+ };
158
+ } catch (error) {
159
+ const message =
160
+ error instanceof Error ? error.message : JSON.stringify(error);
161
+ throw new Error(`updateAddress failed: ${message}`);
107
162
  }
163
+ }
108
164
 
109
- return addressId;
165
+ async deleteAddress(addressId: string): Promise<string> {
166
+ try {
167
+ const context = await getContextFromCookie();
168
+ const customer = await this.getOrCreateCustomer(context);
169
+ customer.Addresses = customer.Addresses ?? [];
170
+
171
+ const rec = customer.Addresses.find(
172
+ (a) => a.RecordId?.toString() === addressId,
173
+ );
174
+ if (rec) {
175
+ rec.Deactivate = true;
176
+ // biome-ignore lint: api datasources should not be typed
177
+ await updateAsync(context as any, customer);
178
+ }
179
+
180
+ return addressId;
181
+ } catch (error) {
182
+ const message =
183
+ error instanceof Error ? error.message : JSON.stringify(error);
184
+ throw new Error(`deleteAddress failed: ${message}`);
185
+ }
110
186
  }
111
187
 
112
188
  async getCountries(input: GetCountriesInput): Promise<CountryRegionInfo[]> {
@@ -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