@evenicanpm/storefront-core 2.1.0 → 2.3.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 (81) hide show
  1. package/package.json +9 -4
  2. package/src/api-manager/README.md +52 -27
  3. package/src/api-manager/datasources/d365/d365-cart.datasource.ts +166 -17
  4. package/src/api-manager/datasources/d365/d365-invoice.datasource.ts +100 -0
  5. package/src/api-manager/datasources/d365/d365-order.datasource.ts +75 -18
  6. package/src/api-manager/datasources/d365/d365-product.datasource.ts +19 -1
  7. package/src/api-manager/datasources/d365/d365-user.datasource.ts +96 -22
  8. package/src/api-manager/datasources/d365/d365.datasource.ts +3 -0
  9. package/src/api-manager/datasources/d365/utils/get-context-cookie.ts +14 -5
  10. package/src/api-manager/datasources/e4/e4.datasource.ts +9 -0
  11. package/src/api-manager/datasources/e4/middleware.ts +35 -0
  12. package/src/api-manager/datasources/e4/order/e4-order.datasource.ts +7 -4
  13. package/src/api-manager/datasources/e4/user/e4-user.datasource.ts +4 -0
  14. package/src/api-manager/index.ts +54 -3
  15. package/src/api-manager/lib/get-graphql-client.ts +13 -6
  16. package/src/api-manager/schemas/cart.schema.ts +10 -1
  17. package/src/api-manager/schemas/invoice.schema.ts +55 -0
  18. package/src/api-manager/schemas/order.schema.ts +13 -1
  19. package/src/api-manager/schemas/user.schema.ts +8 -0
  20. package/src/api-manager/services/create-query.ts +5 -56
  21. package/src/api-manager/services/invoice/queries/get-invoice-details.ts +18 -0
  22. package/src/api-manager/services/invoice/queries/get-invoices.ts +18 -0
  23. package/src/api-manager/services/order/queries/get-order-details.ts +1 -1
  24. package/src/api-manager/services/order/queries/get-orders.ts +6 -3
  25. package/src/api-manager/services/user/queries/get-customer-balance.ts +20 -0
  26. package/src/api-manager/types/Datasource.ts +21 -2
  27. package/src/auth/better-auth.ts +89 -0
  28. package/src/auth/signout.ts +5 -34
  29. package/src/cms/blocks/block-manager.tsx +18 -3
  30. package/src/cms/blocks/components/cta-banner.tsx +65 -0
  31. package/src/cms/blocks/components/hero-image.tsx +83 -0
  32. package/src/cms/blocks/index.tsx +2 -0
  33. package/src/cms/blocks/interfaces.ts +20 -0
  34. package/src/cms/draft-mode-badge.tsx +26 -0
  35. package/src/cms/queries.ts +81 -0
  36. package/src/components/flex-box/flex-box.tsx +2 -0
  37. package/src/components/header/__tests__/header.test.tsx +9 -2
  38. package/src/components/header/__tests__/user.test.tsx +34 -19
  39. package/src/components/header/components/user.tsx +70 -29
  40. package/src/components/mini-cart/mini-cart.tsx +3 -3
  41. package/src/components/wishlist-dialogs/add-to-wishlist/compound/add-to-wishlist-dialog.tsx +3 -1
  42. package/src/components/wishlist-dialogs/add-to-wishlist/compound/wishlist-dialog-header.tsx +4 -1
  43. package/src/hooks/use-nextauth-session.ts +10 -14
  44. package/src/pages/account/account-navigation.tsx +88 -60
  45. package/src/pages/account/account-routes.ts +52 -0
  46. package/src/pages/account/orders/order-history-filter-types.tsx +26 -0
  47. package/src/pages/account/orders/order-history-filters-panel.tsx +375 -0
  48. package/src/pages/account/orders/order-history-header.tsx +54 -0
  49. package/src/pages/account/orders/order-history-pagination.tsx +55 -0
  50. package/src/pages/account/orders/order-history-root.tsx +125 -0
  51. package/src/pages/account/orders/order-history-row.tsx +110 -0
  52. package/src/pages/account/orders/order-history-search-and-filter.tsx +449 -0
  53. package/src/pages/account/orders/order-history-search-bar.tsx +84 -0
  54. package/src/pages/account/orders/order-history-search-dropdown.tsx +53 -0
  55. package/src/pages/account/orders/order-history-sort.tsx +90 -0
  56. package/src/pages/account/orders/order-history-table.tsx +54 -0
  57. package/src/pages/account/orders/order-row.tsx +2 -2
  58. package/src/pages/account/orders/orders-drop-down-handler.tsx +19 -0
  59. package/src/pages/account/table-header.tsx +20 -0
  60. package/src/pages/account/wishlist/wishlist-item.tsx +1 -1
  61. package/src/pages/blog/blog-card.tsx +9 -3
  62. package/src/pages/blog/blog-detail-view.tsx +150 -0
  63. package/src/pages/blog/blog-list-view.tsx +59 -0
  64. package/src/pages/cart/__tests__/checkout-form.test.tsx +4 -2
  65. package/src/pages/cart/estimate-shipping.tsx +6 -5
  66. package/src/pages/checkout/checkout-alt-form/checkout-form.tsx +43 -21
  67. package/src/pages/checkout/checkout-alt-form/steps/payment/payment-details.tsx +144 -19
  68. package/src/pages/cms-page-view.tsx +53 -0
  69. package/src/pages/quickorder/order-upload.tsx +12 -6
  70. package/src/pages/quickorder/quick-order.tsx +25 -20
  71. package/src/api-manager/datasources/d365/utils/decode-jwt.ts +0 -10
  72. package/src/auth/auth-options.test.ts +0 -70
  73. package/src/auth/auth-options.ts +0 -101
  74. package/src/auth/next-auth-cookie-manager.ts +0 -87
  75. package/src/auth/providers/aadb2c-provider.ts +0 -32
  76. package/src/auth/providers/authentik-provider.ts +0 -24
  77. package/src/auth/providers/entra-external-id-provider.ts +0 -50
  78. package/src/auth/providers/index.ts +0 -16
  79. package/src/auth/providers/keycloak-provider.ts +0 -22
  80. package/src/auth/refresh-token.ts +0 -132
  81. package/src/auth/types/next-auth.d.ts +0 -38
@@ -4,6 +4,14 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
4
4
  import { NextIntlClientProvider } from "next-intl";
5
5
  import { vi } from "vitest";
6
6
 
7
+ const { mockMutate, mockHandleRedirectPromise, mockUseSession } = vi.hoisted(
8
+ () => ({
9
+ mockMutate: vi.fn().mockResolvedValue({}),
10
+ mockHandleRedirectPromise: vi.fn().mockResolvedValue(null),
11
+ mockUseSession: vi.fn(),
12
+ }),
13
+ );
14
+
7
15
  vi.mock(
8
16
  "@evenicanpm/storefront-core/src/api-manager/services/session/mutations/session-init",
9
17
  () => ({
@@ -11,10 +19,6 @@ vi.mock(
11
19
  }),
12
20
  );
13
21
 
14
- const mockMutate = vi.fn().mockResolvedValue({});
15
- const mockHandleRedirectPromise = vi.fn().mockResolvedValue(null);
16
- const mockUseSession = vi.fn();
17
-
18
22
  vi.mock(
19
23
  "@evenicanpm/storefront-core/src/api-manager/services/user/mutations/update-user",
20
24
  () => ({
@@ -36,9 +40,17 @@ vi.mock("next/navigation", () => ({
36
40
  usePathname: () => "/mock-path",
37
41
  }));
38
42
 
39
- vi.doMock("next-auth/react", () => ({
40
- useSession: mockUseSession,
41
- signIn: vi.fn(),
43
+ vi.mock("better-auth/react", () => {
44
+ return {
45
+ createAuthClient: () => ({
46
+ useSession: mockUseSession,
47
+ signIn: { oauth2: vi.fn() },
48
+ }),
49
+ };
50
+ });
51
+
52
+ vi.mock("better-auth/client/plugins", () => ({
53
+ genericOAuthClient: () => ({}),
42
54
  }));
43
55
 
44
56
  vi.mock("@evenicanpm/storefront-core/src/auth/msal", () => ({
@@ -58,7 +70,7 @@ describe.skip("User Component", async () => {
58
70
  it("renders login button when user is not authenticated", () => {
59
71
  mockUseSession.mockReturnValue({
60
72
  data: null,
61
- status: "unauthenticated",
73
+ isPending: false,
62
74
  });
63
75
  render(
64
76
  <NextIntlClientProvider locale="en" messages={messages}>
@@ -75,9 +87,9 @@ describe.skip("User Component", async () => {
75
87
  mockUseSession.mockReturnValue({
76
88
  data: {
77
89
  user: { email: "test@example.com" },
78
- accessToken: "mockAccessToken",
90
+ session: { accessToken: "mockAccessToken" },
79
91
  },
80
- status: "authenticated",
92
+ isPending: false,
81
93
  });
82
94
 
83
95
  render(
@@ -94,10 +106,10 @@ describe.skip("User Component", async () => {
94
106
  expect(userMenuButton).toBeInTheDocument();
95
107
  });
96
108
 
97
- it("calls signOut when session.error is present", () => {
109
+ it("calls signOut when session is null after pending", () => {
98
110
  mockUseSession.mockReturnValue({
99
- data: { error: "Some auth error" },
100
- status: "authenticated",
111
+ data: null,
112
+ isPending: false,
101
113
  });
102
114
 
103
115
  render(
@@ -113,8 +125,11 @@ describe.skip("User Component", async () => {
113
125
  const mockSignOut = vi.mocked(signOut);
114
126
 
115
127
  mockUseSession.mockReturnValue({
116
- data: { user: { email: "test@example.com" }, accessToken: "token" },
117
- status: "authenticated",
128
+ data: {
129
+ user: { email: "test@example.com" },
130
+ session: { accessToken: "token" },
131
+ },
132
+ isPending: false,
118
133
  });
119
134
 
120
135
  render(
@@ -139,9 +154,9 @@ describe.skip("User Component", async () => {
139
154
  mockUseSession.mockReturnValue({
140
155
  data: {
141
156
  user: { email: "test@example.com" },
142
- accessToken: "mockAccessToken",
157
+ session: { accessToken: "mockAccessToken" },
143
158
  },
144
- status: "authenticated",
159
+ isPending: false,
145
160
  });
146
161
 
147
162
  vi.mocked(mockHandleRedirectPromise).mockResolvedValue({
@@ -193,9 +208,9 @@ describe.skip("User Component", async () => {
193
208
  mockUseSession.mockReturnValue({
194
209
  data: {
195
210
  user: { email: "test@example.com" },
196
- accessToken: "mockAccessToken",
211
+ session: { accessToken: "mockAccessToken" },
197
212
  },
198
- status: "authenticated",
213
+ isPending: false,
199
214
  });
200
215
 
201
216
  vi.mocked(mockHandleRedirectPromise).mockRejectedValue(
@@ -4,6 +4,7 @@ import useSessionInit from "@evenicanpm/storefront-core/src/api-manager/services
4
4
  import useSessionLogout from "@evenicanpm/storefront-core/src/api-manager/services/session/mutations/session-logout";
5
5
  // API hooks
6
6
  import useUpdateUser from "@evenicanpm/storefront-core/src/api-manager/services/user/mutations/update-user";
7
+ import getUser from "@evenicanpm/storefront-core/src/api-manager/services/user/queries/get-user";
7
8
  import {
8
9
  getMsalInstance,
9
10
  initializeMsal,
@@ -12,6 +13,11 @@ import signOutCustom from "@evenicanpm/storefront-core/src/auth/signout";
12
13
  import MobileUserIcon from "@evenicanpm/storefront-core/src/components/icons/user";
13
14
  import { NavLink } from "@evenicanpm/storefront-core/src/components/nav-link";
14
15
  import T from "@evenicanpm/storefront-core/src/components/T";
16
+ import {
17
+ type AccountRoute,
18
+ type AccountRouteOverrides,
19
+ createAccountRoutes,
20
+ } from "@evenicanpm/storefront-core/src/pages/account/account-routes";
15
21
  // UI Components
16
22
  import {
17
23
  Box,
@@ -21,9 +27,16 @@ import {
21
27
  ListItemButton,
22
28
  Popover,
23
29
  } from "@mui/material";
24
- import { useRouter } from "next/navigation";
30
+ import { genericOAuthClient } from "better-auth/client/plugins";
25
31
  // Auth components
26
- import { signIn, useSession } from "next-auth/react";
32
+ import { createAuthClient } from "better-auth/react";
33
+ import { useRouter } from "next/navigation";
34
+
35
+ const authClient = createAuthClient({
36
+ plugins: [genericOAuthClient()],
37
+ });
38
+ const { useSession: useBetterAuthSession } = authClient;
39
+
27
40
  import type React from "react";
28
41
  import {
29
42
  createContext,
@@ -40,19 +53,32 @@ interface IdTokenClaims {
40
53
  family_name?: string;
41
54
  }
42
55
 
43
- const MENU_ITEMS = [
44
- { href: "/account/profile", label: "Account.Menu.profile" },
45
- { href: "/account/addresses", label: "Account.Menu.addresses" },
46
- { href: "/account/orders", label: "Account.Menu.orders" },
47
- { href: "/account/wish-lists", label: "Account.Menu.wishlists" },
48
- ];
56
+ const routeToMenuItem = (route: AccountRoute) => ({
57
+ href: route.href,
58
+ label: `Account.Menu.${route.titleKey}`,
59
+ Icon: route.Icon,
60
+ count: route.count,
61
+ });
62
+
63
+ const buildMenuItems = (overrides?: AccountRouteOverrides) => {
64
+ const routes = createAccountRoutes(overrides);
65
+ return [
66
+ routeToMenuItem(routes.profile),
67
+ routeToMenuItem(routes.orders),
68
+ routeToMenuItem(routes.wishlists),
69
+ routeToMenuItem(routes.invoices),
70
+ routeToMenuItem(routes.addresses),
71
+ ];
72
+ };
73
+
74
+ type MenuItem = { href: string; label: string };
49
75
 
50
76
  interface UserContextValue {
51
77
  isAuthenticated: boolean;
52
78
  status: string;
53
- menuItems: typeof MENU_ITEMS;
79
+ menuItems: MenuItem[];
54
80
  handleLogout: () => Promise<void>;
55
- signIn: typeof signIn;
81
+ handleSignIn: () => void;
56
82
  signOut: () => Promise<void>;
57
83
  anchorEl: HTMLButtonElement | null;
58
84
  setAnchorEl: (el: HTMLButtonElement | null) => void;
@@ -63,30 +89,36 @@ const UserContext = createContext<UserContextValue | undefined>(undefined);
63
89
 
64
90
  interface Props {
65
91
  children?: ReactNode;
92
+ routeOverrides?: AccountRouteOverrides;
66
93
  }
67
94
 
68
- const User = ({ children }: Props) => {
69
- const { data: session, status } = useSession();
95
+ const User = ({ children, routeOverrides }: Props) => {
96
+ const { data: session, isPending } = useBetterAuthSession();
97
+ const status = isPending
98
+ ? "loading"
99
+ : session
100
+ ? "authenticated"
101
+ : "unauthenticated";
70
102
 
71
- if (session?.error) {
72
- // Custom error set in server - /api/auth/[...nextauth]
73
- console.warn("Auth Error", session?.error);
74
- signOutCustom();
103
+ if (session === null && !isPending) {
104
+ // No error handling needed - session is simply null when not authenticated
75
105
  }
76
106
 
77
107
  const isAuthenticated = !!session;
78
108
  const { mutateAsync: sessionInit } = useSessionInit();
79
109
  const { mutateAsync: updateUser } = useUpdateUser();
80
110
  const { mutateAsync: sessionLogout } = useSessionLogout();
111
+ // Register the "me" query on the client so the hydrated cache entry has a queryFn for refetches
112
+ getUser.useData();
81
113
 
82
114
  // session init
83
115
  useEffect(() => {
84
- if (isAuthenticated) {
85
- const { accessToken, user } = session;
86
- const email = user?.email;
87
- if (accessToken && email) {
88
- sessionInit({ accessToken, email });
89
- }
116
+ if (isAuthenticated && session?.user?.email) {
117
+ const accessToken =
118
+ (session.session as { accessToken?: string })?.accessToken || "";
119
+ sessionInit({ accessToken, email: session.user.email }).catch(
120
+ console.error,
121
+ );
90
122
  }
91
123
  }, [isAuthenticated, session, sessionInit]);
92
124
 
@@ -124,15 +156,24 @@ const User = ({ children }: Props) => {
124
156
  }
125
157
  };
126
158
 
159
+ const handleSignIn = () => {
160
+ authClient.signIn.oauth2({
161
+ providerId: "entra-external-id",
162
+ callbackURL: "/",
163
+ });
164
+ };
165
+
127
166
  const [anchorEl, setAnchorEl] = useState<HTMLButtonElement | null>(null);
128
167
  const open = Boolean(anchorEl);
129
168
 
169
+ const menuItems = buildMenuItems(routeOverrides);
170
+
130
171
  const value: UserContextValue = {
131
172
  isAuthenticated,
132
173
  status,
133
- menuItems: MENU_ITEMS,
174
+ menuItems,
134
175
  handleLogout,
135
- signIn,
176
+ handleSignIn,
136
177
  signOut: signOutCustom,
137
178
  anchorEl,
138
179
  setAnchorEl,
@@ -149,13 +190,13 @@ User.MobileTrigger = function MobileTrigger() {
149
190
  const ctx = useContext(UserContext);
150
191
  const router = useRouter();
151
192
  if (!ctx) return null;
152
- const { isAuthenticated, signIn } = ctx;
193
+ const { isAuthenticated, handleSignIn } = ctx;
153
194
  return (
154
195
  <IconButton
155
196
  aria-label="User Menu"
156
197
  onClick={() => {
157
198
  if (isAuthenticated) router.push("/account/profile");
158
- else signIn();
199
+ else handleSignIn();
159
200
  }}
160
201
  >
161
202
  <MobileUserIcon style={{ fontSize: "1.25rem" }} />
@@ -182,7 +223,7 @@ User.DesktopTrigger = function DesktopTrigger() {
182
223
  User.Menu = function Menu({
183
224
  children,
184
225
  }: {
185
- children?: (items: typeof MENU_ITEMS) => React.ReactNode;
226
+ children?: (items: MenuItem[]) => React.ReactNode;
186
227
  }) {
187
228
  const ctx = useContext(UserContext);
188
229
  if (!ctx) return null;
@@ -239,13 +280,13 @@ User.MenuItem = function MenuItem({
239
280
  User.LoginButton = function LoginButton() {
240
281
  const ctx = useContext(UserContext);
241
282
  if (!ctx) return null;
242
- const { signIn } = ctx;
283
+ const { handleSignIn } = ctx;
243
284
  return (
244
285
  <Button
245
286
  size="small"
246
287
  variant="contained"
247
288
  color="primary"
248
- onClick={() => signIn()}
289
+ onClick={() => handleSignIn()}
249
290
  >
250
291
  <T path="Account.Menu.btnLogin" />
251
292
  </Button>
@@ -121,8 +121,8 @@ MiniCart.Items = () => {
121
121
  } = ctx;
122
122
 
123
123
  return (
124
- <Box height={`calc(100vh - ${cart?.CartLines.length ? "207px" : "75px"})`}>
125
- {cart.CartLines.length > 0 ? (
124
+ <Box height={`calc(100vh - ${cart?.CartLines?.length ? "207px" : "75px"})`}>
125
+ {cart?.CartLines?.length > 0 ? (
126
126
  <Scrollbar>
127
127
  {cart?.CartLines?.map((item) => (
128
128
  <MiniCartItem
@@ -153,7 +153,7 @@ MiniCart.Actions = () => {
153
153
  const ctx = useContext(MiniCartContext);
154
154
  if (!ctx) return null;
155
155
  const { cart, currency, handleNavigate } = ctx;
156
- return cart?.CartLines.length > 0 ? (
156
+ return cart?.CartLines?.length > 0 ? (
157
157
  <BottomActions
158
158
  total={currency(cart?.TotalAmount || 0)}
159
159
  handleNavigate={handleNavigate}
@@ -8,9 +8,11 @@ import { AddToWishListDialogContext } from "@evenicanpm/storefront-core/src/comp
8
8
  // LOCAL COMPONENTS
9
9
  import DialogHeader from "@evenicanpm/storefront-core/src/components/wishlist-dialogs/add-to-wishlist/compound/wishlist-dialog-header";
10
10
  import { Dialog, DialogContent } from "@mui/material";
11
- import { useSession } from "next-auth/react";
11
+ import { createAuthClient } from "better-auth/react";
12
12
  import type React from "react";
13
13
 
14
+ const { useSession } = createAuthClient();
15
+
14
16
  export interface AddToWishlistDialogProps {
15
17
  open: boolean;
16
18
  handleClose: () => void;
@@ -9,8 +9,11 @@ import { H2 } from "@evenicanpm/storefront-core/src/components/Typography";
9
9
  import CreateWishlistButton from "@evenicanpm/storefront-core/src/components/wishlist-dialogs/create-wishlist/create-wishlist-button";
10
10
  import { IconButton, styled, type Theme } from "@mui/material";
11
11
  import useMediaQuery from "@mui/material/useMediaQuery";
12
- import { useSession } from "next-auth/react";
12
+ import { createAuthClient } from "better-auth/react";
13
13
  import { useTranslations } from "next-intl";
14
+
15
+ const { useSession } = createAuthClient();
16
+
14
17
  // ICONS
15
18
  import { MdFavorite } from "react-icons/md";
16
19
 
@@ -1,36 +1,31 @@
1
1
  // hooks/useAppSession.ts
2
2
 
3
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
4
  // API hooks
6
5
  import useUpdateUser from "@evenicanpm/storefront-core/src/api-manager/services/user/mutations/update-user";
7
6
  import {
8
7
  getMsalInstance,
9
8
  initializeMsal,
10
9
  } from "@evenicanpm/storefront-core/src/auth/msal";
11
- import { useSession } from "next-auth/react";
10
+ import { createAuthClient } from "better-auth/react";
12
11
  import { useEffect } from "react";
13
12
 
13
+ const authClient = createAuthClient();
14
+ const { useSession: useBetterAuthSession } = authClient;
15
+
14
16
  export function useAppSession() {
15
- const { data: session } = useSession();
17
+ const { data: session, isPending } = useBetterAuthSession();
16
18
  const { mutateAsync: sessionInit } = useSessionInit();
17
19
  const { mutateAsync: updateUser } = useUpdateUser();
18
- const { mutateAsync: sessionLogout } = useSessionLogout();
19
-
20
- // Handle custom auth errors
21
- useEffect(() => {
22
- if (session?.error) {
23
- console.warn("Auth Error", session.error);
24
- sessionLogout({}); // Or your custom signOut
25
- }
26
- }, [session, sessionLogout]);
27
20
 
28
21
  // Initialize session in API
29
22
  useEffect(() => {
30
23
  if (!session) return;
31
24
 
32
- const { accessToken, user } = session;
33
- if (accessToken && user?.email) {
25
+ const { user } = session;
26
+ const accessToken =
27
+ (session.session as { accessToken?: string })?.accessToken || "";
28
+ if (user?.email) {
34
29
  sessionInit({ accessToken, email: user.email }).catch(console.error);
35
30
  }
36
31
  }, [session, sessionInit]);
@@ -64,5 +59,6 @@ export function useAppSession() {
64
59
  return {
65
60
  session,
66
61
  isAuthenticated: !!session,
62
+ isPending,
67
63
  };
68
64
  }
@@ -9,6 +9,11 @@ import {
9
9
  Paragraph,
10
10
  Span,
11
11
  } from "@evenicanpm/storefront-core/src/components/Typography";
12
+ import {
13
+ type AccountRoute,
14
+ type AccountRouteOverrides,
15
+ createAccountRoutes,
16
+ } from "@evenicanpm/storefront-core/src/pages/account/account-routes";
12
17
  // STYLED COMPONENTS
13
18
  import {
14
19
  MainContainer,
@@ -17,88 +22,114 @@ import {
17
22
  import { usePathname } from "next/navigation";
18
23
  import { useTranslations } from "next-intl";
19
24
  import { createContext, Fragment, type ReactNode } from "react";
20
- // ICON COMPONENTS
21
- import {
22
- MdFavoriteBorder as FavoriteBorder,
23
- MdPerson as Person,
24
- MdPlace as Place,
25
- MdOutlineShoppingBag as ShoppingBagOutlined,
26
- } from "react-icons/md";
25
+ import type { IconType } from "react-icons";
27
26
 
28
27
  /**
29
28
  *
30
29
  *** Extend menu:
31
30
  <AccountNavigation
32
- items={[
33
- ...DEFAULT_MENU_ITEMS,
34
- {
35
- title: "EXTRAS",
36
- list: [
37
- {
38
- href: "/account/rewards",
39
- title: "rewards",
40
- Icon: RewardsIcon,
41
- count: 5,
42
- },
43
- ],
44
- },
45
- ]}
31
+ items={[
32
+ ...DEFAULT_MENU_ITEMS,
33
+ {
34
+ title: "EXTRAS",
35
+ list: [
36
+ {
37
+ href: "/account/rewards",
38
+ title: "rewards",
39
+ Icon: RewardsIcon,
40
+ count: 5,
41
+ },
42
+ ],
43
+ },
44
+ ]}
46
45
  />;
47
46
 
48
47
  *** Override completely:
49
48
  <AccountNavigation>
50
- <AccountNavigation.Section title="Custom Section">
51
- <AccountNavigation.Item
52
- href="/custom"
53
- Icon={CustomIcon}
54
- label="Custom Page"
55
- />
56
- </AccountNavigation.Section>
49
+ <AccountNavigation.Section title="Custom Section">
50
+ <AccountNavigation.Item
51
+ href="/custom"
52
+ Icon={CustomIcon}
53
+ label="Custom Page"
54
+ />
55
+ </AccountNavigation.Section>
57
56
  </AccountNavigation>;
58
57
  */
59
58
 
60
- export const DEFAULT_MENU_ITEMS = [
61
- {
62
- title: "DASHBOARD",
63
- list: [
64
- {
65
- href: "/account/orders",
66
- title: "orders",
67
- Icon: ShoppingBagOutlined,
68
- count: 0,
69
- },
70
- { href: "/account/wish-lists", title: "wishlists", Icon: FavoriteBorder },
71
- ],
72
- },
73
- {
74
- title: "ACCOUNT SETTINGS",
75
- list: [
76
- { href: "/account/profile", title: "profile", Icon: Person },
77
- { href: "/account/addresses", title: "addresses", Icon: Place, count: 0 },
78
- ],
79
- },
80
- ];
59
+ interface NavItem {
60
+ href: string;
61
+ title: string;
62
+ Icon: IconType;
63
+ count?: number;
64
+ }
65
+
66
+ interface NavSection {
67
+ title: string;
68
+ list: NavItem[];
69
+ }
70
+
71
+ /**
72
+ *
73
+ * @param route
74
+ * @returns
75
+ */
76
+ const routeToNavItem = (route: AccountRoute): NavItem => {
77
+ return {
78
+ href: route.href,
79
+ title: route.titleKey,
80
+ Icon: route.Icon,
81
+ count: route.count,
82
+ };
83
+ };
84
+
85
+ export const DEFAULT_MENU_ITEMS = (
86
+ overrides?: AccountRouteOverrides,
87
+ ): NavSection[] => {
88
+ const routes = createAccountRoutes(overrides);
89
+ return [
90
+ {
91
+ title: "DASHBOARD",
92
+ list: [
93
+ routeToNavItem(routes.orders),
94
+ routeToNavItem(routes.wishlists),
95
+ routeToNavItem(routes.invoices),
96
+ ],
97
+ },
98
+ {
99
+ title: "ACCOUNT SETTINGS",
100
+ list: [routeToNavItem(routes.profile), routeToNavItem(routes.addresses)],
101
+ },
102
+ ];
103
+ };
81
104
 
82
105
  interface AccountNavigationContextValue {
83
- items: typeof DEFAULT_MENU_ITEMS;
106
+ items: NavSection[];
84
107
  }
85
108
 
86
109
  const AccountNavigationContext =
87
110
  createContext<AccountNavigationContextValue | null>(null);
111
+
88
112
  interface AccountNavigationProps {
89
- items?: typeof DEFAULT_MENU_ITEMS;
113
+ items?: NavSection[];
90
114
  children?: ReactNode;
115
+ routeOverrides?: AccountRouteOverrides;
91
116
  }
92
117
 
93
- const AccountNavigation = ({ items, children }: AccountNavigationProps) => {
118
+ const AccountNavigation = ({
119
+ items,
120
+ children,
121
+ routeOverrides,
122
+ }: AccountNavigationProps) => {
94
123
  const pathname = usePathname();
95
124
  const t = useTranslations("Account.Menu");
96
125
 
97
126
  const { data: user } = getUser.useData();
98
127
  const { data: counts } = getUserCounts.useData(String(user?.RecordId) || "");
99
128
 
100
- // Merge default items with provided overrides
101
- const mergedItems = (items || DEFAULT_MENU_ITEMS).map((section) => ({
129
+ const defaultItems = DEFAULT_MENU_ITEMS(routeOverrides);
130
+
131
+ // Render either the props items or the default items
132
+ const itemsWCounts = (items || defaultItems).map((section) => ({
102
133
  ...section,
103
134
  list: section.list.map((item) => {
104
135
  if (item.href === "/account/orders") {
@@ -112,10 +143,10 @@ const AccountNavigation = ({ items, children }: AccountNavigationProps) => {
112
143
  }));
113
144
 
114
145
  return (
115
- <AccountNavigationContext.Provider value={{ items: mergedItems }}>
146
+ <AccountNavigationContext.Provider value={{ items: itemsWCounts }}>
116
147
  <MainContainer>
117
148
  {children ||
118
- mergedItems.map((section) => (
149
+ itemsWCounts.map((section) => (
119
150
  <AccountNavigation.Section
120
151
  key={section.title}
121
152
  title={section.title}
@@ -151,10 +182,7 @@ AccountNavigation.Section = ({ title, children }: SectionProps) => {
151
182
  );
152
183
  };
153
184
 
154
- interface ItemProps {
155
- href: string;
156
- Icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
157
- count?: number;
185
+ interface ItemProps extends Pick<NavItem, "href" | "Icon" | "count"> {
158
186
  label: string;
159
187
  isActive?: boolean;
160
188
  }
@@ -0,0 +1,52 @@
1
+ import type { IconType } from "react-icons";
2
+ import {
3
+ MdFavoriteBorder as FavoriteBorder,
4
+ MdDocumentScanner as InvoicesIcon,
5
+ MdPerson as Person,
6
+ MdPlace as Place,
7
+ MdOutlineShoppingBag as ShoppingBagOutlined,
8
+ } from "react-icons/md";
9
+
10
+ export interface AccountRoute {
11
+ href: string;
12
+ titleKey: string;
13
+ Icon: IconType;
14
+ count?: number;
15
+ }
16
+
17
+ export interface AccountRouteOverrides {
18
+ wishlists?: { href?: string; titleKey?: string };
19
+ }
20
+
21
+ const defaults = {
22
+ orders: {
23
+ href: "/account/orders",
24
+ titleKey: "orders",
25
+ Icon: ShoppingBagOutlined,
26
+ count: 0,
27
+ },
28
+ wishlists: {
29
+ href: "/account/wish-lists",
30
+ titleKey: "wishlists",
31
+ Icon: FavoriteBorder,
32
+ },
33
+ invoices: {
34
+ href: "/account/invoices",
35
+ titleKey: "invoices",
36
+ Icon: InvoicesIcon,
37
+ },
38
+ profile: { href: "/account/profile", titleKey: "profile", Icon: Person },
39
+ addresses: {
40
+ href: "/account/addresses",
41
+ titleKey: "addresses",
42
+ Icon: Place,
43
+ count: 0,
44
+ },
45
+ } as const satisfies Record<string, AccountRoute>;
46
+
47
+ export type AccountRouteKey = keyof typeof defaults;
48
+
49
+ export const createAccountRoutes = (overrides?: AccountRouteOverrides) => ({
50
+ ...defaults,
51
+ wishlists: { ...defaults.wishlists, ...overrides?.wishlists },
52
+ });