@open-mercato/ui 0.8.1-develop.7275.1.f772b944d4 → 0.8.1-develop.7294.1.0ef99fc518

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.
@@ -2,6 +2,7 @@
2
2
 
3
3
  jest.mock('@open-mercato/shared/lib/i18n/context', () => ({
4
4
  useT: () => (_key: string, fallback: string) => fallback,
5
+ useOptionalLocale: () => undefined,
5
6
  }))
6
7
 
7
8
  jest.mock('@open-mercato/shared/lib/phone', () => ({
@@ -22,7 +23,15 @@ jest.mock('@open-mercato/shared/lib/phone', () => ({
22
23
 
23
24
  import * as React from 'react'
24
25
  import { fireEvent, render, screen } from '@testing-library/react'
25
- import { PHONE_COUNTRIES, PhoneNumberField } from '../PhoneNumberField'
26
+ import type { TranslateFn } from '@open-mercato/shared/lib/i18n/context'
27
+ import {
28
+ PHONE_COUNTRIES,
29
+ PhoneNumberField,
30
+ buildPhoneCountryOptions,
31
+ phoneCountryLabelKey,
32
+ resolvePhoneCountryLabel,
33
+ type PhoneCountry,
34
+ } from '../PhoneNumberField'
26
35
 
27
36
  function PhoneFieldHarness(props: Partial<React.ComponentProps<typeof PhoneNumberField>>) {
28
37
  const [value, setValue] = React.useState<string | undefined>(undefined)
@@ -148,6 +157,88 @@ describe('PhoneNumberField initial country detection', () => {
148
157
  })
149
158
  })
150
159
 
160
+ const englishOnly: TranslateFn = (key, fallback) => (typeof fallback === 'string' ? fallback : key)
161
+
162
+ describe('PhoneNumberField country name localization', () => {
163
+ const poland = PHONE_COUNTRIES.find((country) => country.iso2 === 'PL') as PhoneCountry
164
+
165
+ it('builds the translation key from the ISO 3166-1 alpha-2 code', () => {
166
+ expect(phoneCountryLabelKey('pl')).toBe('ui.inputs.phoneNumberField.countryName.PL')
167
+ })
168
+
169
+ it('prefers a locale-file entry over every other source', () => {
170
+ const withOverride: TranslateFn = (key, fallback) =>
171
+ key === 'ui.inputs.phoneNumberField.countryName.PL' ? 'Rzeczpospolita Polska' : englishOnly(key, fallback)
172
+
173
+ expect(resolvePhoneCountryLabel(poland, withOverride, 'pl')).toBe('Rzeczpospolita Polska')
174
+ })
175
+
176
+ it('localizes an untranslated country through the active locale region data', () => {
177
+ expect(resolvePhoneCountryLabel(poland, englishOnly, 'pl')).toBe('Polska')
178
+ expect(resolvePhoneCountryLabel(poland, englishOnly, 'de')).toBe('Polen')
179
+ })
180
+
181
+ it('falls back to the English label when no locale is in scope', () => {
182
+ expect(resolvePhoneCountryLabel(poland, englishOnly, undefined)).toBe('Poland')
183
+ })
184
+
185
+ it('keeps the curated English dictionary names under an English locale', () => {
186
+ const antigua = PHONE_COUNTRIES.find((country) => country.iso2 === 'AG') as PhoneCountry
187
+
188
+ expect(resolvePhoneCountryLabel(poland, englishOnly, 'en')).toBe('Poland')
189
+ // CLDR spells this "Antigua & Barbuda"; the dictionary wins so existing
190
+ // English copy is never silently rewritten.
191
+ expect(antigua.label).toBe('Antigua and Barbuda')
192
+ expect(resolvePhoneCountryLabel(antigua, englishOnly, 'en')).toBe('Antigua and Barbuda')
193
+ expect(resolvePhoneCountryLabel(antigua, englishOnly, 'en-GB')).toBe('Antigua and Barbuda')
194
+ })
195
+
196
+ it('falls back to the English label for a code the runtime cannot name', () => {
197
+ const unassigned: PhoneCountry = { iso2: 'QQ', dialCode: '+999', label: 'Nowhere', flag: '' }
198
+ const malformed: PhoneCountry = { iso2: 'QQQ', dialCode: '+999', label: 'Nowhere', flag: '' }
199
+
200
+ expect(resolvePhoneCountryLabel(unassigned, englishOnly, 'pl')).toBe('Nowhere')
201
+ expect(resolvePhoneCountryLabel(malformed, englishOnly, 'pl')).toBe('Nowhere')
202
+ })
203
+ })
204
+
205
+ describe('PhoneNumberField country option list', () => {
206
+ it('localizes the built-in list and re-sorts it for the active locale', () => {
207
+ const options = buildPhoneCountryOptions(undefined, englishOnly, 'pl')
208
+
209
+ expect(options).toHaveLength(PHONE_COUNTRIES.length)
210
+ expect(options.find((option) => option.country.iso2 === 'DE')?.label).toBe('Niemcy')
211
+
212
+ const labels = options.map((option) => option.label)
213
+ const sorted = [...labels].sort((a, b) => a.localeCompare(b, 'pl', { sensitivity: 'base' }))
214
+ expect(labels).toEqual(sorted)
215
+ })
216
+
217
+ it('keeps a caller-supplied list authoritative in both label and order', () => {
218
+ const supplied: PhoneCountry[] = [
219
+ { iso2: 'DE', dialCode: '+49', label: 'Deutschland', flag: '🇩🇪' },
220
+ { iso2: 'PL', dialCode: '+48', label: 'Rzeczpospolita', flag: '🇵🇱' },
221
+ ]
222
+
223
+ expect(buildPhoneCountryOptions(supplied, englishOnly, 'pl')).toEqual([
224
+ { country: supplied[0], label: 'Deutschland' },
225
+ { country: supplied[1], label: 'Rzeczpospolita' },
226
+ ])
227
+ })
228
+ })
229
+
230
+ describe('PhoneNumberField option list under an unusable locale', () => {
231
+ it('falls back to English names and English collation instead of throwing', () => {
232
+ const options = buildPhoneCountryOptions(undefined, englishOnly, 'not a locale tag')
233
+
234
+ expect(options.find((option) => option.country.iso2 === 'DE')?.label).toBe('Germany')
235
+
236
+ const labels = options.map((option) => option.label)
237
+ const sorted = [...labels].sort((a, b) => a.localeCompare(b, 'en', { sensitivity: 'base' }))
238
+ expect(labels).toEqual(sorted)
239
+ })
240
+ })
241
+
151
242
  describe('PhoneNumberField source options', () => {
152
243
  it('uses the translated country picker name and optional country asset', () => {
153
244
  render(<PhoneFieldHarness size={40} countryLabel="Numer kierunkowy kraju" renderCountryIcon={country => <img src={`${country.iso2}.svg`} alt="" />} />)
@@ -10,6 +10,7 @@ import {
10
10
  } from 'react'
11
11
  import type { CustomerAuthResult } from '@open-mercato/shared/modules/customer-auth'
12
12
  import type { CustomerAuthContext } from '@open-mercato/shared/modules/customer-auth'
13
+ import { navigateWithPageReload } from '@open-mercato/shared/lib/navigation/pageReload'
13
14
  import { apiCall } from '../backend/utils/apiCall'
14
15
 
15
16
  /* ------------------------------------------------------------------ */
@@ -155,7 +156,7 @@ export function PortalProvider({ orgSlug, children, initialAuth, initialTenant }
155
156
  }
156
157
  profileEnrichedRef.current = false
157
158
  setAuthState({ user: null, roles: [], resolvedFeatures: [], isPortalAdmin: false, loading: false, error: null })
158
- window.location.assign(`/${orgSlug}/portal/login`)
159
+ navigateWithPageReload(`/${orgSlug}/portal/login`)
159
160
  }, [orgSlug])
160
161
 
161
162
  /* ---- Tenant state ---- */
@@ -0,0 +1,118 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+
5
+ import * as React from 'react'
6
+ import { act, render, waitFor } from '@testing-library/react'
7
+ import type { CustomerAuthContext } from '@open-mercato/shared/modules/customer-auth'
8
+ import { PortalProvider, usePortalContext } from '../PortalContext'
9
+ import { useCustomerAuth } from '../hooks/useCustomerAuth'
10
+
11
+ const apiCallMock = jest.fn()
12
+ const navigateWithPageReloadMock = jest.fn()
13
+ const routerPushMock = jest.fn()
14
+ const routerReplaceMock = jest.fn()
15
+
16
+ jest.mock('../../backend/utils/apiCall', () => ({
17
+ apiCall: (...args: unknown[]) => apiCallMock(...args),
18
+ }))
19
+
20
+ jest.mock('@open-mercato/shared/lib/navigation/pageReload', () => ({
21
+ navigateWithPageReload: (...args: unknown[]) => navigateWithPageReloadMock(...args),
22
+ }))
23
+
24
+ jest.mock('next/navigation', () => ({
25
+ useRouter: () => ({ push: routerPushMock, replace: routerReplaceMock }),
26
+ }))
27
+
28
+ function makeAuth(): CustomerAuthContext {
29
+ return {
30
+ sub: 'user-1',
31
+ email: 'user@example.com',
32
+ displayName: 'User One',
33
+ tenantId: 't-1',
34
+ orgId: 'o-1',
35
+ resolvedFeatures: ['portal.view'],
36
+ customerEntityId: null,
37
+ personEntityId: null,
38
+ } as unknown as CustomerAuthContext
39
+ }
40
+
41
+ /**
42
+ * The portal layout that computes `authenticated` and the customer session sits above the
43
+ * `[...slug]` segment, so it only re-runs on a document load. Both logout paths must therefore
44
+ * leave the authenticated side with a full page load; a client-side navigation would strand
45
+ * authenticated chrome over the login page. See `navigateWithPageReload`.
46
+ */
47
+ describe('portal logout crosses the auth boundary with a full page load', () => {
48
+ beforeEach(() => {
49
+ apiCallMock.mockReset()
50
+ apiCallMock.mockResolvedValue({ ok: true, status: 200, result: { ok: true } })
51
+ navigateWithPageReloadMock.mockReset()
52
+ routerPushMock.mockReset()
53
+ routerReplaceMock.mockReset()
54
+ })
55
+
56
+ describe('useCustomerAuth().logout', () => {
57
+ function LogoutProbe({ orgSlug }: { orgSlug?: string }) {
58
+ const { logout } = useCustomerAuth(orgSlug)
59
+ return <button type="button" onClick={() => { void logout() }}>logout</button>
60
+ }
61
+
62
+ it('reloads the page onto the org-scoped login route', async () => {
63
+ const { getByText } = render(<LogoutProbe orgSlug="acme" />)
64
+
65
+ await act(async () => { getByText('logout').click() })
66
+
67
+ await waitFor(() => {
68
+ expect(navigateWithPageReloadMock).toHaveBeenCalledWith('/acme/portal/login')
69
+ })
70
+ expect(routerPushMock).not.toHaveBeenCalled()
71
+ expect(routerReplaceMock).not.toHaveBeenCalled()
72
+ })
73
+
74
+ it('reloads the page onto the unscoped login route when no org slug is known', async () => {
75
+ const { getByText } = render(<LogoutProbe />)
76
+
77
+ await act(async () => { getByText('logout').click() })
78
+
79
+ await waitFor(() => {
80
+ expect(navigateWithPageReloadMock).toHaveBeenCalledWith('/portal/login')
81
+ })
82
+ })
83
+
84
+ it('still reloads when the logout request fails', async () => {
85
+ apiCallMock.mockRejectedValue(new Error('[internal] network down'))
86
+ const { getByText } = render(<LogoutProbe orgSlug="acme" />)
87
+
88
+ await act(async () => { getByText('logout').click() })
89
+
90
+ await waitFor(() => {
91
+ expect(navigateWithPageReloadMock).toHaveBeenCalledWith('/acme/portal/login')
92
+ })
93
+ })
94
+ })
95
+
96
+ describe('PortalContext logout', () => {
97
+ function ContextLogoutProbe() {
98
+ const { auth } = usePortalContext()
99
+ return <button type="button" onClick={() => { void auth.logout() }}>logout</button>
100
+ }
101
+
102
+ it('reloads the page onto the login route', async () => {
103
+ const { getByText } = render(
104
+ <PortalProvider orgSlug="acme" initialAuth={makeAuth()}>
105
+ <ContextLogoutProbe />
106
+ </PortalProvider>,
107
+ )
108
+
109
+ await act(async () => { getByText('logout').click() })
110
+
111
+ await waitFor(() => {
112
+ expect(navigateWithPageReloadMock).toHaveBeenCalledWith('/acme/portal/login')
113
+ })
114
+ expect(routerPushMock).not.toHaveBeenCalled()
115
+ expect(routerReplaceMock).not.toHaveBeenCalled()
116
+ })
117
+ })
118
+ })
@@ -1,7 +1,7 @@
1
1
  "use client"
2
2
  import { useCallback, useEffect, useState } from 'react'
3
- import { useRouter } from 'next/navigation'
4
3
  import type { CustomerUser, CustomerRole, CustomerAuthResult } from '@open-mercato/shared/modules/customer-auth'
4
+ import { navigateWithPageReload } from '@open-mercato/shared/lib/navigation/pageReload'
5
5
  import { apiCall } from '../../backend/utils/apiCall'
6
6
 
7
7
  export type { CustomerUser, CustomerRole, CustomerAuthResult }
@@ -27,7 +27,6 @@ export type { CustomerUser, CustomerRole, CustomerAuthResult }
27
27
  * ```
28
28
  */
29
29
  export function useCustomerAuth(orgSlug?: string) {
30
- const router = useRouter()
31
30
  const [state, setState] = useState<CustomerAuthResult>({
32
31
  user: null,
33
32
  roles: [],
@@ -90,8 +89,8 @@ export function useCustomerAuth(orgSlug?: string) {
90
89
  loading: false,
91
90
  error: null,
92
91
  })
93
- router.push(loginPath)
94
- }, [router, loginPath])
92
+ navigateWithPageReload(loginPath)
93
+ }, [loginPath])
95
94
 
96
95
  return { ...state, logout }
97
96
  }