@vtex/faststore-plugin-buyer-portal 2.0.19 → 2.0.21

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 (28) hide show
  1. package/CHANGELOG.md +14 -1
  2. package/docs/i18n/locales.json +39 -0
  3. package/docs/i18n/migration-inventory.md +78 -0
  4. package/package.json +1 -1
  5. package/specs/analytics-actor-type.md +243 -0
  6. package/src/features/contracts/components/AddContractsDrawer/AddContractsDrawer.tsx +28 -15
  7. package/src/features/contracts/components/ConfirmRemoveDrawer/ConfirmRemoveDrawer.tsx +15 -12
  8. package/src/features/contracts/components/ContractListItem/ContractListItem.tsx +6 -4
  9. package/src/features/contracts/components/ContractSelectAllRow/ContractSelectAllRow.tsx +5 -1
  10. package/src/features/contracts/components/ContractSelectionBar/ContractSelectionBar.tsx +5 -2
  11. package/src/features/contracts/components/ContractSelectionList/ContractSelectionList.tsx +4 -1
  12. package/src/features/contracts/components/ContractSwitchOverlay/ContractSwitchOverlay.tsx +5 -2
  13. package/src/features/contracts/components/ContractsListHeader/ContractsListHeader.tsx +4 -1
  14. package/src/features/contracts/components/ErrorRemoveDrawer/ErrorRemoveDrawer.tsx +12 -15
  15. package/src/features/contracts/components/UnableToRemoveContractsDrawer/UnableToRemoveContractsDrawer.tsx +11 -8
  16. package/src/features/contracts/layouts/ContractInformationLayout/ContractInformationLayout.tsx +27 -20
  17. package/src/features/contracts/layouts/ContractListingLayout/ContractListingLayout.tsx +28 -26
  18. package/src/features/contracts/layouts/ContractListingLayout/ContractSettingsNav.tsx +9 -6
  19. package/src/features/shared/hooks/analytics/useAnalytics.ts +1 -1
  20. package/src/features/shared/services/logger/analytics/__tests__/deriveActorType.test.ts +30 -0
  21. package/src/features/shared/services/logger/analytics/__tests__/mergeActorTypeIntoEvent.test.ts +83 -0
  22. package/src/features/shared/services/logger/analytics/analytics.ts +2 -24
  23. package/src/features/shared/services/logger/analytics/deriveActorType.ts +24 -0
  24. package/src/features/shared/services/logger/analytics/mergeActorTypeIntoEvent.ts +29 -0
  25. package/src/features/shared/services/logger/analytics/types.ts +5 -0
  26. package/src/features/shared/services/logger/analytics/useDataIngestionApi.ts +32 -0
  27. package/src/features/shared/utils/constants.ts +1 -1
  28. package/src/features/users/components/RolesMultiSelect/RolesMultiSelect.tsx +9 -10
@@ -1,6 +1,6 @@
1
1
  import { useCallback, useEffect, useRef } from "react";
2
2
 
3
- import { useDataIngestionApi } from "../../services/logger/analytics/analytics";
3
+ import { useDataIngestionApi } from "../../services/logger/analytics/useDataIngestionApi";
4
4
 
5
5
  import type {
6
6
  AnalyticsTimer,
@@ -0,0 +1,30 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { deriveActorType } from "../deriveActorType";
4
+
5
+ describe("deriveActorType", () => {
6
+ it("returns internal for exact @vtex.com domain", () => {
7
+ expect(deriveActorType("rodrigo.tavares@vtex.com")).toBe("internal");
8
+ });
9
+
10
+ it("returns internal for case-insensitive and plus-tagged vtex.com emails", () => {
11
+ expect(deriveActorType("Name+qa@VTEX.COM")).toBe("internal");
12
+ });
13
+
14
+ it("returns external for non-vtex.com domains", () => {
15
+ expect(deriveActorType("buyer@acme.com")).toBe("external");
16
+ });
17
+
18
+ it("returns external for vtex.com.br and subdomain lookalikes", () => {
19
+ expect(deriveActorType("user@vtex.com.br")).toBe("external");
20
+ expect(deriveActorType("user@corp.vtex.com")).toBe("external");
21
+ });
22
+
23
+ it("returns external when email is missing, empty, or malformed", () => {
24
+ expect(deriveActorType(undefined)).toBe("external");
25
+ expect(deriveActorType(null)).toBe("external");
26
+ expect(deriveActorType("")).toBe("external");
27
+ expect(deriveActorType(" ")).toBe("external");
28
+ expect(deriveActorType("not-an-email")).toBe("external");
29
+ });
30
+ });
@@ -0,0 +1,83 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ import * as deriveActorTypeModule from "../deriveActorType";
4
+ import { mergeActorTypeIntoEvent } from "../mergeActorTypeIntoEvent";
5
+
6
+ import type { DataIngestionApiFields } from "../types";
7
+
8
+ describe("mergeActorTypeIntoEvent", () => {
9
+ it("attaches internal actor_type into metadata from a vtex.com email", () => {
10
+ const result = mergeActorTypeIntoEvent(
11
+ { event_name: "budget_created" },
12
+ "alice@vtex.com"
13
+ );
14
+
15
+ expect(result.metadata?.actor_type).toBe("internal");
16
+ expect(result).not.toHaveProperty("actor_type");
17
+ expect(result).not.toHaveProperty("email");
18
+ expect(result).not.toHaveProperty("login");
19
+ expect(result).not.toHaveProperty("userId");
20
+ expect(result).not.toHaveProperty("sub");
21
+ });
22
+
23
+ it("attaches external actor_type into metadata from a non-vtex email", () => {
24
+ const result = mergeActorTypeIntoEvent(
25
+ { event_name: "budget_created" },
26
+ "buyer@acme.com"
27
+ );
28
+
29
+ expect(result.metadata?.actor_type).toBe("external");
30
+ });
31
+
32
+ it("defaults to external when email is unavailable", () => {
33
+ expect(
34
+ mergeActorTypeIntoEvent({ event_name: "budget_created" }, null).metadata
35
+ ?.actor_type
36
+ ).toBe("external");
37
+ expect(
38
+ mergeActorTypeIntoEvent({ event_name: "budget_created" }, undefined)
39
+ .metadata?.actor_type
40
+ ).toBe("external");
41
+ });
42
+
43
+ it("preserves other metadata fields while attaching actor_type", () => {
44
+ const fields: DataIngestionApiFields = {
45
+ event_name: "budget_created",
46
+ metadata: { foo: "bar" },
47
+ };
48
+
49
+ const result = mergeActorTypeIntoEvent(fields, "buyer@acme.com");
50
+
51
+ expect(result.metadata).toEqual({ foo: "bar", actor_type: "external" });
52
+ });
53
+
54
+ it("falls back to external when deriveActorType throws", () => {
55
+ vi.spyOn(deriveActorTypeModule, "deriveActorType").mockImplementation(
56
+ () => {
57
+ throw new Error("unexpected");
58
+ }
59
+ );
60
+
61
+ expect(
62
+ mergeActorTypeIntoEvent(
63
+ { event_name: "budget_created" },
64
+ "alice@vtex.com"
65
+ ).metadata?.actor_type
66
+ ).toBe("external");
67
+
68
+ vi.restoreAllMocks();
69
+ });
70
+
71
+ it("does not put the source email into the event payload", () => {
72
+ const email = "alice@vtex.com";
73
+ const result = mergeActorTypeIntoEvent(
74
+ { event_name: "address_edited", event_category: "edition" },
75
+ email
76
+ );
77
+ const serialized = JSON.stringify(result);
78
+
79
+ expect(serialized).not.toContain(email);
80
+ expect(serialized).not.toContain("alice");
81
+ expect(result.metadata?.actor_type).toBe("internal");
82
+ });
83
+ });
@@ -2,7 +2,7 @@ import storeConfig from "discovery.config";
2
2
 
3
3
  import { isDevelopment } from "../../../utils/environment";
4
4
 
5
- import type { DataIngestionApiFields, DataIngestionApiParams } from "./types";
5
+ import type { DataIngestionApiParams } from "./types";
6
6
 
7
7
  /**
8
8
  * VTEX Data Ingestion API for Analytics
@@ -50,7 +50,7 @@ export async function dataIngestionApi(
50
50
  /**
51
51
  * Get common analytics context
52
52
  */
53
- function getCommonAnalyticsContext() {
53
+ export function getCommonAnalyticsContext() {
54
54
  const account = storeConfig?.api?.storeId || "unknown";
55
55
  const locale =
56
56
  typeof window !== "undefined"
@@ -77,25 +77,3 @@ function getCommonAnalyticsContext() {
77
77
  device,
78
78
  };
79
79
  }
80
-
81
- /**
82
- * Hook to send analytics events
83
- * This is a simple version that will be extended by useAnalytics hook
84
- */
85
- export function useDataIngestionApi() {
86
- const commonFields = getCommonAnalyticsContext();
87
-
88
- const sendEvent = (fields: DataIngestionApiFields) => {
89
- // Get workspace from environment
90
- const workspace = isDevelopment() ? "dev" : "master";
91
-
92
- dataIngestionApi({
93
- ...commonFields,
94
- event_category: fields.event_category || "click",
95
- ...fields,
96
- workspace,
97
- });
98
- };
99
-
100
- return { sendEvent };
101
- }
@@ -0,0 +1,24 @@
1
+ import type { ActorType } from "./types";
2
+
3
+ const INTERNAL_EMAIL_DOMAIN = "vtex.com";
4
+
5
+ /**
6
+ * Derive analytics actor_type from the logged-in user's email domain.
7
+ * Never logs or returns the input email — only the enum.
8
+ */
9
+ export function deriveActorType(email?: string | null): ActorType {
10
+ if (!email) {
11
+ return "external";
12
+ }
13
+
14
+ const trimmed = email.trim().toLowerCase();
15
+ const atIndex = trimmed.lastIndexOf("@");
16
+
17
+ if (atIndex < 0 || atIndex === trimmed.length - 1) {
18
+ return "external";
19
+ }
20
+
21
+ const domain = trimmed.slice(atIndex + 1);
22
+
23
+ return domain === INTERNAL_EMAIL_DOMAIN ? "internal" : "external";
24
+ }
@@ -0,0 +1,29 @@
1
+ import { deriveActorType } from "./deriveActorType";
2
+
3
+ import type { ActorType, DataIngestionApiFields } from "./types";
4
+
5
+ /**
6
+ * Attach derived actor_type to analytics fields' metadata.
7
+ * Caller-supplied actor_type is ignored — derivation always wins.
8
+ * Never copies email or other identity into the payload.
9
+ */
10
+ export function mergeActorTypeIntoEvent(
11
+ fields: DataIngestionApiFields,
12
+ email?: string | null
13
+ ): DataIngestionApiFields {
14
+ let actorType: ActorType;
15
+
16
+ try {
17
+ actorType = deriveActorType(email);
18
+ } catch {
19
+ actorType = "external";
20
+ }
21
+
22
+ return {
23
+ ...fields,
24
+ metadata: {
25
+ ...fields.metadata,
26
+ actor_type: actorType,
27
+ },
28
+ };
29
+ }
@@ -1,3 +1,8 @@
1
+ /**
2
+ * Actor classification for analytics events (internal VTEX vs external).
3
+ */
4
+ export type ActorType = "internal" | "external";
5
+
1
6
  /**
2
7
  * Analytics event categories
3
8
  */
@@ -0,0 +1,32 @@
1
+ import { useContext } from "react";
2
+
3
+ import { BuyerPortalContext } from "../../../components/BuyerPortalProvider/BuyerPortalProvider";
4
+ import { isDevelopment } from "../../../utils/environment";
5
+
6
+ import { dataIngestionApi, getCommonAnalyticsContext } from "./analytics";
7
+ import { mergeActorTypeIntoEvent } from "./mergeActorTypeIntoEvent";
8
+
9
+ import type { DataIngestionApiFields } from "./types";
10
+
11
+ /**
12
+ * Hook to send analytics events with actor_type derived from the logged-in user.
13
+ */
14
+ export function useDataIngestionApi() {
15
+ const buyerPortalContext = useContext(BuyerPortalContext);
16
+ const commonFields = getCommonAnalyticsContext();
17
+ const email = buyerPortalContext?.currentUser?.email;
18
+
19
+ const sendEvent = (fields: DataIngestionApiFields) => {
20
+ const workspace = isDevelopment() ? "dev" : "master";
21
+ const enrichedFields = mergeActorTypeIntoEvent(fields, email);
22
+
23
+ dataIngestionApi({
24
+ ...commonFields,
25
+ event_category: enrichedFields.event_category || "click",
26
+ ...enrichedFields,
27
+ workspace,
28
+ });
29
+ };
30
+
31
+ return { sendEvent };
32
+ }
@@ -22,4 +22,4 @@ export const SCOPE_KEYS = {
22
22
  CREDIT_CARDS: "creditCards",
23
23
  } as const;
24
24
 
25
- export const CURRENT_VERSION = "2.0.19";
25
+ export const CURRENT_VERSION = "2.0.21";
@@ -13,6 +13,7 @@ import { Skeleton, Tag as RemovableTag } from "@faststore/ui";
13
13
  import { Tag } from "../../../shared/components";
14
14
  import { useAutocompletePosition } from "../../../shared/components/AutocompleteDropdown/useAutocompletePosition";
15
15
  import { useDropdownFlipPosition } from "../../../shared/components/AutocompleteDropdown/useDropdownFlipPosition";
16
+ import { useLocalization } from "../../../shared/localization/LocalizationContext";
16
17
 
17
18
  import {
18
19
  filterRoleOptions,
@@ -43,6 +44,7 @@ export const RolesMultiSelect = ({
43
44
  isLoading = false,
44
45
  mode,
45
46
  }: RolesMultiSelectProps) => {
47
+ const { t } = useLocalization();
46
48
  const listboxId = useId();
47
49
  const roleOptions = useMemo(() => options ?? [], [options]);
48
50
 
@@ -236,11 +238,9 @@ export const RolesMultiSelect = ({
236
238
  onKeyDown={handleKeyDown}
237
239
  />
238
240
 
239
- {/* No CMS localization key exists yet for this floating label (see
240
- Decision 6 in the spec) — hardcoded for the same reason as "Custom"
241
- and "No matching roles" below: t() has no fallback and would render
242
- the raw key string if missing. */}
243
- <label data-fs-bp-roles-multi-select-label>Roles</label>
241
+ <label data-fs-bp-roles-multi-select-label>
242
+ {t("users.forms.roles")}
243
+ </label>
244
244
  </div>
245
245
 
246
246
  {isOpen && (
@@ -254,10 +254,9 @@ export const RolesMultiSelect = ({
254
254
  style={{ width: positionStyle.width }}
255
255
  >
256
256
  {filteredOptions.length === 0 ? (
257
- // "No matching roles" has no CMS localization key yet (like the
258
- // "Custom" tag below) — hardcoded for the same reason: t() has no
259
- // fallback and would render the raw key string if missing.
260
- <li data-fs-bp-roles-multi-select-empty>No matching roles</li>
257
+ <li data-fs-bp-roles-multi-select-empty>
258
+ {t("users.rolesMultiSelect.noMatchingRoles")}
259
+ </li>
261
260
  ) : (
262
261
  filteredOptions.map((role, index) => {
263
262
  const isChecked = selected.includes(role.roleId);
@@ -297,7 +296,7 @@ export const RolesMultiSelect = ({
297
296
  </span>
298
297
  {role.isCustom && (
299
298
  <Tag data-fs-bp-roles-multi-select-option-custom-tag>
300
- Custom
299
+ {t("users.rolesMultiSelect.customTag")}
301
300
  </Tag>
302
301
  )}
303
302
  </div>