@vtex/faststore-plugin-buyer-portal 2.0.13 → 2.0.14

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 (219) hide show
  1. package/CHANGELOG.md +18 -1
  2. package/cms/b2b/components/.gitkeep +0 -0
  3. package/cms/b2b/pages/cms_content_type__i18n.jsonc +36 -0
  4. package/cypress/integration/localization.test.ts +482 -0
  5. package/cypress/support/commands.ts +8 -0
  6. package/docs/i18n/i18n-messages-format.md +391 -0
  7. package/docs/i18n/locales.json +701 -0
  8. package/docs/i18n/migration-inventory.md +959 -0
  9. package/package.json +3 -3
  10. package/specs/custom-localization-system.md +711 -0
  11. package/specs/multi-locale-e2e-validation.md +277 -0
  12. package/src/__mocks__/discovery.config.ts +12 -0
  13. package/src/features/accounting-fields/components/AccountingConfirmDelete/AccountingConfirmDelete.tsx +14 -4
  14. package/src/features/accounting-fields/components/AccountingFieldConfirmEdit/AccountingFieldConfirmEdit.tsx +11 -6
  15. package/src/features/accounting-fields/components/AccountingFieldDropdown/AccountingFieldDropdown.tsx +5 -3
  16. package/src/features/accounting-fields/components/AccountingFieldForm/AccountingFieldForm.tsx +33 -23
  17. package/src/features/accounting-fields/components/AccountingValueConfirmEdit/AccountingValueConfirmEdit.tsx +10 -6
  18. package/src/features/accounting-fields/components/AccountingValueDropdown/AccountingValueDropdown.tsx +19 -9
  19. package/src/features/accounting-fields/components/AccountingValueForm/AccountingValueForm.tsx +21 -10
  20. package/src/features/accounting-fields/components/CreateAccountingFieldDrawer/CreateAccountingFieldDrawer.tsx +11 -8
  21. package/src/features/accounting-fields/components/CreateAccountingValueDrawer/CreateAccountingValueDrawer.tsx +18 -7
  22. package/src/features/accounting-fields/components/DeleteAccountingFieldDrawer/DeleteAccountingFieldDrawer.tsx +14 -12
  23. package/src/features/accounting-fields/components/DeleteAccountingValueDrawer/DeleteAccountingValueDrawer.tsx +20 -10
  24. package/src/features/accounting-fields/components/EditAccountingFieldDrawer/EditAccountingFieldDrawer.tsx +5 -3
  25. package/src/features/accounting-fields/components/EditAccountingValueDrawer/EditAccountingValueDrawer.tsx +13 -6
  26. package/src/features/accounting-fields/components/RemoveValueFromUnitDrawer/RemoveValueFromUnitDrawer.tsx +19 -8
  27. package/src/features/accounting-fields/layouts/AccountingFieldLayout/AccountingFieldLayout.tsx +11 -9
  28. package/src/features/addresses/components/AddRecipientsDrawer/AddRecipientsDrawer.tsx +13 -7
  29. package/src/features/addresses/components/AddressDropdownMenu/AddressDropdownMenu.tsx +22 -20
  30. package/src/features/addresses/components/AddressForm/AddressForm.tsx +33 -22
  31. package/src/features/addresses/components/AddressLine/AddressLine.tsx +29 -25
  32. package/src/features/addresses/components/AddressLocationsList/AddressLocationsList.tsx +15 -6
  33. package/src/features/addresses/components/AddressRecipientsList/AddressRecipientsList.tsx +9 -4
  34. package/src/features/addresses/components/AddressesCard/AddressesCard.tsx +8 -5
  35. package/src/features/addresses/components/CreateAddressDrawer/CreateAddressDrawer.tsx +27 -14
  36. package/src/features/addresses/components/CreateAddressSettingsDrawer/CreateAddressSettingsDrawer.tsx +26 -21
  37. package/src/features/addresses/components/DeleteAddressDrawer/DeleteAddressDrawer.tsx +20 -16
  38. package/src/features/addresses/components/DeleteAddressLocationDrawer/DeleteAddressLocationDrawer.tsx +26 -14
  39. package/src/features/addresses/components/DeleteRecipientAddressDrawer/DeleteRecipientAddressDrawer.tsx +20 -17
  40. package/src/features/addresses/components/EditAddressDrawer/EditAddressDrawer.tsx +12 -6
  41. package/src/features/addresses/components/EditAddressLocationDrawer/EditAddressLocationDrawer.tsx +24 -17
  42. package/src/features/addresses/components/EditRecipientAddressDrawer/EditRecipientAddressDrawer.tsx +17 -15
  43. package/src/features/addresses/components/LocationForm/LocationField/LocationField.tsx +8 -5
  44. package/src/features/addresses/components/LocationForm/LocationForm.tsx +9 -3
  45. package/src/features/addresses/components/LocationsDrawer/LocationsDrawer.tsx +15 -9
  46. package/src/features/addresses/components/RecipientsForm/RecipientItem/RecipientItem.tsx +55 -46
  47. package/src/features/addresses/components/RecipientsForm/RecipientsForm.tsx +4 -1
  48. package/src/features/addresses/components/RemoveAddressDrawer/RemoveAddressDrawer.tsx +21 -14
  49. package/src/features/addresses/layouts/AddressDetailsLayout/AddressDetailsLayout.tsx +16 -9
  50. package/src/features/addresses/layouts/AddressesLayout/AddressesLayout.tsx +11 -8
  51. package/src/features/b2b-agent/layouts/B2BAgentLayout/B2BAgentLayout.tsx +3 -1
  52. package/src/features/budgets/components/BudgetAddForm/BudgetAddForm.tsx +14 -12
  53. package/src/features/budgets/components/BudgetAddSuccess/BudgetAddSuccess.tsx +16 -14
  54. package/src/features/budgets/components/BudgetAllocationsSelection/BudgetAllocationsSelection.tsx +8 -5
  55. package/src/features/budgets/components/BudgetAllocationsTable/BudgetAllocationsTable.tsx +9 -6
  56. package/src/features/budgets/components/BudgetDeleteDrawer/BudgetDeleteDrawer.tsx +23 -17
  57. package/src/features/budgets/components/BudgetEditNotificationDrawer/BudgetEditNotificationDrawer.tsx +13 -6
  58. package/src/features/budgets/components/BudgetNotificationForm/BudgetNotificationForm.tsx +31 -24
  59. package/src/features/budgets/components/BudgetNotificationsInfo/BudgetNotificationsInfo.tsx +15 -9
  60. package/src/features/budgets/components/BudgetRemainingBalance/BudgetRemainingBalance.tsx +6 -2
  61. package/src/features/budgets/components/BudgetSettingsInfo/BudgetSettingsInfo.tsx +27 -10
  62. package/src/features/budgets/components/BudgetUsersTable/BudgetUsersTable.tsx +14 -7
  63. package/src/features/budgets/components/BudgetsTable/BudgetsTable.tsx +11 -8
  64. package/src/features/budgets/components/CreateBudgetAllocationDrawer/CreateBudgetAllocationDrawer.tsx +8 -5
  65. package/src/features/budgets/components/CreateBudgetDrawer/CreateBudgetDrawer.tsx +31 -18
  66. package/src/features/budgets/components/DeleteBudgetAllocationDrawer/DeleteBudgetAllocationDrawer.tsx +17 -12
  67. package/src/features/budgets/components/EditBudgetDrawer/EditBudgetDrawer.tsx +14 -8
  68. package/src/features/budgets/components/EmptyState.tsx/EmptyState.tsx +4 -1
  69. package/src/features/budgets/layouts/BudgetsDetailsLayout/BudgetsDetailsLayout.tsx +7 -5
  70. package/src/features/budgets/layouts/BudgetsLayout/BudgetsLayout.tsx +5 -3
  71. package/src/features/budgets/messages/budgetNotifications.ts +5 -5
  72. package/src/features/budgets/utils/handleBudgetError.tsx +5 -8
  73. package/src/features/budgets/utils/handleBudgetError.types.ts +3 -0
  74. package/src/features/buying-policies/components/AccountingFieldCriteriaSelector/AccountingFieldCriteriaSelector.tsx +5 -3
  75. package/src/features/buying-policies/components/AccountingFieldCriteriaSelector/AccountingValueCriteriaSelector.tsx +20 -9
  76. package/src/features/buying-policies/components/AddBuyingPolicyDrawer/AddBuyingPolicyDrawer.tsx +8 -5
  77. package/src/features/buying-policies/components/BasicBuyingPolicyDrawer/BasicBuyingPolicyDrawer.tsx +49 -33
  78. package/src/features/buying-policies/components/BudgetCriteriaSelector/BudgetCriteriaSelector.tsx +8 -2
  79. package/src/features/buying-policies/components/BuyingPolicyDropdownMenu/BuyingPolicyDropdownMenu.tsx +5 -3
  80. package/src/features/buying-policies/components/DeleteBuyingPolicyDrawer/DeleteBuyingPolicyDrawer.tsx +28 -24
  81. package/src/features/buying-policies/components/UpdateBuyingPolicyDrawer/UpdateBuyingPolicyDrawer.tsx +10 -7
  82. package/src/features/buying-policies/layouts/BuyingPoliciesLayout/BuyingPoliciesLayout.tsx +11 -4
  83. package/src/features/buying-policies/layouts/BuyingPolicyDetailsLayout/BuyingPolicyDetailsLayout.tsx +26 -9
  84. package/src/features/buying-policies/utils/accountingFieldCriteriaHelpers.ts +2 -2
  85. package/src/features/buying-policies/utils/buyingPoliciesWorkflowTypes.ts +3 -3
  86. package/src/features/buying-policies/utils/orderFieldsCriteriaOptions.ts +8 -8
  87. package/src/features/buying-policies/utils/spendingLimitsCriteriaOptions.ts +6 -6
  88. package/src/features/contracts/components/ContractsCard/ContractsCard.tsx +7 -5
  89. package/src/features/contracts/layouts/ContractsLayout/ContractsLayout.tsx +9 -4
  90. package/src/features/credit-cards/components/CreateCreditCardDrawer/CreateCreditCardDrawer.tsx +17 -11
  91. package/src/features/credit-cards/components/CreditCardDropdownMenu/CreditCardDropdownMenu.tsx +17 -11
  92. package/src/features/credit-cards/components/CreditCardForm/CreditCardForm.tsx +8 -5
  93. package/src/features/credit-cards/components/CreditCardSettingsDrawer/CreditCardSettingsDrawer.tsx +10 -8
  94. package/src/features/credit-cards/components/DeleteCreditCardDrawer/DeleteCreditCardDrawer.tsx +19 -14
  95. package/src/features/credit-cards/components/EditCreditCardDrawer/EditCreditCardDrawer.tsx +14 -13
  96. package/src/features/credit-cards/components/RemoveCreditCardDrawer/RemoveCreditCardDrawer.tsx +18 -15
  97. package/src/features/credit-cards/layouts/CreditCardsLayout/CreditCardLayout.tsx +10 -4
  98. package/src/features/order-entry/layouts/OrderEntryLayout.tsx +3 -1
  99. package/src/features/org-units/components/AddAllToOrgUnitDropdown/AddAllToOrgUnitDropdown.tsx +11 -9
  100. package/src/features/org-units/components/AuthSetupDrawer/AuthSetupDrawer.tsx +28 -16
  101. package/src/features/org-units/components/CreateOrgUnitDrawer/CreateOrgUnitDrawer.tsx +18 -12
  102. package/src/features/org-units/components/DeleteOrgUnitDrawer/DeleteOrgUnitDrawer.tsx +28 -29
  103. package/src/features/org-units/components/OrgUnitBreadcrumb/OrgUnitBreadcrumbLink.tsx +4 -4
  104. package/src/features/org-units/components/OrgUnitDetailsDropdownMenu/OrgUnitDetailsDropdownMenu.tsx +4 -2
  105. package/src/features/org-units/components/OrgUnitDetailsNavbar/OrgUnitDetailsNavbar.tsx +23 -5
  106. package/src/features/org-units/components/OrgUnitDetailsNavbar/org-unit-details-navbar.scss +18 -2
  107. package/src/features/org-units/components/OrgUnitsDropdownMenu/OrgUnitsDropdownMenu.tsx +6 -4
  108. package/src/features/org-units/components/OrgUnitsHierarchyTree/OrgUnitsHierarchyTree.tsx +9 -6
  109. package/src/features/org-units/components/OrganizationalUnitsCard/OrganizationalUnitsCard.tsx +15 -7
  110. package/src/features/org-units/components/UpdateOrgUnitDrawer/UpdateOrgUnitDrawer.tsx +15 -9
  111. package/src/features/org-units/layouts/OrgUnitDetailsLayout/OrgUnitDetailsLayout.tsx +27 -26
  112. package/src/features/org-units/layouts/OrgUnitsLayout/OrgUnitsLayout.tsx +8 -3
  113. package/src/features/payment-methods/components/AddPaymentMethodsDrawer/AddPaymentMethodsDrawer.tsx +29 -18
  114. package/src/features/payment-methods/components/PaymentMethodSettingsDrawer/PaymentMethodSettingsDrawer.tsx +13 -9
  115. package/src/features/payment-methods/components/RemoveMethodButton/RemoveMethodButton.tsx +5 -2
  116. package/src/features/payment-methods/components/RemovePaymentMethodsDrawer/RemovePaymentMethodsDrawer.tsx +10 -7
  117. package/src/features/payment-methods/hooks/useRemovePaymentMethodSubmit.ts +6 -4
  118. package/src/features/payment-methods/layouts/PaymentMethodsLayout/PaymentMethodsLayout.tsx +21 -15
  119. package/src/features/product-assortment/components/ProductAssortmentTable/ProductAssortmentTable.tsx +11 -7
  120. package/src/features/product-assortment/layouts/ProductAssortmentLayout/ProductAssortmentLayout.tsx +9 -6
  121. package/src/features/profile/components/ProfileCard/ProfileCard.tsx +8 -5
  122. package/src/features/profile/components/ProfileSummary/ProfileSummary.tsx +13 -6
  123. package/src/features/profile/layouts/ProfileLayout/ProfileLayout.tsx +16 -6
  124. package/src/features/roles/layout/RoleDetailsLayout/RoleDetailsLayout.tsx +4 -2
  125. package/src/features/roles/layout/RolesLayout/RolesLayout.tsx +5 -3
  126. package/src/features/shared/components/BasicCard/BasicCard.tsx +6 -4
  127. package/src/features/shared/components/BasicCard/BasicCardLine.tsx +5 -4
  128. package/src/features/shared/components/CustomerSwitch/CustomerSwitchDrawer.tsx +10 -2
  129. package/src/features/shared/components/CustomerSwitch/CustomerSwitchSearch.tsx +4 -1
  130. package/src/features/shared/components/DropdownFilter/DropdownFilter.tsx +11 -7
  131. package/src/features/shared/components/Error/Error.tsx +13 -6
  132. package/src/features/shared/components/HeaderInside/HeaderInside.tsx +5 -2
  133. package/src/features/shared/components/InternalSearch/InternalSearch.tsx +4 -1
  134. package/src/features/shared/components/InternalTopbar/InternalTopbar.tsx +7 -5
  135. package/src/features/shared/components/MainLinksDropdownMenu/MainLinksDropdownMenu.tsx +23 -8
  136. package/src/features/shared/components/OrgUnitInputSearch/OrgUnitInputSearch.tsx +7 -2
  137. package/src/features/shared/components/Pagination/Pagination.tsx +6 -2
  138. package/src/features/shared/components/Paginator/Counter.tsx +11 -5
  139. package/src/features/shared/components/Paginator/Interval.tsx +4 -3
  140. package/src/features/shared/components/Paginator/NextPageButton.tsx +6 -2
  141. package/src/features/shared/components/SettingsDrawer/SettingsDrawer.tsx +6 -4
  142. package/src/features/shared/components/SettingsDrawer/SettingsDrawerListType.tsx +11 -10
  143. package/src/features/shared/components/SettingsDrawer/listTypeOptions.ts +10 -7
  144. package/src/features/shared/components/SortFilter/SortFilter.tsx +15 -8
  145. package/src/features/shared/components/Table/TableRow/TableRow.tsx +5 -1
  146. package/src/features/shared/components/VerticalNav/VerticalNavLink.tsx +7 -3
  147. package/src/features/shared/layouts/BaseTabsLayout/Navbar.tsx +28 -4
  148. package/src/features/shared/layouts/BaseTabsLayout/about-drawer/AboutDrawer.tsx +15 -5
  149. package/src/features/shared/layouts/BaseTabsLayout/base-tabs-layout.scss +24 -9
  150. package/src/features/shared/layouts/BaseTabsLayout/sidebar-drawer/SidebarDrawer.tsx +5 -2
  151. package/src/features/shared/layouts/ContractTabsLayout/ContractTabsLayout.tsx +10 -5
  152. package/src/features/shared/layouts/ErrorTabsLayout/ErrorTabsLayout.tsx +7 -4
  153. package/src/features/shared/layouts/FinanceTabsLayout/FinanceTabsLayout.tsx +9 -4
  154. package/src/features/shared/layouts/LoadingTabsLayout/LoadingTabsLayout.tsx +7 -4
  155. package/src/features/shared/layouts/OrgUnitTabsLayout/OrgUnitTabLayout.tsx +3 -0
  156. package/src/features/shared/layouts/SumaPageLayout/FullSidebarNav.tsx +13 -7
  157. package/src/features/shared/layouts/SumaPageLayout/SumaSidebar.tsx +9 -5
  158. package/src/features/shared/localization/LocalizationContext.tsx +35 -0
  159. package/src/features/shared/localization/__tests__/createLocalize.test.ts +166 -0
  160. package/src/features/shared/localization/__tests__/get-localization-labels.service.test.ts +202 -0
  161. package/src/features/shared/localization/clients/LocalizationClient.ts +19 -0
  162. package/src/features/shared/localization/constants.ts +31 -0
  163. package/src/features/shared/localization/createLocalize.ts +78 -0
  164. package/src/features/shared/localization/services/get-localization-labels.service.ts +76 -0
  165. package/src/features/shared/localization/types.ts +66 -0
  166. package/src/features/shared/localization/withLocalization.tsx +22 -0
  167. package/src/features/shared/types/AuthRouteProps.ts +5 -0
  168. package/src/features/shared/types/LoaderData.ts +1 -0
  169. package/src/features/shared/utils/addresLabelToPropMapping.ts +10 -10
  170. package/src/features/shared/utils/constants.ts +1 -4
  171. package/src/features/shared/utils/getContractSettingsLinks.ts +17 -12
  172. package/src/features/shared/utils/getFinanceSettingsLinks.ts +14 -9
  173. package/src/features/shared/utils/getKeyByValue.ts +2 -2
  174. package/src/features/shared/utils/getOrganizationSettingsLinks.ts +13 -3
  175. package/src/features/shared/utils/getSumaListingSidebarLinks.ts +15 -10
  176. package/src/features/shared/utils/index.ts +2 -0
  177. package/src/features/shared/utils/routeLayoutMapping.ts +30 -30
  178. package/src/features/shared/utils/search.tsx +10 -0
  179. package/src/features/shared/utils/withAuth.tsx +6 -0
  180. package/src/features/shared/utils/withAuthLoader.ts +11 -0
  181. package/src/features/shared/utils/withLoaderErrorBoundary.ts +9 -0
  182. package/src/features/shared/utils/withProviders.tsx +13 -2
  183. package/src/features/users/components/CreateUserDrawer/CreateUserDrawer.tsx +39 -27
  184. package/src/features/users/components/CreateUserDrawerWithUsername/CreateUserDrawerWithUsername.tsx +41 -39
  185. package/src/features/users/components/DeleteUserDrawer/DeleteUserDrawer.tsx +27 -23
  186. package/src/features/users/components/EmailTransactionalSection/EmailTransactionalSection.tsx +6 -8
  187. package/src/features/users/components/LoginField/LoginField.tsx +4 -4
  188. package/src/features/users/components/ReassignOrgUnitDrawer/ReassignOrgUnitDrawer.tsx +22 -19
  189. package/src/features/users/components/ResetPasswordDrawer/ResetPasswordDrawer.tsx +16 -21
  190. package/src/features/users/components/UpdateUserDrawer/UpdateUserDrawer.tsx +26 -15
  191. package/src/features/users/components/UpdateUserDrawerWithUsername/UpdateUserDrawerWithUsername.tsx +22 -8
  192. package/src/features/users/components/UserDropdownMenu/UserDropdownMenu.tsx +7 -5
  193. package/src/features/users/components/UserFormFields/UserFormFields.tsx +9 -8
  194. package/src/features/users/components/UsersCard/UsersCard.tsx +4 -2
  195. package/src/features/users/layouts/UserDetailsLayout/UserDetailsLayout.tsx +26 -10
  196. package/src/features/users/layouts/UsersLayout/UsersLayout.tsx +12 -8
  197. package/src/pages/accounting-field.tsx +28 -15
  198. package/src/pages/address-details.tsx +37 -26
  199. package/src/pages/addresses.tsx +27 -12
  200. package/src/pages/budgets-details.tsx +25 -15
  201. package/src/pages/budgets.tsx +31 -17
  202. package/src/pages/buyer-organization-manager.tsx +23 -12
  203. package/src/pages/buying-policies.tsx +38 -20
  204. package/src/pages/buying-policy-details.tsx +20 -19
  205. package/src/pages/contracts.tsx +15 -1
  206. package/src/pages/credit-cards.tsx +30 -17
  207. package/src/pages/order-entry.tsx +26 -15
  208. package/src/pages/org-unit-details.tsx +20 -6
  209. package/src/pages/org-units.tsx +20 -6
  210. package/src/pages/payment-groups.tsx +15 -12
  211. package/src/pages/productAssortment.tsx +10 -1
  212. package/src/pages/profile.tsx +14 -1
  213. package/src/pages/role-details.tsx +19 -6
  214. package/src/pages/roles.tsx +27 -14
  215. package/src/pages/user-details.tsx +29 -17
  216. package/src/pages/users.tsx +11 -0
  217. package/vitest.config.ts +13 -0
  218. package/src/features/addresses/constants/messages.ts +0 -8
  219. package/src/features/credit-cards/constants/messages.ts +0 -6
@@ -0,0 +1,711 @@
1
+ # Custom Localization System
2
+
3
+ > **Status**: Done
4
+ > **Created**: 2026-06-01
5
+
6
+ ## 1. Business Context
7
+
8
+ ### Problem Statement
9
+
10
+ The Buyer Portal plugin currently ships with hardcoded UI strings spread across
11
+ components, layouts, and route configuration. There is no centralized mechanism to
12
+ deliver translated labels, and no dependency-free way to fetch them from VTEX's
13
+ content platform.
14
+
15
+ We need a **custom, dependency-free localization system** that:
16
+
17
+ - Fetches translated labels over HTTP from the VTEX content-platform localization endpoint.
18
+ - Loads those labels **strictly server-side**, inside the `loader` of files under
19
+ `src/pages/`, so no extra client round-trips happen.
20
+ - Exposes a simple `t()` translation function to any component without prop drilling.
21
+ - Adds **zero external runtime dependencies** (no `react-i18next`, `i18next`, etc.).
22
+
23
+ This affects every B2B buyer/administrator using the portal: their UI must render
24
+ localized labels, starting with English (the only locale today), while keeping the
25
+ architecture ready for additional locales later.
26
+
27
+ ### Goals
28
+
29
+ - Centralize all translatable labels behind a single `t(key, interpolations?)` function.
30
+ - Fetch labels once per page, server-side, in the page loader.
31
+ - Achieve O(1) label lookup at render time (no array `.find()` per call).
32
+ - Provide graceful fallback: an unknown key returns the key itself (never crashes, never blank).
33
+ - Support index-based interpolation (`{0}`, `{1}`, …).
34
+ - Keep the plugin boundary intact and ship zero new runtime dependencies (FR-010).
35
+
36
+ ### User Stories
37
+
38
+ #### US-1: Translate a static label
39
+
40
+ - **Story**: As a buyer-portal developer, I want to call `t("feature.group.name")` in a
41
+ component, so that I render the translated string without importing the labels manually.
42
+ - **Acceptance Criteria**:
43
+ - **Given** the page loader fetched labels containing `feature.group.name`, **when** a
44
+ child component calls `t("feature.group.name")`, **then** it returns the translated value.
45
+ - **Given** the key does not exist in the loaded labels, **when** the component calls
46
+ `t("feature.group.name")`, **then** it returns the literal string `"feature.group.name"`.
47
+
48
+ #### US-2: Interpolate runtime values
49
+
50
+ - **Story**: As a developer, I want `t()` to substitute positional placeholders, so that I
51
+ can compose dynamic strings like pagination counters or sentences with embedded links.
52
+ - **Acceptance Criteria**:
53
+ - **Given** the label `shared.pagination.label` = `"De {0} a {1} itens de {2}"`, **when** I
54
+ call `t("shared.pagination.label", [1, 25, 100])`, **then** it returns `"De 1 a 25 itens de 100"`.
55
+ - **Given** a placeholder index has no matching interpolation value, **when** `t()` runs,
56
+ **then** the placeholder token is left untouched in the output.
57
+ - **Given** more interpolations than placeholders are passed, **when** `t()` runs, **then**
58
+ the extra values are ignored and no error is thrown.
59
+ - **Given** at least one interpolation value is a React node (e.g. a JSX element), **when** I
60
+ call `t("key", [<a key="x">Name</a>])`, **then** it returns a `ReactNode` array of
61
+ alternating text segments and the supplied nodes, ready to render inside JSX without manual
62
+ string splitting.
63
+
64
+ #### US-3: Load labels server-side per feature
65
+
66
+ - **Story**: As a developer, I want each page loader to declare which feature entry it needs,
67
+ so that labels are fetched on the server and injected into the page once.
68
+ - **Acceptance Criteria**:
69
+ - **Given** a page loader references a feature `ENTRY_ID`, **when** the loader runs on the
70
+ server, **then** it performs a single GET to the localization endpoint and returns a Key-Value map.
71
+ - **Given** the localization request fails, **when** the loader resolves, **then** the page still
72
+ renders and `t()` falls back to returning keys (no thrown error, no blank page).
73
+
74
+ ### Key Scenarios
75
+
76
+ | Scenario | Pre-conditions | Steps | Expected Result |
77
+ |---|---|---|---|
78
+ | Happy path — translate + interpolate | Loader fetched labels for the feature | Component calls `t("shared.pagination.label", [1, 25, 100])` | Returns `"De 1 a 25 itens de 100"` |
79
+ | Happy path — ReactNode interpolation | Loader fetched labels; template has a `{0}` placeholder | Component calls `t("msg", [<a key="x">Name</a>])` | Returns `["Click ", <a>Name</a>, " here"]`; renders inline without manual split |
80
+ | Error case — localization request fails | Endpoint returns 5xx / network error | Loader resolves; component calls `t("any.key")` | Page renders; `t("any.key")` returns `"any.key"` |
81
+ | Edge case — missing key | Labels loaded but key absent | Component calls `t("unknown.key")` | Returns `"unknown.key"` (the key itself) |
82
+ | Edge case — placeholder without value | Label `"Hello {0} {1}"`, call `t(key, ["world"])` | `t()` runs | Returns `"Hello world {1}"` (untouched token) |
83
+
84
+ ### Functional Requirements
85
+
86
+ - **FR-001**: Fetch labels via `GET https://vtex.vtexcommercestable.com.br/api/content-platform/data/vtex/b2b/{LOCALIZATION_CONTENT_TYPE}/entries/{ENTRY_ID}?locale={LOCALE}`. The content type slug (`LOCALIZATION_CONTENT_TYPE`, default `"i18n"`) is configurable in `constants.ts` and must match the CMS content type (see `cms_content_type__i18n.jsonc`).
87
+ - **FR-002**: `ENTRY_ID` values are stored internally as an immutable map (`as const`), keyed by feature.
88
+ - **FR-003**: `LOCALE` is sourced from the browser/request context (`data.locale`) and forwarded by every page loader. When absent, an empty string is passed as fallback — the CMS is responsible for returning the appropriate language for each locale value.
89
+ - **FR-004**: On response, convert `LocalizationLocaleDataType.labels` (an array) into a flat `Record<string, string>` (`LabelMap`) indexed by `key` — done **once**, not per render.
90
+ - **FR-005**: `locale_metadata` must be fully typed but is not consumed yet.
91
+ - **FR-006**: Provide an overloaded `t()` with two call signatures (see Decision 8):
92
+ - `t(key, (string | number)[]) → string` — pure text interpolation via `String.replace`.
93
+ - `t(key, ReactNode[]) → ReactNode` — node interpolation; returns a `ReactNode[]` of alternating text segments and the supplied nodes.
94
+ - **FR-007**: `t()` returns the key itself when the key is not found (fallback).
95
+ - **FR-008**: `t()` supports index-based interpolation (`{0}`, `{1}`, …).
96
+ - **FR-009**: Labels are loaded **only** in page loaders (server-side), then injected into the page.
97
+ - **FR-010**: No new external runtime dependencies.
98
+ - **FR-011**: A clear separation between a `Client` (HTTP) and a `Service` (orchestration/transform) layer, matching existing conventions.
99
+ - **FR-012**: The service accepts a **params object** `{ entryIds, locale? }` where `entryIds` is a single entry ID or an array of entry IDs; it merges their maps, always including the `shared` entry automatically so common labels are available on every page without per-loader boilerplate.
100
+ - **FR-013**: On a failed fetch the service logs an **error** through the existing OTLP logger and still returns `{}` (the page never breaks).
101
+ - **FR-014**: The `LocalizationClient` does **not** forward the host-store auth cookie/credentials — labels are treated as public/global content, enabling a shared process-local cache across users.
102
+
103
+ ### Non-Functional Requirements
104
+
105
+ - **Performance**: Label lookup is O(1) (object access). The array→map transform happens once per `(entryId, locale)`. A **process-local, permanent in-memory cache** (`Map<string, LabelMap>`) plus single-flight (`Map<string, Promise<LabelMap>>`) avoids duplicate fetches. Cache has **no TTL** — updated translations propagate on process restart/deploy (accepted trade-off).
106
+ - **Resilience**: A failed localization fetch must degrade gracefully (empty `LabelMap`, fallback to keys), log an error, and must never break the host store build or the page render.
107
+ - **Plugin boundary**: All code lives under `src/features/shared/localization/` (cross-domain shared utility). No host-store assumptions, no new routes required.
108
+ - **Type safety**: Strict mode, zero `tsc --noEmit` errors, no unjustified `any` (Constitution §III).
109
+ - **Tests**: Unit tests for the transform, `t()` interpolation, and fallback (Constitution §IV).
110
+
111
+ ### Out of Scope
112
+
113
+ - Runtime locale switching on the client (the spec explicitly states no client-side reactivity is needed).
114
+ - Pluralization, gender, ICU MessageFormat, date/number formatting from `region` metadata.
115
+ - Persisting labels in any client-side storage (localStorage/cookies) or a database.
116
+ - Locale detection/negotiation (locale is empty/default for now).
117
+ - Translating the labels themselves — this system only consumes what the content platform returns.
118
+ - **Migrating existing hardcoded strings** (route titles, buttons, etc.) to `t()`. This work delivers the **mechanism only** (Client/Service/Provider/`t`/types/constants) plus one loader example and one component example. Migrating screens is incremental follow-up work, done per domain in later PRs.
119
+
120
+ ---
121
+
122
+ ## 2. Arch Decisions
123
+
124
+ ### Proposed Solution
125
+
126
+ A four-layer, dependency-free localization module under `src/features/shared/localization/`:
127
+
128
+ 1. **Constants & Types** — immutable `LOCALIZATION_ENTRY_IDS` map (`as const`), configurable
129
+ `LOCALIZATION_CONTENT_TYPE` slug (used to build `LOCALIZATION_BASE_URL`), plus the response
130
+ types from the API contract and the internal `LabelMap` shape.
131
+ 2. **Client (`LocalizationClient`)** — extends the existing base `Client`, but targets the
132
+ content-platform base URL. One method: `getEntry(entryId, locale)`.
133
+ 3. **Service (`getLocalizationLabelsService`)** — accepts a params object `{ entryIds, locale? }` with one or many entry IDs (always merging the
134
+ `shared` entry), calls the client per entry, transforms each `labels[]` into a flat
135
+ `LabelMap`, merges them, applies a permanent process-local cache + single-flight, logs an
136
+ error and returns `{}` on failure (never throws).
137
+ 4. **Delivery (Context + `t` factory + hook)** — the loader returns the `LabelMap` in the
138
+ page `context`; a `LocalizationProvider` (placed in the `withProviders` chain **outside**
139
+ `BuyerPortalProvider`) puts it in a dedicated React Context; components read it via
140
+ `useLocalization()`, which returns a memoized `t`.
141
+
142
+ The `t` logic (fallback + interpolation) is a **pure function** factory:
143
+ `createLocalize(labels) => t`. This makes it trivially unit-testable and reusable both
144
+ inside React (via the hook) and outside (e.g. in non-component utilities, by passing the map).
145
+
146
+ ### Architecture Overview
147
+
148
+ ```mermaid
149
+ sequenceDiagram
150
+ participant Loader as Page loader (SSR)
151
+ participant Service as getLocalizationLabelsService
152
+ participant Client as LocalizationClient
153
+ participant API as content-platform API
154
+ participant Provider as LocalizationProvider (Context)
155
+ participant Comp as Child component
156
+
157
+ Loader->>Service: getLocalizationLabelsService({ entryIds: ENTRY_ID, locale })
158
+ Service->>Client: getEntry(entryId, locale)
159
+ Client->>API: GET .../entries/{ENTRY_ID}?locale={LOCALE}
160
+ API-->>Client: LocalizationLocaleDataType { locale_metadata, labels[] }
161
+ Client-->>Service: LocalizationLocaleDataType
162
+ Service->>Service: labels[] → LabelMap (Record<key,value>)
163
+ Service-->>Loader: LabelMap (or {} on error)
164
+ Loader-->>Provider: context.localization.labels (injected into page props)
165
+ Provider->>Provider: createLocalize(labels) → t (memoized)
166
+ Comp->>Provider: useLocalization()
167
+ Provider-->>Comp: { t }
168
+ Comp->>Comp: t("key", [string|number]) → string OR t("key", [ReactNode]) → ReactNode[]
169
+ ```
170
+
171
+ ### Alternatives Considered
172
+
173
+ | Alternative | Pros | Cons | Verdict |
174
+ |---|---|---|---|
175
+ | **A. React Context + `t` factory (chosen)** | SSR-safe (per-request value, no cross-request leak); matches existing `BuyerPortalContext` pattern; no prop drilling; testable pure `t` | Components must be inside the provider tree | **Accepted** |
176
+ | B. Module-level mutable singleton store as the source of truth | `t()` callable anywhere without React | On the Next.js server a module singleton is shared across requests → cross-tenant label contamination; needs careful hydration guards | Rejected as the primary store (kept only as an optional client-only read-through, see Decision 3) |
177
+ | C. Pass `labels`/`t` as props down the tree | Explicit, no context | Heavy prop drilling across deep layouts; error-prone | Rejected |
178
+ | D. Use `react-i18next` / `i18next` | Mature, batteries included | Adds external runtime deps (violates FR-010); overkill (no client reactivity needed) | Rejected |
179
+
180
+ ### Risks & Mitigations
181
+
182
+ | Risk | Impact | Likelihood | Mitigation |
183
+ |---|---|---|---|
184
+ | Server-side singleton leaks labels across requests/tenants | High | Med | Use React Context as the source of truth (Decision 1); any singleton is hydrated client-only and treated as a non-authoritative cache |
185
+ | Localization endpoint latency adds to TTFB | Med | Med | Single-flight cache per `(entryId, locale)`; fetch in parallel with other loader calls via `Promise.all`; in-memory process cache |
186
+ | Endpoint failure blanks the page | High | Low | Service swallows errors and returns `{}`; `t()` fallback returns the key |
187
+ | `ENTRY_ID` drift between features and content platform | Med | Med | Centralize IDs in one `as const` map; type the keys so misuse fails at compile time |
188
+
189
+ ### Key Decisions
190
+
191
+ #### Decision 1: Dedicated React Context, placed OUTSIDE `BuyerPortalProvider`
192
+
193
+ - **Status**: Accepted
194
+ - **Context**: Data is fetched server-side and injected into the page. Components need global, prop-drill-free access. The app injects per-page context through `withProviders` → `BuyerPortalProvider`. **Critical finding**: `withBuyerPortal` destructures and **strips** `context` before rendering its child (`const { context, ...componentProps } = props`), so any provider placed *inside* `withBuyerPortal` never receives `context.localization`. Additionally, `LoadingTabsLayout` (which renders route titles) lives inside `BuyerPortalProvider`, so translating loading titles requires localization to wrap it.
195
+ - **Decision**: Add a dedicated `LocalizationProvider` to the `withProviders` chain with order `[withAuth, withLocalization, withBuyerPortal]` (→ tree `withAuth › withLocalization › withBuyerPortal › Component`). `withLocalization` receives `context` from `withAuth`, reads `context.localization.labels`, renders `LocalizationProvider`, and **forwards the full props (including `context`)** down to `withBuyerPortal`. The `LocalizationProvider` thus wraps `BuyerPortalProvider` and its `LoadingTabsLayout`.
196
+ - **Consequences**: SSR-safe (value lives per request/render, never on a shared module). `withLocalization` correctly receives the injected labels (fixes the strip-context bug). Loading titles become translatable. `t` is recreated only when `labels` change (effectively once per page).
197
+
198
+ #### Decision 2: `t` is a pure factory `createLocalize(labels) => t`
199
+
200
+ - **Status**: Accepted
201
+ - **Context**: The fallback + interpolation logic must be unit-testable and independent of React.
202
+ - **Decision**: Implement `createLocalize(labels: LabelMap)` returning `t(key, interpolations?)`. The hook memoizes the factory output; tests call the factory directly.
203
+ - **Consequences**: Logic is decoupled from React; trivially testable; reusable from non-component code by passing the map.
204
+
205
+ #### Decision 3: Optional client-only singleton (non-authoritative)
206
+
207
+ - **Status**: Accepted (optional)
208
+ - **Context**: Some non-React utilities may want `t()` without access to the provider.
209
+ - **Decision**: The `LocalizationProvider` may hydrate a `labelStore` singleton **only on the client** (`typeof window !== "undefined"`) for convenience. Context remains authoritative; the singleton is never read during SSR.
210
+ - **Consequences**: Enables ad-hoc `t()` outside React on the client, without risking cross-request contamination on the server.
211
+
212
+ #### Decision 4: Transform array → map once, in the Service
213
+
214
+ - **Status**: Accepted
215
+ - **Context**: FR-004 requires O(1) lookups and no per-render iteration.
216
+ - **Decision**: `getLocalizationLabelsService` reduces `labels[]` into `Record<string,string>` immediately after fetch, before returning to the loader.
217
+ - **Consequences**: The component layer only ever sees the flat map; no `.find()` at render.
218
+
219
+ #### Decision 5: Permanent process-local cache (no TTL)
220
+
221
+ - **Status**: Accepted
222
+ - **Context**: Labels are global and change rarely. The base `Client` uses `next: { revalidate: 300 }`, but a permanent service-level cache shadows it.
223
+ - **Decision**: Keep a permanent `Map<string, LabelMap>` (`resolved`) plus a single-flight `Map<string, Promise<LabelMap>>` (`inFlight`), keyed by `(entryId, locale)`. No TTL.
224
+ - **Consequences**: Fastest possible lookups, minimal network. Trade-off: edited translations only propagate after a process restart/deploy. Aligns with the documented "in-memory process-local cache only" constraint.
225
+
226
+ #### Decision 6: No auth — labels are public/global
227
+
228
+ - **Status**: Accepted
229
+ - **Context**: The localization endpoint is a central VTEX content repo (account `vtex`), independent of the host store and user.
230
+ - **Decision**: `LocalizationClient` does not forward the host-store cookie/credentials. Labels are treated as public/global content.
231
+ - **Consequences**: A single cache entry per `(entryId, locale)` serves all users (no per-user cache, no risk of serving one user's content to another). No auth leakage to a cross-domain host.
232
+
233
+ #### Decision 7: Multi-entry merge with automatic `shared`
234
+
235
+ - **Status**: Accepted
236
+ - **Context**: Pages typically need their domain labels plus common `shared.*` labels. Keys are already namespaced, so merging is collision-safe.
237
+ - **Decision**: `getLocalizationLabelsService` accepts a params object `{ entryIds: entryId | entryId[], locale? }`, always adds the `shared` entry, fetches each (cached/de-duped), and merges the maps via `Object.assign`.
238
+ - **Consequences**: Loaders pass only their domain entry; `shared` is guaranteed everywhere. Less boilerplate; deterministic merge.
239
+
240
+ #### Decision 8: `t()` overloaded to accept `ReactNode[]` and return `ReactNode`
241
+
242
+ - **Status**: Accepted
243
+ - **Context**: Some translated strings contain embedded interactive elements (links, bold text, etc.).
244
+ The original `t()` returns a plain `string`, which forces callers to manually split the translated
245
+ string by a sentinel placeholder and interleave React nodes — a fragile and verbose IIFE pattern.
246
+ - **Decision**: `LocalizeFn` is changed from a single function signature to an **overloaded call
247
+ signature** with two variants:
248
+ 1. `(key, (string | number)[]) → string` — the original fast path, unchanged (uses `String.replace`).
249
+ 2. `(key, ReactNode[]) → ReactNode` — new path; activated automatically by TypeScript when at least
250
+ one interpolation value is not a `string | number`. Internally uses a `RegExp.exec()` loop
251
+ (`interpolateNodes`) to build a `ReactNode[]` of alternating text segments and the caller-supplied
252
+ nodes, which React renders as normal JSX children.
253
+ TypeScript selects the correct overload at the call site: passing `[<a>...</a>]` resolves to the
254
+ second overload; passing `[1, 25]` resolves to the first.
255
+ - **Consequences**:
256
+ - IIFEs and manual `split`/`interleave` are eliminated from components.
257
+ - The string-interpolation path is unaffected (no performance regression).
258
+ - Callers must add `key` props to JSX elements they pass as interpolations (standard React requirement for array children).
259
+ - `createLocalize` introduces a `react` type-only import; the runtime helpers (`hasReactNodes`,
260
+ `interpolateNodes`) add no external dependency.
261
+
262
+ ### Implementation Plan
263
+
264
+ 1. **Types & constants** — `src/features/shared/localization/types.ts`, `src/features/shared/localization/constants.ts` (`LOCALIZATION_ENTRY_IDS as const`, `LOCALIZATION_CONTENT_TYPE`, `LOCALIZATION_BASE_URL`).
265
+ 2. **Client** — `src/features/shared/localization/clients/LocalizationClient.ts` (extends `Client`, content-platform base URL).
266
+ 3. **Service** — `src/features/shared/localization/services/get-localization-labels.service.ts` (multi-entry merge + auto-`shared` + permanent cache + single-flight + error log + fallback).
267
+ 4. **Localize factory** — `src/features/shared/localization/createLocalize.ts` (pure `t`).
268
+ 5. **Context/Provider/Hook** — `LocalizationProvider`, `useLocalization`, insert `withLocalization` into the `withProviders` chain **before** `withBuyerPortal` (`[withAuth, withLocalization, withBuyerPortal]`).
269
+ 6. **Loader integration** — page loader calls the service and returns `localization` in `context`; `withLocalization` HOC reads `context.localization.labels` and forwards full props down to `withBuyerPortal`.
270
+ 7. **Tests** — unit tests for transform, `t` interpolation, fallback.
271
+ 8. **Verification** — `make check` (lint + typecheck + test).
272
+
273
+ ---
274
+
275
+ ## 3. Technical Contract
276
+
277
+ ### Data Models
278
+
279
+ #### API response types (`src/features/shared/localization/types.ts`)
280
+
281
+ ```typescript
282
+ import type { ReactNode } from "react";
283
+
284
+ export type LocalizationLanguageType = {
285
+ name: string;
286
+ language_code: string;
287
+ native_name: string;
288
+ script: string;
289
+ text_direction: "ltr" | "rtl" | string;
290
+ utf_encoding: string;
291
+ };
292
+
293
+ export type LocalizationRegionType = {
294
+ name: string;
295
+ flag: string;
296
+ country_code: string;
297
+ continent: string;
298
+ date_format: string;
299
+ time_format: "12h" | "24h" | string;
300
+ time_format_mask: string;
301
+ unit_system: "imperial" | "metric" | string;
302
+ default_timezone: string;
303
+ };
304
+
305
+ export type LocalizationLocaleMetadataType = {
306
+ code: string;
307
+ locale: string;
308
+ language: LocalizationLanguageType;
309
+ region: LocalizationRegionType;
310
+ };
311
+
312
+ export type LocalizationLabelType = {
313
+ key: string;
314
+ value: string;
315
+ };
316
+
317
+ export type LocalizationLocaleDataType = {
318
+ locale_metadata: LocalizationLocaleMetadataType; // typed, not consumed yet
319
+ labels: LocalizationLabelType[];
320
+ };
321
+
322
+ /** Flat, O(1)-lookup label map produced by the service. */
323
+ export type LabelMap = Record<string, string>;
324
+
325
+ /** Parameters accepted by `getLocalizationLabelsService`. */
326
+ export type GetLocalizationLabelsParams = {
327
+ entryIds: LocalizationEntryId | LocalizationEntryId[];
328
+ locale?: string;
329
+ };
330
+
331
+ /**
332
+ * Localize function exposed to components.
333
+ *
334
+ * Two call signatures (Decision 8):
335
+ * - `t(key, (string | number)[])` → `string` – pure text interpolation (O(1) replace)
336
+ * - `t(key, ReactNode[])` → `ReactNode` – node interpolation; returns an array of
337
+ * alternating text segments and the supplied React nodes, ready to spread inside JSX.
338
+ *
339
+ * TypeScript selects the correct overload automatically based on the interpolation array type.
340
+ */
341
+ export type LocalizeFn = {
342
+ (key: string, interpolations?: (string | number)[]): string;
343
+ (key: string, interpolations: ReactNode[]): ReactNode;
344
+ };
345
+ ```
346
+
347
+ #### Feature entry IDs (`src/features/shared/localization/constants.ts`)
348
+
349
+ ```typescript
350
+ export const LOCALIZATION_ENTRY_IDS = {
351
+ shared: "shared-entry-id-example",
352
+ orgUnits: "org-units-entry-id-example",
353
+ users: "users-entry-id-example",
354
+ } as const;
355
+
356
+ export type LocalizationFeature = keyof typeof LOCALIZATION_ENTRY_IDS;
357
+ export type LocalizationEntryId = (typeof LOCALIZATION_ENTRY_IDS)[LocalizationFeature];
358
+
359
+ /** Content type slug in the Content Platform API path (see cms_content_type__i18n.jsonc). */
360
+ export const LOCALIZATION_CONTENT_TYPE = "i18n";
361
+
362
+ export const LOCALIZATION_BASE_URL = `https://vtex.vtexcommercestable.com.br/api/content-platform/data/vtex/b2b/${LOCALIZATION_CONTENT_TYPE}`;
363
+ ```
364
+
365
+ ### Interfaces
366
+
367
+ #### Client — `LocalizationClient`
368
+
369
+ ```typescript
370
+ import { Client } from "../../clients/Client";
371
+ import { LOCALIZATION_BASE_URL } from "../constants";
372
+
373
+ import type { LocalizationLocaleDataType } from "../types";
374
+
375
+ export class LocalizationClient extends Client {
376
+ constructor() {
377
+ super(LOCALIZATION_BASE_URL);
378
+ }
379
+
380
+ // No `Cookie` header is passed: labels are public/global content (Decision 6),
381
+ // so the base Client adds no auth header for this request.
382
+ getEntry(entryId: string, locale: string = "") {
383
+ return this.get<LocalizationLocaleDataType>(`/entries/${entryId}`, {
384
+ params: { locale },
385
+ componentName: "LocalizationClient",
386
+ });
387
+ }
388
+ }
389
+
390
+ export const localizationClient = new LocalizationClient();
391
+ ```
392
+
393
+ #### Service — `getLocalizationLabelsService` (multi-entry merge + auto-`shared` + permanent cache + single-flight + error log + fallback)
394
+
395
+ ```typescript
396
+ import { logError } from "../../services/logger";
397
+ import { localizationClient } from "../clients/LocalizationClient";
398
+ import {
399
+ LOCALIZATION_ENTRY_IDS,
400
+ type LocalizationEntryId,
401
+ } from "../constants";
402
+
403
+ import type { LocalizationLabelType, LabelMap, GetLocalizationLabelsParams } from "../types";
404
+
405
+ // Permanent process-local cache + single-flight, keyed by (entryId, locale).
406
+ const resolved = new Map<string, LabelMap>();
407
+ const inFlight = new Map<string, Promise<LabelMap>>();
408
+
409
+ const toLabelMap = (labels: LocalizationLabelType[]): LabelMap =>
410
+ labels.reduce<LabelMap>((acc, { key, value }) => {
411
+ acc[key] = value;
412
+ return acc;
413
+ }, {});
414
+
415
+ // Fetches (and caches) a single entry; logs error and falls back to {} on failure.
416
+ const fetchEntry = (entryId: string, locale: string): Promise<LabelMap> => {
417
+ const cacheKey = `${entryId}::${locale}`;
418
+
419
+ const cached = resolved.get(cacheKey);
420
+ if (cached) return Promise.resolve(cached);
421
+
422
+ const ongoing = inFlight.get(cacheKey);
423
+ if (ongoing) return ongoing;
424
+
425
+ const promise = localizationClient
426
+ .getEntry(entryId, locale)
427
+ .then((data) => {
428
+ const map = toLabelMap(data?.labels ?? []);
429
+ resolved.set(cacheKey, map); // permanent cache (no TTL)
430
+ return map;
431
+ })
432
+ .catch((error: unknown) => {
433
+ logError("Failed to fetch localization labels", {
434
+ entryId,
435
+ locale,
436
+ error_message: error instanceof Error ? error.message : String(error),
437
+ });
438
+ return {} as LabelMap; // graceful fallback: t() returns keys
439
+ })
440
+ .finally(() => {
441
+ inFlight.delete(cacheKey);
442
+ });
443
+
444
+ inFlight.set(cacheKey, promise);
445
+ return promise;
446
+ };
447
+
448
+ export const getLocalizationLabelsService = async ({
449
+ entryIds,
450
+ locale = "",
451
+ }: GetLocalizationLabelsParams): Promise<LabelMap> => {
452
+ const requested = Array.isArray(entryIds) ? entryIds : [entryIds];
453
+
454
+ // Always include `shared`; de-duplicate.
455
+ const ids = Array.from(new Set<LocalizationEntryId>([LOCALIZATION_ENTRY_IDS.shared, ...requested]));
456
+
457
+ const maps = await Promise.all(ids.map((id) => fetchEntry(id, locale)));
458
+
459
+ // Namespaced keys make this merge collision-safe.
460
+ return Object.assign({}, ...maps) as LabelMap;
461
+ };
462
+ ```
463
+
464
+ > `logError(message, metadata?)` is the existing OTLP logger convenience function exported
465
+ > from `src/features/shared/services/logger`.
466
+
467
+ #### Localize factory — `createLocalize` (pure)
468
+
469
+ Two execution paths dispatched at runtime by `hasReactNodes` (see Decision 8):
470
+
471
+ ```typescript
472
+ import type { ReactNode } from "react";
473
+ import type { LabelMap, LocalizeFn } from "./types";
474
+
475
+ const PLACEHOLDER_PATTERN = /\{(\d+)\}/g;
476
+
477
+ // Returns true when the array contains at least one value that is not string or number.
478
+ const hasReactNodes = (interpolations: (string | number | ReactNode)[]): boolean =>
479
+ interpolations.some(
480
+ (v) => v !== null && v !== undefined && typeof v !== "string" && typeof v !== "number"
481
+ );
482
+
483
+ // Builds [textSegment, node, textSegment, ...] using exec() so ReactNode values
484
+ // are inserted at placeholder positions without stringification.
485
+ const interpolateNodes = (template: string, interpolations: ReactNode[]): ReactNode[] => {
486
+ const result: ReactNode[] = [];
487
+ const regex = new RegExp(PLACEHOLDER_PATTERN.source, "g");
488
+ let lastIndex = 0;
489
+ let match: RegExpExecArray | null;
490
+
491
+ while ((match = regex.exec(template)) !== null) {
492
+ if (match.index > lastIndex) result.push(template.slice(lastIndex, match.index));
493
+ const idx = Number(match[1]);
494
+ const value = interpolations[idx];
495
+ result.push(value !== undefined ? value : match[0]);
496
+ lastIndex = match.index + match[0].length;
497
+ }
498
+ if (lastIndex < template.length) result.push(template.slice(lastIndex));
499
+
500
+ return result;
501
+ };
502
+
503
+ export const createLocalize = (labels: LabelMap): LocalizeFn => {
504
+ function t(key: string, interpolations?: (string | number)[]): string;
505
+ function t(key: string, interpolations: ReactNode[]): ReactNode;
506
+ function t(key: string, interpolations?: (string | number | ReactNode)[]): string | ReactNode {
507
+ const template = labels[key];
508
+
509
+ if (template === undefined) return key; // fallback: key itself
510
+ if (!interpolations || interpolations.length === 0) return template;
511
+
512
+ if (hasReactNodes(interpolations)) {
513
+ return interpolateNodes(template, interpolations as ReactNode[]);
514
+ }
515
+
516
+ // Pure string/number path — O(1) replace.
517
+ return template.replace(PLACEHOLDER_PATTERN, (token, index: string) => {
518
+ const value = (interpolations as (string | number)[])[Number(index)];
519
+ return value === undefined ? token : String(value);
520
+ });
521
+ }
522
+
523
+ return t as LocalizeFn;
524
+ };
525
+ ```
526
+
527
+ #### Context, Provider & Hook
528
+
529
+ ```typescript
530
+ // src/features/shared/localization/LocalizationContext.tsx
531
+ import { createContext, useContext, useMemo, type ReactNode } from "react";
532
+
533
+ import { createLocalize } from "./createLocalize";
534
+
535
+ import type { LabelMap, LocalizeFn } from "./types";
536
+
537
+ type LocalizationContextType = { t: LocalizeFn; labels: LabelMap };
538
+
539
+ const LocalizationContext = createContext<LocalizationContextType | undefined>(undefined);
540
+
541
+ export const LocalizationProvider = ({
542
+ labels,
543
+ children,
544
+ }: {
545
+ labels: LabelMap;
546
+ children: ReactNode;
547
+ }) => {
548
+ const value = useMemo<LocalizationContextType>(
549
+ () => ({ labels, t: createLocalize(labels) }),
550
+ [labels]
551
+ );
552
+ return <LocalizationContext.Provider value={value}>{children}</LocalizationContext.Provider>;
553
+ };
554
+
555
+ export const useLocalization = (): LocalizationContextType => {
556
+ const ctx = useContext(LocalizationContext);
557
+ // Defensive fallback so t() never crashes outside a provider.
558
+ return ctx ?? { labels: {}, t: createLocalize({}) };
559
+ };
560
+ ```
561
+
562
+ #### HOC — `withLocalization` (mirrors `withBuyerPortal`)
563
+
564
+ ```typescript
565
+ // src/features/shared/localization/withLocalization.tsx
566
+ import { type ComponentType } from "react";
567
+
568
+ import { LocalizationProvider } from "./LocalizationContext";
569
+
570
+ import type { LabelMap } from "./types";
571
+
572
+ export const withLocalization = <T extends Record<string, unknown>>(
573
+ Component: ComponentType<T>
574
+ ) =>
575
+ function WrappedWithLocalization(
576
+ props: T & { context: { localization?: { labels: LabelMap } } }
577
+ ) {
578
+ const labels = props.context?.localization?.labels ?? {};
579
+ return (
580
+ <LocalizationProvider labels={labels}>
581
+ {/* Forward ALL props (including `context`) so `withBuyerPortal` downstream
582
+ still receives it. `withLocalization` must sit BEFORE `withBuyerPortal`, which
583
+ strips `context` (see Decision 1 in specs/custom-localization-system.md). */}
584
+ <Component {...props} />
585
+ </LocalizationProvider>
586
+ );
587
+ };
588
+ ```
589
+
590
+ `withLocalization` is inserted into the shared `projectProviders` chain **before** `withBuyerPortal`:
591
+
592
+ ```typescript
593
+ // src/features/shared/utils/withProviders.tsx (modified)
594
+ // reduceRight applies right→left, so the tree is:
595
+ // withAuth › withLocalization › withBuyerPortal › Component
596
+ // withLocalization thus wraps BuyerPortalProvider (and its LoadingTabsLayout) and still
597
+ // receives `context` (withBuyerPortal would otherwise strip it).
598
+ const projectProviders: ProviderHOC[] = [withAuth, withLocalization, withBuyerPortal];
599
+ ```
600
+
601
+ ### Integration Points
602
+
603
+ #### Loader injection (server-side) — example in a page under `src/pages/`
604
+
605
+ ```typescript
606
+ // src/pages/org-units.tsx (excerpt)
607
+ import { getLocalizationLabelsService } from "../features/shared/localization/services/get-localization-labels.service";
608
+ import { LOCALIZATION_ENTRY_IDS } from "../features/shared/localization/constants";
609
+ // ...existing imports (withAuthLoader, withProviders, etc.)
610
+
611
+ export async function loaderFunction(data: LoaderData<OrgUnitsQuery>) {
612
+ return withAuthLoader(data, async ({ cookie, ...clientContext }) => {
613
+ const [orgUnits, localizationLabels] = await Promise.all([
614
+ getOrgUnitsService({ cookie /* ... */ }),
615
+ // `shared` is merged automatically; pass an array for multiple domains.
616
+ // locale comes from the browser/request context; empty string as fallback.
617
+ getLocalizationLabelsService({ entryIds: LOCALIZATION_ENTRY_IDS.orgUnits, locale: data.locale }),
618
+ ]);
619
+
620
+ return {
621
+ data: orgUnits,
622
+ context: {
623
+ clientContext: { cookie, ...clientContext },
624
+ localization: { labels: localizationLabels }, // <- consumed by withLocalization
625
+ },
626
+ };
627
+ });
628
+ }
629
+
630
+ export default withProviders(OrgUnitsPage); // withProviders now includes withLocalization
631
+ ```
632
+
633
+ #### Component consumption (client) — examples in child components
634
+
635
+ **String interpolation** — `t()` returns `string`:
636
+
637
+ ```typescript
638
+ // Any component under the provider tree
639
+ import { useLocalization } from "../../shared/localization/LocalizationContext";
640
+
641
+ export const PaginationLabel = ({ from, to, total }: { from: number; to: number; total: number }) => {
642
+ const { t } = useLocalization();
643
+
644
+ // Label JSON: { "shared.pagination.label": "De {0} a {1} itens de {2}" }
645
+ return <span>{t("shared.pagination.label", [from, to, total])}</span>;
646
+ // -> "De 1 a 25 itens de 100"
647
+ };
648
+ ```
649
+
650
+ **ReactNode interpolation** — `t()` returns `ReactNode` (Decision 8):
651
+
652
+ ```tsx
653
+ // Label JSON: { "users.delete.messages.confirmDeleteUser": "Permanently delete {0}?" }
654
+ export const DeleteConfirmation = ({ user }: { user: { id: string; name: string } }) => {
655
+ const { t } = useLocalization();
656
+
657
+ return (
658
+ <p>
659
+ {t("users.delete.messages.confirmDeleteUser", [
660
+ <a key="user-name" href={`/user/${user.id}`}>{user.name}</a>,
661
+ ])}
662
+ </p>
663
+ // -> <p>Permanently delete <a href="/user/42">Jane Doe</a>?</p>
664
+ );
665
+ };
666
+ ```
667
+
668
+ TypeScript selects the `ReactNode` overload automatically because `<a>` is not a `string | number`.
669
+ The caller is responsible for adding `key` props to JSX elements passed as interpolations.
670
+
671
+ #### Error-path label loading (`withLoaderErrorBoundary` and `withAuthLoader`)
672
+
673
+ When a page loader throws (caught by `withLoaderErrorBoundary`) or authentication fails (caught by `withAuthLoader`), the system still needs localization labels so the error UI can display translated strings via `t()`.
674
+
675
+ Both utilities follow the same pattern:
676
+
677
+ 1. Call `getLocalizationLabelsService({ entryIds: LOCALIZATION_ENTRY_IDS.shared, locale: data.locale ?? "" })` — fetches only the `shared` entry (common labels are sufficient for error states) and never throws (graceful fallback to `{}`).
678
+ 2. Embed the result as `context.localization.labels` in the error response shape.
679
+ 3. `withAuth` (the HOC) detects the unauthorized/error state (`!props.authorized`) and forwards `props.context` as `errorContext` to the wrapped component, so `withLocalization` can read `context.localization.labels` and provide a valid `t()` even in the error render path.
680
+
681
+ This guarantees that the Error Boundary page renders with translated labels (or at minimum key-as-fallback behavior) regardless of whether the original loader succeeded.
682
+
683
+ ### Invariants & Constraints
684
+
685
+ - **Server-only fetch**: `getLocalizationLabelsService` is invoked exclusively from page loaders. Components never trigger localization network requests.
686
+ - **Single transform**: `labels[]` → `LabelMap` happens once per `(entryId, locale)`; render-time lookups are O(1).
687
+ - **`shared` always present**: every resolved map includes the `shared` entry, merged with any requested domain entries (namespaced keys → collision-safe).
688
+ - **Global, unauthenticated labels**: no host-store cookie is forwarded; one cache entry per `(entryId, locale)` serves all users.
689
+ - **Permanent cache**: the `resolved` map has no TTL; updated translations propagate on process restart/deploy.
690
+ - **Never throws**: a failed fetch logs an error and yields an empty `LabelMap`; `t()` returns the key.
691
+ - **Idempotent fallback**: `t(key)` with a missing key always returns exactly `key`; unmatched `{n}` tokens are left intact in both the string and node paths.
692
+ - **ReactNode interpolation is additive**: passing a `ReactNode[]` activates `interpolateNodes` and returns a `ReactNode[]`; callers must provide `key` props on JSX elements in the array (standard React requirement). The string-interpolation path is unaffected.
693
+ - **Provider placement**: `LocalizationProvider` sits outside `BuyerPortalProvider` (`withLocalization` before `withBuyerPortal`) so it receives `context` and wraps `LoadingTabsLayout`.
694
+ - **No cross-request state on the server**: React Context is authoritative.
695
+ - **Plugin boundary**: all localization code lives in `src/features/shared/localization/`; no new route registration in `plugin.config.js` is required.
696
+ - **Zero new runtime dependencies** (FR-010); strict TypeScript with no unjustified `any`.
697
+
698
+ ### Files to Create / Change
699
+
700
+ | File | Change |
701
+ |---|---|
702
+ | `src/features/shared/localization/types.ts` | API response types, `LabelMap`, `LocalizeFn` |
703
+ | `src/features/shared/localization/constants.ts` | `LOCALIZATION_ENTRY_IDS as const`, `LOCALIZATION_CONTENT_TYPE`, `LOCALIZATION_BASE_URL` |
704
+ | `src/features/shared/localization/clients/LocalizationClient.ts` | HTTP client (extends `Client`) |
705
+ | `src/features/shared/localization/services/get-localization-labels.service.ts` | Multi-entry merge + auto-`shared` + permanent cache + single-flight + error log + fallback |
706
+ | `src/features/shared/localization/createLocalize.ts` | Pure `t` factory |
707
+ | `src/features/shared/localization/LocalizationContext.tsx` | Context, `LocalizationProvider`, `useLocalization` |
708
+ | `src/features/shared/localization/withLocalization.tsx` | HOC reading `context.localization.labels`, forwards full props |
709
+ | `src/features/shared/utils/withProviders.tsx` | Insert `withLocalization` **before** `withBuyerPortal` in `projectProviders` |
710
+ | `src/pages/<page>.tsx` | Loader fetches labels and returns `context.localization` |
711
+ | `src/features/shared/localization/__tests__/*` | Unit tests (transform, interpolation, fallback) |