@vtex/faststore-plugin-buyer-portal 2.0.30 → 2.0.32

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 (56) hide show
  1. package/package.json +1 -1
  2. package/src/features/contracts/components/AddContractsDrawer/AddContractsDrawer.tsx +12 -1
  3. package/src/features/contracts/components/ContractListItem/ContractListItem.tsx +19 -11
  4. package/src/features/contracts/components/ContractListItem/__tests__/contractListItemMenu.test.ts +32 -0
  5. package/src/features/contracts/components/ContractListItem/contractListItemMenu.ts +17 -0
  6. package/src/features/contracts/components/ContractSelectionList/ContractSelectionListSkeleton.tsx +35 -0
  7. package/src/features/contracts/components/ContractSwitchOverlay/ContractSwitchOverlay.tsx +9 -1
  8. package/src/features/contracts/components/index.ts +2 -0
  9. package/src/features/contracts/hooks/useSwitchContract.ts +6 -4
  10. package/src/features/contracts/layouts/ContractInformationLayout/ContractInformationLayout.tsx +9 -12
  11. package/src/features/contracts/layouts/ContractListingLayout/ContractListingLayout.tsx +90 -41
  12. package/src/features/contracts/layouts/ContractListingLayout/__tests__/utils.test.ts +40 -1
  13. package/src/features/contracts/layouts/ContractListingLayout/contract-listing-layout.scss +10 -2
  14. package/src/features/contracts/layouts/ContractListingLayout/utils.ts +25 -0
  15. package/src/features/contracts/utils/__tests__/clearPersistedSessionState.test.ts +32 -0
  16. package/src/features/contracts/utils/__tests__/resolveSwitchRedirect.test.ts +21 -0
  17. package/src/features/contracts/utils/clearPersistedSessionState.ts +43 -5
  18. package/src/features/contracts/utils/resolveSwitchRedirect.ts +15 -0
  19. package/src/features/credit-cards/services/tokenize-credit-card.service.ts +3 -1
  20. package/src/features/org-units/components/OrgUnitBreadcrumb/OrgUnitBreadcrumb.tsx +21 -31
  21. package/src/features/org-units/components/OrgUnitBreadcrumb/__tests__/buildBreadcrumbItems.test.ts +64 -0
  22. package/src/features/org-units/components/OrgUnitBreadcrumb/buildBreadcrumbItems.ts +36 -0
  23. package/src/features/org-units/components/OrgUnitDetailsNavbar/OrgUnitDetailsNavbar.tsx +67 -27
  24. package/src/features/org-units/components/OrgUnitDetailsNavbar/org-unit-details-navbar.scss +22 -1
  25. package/src/features/org-units/components/OrgUnitHeaderNav/OrgUnitHeaderNav.tsx +112 -0
  26. package/src/features/org-units/components/OrgUnitHeaderNav/__tests__/getOrgUnitHeaderNavItems.test.ts +83 -0
  27. package/src/features/org-units/components/OrgUnitHeaderNav/getOrgUnitHeaderNavItems.ts +36 -0
  28. package/src/features/org-units/components/OrgUnitHeaderNav/org-unit-header-nav.scss +129 -0
  29. package/src/features/org-units/components/index.ts +4 -0
  30. package/src/features/org-units/layouts/OrgUnitDetailsLayout/org-units-details.scss +1 -0
  31. package/src/features/org-units/types/OrgUnitBreadcrumbTypes.ts +8 -0
  32. package/src/features/profile/layouts/ProfileLayout/ProfileLayout.tsx +8 -31
  33. package/src/features/shared/hooks/index.ts +1 -0
  34. package/src/features/shared/hooks/useRefreshPageData.ts +35 -0
  35. package/src/features/shared/layouts/BaseTabsLayout/about-drawer/__tests__/aboutQueryParam.test.ts +23 -0
  36. package/src/features/shared/layouts/BaseTabsLayout/about-drawer/aboutQueryParam.ts +13 -0
  37. package/src/features/shared/layouts/ContractTabsLayout/ContractTabsLayout.tsx +2 -2
  38. package/src/features/shared/layouts/ErrorTabsLayout/ErrorTabsLayout.tsx +6 -1
  39. package/src/features/shared/layouts/LoadingTabsLayout/LoadingTabsLayout.tsx +35 -2
  40. package/src/features/shared/layouts/SumaPageLayout/FullSidebarNav.tsx +33 -12
  41. package/src/features/shared/layouts/SumaPageLayout/SumaPageLayout.tsx +4 -2
  42. package/src/features/shared/layouts/SumaPageLayout/SumaSidebar.tsx +26 -17
  43. package/src/features/shared/layouts/SumaPageLayout/__tests__/resolveOrgLinkContractId.test.ts +16 -0
  44. package/src/features/shared/layouts/SumaPageLayout/__tests__/sumaSidebarConfig.test.ts +27 -0
  45. package/src/features/shared/layouts/SumaPageLayout/resolveOrgLinkContractId.ts +13 -0
  46. package/src/features/shared/layouts/SumaPageLayout/sumaSidebarConfig.ts +37 -0
  47. package/src/features/shared/utils/__tests__/getContractSettingsLinks.test.ts +18 -0
  48. package/src/features/shared/utils/__tests__/getSumaListingSidebarLinks.test.ts +43 -0
  49. package/src/features/shared/utils/__tests__/routeLayoutMapping.test.ts +34 -0
  50. package/src/features/shared/utils/constants.ts +1 -1
  51. package/src/features/shared/utils/getContractSettingsLinks.ts +6 -1
  52. package/src/features/shared/utils/routeLayoutMapping.ts +9 -1
  53. package/src/pages/contract-information.tsx +9 -1
  54. package/src/pages/contracts.tsx +5 -0
  55. package/src/pages/home.tsx +25 -19
  56. package/src/pages/org-unit-details.tsx +10 -24
@@ -1,3 +1,5 @@
1
+ import { buyerPortalRoutes } from "../../../shared/utils/buyerPortalRoutes";
2
+
1
3
  import type { ContractData } from "../../types";
2
4
 
3
5
  export const getDefaultContractId = (contracts: ContractData[]) =>
@@ -15,3 +17,26 @@ export const resolveViewedContractId = (
15
17
  contracts.some((c) => c.id === queryContractId)
16
18
  ? (queryContractId as string)
17
19
  : getDefaultContractId(contracts);
20
+
21
+ /** Args for the `{0} contract{1} …` toast messages. */
22
+ export const pluralSuffixArgs = (count: number): [number, string] => [
23
+ count,
24
+ count === 1 ? "" : "s",
25
+ ];
26
+
27
+ /** Query param carrying the removed-contracts count across the post-switch hard reload (AS-6). */
28
+ export const REMOVED_QUERY_PARAM = "removed";
29
+
30
+ export const buildRemovedRedirectUrl = (orgUnitId: string, count: number) =>
31
+ `${buyerPortalRoutes.contracts({
32
+ orgUnitId,
33
+ })}?${REMOVED_QUERY_PARAM}=${count}`;
34
+
35
+ export const parseRemovedCount = (
36
+ value: string | string[] | undefined
37
+ ): number | null => {
38
+ const raw = Array.isArray(value) ? value[0] : value;
39
+ if (!raw) return null;
40
+ const count = Number(raw);
41
+ return Number.isInteger(count) && count > 0 ? count : null;
42
+ };
@@ -0,0 +1,32 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import {
4
+ CHECKOUT_ORDER_FORM_COOKIE,
5
+ PERSISTED_STORE_KEYS,
6
+ getCookieVariants,
7
+ } from "../clearPersistedSessionState";
8
+
9
+ describe("clearPersistedSessionState constants", () => {
10
+ it("clears both the host session and the host cart", () => {
11
+ expect(PERSISTED_STORE_KEYS).toEqual(["fs::session", "fs::cart"]);
12
+ expect(CHECKOUT_ORDER_FORM_COOKIE).toBe("checkout.vtex.com");
13
+ });
14
+ });
15
+
16
+ describe("getCookieVariants", () => {
17
+ it("covers host-only, host and dot-host domains for every parent path", () => {
18
+ expect(
19
+ getCookieVariants("shop.example.com", "/pvt/organization-account")
20
+ ).toEqual([
21
+ { domain: undefined, path: "/" },
22
+ { domain: undefined, path: "/pvt" },
23
+ { domain: undefined, path: "/pvt/organization-account" },
24
+ { domain: "shop.example.com", path: "/" },
25
+ { domain: "shop.example.com", path: "/pvt" },
26
+ { domain: "shop.example.com", path: "/pvt/organization-account" },
27
+ { domain: ".shop.example.com", path: "/" },
28
+ { domain: ".shop.example.com", path: "/pvt" },
29
+ { domain: ".shop.example.com", path: "/pvt/organization-account" },
30
+ ]);
31
+ });
32
+ });
@@ -0,0 +1,21 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { resolveSwitchRedirect } from "../resolveSwitchRedirect";
4
+
5
+ describe("resolveSwitchRedirect", () => {
6
+ it("defaults to the contract's profile page", () => {
7
+ expect(
8
+ resolveSwitchRedirect({ orgUnitId: "unit-1", contractId: "contract-2" })
9
+ ).toBe("/pvt/organization-account/profile/unit-1/contract-2");
10
+ });
11
+
12
+ it("honors an explicit redirect target", () => {
13
+ expect(
14
+ resolveSwitchRedirect({
15
+ orgUnitId: "unit-1",
16
+ contractId: "contract-2",
17
+ redirectTo: "/pvt/organization-account/contracts/unit-1?removed=2",
18
+ })
19
+ ).toBe("/pvt/organization-account/contracts/unit-1?removed=2");
20
+ });
21
+ });
@@ -1,6 +1,41 @@
1
1
  const IDB_NAME = "keyval-store";
2
2
  const IDB_STORE = "keyval";
3
- const SESSION_KEY = "fs::session";
3
+
4
+ /** Host FastStore persisted stores (`packages/sdk`): session and cart. */
5
+ export const PERSISTED_STORE_KEYS = ["fs::session", "fs::cart"] as const;
6
+ /** Cookie holding the checkout orderForm id read by `validateCart`. */
7
+ export const CHECKOUT_ORDER_FORM_COOKIE = "checkout.vtex.com";
8
+
9
+ export const getCookieVariants = (hostname: string, pathname: string) => {
10
+ const domains: Array<string | undefined> = [
11
+ undefined,
12
+ hostname,
13
+ hostname.startsWith(".") ? hostname : `.${hostname}`,
14
+ ];
15
+ const paths = ["/"];
16
+ let current = "";
17
+ for (const part of pathname.split("/").filter(Boolean)) {
18
+ current += `/${part}`;
19
+ if (!paths.includes(current)) paths.push(current);
20
+ }
21
+ return domains.flatMap((domain) => paths.map((path) => ({ domain, path })));
22
+ };
23
+
24
+ const expireCheckoutOrderFormCookie = () => {
25
+ if (typeof document === "undefined" || typeof window === "undefined") return;
26
+ try {
27
+ const secure = window.location.protocol === "https:" ? "; secure" : "";
28
+ for (const { domain, path } of getCookieVariants(
29
+ window.location.hostname,
30
+ window.location.pathname
31
+ )) {
32
+ const domainAttr = domain ? `; domain=${domain}` : "";
33
+ document.cookie = `${CHECKOUT_ORDER_FORM_COOKIE}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; max-age=0; path=${path}${domainAttr}; samesite=lax${secure}`;
34
+ }
35
+ } catch {
36
+ // no-op: cookie expiry is best-effort and must never throw
37
+ }
38
+ };
4
39
 
5
40
  const SESSION_STORAGE_KEYS = [
6
41
  "faststore_session_ready",
@@ -26,7 +61,8 @@ function deletePersistedSession(): Promise<void> {
26
61
  return;
27
62
  }
28
63
  const tx = db.transaction(IDB_STORE, "readwrite");
29
- tx.objectStore(IDB_STORE).delete(SESSION_KEY);
64
+ const store = tx.objectStore(IDB_STORE);
65
+ for (const key of PERSISTED_STORE_KEYS) store.delete(key);
30
66
  tx.oncomplete = () => {
31
67
  db.close();
32
68
  resolve();
@@ -53,12 +89,14 @@ function deletePersistedSession(): Promise<void> {
53
89
  }
54
90
 
55
91
  /**
56
- * Clears client-side session persistence (host FastStore session cache) so a hard
57
- * reload rehydrates from validateSession instead of stale IndexedDB / sessionStorage
58
- * values after switching the active contract.
92
+ * Clears client-side session persistence (host FastStore session cache), the
93
+ * persisted cart, and the checkout orderForm cookie, so the next hard navigation
94
+ * creates a fresh orderForm under the new contract (B2BTEAM-3827, Product review
95
+ * items 1 and 3).
59
96
  */
60
97
  export async function clearPersistedSessionState(): Promise<void> {
61
98
  await deletePersistedSession();
99
+ expireCheckoutOrderFormCookie();
62
100
 
63
101
  if (typeof window === "undefined" || typeof sessionStorage === "undefined") {
64
102
  return;
@@ -0,0 +1,15 @@
1
+ import { buyerPortalRoutes } from "../../shared/utils/buyerPortalRoutes";
2
+
3
+ export type ResolveSwitchRedirectParams = {
4
+ orgUnitId: string;
5
+ contractId: string;
6
+ /** Overrides the default Profile destination (e.g. stay on the listing). */
7
+ redirectTo?: string;
8
+ };
9
+
10
+ export const resolveSwitchRedirect = ({
11
+ orgUnitId,
12
+ contractId,
13
+ redirectTo,
14
+ }: ResolveSwitchRedirectParams): string =>
15
+ redirectTo ?? buyerPortalRoutes.profileDetails({ orgUnitId, contractId });
@@ -27,7 +27,9 @@ export const tokenizeCreditCardService = async ({
27
27
  complement: creditCard.address.streetAddress2,
28
28
  coutry: creditCard.address.countryCode,
29
29
  neighborhood: creditCard.address.neighborhood ?? "",
30
- geoCoordinates: creditCard.address.geoCoordinates.split(",") ?? [],
30
+ geoCoordinates: creditCard.address.geoCoordinates
31
+ ? creditCard.address.geoCoordinates.split(",")
32
+ : [],
31
33
  postalCode: creditCard.address.zip,
32
34
  number: creditCard.address.streetNumber ?? "",
33
35
  receiverName: "NOT EMPTY",
@@ -4,16 +4,13 @@ import { Icon } from "../../../shared/components";
4
4
  import { useBuyerPortal } from "../../../shared/hooks";
5
5
  import { buyerPortalRoutes } from "../../../shared/utils/buyerPortalRoutes";
6
6
  import { resolveContractId } from "../../../shared/utils/resolveContractId";
7
- import { USER_ROLES } from "../../../users/types";
8
7
  import { useOrgUnitByUser } from "../../hooks";
9
8
 
9
+ import { buildOrgUnitBreadcrumbItems } from "./buildBreadcrumbItems";
10
10
  import { OrgUnitBreadcrumbDropdown } from "./OrgUnitBreadcrumbDropdown";
11
11
  import { OrgUnitBreadcrumbPath } from "./OrgUnitBreadcrumbPath";
12
12
 
13
- import type {
14
- OrgUnitBreadcrumbItem,
15
- OrgUnitBreadcrumbProps,
16
- } from "../../types";
13
+ import type { OrgUnitBreadcrumbProps } from "../../types";
17
14
 
18
15
  export const OrgUnitBreadcrumb = ({
19
16
  maxItems,
@@ -21,9 +18,17 @@ export const OrgUnitBreadcrumb = ({
21
18
  orgUnit,
22
19
  userId,
23
20
  userRoles,
21
+ userUnitId,
24
22
  }: OrgUnitBreadcrumbProps) => {
25
- const { currentContract, currentOrgUnit } = useBuyerPortal();
26
- const { orgUnit: userUnit } = useOrgUnitByUser(userId);
23
+ const { currentContract, currentOrgUnit, featureFlags } = useBuyerPortal();
24
+ // Skip the fetch entirely when the caller already knows the user's unit
25
+ // (e.g. `currentUser.orgUnit.id`, already loaded by the page) — otherwise
26
+ // this fires an uncached request on every render and the breadcrumb shows
27
+ // no unit name for non-super-users until it resolves.
28
+ const { orgUnit: fetchedUserUnit } = useOrgUnitByUser(userId, {
29
+ lazy: Boolean(userUnitId),
30
+ });
31
+ const resolvedUserUnitId = userUnitId ?? fetchedUserUnit?.id;
27
32
  const router = useRouter();
28
33
 
29
34
  const contractId = resolveContractId({
@@ -32,30 +37,15 @@ export const OrgUnitBreadcrumb = ({
32
37
  currentOrgUnit,
33
38
  });
34
39
 
35
- const orgUnitList = orgUnit?.path.ids.split("/").slice(1) ?? [];
36
-
37
- const userUnitIndex = orgUnitList.findIndex(
38
- (unitId) => unitId === userUnit?.id
39
- );
40
-
41
- const items = orgUnitList.reduce<OrgUnitBreadcrumbItem[]>(
42
- (acc, orgUnitId, index) => {
43
- if (
44
- userRoles.includes(USER_ROLES.SUPER_USER) ||
45
- (userUnitIndex > -1 && index >= userUnitIndex)
46
- ) {
47
- acc.push({
48
- unitId: orgUnitId,
49
- item: buyerPortalRoutes.orgUnitDetails({ orgUnitId, contractId }),
50
- name: orgUnit?.path.names.split("/")[index] ?? "",
51
- position: index + 1,
52
- enabled: true,
53
- });
54
- }
55
- return acc;
56
- },
57
- []
58
- );
40
+ const items = buildOrgUnitBreadcrumbItems({
41
+ orgUnit,
42
+ userUnitId: resolvedUserUnitId,
43
+ userRoles,
44
+ buildLink: (orgUnitId) =>
45
+ featureFlags?.suma
46
+ ? buyerPortalRoutes.contracts({ orgUnitId })
47
+ : buyerPortalRoutes.orgUnitDetails({ orgUnitId, contractId }),
48
+ });
59
49
 
60
50
  const sortedByPosition = [...items].sort((a, b) => a.position - b.position);
61
51
 
@@ -0,0 +1,64 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { USER_ROLES } from "../../../../users/types";
4
+ import { buildOrgUnitBreadcrumbItems } from "../buildBreadcrumbItems";
5
+
6
+ import type { OrgUnitBasicData } from "../../../types";
7
+
8
+ const orgUnit = {
9
+ id: "child",
10
+ name: "Child",
11
+ path: { ids: "/root/mid/child", names: "Root/Mid/Child" },
12
+ } as OrgUnitBasicData;
13
+
14
+ const buildLink = (id: string) => `/contracts/${id}`;
15
+
16
+ describe("buildOrgUnitBreadcrumbItems", () => {
17
+ it("lists the whole hierarchy for a super user", () => {
18
+ const items = buildOrgUnitBreadcrumbItems({
19
+ orgUnit,
20
+ userUnitId: "child",
21
+ userRoles: [USER_ROLES.SUPER_USER],
22
+ buildLink,
23
+ });
24
+
25
+ expect(items.map((i) => [i.name, i.item, i.position])).toEqual([
26
+ ["Root", "/contracts/root", 1],
27
+ ["Mid", "/contracts/mid", 2],
28
+ ["Child", "/contracts/child", 3],
29
+ ]);
30
+ });
31
+
32
+ it("starts at the user's own unit for a regular admin", () => {
33
+ const items = buildOrgUnitBreadcrumbItems({
34
+ orgUnit,
35
+ userUnitId: "mid",
36
+ userRoles: [],
37
+ buildLink,
38
+ });
39
+
40
+ expect(items.map((i) => i.unitId)).toEqual(["mid", "child"]);
41
+ });
42
+
43
+ it("returns nothing when the user's unit is not in the path", () => {
44
+ expect(
45
+ buildOrgUnitBreadcrumbItems({
46
+ orgUnit,
47
+ userUnitId: "other",
48
+ userRoles: [],
49
+ buildLink,
50
+ })
51
+ ).toEqual([]);
52
+ });
53
+
54
+ it("returns nothing without an org unit", () => {
55
+ expect(
56
+ buildOrgUnitBreadcrumbItems({
57
+ orgUnit: null,
58
+ userUnitId: "x",
59
+ userRoles: [],
60
+ buildLink,
61
+ })
62
+ ).toEqual([]);
63
+ });
64
+ });
@@ -0,0 +1,36 @@
1
+ import { USER_ROLES } from "../../../users/types";
2
+
3
+ import type { OrgUnitBasicData, OrgUnitBreadcrumbItem } from "../../types";
4
+
5
+ export type BuildOrgUnitBreadcrumbItemsParams = {
6
+ orgUnit?: OrgUnitBasicData | null;
7
+ userUnitId?: string;
8
+ userRoles: string[];
9
+ buildLink: (orgUnitId: string) => string;
10
+ };
11
+
12
+ export const buildOrgUnitBreadcrumbItems = ({
13
+ orgUnit,
14
+ userUnitId,
15
+ userRoles,
16
+ buildLink,
17
+ }: BuildOrgUnitBreadcrumbItemsParams): OrgUnitBreadcrumbItem[] => {
18
+ const ids = orgUnit?.path.ids.split("/").slice(1) ?? [];
19
+ const names = orgUnit?.path.names.split("/") ?? [];
20
+ const userUnitIndex = ids.findIndex((id) => id === userUnitId);
21
+ const isSuperUser = userRoles.includes(USER_ROLES.SUPER_USER);
22
+
23
+ return ids.flatMap((unitId, index) =>
24
+ isSuperUser || (userUnitIndex > -1 && index >= userUnitIndex)
25
+ ? [
26
+ {
27
+ unitId,
28
+ item: buildLink(unitId),
29
+ name: names[index] ?? "",
30
+ position: index + 1,
31
+ enabled: true,
32
+ },
33
+ ]
34
+ : []
35
+ );
36
+ };
@@ -1,6 +1,9 @@
1
+ import { useEffect } from "react";
2
+
1
3
  import storeConfig from "discovery.config";
2
4
 
3
5
  import dynamic from "next/dynamic";
6
+ import { useRouter } from "next/router";
4
7
 
5
8
  import { useLink_unstable as useLink } from "@faststore/core/experimental";
6
9
  import { Dropdown, DropdownMenu, IconButton, Link } from "@faststore/ui";
@@ -15,11 +18,19 @@ const LocalizationButton = dynamic(
15
18
 
16
19
  import { ProfileSummary } from "../../../profile/components";
17
20
  import { BasicDropdownMenu, Icon } from "../../../shared/components";
18
- import { useDrawerProps } from "../../../shared/hooks";
21
+ import { useBuyerPortal, useDrawerProps } from "../../../shared/hooks";
19
22
  import { AboutDrawer } from "../../../shared/layouts/BaseTabsLayout/about-drawer/AboutDrawer";
23
+ import {
24
+ ABOUT_QUERY_PARAM,
25
+ shouldOpenAboutDrawer,
26
+ } from "../../../shared/layouts/BaseTabsLayout/about-drawer/aboutQueryParam";
20
27
  import { SumaSidebarDrawer } from "../../../shared/layouts/SumaPageLayout/SumaSidebarDrawer";
21
28
  import { useLocalization } from "../../../shared/localization/LocalizationContext";
22
29
  import { doLogout } from "../../../shared/utils";
30
+ import { buyerPortalRoutes } from "../../../shared/utils/buyerPortalRoutes";
31
+ import { OrgUnitHeaderNav } from "../OrgUnitHeaderNav/OrgUnitHeaderNav";
32
+
33
+ import type { SumaSidebarVariant } from "../../../shared/layouts/SumaPageLayout/sumaSidebarConfig";
23
34
 
24
35
  export type OrgUnitDetailsNavbarProps = {
25
36
  orgId: string;
@@ -32,7 +43,7 @@ export type OrgUnitDetailsNavbarProps = {
32
43
  email?: string;
33
44
  };
34
45
  loading?: boolean;
35
- sidebarVariant?: "listing" | "full";
46
+ sidebarVariant?: SumaSidebarVariant;
36
47
  showBackLink?: boolean;
37
48
  };
38
49
 
@@ -46,6 +57,9 @@ export const OrgUnitDetailsNavbar = ({
46
57
  }: OrgUnitDetailsNavbarProps) => {
47
58
  const { t } = useLocalization();
48
59
  const { resolveLink } = useLink();
60
+ const router = useRouter();
61
+ const { currentOrgUnit, currentUser, featureFlags } = useBuyerPortal();
62
+ const isSuma = Boolean(featureFlags?.suma);
49
63
  const {
50
64
  open: openAboutMenu,
51
65
  isOpen: isOpenAboutMenu,
@@ -58,6 +72,22 @@ export const OrgUnitDetailsNavbar = ({
58
72
  ...sidebarMenuDrawerProps
59
73
  } = useDrawerProps();
60
74
 
75
+ // `?about=1` opens the About/version drawer and is stripped from the URL.
76
+ // Under SUMA there is no visible trigger (QA-11), so this is the way to
77
+ // read the installed versions on any Organization Account page.
78
+ const aboutParam = router.query[ABOUT_QUERY_PARAM];
79
+ useEffect(() => {
80
+ if (!shouldOpenAboutDrawer(aboutParam)) return;
81
+ openAboutMenu();
82
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
83
+ const { [ABOUT_QUERY_PARAM]: _about, ...rest } = router.query;
84
+ void router
85
+ .replace({ pathname: router.pathname, query: rest }, undefined, {
86
+ shallow: true,
87
+ })
88
+ .catch(() => {});
89
+ }, [aboutParam]);
90
+
61
91
  return (
62
92
  <header data-fs-bp-org-unit-details-navbar>
63
93
  <div data-fs-bp-org-unit-details-navbar-heading>
@@ -73,17 +103,24 @@ export const OrgUnitDetailsNavbar = ({
73
103
  {storeConfig.seo.title}
74
104
  </Link>
75
105
  </div>
106
+ {isSuma && currentOrgUnit && (
107
+ <div data-fs-bp-org-unit-details-navbar-unit>
108
+ <OrgUnitHeaderNav
109
+ orgUnit={currentOrgUnit}
110
+ userId={currentUser?.id ?? ""}
111
+ userUnitId={currentUser?.orgUnit?.id}
112
+ userRoles={currentUser?.roles ?? []}
113
+ />
114
+ </div>
115
+ )}
76
116
  <div data-fs-bp-org-unit-details-navbar-actions>
77
- {/* TO-DO: Reenable button when agent is made ready for users */}
78
- {/*
79
117
  <Link
80
118
  href={buyerPortalRoutes.b2bAgent}
81
119
  data-fs-bp-org-unit-details-navbar-agent-link
82
120
  >
83
121
  <Icon name="Sparkle" width={20} height={20} weight="regular" />
84
- Organization Manager
122
+ {t("shared.navigation.organizationManager")}
85
123
  </Link>
86
- */}
87
124
 
88
125
  <LocalizationButton
89
126
  saveLabel={t("shared.buttons.save")}
@@ -91,27 +128,30 @@ export const OrgUnitDetailsNavbar = ({
91
128
  languageLabel={t("layouts.labels.language")}
92
129
  icon="Globe"
93
130
  />
94
- <Link
95
- target="_blank"
96
- href={resolveLink("/")}
97
- data-fs-bp-org-unit-details-navbar-start-shopping-link
98
- >
99
- {t("layouts.navigation.startShopping")}
100
- </Link>
101
-
102
- <IconButton
103
- data-fs-bp-nav-about-trigger
104
- icon={
105
- <Icon
106
- name="CircleQuestionMark"
107
- width={24}
108
- height={24}
109
- weight="regular"
110
- />
111
- }
112
- aria-label={"open about menu"}
113
- onClick={openAboutMenu}
114
- />
131
+ {!isSuma && (
132
+ <Link
133
+ target="_blank"
134
+ href={resolveLink("/")}
135
+ data-fs-bp-org-unit-details-navbar-start-shopping-link
136
+ >
137
+ {t("layouts.navigation.startShopping")}
138
+ </Link>
139
+ )}
140
+ {!isSuma && (
141
+ <IconButton
142
+ data-fs-bp-nav-about-trigger
143
+ icon={
144
+ <Icon
145
+ name="CircleQuestionMark"
146
+ width={24}
147
+ height={24}
148
+ weight="regular"
149
+ />
150
+ }
151
+ aria-label={"open about menu"}
152
+ onClick={openAboutMenu}
153
+ />
154
+ )}
115
155
  <Dropdown>
116
156
  <BasicDropdownMenu.Trigger iconSize={20} iconName="AccountCircle" />
117
157
  <DropdownMenu align="right" data-fs-bp-org-unit-details-navbar-menu>
@@ -1,7 +1,7 @@
1
1
  @use "~@faststore/ui/src/styles/base/utilities" as u;
2
2
 
3
3
  [data-fs-bp-org-unit-details-navbar] {
4
-
4
+ position: relative;
5
5
  display: flex;
6
6
  justify-content: space-between;
7
7
  align-items: center;
@@ -21,6 +21,27 @@
21
21
  gap: var(--fs-bp-gap-2);
22
22
  }
23
23
 
24
+ // Current unit control: sits between the store name and the actions on
25
+ // small screens and is centered on the header (prototype) from notebook up.
26
+ [data-fs-bp-org-unit-details-navbar-unit] {
27
+ display: flex;
28
+ flex: 1 1 auto;
29
+ align-items: center;
30
+ justify-content: center;
31
+ min-width: 0;
32
+ padding: 0 var(--fs-bp-padding-3);
33
+
34
+ @include u.media(">=notebook") {
35
+ position: absolute;
36
+ top: 50%;
37
+ left: 50%;
38
+ flex: none;
39
+ max-width: 40%;
40
+ padding: 0;
41
+ transform: translate(-50%, -50%);
42
+ }
43
+ }
44
+
24
45
  [data-fs-localization-button] {
25
46
  [data-fs-button-wrapper] {
26
47
  span {
@@ -0,0 +1,112 @@
1
+ import { useRouter } from "next/router";
2
+
3
+ import {
4
+ Dropdown,
5
+ DropdownButton,
6
+ DropdownItem,
7
+ DropdownMenu,
8
+ } from "@faststore/ui";
9
+
10
+ import { Icon } from "../../../shared/components";
11
+ import { useBuyerPortal } from "../../../shared/hooks";
12
+ import { useLocalization } from "../../../shared/localization/LocalizationContext";
13
+ import { buyerPortalRoutes } from "../../../shared/utils/buyerPortalRoutes";
14
+ import { useOrgUnitByUser } from "../../hooks";
15
+
16
+ import { getOrgUnitHeaderNavItems } from "./getOrgUnitHeaderNavItems";
17
+
18
+ import type { OrgUnitBasicData } from "../../types";
19
+
20
+ export type OrgUnitHeaderNavProps = {
21
+ orgUnit: OrgUnitBasicData;
22
+ userId: string;
23
+ userRoles: string[];
24
+ /** The user's own unit id when already known; skips the lookup. */
25
+ userUnitId?: string;
26
+ };
27
+
28
+ /**
29
+ * Header control of the SUMA shell: a pill with the current Organization Unit
30
+ * and, when the user may go up the hierarchy, a dropdown listing the parent
31
+ * units (root first) with the current unit highlighted at the bottom.
32
+ * Selecting a unit lands on its Contracts listing (unit view).
33
+ * Mirrors `Header.tsx` in the `multiple-contracts` prototype.
34
+ */
35
+ export const OrgUnitHeaderNav = ({
36
+ orgUnit,
37
+ userId,
38
+ userRoles,
39
+ userUnitId,
40
+ }: OrgUnitHeaderNavProps) => {
41
+ const { t } = useLocalization();
42
+ const { push } = useRouter();
43
+ const { featureFlags } = useBuyerPortal();
44
+ const { orgUnit: fetchedUserUnit } = useOrgUnitByUser(userId, {
45
+ lazy: Boolean(userUnitId),
46
+ });
47
+
48
+ const { ancestors, canNavigate } = getOrgUnitHeaderNavItems({
49
+ orgUnit,
50
+ userUnitId: userUnitId ?? fetchedUserUnit?.id,
51
+ userRoles,
52
+ buildLink: (orgUnitId) =>
53
+ featureFlags?.suma
54
+ ? buyerPortalRoutes.contracts({ orgUnitId })
55
+ : buyerPortalRoutes.orgUnitDetails({ orgUnitId }),
56
+ });
57
+
58
+ return (
59
+ <div data-fs-bp-org-unit-header-nav>
60
+ <Dropdown>
61
+ <DropdownButton asChild>
62
+ <button
63
+ type="button"
64
+ data-fs-bp-org-unit-header-nav-trigger
65
+ disabled={!canNavigate}
66
+ aria-label={t("layouts.navigation.organizationalUnits")}
67
+ >
68
+ <Icon
69
+ name="FilledFolder"
70
+ width={20}
71
+ height={20}
72
+ data-fs-bp-org-unit-header-nav-icon
73
+ />
74
+ <span data-fs-bp-org-unit-header-nav-name>{orgUnit.name}</span>
75
+ {canNavigate && (
76
+ <Icon
77
+ name="ArrowDropDown"
78
+ width={20}
79
+ height={20}
80
+ data-fs-bp-org-unit-header-nav-caret
81
+ />
82
+ )}
83
+ </button>
84
+ </DropdownButton>
85
+
86
+ <DropdownMenu align="center" data-fs-bp-org-unit-header-nav-menu>
87
+ {ancestors.map((ancestor) => (
88
+ <DropdownItem
89
+ key={ancestor.unitId}
90
+ data-fs-bp-org-unit-header-nav-item
91
+ onClick={() => push(ancestor.item)}
92
+ >
93
+ <Icon name="Folder" width={20} height={20} />
94
+ <span data-fs-bp-org-unit-header-nav-item-name>
95
+ {ancestor.name}
96
+ </span>
97
+ </DropdownItem>
98
+ ))}
99
+ <DropdownItem
100
+ data-fs-bp-org-unit-header-nav-item
101
+ data-fs-bp-org-unit-header-nav-item-current
102
+ aria-current="page"
103
+ dismissOnClick
104
+ >
105
+ <Icon name="FilledFolder" width={20} height={20} />
106
+ <span data-fs-bp-org-unit-header-nav-item-name>{orgUnit.name}</span>
107
+ </DropdownItem>
108
+ </DropdownMenu>
109
+ </Dropdown>
110
+ </div>
111
+ );
112
+ };