@faststore/core 4.6.1-dev.0 → 4.7.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 (32) hide show
  1. package/.turbo/turbo-generate.log +4 -4
  2. package/.turbo/turbo-test.log +109 -95
  3. package/@generated/cached-operations.json +1 -0
  4. package/@generated/gql.ts +6 -0
  5. package/@generated/graphql.ts +61 -0
  6. package/@generated/persisted-documents.json +1 -0
  7. package/@generated/schema.graphql +56 -0
  8. package/CHANGELOG.md +10 -0
  9. package/cms/faststore/my-account/components/cms_component__accountlistcards.jsonc +90 -0
  10. package/cms/faststore/my-account/pages/cms_content_type__myaccountcards.jsonc +18 -0
  11. package/package.json +6 -6
  12. package/public/icons.svg +1 -1
  13. package/src/components/account/Layout/Layout.tsx +4 -0
  14. package/src/components/account/cards/MyAccountListCards/MyAccountListCards.tsx +409 -0
  15. package/src/components/account/cards/MyAccountListCards/index.ts +11 -0
  16. package/src/components/account/cards/MyAccountListCards/myAccountListCardsLabels.ts +44 -0
  17. package/src/components/account/cards/MyAccountListCards/styles.module.scss +126 -0
  18. package/src/components/cms/account/Components.ts +2 -0
  19. package/src/components/sections/Account/AccountListCards.tsx +69 -0
  20. package/src/pages/pvt/account/cards/index.tsx +244 -0
  21. package/src/sdk/account/accountPageContext.ts +16 -0
  22. package/src/sdk/account/getB2BSessionClaims.ts +42 -0
  23. package/src/sdk/account/getMyAccountRoutes.ts +7 -0
  24. package/src/server/cms/myAccountDefaultSections.ts +2 -0
  25. package/src/utils/getCookie.ts +8 -1
  26. package/test/components/account/cards/MyAccountListCards.browser.test.tsx +301 -0
  27. package/test/components/sections/Account/AccountListCards.browser.test.tsx +57 -0
  28. package/test/pages/pvt/account/cards/index.test.ts +202 -0
  29. package/test/sdk/account/getB2BSessionClaims.test.ts +48 -0
  30. package/test/sdk/account/getMyAccountRoutes.test.ts +10 -0
  31. package/test/server/index.test.ts +2 -0
  32. package/test/utils/getCookie.test.ts +23 -0
@@ -78,6 +78,16 @@ type BusinessHour {
78
78
  closingTime: String
79
79
  }
80
80
 
81
+ """
82
+ Whether a saved card is personally owned by the buyer or shared by their
83
+ Organization/Contract. Resolved server-side by the Saved-cards service from
84
+ the buyer's `useAdHocCard` role — never inferred client-side.
85
+ """
86
+ enum CardOrigin {
87
+ personal
88
+ shared
89
+ }
90
+
81
91
  """Commercial Authorization dimension status."""
82
92
  type CommercialAuthorizationDimensionStatus {
83
93
  """Dimension status ID."""
@@ -928,6 +938,17 @@ type Query {
928
938
  """Identifier for the order."""
929
939
  orderId: String!
930
940
  ): UserOrderResult @auth @cacheControl(scope: "private", sMaxAge: 300, staleWhileRevalidate: 3600)
941
+ """Returns the list of saved credit cards for the current user."""
942
+ listCreditCards: SavedCardListResult @auth @cacheControl(scope: "private", sMaxAge: 300, staleWhileRevalidate: 3600)
943
+ """
944
+ Whether the current user holds the ad-hoc card platform permission, which
945
+ gates personal card management for Unit/Contract-affiliated buyers.
946
+
947
+ Resolved from the user's roles by License Manager — it is not a session token
948
+ claim. Fails open (`true`) when the permission service is unreachable, so an
949
+ outage never locks buyers out of their own cards.
950
+ """
951
+ hasAdHocCardAccess: Boolean! @auth @cacheControl(scope: "private", sMaxAge: 60, staleWhileRevalidate: 300)
931
952
  """Returns the list of Orders that the User can view."""
932
953
  listUserOrders(
933
954
  """Page number of the list of orders."""
@@ -1025,6 +1046,41 @@ type SKUSpecificationValue {
1025
1046
  originalName: String
1026
1047
  }
1027
1048
 
1049
+ """A saved payment card returned by the Saved-cards service."""
1050
+ type SavedCard {
1051
+ """Account identifier that owns the card."""
1052
+ accountId: String
1053
+ """First digits of the card (BIN)."""
1054
+ bin: String
1055
+ """Masked card number, per PCI display rules."""
1056
+ cardNumber: String
1057
+ """
1058
+ Buyer- or admin-assigned nickname for the card (e.g. "Team lunch card"), when
1059
+ the Saved-cards service has one on file. Absent for most cards — callers
1060
+ should fall back to `paymentSystemName` for display.
1061
+ """
1062
+ cardLabel: String
1063
+ """Numeric payment system identifier."""
1064
+ paymentSystem: String
1065
+ """Human-readable payment system name (e.g. Visa, Mastercard)."""
1066
+ paymentSystemName: String
1067
+ """Whether this card is the account default."""
1068
+ isDefault: Boolean
1069
+ """Whether this card is active."""
1070
+ isActive: Boolean
1071
+ """
1072
+ Whether this card is personally owned by the buyer or shared by their
1073
+ Organization/Contract.
1074
+ """
1075
+ origin: CardOrigin!
1076
+ }
1077
+
1078
+ """Result of listing the current user's saved credit cards."""
1079
+ type SavedCardListResult {
1080
+ """The list of saved credit cards."""
1081
+ list: [SavedCard!]
1082
+ }
1083
+
1028
1084
  """Search result."""
1029
1085
  type SearchMetadata {
1030
1086
  """Indicates if the search term was misspelled."""
package/CHANGELOG.md CHANGED
@@ -3,6 +3,16 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ # [4.7.0-dev.1](https://github.com/vtex/faststore/compare/v4.7.0-dev.0...v4.7.0-dev.1) (2026-08-24)
7
+
8
+ **Note:** Version bump only for package @faststore/core
9
+
10
+ # [4.7.0-dev.0](https://github.com/vtex/faststore/compare/v4.6.1-dev.0...v4.7.0-dev.0) (2026-08-24)
11
+
12
+ ### Features
13
+
14
+ - My Account Cards — Personal + Shared listing ([#3443](https://github.com/vtex/faststore/issues/3443)) ([6dded69](https://github.com/vtex/faststore/commit/6dded6905ed5bbf5c7fe71cfc106abf95c816888)), closes [vtex/faststore-dx-spec-kit#24](https://github.com/vtex/faststore-dx-spec-kit/issues/24)
15
+
6
16
  ## [4.6.1-dev.0](https://github.com/vtex/faststore/compare/v4.6.0...v4.6.1-dev.0) (2026-08-18)
7
17
 
8
18
  **Note:** Version bump only for package @faststore/core
@@ -0,0 +1,90 @@
1
+ {
2
+ "$extends": ["#/$defs/base-component"],
3
+ "$componentKey": "AccountListCards",
4
+ "$componentTitle": "Account List Cards",
5
+ "title": "Account List Cards",
6
+ "description": "Cards list section for My Account (Personal + Shared)",
7
+ "type": "object",
8
+ "properties": {
9
+ "pageTitle": {
10
+ "type": "string",
11
+ "title": "Page Title",
12
+ "default": "Cards"
13
+ },
14
+ "personalTabLabel": {
15
+ "type": "string",
16
+ "title": "Personal Tab Label",
17
+ "default": "Personal"
18
+ },
19
+ "sharedTabLabel": {
20
+ "type": "string",
21
+ "title": "Shared Tab Label",
22
+ "default": "Shared"
23
+ },
24
+ "searchPlaceholder": {
25
+ "type": "string",
26
+ "title": "Search Placeholder",
27
+ "default": "Search"
28
+ },
29
+ "paginationOfLabel": {
30
+ "type": "string",
31
+ "title": "Pagination Of Label",
32
+ "default": "of"
33
+ },
34
+ "previousPageLabel": {
35
+ "type": "string",
36
+ "title": "Previous Page Label",
37
+ "default": "Previous Page"
38
+ },
39
+ "nextPageLabel": {
40
+ "type": "string",
41
+ "title": "Next Page Label",
42
+ "default": "Next Page"
43
+ },
44
+ "noPersonalCardsLabel": {
45
+ "type": "string",
46
+ "title": "No Personal Cards Label",
47
+ "default": "You don't have any cards"
48
+ },
49
+ "noSharedCardsLabel": {
50
+ "type": "string",
51
+ "title": "No Shared Cards Label",
52
+ "default": "Your organization has no shared cards"
53
+ },
54
+ "noResultsLabel": {
55
+ "type": "string",
56
+ "title": "No Results Label",
57
+ "default": "No results found"
58
+ },
59
+ "errorTitleLabel": {
60
+ "type": "string",
61
+ "title": "Error Title Label",
62
+ "default": "We couldn't load your cards"
63
+ },
64
+ "tryAgainLabel": {
65
+ "type": "string",
66
+ "title": "Try Again Label",
67
+ "default": "Try again"
68
+ },
69
+ "sharedCardsTooltipLabel": {
70
+ "type": "string",
71
+ "title": "Shared Cards Tooltip Label",
72
+ "default": "These cards are shared by the organization you belong to. Changes can only be made by the administrator."
73
+ },
74
+ "defaultCardLabel": {
75
+ "type": "string",
76
+ "title": "Default Card Label",
77
+ "default": "Default"
78
+ },
79
+ "genericCardLabel": {
80
+ "type": "string",
81
+ "title": "Generic Card Label",
82
+ "description": "Shown in place of the brand name when the service doesn't return one.",
83
+ "default": "Card"
84
+ }
85
+ },
86
+ "readOnly": false,
87
+ "writeOnly": false,
88
+ "deprecated": false,
89
+ "$abstract": false
90
+ }
@@ -0,0 +1,18 @@
1
+ {
2
+ "myAccountCards": {
3
+ "$extends": ["#/components/base-page-template"],
4
+ "$singleton": true,
5
+ "type": "object",
6
+ "title": "My Account — Cards",
7
+ "properties": {
8
+ "sections": {
9
+ "$ref": "#/$defs/$ALLOW_ALL_COMPONENTS"
10
+ }
11
+ },
12
+ "readOnly": false,
13
+ "writeOnly": false,
14
+ "deprecated": false,
15
+ "$abstract": false,
16
+ "identifierKeys": []
17
+ }
18
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@faststore/core",
3
- "version": "4.6.1-dev.0",
3
+ "version": "4.7.0-dev.1",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -71,11 +71,11 @@
71
71
  "style-loader": "^3.3.1",
72
72
  "swr": "^2.2.5",
73
73
  "use-sync-external-store": "^1.6.0",
74
- "@faststore/api": "4.6.1-dev.0",
75
- "@faststore/diagnostics": "4.6.1-dev.0",
76
- "@faststore/lighthouse": "4.6.1-dev.0",
77
- "@faststore/sdk": "4.6.1-dev.0",
78
- "@faststore/ui": "4.6.1-dev.0"
74
+ "@faststore/api": "4.7.0-dev.1",
75
+ "@faststore/diagnostics": "4.7.0-dev.1",
76
+ "@faststore/lighthouse": "4.7.0-dev.1",
77
+ "@faststore/sdk": "4.7.0-dev.1",
78
+ "@faststore/ui": "4.7.0-dev.1"
79
79
  },
80
80
  "devDependencies": {
81
81
  "@cypress/code-coverage": "^3.12.1",
package/public/icons.svg CHANGED
@@ -85,7 +85,7 @@
85
85
 
86
86
  <symbol id="House" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"></rect><path d="M151.99414,207.99263v-48.001a8,8,0,0,0-8-8h-32a8,8,0,0,0-8,8v48.001a8,8,0,0,1-7.999,8l-47.99414.00632a8,8,0,0,1-8.001-8v-92.4604a8,8,0,0,1,2.61811-5.91906l79.9945-72.73477a8,8,0,0,1,10.76339-.00036l80.0055,72.73509A8,8,0,0,1,216,115.53887V207.999a8,8,0,0,1-8.001,8l-48.00586-.00632A8,8,0,0,1,151.99414,207.99263Z" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path></symbol>
87
87
 
88
- <symbol id="Info" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none"><path fill-rule="evenodd" clip-rule="evenodd" d="M12 3.75a8.25 8.25 0 1 0 0 16.5 8.25 8.25 0 0 0 0-16.5ZM2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12Zm8.25-.75a.75.75 0 0 1 .75-.75H12a.75.75 0 0 1 .75.75v4.5a.75.75 0 0 1 0 1.5H12a.75.75 0 0 1-.75-.75V12a.75.75 0 0 1-.75-.75Zm2.438-3.375a1.125 1.125 0 1 1-2.25 0 1.125 1.125 0 0 1 2.25 0Z" fill="currentColor"/></symbol>
88
+ <symbol id="Info" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"><path fill-rule="evenodd" clip-rule="evenodd" d="M12 3.75a8.25 8.25 0 1 0 0 16.5 8.25 8.25 0 0 0 0-16.5ZM2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12Zm8.25-.75a.75.75 0 0 1 .75-.75H12a.75.75 0 0 1 .75.75v4.5a.75.75 0 0 1 0 1.5H12a.75.75 0 0 1-.75-.75V12a.75.75 0 0 1-.75-.75Zm2.438-3.375a1.125 1.125 0 1 1-2.25 0 1.125 1.125 0 0 1 2.25 0Z" fill="currentColor"/></symbol>
89
89
 
90
90
  <symbol id="List" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"></rect><line x1="40" y1="128" x2="216" y2="128" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></line><line x1="40" y1="64" x2="216" y2="64" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></line><line x1="40" y1="192" x2="216" y2="192" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></line></symbol>
91
91
 
@@ -35,6 +35,10 @@ const Layout = ({
35
35
  })
36
36
  : menuRoutes
37
37
 
38
+ // Cards is never route-gated here: `useAdHocCard` only affects the
39
+ // Personal-tab rendering inside the page itself (spec
40
+ // my-account-cards-gating-plan), so it never joins
41
+ // ROUTES_ONLY_FOR_B2B_MEMBERS — the sidebar entry always stays visible.
38
42
  const routes = (
39
43
  isRepresentative
40
44
  ? menuItems
@@ -0,0 +1,409 @@
1
+ import { useRouter } from 'next/router'
2
+ import type { KeyboardEvent, Ref } from 'react'
3
+ import { useMemo, useRef, useState } from 'react'
4
+
5
+ import {
6
+ Button,
7
+ EmptyState,
8
+ Icon,
9
+ IconButton,
10
+ SearchInputField,
11
+ Tooltip,
12
+ } from '@faststore/ui'
13
+
14
+ import AccountHeader from '../../components/Header'
15
+ import type { MyAccountListCardsSectionLabels } from './myAccountListCardsLabels'
16
+ import { resolveMyAccountListCardsLabels } from './myAccountListCardsLabels'
17
+ import styles from './styles.module.scss'
18
+
19
+ export type SavedCardItem = {
20
+ accountId?: string | null
21
+ bin?: string | null
22
+ cardNumber?: string | null
23
+ cardLabel?: string | null
24
+ paymentSystem?: string | null
25
+ paymentSystemName?: string | null
26
+ isDefault?: boolean | null
27
+ isActive?: boolean | null
28
+ }
29
+
30
+ export type CardsTabVariant = 'personal' | 'shared'
31
+
32
+ export type MyAccountListCardsProps = {
33
+ personalCards: SavedCardItem[]
34
+ sharedCards: SavedCardItem[]
35
+ /** Whether the buyer has an Organizational Unit association — gates the Shared tab (FR-5). */
36
+ hasOrgAssociation: boolean
37
+ /**
38
+ * Whether the buyer can view/manage personal cards — gates the Personal tab
39
+ * (spec my-account-cards-gating-plan). Independent of route access, which
40
+ * is never gated.
41
+ */
42
+ canViewPersonalCards: boolean
43
+ /** Whether either list failed to load — renders the error state instead of the grid (US-5). */
44
+ hasError?: boolean
45
+ labels?: MyAccountListCardsSectionLabels
46
+ }
47
+
48
+ const PAGE_SIZE = 25
49
+ const PERSONAL_TAB_ID = 'fs-list-cards-tab-personal'
50
+ const SHARED_TAB_ID = 'fs-list-cards-tab-shared'
51
+ const PANEL_ID = 'fs-list-cards-panel'
52
+
53
+ function cardKey(card: SavedCardItem, index: number) {
54
+ return card.accountId ? `${card.accountId}-${index}` : `card-${index}`
55
+ }
56
+
57
+ function matchesSearch(card: SavedCardItem, search: string) {
58
+ if (!search) return true
59
+
60
+ const needle = search.trim().toLowerCase()
61
+
62
+ return (
63
+ card.cardLabel?.toLowerCase().includes(needle) ||
64
+ card.paymentSystemName?.toLowerCase().includes(needle) ||
65
+ card.cardNumber?.toLowerCase().includes(needle) ||
66
+ false
67
+ )
68
+ }
69
+
70
+ // The buyer's own nickname for the card takes precedence over the brand name
71
+ // — it's the whole point of setting one. Falls back to the brand name, then
72
+ // a generic label when the service has neither on file.
73
+ function getCardLabel(card: SavedCardItem, genericLabel: string) {
74
+ return card.cardLabel ?? card.paymentSystemName ?? genericLabel
75
+ }
76
+
77
+ // Maps the payment system name from the Saved-cards service to one of the
78
+ // brand icons available in this app's icon sprite (public/icons.svg). Falls
79
+ // back to a generic "Bag" icon for brands without a dedicated symbol.
80
+ const BRAND_ICON_BY_NAME: Record<string, string> = {
81
+ visa: 'Visa',
82
+ mastercard: 'Mastercard',
83
+ amex: 'Amex',
84
+ 'american express': 'Amex',
85
+ elo: 'EloCard',
86
+ diners: 'Diners',
87
+ 'diners club': 'Diners',
88
+ hipercard: 'Hipercard',
89
+ }
90
+
91
+ function getBrandIconName(card: SavedCardItem) {
92
+ const name = card.paymentSystemName?.toLowerCase().trim() ?? ''
93
+ return BRAND_ICON_BY_NAME[name] ?? 'Bag'
94
+ }
95
+
96
+ type TabsHeaderProps = {
97
+ sharedOnly: boolean
98
+ isShared: boolean
99
+ hasOrgAssociation: boolean
100
+ labels: Required<MyAccountListCardsSectionLabels>
101
+ personalTabRef: Ref<HTMLButtonElement>
102
+ sharedTabRef: Ref<HTMLButtonElement>
103
+ onTabChange: (tab: CardsTabVariant) => void
104
+ onTabKeyDown: (event: KeyboardEvent<HTMLButtonElement>) => void
105
+ }
106
+
107
+ // Extracted so the tab-vs-shared-only branching doesn't add to the main
108
+ // component's cognitive complexity budget.
109
+ function TabsHeader({
110
+ sharedOnly,
111
+ isShared,
112
+ hasOrgAssociation,
113
+ labels,
114
+ personalTabRef,
115
+ sharedTabRef,
116
+ onTabChange,
117
+ onTabKeyDown,
118
+ }: Readonly<TabsHeaderProps>) {
119
+ if (sharedOnly) {
120
+ return (
121
+ <div data-fs-list-cards-tabs-shared-only>
122
+ <span data-fs-list-cards-tab-label>{labels.sharedTabLabel}</span>
123
+ <Tooltip
124
+ content={labels.sharedCardsTooltipLabel}
125
+ placement="bottom-start"
126
+ >
127
+ <Icon name="Info" width={24} height={24} />
128
+ </Tooltip>
129
+ </div>
130
+ )
131
+ }
132
+
133
+ return (
134
+ <div data-fs-list-cards-tabs-row>
135
+ <div data-fs-list-cards-tabs role="tablist">
136
+ <button
137
+ ref={personalTabRef}
138
+ id={PERSONAL_TAB_ID}
139
+ type="button"
140
+ role="tab"
141
+ aria-selected={!isShared}
142
+ aria-controls={PANEL_ID}
143
+ tabIndex={isShared ? -1 : 0}
144
+ data-fs-list-cards-tab
145
+ data-fs-list-cards-tab-active={!isShared}
146
+ onClick={() => onTabChange('personal')}
147
+ onKeyDown={onTabKeyDown}
148
+ >
149
+ {labels.personalTabLabel}
150
+ </button>
151
+ {hasOrgAssociation && (
152
+ <button
153
+ ref={sharedTabRef}
154
+ id={SHARED_TAB_ID}
155
+ type="button"
156
+ role="tab"
157
+ aria-selected={isShared}
158
+ aria-controls={PANEL_ID}
159
+ tabIndex={isShared ? 0 : -1}
160
+ data-fs-list-cards-tab
161
+ data-fs-list-cards-tab-active={isShared}
162
+ onClick={() => onTabChange('shared')}
163
+ onKeyDown={onTabKeyDown}
164
+ >
165
+ {labels.sharedTabLabel}
166
+ </button>
167
+ )}
168
+ </div>
169
+ {isShared && hasOrgAssociation && (
170
+ <Tooltip
171
+ content={labels.sharedCardsTooltipLabel}
172
+ placement="bottom-start"
173
+ >
174
+ <Icon name="Info" width={24} height={24} />
175
+ </Tooltip>
176
+ )}
177
+ </div>
178
+ )
179
+ }
180
+
181
+ type CardsGridProps = {
182
+ cards: SavedCardItem[]
183
+ labels: Required<MyAccountListCardsSectionLabels>
184
+ }
185
+
186
+ // Extracted so the .map callback's branching doesn't nest inside the main
187
+ // component's cognitive complexity budget.
188
+ function CardsGrid({ cards, labels }: Readonly<CardsGridProps>) {
189
+ return (
190
+ <div data-fs-list-cards-grid>
191
+ {cards.map((card, index) => (
192
+ <div key={cardKey(card, index)} data-fs-list-cards-item>
193
+ <div data-fs-list-cards-item-brand>
194
+ <Icon name={getBrandIconName(card)} width={32} height={32} />
195
+ </div>
196
+ <div data-fs-list-cards-item-label>
197
+ {getCardLabel(card, labels.genericCardLabel)}
198
+ {card.isDefault && (
199
+ <span data-fs-list-cards-item-default-badge>
200
+ {labels.defaultCardLabel}
201
+ </span>
202
+ )}
203
+ </div>
204
+ <div data-fs-list-cards-item-number>{card.cardNumber}</div>
205
+ </div>
206
+ ))}
207
+ </div>
208
+ )
209
+ }
210
+
211
+ type PanelBodyProps = {
212
+ hasError: boolean
213
+ total: number
214
+ search: string
215
+ noCardsLabel: string
216
+ pageCards: SavedCardItem[]
217
+ labels: Required<MyAccountListCardsSectionLabels>
218
+ onRetry: () => void
219
+ }
220
+
221
+ // Extracted so the error/empty/grid three-way branching doesn't nest inside
222
+ // the main component's cognitive complexity budget.
223
+ function PanelBody({
224
+ hasError,
225
+ total,
226
+ search,
227
+ noCardsLabel,
228
+ pageCards,
229
+ labels,
230
+ onRetry,
231
+ }: Readonly<PanelBodyProps>) {
232
+ if (hasError) {
233
+ return (
234
+ <EmptyState
235
+ testId="fs-list-cards-error"
236
+ titleIcon={<Icon name="Warning" width={56} height={56} weight="thin" />}
237
+ title={labels.errorTitleLabel}
238
+ bkgColor="light"
239
+ >
240
+ <Button data-fs-list-cards-retry variant="secondary" onClick={onRetry}>
241
+ {labels.tryAgainLabel}
242
+ </Button>
243
+ </EmptyState>
244
+ )
245
+ }
246
+
247
+ if (total === 0) {
248
+ return (
249
+ <EmptyState
250
+ testId="fs-list-cards-empty"
251
+ titleIcon={
252
+ <Icon
253
+ name={search ? 'MagnifyingGlass' : 'Bag2'}
254
+ width={56}
255
+ height={56}
256
+ weight="thin"
257
+ />
258
+ }
259
+ title={search ? labels.noResultsLabel : noCardsLabel}
260
+ bkgColor="light"
261
+ />
262
+ )
263
+ }
264
+
265
+ return <CardsGrid cards={pageCards} labels={labels} />
266
+ }
267
+
268
+ export default function MyAccountListCards({
269
+ personalCards,
270
+ sharedCards,
271
+ hasOrgAssociation,
272
+ canViewPersonalCards,
273
+ hasError = false,
274
+ labels: labelsProp,
275
+ }: Readonly<MyAccountListCardsProps>) {
276
+ const router = useRouter()
277
+ const labels = resolveMyAccountListCardsLabels(labelsProp)
278
+
279
+ // Confirmed row (spec my-account-cards-gating-plan, O1): when Personal isn't
280
+ // available but Shared is, no tab chrome renders — the shared list is shown
281
+ // directly. The other two combinations (Personal only; neither) are
282
+ // unconfirmed and keep the pre-existing tab-bar rendering unchanged.
283
+ const sharedOnly = !canViewPersonalCards && hasOrgAssociation
284
+
285
+ const [activeTab, setActiveTab] = useState<CardsTabVariant>(
286
+ sharedOnly ? 'shared' : 'personal'
287
+ )
288
+ const [search, setSearch] = useState('')
289
+ const [page, setPage] = useState(1)
290
+ const personalTabRef = useRef<HTMLButtonElement>(null)
291
+ const sharedTabRef = useRef<HTMLButtonElement>(null)
292
+
293
+ const isShared = activeTab === 'shared'
294
+ const allCards = isShared ? sharedCards : personalCards
295
+
296
+ const filteredCards = useMemo(
297
+ () => allCards.filter((card) => matchesSearch(card, search)),
298
+ [allCards, search]
299
+ )
300
+
301
+ const total = filteredCards.length
302
+ const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
303
+ const currentPage = Math.min(page, totalPages)
304
+ const pageCards = filteredCards.slice(
305
+ (currentPage - 1) * PAGE_SIZE,
306
+ currentPage * PAGE_SIZE
307
+ )
308
+
309
+ const firstIndexLabel = total === 0 ? 0 : (currentPage - 1) * PAGE_SIZE + 1
310
+ const lastIndexLabel = Math.min(currentPage * PAGE_SIZE, total)
311
+
312
+ const handleTabChange = (tab: CardsTabVariant) => {
313
+ setActiveTab(tab)
314
+ setSearch('')
315
+ setPage(1)
316
+ }
317
+
318
+ // Arrow-key navigation between tabs, per the WAI-ARIA tabs pattern. Only
319
+ // meaningful when both tabs are rendered (hasOrgAssociation); a single tab
320
+ // has nothing to move to.
321
+ const handleTabKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
322
+ if (!hasOrgAssociation) return
323
+ if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return
324
+
325
+ event.preventDefault()
326
+ const nextTab: CardsTabVariant = isShared ? 'personal' : 'shared'
327
+ handleTabChange(nextTab)
328
+ ;(nextTab === 'personal' ? personalTabRef : sharedTabRef).current?.focus()
329
+ }
330
+
331
+ const noCardsLabel = isShared
332
+ ? labels.noSharedCardsLabel
333
+ : labels.noPersonalCardsLabel
334
+
335
+ const activeTabId = isShared ? SHARED_TAB_ID : PERSONAL_TAB_ID
336
+
337
+ return (
338
+ <div className={styles.page} data-fs-list-cards>
339
+ <AccountHeader pageTitle={labels.pageTitle} />
340
+
341
+ <TabsHeader
342
+ sharedOnly={sharedOnly}
343
+ isShared={isShared}
344
+ hasOrgAssociation={hasOrgAssociation}
345
+ labels={labels}
346
+ personalTabRef={personalTabRef}
347
+ sharedTabRef={sharedTabRef}
348
+ onTabChange={handleTabChange}
349
+ onTabKeyDown={handleTabKeyDown}
350
+ />
351
+
352
+ <div
353
+ role={sharedOnly ? undefined : 'tabpanel'}
354
+ id={sharedOnly ? undefined : PANEL_ID}
355
+ aria-labelledby={sharedOnly ? undefined : activeTabId}
356
+ >
357
+ {!hasError && (
358
+ <div data-fs-list-cards-controls>
359
+ <div data-fs-list-cards-search-wrapper>
360
+ <SearchInputField
361
+ data-fs-list-cards-search
362
+ placeholder={labels.searchPlaceholder}
363
+ value={search}
364
+ onChange={(event) => {
365
+ setSearch(event.target.value)
366
+ setPage(1)
367
+ }}
368
+ onSubmit={() => {
369
+ setPage(1)
370
+ }}
371
+ />
372
+ </div>
373
+ {total > 0 && (
374
+ <div data-fs-list-cards-pagination>
375
+ <p>{`${firstIndexLabel} — ${lastIndexLabel} ${labels.paginationOfLabel} ${total}`}</p>
376
+ <IconButton
377
+ size="small"
378
+ variant="tertiary"
379
+ disabled={currentPage === 1}
380
+ onClick={() => setPage((current) => current - 1)}
381
+ icon={<Icon name="CaretLeft" />}
382
+ aria-label={labels.previousPageLabel}
383
+ />
384
+ <IconButton
385
+ size="small"
386
+ variant="tertiary"
387
+ disabled={currentPage === totalPages}
388
+ onClick={() => setPage((current) => current + 1)}
389
+ icon={<Icon name="CaretRight" />}
390
+ aria-label={labels.nextPageLabel}
391
+ />
392
+ </div>
393
+ )}
394
+ </div>
395
+ )}
396
+
397
+ <PanelBody
398
+ hasError={hasError}
399
+ total={total}
400
+ search={search}
401
+ noCardsLabel={noCardsLabel}
402
+ pageCards={pageCards}
403
+ labels={labels}
404
+ onRetry={() => router.reload()}
405
+ />
406
+ </div>
407
+ </div>
408
+ )
409
+ }
@@ -0,0 +1,11 @@
1
+ export { default as MyAccountListCards } from './MyAccountListCards'
2
+ export type {
3
+ MyAccountListCardsProps,
4
+ SavedCardItem,
5
+ CardsTabVariant,
6
+ } from './MyAccountListCards'
7
+ export {
8
+ defaultMyAccountListCardsLabels,
9
+ resolveMyAccountListCardsLabels,
10
+ } from './myAccountListCardsLabels'
11
+ export type { MyAccountListCardsSectionLabels } from './myAccountListCardsLabels'