@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.
- package/.turbo/turbo-generate.log +4 -4
- package/.turbo/turbo-test.log +109 -95
- package/@generated/cached-operations.json +1 -0
- package/@generated/gql.ts +6 -0
- package/@generated/graphql.ts +61 -0
- package/@generated/persisted-documents.json +1 -0
- package/@generated/schema.graphql +56 -0
- package/CHANGELOG.md +10 -0
- package/cms/faststore/my-account/components/cms_component__accountlistcards.jsonc +90 -0
- package/cms/faststore/my-account/pages/cms_content_type__myaccountcards.jsonc +18 -0
- package/package.json +6 -6
- package/public/icons.svg +1 -1
- package/src/components/account/Layout/Layout.tsx +4 -0
- package/src/components/account/cards/MyAccountListCards/MyAccountListCards.tsx +409 -0
- package/src/components/account/cards/MyAccountListCards/index.ts +11 -0
- package/src/components/account/cards/MyAccountListCards/myAccountListCardsLabels.ts +44 -0
- package/src/components/account/cards/MyAccountListCards/styles.module.scss +126 -0
- package/src/components/cms/account/Components.ts +2 -0
- package/src/components/sections/Account/AccountListCards.tsx +69 -0
- package/src/pages/pvt/account/cards/index.tsx +244 -0
- package/src/sdk/account/accountPageContext.ts +16 -0
- package/src/sdk/account/getB2BSessionClaims.ts +42 -0
- package/src/sdk/account/getMyAccountRoutes.ts +7 -0
- package/src/server/cms/myAccountDefaultSections.ts +2 -0
- package/src/utils/getCookie.ts +8 -1
- package/test/components/account/cards/MyAccountListCards.browser.test.tsx +301 -0
- package/test/components/sections/Account/AccountListCards.browser.test.tsx +57 -0
- package/test/pages/pvt/account/cards/index.test.ts +202 -0
- package/test/sdk/account/getB2BSessionClaims.test.ts +48 -0
- package/test/sdk/account/getMyAccountRoutes.test.ts +10 -0
- package/test/server/index.test.ts +2 -0
- 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.
|
|
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.
|
|
75
|
-
"@faststore/diagnostics": "4.
|
|
76
|
-
"@faststore/lighthouse": "4.
|
|
77
|
-
"@faststore/sdk": "4.
|
|
78
|
-
"@faststore/ui": "4.
|
|
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'
|