@evenicanpm/storefront-core 2.3.0 → 2.3.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.
Files changed (25) hide show
  1. package/package.json +3 -3
  2. package/src/api-manager/datasources/d365/d365-user.datasource.ts +29 -14
  3. package/src/api-manager/datasources/e4/graphqlRequestSdk.ts +7 -0
  4. package/src/cms/blocks/components/footer/footer-content.tsx +61 -0
  5. package/src/cms/blocks/components/footer/index.tsx +9 -71
  6. package/src/cms/blocks/components/footer/interfaces.ts +4 -0
  7. package/src/cms/blocks/components/footer/sections/footer-contact.tsx +18 -9
  8. package/src/cms/blocks/components/footer/sections/footer-logo.tsx +17 -2
  9. package/src/components/_tests_/site-logo.test.tsx +1 -1
  10. package/src/components/header/components/user.tsx +4 -5
  11. package/src/components/header/sticky-header.tsx +1 -0
  12. package/src/components/product-cards/product-card-plp/product-card.tsx +4 -12
  13. package/src/components/product-cards/product-card-plp/styles/index.ts +15 -0
  14. package/src/components/site-logo.tsx +1 -1
  15. package/src/components/wishlist-dialogs/add-to-wishlist/compound/wishlist-dialog-item.tsx +3 -1
  16. package/src/pages/account/account-navigation.tsx +2 -2
  17. package/src/pages/account/account-routes.ts +38 -4
  18. package/src/pages/account/table-row-skeleton.tsx +31 -0
  19. package/src/pages/account/wishlist/wishlist-item.tsx +4 -1
  20. package/src/pages/blog/blog-detail-view.tsx +3 -1
  21. package/src/pages/cms-page-view.tsx +1 -1
  22. package/src/pages/product-details/product-description.tsx +25 -13
  23. package/src/pages/product-details/product-intro/compound/product-intro-images.tsx +26 -8
  24. package/src/pages/product-details/product-intro/compound/thumbnail-with-skeleton.tsx +17 -21
  25. package/src/pages/product-details/product-tabs.tsx +20 -4
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evenicanpm/storefront-core",
3
- "version": "2.3.0",
3
+ "version": "2.3.1",
4
4
  "description": "Core module for D365/e4 Headless Storefront",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -16,7 +16,7 @@
16
16
  "author": "Evenica",
17
17
  "license": "ISC",
18
18
  "dependencies": {
19
- "@evenicanpm/cms": "^2.3.0",
19
+ "@evenicanpm/cms": "^2.3.1",
20
20
  "@evenicanpm/ui": "^2.0.0",
21
21
  "strapi-sdk-js": "^3.0.0"
22
22
  },
@@ -54,5 +54,5 @@
54
54
  "esbuild": "0.19.11"
55
55
  }
56
56
  },
57
- "gitHead": "72bf1aeb02a3d96c587706c0d91606698e38ff6a"
57
+ "gitHead": "7a5a6a8c3c3dd9efe96fbbb884b21e7681d32550"
58
58
  }
@@ -320,22 +320,37 @@ class D365UserDatasource extends D365DatasourceBase implements UserApi {
320
320
  }
321
321
 
322
322
  async addProductsToWishlist(input: WishlistLinesInput): Promise<Wishlist> {
323
- const context = this.context;
324
- const id = input.Id;
325
- const productListLines = input.ProductListLines;
323
+ return tracer.startActiveSpan(
324
+ "d365.addProductsToWishlist",
325
+ async (span) => {
326
+ const context = this.context;
327
+ const id = input.Id;
328
+ const catalogId = this.context.requestContext.apiSettings.catalogId;
326
329
 
327
- try {
328
- const updatedProductListLines = await addProductListLinesAsync(
329
- this.getRetailContext(context),
330
- id,
331
- productListLines,
332
- );
330
+ const productListLines = input.ProductListLines.map((line) => ({
331
+ ...line,
332
+ CatalogId: catalogId,
333
+ }));
334
+ console.log(productListLines);
335
+ try {
336
+ const updatedProductListLines = await addProductListLinesAsync(
337
+ this.getRetailContext(context),
338
+ id,
339
+ productListLines,
340
+ );
333
341
 
334
- const wishlist = { Id: id, productListLines: updatedProductListLines };
335
- return wishlist;
336
- } catch (error) {
337
- throw new Error(`Error adding to wishlist: ${error}`);
338
- }
342
+ const wishlist = {
343
+ Id: id,
344
+ productListLines: updatedProductListLines,
345
+ };
346
+ return wishlist;
347
+ } catch (error) {
348
+ throw new Error(`Error adding to wishlist: ${error}`);
349
+ } finally {
350
+ span.end();
351
+ }
352
+ },
353
+ );
339
354
  }
340
355
 
341
356
  async removeProductsFromWishlist(input: WishlistLinesInput): Promise<void> {
@@ -3428,6 +3428,7 @@ export type Mutation = {
3428
3428
  reindex: IndexAttemptResponse;
3429
3429
  relatedAccountCreate?: Maybe<RelatedAccountCreateMutationResponse>;
3430
3430
  relatedAccountDelete?: Maybe<RelatedAccountDeleteMutationResponse>;
3431
+ removeIndexHistory?: Maybe<Scalars['ID']['output']>;
3431
3432
  /** Removes role and returns removed role */
3432
3433
  removeRole?: Maybe<Role>;
3433
3434
  /**
@@ -4290,6 +4291,12 @@ export type MutationRelatedAccountDeleteArgs = {
4290
4291
  };
4291
4292
 
4292
4293
 
4294
+ export type MutationRemoveIndexHistoryArgs = {
4295
+ id: Scalars['ID']['input'];
4296
+ index?: InputMaybe<Scalars['String']['input']>;
4297
+ };
4298
+
4299
+
4293
4300
  export type MutationRemoveRoleArgs = {
4294
4301
  input: RemoveRoleInput;
4295
4302
  };
@@ -0,0 +1,61 @@
1
+ import type { FooterBlock } from "@evenicanpm/cms/src/content/footer/types";
2
+ import { Box, Container } from "@mui/material";
3
+ import Grid from "@mui/material/Grid2";
4
+ import Contact from "@/cms/blocks/components/footer/sections/footer-contact";
5
+ import Links from "@/cms/blocks/components/footer/sections/footer-links";
6
+ import Logo from "@/cms/blocks/components/footer/sections/footer-logo";
7
+
8
+ const getBlockComponent = (block: FooterBlock, index: number) => {
9
+ switch (block.__component) {
10
+ case "footer.links":
11
+ return (
12
+ <Links key={`index-${index}`} title={block.title} links={block.links} />
13
+ );
14
+ case "footer.logo":
15
+ return (
16
+ <Logo
17
+ key={`index-${index}`}
18
+ logoImage={block.logoImage}
19
+ description={block.description}
20
+ />
21
+ );
22
+ case "footer.contact":
23
+ return (
24
+ <Contact
25
+ key={`index-${index}`}
26
+ address={block.address}
27
+ email={block.email}
28
+ phoneNumber={block.phoneNumber}
29
+ social={block.social}
30
+ />
31
+ );
32
+ default:
33
+ return null;
34
+ }
35
+ };
36
+
37
+ const FooterContent = ({ sections }: { sections: FooterBlock[] }) => {
38
+ if (!sections?.length) return null;
39
+
40
+ return (
41
+ <Box
42
+ component="footer"
43
+ bgcolor="#222935"
44
+ mt={{ xs: 7 }}
45
+ mb={{ sm: 0, xs: 7 }}
46
+ >
47
+ <Box
48
+ component={Container}
49
+ color="white"
50
+ overflow="hidden"
51
+ py={{ sm: 10, xs: 4 }}
52
+ >
53
+ <Grid container spacing={6}>
54
+ {sections.map((block, index) => getBlockComponent(block, index))}
55
+ </Grid>
56
+ </Box>
57
+ </Box>
58
+ );
59
+ };
60
+
61
+ export default FooterContent;
@@ -1,86 +1,24 @@
1
1
  // MUI
2
- import { Box, Container } from "@mui/material";
3
- import Grid from "@mui/material/Grid2";
4
- import type {
5
- FooterBlock,
6
- FooterResponse,
7
- } from "@/cms/blocks/components/footer/interfaces";
8
- import Contact from "@/cms/blocks/components/footer/sections/footer-contact";
9
- import Links from "@/cms/blocks/components/footer/sections/footer-links";
10
- // Local Custom Components
11
- import Logo from "@/cms/blocks/components/footer/sections/footer-logo";
12
- import cms from "@/cms/endpoints";
2
+ import { cms } from "@evenicanpm/cms";
3
+ import type { FooterBlock } from "@evenicanpm/cms/src/content/footer/types";
4
+ import FooterContent from "@/cms/blocks/components/footer/footer-content";
13
5
 
14
- const getBlockComponent = (block: FooterBlock, index: number) => {
15
- let Block: typeof Links | typeof Logo | typeof Contact;
16
-
17
- switch (block.__component) {
18
- case "footer.links":
19
- Block = Links;
20
- return (
21
- <Block key={`index-${index}`} title={block.title} links={block.links} />
22
- );
23
- case "footer.logo":
24
- Block = Logo;
25
- return <Block key={`index-${index}`} description={block.description} />;
26
- case "footer.contact":
27
- Block = Contact;
28
- return <Block key={`index-${index}`} social={block.social} />;
29
- default:
30
- return null;
31
- }
32
- };
33
-
34
- const fetchFooterData = async (): Promise<FooterResponse | null> => {
6
+ const Footer = async () => {
7
+ let sections: FooterBlock[] | undefined;
35
8
  try {
36
- const response = await fetch(cms.getCmsApiUrl("footer"), {
37
- cache: "no-store", // Ensure server-side fetching
38
- });
39
-
40
- if (!response.ok) {
41
- return null;
42
- }
43
-
44
- const text = await response.text();
45
-
46
- if (!text) {
47
- return null;
48
- }
49
-
50
- return JSON.parse(text) as FooterResponse;
9
+ const response = await cms.footer.getFooter();
10
+ sections = (response as { data?: { sections?: FooterBlock[] } })?.data
11
+ ?.sections;
51
12
  } catch (error) {
52
13
  console.error("Error fetching footer data:", error);
53
14
  return null;
54
15
  }
55
- };
56
-
57
- const Footer = async () => {
58
- const data = await fetchFooterData();
59
- const sections = data?.data?.sections;
60
16
 
61
17
  if (!sections?.length) {
62
18
  return null;
63
19
  }
64
20
 
65
- return (
66
- <Box
67
- component="footer"
68
- bgcolor="#222935"
69
- mt={{ xs: 7 }}
70
- mb={{ sm: 0, xs: 7 }}
71
- >
72
- <Box
73
- component={Container}
74
- color="white"
75
- overflow="hidden"
76
- py={{ sm: 10, xs: 4 }}
77
- >
78
- <Grid container spacing={6}>
79
- {sections.map((block, index) => getBlockComponent(block, index))}
80
- </Grid>
81
- </Box>
82
- </Box>
83
- );
21
+ return <FooterContent sections={sections} />;
84
22
  };
85
23
 
86
24
  export default Footer;
@@ -9,6 +9,7 @@ export interface SocialLink {
9
9
  interface FooterLogoBlock {
10
10
  id: number;
11
11
  __component: "footer.logo";
12
+ logoImage?: { url: string; alternativeText?: string };
12
13
  description: string;
13
14
  }
14
15
 
@@ -22,6 +23,9 @@ interface FooterLinksBlock {
22
23
  interface FooterContactBlock {
23
24
  id: number;
24
25
  __component: "footer.contact";
26
+ address?: string;
27
+ email?: string;
28
+ phoneNumber?: string;
25
29
  social: SocialLink[];
26
30
  }
27
31
 
@@ -7,6 +7,9 @@ import { Heading } from "@/cms/blocks/components/footer/styles/index";
7
7
  interface Props {
8
8
  isDark?: boolean;
9
9
  title?: string;
10
+ address?: string;
11
+ email?: string;
12
+ phoneNumber?: string;
10
13
  social?: SocialLink[];
11
14
  }
12
15
 
@@ -15,17 +18,23 @@ const AboutLinks = (props: Props) => {
15
18
  <Grid size={{ lg: 3, md: 6, sm: 6, xs: 12 }}>
16
19
  <Heading>Contact Us</Heading>
17
20
 
18
- <Paragraph py={0.6} color="grey.500">
19
- 70 Washington Square South, New York, NY 10012, United States
20
- </Paragraph>
21
+ {props.address && (
22
+ <Paragraph py={0.6} color="grey.500">
23
+ {props.address}
24
+ </Paragraph>
25
+ )}
21
26
 
22
- <Paragraph py={0.6} color="grey.500">
23
- Email: evenica@example.org
24
- </Paragraph>
27
+ {props.email && (
28
+ <Paragraph py={0.6} color="grey.500">
29
+ Email: {props.email}
30
+ </Paragraph>
31
+ )}
25
32
 
26
- <Paragraph py={0.6} mb={2} color="grey.500">
27
- Phone: +1 1123 456 780
28
- </Paragraph>
33
+ {props.phoneNumber && (
34
+ <Paragraph py={0.6} mb={2} color="grey.500">
35
+ Phone: {props.phoneNumber}
36
+ </Paragraph>
37
+ )}
29
38
 
30
39
  <SocialLinks socials={props.social} />
31
40
  </Grid>
@@ -2,20 +2,35 @@
2
2
 
3
3
  import SiteLogo from "@evenicanpm/storefront-core/src/components/site-logo";
4
4
  import { Paragraph } from "@evenicanpm/storefront-core/src/components/Typography";
5
+ import { Box } from "@mui/material";
5
6
  import Grid from "@mui/material/Grid2";
6
7
  import useTheme from "@mui/material/styles/useTheme";
7
8
  import useMediaQuery from "@mui/material/useMediaQuery";
9
+ import cmsEndPoints from "@/cms/endpoints";
8
10
 
9
11
  interface Props {
12
+ logoImage?: { url: string; alternativeText?: string };
10
13
  description: string;
11
14
  }
12
15
 
13
16
  export default function LogoSection(props: Readonly<Props>) {
14
17
  const theme = useTheme();
15
- const downMd = useMediaQuery(theme.breakpoints.down(1150)); // same like in header
18
+ const downMd = useMediaQuery(theme.breakpoints.down(1150));
19
+ const logoHeight = downMd ? "2.125rem" : "3rem";
20
+
16
21
  return (
17
22
  <Grid size={{ lg: 3, md: 6, sm: 6, xs: 12 }}>
18
- <SiteLogo {...(downMd ? { height: "2.125rem" } : {})} />
23
+ {props.logoImage?.url ? (
24
+ <Box mb={1}>
25
+ <img
26
+ src={cmsEndPoints.buildCmsMediaUrl(props.logoImage.url)}
27
+ alt={props.logoImage.alternativeText || "Logo"}
28
+ style={{ height: logoHeight, width: "auto" }}
29
+ />
30
+ </Box>
31
+ ) : (
32
+ <SiteLogo {...(downMd ? { height: "2.125rem" } : {})} />
33
+ )}
19
34
 
20
35
  <Paragraph mb={2.5} color="grey.500">
21
36
  {props.description}
@@ -15,7 +15,7 @@ vi.mock("next/link", () => ({
15
15
  describe("SiteLogo", () => {
16
16
  it("renders logo image", () => {
17
17
  render(<SiteLogo />);
18
- const logo = screen.getByAltText("E4 Logo");
18
+ const logo = screen.getByAltText("e4 Logo");
19
19
  expect(logo).toBeInTheDocument();
20
20
  // next/image rewrites src to an optimized URL (e.g. /_next/image?url=...)
21
21
  expect(logo.getAttribute("src")).toMatch(/logo-blue\.png/);
@@ -16,7 +16,7 @@ import T from "@evenicanpm/storefront-core/src/components/T";
16
16
  import {
17
17
  type AccountRoute,
18
18
  type AccountRouteOverrides,
19
- createAccountRoutes,
19
+ useCreateAccountRoutes,
20
20
  } from "@evenicanpm/storefront-core/src/pages/account/account-routes";
21
21
  // UI Components
22
22
  import {
@@ -61,7 +61,7 @@ const routeToMenuItem = (route: AccountRoute) => ({
61
61
  });
62
62
 
63
63
  const buildMenuItems = (overrides?: AccountRouteOverrides) => {
64
- const routes = createAccountRoutes(overrides);
64
+ const routes = useCreateAccountRoutes(overrides);
65
65
  return [
66
66
  routeToMenuItem(routes.profile),
67
67
  routeToMenuItem(routes.orders),
@@ -89,10 +89,9 @@ const UserContext = createContext<UserContextValue | undefined>(undefined);
89
89
 
90
90
  interface Props {
91
91
  children?: ReactNode;
92
- routeOverrides?: AccountRouteOverrides;
93
92
  }
94
93
 
95
- const User = ({ children, routeOverrides }: Props) => {
94
+ const User = ({ children }: Props) => {
96
95
  const { data: session, isPending } = useBetterAuthSession();
97
96
  const status = isPending
98
97
  ? "loading"
@@ -166,7 +165,7 @@ const User = ({ children, routeOverrides }: Props) => {
166
165
  const [anchorEl, setAnchorEl] = useState<HTMLButtonElement | null>(null);
167
166
  const open = Boolean(anchorEl);
168
167
 
169
- const menuItems = buildMenuItems(routeOverrides);
168
+ const menuItems = buildMenuItems();
170
169
 
171
170
  const value: UserContextValue = {
172
171
  isAuthenticated,
@@ -8,6 +8,7 @@ import { Sticky } from "@evenicanpm/ui";
8
8
  import useTheme from "@mui/material/styles/useTheme";
9
9
  import useMediaQuery from "@mui/material/useMediaQuery";
10
10
  import { useCallback, useState } from "react";
11
+ import { AccountRouteOverrides } from "../../pages/account/account-routes";
11
12
 
12
13
  /**
13
14
  * @category Implementation
@@ -1,7 +1,4 @@
1
- import {
2
- FlexBetween,
3
- FlexBox,
4
- } from "@evenicanpm/storefront-core/src/components/flex-box/index";
1
+ import { FlexBetween } from "@evenicanpm/storefront-core/src/components/flex-box/index";
5
2
  import ImageWithFallback from "@evenicanpm/storefront-core/src/components/ImageWithFallback";
6
3
  // LOCAL CUSTOM COMPONENTS
7
4
  import DiscountChip from "@evenicanpm/storefront-core/src/components/product-cards/discount-chip";
@@ -9,6 +6,7 @@ import FavoriteButton from "@evenicanpm/storefront-core/src/components/product-c
9
6
  // STYLED COMPONENTS
10
7
  import {
11
8
  HoverIconWrapper,
9
+ PlpImageWrapper,
12
10
  PriceText,
13
11
  } from "@evenicanpm/storefront-core/src/components/product-cards/product-card-plp/styles/index";
14
12
  import QuantityButtons from "@evenicanpm/storefront-core/src/components/product-cards/quantity-buttons";
@@ -86,15 +84,9 @@ ProductCardPlp.ImageWrapper = ({ children }: { children: React.ReactNode }) => {
86
84
 
87
85
  return (
88
86
  <Link href={buildDetailUrl(title, id)}>
89
- <FlexBox
90
- className="hover-box-container"
91
- position="relative"
92
- bgcolor="grey.50"
93
- borderRadius={3}
94
- mb={2}
95
- >
87
+ <PlpImageWrapper className="hover-box-container">
96
88
  {children}
97
- </FlexBox>
89
+ </PlpImageWrapper>
98
90
  </Link>
99
91
  );
100
92
  };
@@ -1,5 +1,6 @@
1
1
  "use client";
2
2
 
3
+ import { FlexBox } from "@evenicanpm/storefront-core/src/components/flex-box/index";
3
4
  import styled from "@mui/material/styles/styled";
4
5
 
5
6
  export const PriceText = styled("p")(({ theme }) => ({
@@ -17,6 +18,20 @@ export const PriceText = styled("p")(({ theme }) => ({
17
18
  },
18
19
  }));
19
20
 
21
+ export const PlpImageWrapper = styled(FlexBox)(({ theme }) => ({
22
+ position: "relative",
23
+ backgroundColor: theme.palette.grey[50],
24
+ borderRadius: theme.shape.borderRadius * 3,
25
+ marginBottom: theme.spacing(2),
26
+
27
+ "& img": {
28
+ maxHeight: 300,
29
+ objectFit: "contain",
30
+ width: "100%",
31
+ borderRadius: 12,
32
+ },
33
+ }));
34
+
20
35
  export const HoverIconWrapper = styled("div")({
21
36
  zIndex: 2,
22
37
  top: "7px",
@@ -12,7 +12,7 @@ const SiteLogo: React.FC<SiteLogoProps> = () => {
12
12
  <Link href="/">
13
13
  <Image
14
14
  src="/logo-blue.png"
15
- alt="E4 Logo"
15
+ alt="e4 Logo"
16
16
  width={180}
17
17
  height={54}
18
18
  style={{ width: "auto", height: 54 }}
@@ -9,6 +9,7 @@ import {
9
9
  } from "@evenicanpm/storefront-core/src/components/wishlist-dialogs/add-to-wishlist/compound/context";
10
10
  import UseWishlist from "@evenicanpm/storefront-core/src/components/wishlist-dialogs/add-to-wishlist/hooks/use-add-to-wishlist";
11
11
  import { StyledTableRow } from "@evenicanpm/storefront-core/src/components/wishlist-dialogs/styles";
12
+ import { useCreateAccountRoutes } from "@evenicanpm/storefront-core/src/pages/account/account-routes";
12
13
  import { Add, Delete } from "@mui/icons-material";
13
14
  import { IconButton } from "@mui/material";
14
15
  import { format } from "date-fns";
@@ -25,6 +26,7 @@ export default function WishlistDialogItem() {
25
26
 
26
27
  const itemCtx = useContext(WishListItemContext);
27
28
  const dialogCtx = useContext(AddToWishListDialogContext);
29
+ const { wishlists: wishlistsRoute } = useCreateAccountRoutes();
28
30
 
29
31
  if (!itemCtx || !dialogCtx)
30
32
  throw new Error("WishlistModalItem must be used within a wishlist context");
@@ -59,7 +61,7 @@ export default function WishlistDialogItem() {
59
61
  }}
60
62
  >
61
63
  <Paragraph ellipsis sx={{ fontWeight: "bold" }}>
62
- <Link href={`/account/wish-lists/${wishlist?.Id}`}>
64
+ <Link href={`${wishlistsRoute.href}/${wishlist?.Id}`}>
63
65
  {wishlist?.Name ?? ""}
64
66
  </Link>
65
67
  </Paragraph>
@@ -12,7 +12,7 @@ import {
12
12
  import {
13
13
  type AccountRoute,
14
14
  type AccountRouteOverrides,
15
- createAccountRoutes,
15
+ useCreateAccountRoutes,
16
16
  } from "@evenicanpm/storefront-core/src/pages/account/account-routes";
17
17
  // STYLED COMPONENTS
18
18
  import {
@@ -85,7 +85,7 @@ const routeToNavItem = (route: AccountRoute): NavItem => {
85
85
  export const DEFAULT_MENU_ITEMS = (
86
86
  overrides?: AccountRouteOverrides,
87
87
  ): NavSection[] => {
88
- const routes = createAccountRoutes(overrides);
88
+ const routes = useCreateAccountRoutes(overrides);
89
89
  return [
90
90
  {
91
91
  title: "DASHBOARD",
@@ -1,3 +1,4 @@
1
+ import { createContext, useContext } from "react";
1
2
  import type { IconType } from "react-icons";
2
3
  import {
3
4
  MdFavoriteBorder as FavoriteBorder,
@@ -44,9 +45,42 @@ const defaults = {
44
45
  },
45
46
  } as const satisfies Record<string, AccountRoute>;
46
47
 
48
+ /**
49
+ * Context for overriding account route configuration.
50
+ * Allows parent providers to customize route paths and title keys
51
+ * without modifying the default route definitions.
52
+ *
53
+ * @example
54
+ * ```tsx
55
+ * <RoutesOverrideContext.Provider value={{ wishlists: { href: "/account/order-templates", titleKey: "orderTemplates" } }}>
56
+ * {children}
57
+ * </RoutesOverrideContext.Provider>
58
+ * ```
59
+ */
60
+ export const RoutesOverrideContext = createContext<
61
+ AccountRouteOverrides | undefined
62
+ >(undefined);
63
+
47
64
  export type AccountRouteKey = keyof typeof defaults;
48
65
 
49
- export const createAccountRoutes = (overrides?: AccountRouteOverrides) => ({
50
- ...defaults,
51
- wishlists: { ...defaults.wishlists, ...overrides?.wishlists },
52
- });
66
+ /**
67
+ * Hook that builds the account routes map, merging defaults with context-level
68
+ * and call-site overrides. Override priority (highest wins):
69
+ * 1. `overrides` parameter passed directly to the hook
70
+ * 2. Values from {@link RoutesOverrideContext}
71
+ * 3. Built-in defaults
72
+ *
73
+ * @param overrides - Optional route overrides applied at the call site
74
+ * @returns The full set of account routes with any overrides merged in
75
+ */
76
+ export const useCreateAccountRoutes = (overrides?: AccountRouteOverrides) => {
77
+ const ctxOverrides = useContext(RoutesOverrideContext);
78
+ return {
79
+ ...defaults,
80
+ wishlists: {
81
+ ...defaults.wishlists,
82
+ ...ctxOverrides?.wishlists,
83
+ ...overrides?.wishlists,
84
+ },
85
+ };
86
+ };
@@ -0,0 +1,31 @@
1
+ import TableRow from "@evenicanpm/storefront-core/src/pages/account/table-row";
2
+ import Box from "@mui/material/Box";
3
+ import Skeleton from "@mui/material/Skeleton";
4
+ import type { SxProps, Theme } from "@mui/material/styles";
5
+
6
+ interface TableRowSkeletonProps {
7
+ sx?: SxProps<Theme>;
8
+ }
9
+
10
+ export default function TableRowSkeleton({
11
+ sx = { gridTemplateColumns: "2fr 1fr 1fr 1fr 1fr" },
12
+ }: TableRowSkeletonProps) {
13
+ return (
14
+ <TableRow sx={sx}>
15
+ <Skeleton variant="text" width="60%" />
16
+ <Box textAlign="center">
17
+ <Skeleton
18
+ variant="rounded"
19
+ width={70}
20
+ height={24}
21
+ sx={{ mx: "auto" }}
22
+ />
23
+ </Box>
24
+ <Skeleton variant="text" width="70%" sx={{ mx: { sm: "auto" } }} />
25
+ <Skeleton variant="text" width="50%" sx={{ mx: { sm: "auto" } }} />
26
+ <Box display={{ sm: "inline-flex", xs: "none" }} justifyContent="end">
27
+ <Skeleton variant="circular" width={32} height={32} />
28
+ </Box>
29
+ </TableRow>
30
+ );
31
+ }
@@ -16,6 +16,7 @@ import { format } from "date-fns";
16
16
  import { useTranslations } from "next-intl";
17
17
  import { createContext, type ReactNode, useContext } from "react";
18
18
  import { MdDelete as Delete } from "react-icons/md";
19
+ import { useCreateAccountRoutes } from "../account-routes";
19
20
 
20
21
  interface WishlistItemContextValue {
21
22
  wishlist: Wishlist;
@@ -76,10 +77,12 @@ WishlistItem.Name = () => {
76
77
  const ctx = useContext(WishlistItemContext);
77
78
  if (!ctx) return null;
78
79
  const { wishlist } = ctx;
80
+ const { wishlists: wishlistsRoute } = useCreateAccountRoutes();
81
+
79
82
  return (
80
83
  <Paragraph ellipsis>
81
84
  <NavLink3
82
- href={`/account/order-templates/${wishlist?.Id}`}
85
+ href={`${wishlistsRoute.href}/${wishlist?.Id}`}
83
86
  text={wishlist?.Name ?? ""}
84
87
  />
85
88
  </Paragraph>
@@ -7,7 +7,7 @@ import { useSuspenseQuery } from "@tanstack/react-query";
7
7
  import Link from "next/link";
8
8
  import { cmsQueryOptions } from "../../cms/queries";
9
9
  import { BorderShadowCard } from "../../components/BoxShadowCard";
10
- import { Display, Subtitle } from "../../components/Typography";
10
+ import { Display, Paragraph, Subtitle } from "../../components/Typography";
11
11
 
12
12
  interface Props {
13
13
  id: string;
@@ -29,6 +29,7 @@ export const BlogDetailView = ({ id, locale, cmsLocale }: Props) => {
29
29
  const title = blogData?.title;
30
30
  const shortDescription = blogData?.shortDescription;
31
31
  const content = blogData?.content;
32
+ const author = blogData?.author;
32
33
 
33
34
  return (
34
35
  <Container className="mt-2 mb-2" maxWidth="xl">
@@ -128,6 +129,7 @@ export const BlogDetailView = ({ id, locale, cmsLocale }: Props) => {
128
129
  </Grid>
129
130
  <Grid size={{ xs: 12, md: 3 }} order={{ xs: 1, md: 2 }}>
130
131
  <Box sx={{ paddingBottom: "15px" }}>
132
+ {author && <Paragraph sx={noSpaceSx}>{author}</Paragraph>}
131
133
  <Subtitle sx={noSpaceSx}>Published on: {publishDate}</Subtitle>
132
134
  </Box>
133
135
  </Grid>
@@ -40,7 +40,7 @@ export const CmsPageView = ({ slug, cmsLocale, isDraftMode }: Props) => {
40
40
  ) : (
41
41
  <Container>
42
42
  <Paper sx={{ marginTop: "25px", padding: "50px" }} elevation={1}>
43
- <H1>E4 Storefront</H1>
43
+ <H1>e4 Storefront</H1>
44
44
  <H4>
45
45
  Add a page to the &quot;Page&quot; content-type in CMS to add
46
46
  content to this page.
@@ -3,22 +3,34 @@
3
3
  import { H3 } from "@evenicanpm/storefront-core/src/components/Typography";
4
4
  import type { AttributeValue } from "@msdyn365-commerce/retail-proxy";
5
5
 
6
- type Props = { attributes: AttributeValue[] };
6
+ type Props = { attributes: AttributeValue[]; description?: string };
7
7
 
8
- export default function ProductDescription({ attributes }: Readonly<Props>) {
8
+ export default function ProductDescription({
9
+ attributes,
10
+ description,
11
+ }: Readonly<Props>) {
9
12
  return (
10
13
  <div>
11
- <H3 mb={2}>Specifications:</H3>
12
- {attributes?.map((attr, index) => {
13
- if (attr?.TextValue && attr?.Name) {
14
- return (
15
- <div key={`attr-${index + 1}`}>
16
- {attr.Name}: {attr.TextValue}
17
- </div>
18
- );
19
- }
20
- return null;
21
- })}
14
+ {description && (
15
+ <div style={{ marginBottom: 16 }}>
16
+ <p>{description}</p>
17
+ </div>
18
+ )}
19
+ {attributes?.length > 0 && (
20
+ <>
21
+ <H3 mb={2}>Specifications:</H3>
22
+ {attributes.map((attr, index) => {
23
+ if (attr?.TextValue && attr?.Name) {
24
+ return (
25
+ <div key={`attr-${index + 1}`}>
26
+ {attr.Name}: {attr.TextValue}
27
+ </div>
28
+ );
29
+ }
30
+ return null;
31
+ })}
32
+ </>
33
+ )}
22
34
  </div>
23
35
  );
24
36
  }
@@ -11,6 +11,7 @@ import {
11
11
  } from "@evenicanpm/storefront-core/src/pages/product-details/product-intro/compound/context";
12
12
  import ThumbnailWithSkeleton from "@evenicanpm/storefront-core/src/pages/product-details/product-intro/compound/thumbnail-with-skeleton";
13
13
  import Grid from "@mui/material/Grid2";
14
+ import Skeleton from "@mui/material/Skeleton";
14
15
  import type React from "react";
15
16
  import { useContext, useState } from "react";
16
17
 
@@ -124,21 +125,38 @@ ProductImages.Main = () => {
124
125
  "ProductImages.Main must be used inside a product images context",
125
126
  );
126
127
  const { name, images, primaryImageUrl, selectedImageIndex } = ctx;
128
+ const [loaded, setLoaded] = useState(false);
129
+
130
+ const src =
131
+ Array.isArray(images) &&
132
+ selectedImageIndex !== undefined &&
133
+ images[selectedImageIndex]
134
+ ? images[selectedImageIndex]
135
+ : primaryImageUrl;
127
136
 
128
137
  return (
129
- <FlexBox borderRadius={3} overflow="hidden" justifyContent="center" mb={6}>
138
+ <FlexBox
139
+ borderRadius={3}
140
+ overflow="hidden"
141
+ justifyContent="center"
142
+ mb={6}
143
+ position="relative"
144
+ >
145
+ {!loaded && (
146
+ <Skeleton
147
+ variant="rounded"
148
+ width={300}
149
+ height={300}
150
+ sx={{ position: "absolute", inset: 0, m: "auto" }}
151
+ />
152
+ )}
130
153
  <ImageWithFallback
131
154
  alt={name ?? "product"}
132
155
  width={300}
133
156
  height={300}
134
157
  loading="eager"
135
- src={
136
- Array.isArray(images) &&
137
- selectedImageIndex !== undefined &&
138
- images[selectedImageIndex]
139
- ? images[selectedImageIndex]
140
- : primaryImageUrl
141
- }
158
+ src={src}
159
+ onLoad={() => setLoaded(true)}
142
160
  style={{ objectFit: "contain" }}
143
161
  />
144
162
  </FlexBox>
@@ -1,31 +1,27 @@
1
- import { Skeleton } from "@mui/material";
2
1
  import Image from "next/image";
3
- import { useState } from "react";
2
+
3
+ const shimmer = `<svg width="50" height="50" xmlns="http://www.w3.org/2000/svg"><rect width="50" height="50" fill="#f0f0f0"/></svg>`;
4
+ const blurDataURL = `data:image/svg+xml;base64,${typeof window === "undefined" ? Buffer.from(shimmer).toString("base64") : btoa(shimmer)}`;
4
5
 
5
6
  /**
6
- * Shows a skeleton until the image loads
7
+ * Product thumbnail image
7
8
  * @category Sub-Component
8
9
  */
9
10
  function ThumbnailWithSkeleton({ src }: Readonly<{ src: string }>) {
10
- const [loaded, setLoaded] = useState(false);
11
-
12
11
  return (
13
- <>
14
- {!loaded && <Skeleton variant="rounded" width={50} height={50} />}
15
- <Image
16
- src={src}
17
- alt="Product thumbnail"
18
- width={50}
19
- height={50}
20
- onLoad={() => setLoaded(true)}
21
- style={{
22
- display: loaded ? "block" : "none",
23
- width: "80%",
24
- height: "80%",
25
- objectFit: "cover",
26
- }}
27
- />
28
- </>
12
+ <Image
13
+ src={src}
14
+ alt="Product thumbnail"
15
+ width={50}
16
+ height={50}
17
+ placeholder="blur"
18
+ blurDataURL={blurDataURL}
19
+ style={{
20
+ width: "80%",
21
+ height: "80%",
22
+ objectFit: "cover",
23
+ }}
24
+ />
29
25
  );
30
26
  }
31
27
 
@@ -23,11 +23,16 @@ const StyledTabs = styled(Tabs)(({ theme }) => ({
23
23
  },
24
24
  }));
25
25
 
26
- type Props = { productAttributes: AttributeValue[]; children: React.ReactNode };
26
+ type Props = {
27
+ productAttributes: AttributeValue[];
28
+ productDescription?: string;
29
+ children: React.ReactNode;
30
+ };
27
31
 
28
32
  export interface TabsContext {
29
33
  selectedOption: number;
30
34
  productAttributes: AttributeValue[];
35
+ productDescription?: string;
31
36
  handleOptionClick: (_: React.SyntheticEvent, value: number) => void;
32
37
  }
33
38
 
@@ -54,6 +59,7 @@ const TabsContext = createContext<TabsContext | null>(null);
54
59
  */
55
60
  export default function ProductTabs({
56
61
  productAttributes,
62
+ productDescription,
57
63
  children,
58
64
  }: Readonly<Props>) {
59
65
  const [selectedOption, setSelectedOption] = useState(0);
@@ -62,7 +68,12 @@ export default function ProductTabs({
62
68
 
63
69
  return (
64
70
  <TabsContext.Provider
65
- value={{ selectedOption, handleOptionClick, productAttributes }}
71
+ value={{
72
+ selectedOption,
73
+ handleOptionClick,
74
+ productAttributes,
75
+ productDescription,
76
+ }}
66
77
  >
67
78
  <Box mb={6}>{children}</Box>
68
79
  </TabsContext.Provider>
@@ -130,6 +141,11 @@ ProductTabs.Description = () => {
130
141
  throw new Error(
131
142
  "ProductTabs.Tabs must be used inside a ProductTabsContext",
132
143
  );
133
- const { productAttributes } = ctx;
134
- return <ProductDescription attributes={productAttributes} />;
144
+ const { productAttributes, productDescription } = ctx;
145
+ return (
146
+ <ProductDescription
147
+ attributes={productAttributes}
148
+ description={productDescription}
149
+ />
150
+ );
135
151
  };