@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.
- package/CHANGELOG.md +17 -0
- package/package.json +42 -9
- package/src/api-manager/datasources/d365/d365-address.datasource.ts +148 -61
- package/src/api-manager/datasources/d365/d365-cart.datasource.ts +23 -1
- package/src/api-manager/datasources/d365/d365-order.datasource.ts +6 -1
- package/src/api-manager/datasources/d365/d365-user.datasource.ts +109 -37
- package/src/api-manager/datasources/d365/utils/get-context-cookie.ts +44 -10
- package/src/api-manager/index.ts +2 -1
- package/src/api-manager/lib/get-graphql-client.ts +35 -7
- package/src/api-manager/schemas/product.schema.ts +1 -1
- package/src/api-manager/services/create-query.ts +36 -3
- package/src/api-manager/services/get-query-client.ts +10 -0
- package/src/auth/better-auth.ts +282 -15
- package/src/cms/blocks/block-manager.tsx +1 -1
- package/src/cms/blocks/components/product-section-fullwidth/index.tsx +3 -1
- package/src/cms/endpoints.ts +7 -2
- package/src/components/BazaarMenu.tsx +1 -1
- package/src/components/Typography.tsx +4 -2
- package/src/components/carousel-cards/carousel-card-1/CarouselCard1.stories.tsx +1 -1
- package/src/components/categories/category-list/category-list.tsx +9 -5
- package/src/components/categories/category-menu.tsx +1 -1
- package/src/components/countries-input.tsx +4 -0
- package/src/components/header/__tests__/user.test.tsx +5 -107
- package/src/components/header/components/user.tsx +72 -45
- package/src/components/header/sticky-header.tsx +0 -1
- package/src/components/navbar/mobile-menu/mobile-menu.test.tsx +1 -1
- package/src/components/navbar/utils/transform-nav.test.ts +1 -1
- package/src/components/navbar/utils/transform-nav.ts +1 -1
- package/src/components/product-cards/product-card/product-card.tsx +5 -2
- package/src/components/product-cards/product-card/styles/index.ts +1 -5
- package/src/components/products-view/compound/products-grid-view.tsx +5 -1
- package/src/components/products-view/compound/products-list-view.tsx +5 -1
- package/src/global.d.ts +3 -0
- package/src/hooks/use-nextauth-session.ts +0 -33
- package/src/lib/auth-client.ts +14 -0
- package/src/lib/auth.ts +4 -0
- package/src/lib/entra-native-auth.ts +138 -0
- package/src/lib/native-session.ts +434 -0
- package/src/pages/account/addresses/address-form.tsx +20 -2
- package/src/pages/account/profile/__tests__/profile-form.test.tsx +173 -0
- package/src/pages/account/profile/profile-form.tsx +285 -0
- package/src/pages/account/profile/profile-validation.ts +52 -0
- package/src/pages/account/wishlist/wishlist-item.tsx +1 -2
- package/src/pages/auth/auth-activate-page.tsx +509 -0
- package/src/pages/auth/auth-layout.tsx +73 -0
- package/src/pages/auth/auth-login-page.tsx +241 -0
- package/src/pages/auth/auth-reset-password-page.tsx +487 -0
- package/src/pages/auth/compound/auth-form.tsx +182 -0
- package/src/pages/auth/compound/auth-pages.tsx +21 -0
- package/src/pages/auth/lib/friendly-error.ts +70 -0
- package/src/pages/auth/lib/index.ts +2 -0
- package/src/pages/auth/lib/types.ts +5 -0
- package/src/pages/cart/cart-item.tsx +1 -7
- package/src/pages/checkout/checkout-alt-form/checkout-form.tsx +14 -3
- package/src/pages/checkout/checkout-alt-form/steps/address/address-card.tsx +5 -3
- package/src/pages/checkout/checkout-alt-form/steps/address/delivery-address.tsx +20 -6
- package/src/pages/checkout/checkout-alt-form/steps/address/new-address-form.tsx +1 -1
- package/src/pages/checkout/checkout-alt-form/steps/customer-info/customer-information.tsx +1 -1
- package/src/pages/confirmation/ordered-products.tsx +3 -1
- package/src/pages/product-details/bopis/find-in-store-modal.tsx +4 -4
- package/src/pages/product-details/bopis/pickup-option-select.tsx +2 -2
- package/src/pages/product-details/bopis/search-header.tsx +2 -2
- package/src/pages/product-details/product-intro/compound/context.ts +8 -3
- package/src/pages/product-details/product-intro/compound/product-info.tsx +25 -19
- package/src/pages/product-list/product-list-view.tsx +2 -1
- package/src/providers/nav-provider/index.tsx +1 -1
- package/src/providers/nav-provider/utils/createLink.ts +1 -1
- package/tsconfig.json +20 -15
- package/__mocks__/countries.ts +0 -11
- package/__mocks__/create-mutation.ts +0 -68
- package/__mocks__/create-query.ts +0 -94
- package/__mocks__/data/categories.json +0 -795
- package/__mocks__/get-product-by-id.ts +0 -6
- package/__mocks__/next-auth-react.ts +0 -9
- package/__mocks__/next-font.js +0 -4
- package/__mocks__/next-headers.js +0 -13
- package/__mocks__/next-image.tsx +0 -18
- package/__mocks__/next-link.js +0 -19
- package/__mocks__/next-navigation.ts +0 -29
- package/__mocks__/product-dimensions.ts +0 -635
- package/__mocks__/products.ts +0 -533
- package/__mocks__/query-client.ts +0 -3
- package/__mocks__/wishlists.json +0 -408
- package/src/auth/msal.ts +0 -68
- package/src/cms/blog.ts +0 -229
- package/src/cms/interfaces/navigation.ts +0 -52
- package/src/cms/interfaces/product-data.ts +0 -83
- package/src/cms/pages.ts +0 -149
- package/src/pages/account/profile/profile-button.test.tsx +0 -59
- package/src/pages/account/profile/profile-button.tsx +0 -51
package/src/auth/better-auth.ts
CHANGED
|
@@ -1,19 +1,231 @@
|
|
|
1
|
+
import type { Auth, BetterAuthOptions } from "better-auth";
|
|
1
2
|
import { betterAuth } from "better-auth";
|
|
2
3
|
import { nextCookies } from "better-auth/next-js";
|
|
4
|
+
import { decryptOAuthToken, setTokenUtil } from "better-auth/oauth2";
|
|
3
5
|
import { customSession, genericOAuth } from "better-auth/plugins";
|
|
6
|
+
import { Pool } from "pg";
|
|
7
|
+
|
|
8
|
+
function toValidDate(dateLike: unknown): Date | null {
|
|
9
|
+
if (dateLike instanceof Date) {
|
|
10
|
+
return Number.isNaN(dateLike.getTime()) ? null : dateLike;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
if (typeof dateLike === "string" || typeof dateLike === "number") {
|
|
14
|
+
const date = new Date(dateLike);
|
|
15
|
+
return Number.isNaN(date.getTime()) ? null : date;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Check if a date/timestamp has already passed (expired).
|
|
23
|
+
*/
|
|
24
|
+
function isPast(dateLike: unknown): boolean {
|
|
25
|
+
if (!dateLike) return false;
|
|
26
|
+
const date = toValidDate(dateLike);
|
|
27
|
+
if (!date) return false;
|
|
28
|
+
return date.getTime() <= Date.now();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Check if a date/timestamp is within a specified number of milliseconds from now.
|
|
33
|
+
* Useful for detecting tokens approaching expiry.
|
|
34
|
+
*/
|
|
35
|
+
function isWithinMs(dateLike: unknown, windowMs: number): boolean {
|
|
36
|
+
if (!dateLike) return false;
|
|
37
|
+
const date = toValidDate(dateLike);
|
|
38
|
+
if (!date) return false;
|
|
39
|
+
return date.getTime() - Date.now() <= windowMs;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Proactive refresh window: refresh access tokens 5 minutes before expiry.
|
|
44
|
+
* Prevents users from being signed out when their token expires.
|
|
45
|
+
* Better Auth's default is only ~5 seconds, which is too late.
|
|
46
|
+
*/
|
|
47
|
+
const ACCESS_TOKEN_REFRESH_WINDOW_MS = 5 * 60 * 1000;
|
|
48
|
+
|
|
49
|
+
type EntraAccount = {
|
|
50
|
+
id?: string;
|
|
51
|
+
providerId?: string;
|
|
52
|
+
idToken?: string | null;
|
|
53
|
+
accessToken?: string | null;
|
|
54
|
+
refreshToken?: string | null;
|
|
55
|
+
accessTokenExpiresAt?: Date | null;
|
|
56
|
+
refreshTokenExpiresAt?: Date | null;
|
|
57
|
+
scope?: string | null;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
async function decryptStoredOAuthToken(
|
|
61
|
+
token: string | null | undefined,
|
|
62
|
+
context: Parameters<typeof decryptOAuthToken>[1],
|
|
63
|
+
): Promise<string> {
|
|
64
|
+
if (!token) return "";
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
return await decryptOAuthToken(token, context);
|
|
68
|
+
} catch {
|
|
69
|
+
return "";
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function resolveEntraRefreshProvider(
|
|
74
|
+
socialProvidersSource: unknown,
|
|
75
|
+
): Promise<{
|
|
76
|
+
id?: string;
|
|
77
|
+
refreshAccessToken?: (token: string) => Promise<{
|
|
78
|
+
accessToken?: string;
|
|
79
|
+
refreshToken?: string;
|
|
80
|
+
accessTokenExpiresAt?: Date | null;
|
|
81
|
+
refreshTokenExpiresAt?: Date | null;
|
|
82
|
+
idToken?: string;
|
|
83
|
+
scopes?: string[];
|
|
84
|
+
} | null>;
|
|
85
|
+
} | null> {
|
|
86
|
+
const socialProviders =
|
|
87
|
+
typeof socialProvidersSource === "function"
|
|
88
|
+
? await socialProvidersSource()
|
|
89
|
+
: await Promise.resolve(socialProvidersSource);
|
|
90
|
+
|
|
91
|
+
if (Array.isArray(socialProviders)) {
|
|
92
|
+
return (
|
|
93
|
+
socialProviders.find(
|
|
94
|
+
(provider: { id?: string }) => provider?.id === "entra-external-id",
|
|
95
|
+
) || null
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (socialProviders && typeof socialProviders === "object") {
|
|
100
|
+
return (
|
|
101
|
+
(
|
|
102
|
+
socialProviders as Record<
|
|
103
|
+
string,
|
|
104
|
+
{
|
|
105
|
+
id?: string;
|
|
106
|
+
refreshAccessToken?: (token: string) => Promise<{
|
|
107
|
+
accessToken?: string;
|
|
108
|
+
refreshToken?: string;
|
|
109
|
+
accessTokenExpiresAt?: Date | null;
|
|
110
|
+
refreshTokenExpiresAt?: Date | null;
|
|
111
|
+
idToken?: string;
|
|
112
|
+
scopes?: string[];
|
|
113
|
+
} | null>;
|
|
114
|
+
}
|
|
115
|
+
>
|
|
116
|
+
)["entra-external-id"] || null
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function updateStoredEntraTokens(
|
|
124
|
+
account: EntraAccount,
|
|
125
|
+
refreshed: {
|
|
126
|
+
accessToken?: string;
|
|
127
|
+
refreshToken?: string;
|
|
128
|
+
accessTokenExpiresAt?: Date | null;
|
|
129
|
+
refreshTokenExpiresAt?: Date | null;
|
|
130
|
+
idToken?: string;
|
|
131
|
+
scopes?: string[];
|
|
132
|
+
} | null,
|
|
133
|
+
context: Parameters<typeof decryptOAuthToken>[1] & {
|
|
134
|
+
internalAdapter: {
|
|
135
|
+
updateAccount: (
|
|
136
|
+
accountId: string,
|
|
137
|
+
data: Record<string, unknown>,
|
|
138
|
+
) => Promise<unknown>;
|
|
139
|
+
};
|
|
140
|
+
},
|
|
141
|
+
): Promise<void> {
|
|
142
|
+
if (!account.id || !refreshed) return;
|
|
143
|
+
|
|
144
|
+
const updatedAccessToken = refreshed.accessToken
|
|
145
|
+
? await setTokenUtil(refreshed.accessToken, context)
|
|
146
|
+
: account.accessToken;
|
|
147
|
+
const updatedRefreshToken = refreshed.refreshToken
|
|
148
|
+
? await setTokenUtil(refreshed.refreshToken, context)
|
|
149
|
+
: account.refreshToken;
|
|
150
|
+
|
|
151
|
+
await context.internalAdapter.updateAccount(account.id, {
|
|
152
|
+
accessToken: updatedAccessToken,
|
|
153
|
+
refreshToken: updatedRefreshToken,
|
|
154
|
+
accessTokenExpiresAt:
|
|
155
|
+
refreshed.accessTokenExpiresAt || account.accessTokenExpiresAt,
|
|
156
|
+
refreshTokenExpiresAt:
|
|
157
|
+
refreshed.refreshTokenExpiresAt || account.refreshTokenExpiresAt,
|
|
158
|
+
idToken: refreshed.idToken || account.idToken,
|
|
159
|
+
scope: refreshed.scopes?.join(",") || account.scope,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function refreshEntraTokens(
|
|
164
|
+
account: EntraAccount,
|
|
165
|
+
context: Parameters<typeof decryptOAuthToken>[1] & {
|
|
166
|
+
socialProviders: unknown;
|
|
167
|
+
internalAdapter: {
|
|
168
|
+
updateAccount: (
|
|
169
|
+
accountId: string,
|
|
170
|
+
data: Record<string, unknown>,
|
|
171
|
+
) => Promise<unknown>;
|
|
172
|
+
};
|
|
173
|
+
},
|
|
174
|
+
): Promise<{
|
|
175
|
+
accessToken?: string;
|
|
176
|
+
refreshToken?: string;
|
|
177
|
+
accessTokenExpiresAt?: Date | null;
|
|
178
|
+
refreshTokenExpiresAt?: Date | null;
|
|
179
|
+
idToken?: string;
|
|
180
|
+
scopes?: string[];
|
|
181
|
+
} | null> {
|
|
182
|
+
const provider = await resolveEntraRefreshProvider(context.socialProviders);
|
|
183
|
+
if (!provider?.refreshAccessToken) {
|
|
184
|
+
console.warn(
|
|
185
|
+
"[better-auth] proactive refresh skipped: provider not resolvable",
|
|
186
|
+
);
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const refreshToken = await decryptOAuthToken(
|
|
191
|
+
account.refreshToken || "",
|
|
192
|
+
context,
|
|
193
|
+
);
|
|
194
|
+
const refreshed = await provider.refreshAccessToken(refreshToken);
|
|
195
|
+
await updateStoredEntraTokens(account, refreshed, context);
|
|
196
|
+
return refreshed;
|
|
197
|
+
}
|
|
4
198
|
|
|
5
199
|
const tenantName = process.env.NEXT_PUBLIC_ENTRA_EXTERNAL_ID_TENANT_NAME || "";
|
|
6
200
|
const loginAuthority = `https://${tenantName}.ciamlogin.com/${tenantName}.onmicrosoft.com`;
|
|
7
201
|
|
|
8
|
-
|
|
202
|
+
const globalForAuthDb = globalThis as typeof globalThis & {
|
|
203
|
+
storefrontAuthDbPool?: Pool;
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
const authDbPool =
|
|
207
|
+
globalForAuthDb.storefrontAuthDbPool ||
|
|
208
|
+
new Pool({
|
|
209
|
+
connectionString: process.env.AUTH_DB_URL,
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
if (process.env.NODE_ENV !== "production") {
|
|
213
|
+
globalForAuthDb.storefrontAuthDbPool = authDbPool;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const authConfig = {
|
|
9
217
|
secret: process.env.BETTER_AUTH_SECRET,
|
|
10
|
-
baseURL: process.env.BETTER_AUTH_URL
|
|
218
|
+
baseURL: process.env.BETTER_AUTH_URL,
|
|
219
|
+
database: authDbPool,
|
|
220
|
+
// Required for native screen login: Entra ROPC validates the password,
|
|
221
|
+
// Better Auth uses email/password internally only to create a session.
|
|
222
|
+
// The entra-external-id account with real OAuth tokens is upserted separately.
|
|
223
|
+
emailAndPassword: {
|
|
224
|
+
enabled: true,
|
|
225
|
+
},
|
|
11
226
|
session: {
|
|
12
227
|
cookieCache: {
|
|
13
|
-
enabled:
|
|
14
|
-
maxAge: 60 * 60, // 1 hour
|
|
15
|
-
strategy: "jwe",
|
|
16
|
-
refreshCache: true,
|
|
228
|
+
enabled: false,
|
|
17
229
|
},
|
|
18
230
|
},
|
|
19
231
|
account: {
|
|
@@ -24,10 +236,10 @@ export const auth = betterAuth({
|
|
|
24
236
|
genericOAuth({
|
|
25
237
|
config: [
|
|
26
238
|
{
|
|
239
|
+
// Entra External ID is configured as public client.
|
|
240
|
+
// No client secret is sent during refresh (Entra rejects it).
|
|
27
241
|
providerId: "entra-external-id",
|
|
28
242
|
clientId: process.env.NEXT_PUBLIC_ENTRA_EXTERNAL_ID_CLIENT_ID || "",
|
|
29
|
-
clientSecret:
|
|
30
|
-
process.env.NEXT_PUBLIC_ENTRA_EXTERNAL_ID_CLIENT_SECRET || "",
|
|
31
243
|
discoveryUrl: `${loginAuthority}/v2.0/.well-known/openid-configuration`,
|
|
32
244
|
scopes: (
|
|
33
245
|
process.env.NEXT_PUBLIC_ENTRA_EXTERNAL_ID_CUSTOM_SCOPES ||
|
|
@@ -60,30 +272,85 @@ export const auth = betterAuth({
|
|
|
60
272
|
},
|
|
61
273
|
],
|
|
62
274
|
}),
|
|
275
|
+
// Custom session plugin that:
|
|
276
|
+
// 1. Reads Entra account tokens from public.account table
|
|
277
|
+
// 2. Attempts proactive refresh 5 minutes before expiry
|
|
278
|
+
// 3. Prevents unexpected signout by keeping current token until truly expired
|
|
63
279
|
customSession(async ({ user, session }, ctx) => {
|
|
64
280
|
let accessToken = "";
|
|
281
|
+
let graphAccessToken = "";
|
|
65
282
|
try {
|
|
66
|
-
const accounts = await ctx.context.internalAdapter.findAccounts(
|
|
283
|
+
const accounts = (await ctx.context.internalAdapter.findAccounts(
|
|
67
284
|
user.id,
|
|
68
|
-
);
|
|
285
|
+
)) as EntraAccount[] | undefined;
|
|
69
286
|
const account = accounts?.find(
|
|
70
|
-
(
|
|
287
|
+
(storedAccount) => storedAccount.providerId === "entra-external-id",
|
|
71
288
|
);
|
|
72
|
-
|
|
73
|
-
|
|
289
|
+
if (!account) {
|
|
290
|
+
return {
|
|
291
|
+
user,
|
|
292
|
+
session: {
|
|
293
|
+
...session,
|
|
294
|
+
accessToken,
|
|
295
|
+
graphAccessToken,
|
|
296
|
+
},
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const tokenExpired = isPast(account.accessTokenExpiresAt);
|
|
301
|
+
const shouldRefreshUpfront = isWithinMs(
|
|
302
|
+
account.accessTokenExpiresAt,
|
|
303
|
+
ACCESS_TOKEN_REFRESH_WINDOW_MS,
|
|
304
|
+
);
|
|
305
|
+
const currentIdToken = account.idToken || "";
|
|
306
|
+
const currentGraphToken = await decryptStoredOAuthToken(
|
|
307
|
+
account.accessToken,
|
|
308
|
+
ctx.context,
|
|
309
|
+
);
|
|
310
|
+
|
|
311
|
+
// Keep ID token for D365 and access token for Graph while valid.
|
|
312
|
+
accessToken = tokenExpired ? "" : currentIdToken;
|
|
313
|
+
graphAccessToken = tokenExpired ? "" : currentGraphToken;
|
|
314
|
+
|
|
315
|
+
// Attempt proactive refresh if token is within 5-minute window and a refresh token exists.
|
|
316
|
+
// This ensures users stay signed in without waiting until token expires.
|
|
317
|
+
if (account.refreshToken && shouldRefreshUpfront) {
|
|
318
|
+
try {
|
|
319
|
+
const refreshed = await refreshEntraTokens(account, ctx.context);
|
|
320
|
+
if (refreshed) {
|
|
321
|
+
// Keep ID token for D365 and access token for Graph APIs.
|
|
322
|
+
accessToken = refreshed.idToken || account.idToken || accessToken;
|
|
323
|
+
graphAccessToken = refreshed.accessToken || graphAccessToken;
|
|
324
|
+
}
|
|
325
|
+
} catch (error) {
|
|
326
|
+
// Preserve the current token and avoid signing out early on refresh failures.
|
|
327
|
+
console.warn(
|
|
328
|
+
"[better-auth] proactive refresh failed for entra-external-id",
|
|
329
|
+
error,
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
74
333
|
} catch {
|
|
75
|
-
// Account data may not be accessible in all modes
|
|
334
|
+
// Account data may not be accessible in all modes.
|
|
335
|
+
// Keep existing empty default if we cannot read account data.
|
|
76
336
|
}
|
|
77
337
|
return {
|
|
78
338
|
user,
|
|
79
339
|
session: {
|
|
80
340
|
...session,
|
|
81
341
|
accessToken,
|
|
342
|
+
graphAccessToken,
|
|
82
343
|
},
|
|
83
344
|
};
|
|
84
345
|
}),
|
|
85
346
|
nextCookies(), // must be last plugin
|
|
86
347
|
],
|
|
87
|
-
}
|
|
348
|
+
} satisfies Parameters<typeof betterAuth>[0];
|
|
349
|
+
|
|
350
|
+
const auth: Auth<BetterAuthOptions> = betterAuth(
|
|
351
|
+
authConfig as BetterAuthOptions,
|
|
352
|
+
);
|
|
353
|
+
|
|
354
|
+
export { auth };
|
|
88
355
|
|
|
89
356
|
export type Session = typeof auth.$Infer.Session;
|
|
@@ -41,7 +41,7 @@ const componentMap = {
|
|
|
41
41
|
const getBlockComponent: <B extends Block>(
|
|
42
42
|
block: B,
|
|
43
43
|
idx: number,
|
|
44
|
-
) => JSX.Element | null = (block: Block) => {
|
|
44
|
+
) => React.JSX.Element | null = (block: Block) => {
|
|
45
45
|
const Component = componentMap[
|
|
46
46
|
block.__component as keyof typeof componentMap
|
|
47
47
|
] as React.ComponentType<Block> | undefined;
|
|
@@ -31,7 +31,9 @@ export default async function ProductSectionFullWidth(
|
|
|
31
31
|
|
|
32
32
|
const skus = data?.ProductWrapper?.map((element) => element?.product?.sku);
|
|
33
33
|
|
|
34
|
-
const e4Products = await getProductByIds.fetchData(queryClient, {
|
|
34
|
+
const e4Products = (await getProductByIds.fetchData(queryClient, {
|
|
35
|
+
id: skus,
|
|
36
|
+
})) as SimpleProduct[] | undefined;
|
|
35
37
|
|
|
36
38
|
if (!e4Products?.length) {
|
|
37
39
|
return null;
|
package/src/cms/endpoints.ts
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import type { Formats } from "@/cms/interfaces/product-data";
|
|
2
|
-
|
|
3
1
|
const apiKeys = {
|
|
4
2
|
pages: "/cms/pages",
|
|
5
3
|
blogs: "/cms/blogs",
|
|
@@ -42,6 +40,13 @@ export const extendCmsEndpoints = (
|
|
|
42
40
|
|
|
43
41
|
export type sizes = "small" | "medium" | "large" | "thumbnail";
|
|
44
42
|
|
|
43
|
+
interface Formats {
|
|
44
|
+
small?: { url: string };
|
|
45
|
+
medium?: { url: string };
|
|
46
|
+
large?: { url: string };
|
|
47
|
+
thumbnail?: { url: string };
|
|
48
|
+
}
|
|
49
|
+
|
|
45
50
|
/**
|
|
46
51
|
* @param formats formats array from CMS data
|
|
47
52
|
* @param size size of the image
|
|
@@ -6,9 +6,11 @@ import clsx from "clsx";
|
|
|
6
6
|
|
|
7
7
|
// ==============================================================
|
|
8
8
|
type Ellipsis = { ellipsis: number };
|
|
9
|
-
export
|
|
9
|
+
export type EllipsisProps = BoxProps & {
|
|
10
10
|
ellipsis?: boolean;
|
|
11
|
-
|
|
11
|
+
children?: React.ReactNode;
|
|
12
|
+
className?: string;
|
|
13
|
+
};
|
|
12
14
|
// ==============================================================
|
|
13
15
|
|
|
14
16
|
const StyledBox = styled(Box, {
|
|
@@ -40,7 +40,12 @@ const CategoryList = ({
|
|
|
40
40
|
children,
|
|
41
41
|
}: RootProps) => {
|
|
42
42
|
const { data: fetchedCategories } = getCategories.useSuspenseData();
|
|
43
|
-
|
|
43
|
+
let cats: typeof categories = [];
|
|
44
|
+
if (Array.isArray(categories)) {
|
|
45
|
+
cats = categories;
|
|
46
|
+
} else if (Array.isArray(fetchedCategories)) {
|
|
47
|
+
cats = fetchedCategories;
|
|
48
|
+
}
|
|
44
49
|
|
|
45
50
|
const megaMenuRenderers: Record<
|
|
46
51
|
MegaMenuType,
|
|
@@ -69,7 +74,8 @@ CategoryList.Item = ({ category }: ItemProps) => {
|
|
|
69
74
|
if (!ctx) return null;
|
|
70
75
|
const { megaMenuRenderers } = ctx;
|
|
71
76
|
const { RecordId, Name, Url, Children } = category;
|
|
72
|
-
const
|
|
77
|
+
const childCategories = Array.isArray(Children) ? Children : [];
|
|
78
|
+
const hasChildren = childCategories.length > 0;
|
|
73
79
|
|
|
74
80
|
// Sub menu can also be "categories/mega-menu/mega-menu-1" or "mega-menu-2",
|
|
75
81
|
// Mega menus have submenu items listed in columns and also have option to show banners(offers).
|
|
@@ -83,9 +89,7 @@ CategoryList.Item = ({ category }: ItemProps) => {
|
|
|
83
89
|
title={Name ?? ""}
|
|
84
90
|
caret={hasChildren}
|
|
85
91
|
render={
|
|
86
|
-
|
|
87
|
-
? megaMenuRenderers[menuType]?.(Children)
|
|
88
|
-
: null
|
|
92
|
+
hasChildren ? megaMenuRenderers[menuType]?.(childCategories) : null
|
|
89
93
|
}
|
|
90
94
|
/>
|
|
91
95
|
);
|
|
@@ -57,6 +57,10 @@ export default function CountryInput({
|
|
|
57
57
|
fullWidth
|
|
58
58
|
value={selectedCountry}
|
|
59
59
|
options={countries}
|
|
60
|
+
getOptionKey={(option) => option.CountryRegionId}
|
|
61
|
+
isOptionEqualToValue={(option, value) =>
|
|
62
|
+
option.CountryRegionId === value?.CountryRegionId
|
|
63
|
+
}
|
|
60
64
|
onChange={(_event, newValue) => {
|
|
61
65
|
setSelectedCountry(newValue);
|
|
62
66
|
}}
|
|
@@ -1,16 +1,13 @@
|
|
|
1
1
|
import signOut from "@evenicanpm/storefront-core/src/auth/signout";
|
|
2
2
|
import Header from "@evenicanpm/storefront-core/src/components/header/header";
|
|
3
|
-
import { fireEvent, render, screen
|
|
3
|
+
import { fireEvent, render, screen } from "@testing-library/react";
|
|
4
4
|
import { NextIntlClientProvider } from "next-intl";
|
|
5
5
|
import { vi } from "vitest";
|
|
6
6
|
|
|
7
|
-
const {
|
|
8
|
-
()
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
mockUseSession: vi.fn(),
|
|
12
|
-
}),
|
|
13
|
-
);
|
|
7
|
+
const { mockUseSession } = vi.hoisted(() => ({
|
|
8
|
+
mockMutate: vi.fn().mockResolvedValue({}),
|
|
9
|
+
mockUseSession: vi.fn(),
|
|
10
|
+
}));
|
|
14
11
|
|
|
15
12
|
vi.mock(
|
|
16
13
|
"@evenicanpm/storefront-core/src/api-manager/services/session/mutations/session-init",
|
|
@@ -19,13 +16,6 @@ vi.mock(
|
|
|
19
16
|
}),
|
|
20
17
|
);
|
|
21
18
|
|
|
22
|
-
vi.mock(
|
|
23
|
-
"@evenicanpm/storefront-core/src/api-manager/services/user/mutations/update-user",
|
|
24
|
-
() => ({
|
|
25
|
-
default: () => ({ mutateAsync: mockMutate }),
|
|
26
|
-
}),
|
|
27
|
-
);
|
|
28
|
-
|
|
29
19
|
vi.mock(
|
|
30
20
|
"@evenicanpm/storefront-core/src/api-manager/services/session/mutations/session-logout",
|
|
31
21
|
() => ({
|
|
@@ -53,13 +43,6 @@ vi.mock("better-auth/client/plugins", () => ({
|
|
|
53
43
|
genericOAuthClient: () => ({}),
|
|
54
44
|
}));
|
|
55
45
|
|
|
56
|
-
vi.mock("@evenicanpm/storefront-core/src/auth/msal", () => ({
|
|
57
|
-
getMsalInstance: () => ({
|
|
58
|
-
handleRedirectPromise: mockHandleRedirectPromise,
|
|
59
|
-
}),
|
|
60
|
-
initializeMsal: vi.fn().mockResolvedValue(undefined),
|
|
61
|
-
}));
|
|
62
|
-
|
|
63
46
|
vi.mock("@evenicanpm/storefront-core/src/auth/signout", () => ({
|
|
64
47
|
default: vi.fn(),
|
|
65
48
|
}));
|
|
@@ -149,89 +132,4 @@ describe.skip("User Component", async () => {
|
|
|
149
132
|
fireEvent.click(logoutButton);
|
|
150
133
|
expect(mockSignOut).toHaveBeenCalled();
|
|
151
134
|
});
|
|
152
|
-
|
|
153
|
-
it("handles MSAL redirect and updates user on success", async () => {
|
|
154
|
-
mockUseSession.mockReturnValue({
|
|
155
|
-
data: {
|
|
156
|
-
user: { email: "test@example.com" },
|
|
157
|
-
session: { accessToken: "mockAccessToken" },
|
|
158
|
-
},
|
|
159
|
-
isPending: false,
|
|
160
|
-
});
|
|
161
|
-
|
|
162
|
-
vi.mocked(mockHandleRedirectPromise).mockResolvedValue({
|
|
163
|
-
authority: "https://login.microsoftonline.com/some_authority",
|
|
164
|
-
uniqueId: "some_unique_id",
|
|
165
|
-
tenantId: "some_tenant_id",
|
|
166
|
-
tokenType: "id_token",
|
|
167
|
-
correlationId: "some_correlation_id",
|
|
168
|
-
idToken: "some_id_token",
|
|
169
|
-
accessToken: "some_access_token",
|
|
170
|
-
fromCache: false,
|
|
171
|
-
expiresOn: new Date(Date.now() + 3600000),
|
|
172
|
-
scopes: ["some_scope"],
|
|
173
|
-
account: {
|
|
174
|
-
homeAccountId: "some_home_account_id",
|
|
175
|
-
username: "some_username",
|
|
176
|
-
localAccountId: "some_local_account_id",
|
|
177
|
-
environment: "some_environment",
|
|
178
|
-
tenantId: "some_tenant_id",
|
|
179
|
-
},
|
|
180
|
-
idTokenClaims: {
|
|
181
|
-
given_name: "John",
|
|
182
|
-
family_name: "Doe",
|
|
183
|
-
},
|
|
184
|
-
});
|
|
185
|
-
|
|
186
|
-
render(
|
|
187
|
-
<NextIntlClientProvider locale="en" messages={messages}>
|
|
188
|
-
<Header>
|
|
189
|
-
<Header.User />
|
|
190
|
-
</Header>
|
|
191
|
-
</NextIntlClientProvider>,
|
|
192
|
-
);
|
|
193
|
-
|
|
194
|
-
await waitFor(() =>
|
|
195
|
-
expect(mockMutate).toHaveBeenCalledWith({
|
|
196
|
-
AccountNumber: "",
|
|
197
|
-
FirstName: "John",
|
|
198
|
-
LastName: "Doe",
|
|
199
|
-
}),
|
|
200
|
-
);
|
|
201
|
-
});
|
|
202
|
-
|
|
203
|
-
it("handles error in MSAL redirect", async () => {
|
|
204
|
-
const consoleErrorSpy = vi
|
|
205
|
-
.spyOn(console, "error")
|
|
206
|
-
.mockImplementation(vi.fn());
|
|
207
|
-
|
|
208
|
-
mockUseSession.mockReturnValue({
|
|
209
|
-
data: {
|
|
210
|
-
user: { email: "test@example.com" },
|
|
211
|
-
session: { accessToken: "mockAccessToken" },
|
|
212
|
-
},
|
|
213
|
-
isPending: false,
|
|
214
|
-
});
|
|
215
|
-
|
|
216
|
-
vi.mocked(mockHandleRedirectPromise).mockRejectedValue(
|
|
217
|
-
new Error("MSAL Error"),
|
|
218
|
-
);
|
|
219
|
-
|
|
220
|
-
render(
|
|
221
|
-
<NextIntlClientProvider locale="en" messages={messages}>
|
|
222
|
-
<Header>
|
|
223
|
-
<Header.User />
|
|
224
|
-
</Header>
|
|
225
|
-
</NextIntlClientProvider>,
|
|
226
|
-
);
|
|
227
|
-
|
|
228
|
-
await waitFor(() =>
|
|
229
|
-
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
230
|
-
"MSAL redirect error:",
|
|
231
|
-
expect.any(Error),
|
|
232
|
-
),
|
|
233
|
-
);
|
|
234
|
-
|
|
235
|
-
consoleErrorSpy.mockRestore();
|
|
236
|
-
});
|
|
237
135
|
});
|