@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
@@ -0,0 +1,18 @@
1
+ import type { GetInvoicesDetailsInput } from "@evenicanpm/storefront-core/src/api-manager/schemas/invoice.schema";
2
+ import {
3
+ createApiPath,
4
+ createQuery,
5
+ } from "@evenicanpm/storefront-core/src/api-manager/services/create-query";
6
+ import type { SalesInvoice } from "@msdyn365-commerce/retail-proxy/dist";
7
+
8
+ const apiPath = createApiPath("invoice", "getInvoiceDetails");
9
+ const config = {
10
+ queryKey: "invoices",
11
+ };
12
+
13
+ const getInvoices = createQuery<GetInvoicesDetailsInput, SalesInvoice>(
14
+ apiPath,
15
+ config,
16
+ );
17
+
18
+ export default getInvoices;
@@ -0,0 +1,18 @@
1
+ import {
2
+ createApiPath,
3
+ createQuery,
4
+ } from "@evenicanpm/storefront-core/src/api-manager/services/create-query";
5
+ import type { OrderInvoice } from "@msdyn365-commerce/retail-proxy/dist";
6
+ import type { GetInvoicesInput } from "../../../schemas/invoice.schema";
7
+
8
+ const apiPath = createApiPath("invoice", "getInvoices");
9
+ const config = {
10
+ queryKey: "invoices",
11
+ };
12
+
13
+ const getInvoices = createQuery<GetInvoicesInput, OrderInvoice[]>(
14
+ apiPath,
15
+ config,
16
+ );
17
+
18
+ export default getInvoices;
@@ -9,7 +9,7 @@ import {
9
9
 
10
10
  const apiPath = createApiPath("order", "getSalesOrderDetails");
11
11
  const config = {
12
- queryKey: "salesOrderDetails",
12
+ queryKey: "salesOrders",
13
13
  };
14
14
 
15
15
  const getOrderDetails = createQuery<GetSalesOrderDetailsInput, SalesOrder>(
@@ -1,6 +1,6 @@
1
1
  import type {
2
+ GetSalesOrdersInput,
2
3
  SalesOrder,
3
- SalesOrderInput,
4
4
  } from "@evenicanpm/storefront-core/src/api-manager/schemas/order.schema";
5
5
  import {
6
6
  createApiPath,
@@ -9,9 +9,12 @@ import {
9
9
 
10
10
  const apiPath = createApiPath("order", "getSalesOrders");
11
11
  const config = {
12
- queryKey: "salesOrderDetails",
12
+ queryKey: "SalesOrders",
13
13
  };
14
14
 
15
- const getOrders = createQuery<SalesOrderInput, SalesOrder[]>(apiPath, config);
15
+ const getOrders = createQuery<GetSalesOrdersInput, SalesOrder[]>(
16
+ apiPath,
17
+ config,
18
+ );
16
19
 
17
20
  export default getOrders;
@@ -0,0 +1,20 @@
1
+ import type {
2
+ CustomerBalances,
3
+ GetCustomerBalanceInput,
4
+ } from "@evenicanpm/storefront-core/src/api-manager/schemas/user.schema";
5
+ import {
6
+ createApiPath,
7
+ createQuery,
8
+ } from "@evenicanpm/storefront-core/src/api-manager/services/create-query";
9
+
10
+ const apiPath = createApiPath("user", "getCustomerBalance");
11
+
12
+ const getCustomerBalance = createQuery<
13
+ GetCustomerBalanceInput,
14
+ CustomerBalances
15
+ >(apiPath, {
16
+ queryKey: "customerBalance",
17
+ disableProperties: ["accountNumber"],
18
+ });
19
+
20
+ export default getCustomerBalance;
@@ -7,10 +7,16 @@ import type {
7
7
  GetStatesInput,
8
8
  } from "@evenicanpm/storefront-core/src/api-manager/schemas/address.schema";
9
9
  import type * as cartSchemas from "@evenicanpm/storefront-core/src/api-manager/schemas/cart.schema";
10
+ import type {
11
+ GetInvoicesDetailsInput,
12
+ GetInvoicesInput,
13
+ Invoice,
14
+ InvoiceDetails,
15
+ } from "@evenicanpm/storefront-core/src/api-manager/schemas/invoice.schema";
10
16
  import type {
11
17
  GetSalesOrderDetailsInput,
18
+ GetSalesOrdersInput,
12
19
  SalesOrder,
13
- SalesOrderInput,
14
20
  } from "@evenicanpm/storefront-core/src/api-manager/schemas/order.schema";
15
21
  import type {
16
22
  ChannelConfiguration,
@@ -47,6 +53,7 @@ export interface DatasourceApis {
47
53
  order: OrderApi;
48
54
  address: AddressApi;
49
55
  organization: OrganizationApi;
56
+ invoice: InvoiceApi;
50
57
  }
51
58
 
52
59
  export abstract class Datasource {
@@ -59,6 +66,7 @@ export abstract class Datasource {
59
66
  abstract address: Partial<AddressApi>;
60
67
  abstract organization: Partial<OrganizationApi>;
61
68
  abstract session: Partial<SessionApi>;
69
+ abstract invoice: Partial<InvoiceApi>;
62
70
 
63
71
  constructor(name: string) {
64
72
  this.name = name;
@@ -89,6 +97,9 @@ export interface UserApi {
89
97
  updateUser(user: userSchemas.User): Promise<userSchemas.User>;
90
98
  getUser(): Promise<userSchemas.User | null>;
91
99
  getUserCounts(userId: string): Promise<userSchemas.UserCounts>;
100
+ getCustomerBalance(
101
+ input: userSchemas.GetCustomerBalanceInput,
102
+ ): Promise<userSchemas.CustomerBalances>;
92
103
  // wishlists
93
104
  getWishlists(): Promise<userSchemas.Wishlist[]>;
94
105
  getWishlistDetails(
@@ -199,7 +210,7 @@ export interface CategoryApi {
199
210
  * Order Data Actions
200
211
  */
201
212
  export interface OrderApi {
202
- getSalesOrders(input: SalesOrderInput): Promise<SalesOrder[]>;
213
+ getSalesOrders(input: GetSalesOrdersInput): Promise<SalesOrder[]>;
203
214
  getSalesOrderDetails(input: GetSalesOrderDetailsInput): Promise<SalesOrder>;
204
215
  }
205
216
 
@@ -214,3 +225,11 @@ export interface AddressApi {
214
225
  updateAddress(address: Address): Promise<Address>;
215
226
  deleteAddress(addressId: string): Promise<string>;
216
227
  }
228
+
229
+ /**
230
+ * Invoice Data Actions
231
+ */
232
+ export interface InvoiceApi {
233
+ getInvoices(input: GetInvoicesInput): Promise<Invoice[]>;
234
+ getInvoiceDetails(input: GetInvoicesDetailsInput): Promise<InvoiceDetails>;
235
+ }
@@ -0,0 +1,89 @@
1
+ import { betterAuth } from "better-auth";
2
+ import { nextCookies } from "better-auth/next-js";
3
+ import { customSession, genericOAuth } from "better-auth/plugins";
4
+
5
+ const tenantName = process.env.NEXT_PUBLIC_ENTRA_EXTERNAL_ID_TENANT_NAME || "";
6
+ const loginAuthority = `https://${tenantName}.ciamlogin.com/${tenantName}.onmicrosoft.com`;
7
+
8
+ export const auth = betterAuth({
9
+ secret: process.env.BETTER_AUTH_SECRET,
10
+ baseURL: process.env.BETTER_AUTH_URL || process.env.NEXTAUTH_URL,
11
+ session: {
12
+ cookieCache: {
13
+ enabled: true,
14
+ maxAge: 60 * 60, // 1 hour
15
+ strategy: "jwe",
16
+ refreshCache: true,
17
+ },
18
+ },
19
+ account: {
20
+ storeStateStrategy: "cookie",
21
+ storeAccountCookie: true,
22
+ },
23
+ plugins: [
24
+ genericOAuth({
25
+ config: [
26
+ {
27
+ providerId: "entra-external-id",
28
+ clientId: process.env.NEXT_PUBLIC_ENTRA_EXTERNAL_ID_CLIENT_ID || "",
29
+ clientSecret:
30
+ process.env.NEXT_PUBLIC_ENTRA_EXTERNAL_ID_CLIENT_SECRET || "",
31
+ discoveryUrl: `${loginAuthority}/v2.0/.well-known/openid-configuration`,
32
+ scopes: (
33
+ process.env.NEXT_PUBLIC_ENTRA_EXTERNAL_ID_CUSTOM_SCOPES ||
34
+ "offline_access openid email profile"
35
+ ).split(" "),
36
+ pkce: true,
37
+ prompt: "login",
38
+ getUserInfo: async (tokens) => {
39
+ // Decode the id_token to get user claims
40
+ const idToken = tokens.idToken;
41
+ if (idToken) {
42
+ const parts = idToken.split(".");
43
+ if (parts.length === 3) {
44
+ const payload = JSON.parse(
45
+ Buffer.from(parts[1], "base64url").toString(),
46
+ );
47
+ return {
48
+ id: payload.oid || payload.sub,
49
+ name: payload.name,
50
+ email:
51
+ payload.email ||
52
+ payload.preferred_username ||
53
+ payload.emails?.[0],
54
+ emailVerified: !!payload.email_verified,
55
+ };
56
+ }
57
+ }
58
+ return null;
59
+ },
60
+ },
61
+ ],
62
+ }),
63
+ customSession(async ({ user, session }, ctx) => {
64
+ let accessToken = "";
65
+ try {
66
+ const accounts = await ctx.context.internalAdapter.findAccounts(
67
+ user.id,
68
+ );
69
+ const account = accounts?.find(
70
+ (a) => a.providerId === "entra-external-id",
71
+ );
72
+ // For Entra External ID, use id_token as the access token
73
+ accessToken = account?.idToken || account?.accessToken || "";
74
+ } catch {
75
+ // Account data may not be accessible in all modes
76
+ }
77
+ return {
78
+ user,
79
+ session: {
80
+ ...session,
81
+ accessToken,
82
+ },
83
+ };
84
+ }),
85
+ nextCookies(), // must be last plugin
86
+ ],
87
+ });
88
+
89
+ export type Session = typeof auth.$Infer.Session;
@@ -1,42 +1,13 @@
1
- const fetchCsrfToken = async (): Promise<string> => {
2
- try {
3
- const response = await fetch("/api/auth/csrf");
4
- if (!response.ok) {
5
- throw new Error(`Failed to fetch CSRF token: ${response.statusText}`);
6
- }
7
- const data: { csrfToken: string } = await response.json();
8
- return data.csrfToken;
9
- } catch (error) {
10
- console.error("Error fetching CSRF token:", error);
11
- throw error;
12
- }
13
- };
1
+ import { createAuthClient } from "better-auth/react";
2
+
3
+ const authClient = createAuthClient();
14
4
 
15
5
  /**
16
- * Alternative to signOut from "next-auth/react" - https://github.com/nextauthjs/next-auth/blob/main/packages/next-auth/src/react.tsx
17
- * Solution to known next-auth issue - https://github.com/nextauthjs/next-auth/issues/4612
6
+ * Signs the user out and redirects to the home page.
18
7
  */
19
8
  const signOut = async (): Promise<void> => {
20
9
  try {
21
- const csrfToken = await fetchCsrfToken();
22
-
23
- const formData = new URLSearchParams({
24
- csrfToken,
25
- json: "true",
26
- });
27
-
28
- const response = await fetch("/api/auth/signout", {
29
- method: "POST",
30
- headers: {
31
- "Content-Type": "application/x-www-form-urlencoded",
32
- },
33
- body: formData,
34
- });
35
-
36
- if (!response.ok) {
37
- throw new Error(`Failed to sign out: ${response.statusText}`);
38
- }
39
-
10
+ await authClient.signOut();
40
11
  globalThis.location.href = "/";
41
12
  } catch (error) {
42
13
  console.error(error);
@@ -3,12 +3,14 @@ import type { Block } from "@/cms/blocks/interfaces";
3
3
  import {
4
4
  BrandLogosScroller,
5
5
  CategoryCarousel,
6
+ CtaBanner,
6
7
  FeaturedCategories,
7
8
  FeaturedProducts,
8
9
  FeaturePill,
9
10
  Footer,
10
11
  Hero,
11
12
  HeroCarousel,
13
+ HeroImage,
12
14
  Image,
13
15
  ProductCarousel,
14
16
  ProductSectionFullWidth,
@@ -18,6 +20,8 @@ import {
18
20
 
19
21
  const componentMap = {
20
22
  "blocks.hero-section": Hero,
23
+ "blocks.hero-image": HeroImage,
24
+ "blocks.cta-banner": CtaBanner,
21
25
  "blocks.features-pill": FeaturePill, // Should replace this with bazaar-react-ts/src/pages-sections/market-2/section-2/section-2.tsx
22
26
  "blocks.featured-products": FeaturedProducts,
23
27
  "blocks.featured-categories": FeaturedCategories,
@@ -40,8 +44,16 @@ const getBlockComponent: <B extends Block>(
40
44
  ) => JSX.Element | null = (block: Block) => {
41
45
  const Component = componentMap[
42
46
  block.__component as keyof typeof componentMap
43
- ] as React.ComponentType<Block>;
44
- return <Component key={`block-${block.id}`} {...block} />;
47
+ ] as React.ComponentType<Block> | undefined;
48
+ if (!Component) {
49
+ console.warn(
50
+ `BlockManager: no component mapped for "${block.__component}"`,
51
+ );
52
+ return null;
53
+ }
54
+ return (
55
+ <Component key={`block-${block.__component}-${block.id}`} {...block} />
56
+ );
45
57
  };
46
58
 
47
59
  interface BlockManagerProps {
@@ -55,7 +67,10 @@ const BlockManager: React.FC<BlockManagerProps> = ({ blocks }) => {
55
67
  {blocks.map((block, idx) => {
56
68
  const isLast = idx === blocks.length - 1;
57
69
  return (
58
- <Box key={`block-wrapper-${block.id}`} mb={isLast ? 0 : 6}>
70
+ <Box
71
+ key={`block-wrapper-${block.__component}-${block.id}`}
72
+ mb={isLast ? 0 : 6}
73
+ >
59
74
  {getBlockComponent(block, idx)}
60
75
  </Box>
61
76
  );
@@ -0,0 +1,65 @@
1
+ import { Box, Button, Container, Typography } from "@mui/material";
2
+ import NextLink from "next/link";
3
+ import type { CtaBannerBlock } from "@/cms/blocks/interfaces";
4
+
5
+ const CtaBanner = ({
6
+ heading,
7
+ subtext,
8
+ buttonText,
9
+ buttonLink,
10
+ backgroundColor,
11
+ }: CtaBannerBlock) => {
12
+ const bgColor = backgroundColor || "#1a237e";
13
+
14
+ return (
15
+ <Box
16
+ sx={{
17
+ backgroundColor: bgColor,
18
+ color: "#fff",
19
+ py: { xs: 6, md: 8 },
20
+ px: 2,
21
+ textAlign: "center",
22
+ width: "100%",
23
+ }}
24
+ >
25
+ <Container maxWidth="md">
26
+ <Typography
27
+ variant="h3"
28
+ component="h2"
29
+ sx={{ fontWeight: 700, mb: subtext ? 2 : 3, color: "#fff" }}
30
+ >
31
+ {heading}
32
+ </Typography>
33
+ {subtext && (
34
+ <Typography
35
+ variant="h6"
36
+ sx={{ color: "rgba(255,255,255,0.85)", mb: 3, fontWeight: 400 }}
37
+ >
38
+ {subtext}
39
+ </Typography>
40
+ )}
41
+ {buttonText && buttonLink && (
42
+ <Button
43
+ component={NextLink}
44
+ href={buttonLink}
45
+ variant="contained"
46
+ size="large"
47
+ sx={{
48
+ backgroundColor: "#fff",
49
+ color: bgColor,
50
+ fontWeight: 700,
51
+ px: 4,
52
+ "&:hover": {
53
+ backgroundColor: "rgba(255,255,255,0.9)",
54
+ },
55
+ }}
56
+ >
57
+ {buttonText}
58
+ </Button>
59
+ )}
60
+ </Container>
61
+ </Box>
62
+ );
63
+ };
64
+
65
+ export default CtaBanner;
@@ -0,0 +1,83 @@
1
+ import { Box, Button, Container, Typography } from "@mui/material";
2
+ import NextImage from "next/image";
3
+ import NextLink from "next/link";
4
+ import type { HeroImageBlock } from "@/cms/blocks/interfaces";
5
+
6
+ const sxBanner = {
7
+ position: "relative",
8
+ backgroundColor: "grey.900",
9
+ color: "white",
10
+ height: 500,
11
+ width: "100%",
12
+ display: "flex",
13
+ alignItems: "center",
14
+ justifyContent: "center",
15
+ overflow: "hidden",
16
+ marginBottom: "50px",
17
+ };
18
+
19
+ const contentContainer = {
20
+ position: "absolute",
21
+ top: 0,
22
+ left: 0,
23
+ right: 0,
24
+ bottom: 0,
25
+ display: "flex",
26
+ flexDirection: "column",
27
+ justifyContent: "center",
28
+ alignItems: "center",
29
+ zIndex: 2,
30
+ textAlign: "center",
31
+ padding: "0 20px",
32
+ };
33
+
34
+ const overlayStyle = {
35
+ position: "absolute",
36
+ inset: 0,
37
+ backgroundColor: "rgba(10,25,60,0.55)",
38
+ zIndex: 1,
39
+ };
40
+
41
+ const HeroImage = ({
42
+ heading,
43
+ subheading,
44
+ buttonText,
45
+ buttonLink,
46
+ image,
47
+ }: HeroImageBlock) => {
48
+ const imageUrl = image?.url;
49
+
50
+ return (
51
+ <Box sx={sxBanner}>
52
+ {imageUrl && (
53
+ <NextImage
54
+ src={imageUrl}
55
+ alt={heading}
56
+ fill
57
+ style={{ objectFit: "cover", objectPosition: "center" }}
58
+ priority
59
+ />
60
+ )}
61
+ <Box sx={overlayStyle} />
62
+ <Container sx={contentContainer}>
63
+ <Typography variant="h1" gutterBottom>
64
+ {heading}
65
+ </Typography>
66
+ {subheading && (
67
+ <Typography variant="h5" gutterBottom sx={{ marginBottom: "30px" }}>
68
+ {subheading}
69
+ </Typography>
70
+ )}
71
+ {buttonText && buttonLink && (
72
+ <NextLink href={buttonLink} passHref>
73
+ <Button color="primary" size="large" variant="contained">
74
+ {buttonText}
75
+ </Button>
76
+ </NextLink>
77
+ )}
78
+ </Container>
79
+ </Box>
80
+ );
81
+ };
82
+
83
+ export default HeroImage;
@@ -14,3 +14,5 @@ export { default as ProductCarousel } from "@/cms/blocks/components/product-caro
14
14
  export { default as ProductSectionFullWidth } from "@/cms/blocks/components/product-section-fullwidth/index";
15
15
  export { default as RichText } from "@/cms/blocks/components/rich-text";
16
16
  export { default as Services } from "@/cms/blocks/components/services/index";
17
+ export { default as CtaBanner } from "./components/cta-banner";
18
+ export { default as HeroImage } from "./components/hero-image";
@@ -141,6 +141,26 @@ export interface RichTextBlock {
141
141
  content: string;
142
142
  }
143
143
 
144
+ export interface HeroImageBlock {
145
+ id: number;
146
+ __component: "blocks.hero-image";
147
+ heading: string;
148
+ subheading?: string | null;
149
+ buttonText?: string | null;
150
+ buttonLink?: string | null;
151
+ image?: Media | null;
152
+ }
153
+
154
+ export interface CtaBannerBlock {
155
+ id: number;
156
+ __component: "blocks.cta-banner";
157
+ heading: string;
158
+ subtext?: string | null;
159
+ buttonText?: string | null;
160
+ buttonLink?: string | null;
161
+ backgroundColor?: string | null;
162
+ }
163
+
144
164
  export interface ImageFormat {
145
165
  ext: string;
146
166
  url: string;
@@ -0,0 +1,26 @@
1
+ import { Box, Chip } from "@mui/material";
2
+
3
+ export function DraftModeBadge() {
4
+ return (
5
+ <Box
6
+ sx={{
7
+ position: "fixed",
8
+ bottom: 16,
9
+ right: 16,
10
+ zIndex: 9999,
11
+ }}
12
+ >
13
+ <Chip
14
+ label="Draft Mode"
15
+ size="small"
16
+ sx={{
17
+ backgroundColor: "#f59e0b",
18
+ color: "#000",
19
+ fontWeight: 700,
20
+ fontSize: "0.75rem",
21
+ boxShadow: "0 2px 8px rgba(0,0,0,0.3)",
22
+ }}
23
+ />
24
+ </Box>
25
+ );
26
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * THIS IS A REACT-QUERY WRAPPER LAYER AROUND THE CMS SDK.
3
+ * IT PROVIDES TYPING AND STRUCTURE TO THE QUERIES USED THROUGHOUT THE APP, AND
4
+ * IT ALSO PROVIDES A SINGLE PLACE TO CONFIGURE QUERY OPTIONS FOR CMS DATA.
5
+ */
6
+ import { cms } from "@evenicanpm/cms";
7
+ import type { Blog } from "@evenicanpm/cms/src/content/blogs/types";
8
+ import type { Page } from "@evenicanpm/cms/src/content/pages/types";
9
+ import { queryOptions } from "@tanstack/react-query";
10
+ import type { StrapiResponse } from "strapi-sdk-js";
11
+
12
+ // strapi-sdk-js's StrapiResponseMetaPagination is missing pageCount, which Strapi
13
+ // includes when queries use withCount:true. This extends the meta to include it.
14
+ type StrapiPagedResponse<T> = StrapiResponse<T> & {
15
+ meta: {
16
+ pagination: {
17
+ page: number;
18
+ pageSize: number;
19
+ pageCount: number;
20
+ total: number;
21
+ };
22
+ };
23
+ };
24
+
25
+ export const cmsQueryKeys = {
26
+ navigation: () => ["cms", "navigation"] as const,
27
+ page: (
28
+ slug: string,
29
+ status: "draft" | "published" = "published",
30
+ locale?: string,
31
+ ) => ["cms", "pages", slug, status, locale] as const,
32
+ blogs: (page: number, pageSize: number, locale?: string) =>
33
+ ["cms", "blogs", page, pageSize, locale] as const,
34
+ blog: (id: string, locale?: string) => ["cms", "blog", id, locale] as const,
35
+ };
36
+
37
+ export const cmsQueryOptions = {
38
+ navigation: () =>
39
+ queryOptions({
40
+ queryKey: cmsQueryKeys.navigation(),
41
+ queryFn: (): Promise<unknown> => cms.navigation.getMainNavigation(),
42
+ }),
43
+ page: (
44
+ slug: string,
45
+ status: "draft" | "published" = "published",
46
+ locale?: string,
47
+ ) =>
48
+ queryOptions({
49
+ queryKey: cmsQueryKeys.page(slug, status, locale),
50
+ queryFn: (): Promise<StrapiResponse<Page[]>> =>
51
+ (
52
+ cms.pages.getPage as (opts: {
53
+ slug: string;
54
+ status?: "draft" | "published";
55
+ locale?: string;
56
+ }) => Promise<StrapiResponse<Page[]>>
57
+ )({ slug, status, locale }),
58
+ }),
59
+ blogs: (page: number, pageSize: number, locale?: string) =>
60
+ queryOptions({
61
+ queryKey: cmsQueryKeys.blogs(page, pageSize, locale),
62
+ queryFn: (): Promise<StrapiPagedResponse<Blog[]>> =>
63
+ (
64
+ cms.blogs.getBlogs as (opts: {
65
+ pagination: { page: number; pageSize: number };
66
+ locale?: string;
67
+ }) => Promise<StrapiPagedResponse<Blog[]>>
68
+ )({ pagination: { page, pageSize }, locale }),
69
+ }),
70
+ blog: (id: string, locale?: string) =>
71
+ queryOptions({
72
+ queryKey: cmsQueryKeys.blog(id, locale),
73
+ queryFn: (): Promise<StrapiResponse<Blog>> =>
74
+ (
75
+ cms.blogs.getBlog as (opts: {
76
+ id: string;
77
+ locale?: string;
78
+ }) => Promise<StrapiResponse<Blog>>
79
+ )({ id, locale }),
80
+ }),
81
+ };
@@ -1,3 +1,5 @@
1
+ "use client";
2
+
1
3
  import Box, { type BoxProps } from "@mui/material/Box";
2
4
 
3
5
  /**
@@ -7,8 +7,15 @@ vi.mock("@evenicanpm/storefront-core/src/hooks/use-nextauth-session", () => ({
7
7
  useAppSession: vi.fn(() => ({ session: null })),
8
8
  }));
9
9
 
10
- vi.mock("next-auth/react", () => ({
11
- useSession: () => ({ data: null, status: "unauthenticated" }),
10
+ vi.mock("better-auth/react", () => ({
11
+ createAuthClient: () => ({
12
+ useSession: () => ({ data: null, isPending: false }),
13
+ signIn: { oauth2: vi.fn() },
14
+ }),
15
+ }));
16
+
17
+ vi.mock("better-auth/client/plugins", () => ({
18
+ genericOAuthClient: () => ({}),
12
19
  }));
13
20
 
14
21
  vi.mock(