@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.
Files changed (122) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/package.json +1 -1
  3. package/src/api-manager/datasources/d365/d365-cart.datasource.ts +4 -3
  4. package/src/api-manager/datasources/d365/d365-user.datasource.ts +3 -3
  5. package/src/api-manager/datasources/d365/utils/get-context-cookie.ts +1 -0
  6. package/src/api-manager/datasources/e4/address/e4-address.datasource.ts +5 -14
  7. package/src/api-manager/datasources/e4/address/e4-address.translator.ts +4 -3
  8. package/src/api-manager/datasources/e4/categories/e4-categories.translator.ts +1 -1
  9. package/src/api-manager/datasources/e4/graphqlRequestSdk.ts +29 -3
  10. package/src/api-manager/datasources/e4/product/e4-product.translator.ts +1 -1
  11. package/src/api-manager/index.ts +1 -1
  12. package/src/api-manager/lib/get-graphql-client.ts +1 -1
  13. package/src/api-manager/services/cart/mutations/add-discount-code.ts +0 -11
  14. package/src/api-manager/services/create-query.ts +13 -7
  15. package/src/api-manager/services/user/mutations/add-to-wishlist.ts +1 -1
  16. package/src/api-manager/services/user/mutations/remove-from-wishlist.ts +1 -1
  17. package/src/auth/AUTH GUIDE.md +880 -0
  18. package/src/cms/blocks/components/featured-categories.tsx +18 -15
  19. package/src/cms/blocks/components/featured-products.tsx +3 -2
  20. package/src/cms/blocks/components/footer/sections/footer-contact.tsx +0 -2
  21. package/src/cms/blocks/components/footer/sections/footer-logo.tsx +2 -1
  22. package/src/components/SearchInput.stories.tsx +12 -10
  23. package/src/components/base-layout.tsx +42 -0
  24. package/src/components/categories/category-list/category-list.tsx +32 -12
  25. package/src/components/header/__tests__/header.test.tsx +0 -24
  26. package/src/components/header/components/mobile-header.tsx +16 -8
  27. package/src/components/header/header.tsx +6 -3
  28. package/src/components/mini-cart/components/bottom-actions.tsx +7 -2
  29. package/src/components/mini-cart/components/cart-item.tsx +16 -13
  30. package/src/components/mini-cart/components/empty-view.tsx +12 -6
  31. package/src/components/mini-cart/components/top-header.tsx +9 -8
  32. package/src/components/mini-cart/mini-cart-trigger.tsx +11 -5
  33. package/src/components/mini-cart/mini-cart.tsx +30 -15
  34. package/src/components/mobile-navigation/mobile-navigation-bar.tsx +6 -2
  35. package/src/components/navbar/categories.tsx +8 -2
  36. package/src/components/navbar/category-based-menu/category-based-menu.tsx +6 -2
  37. package/src/components/navbar/mega-menu/mega-menu.tsx +22 -14
  38. package/src/components/navbar/mobile-menu/mobile-menu.test.tsx +8 -11
  39. package/src/components/navbar/mobile-menu/mobile-menu.tsx +13 -11
  40. package/src/components/navbar/nav-list/nav-list.tsx +3 -2
  41. package/src/components/navbar/navbar.tsx +4 -2
  42. package/src/components/product-cards/product-card/product-card.tsx +50 -26
  43. package/src/components/product-cards/product-card-plp/product-card.tsx +37 -16
  44. package/src/components/product-cards/product-card-plp-list/product-card.tsx +38 -16
  45. package/src/components/product-cards/product-card-search/product-card.tsx +34 -13
  46. package/src/components/product-dialog/ProductDialog.stories.tsx +20 -18
  47. package/src/components/product-dialog/compound/product-dialog.tsx +7 -4
  48. package/src/components/product-dimensions/compound/dimensions-group.tsx +4 -2
  49. package/src/components/product-dimensions/compound/dimensions.tsx +15 -6
  50. package/src/components/product-quantity-buttons/compound/quantity-buttons-root.tsx +21 -13
  51. package/src/components/products-view/compound/products-grid-view.tsx +11 -4
  52. package/src/components/products-view/compound/products-list-view.tsx +11 -4
  53. package/src/components/search-bar/compound/search-bar-root.tsx +6 -4
  54. package/src/components/search-bar/compound/textfield-adornment-category.tsx +25 -8
  55. package/src/components/search-bar/hooks/use-click-outside.ts +7 -1
  56. package/src/components/search-bar/hooks/use-search.ts +3 -3
  57. package/src/components/user/index.ts +3 -0
  58. package/src/components/user/use-user-auth.ts +163 -0
  59. package/src/components/user/user.tsx +215 -0
  60. package/src/components/wishlist-dialogs/add-to-wishlist/AddToWishlist.stories.tsx +20 -15
  61. package/src/components/wishlist-dialogs/add-to-wishlist/compound/add-to-wishlist-dialog.tsx +11 -8
  62. package/src/components/wishlist-dialogs/add-to-wishlist/compound/add-to-wishlist-list.tsx +18 -3
  63. package/src/components/wishlist-dialogs/add-to-wishlist/index.tsx +0 -1
  64. package/src/components/wishlist-dialogs/create-wishlist/compound/create-wishlist-dialog.tsx +6 -2
  65. package/src/hooks/use-overflow-detect.ts +4 -4
  66. package/src/lib/page-slugs.ts +16 -0
  67. package/src/pages/account/account-navigation.tsx +7 -14
  68. package/src/pages/account/account-routes.ts +4 -9
  69. package/src/pages/account/addresses/address-form.tsx +38 -16
  70. package/src/pages/account/addresses/address-item.tsx +18 -8
  71. package/src/pages/account/dashboard-header.tsx +20 -13
  72. package/src/pages/account/orders/order-history-filters-panel.tsx +21 -20
  73. package/src/pages/account/orders/order-history-header.tsx +14 -4
  74. package/src/pages/account/orders/order-history-root.tsx +3 -1
  75. package/src/pages/account/orders/order-history-row.tsx +12 -4
  76. package/src/pages/account/orders/order-history-search-and-filter.tsx +88 -59
  77. package/src/pages/account/orders/order-history-search-dropdown.tsx +26 -9
  78. package/src/pages/account/orders/order-history-sort.tsx +3 -1
  79. package/src/pages/account/orders/order-history-table.tsx +27 -5
  80. package/src/pages/account/orders/order-progress.tsx +12 -6
  81. package/src/pages/account/orders/order-row.tsx +18 -6
  82. package/src/pages/account/orders/order-summary.tsx +24 -8
  83. package/src/pages/account/orders/ordered-products.tsx +15 -5
  84. package/src/pages/account/profile/user-info.tsx +11 -3
  85. package/src/pages/account/table-row-skeleton.tsx +1 -1
  86. package/src/pages/account/wishlist/create-new-list.tsx +33 -9
  87. package/src/pages/account/wishlist/wishlist-item.tsx +18 -6
  88. package/src/pages/cart/cart-item.tsx +21 -7
  89. package/src/pages/cart/checkout-form.tsx +55 -22
  90. package/src/pages/cart/coupon-entry.tsx +143 -36
  91. package/src/pages/cart/estimate-shipping.tsx +45 -21
  92. package/src/pages/checkout/checkout-alt-form/checkout-form.tsx +22 -14
  93. package/src/pages/checkout/checkout-alt-form/checkout-step.tsx +37 -16
  94. package/src/pages/checkout/checkout-alt-form/steps/address/address-card.tsx +9 -3
  95. package/src/pages/checkout/checkout-alt-form/steps/address/delivery-address.tsx +84 -51
  96. package/src/pages/checkout/checkout-alt-form/steps/address/new-address-form.tsx +41 -19
  97. package/src/pages/checkout/checkout-alt-form/steps/customer-info/customer-information.tsx +17 -10
  98. package/src/pages/checkout/checkout-alt-form/steps/payment/payment-details.tsx +15 -6
  99. package/src/pages/checkout/checkout-alt-form/steps/shipping/delivery-options.tsx +6 -2
  100. package/src/pages/checkout/checkout-alt-summary/cart-item.tsx +12 -4
  101. package/src/pages/checkout/checkout-alt-summary/checkout-alt-summary.tsx +12 -5
  102. package/src/pages/checkout/checkout-alt-summary/list-item.tsx +17 -10
  103. package/src/pages/confirmation/address.tsx +6 -2
  104. package/src/pages/confirmation/confirmation-summary.tsx +12 -4
  105. package/src/pages/confirmation/ordered-products.tsx +12 -4
  106. package/src/pages/product-details/bopis/store-card/shop-card.tsx +2 -2
  107. package/src/pages/product-details/bopis/store-card/utils/getDaysResources.ts +1 -1
  108. package/src/pages/product-details/product-intro/compound/product-info.tsx +41 -18
  109. package/src/pages/product-details/product-intro/compound/product-intro-images.tsx +18 -12
  110. package/src/pages/product-details/product-intro/compound/product-intro.tsx +20 -10
  111. package/src/pages/product-details/product-intro/compound/thumbnail-with-skeleton.tsx +1 -1
  112. package/src/pages/product-details/product-tabs.tsx +20 -12
  113. package/src/pages/product-list/product-list-view.tsx +4 -2
  114. package/src/pages/product-list/search-bar.tsx +7 -7
  115. package/src/pages/product-list/side-nav.tsx +5 -3
  116. package/src/pages/quickorder/order-upload.tsx +1 -1
  117. package/src/pages/quickorder/provider.tsx +26 -14
  118. package/src/pages/quickorder/quick-order.tsx +49 -22
  119. package/src/providers/nav-provider/index.tsx +16 -11
  120. package/tsconfig.json +0 -5
  121. package/src/components/header/components/user.tsx +0 -322
  122. /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={{ products, productPrices }}>
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
- ProductsListView.List = ({
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
- ProductsListView.Card = ({ product, price }: ProductCardProps) => {
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
- <MenuItem
86
+ <CategoryOptionItem
67
87
  key={item.id}
68
- onClick={() => {
69
- handleChange(item.id);
70
- onClose();
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 (ref.current && !ref.current.contains(e.target as Node)) callback();
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, setSelectedCategory] = React.useState<number>(0);
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
- setSelectedCategory(categoryId);
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
- setSelectedCategory(0);
40
+ setSearchCategory(0);
41
41
  };
42
42
 
43
43
  const handleSearch = _.debounce((e) => {
@@ -0,0 +1,3 @@
1
+ export type { AuthStatus, UseUserAuthResult } from "./use-user-auth";
2
+ export { default as useUserAuth } from "./use-user-auth";
3
+ export { default } from "./user";
@@ -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
- <WishListItemContext.Provider value={{ wishlist }} key={wishlist.Id}>
52
+ <WishListItemProvider wishlist={wishlist} key={wishlist.Id}>
38
53
  {children}
39
- </WishListItemContext.Provider>
54
+ </WishListItemProvider>
40
55
  ));
41
56
  };
42
57
 
@@ -4,7 +4,6 @@ interface Props {
4
4
  open: boolean;
5
5
  handleClose: () => void;
6
6
  productId?: number;
7
- onSuccess?: () => void;
8
7
  }
9
8
 
10
9
  /**