@faststore/core 4.4.1-dev.0 → 4.5.0-dev.1

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 (89) hide show
  1. package/.turbo/turbo-generate.log +4 -4
  2. package/.turbo/turbo-test.log +137 -51
  3. package/@generated/cached-operations.json +4 -0
  4. package/@generated/gql.ts +30 -0
  5. package/@generated/graphql.ts +141 -0
  6. package/@generated/persisted-documents.json +5 -0
  7. package/@generated/schema.graphql +100 -0
  8. package/CHANGELOG.md +26 -0
  9. package/cms/faststore/components/cms_component__recommendationshelf.jsonc +86 -0
  10. package/cms/faststore/schema.json +89 -0
  11. package/discovery.config.default.js +3 -0
  12. package/discovery.config.js +10 -1
  13. package/discovery.config.local.example.js +62 -0
  14. package/package.json +6 -6
  15. package/public/icons.svg +4 -0
  16. package/src/Layout.tsx +6 -0
  17. package/src/components/account/Drawer/OrganizationDrawer/ContractSwitcher/ContractSwitcher.tsx +376 -0
  18. package/src/components/account/Drawer/OrganizationDrawer/ContractSwitcher/index.ts +2 -0
  19. package/src/components/account/Drawer/OrganizationDrawer/OrganizationDrawer.tsx +52 -23
  20. package/src/components/account/Drawer/OrganizationDrawer/OrganizationDrawerBody.tsx +16 -6
  21. package/src/components/account/Drawer/OrganizationDrawer/OrganizationDrawerFooter.tsx +55 -0
  22. package/src/components/account/Drawer/OrganizationDrawer/OrganizationDrawerHeader.tsx +45 -5
  23. package/src/components/account/Drawer/OrganizationDrawer/index.ts +5 -0
  24. package/src/components/account/Drawer/OrganizationDrawer/section.module.scss +452 -54
  25. package/src/components/account/Layout/Layout.tsx +14 -5
  26. package/src/components/account/quotes/MyAccountListQuotes/MyAccountListQuotes.tsx +170 -0
  27. package/src/components/account/quotes/MyAccountListQuotes/MyAccountListQuotesTable/MyAccountListQuotesTable.tsx +194 -0
  28. package/src/components/account/quotes/MyAccountListQuotes/MyAccountListQuotesTable/MyAccountQuoteStatusBadge.tsx +15 -0
  29. package/src/components/account/quotes/MyAccountListQuotes/MyAccountQuotesFilterSlider/MyAccountQuotesFilterSlider.tsx +162 -0
  30. package/src/components/account/quotes/MyAccountListQuotes/MyAccountQuotesFilterSlider/section.module.scss +49 -0
  31. package/src/components/account/quotes/MyAccountListQuotes/MyAccountQuotesSelectedTags/MyAccountQuotesSelectedTags.tsx +125 -0
  32. package/src/components/account/quotes/MyAccountListQuotes/MyAccountQuotesStatusSelector/MyAccountQuotesStatusSelector.tsx +107 -0
  33. package/src/components/account/quotes/MyAccountListQuotes/MyAccountQuotesStatusSelector/styles.module.scss +144 -0
  34. package/src/components/account/quotes/MyAccountListQuotes/index.ts +2 -0
  35. package/src/components/account/quotes/MyAccountListQuotes/quoteFilters.ts +89 -0
  36. package/src/components/account/quotes/MyAccountListQuotes/styles.module.scss +174 -0
  37. package/src/components/cms/global/Components.ts +10 -0
  38. package/src/components/sections/ProductGallery/ProductGallery.tsx +1 -1
  39. package/src/components/sections/RecommendationShelf/RecommendationShelf.tsx +223 -0
  40. package/src/components/sections/RecommendationShelf/RecommendationShelf.types.ts +111 -0
  41. package/src/components/sections/RecommendationShelf/index.ts +1 -0
  42. package/src/components/sections/RecommendationShelf/section.module.scss +49 -0
  43. package/src/components/sections/RecommendationShelf/useRecommendationUserId.ts +49 -0
  44. package/src/components/sections/RecommendationShelf/useRecommendations.ts +55 -0
  45. package/src/components/sections/RecommendationShelf/vrn.ts +55 -0
  46. package/src/customizations/src/myAccount/extensions/quotes/after.tsx +5 -0
  47. package/src/customizations/src/myAccount/extensions/quotes/before.tsx +5 -0
  48. package/src/pages/[slug]/p.tsx +45 -0
  49. package/src/pages/pvt/account/quotes/index.tsx +264 -0
  50. package/src/sdk/account/changeContractToken.ts +119 -0
  51. package/src/sdk/account/clearPersistedSessionState.ts +26 -0
  52. package/src/sdk/account/getMyAccountRoutes.ts +7 -0
  53. package/src/sdk/account/refreshToken.ts +21 -8
  54. package/src/sdk/account/useAvailableContracts.ts +49 -0
  55. package/src/sdk/account/useSwitchContract.ts +60 -0
  56. package/src/sdk/analytics/hooks/useStartRecommendationSession.ts +96 -0
  57. package/src/sdk/analytics/utils.ts +9 -0
  58. package/src/utils/quoteStatus.ts +27 -0
  59. package/src/utils/retry.ts +69 -0
  60. package/test/components/RecommendationShelf/RecommendationShelf.browser.test.tsx +235 -0
  61. package/test/components/RecommendationShelf/useRecommendationUserId.test.ts +69 -0
  62. package/test/components/RecommendationShelf/useRecommendations.test.ts +52 -0
  63. package/test/components/RecommendationShelf/vrn.test.ts +56 -0
  64. package/test/components/account/ContractSwitcher.browser.test.tsx +236 -0
  65. package/test/components/account/Layout.test.tsx +62 -0
  66. package/test/components/account/OrganizationDrawer.browser.test.tsx +197 -0
  67. package/test/components/account/OrganizationDrawerBody.test.tsx +52 -0
  68. package/test/components/account/OrganizationDrawerFooter.browser.test.tsx +54 -0
  69. package/test/components/account/OrganizationDrawerHeader.browser.test.tsx +55 -0
  70. package/test/components/account/doLogout.browser.test.ts +102 -0
  71. package/test/components/quotes/MyAccountListQuotes.test.tsx +232 -0
  72. package/test/components/quotes/MyAccountListQuotesTable.test.tsx +240 -0
  73. package/test/components/quotes/MyAccountQuoteStatusBadge.test.tsx +28 -0
  74. package/test/components/quotes/MyAccountQuotesFilterSlider.test.tsx +152 -0
  75. package/test/components/quotes/MyAccountQuotesSelectedTags.test.tsx +122 -0
  76. package/test/components/quotes/MyAccountQuotesStatusSelector.test.tsx +85 -0
  77. package/test/components/quotes/quoteFilters.test.ts +185 -0
  78. package/test/sdk/account/changeContractToken.browser.test.ts +157 -0
  79. package/test/sdk/account/clearPersistedSessionState.browser.test.ts +41 -0
  80. package/test/sdk/account/clearPersistedSessionState.test.ts +20 -0
  81. package/test/sdk/account/getMyAccountRoutes.test.ts +90 -0
  82. package/test/sdk/account/refreshToken.browser.test.ts +34 -0
  83. package/test/sdk/account/refreshToken.test.ts +87 -0
  84. package/test/sdk/account/useAvailableContracts.browser.test.ts +124 -0
  85. package/test/sdk/account/useSwitchContract.browser.test.ts +111 -0
  86. package/test/sdk/analytics/useStartRecommendationSession.test.ts +69 -0
  87. package/test/sdk/analytics/utils.test.ts +23 -0
  88. package/test/server/index.test.ts +5 -0
  89. package/test/utils/retry.test.ts +59 -0
@@ -0,0 +1,102 @@
1
+ /**
2
+ * @vitest-environment jsdom
3
+ */
4
+
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
6
+
7
+ const mockFetch = vi.hoisted(() => vi.fn())
8
+ const mockAssign = vi.hoisted(() => vi.fn())
9
+ const mockDel = vi.hoisted(() => vi.fn())
10
+ const mockSetReloadAfterLogoutReturn = vi.hoisted(() => vi.fn())
11
+ const mockGetStoreURL = vi.hoisted(() => vi.fn())
12
+ const mockExpireCookieClient = vi.hoisted(() => vi.fn())
13
+
14
+ vi.stubGlobal('fetch', mockFetch)
15
+ vi.mock('idb-keyval', () => ({ del: mockDel }))
16
+ vi.mock('src/sdk/localization/useLocalizationConfig', () => ({
17
+ getStoreURL: mockGetStoreURL,
18
+ }))
19
+ vi.mock(
20
+ '../../../src/components/account/Drawer/OrganizationDrawer/useReloadAfterLogoutReturn',
21
+ () => ({
22
+ setReloadAfterLogoutReturn: mockSetReloadAfterLogoutReturn,
23
+ })
24
+ )
25
+ vi.mock('src/utils/clearCookies', () => ({
26
+ expireCookieClient: mockExpireCookieClient,
27
+ getCookieDomains: () => ['.example.com'],
28
+ getCookiePaths: () => ['/'],
29
+ getVtexCookieNames: (names: string[]) =>
30
+ names.filter((name) => /vtex/i.test(name)),
31
+ }))
32
+ vi.mock('../../../discovery.config', () => ({
33
+ default: {
34
+ secureSubdomain: 'https://secure.example.com',
35
+ api: { storeId: 'teststore' },
36
+ },
37
+ }))
38
+
39
+ import { doLogout } from '../../../src/components/account/Drawer/OrganizationDrawer/OrganizationDrawer'
40
+
41
+ describe('doLogout', () => {
42
+ beforeEach(() => {
43
+ mockFetch.mockResolvedValue({
44
+ json: async () => ({}),
45
+ })
46
+ mockGetStoreURL.mockReturnValue('https://store.example.com')
47
+ mockDel.mockResolvedValue(undefined)
48
+
49
+ Object.defineProperty(globalThis, 'indexedDB', {
50
+ configurable: true,
51
+ value: {
52
+ deleteDatabase: () => {
53
+ const request = {
54
+ onsuccess: null as (() => void) | null,
55
+ onerror: null,
56
+ onblocked: null,
57
+ }
58
+
59
+ queueMicrotask(() => request.onsuccess?.())
60
+
61
+ return request
62
+ },
63
+ },
64
+ })
65
+
66
+ Object.defineProperty(globalThis.window, 'location', {
67
+ configurable: true,
68
+ value: {
69
+ hostname: 'store.example.com',
70
+ protocol: 'https:',
71
+ pathname: '/',
72
+ assign: mockAssign,
73
+ },
74
+ })
75
+
76
+ document.cookie = 'VtexIdclientAutCookie_teststore=token; other=1'
77
+ sessionStorage.setItem('faststore_session_ready', '1')
78
+ sessionStorage.setItem('__fs_gallery_page_1', '1')
79
+ })
80
+
81
+ afterEach(() => {
82
+ vi.clearAllMocks()
83
+ sessionStorage.clear()
84
+ document.cookie = ''
85
+ })
86
+
87
+ it('clears client storage, calls logout API, and redirects to VTEX logout', async () => {
88
+ await doLogout()
89
+
90
+ expect(mockDel).toHaveBeenCalledWith('fs::session')
91
+ expect(sessionStorage.getItem('faststore_session_ready')).toBeNull()
92
+ expect(sessionStorage.getItem('__fs_gallery_page_1')).toBeNull()
93
+ expect(mockFetch).toHaveBeenCalledWith('/api/fs/logout', {
94
+ method: 'POST',
95
+ credentials: 'include',
96
+ })
97
+ expect(mockSetReloadAfterLogoutReturn).toHaveBeenCalledTimes(1)
98
+ expect(mockAssign).toHaveBeenCalledWith(
99
+ 'https://secure.example.com/api/vtexid/pub/logout?scope=teststore&returnUrl=https://store.example.com'
100
+ )
101
+ })
102
+ })
@@ -0,0 +1,232 @@
1
+ /**
2
+ * @vitest-environment jsdom
3
+ */
4
+
5
+ import { fireEvent, render, screen } from '@testing-library/react'
6
+ import React from 'react'
7
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
8
+
9
+ const mockUseScreenResize = vi.hoisted(() => vi.fn())
10
+ vi.mock('src/sdk/ui/useScreenResize', () => ({
11
+ default: mockUseScreenResize,
12
+ }))
13
+
14
+ vi.mock(
15
+ '../../../src/components/account/quotes/MyAccountListQuotes/MyAccountListQuotesTable/MyAccountListQuotesTable',
16
+ () => ({
17
+ __esModule: true,
18
+ default: () => <div data-testid="quotes-table" />,
19
+ Pagination: ({ page }: { page: number }) => (
20
+ <div data-testid="pagination">page {page}</div>
21
+ ),
22
+ })
23
+ )
24
+
25
+ vi.mock(
26
+ '../../../src/components/account/quotes/MyAccountListQuotes/MyAccountQuotesFilterSlider/MyAccountQuotesFilterSlider',
27
+ () => ({
28
+ __esModule: true,
29
+ default: () => <div data-testid="filter-slider" />,
30
+ })
31
+ )
32
+
33
+ const mockUseUI = vi.hoisted(() => vi.fn())
34
+ vi.mock('@faststore/ui', async () => {
35
+ const actual =
36
+ await vi.importActual<typeof import('@faststore/ui')>('@faststore/ui')
37
+ return {
38
+ ...actual,
39
+ useUI: mockUseUI,
40
+ SearchInputField: React.forwardRef(function MockSearchInputField(
41
+ { onBlur, onKeyDown }: any,
42
+ ref: any
43
+ ) {
44
+ const inputRef = React.useRef<HTMLInputElement>(null)
45
+ React.useImperativeHandle(ref, () => ({ inputRef: inputRef.current }))
46
+ return (
47
+ <input
48
+ ref={inputRef}
49
+ placeholder="Search"
50
+ onBlur={onBlur}
51
+ onKeyDown={onKeyDown}
52
+ />
53
+ )
54
+ }),
55
+ }
56
+ })
57
+
58
+ import MyAccountListQuotes from '../../../src/components/account/quotes/MyAccountListQuotes/MyAccountListQuotes'
59
+
60
+ const originalLocation = window.location
61
+
62
+ function stubLocation(search = '') {
63
+ Object.defineProperty(window, 'location', {
64
+ configurable: true,
65
+ value: { ...originalLocation, search, href: '' },
66
+ })
67
+ }
68
+
69
+ function baseFilters(overrides: Record<string, unknown> = {}) {
70
+ return {
71
+ page: 1,
72
+ status: [],
73
+ createdAtFrom: '',
74
+ createdAtTo: '',
75
+ expiresAtFrom: '',
76
+ expiresAtTo: '',
77
+ label: '',
78
+ ...overrides,
79
+ }
80
+ }
81
+
82
+ describe('MyAccountListQuotes', () => {
83
+ const openFilter = vi.fn()
84
+
85
+ beforeEach(() => {
86
+ stubLocation()
87
+ mockUseScreenResize.mockReturnValue({ isDesktop: true })
88
+ mockUseUI.mockReturnValue({ openFilter, filter: false })
89
+ })
90
+
91
+ afterEach(() => {
92
+ vi.clearAllMocks()
93
+ Object.defineProperty(window, 'location', {
94
+ configurable: true,
95
+ value: originalLocation,
96
+ })
97
+ })
98
+
99
+ it('renders the quotes table when there are results', () => {
100
+ render(
101
+ <MyAccountListQuotes
102
+ listQuotes={{ list: [{ id: 'q-1' }] } as any}
103
+ total={1}
104
+ perPage={25}
105
+ filters={baseFilters()}
106
+ />
107
+ )
108
+
109
+ expect(screen.getByTestId('quotes-table')).toBeTruthy()
110
+ })
111
+
112
+ it('renders the "no quotes" empty state when there are no results and no filters', () => {
113
+ render(
114
+ <MyAccountListQuotes
115
+ listQuotes={{ list: [] } as any}
116
+ total={0}
117
+ perPage={25}
118
+ filters={baseFilters()}
119
+ />
120
+ )
121
+
122
+ expect(screen.getByText("You don't have any quotes")).toBeTruthy()
123
+ expect(screen.queryByTestId('quotes-table')).toBeNull()
124
+ })
125
+
126
+ it('renders the "no results" empty state when filters are active but nothing matches', () => {
127
+ render(
128
+ <MyAccountListQuotes
129
+ listQuotes={{ list: [] } as any}
130
+ total={0}
131
+ perPage={25}
132
+ filters={baseFilters({ status: ['Draft'] })}
133
+ />
134
+ )
135
+
136
+ expect(screen.getByText('No results found')).toBeTruthy()
137
+ })
138
+
139
+ it('opens the filter slider and dispatches the current selected facets when Filters is clicked', () => {
140
+ render(
141
+ <MyAccountListQuotes
142
+ listQuotes={{ list: [] } as any}
143
+ total={0}
144
+ perPage={25}
145
+ filters={baseFilters({ status: ['Draft'] })}
146
+ />
147
+ )
148
+
149
+ fireEvent.click(screen.getByText('Filters'))
150
+
151
+ expect(openFilter).toHaveBeenCalledTimes(1)
152
+ })
153
+
154
+ it('shows the active filter count badge', () => {
155
+ render(
156
+ <MyAccountListQuotes
157
+ listQuotes={{ list: [] } as any}
158
+ total={0}
159
+ perPage={25}
160
+ filters={baseFilters({ status: ['Draft'] })}
161
+ />
162
+ )
163
+
164
+ expect(screen.getByText('1')).toBeTruthy()
165
+ })
166
+
167
+ it('renders the filter slider when the UI filter drawer is open', () => {
168
+ mockUseUI.mockReturnValue({ openFilter, filter: true })
169
+
170
+ render(
171
+ <MyAccountListQuotes
172
+ listQuotes={{ list: [] } as any}
173
+ total={0}
174
+ perPage={25}
175
+ filters={baseFilters()}
176
+ />
177
+ )
178
+
179
+ expect(screen.getByTestId('filter-slider')).toBeTruthy()
180
+ })
181
+
182
+ it('shows pagination on desktop when there are results', () => {
183
+ mockUseScreenResize.mockReturnValue({ isDesktop: true })
184
+
185
+ render(
186
+ <MyAccountListQuotes
187
+ listQuotes={{ list: [{ id: 'q-1' }] } as any}
188
+ total={30}
189
+ perPage={25}
190
+ filters={baseFilters({ page: 2 })}
191
+ />
192
+ )
193
+
194
+ expect(screen.getAllByTestId('pagination').length).toBeGreaterThan(0)
195
+ })
196
+
197
+ it('navigates with the search term on blur', async () => {
198
+ render(
199
+ <MyAccountListQuotes
200
+ listQuotes={{ list: [] } as any}
201
+ total={0}
202
+ perPage={25}
203
+ filters={baseFilters()}
204
+ />
205
+ )
206
+
207
+ const input = screen.getByPlaceholderText('Search')
208
+ fireEvent.change(input, { target: { value: 'ACME' } })
209
+ fireEvent.blur(input)
210
+ await new Promise((resolve) => setTimeout(resolve, 350))
211
+
212
+ expect(window.location.href).toBe('/pvt/account/quotes?label=ACME')
213
+ })
214
+
215
+ it('navigates without a label param when the search is cleared', async () => {
216
+ render(
217
+ <MyAccountListQuotes
218
+ listQuotes={{ list: [] } as any}
219
+ total={0}
220
+ perPage={25}
221
+ filters={baseFilters({ label: 'ACME' })}
222
+ />
223
+ )
224
+
225
+ const input = screen.getByPlaceholderText('Search')
226
+ fireEvent.change(input, { target: { value: '' } })
227
+ fireEvent.blur(input)
228
+ await new Promise((resolve) => setTimeout(resolve, 350))
229
+
230
+ expect(window.location.href).toBe('/pvt/account/quotes?')
231
+ })
232
+ })
@@ -0,0 +1,240 @@
1
+ /**
2
+ * @vitest-environment jsdom
3
+ */
4
+
5
+ import { render, screen } from '@testing-library/react'
6
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
7
+
8
+ const mockUseSession = vi.hoisted(() => vi.fn())
9
+ const mockUseScreenResize = vi.hoisted(() => vi.fn())
10
+ vi.mock('src/sdk/session', () => ({ useSession: mockUseSession }))
11
+ vi.mock('src/sdk/ui/useScreenResize', () => ({
12
+ default: mockUseScreenResize,
13
+ }))
14
+
15
+ import MyAccountListQuotesTable, {
16
+ Pagination,
17
+ } from '../../../src/components/account/quotes/MyAccountListQuotes/MyAccountListQuotesTable/MyAccountListQuotesTable'
18
+
19
+ const originalLocation = window.location
20
+
21
+ function stubLocation() {
22
+ Object.defineProperty(window, 'location', {
23
+ configurable: true,
24
+ value: { ...originalLocation, search: '', href: '' },
25
+ })
26
+ }
27
+
28
+ function baseFilters(page = 1) {
29
+ return {
30
+ page,
31
+ status: [],
32
+ createdAtFrom: '',
33
+ createdAtTo: '',
34
+ expiresAtFrom: '',
35
+ expiresAtTo: '',
36
+ label: '',
37
+ }
38
+ }
39
+
40
+ function baseQuote(overrides: Partial<Record<string, unknown>> = {}) {
41
+ return {
42
+ id: 'q-1',
43
+ status: 'Draft',
44
+ label: null,
45
+ createdAt: '2026-01-01T10:00:00Z',
46
+ expiresAt: '2099-01-01T00:00:00Z',
47
+ amount: 150.5,
48
+ createdBy: null,
49
+ ...overrides,
50
+ }
51
+ }
52
+
53
+ describe('MyAccountListQuotesTable', () => {
54
+ beforeEach(() => {
55
+ stubLocation()
56
+ mockUseSession.mockReturnValue({
57
+ locale: 'en-US',
58
+ currency: { code: 'USD' },
59
+ })
60
+ })
61
+
62
+ afterEach(() => {
63
+ vi.clearAllMocks()
64
+ Object.defineProperty(window, 'location', {
65
+ configurable: true,
66
+ value: originalLocation,
67
+ })
68
+ })
69
+
70
+ it('renders a row per quote with id, status and formatted amount', () => {
71
+ mockUseScreenResize.mockReturnValue({ isDesktop: true })
72
+
73
+ render(
74
+ <MyAccountListQuotesTable
75
+ listQuotes={{ list: [baseQuote()], paging: {} as any }}
76
+ total={1}
77
+ perPage={25}
78
+ filters={baseFilters()}
79
+ />
80
+ )
81
+
82
+ expect(screen.getByText('q-1')).toBeTruthy()
83
+ expect(screen.getByText('Draft')).toBeTruthy()
84
+ expect(screen.getByText('$150.50')).toBeTruthy()
85
+ })
86
+
87
+ it('renders the label when present and omits it when absent', () => {
88
+ mockUseScreenResize.mockReturnValue({ isDesktop: true })
89
+
90
+ const { rerender } = render(
91
+ <MyAccountListQuotesTable
92
+ listQuotes={{
93
+ list: [baseQuote({ label: 'My favorite quote' })],
94
+ paging: {} as any,
95
+ }}
96
+ total={1}
97
+ perPage={25}
98
+ filters={baseFilters()}
99
+ />
100
+ )
101
+ expect(screen.getByText('My favorite quote')).toBeTruthy()
102
+
103
+ rerender(
104
+ <MyAccountListQuotesTable
105
+ listQuotes={{ list: [baseQuote({ label: null })], paging: {} as any }}
106
+ total={1}
107
+ perPage={25}
108
+ filters={baseFilters()}
109
+ />
110
+ )
111
+ expect(screen.queryByText('My favorite quote')).toBeNull()
112
+ })
113
+
114
+ it('hides the dates column on mobile', () => {
115
+ mockUseScreenResize.mockReturnValue({ isDesktop: false })
116
+
117
+ render(
118
+ <MyAccountListQuotesTable
119
+ listQuotes={{ list: [baseQuote()], paging: {} as any }}
120
+ total={1}
121
+ perPage={25}
122
+ filters={baseFilters()}
123
+ />
124
+ )
125
+
126
+ expect(screen.queryByText('Creation date')).toBeNull()
127
+ })
128
+
129
+ it('shows "Created by" only when createdBy is set', () => {
130
+ mockUseScreenResize.mockReturnValue({ isDesktop: true })
131
+
132
+ render(
133
+ <MyAccountListQuotesTable
134
+ listQuotes={{
135
+ list: [baseQuote({ createdBy: 'Ada Lovelace' })],
136
+ paging: {} as any,
137
+ }}
138
+ total={1}
139
+ perPage={25}
140
+ filters={baseFilters()}
141
+ />
142
+ )
143
+
144
+ expect(screen.getByText('Created by')).toBeTruthy()
145
+ expect(screen.getByText('Ada Lovelace')).toBeTruthy()
146
+ })
147
+
148
+ it('shows a relative expiry hint for quotes expiring soon', () => {
149
+ mockUseScreenResize.mockReturnValue({ isDesktop: true })
150
+ const soon = new Date(Date.now() + 3 * 3_600_000).toISOString()
151
+
152
+ render(
153
+ <MyAccountListQuotesTable
154
+ listQuotes={{
155
+ list: [baseQuote({ expiresAt: soon })],
156
+ paging: {} as any,
157
+ }}
158
+ total={1}
159
+ perPage={25}
160
+ filters={baseFilters()}
161
+ />
162
+ )
163
+
164
+ expect(screen.getByText(/hours left/)).toBeTruthy()
165
+ })
166
+
167
+ it('omits the relative expiry hint for already-expired quotes', () => {
168
+ mockUseScreenResize.mockReturnValue({ isDesktop: true })
169
+ const past = new Date(Date.now() - 3_600_000).toISOString()
170
+
171
+ render(
172
+ <MyAccountListQuotesTable
173
+ listQuotes={{
174
+ list: [baseQuote({ expiresAt: past })],
175
+ paging: {} as any,
176
+ }}
177
+ total={1}
178
+ perPage={25}
179
+ filters={baseFilters()}
180
+ />
181
+ )
182
+
183
+ expect(screen.queryByText(/left/)).toBeNull()
184
+ })
185
+ })
186
+
187
+ describe('Pagination', () => {
188
+ beforeEach(() => {
189
+ stubLocation()
190
+ })
191
+
192
+ afterEach(() => {
193
+ Object.defineProperty(window, 'location', {
194
+ configurable: true,
195
+ value: originalLocation,
196
+ })
197
+ })
198
+
199
+ it('renders the current index range', () => {
200
+ render(<Pagination page={2} total={60} perPage={25} />)
201
+
202
+ expect(screen.getByText('26 — 50 of 60')).toBeTruthy()
203
+ })
204
+
205
+ it('caps the last index label at the total', () => {
206
+ render(<Pagination page={3} total={60} perPage={25} />)
207
+
208
+ expect(screen.getByText('51 — 60 of 60')).toBeTruthy()
209
+ })
210
+
211
+ it('disables the previous button on the first page', () => {
212
+ render(<Pagination page={1} total={60} perPage={25} />)
213
+
214
+ const button = screen.getByRole('button', { name: 'Previous Page' })
215
+ expect((button as HTMLButtonElement).disabled).toBe(true)
216
+ })
217
+
218
+ it('disables the next button on the last page', () => {
219
+ render(<Pagination page={3} total={60} perPage={25} />)
220
+
221
+ const button = screen.getByRole('button', { name: 'Next Page' })
222
+ expect((button as HTMLButtonElement).disabled).toBe(true)
223
+ })
224
+
225
+ it('navigates without a page param when going back to page 1', () => {
226
+ render(<Pagination page={2} total={60} perPage={25} />)
227
+
228
+ screen.getByRole('button', { name: 'Previous Page' }).click()
229
+
230
+ expect(window.location.href).toBe('/pvt/account/quotes')
231
+ })
232
+
233
+ it('navigates with the target page param otherwise', () => {
234
+ render(<Pagination page={1} total={60} perPage={25} />)
235
+
236
+ screen.getByRole('button', { name: 'Next Page' }).click()
237
+
238
+ expect(window.location.href).toBe('/pvt/account/quotes?page=2')
239
+ })
240
+ })
@@ -0,0 +1,28 @@
1
+ /**
2
+ * @vitest-environment jsdom
3
+ */
4
+
5
+ import { render, screen } from '@testing-library/react'
6
+ import { describe, expect, it } from 'vitest'
7
+
8
+ import MyAccountQuoteStatusBadge from '../../../src/components/account/quotes/MyAccountListQuotes/MyAccountListQuotesTable/MyAccountQuoteStatusBadge'
9
+
10
+ describe('MyAccountQuoteStatusBadge', () => {
11
+ it('renders the mapped label and variant for a known status', () => {
12
+ render(<MyAccountQuoteStatusBadge status="Approved" />)
13
+
14
+ const badge = screen.getByText('Approved')
15
+ expect(badge.getAttribute('data-fs-my-account-badge-variant')).toBe(
16
+ 'success'
17
+ )
18
+ })
19
+
20
+ it('falls back to the raw status and a neutral variant when unknown', () => {
21
+ render(<MyAccountQuoteStatusBadge status="SomethingElse" />)
22
+
23
+ const badge = screen.getByText('SomethingElse')
24
+ expect(badge.getAttribute('data-fs-my-account-badge-variant')).toBe(
25
+ 'neutral'
26
+ )
27
+ })
28
+ })