@evenicanpm/storefront-core 2.4.1 → 2.5.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.
- package/CHANGELOG.md +16 -0
- package/package.json +1 -1
- package/src/api-manager/datasources/d365/d365-cart.datasource.ts +4 -3
- package/src/api-manager/datasources/d365/d365-user.datasource.ts +3 -3
- package/src/api-manager/datasources/d365/utils/get-context-cookie.ts +1 -0
- package/src/api-manager/datasources/e4/address/e4-address.datasource.ts +5 -14
- package/src/api-manager/datasources/e4/address/e4-address.translator.ts +4 -3
- package/src/api-manager/datasources/e4/categories/e4-categories.translator.ts +1 -1
- package/src/api-manager/datasources/e4/graphqlRequestSdk.ts +29 -3
- package/src/api-manager/datasources/e4/product/e4-product.translator.ts +1 -1
- package/src/api-manager/index.ts +1 -1
- package/src/api-manager/lib/get-graphql-client.ts +1 -1
- package/src/api-manager/services/cart/mutations/add-discount-code.ts +0 -11
- package/src/api-manager/services/create-query.ts +13 -7
- package/src/api-manager/services/user/mutations/add-to-wishlist.ts +1 -1
- package/src/api-manager/services/user/mutations/remove-from-wishlist.ts +1 -1
- package/src/auth/AUTH GUIDE.md +880 -0
- package/src/cms/blocks/components/featured-categories.tsx +18 -15
- package/src/cms/blocks/components/featured-products.tsx +3 -2
- package/src/cms/blocks/components/footer/sections/footer-contact.tsx +0 -2
- package/src/cms/blocks/components/footer/sections/footer-logo.tsx +2 -1
- package/src/components/SearchInput.stories.tsx +12 -10
- package/src/components/base-layout.tsx +42 -0
- package/src/components/categories/category-list/category-list.tsx +32 -12
- package/src/components/header/__tests__/header.test.tsx +0 -24
- package/src/components/header/components/mobile-header.tsx +16 -8
- package/src/components/header/header.tsx +6 -3
- package/src/components/mini-cart/components/bottom-actions.tsx +7 -2
- package/src/components/mini-cart/components/cart-item.tsx +16 -13
- package/src/components/mini-cart/components/empty-view.tsx +12 -6
- package/src/components/mini-cart/components/top-header.tsx +9 -8
- package/src/components/mini-cart/mini-cart-trigger.tsx +11 -5
- package/src/components/mini-cart/mini-cart.tsx +30 -15
- package/src/components/mobile-navigation/mobile-navigation-bar.tsx +6 -2
- package/src/components/navbar/categories.tsx +8 -2
- package/src/components/navbar/category-based-menu/category-based-menu.tsx +6 -2
- package/src/components/navbar/mega-menu/mega-menu.tsx +22 -14
- package/src/components/navbar/mobile-menu/mobile-menu.test.tsx +8 -11
- package/src/components/navbar/mobile-menu/mobile-menu.tsx +13 -11
- package/src/components/navbar/nav-list/nav-list.tsx +3 -2
- package/src/components/navbar/navbar.tsx +4 -2
- package/src/components/product-cards/product-card/product-card.tsx +50 -26
- package/src/components/product-cards/product-card-plp/product-card.tsx +37 -16
- package/src/components/product-cards/product-card-plp-list/product-card.tsx +38 -16
- package/src/components/product-cards/product-card-search/product-card.tsx +34 -13
- package/src/components/product-dialog/ProductDialog.stories.tsx +20 -18
- package/src/components/product-dialog/compound/product-dialog.tsx +7 -4
- package/src/components/product-dimensions/compound/dimensions-group.tsx +4 -2
- package/src/components/product-dimensions/compound/dimensions.tsx +15 -6
- package/src/components/product-quantity-buttons/compound/quantity-buttons-root.tsx +21 -13
- package/src/components/products-view/compound/products-grid-view.tsx +11 -4
- package/src/components/products-view/compound/products-list-view.tsx +11 -4
- package/src/components/search-bar/compound/search-bar-root.tsx +6 -4
- package/src/components/search-bar/compound/textfield-adornment-category.tsx +25 -8
- package/src/components/search-bar/hooks/use-click-outside.ts +7 -1
- package/src/components/search-bar/hooks/use-search.ts +3 -3
- package/src/components/user/index.ts +3 -0
- package/src/components/user/use-user-auth.ts +163 -0
- package/src/components/user/user.tsx +215 -0
- package/src/components/wishlist-dialogs/add-to-wishlist/AddToWishlist.stories.tsx +20 -15
- package/src/components/wishlist-dialogs/add-to-wishlist/compound/add-to-wishlist-dialog.tsx +11 -8
- package/src/components/wishlist-dialogs/add-to-wishlist/compound/add-to-wishlist-list.tsx +18 -3
- package/src/components/wishlist-dialogs/add-to-wishlist/index.tsx +0 -1
- package/src/components/wishlist-dialogs/create-wishlist/compound/create-wishlist-dialog.tsx +6 -2
- package/src/hooks/use-overflow-detect.ts +4 -4
- package/src/lib/page-slugs.ts +16 -0
- package/src/pages/account/account-navigation.tsx +7 -14
- package/src/pages/account/account-routes.ts +4 -9
- package/src/pages/account/addresses/address-form.tsx +38 -16
- package/src/pages/account/addresses/address-item.tsx +18 -8
- package/src/pages/account/dashboard-header.tsx +20 -13
- package/src/pages/account/orders/order-history-filters-panel.tsx +21 -20
- package/src/pages/account/orders/order-history-header.tsx +14 -4
- package/src/pages/account/orders/order-history-root.tsx +3 -1
- package/src/pages/account/orders/order-history-row.tsx +12 -4
- package/src/pages/account/orders/order-history-search-and-filter.tsx +88 -59
- package/src/pages/account/orders/order-history-search-dropdown.tsx +26 -9
- package/src/pages/account/orders/order-history-sort.tsx +3 -1
- package/src/pages/account/orders/order-history-table.tsx +27 -5
- package/src/pages/account/orders/order-progress.tsx +12 -6
- package/src/pages/account/orders/order-row.tsx +18 -6
- package/src/pages/account/orders/order-summary.tsx +24 -8
- package/src/pages/account/orders/ordered-products.tsx +15 -5
- package/src/pages/account/profile/user-info.tsx +11 -3
- package/src/pages/account/table-row-skeleton.tsx +1 -1
- package/src/pages/account/wishlist/create-new-list.tsx +33 -9
- package/src/pages/account/wishlist/wishlist-item.tsx +18 -6
- package/src/pages/cart/cart-item.tsx +21 -7
- package/src/pages/cart/checkout-form.tsx +55 -22
- package/src/pages/cart/coupon-entry.tsx +143 -36
- package/src/pages/cart/estimate-shipping.tsx +45 -21
- package/src/pages/checkout/checkout-alt-form/checkout-form.tsx +22 -14
- package/src/pages/checkout/checkout-alt-form/checkout-step.tsx +37 -16
- package/src/pages/checkout/checkout-alt-form/steps/address/address-card.tsx +9 -3
- package/src/pages/checkout/checkout-alt-form/steps/address/delivery-address.tsx +84 -51
- package/src/pages/checkout/checkout-alt-form/steps/address/new-address-form.tsx +41 -19
- package/src/pages/checkout/checkout-alt-form/steps/customer-info/customer-information.tsx +17 -10
- package/src/pages/checkout/checkout-alt-form/steps/payment/payment-details.tsx +15 -6
- package/src/pages/checkout/checkout-alt-form/steps/shipping/delivery-options.tsx +6 -2
- package/src/pages/checkout/checkout-alt-summary/cart-item.tsx +12 -4
- package/src/pages/checkout/checkout-alt-summary/checkout-alt-summary.tsx +12 -5
- package/src/pages/checkout/checkout-alt-summary/list-item.tsx +17 -10
- package/src/pages/confirmation/address.tsx +6 -2
- package/src/pages/confirmation/confirmation-summary.tsx +12 -4
- package/src/pages/confirmation/ordered-products.tsx +12 -4
- package/src/pages/product-details/bopis/store-card/shop-card.tsx +2 -2
- package/src/pages/product-details/bopis/store-card/utils/getDaysResources.ts +1 -1
- package/src/pages/product-details/product-intro/compound/product-info.tsx +41 -18
- package/src/pages/product-details/product-intro/compound/product-intro-images.tsx +18 -12
- package/src/pages/product-details/product-intro/compound/product-intro.tsx +20 -10
- package/src/pages/product-details/product-intro/compound/thumbnail-with-skeleton.tsx +1 -1
- package/src/pages/product-details/product-tabs.tsx +20 -12
- package/src/pages/product-list/product-list-view.tsx +4 -2
- package/src/pages/product-list/search-bar.tsx +7 -7
- package/src/pages/product-list/side-nav.tsx +5 -3
- package/src/pages/quickorder/order-upload.tsx +1 -1
- package/src/pages/quickorder/provider.tsx +26 -14
- package/src/pages/quickorder/quick-order.tsx +49 -22
- package/src/providers/nav-provider/index.tsx +16 -11
- package/tsconfig.json +0 -5
- package/src/components/header/components/user.tsx +0 -322
- /package/src/components/{header → user}/__tests__/user.test.tsx +0 -0
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
import type { ProductsViewProps } from "@evenicanpm/storefront-core/src/components/products-view/compound/types";
|
|
14
14
|
// DATA MODEL
|
|
15
15
|
import type { ProductSearchResult as Product } from "@msdyn365-commerce/retail-proxy";
|
|
16
|
-
import { cloneElement } from "react";
|
|
16
|
+
import { cloneElement, useMemo } from "react";
|
|
17
17
|
|
|
18
18
|
/**
|
|
19
19
|
* Root component of the ProductsListView.
|
|
@@ -21,8 +21,12 @@ import { cloneElement } from "react";
|
|
|
21
21
|
export default function ProductsListView(props: Readonly<ProductsViewProps>) {
|
|
22
22
|
const { pagination, products, productPrices, children } = props;
|
|
23
23
|
console.log(props);
|
|
24
|
+
const contextValue = useMemo(
|
|
25
|
+
() => ({ products, productPrices }),
|
|
26
|
+
[products, productPrices],
|
|
27
|
+
);
|
|
24
28
|
return (
|
|
25
|
-
<ProductsViewContext.Provider value={
|
|
29
|
+
<ProductsViewContext.Provider value={contextValue}>
|
|
26
30
|
{children}
|
|
27
31
|
<FlexBetween flexWrap="wrap" mt={4}>
|
|
28
32
|
{pagination}
|
|
@@ -38,7 +42,7 @@ export default function ProductsListView(props: Readonly<ProductsViewProps>) {
|
|
|
38
42
|
* @category Sub-Component
|
|
39
43
|
*
|
|
40
44
|
*/
|
|
41
|
-
|
|
45
|
+
const ProductsList = ({
|
|
42
46
|
children,
|
|
43
47
|
}: {
|
|
44
48
|
children: React.ReactElement<Record<string, unknown>>;
|
|
@@ -76,7 +80,7 @@ export interface ProductCardProps {
|
|
|
76
80
|
* @param props.price The product price data.
|
|
77
81
|
* @category Sub-Component
|
|
78
82
|
*/
|
|
79
|
-
|
|
83
|
+
const ProductsListCard = ({ product, price }: ProductCardProps) => {
|
|
80
84
|
if (!product) return null;
|
|
81
85
|
return (
|
|
82
86
|
<ProductCardPlpList
|
|
@@ -110,3 +114,6 @@ ProductsListView.Card = ({ product, price }: ProductCardProps) => {
|
|
|
110
114
|
</ProductCardPlpList>
|
|
111
115
|
);
|
|
112
116
|
};
|
|
117
|
+
|
|
118
|
+
ProductsListView.List = ProductsList;
|
|
119
|
+
ProductsListView.Card = ProductsListCard;
|
|
@@ -13,7 +13,7 @@ import { useClickOutside } from "@evenicanpm/storefront-core/src/components/sear
|
|
|
13
13
|
import useSearch from "@evenicanpm/storefront-core/src/components/search-bar/hooks/use-search";
|
|
14
14
|
import Box from "@mui/material/Box";
|
|
15
15
|
import type React from "react";
|
|
16
|
-
import { useRef } from "react";
|
|
16
|
+
import { useMemo, useRef } from "react";
|
|
17
17
|
|
|
18
18
|
interface Props {
|
|
19
19
|
handleCloseDrawer?: () => void;
|
|
@@ -39,11 +39,13 @@ export default function SearchBarRoot({
|
|
|
39
39
|
const { setters, handlers, state } = useSearch();
|
|
40
40
|
const inputRef = useRef(null);
|
|
41
41
|
useClickOutside(inputRef, () => setters.setResultsOpen(false));
|
|
42
|
+
const contextValue = useMemo(
|
|
43
|
+
() => ({ setters, handlers, state, handleCloseDrawer }),
|
|
44
|
+
[setters, handlers, state, handleCloseDrawer],
|
|
45
|
+
);
|
|
42
46
|
|
|
43
47
|
return (
|
|
44
|
-
<SearchContext.Provider
|
|
45
|
-
value={{ setters, handlers, state, handleCloseDrawer }}
|
|
46
|
-
>
|
|
48
|
+
<SearchContext.Provider value={contextValue}>
|
|
47
49
|
<Box
|
|
48
50
|
position="relative"
|
|
49
51
|
flex="1 1 0"
|
|
@@ -17,6 +17,26 @@ type DisplayCategory = {
|
|
|
17
17
|
id: number;
|
|
18
18
|
};
|
|
19
19
|
|
|
20
|
+
const CategoryOptionItem = ({
|
|
21
|
+
item,
|
|
22
|
+
onClose,
|
|
23
|
+
handleChange,
|
|
24
|
+
}: {
|
|
25
|
+
item: DisplayCategory;
|
|
26
|
+
onClose: () => void;
|
|
27
|
+
handleChange: (categoryId: number) => void;
|
|
28
|
+
}) => (
|
|
29
|
+
<MenuItem
|
|
30
|
+
key={item.id}
|
|
31
|
+
onClick={() => {
|
|
32
|
+
handleChange(item.id);
|
|
33
|
+
onClose();
|
|
34
|
+
}}
|
|
35
|
+
>
|
|
36
|
+
{item.name}
|
|
37
|
+
</MenuItem>
|
|
38
|
+
);
|
|
39
|
+
|
|
20
40
|
/**
|
|
21
41
|
* @category Props
|
|
22
42
|
*/
|
|
@@ -63,15 +83,12 @@ const CategoryDropdown = ({
|
|
|
63
83
|
return options
|
|
64
84
|
?.filter((current: DisplayCategory) => current.name !== title)
|
|
65
85
|
.map((item: DisplayCategory) => (
|
|
66
|
-
<
|
|
86
|
+
<CategoryOptionItem
|
|
67
87
|
key={item.id}
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
>
|
|
73
|
-
{item.name}
|
|
74
|
-
</MenuItem>
|
|
88
|
+
item={item}
|
|
89
|
+
onClose={onClose}
|
|
90
|
+
handleChange={handleChange}
|
|
91
|
+
/>
|
|
75
92
|
));
|
|
76
93
|
}}
|
|
77
94
|
/>
|
|
@@ -9,7 +9,13 @@ export const useClickOutside = (
|
|
|
9
9
|
) => {
|
|
10
10
|
useEffect(() => {
|
|
11
11
|
const handleClick = (e: Event) => {
|
|
12
|
-
if (
|
|
12
|
+
if (
|
|
13
|
+
ref.current &&
|
|
14
|
+
e.target instanceof Node &&
|
|
15
|
+
!ref.current.contains(e.target)
|
|
16
|
+
) {
|
|
17
|
+
callback();
|
|
18
|
+
}
|
|
13
19
|
};
|
|
14
20
|
|
|
15
21
|
document.addEventListener("click", handleClick);
|
|
@@ -12,14 +12,14 @@ export default function useSearch() {
|
|
|
12
12
|
// State
|
|
13
13
|
const [resultsOpen, setResultsOpen] = useState(false);
|
|
14
14
|
const [searchText, setSearchText] = React.useState<string>("");
|
|
15
|
-
const [searchCategory,
|
|
15
|
+
const [searchCategory, setSearchCategory] = React.useState<number>(0);
|
|
16
16
|
|
|
17
17
|
// Hooks
|
|
18
18
|
const router = useRouter();
|
|
19
19
|
|
|
20
20
|
// Handlers
|
|
21
21
|
const handleCategoryChange = (categoryId: number) => {
|
|
22
|
-
|
|
22
|
+
setSearchCategory(categoryId);
|
|
23
23
|
};
|
|
24
24
|
|
|
25
25
|
const handleNavigate = () => {
|
|
@@ -37,7 +37,7 @@ export default function useSearch() {
|
|
|
37
37
|
handleNavigate();
|
|
38
38
|
setResultsOpen(false);
|
|
39
39
|
setSearchText("");
|
|
40
|
-
|
|
40
|
+
setSearchCategory(0);
|
|
41
41
|
};
|
|
42
42
|
|
|
43
43
|
const handleSearch = _.debounce((e) => {
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import useSessionInit from "@evenicanpm/storefront-core/src/api-manager/services/session/mutations/session-init";
|
|
4
|
+
import useSessionLogout from "@evenicanpm/storefront-core/src/api-manager/services/session/mutations/session-logout";
|
|
5
|
+
import useUpdateUser from "@evenicanpm/storefront-core/src/api-manager/services/user/mutations/update-user";
|
|
6
|
+
import getUser from "@evenicanpm/storefront-core/src/api-manager/services/user/queries/get-user";
|
|
7
|
+
import signOutCustom from "@evenicanpm/storefront-core/src/auth/signout";
|
|
8
|
+
import {
|
|
9
|
+
customSessionClient,
|
|
10
|
+
genericOAuthClient,
|
|
11
|
+
} from "better-auth/client/plugins";
|
|
12
|
+
import { createAuthClient } from "better-auth/react";
|
|
13
|
+
import { useEffect, useRef } from "react";
|
|
14
|
+
|
|
15
|
+
const authClient = createAuthClient({
|
|
16
|
+
plugins: [genericOAuthClient(), customSessionClient()],
|
|
17
|
+
});
|
|
18
|
+
const { useSession: useBetterAuthSession } = authClient;
|
|
19
|
+
|
|
20
|
+
/** Represents the current authentication lifecycle state. */
|
|
21
|
+
export type AuthStatus = "loading" | "authenticated" | "unauthenticated";
|
|
22
|
+
|
|
23
|
+
/** Return value of {@link useUserAuth}. */
|
|
24
|
+
export interface UseUserAuthResult {
|
|
25
|
+
/** `true` when a valid session exists. */
|
|
26
|
+
isAuthenticated: boolean;
|
|
27
|
+
/** Current authentication lifecycle state. */
|
|
28
|
+
status: AuthStatus;
|
|
29
|
+
/** Raw Better Auth session data, or `null` / `undefined` when unauthenticated. */
|
|
30
|
+
session: ReturnType<typeof useBetterAuthSession>["data"];
|
|
31
|
+
/** Terminates the current session and redirects via {@link signOutCustom}. */
|
|
32
|
+
handleLogout: () => Promise<void>;
|
|
33
|
+
/** Redirects to `/login` with the current path as the `callbackUrl`. */
|
|
34
|
+
handleSignIn: () => void;
|
|
35
|
+
/** Low-level sign-out helper (clears the local auth cookie / token). */
|
|
36
|
+
signOut: () => Promise<void>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Manages authentication state for the storefront user.
|
|
41
|
+
*
|
|
42
|
+
* - Derives `isAuthenticated` and `status` from the Better Auth session.
|
|
43
|
+
* - For the **E4** datasource, calls `sessionInit` on sign-in to register the
|
|
44
|
+
* access token with the backend.
|
|
45
|
+
* - For the **D365** datasource, back-fills `FirstName` / `LastName` from the
|
|
46
|
+
* auth session when the commerce profile has no name yet, preventing stale
|
|
47
|
+
* overwrites on subsequent renders.
|
|
48
|
+
*
|
|
49
|
+
* @returns Auth state and action handlers consumed by the `User` header component.
|
|
50
|
+
*/
|
|
51
|
+
const useUserAuth = (): UseUserAuthResult => {
|
|
52
|
+
const { data: session, isPending } = useBetterAuthSession();
|
|
53
|
+
|
|
54
|
+
let status: AuthStatus = "unauthenticated";
|
|
55
|
+
if (isPending) {
|
|
56
|
+
status = "loading";
|
|
57
|
+
} else if (session) {
|
|
58
|
+
status = "authenticated";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const isAuthenticated = !!session;
|
|
62
|
+
const isE4Datasource = (process.env.API_MANAGER_PRIMARY ?? "e4") === "e4";
|
|
63
|
+
const isD365Datasource = !isE4Datasource;
|
|
64
|
+
|
|
65
|
+
const { mutateAsync: sessionInit } = useSessionInit();
|
|
66
|
+
const { mutateAsync: sessionLogout } = useSessionLogout();
|
|
67
|
+
const { mutateAsync: updateUser } = useUpdateUser();
|
|
68
|
+
const syncedUserKeyRef = useRef<string>("");
|
|
69
|
+
|
|
70
|
+
const { data: currentUser, isPending: isUserPending } = getUser.useData();
|
|
71
|
+
|
|
72
|
+
// session init for E4 datasource
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
const accessToken =
|
|
75
|
+
(session?.session as { accessToken?: string } | undefined)?.accessToken ||
|
|
76
|
+
"";
|
|
77
|
+
|
|
78
|
+
if (
|
|
79
|
+
isE4Datasource &&
|
|
80
|
+
isAuthenticated &&
|
|
81
|
+
session?.user?.email &&
|
|
82
|
+
accessToken
|
|
83
|
+
) {
|
|
84
|
+
sessionInit({ accessToken, email: session.user.email }).catch(
|
|
85
|
+
console.error,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
}, [isE4Datasource, isAuthenticated, session, sessionInit]);
|
|
89
|
+
|
|
90
|
+
// For D365, only seed profile details from the auth session when D365 does not
|
|
91
|
+
// already have a name. This avoids overwriting explicit profile edits with a
|
|
92
|
+
// stale session.user.name snapshot.
|
|
93
|
+
useEffect(() => {
|
|
94
|
+
if (!isD365Datasource || !isAuthenticated || isUserPending) return;
|
|
95
|
+
|
|
96
|
+
const existingFirstName =
|
|
97
|
+
typeof currentUser?.FirstName === "string"
|
|
98
|
+
? currentUser.FirstName.trim()
|
|
99
|
+
: "";
|
|
100
|
+
const existingLastName =
|
|
101
|
+
typeof currentUser?.LastName === "string"
|
|
102
|
+
? currentUser.LastName.trim()
|
|
103
|
+
: "";
|
|
104
|
+
if (existingFirstName || existingLastName) return;
|
|
105
|
+
|
|
106
|
+
const fullName =
|
|
107
|
+
typeof session?.user?.name === "string" ? session.user.name.trim() : "";
|
|
108
|
+
const [firstName = "", ...lastParts] = fullName
|
|
109
|
+
.split(/\s+/)
|
|
110
|
+
.filter(Boolean);
|
|
111
|
+
const lastName = lastParts.join(" ");
|
|
112
|
+
|
|
113
|
+
if (!firstName && !lastName) return;
|
|
114
|
+
|
|
115
|
+
const syncKey = `${session?.user?.email || ""}:${firstName}:${lastName}`;
|
|
116
|
+
if (syncedUserKeyRef.current === syncKey) return;
|
|
117
|
+
|
|
118
|
+
syncedUserKeyRef.current = syncKey;
|
|
119
|
+
updateUser({
|
|
120
|
+
AccountNumber: "",
|
|
121
|
+
Email: session?.user?.email || undefined,
|
|
122
|
+
FirstName: firstName || undefined,
|
|
123
|
+
LastName: lastName || undefined,
|
|
124
|
+
}).catch((error) => {
|
|
125
|
+
syncedUserKeyRef.current = "";
|
|
126
|
+
console.warn("D365 user sync error:", error);
|
|
127
|
+
});
|
|
128
|
+
}, [
|
|
129
|
+
currentUser,
|
|
130
|
+
isD365Datasource,
|
|
131
|
+
isAuthenticated,
|
|
132
|
+
isUserPending,
|
|
133
|
+
session,
|
|
134
|
+
updateUser,
|
|
135
|
+
]);
|
|
136
|
+
|
|
137
|
+
const handleLogout = async () => {
|
|
138
|
+
try {
|
|
139
|
+
await sessionLogout({});
|
|
140
|
+
signOutCustom();
|
|
141
|
+
} catch (err) {
|
|
142
|
+
console.error("Logout Error:", err);
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const handleSignIn = () => {
|
|
147
|
+
const callbackUrl = encodeURIComponent(
|
|
148
|
+
globalThis.location.pathname + globalThis.location.search,
|
|
149
|
+
);
|
|
150
|
+
globalThis.location.href = `/login?callbackUrl=${callbackUrl}`;
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
isAuthenticated,
|
|
155
|
+
status,
|
|
156
|
+
session,
|
|
157
|
+
handleLogout,
|
|
158
|
+
handleSignIn,
|
|
159
|
+
signOut: signOutCustom,
|
|
160
|
+
};
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
export default useUserAuth;
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import MobileUserIcon from "@evenicanpm/storefront-core/src/components/icons/user";
|
|
4
|
+
import { NavLink } from "@evenicanpm/storefront-core/src/components/nav-link";
|
|
5
|
+
import T from "@evenicanpm/storefront-core/src/components/T";
|
|
6
|
+
import {
|
|
7
|
+
type AccountRoute,
|
|
8
|
+
useCreateAccountRoutes,
|
|
9
|
+
} from "@evenicanpm/storefront-core/src/pages/account/account-routes";
|
|
10
|
+
// UI Components
|
|
11
|
+
import {
|
|
12
|
+
Box,
|
|
13
|
+
Button,
|
|
14
|
+
IconButton,
|
|
15
|
+
List,
|
|
16
|
+
ListItemButton,
|
|
17
|
+
Popover,
|
|
18
|
+
} from "@mui/material";
|
|
19
|
+
import { useRouter } from "next/navigation";
|
|
20
|
+
import type React from "react";
|
|
21
|
+
import {
|
|
22
|
+
createContext,
|
|
23
|
+
type MouseEvent,
|
|
24
|
+
type ReactNode,
|
|
25
|
+
useContext,
|
|
26
|
+
useMemo,
|
|
27
|
+
useState,
|
|
28
|
+
} from "react";
|
|
29
|
+
import { MdPersonOutline as PersonOutline } from "react-icons/md";
|
|
30
|
+
import useUserAuth from "./use-user-auth";
|
|
31
|
+
|
|
32
|
+
const routeToMenuItem = (route: AccountRoute) => ({
|
|
33
|
+
href: route.href,
|
|
34
|
+
label: `Account.Menu.${route.titleKey}`,
|
|
35
|
+
Icon: route.Icon,
|
|
36
|
+
count: route.count,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const useBuildMenuItems = () => {
|
|
40
|
+
const routes = useCreateAccountRoutes();
|
|
41
|
+
return [
|
|
42
|
+
routeToMenuItem(routes.profile),
|
|
43
|
+
routeToMenuItem(routes.orders),
|
|
44
|
+
routeToMenuItem(routes.wishlists),
|
|
45
|
+
routeToMenuItem(routes.invoices),
|
|
46
|
+
routeToMenuItem(routes.addresses),
|
|
47
|
+
];
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
type MenuItem = { href: string; label: string };
|
|
51
|
+
|
|
52
|
+
interface UserContextValue {
|
|
53
|
+
isAuthenticated: boolean;
|
|
54
|
+
status: string;
|
|
55
|
+
menuItems: MenuItem[];
|
|
56
|
+
handleLogout: () => Promise<void>;
|
|
57
|
+
handleSignIn: () => void;
|
|
58
|
+
signOut: () => Promise<void>;
|
|
59
|
+
anchorEl: HTMLButtonElement | null;
|
|
60
|
+
setAnchorEl: (el: HTMLButtonElement | null) => void;
|
|
61
|
+
open: boolean;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const UserContext = createContext<UserContextValue | undefined>(undefined);
|
|
65
|
+
|
|
66
|
+
interface Props {
|
|
67
|
+
children?: ReactNode;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const User = ({ children }: Props) => {
|
|
71
|
+
const { isAuthenticated, status, handleLogout, handleSignIn, signOut } =
|
|
72
|
+
useUserAuth();
|
|
73
|
+
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement | null>(null);
|
|
74
|
+
const open = Boolean(anchorEl);
|
|
75
|
+
const menuItems = useBuildMenuItems();
|
|
76
|
+
|
|
77
|
+
const value: UserContextValue = useMemo(
|
|
78
|
+
() => ({
|
|
79
|
+
isAuthenticated,
|
|
80
|
+
status,
|
|
81
|
+
menuItems,
|
|
82
|
+
handleLogout,
|
|
83
|
+
handleSignIn,
|
|
84
|
+
signOut,
|
|
85
|
+
anchorEl,
|
|
86
|
+
setAnchorEl,
|
|
87
|
+
open,
|
|
88
|
+
}),
|
|
89
|
+
[
|
|
90
|
+
isAuthenticated,
|
|
91
|
+
status,
|
|
92
|
+
menuItems,
|
|
93
|
+
handleLogout,
|
|
94
|
+
handleSignIn,
|
|
95
|
+
signOut,
|
|
96
|
+
anchorEl,
|
|
97
|
+
setAnchorEl,
|
|
98
|
+
open,
|
|
99
|
+
],
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
if (status === "loading") return null;
|
|
103
|
+
|
|
104
|
+
return <UserContext.Provider value={value}>{children}</UserContext.Provider>;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
// Subcomponents
|
|
108
|
+
User.MobileTrigger = function MobileTrigger() {
|
|
109
|
+
const ctx = useContext(UserContext);
|
|
110
|
+
const router = useRouter();
|
|
111
|
+
if (!ctx) return null;
|
|
112
|
+
const { isAuthenticated, handleSignIn } = ctx;
|
|
113
|
+
return (
|
|
114
|
+
<IconButton
|
|
115
|
+
aria-label="User Menu"
|
|
116
|
+
onClick={() => {
|
|
117
|
+
if (isAuthenticated) router.push("/account/profile");
|
|
118
|
+
else handleSignIn();
|
|
119
|
+
}}
|
|
120
|
+
>
|
|
121
|
+
<MobileUserIcon style={{ fontSize: "1.25rem" }} />
|
|
122
|
+
</IconButton>
|
|
123
|
+
);
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
User.DesktopTrigger = function DesktopTrigger() {
|
|
127
|
+
const ctx = useContext(UserContext);
|
|
128
|
+
if (!ctx) return null;
|
|
129
|
+
const { setAnchorEl } = ctx;
|
|
130
|
+
return (
|
|
131
|
+
<IconButton
|
|
132
|
+
aria-label="User Menu"
|
|
133
|
+
onClick={(e: MouseEvent<HTMLButtonElement>) =>
|
|
134
|
+
setAnchorEl(e.currentTarget)
|
|
135
|
+
}
|
|
136
|
+
>
|
|
137
|
+
<PersonOutline />
|
|
138
|
+
</IconButton>
|
|
139
|
+
);
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
User.Menu = function Menu({
|
|
143
|
+
children,
|
|
144
|
+
}: {
|
|
145
|
+
children?: (items: MenuItem[]) => React.ReactNode;
|
|
146
|
+
}) {
|
|
147
|
+
const ctx = useContext(UserContext);
|
|
148
|
+
if (!ctx) return null;
|
|
149
|
+
const { anchorEl, open, setAnchorEl, menuItems, handleLogout } = ctx;
|
|
150
|
+
const id = open ? "account-popover" : undefined;
|
|
151
|
+
|
|
152
|
+
return (
|
|
153
|
+
<Popover
|
|
154
|
+
id={id}
|
|
155
|
+
open={open}
|
|
156
|
+
anchorEl={anchorEl}
|
|
157
|
+
onClose={() => setAnchorEl(null)}
|
|
158
|
+
anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
|
|
159
|
+
transformOrigin={{ vertical: "top", horizontal: "right" }}
|
|
160
|
+
>
|
|
161
|
+
<List sx={{ p: 1 }} onClick={() => setAnchorEl(null)}>
|
|
162
|
+
{children
|
|
163
|
+
? children(menuItems) // Allow caller to control what gets rendered
|
|
164
|
+
: menuItems.map(({ href, label }) => (
|
|
165
|
+
<User.MenuItem key={href} href={href} label={label} />
|
|
166
|
+
))}
|
|
167
|
+
<Box p={1}>
|
|
168
|
+
<Button
|
|
169
|
+
fullWidth
|
|
170
|
+
size="small"
|
|
171
|
+
color="primary"
|
|
172
|
+
variant="outlined"
|
|
173
|
+
onClick={handleLogout}
|
|
174
|
+
>
|
|
175
|
+
<T path="Account.Menu.btnLogout" />
|
|
176
|
+
</Button>
|
|
177
|
+
</Box>
|
|
178
|
+
</List>
|
|
179
|
+
</Popover>
|
|
180
|
+
);
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
User.MenuItem = function MenuItem({
|
|
184
|
+
href,
|
|
185
|
+
label,
|
|
186
|
+
}: {
|
|
187
|
+
href: string;
|
|
188
|
+
label: string;
|
|
189
|
+
}) {
|
|
190
|
+
return (
|
|
191
|
+
<NavLink href={href} style={{ color: "inherit" }}>
|
|
192
|
+
<ListItemButton>
|
|
193
|
+
<T path={label} />
|
|
194
|
+
</ListItemButton>
|
|
195
|
+
</NavLink>
|
|
196
|
+
);
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
User.LoginButton = function LoginButton() {
|
|
200
|
+
const ctx = useContext(UserContext);
|
|
201
|
+
if (!ctx) return null;
|
|
202
|
+
const { handleSignIn } = ctx;
|
|
203
|
+
return (
|
|
204
|
+
<Button
|
|
205
|
+
size="small"
|
|
206
|
+
variant="contained"
|
|
207
|
+
color="primary"
|
|
208
|
+
onClick={() => handleSignIn()}
|
|
209
|
+
>
|
|
210
|
+
<T path="Account.Menu.btnLogin" />
|
|
211
|
+
</Button>
|
|
212
|
+
);
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
export default User;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Button } from "@mui/material";
|
|
2
2
|
import type { Meta, StoryObj } from "@storybook/react";
|
|
3
|
+
import type React from "react";
|
|
3
4
|
import { useArgs } from "storybook/internal/preview-api";
|
|
4
5
|
import { data } from "../../../../__mocks__/products";
|
|
5
6
|
import AddToWishListDialog1 from "./index";
|
|
@@ -13,25 +14,29 @@ const meta = {
|
|
|
13
14
|
export default meta;
|
|
14
15
|
type Story = StoryObj<typeof meta>;
|
|
15
16
|
|
|
17
|
+
const WithTriggerRender = (
|
|
18
|
+
args: React.ComponentProps<typeof AddToWishListDialog1>,
|
|
19
|
+
) => {
|
|
20
|
+
const [, setArgs] = useArgs();
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<>
|
|
24
|
+
<Button variant="contained" onClick={() => setArgs({ open: true })}>
|
|
25
|
+
Add To Wishlist
|
|
26
|
+
</Button>
|
|
27
|
+
<AddToWishListDialog1
|
|
28
|
+
{...args}
|
|
29
|
+
handleClose={() => setArgs({ open: false })}
|
|
30
|
+
/>
|
|
31
|
+
</>
|
|
32
|
+
);
|
|
33
|
+
};
|
|
34
|
+
|
|
16
35
|
export const WithTrigger: Story = {
|
|
17
36
|
args: {
|
|
18
37
|
productId: Number(data.products[0].ItemId),
|
|
19
38
|
open: false,
|
|
20
39
|
handleClose: () => {},
|
|
21
40
|
},
|
|
22
|
-
render: (args) => {
|
|
23
|
-
const [, setArgs] = useArgs();
|
|
24
|
-
|
|
25
|
-
return (
|
|
26
|
-
<>
|
|
27
|
-
<Button variant="contained" onClick={() => setArgs({ open: true })}>
|
|
28
|
-
Add To Wishlist
|
|
29
|
-
</Button>
|
|
30
|
-
<AddToWishListDialog1
|
|
31
|
-
{...args}
|
|
32
|
-
handleClose={() => setArgs({ open: false })}
|
|
33
|
-
/>
|
|
34
|
-
</>
|
|
35
|
-
);
|
|
36
|
-
},
|
|
41
|
+
render: (args) => <WithTriggerRender {...args} />,
|
|
37
42
|
};
|
|
@@ -10,6 +10,7 @@ import DialogHeader from "@evenicanpm/storefront-core/src/components/wishlist-di
|
|
|
10
10
|
import { Dialog, DialogContent } from "@mui/material";
|
|
11
11
|
import { createAuthClient } from "better-auth/react";
|
|
12
12
|
import type React from "react";
|
|
13
|
+
import { useMemo } from "react";
|
|
13
14
|
|
|
14
15
|
const { useSession } = createAuthClient();
|
|
15
16
|
|
|
@@ -57,16 +58,18 @@ export default function AddToWishlistDialog({
|
|
|
57
58
|
handleClose();
|
|
58
59
|
}
|
|
59
60
|
};
|
|
61
|
+
const contextValue = useMemo(
|
|
62
|
+
() => ({
|
|
63
|
+
wishlists,
|
|
64
|
+
productId,
|
|
65
|
+
isAuthenticated,
|
|
66
|
+
handleClose: handleCloseDialog,
|
|
67
|
+
}),
|
|
68
|
+
[wishlists, productId, isAuthenticated, handleCloseDialog],
|
|
69
|
+
);
|
|
60
70
|
|
|
61
71
|
return (
|
|
62
|
-
<AddToWishListDialogContext.Provider
|
|
63
|
-
value={{
|
|
64
|
-
wishlists,
|
|
65
|
-
productId,
|
|
66
|
-
isAuthenticated,
|
|
67
|
-
handleClose: handleCloseDialog,
|
|
68
|
-
}}
|
|
69
|
-
>
|
|
72
|
+
<AddToWishListDialogContext.Provider value={contextValue}>
|
|
70
73
|
<Dialog
|
|
71
74
|
open={open}
|
|
72
75
|
onClick={(e) => {
|
|
@@ -11,7 +11,22 @@ import {
|
|
|
11
11
|
} from "@evenicanpm/storefront-core/src/components/wishlist-dialogs/add-to-wishlist/compound/context";
|
|
12
12
|
// LOCAL COMPONENTS
|
|
13
13
|
import WishlistItem from "@evenicanpm/storefront-core/src/components/wishlist-dialogs/add-to-wishlist/compound/wishlist-dialog-item";
|
|
14
|
-
import { useContext } from "react";
|
|
14
|
+
import { useContext, useMemo } from "react";
|
|
15
|
+
|
|
16
|
+
function WishListItemProvider({
|
|
17
|
+
wishlist,
|
|
18
|
+
children,
|
|
19
|
+
}: Readonly<{
|
|
20
|
+
wishlist: Wishlist;
|
|
21
|
+
children: React.ReactNode;
|
|
22
|
+
}>) {
|
|
23
|
+
const contextValue = useMemo(() => ({ wishlist }), [wishlist]);
|
|
24
|
+
return (
|
|
25
|
+
<WishListItemContext.Provider value={contextValue}>
|
|
26
|
+
{children}
|
|
27
|
+
</WishListItemContext.Provider>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
15
30
|
|
|
16
31
|
/**
|
|
17
32
|
* Maps out the list items (wishlists) or displays no records.
|
|
@@ -34,9 +49,9 @@ const WishlistList = ({ children }: { children: React.ReactNode }) => {
|
|
|
34
49
|
}
|
|
35
50
|
|
|
36
51
|
return wishlists?.map((wishlist: Wishlist) => (
|
|
37
|
-
<
|
|
52
|
+
<WishListItemProvider wishlist={wishlist} key={wishlist.Id}>
|
|
38
53
|
{children}
|
|
39
|
-
</
|
|
54
|
+
</WishListItemProvider>
|
|
40
55
|
));
|
|
41
56
|
};
|
|
42
57
|
|