@faststore/core 4.6.0 → 4.7.0-dev.0

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 +105 -90
  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
@@ -0,0 +1,44 @@
1
+ export type MyAccountListCardsSectionLabels = {
2
+ pageTitle?: string
3
+ personalTabLabel?: string
4
+ sharedTabLabel?: string
5
+ searchPlaceholder?: string
6
+ paginationOfLabel?: string
7
+ previousPageLabel?: string
8
+ nextPageLabel?: string
9
+ noPersonalCardsLabel?: string
10
+ noSharedCardsLabel?: string
11
+ noResultsLabel?: string
12
+ errorTitleLabel?: string
13
+ tryAgainLabel?: string
14
+ sharedCardsTooltipLabel?: string
15
+ defaultCardLabel?: string
16
+ /** Shown in place of the brand name when the service doesn't return one. */
17
+ genericCardLabel?: string
18
+ }
19
+
20
+ export const defaultMyAccountListCardsLabels: Required<MyAccountListCardsSectionLabels> =
21
+ {
22
+ pageTitle: 'Cards',
23
+ personalTabLabel: 'Personal',
24
+ sharedTabLabel: 'Shared',
25
+ searchPlaceholder: 'Search',
26
+ paginationOfLabel: 'of',
27
+ previousPageLabel: 'Previous Page',
28
+ nextPageLabel: 'Next Page',
29
+ noPersonalCardsLabel: "You don't have any cards",
30
+ noSharedCardsLabel: 'Your organization has no shared cards',
31
+ noResultsLabel: 'No results found',
32
+ errorTitleLabel: "We couldn't load your cards",
33
+ tryAgainLabel: 'Try again',
34
+ sharedCardsTooltipLabel:
35
+ 'These cards are shared by the organization you belong to. Changes can only be made by the administrator.',
36
+ defaultCardLabel: 'Default',
37
+ genericCardLabel: 'Card',
38
+ }
39
+
40
+ export function resolveMyAccountListCardsLabels(
41
+ labels?: MyAccountListCardsSectionLabels
42
+ ): Required<MyAccountListCardsSectionLabels> {
43
+ return { ...defaultMyAccountListCardsLabels, ...labels }
44
+ }
@@ -0,0 +1,126 @@
1
+ @use "sass:meta";
2
+
3
+ .page {
4
+ @include meta.load-css("~@faststore/ui/src/components/organisms/EmptyState/styles.scss");
5
+
6
+ display: flex;
7
+ flex-direction: column;
8
+ gap: var(--fs-spacing-4, 1.5rem);
9
+
10
+ [data-fs-list-cards-tabs-row] {
11
+ display: flex;
12
+ gap: var(--fs-spacing-0-5, 0.25rem);
13
+ align-items: center;
14
+ border-bottom: 1px solid var(--fs-border-color, #d9d9d9);
15
+ }
16
+
17
+ [data-fs-list-cards-tabs] {
18
+ display: flex;
19
+ gap: var(--fs-spacing-4, 1.5rem);
20
+ }
21
+
22
+ [data-fs-list-cards-tab] {
23
+ display: inline-flex;
24
+ gap: var(--fs-spacing-0-5, 0.25rem);
25
+ align-items: center;
26
+ padding: var(--fs-spacing-1, 0.5rem) 0;
27
+ font: inherit;
28
+ color: var(--fs-text-secondary, #6f6f6f);
29
+ cursor: pointer;
30
+ background: none;
31
+ border: none;
32
+ }
33
+
34
+ [data-fs-list-cards-tab-active="true"] {
35
+ font-weight: 600;
36
+ color: var(--fs-text-link, #0632c1);
37
+ border-bottom: 2px solid var(--fs-text-link, #0632c1);
38
+ }
39
+
40
+ // Shared-only rendering (spec my-account-cards-gating-plan): no tab bar to
41
+ // switch between, just a heading-style label + info tooltip above the grid.
42
+ [data-fs-list-cards-tabs-shared-only] {
43
+ display: flex;
44
+ gap: var(--fs-spacing-0-5, 0.25rem);
45
+ align-items: center;
46
+ padding-bottom: var(--fs-spacing-1, 0.5rem);
47
+ border-bottom: 1px solid var(--fs-border-color, #d9d9d9);
48
+ }
49
+
50
+ [data-fs-list-cards-tab-label] {
51
+ font-weight: 600;
52
+ color: var(--fs-text-link, #0632c1);
53
+ }
54
+
55
+ [data-fs-list-cards-controls] {
56
+ display: flex;
57
+ flex-wrap: wrap;
58
+ gap: var(--fs-spacing-2, 1rem);
59
+ align-items: center;
60
+ justify-content: space-between;
61
+ }
62
+
63
+ [data-fs-list-cards-search-wrapper] {
64
+ flex: 1;
65
+ max-width: 25rem;
66
+ }
67
+
68
+ [data-fs-list-cards-pagination] {
69
+ display: flex;
70
+ gap: var(--fs-spacing-1, 0.5rem);
71
+ align-items: center;
72
+ white-space: nowrap;
73
+ }
74
+
75
+ [data-fs-list-cards-grid] {
76
+ display: grid;
77
+ grid-template-columns: repeat(auto-fill, 10rem);
78
+ gap: 1.25rem;
79
+
80
+ @media (max-width: 768px) {
81
+ grid-template-columns: repeat(2, 1fr);
82
+ }
83
+ }
84
+
85
+ [data-fs-list-cards-item] {
86
+ display: flex;
87
+ flex-direction: column;
88
+ gap: var(--fs-spacing-1, 0.5rem);
89
+ padding: var(--fs-spacing-2, 1rem);
90
+ border: 1px solid var(--fs-border-color, #d9d9d9);
91
+ border-radius: var(--fs-border-radius, 0.25rem);
92
+ }
93
+
94
+ [data-fs-list-cards-item-brand] {
95
+ font-size: 0.875rem;
96
+ font-weight: 700;
97
+ }
98
+
99
+ [data-fs-list-cards-item-label] {
100
+ display: flex;
101
+ gap: var(--fs-spacing-0-5, 0.25rem);
102
+ align-items: center;
103
+ }
104
+
105
+ [data-fs-list-cards-item-default-badge] {
106
+ padding: 0.0625rem 0.5rem;
107
+ font-size: 0.75rem;
108
+ color: var(--fs-text-secondary, #6f6f6f);
109
+ border: 1px solid var(--fs-border-color, #d9d9d9);
110
+ border-radius: 999px;
111
+ }
112
+
113
+ [data-fs-list-cards-item-number] {
114
+ font-size: 0.875rem;
115
+ color: var(--fs-text-secondary, #6f6f6f);
116
+ }
117
+
118
+ // The shared EmptyState is designed for full-page empty states
119
+ // (min-height: 50vh) — shrink that here so it sits inline within this
120
+ // list section instead of pushing a huge block, while keeping its
121
+ // default centered content.
122
+ [data-fs-empty-state] {
123
+ min-height: auto;
124
+ padding: 0;
125
+ }
126
+ }
@@ -1,5 +1,6 @@
1
1
  import type { ComponentType } from 'react'
2
2
 
3
+ import AccountListCards from 'src/components/sections/Account/AccountListCards'
3
4
  import AccountNavigation from 'src/components/sections/Account/AccountNavigation'
4
5
  import AccountOrderBudgets from 'src/components/sections/Account/AccountOrderBudgets'
5
6
  import AccountOrderDelivery from 'src/components/sections/Account/AccountOrderDelivery'
@@ -25,6 +26,7 @@ const ACCOUNT_COMPONENTS: Record<string, ComponentType<any>> = {
25
26
  [getComponentKey(AccountNavigation, 'AccountNavigation')]: AccountNavigation,
26
27
  [getComponentKey(AccountProfile, 'AccountProfile')]: AccountProfile,
27
28
  [getComponentKey(AccountOrdersList, 'AccountOrdersList')]: AccountOrdersList,
29
+ [getComponentKey(AccountListCards, 'AccountListCards')]: AccountListCards,
28
30
  [getComponentKey(AccountUserDetails, 'AccountUserDetails')]:
29
31
  AccountUserDetails,
30
32
  [getComponentKey(AccountSecurity, 'AccountSecurity')]: AccountSecurity,
@@ -0,0 +1,69 @@
1
+ import { MyAccountListCards } from 'src/components/account/cards/MyAccountListCards'
2
+ import type { MyAccountListCardsSectionLabels } from 'src/components/account/cards/MyAccountListCards/myAccountListCardsLabels'
3
+ import { defaultMyAccountListCardsLabels } from 'src/components/account/cards/MyAccountListCards/myAccountListCardsLabels'
4
+ import {
5
+ type AccountCardsPageData,
6
+ useAccountPageData,
7
+ } from 'src/sdk/account/accountPageContext'
8
+ import Section from '../Section'
9
+
10
+ export type AccountListCardsProps = MyAccountListCardsSectionLabels
11
+
12
+ const AccountListCards = ({
13
+ pageTitle = defaultMyAccountListCardsLabels.pageTitle,
14
+ personalTabLabel = defaultMyAccountListCardsLabels.personalTabLabel,
15
+ sharedTabLabel = defaultMyAccountListCardsLabels.sharedTabLabel,
16
+ searchPlaceholder = defaultMyAccountListCardsLabels.searchPlaceholder,
17
+ paginationOfLabel = defaultMyAccountListCardsLabels.paginationOfLabel,
18
+ previousPageLabel = defaultMyAccountListCardsLabels.previousPageLabel,
19
+ nextPageLabel = defaultMyAccountListCardsLabels.nextPageLabel,
20
+ noPersonalCardsLabel = defaultMyAccountListCardsLabels.noPersonalCardsLabel,
21
+ noSharedCardsLabel = defaultMyAccountListCardsLabels.noSharedCardsLabel,
22
+ noResultsLabel = defaultMyAccountListCardsLabels.noResultsLabel,
23
+ errorTitleLabel = defaultMyAccountListCardsLabels.errorTitleLabel,
24
+ tryAgainLabel = defaultMyAccountListCardsLabels.tryAgainLabel,
25
+ sharedCardsTooltipLabel = defaultMyAccountListCardsLabels.sharedCardsTooltipLabel,
26
+ defaultCardLabel = defaultMyAccountListCardsLabels.defaultCardLabel,
27
+ genericCardLabel = defaultMyAccountListCardsLabels.genericCardLabel,
28
+ }: AccountListCardsProps) => {
29
+ const {
30
+ personalCards,
31
+ sharedCards,
32
+ hasOrgAssociation,
33
+ canViewPersonalCards,
34
+ hasError,
35
+ } = useAccountPageData<AccountCardsPageData>()
36
+
37
+ return (
38
+ <Section className="section-account-list-cards">
39
+ <MyAccountListCards
40
+ personalCards={personalCards ?? []}
41
+ sharedCards={sharedCards ?? []}
42
+ hasOrgAssociation={hasOrgAssociation}
43
+ canViewPersonalCards={canViewPersonalCards}
44
+ hasError={hasError}
45
+ labels={{
46
+ pageTitle,
47
+ personalTabLabel,
48
+ sharedTabLabel,
49
+ searchPlaceholder,
50
+ paginationOfLabel,
51
+ previousPageLabel,
52
+ nextPageLabel,
53
+ noPersonalCardsLabel,
54
+ noSharedCardsLabel,
55
+ noResultsLabel,
56
+ errorTitleLabel,
57
+ tryAgainLabel,
58
+ sharedCardsTooltipLabel,
59
+ defaultCardLabel,
60
+ genericCardLabel,
61
+ }}
62
+ />
63
+ </Section>
64
+ )
65
+ }
66
+
67
+ AccountListCards.$componentKey = 'AccountListCards'
68
+
69
+ export default AccountListCards
@@ -0,0 +1,244 @@
1
+ import type { Locator, Section } from '@vtex/client-cms'
2
+ import type { GetServerSideProps } from 'next'
3
+ import { NextSeo } from 'next-seo'
4
+ import type { ComponentType } from 'react'
5
+ import { Layout } from 'src/components/account'
6
+ import RenderSections, {
7
+ RenderSectionsBase,
8
+ } from 'src/components/cms/RenderSections'
9
+ import ACCOUNT_COMPONENTS from 'src/components/cms/account/Components'
10
+ import GLOBAL_COMPONENTS from 'src/components/cms/global/Components'
11
+ import CUSTOM_COMPONENTS from 'src/customizations/src/components'
12
+
13
+ import { getGlobalSectionsData } from 'src/components/cms/GlobalSections'
14
+
15
+ import { gql } from '@generated/gql'
16
+ import type {
17
+ ServerListCardsQueryQuery,
18
+ ServerListCardsQueryQueryVariables,
19
+ } from '@generated/graphql'
20
+ import type { MyAccountProps } from 'src/experimental/myAccountServerSideProps'
21
+ import type { AccountCardsPageData } from 'src/sdk/account/accountPageContext'
22
+ import type { AccountNavigationLabels } from 'src/sdk/account/getMyAccountRoutes'
23
+ import { getB2BSessionClaims } from 'src/sdk/account/getB2BSessionClaims'
24
+ import { getIsRepresentative } from 'src/sdk/account/getIsRepresentative'
25
+ import { execute } from 'src/server'
26
+ import { injectGlobalSections } from 'src/server/cms/global'
27
+ import { extractAccountNavigationData } from 'src/server/cms/myAccountDefaultSections'
28
+ import { fetchMyAccountPageContent } from 'src/server/cms/fetchMyAccountPageContent'
29
+ import { withLocaleValidationSSR } from 'src/utils/localization/withLocaleValidation'
30
+ import { getMyAccountRedirect } from 'src/utils/myAccountRedirect'
31
+
32
+ import storeConfig from 'discovery.config'
33
+ import PageProvider from 'src/sdk/overrides/PageProvider'
34
+ import { extractStatusFromError } from 'src/utils/utilities'
35
+
36
+ const COMPONENTS: Record<string, ComponentType<any>> = {
37
+ ...GLOBAL_COMPONENTS,
38
+ ...CUSTOM_COMPONENTS,
39
+ }
40
+
41
+ const query = gql(`
42
+ query ServerListCardsQuery {
43
+ listCreditCards {
44
+ list {
45
+ accountId
46
+ bin
47
+ cardNumber
48
+ cardLabel
49
+ paymentSystem
50
+ paymentSystemName
51
+ isDefault
52
+ isActive
53
+ origin
54
+ }
55
+ }
56
+ accountProfile {
57
+ name
58
+ }
59
+ hasAdHocCardAccess
60
+ }
61
+ `)
62
+
63
+ type ListCardsPageProps = {
64
+ pageSections: Section[]
65
+ navigationLabels: AccountNavigationLabels
66
+ accountPageData: AccountCardsPageData
67
+ } & MyAccountProps
68
+
69
+ export default function ListCardsPage({
70
+ globalSections: globalSectionsProp,
71
+ pageSections,
72
+ navigationLabels,
73
+ accountPageData,
74
+ accountName,
75
+ isRepresentative,
76
+ }: ListCardsPageProps) {
77
+ const { sections: globalSections, settings: globalSettings } =
78
+ globalSectionsProp ?? {}
79
+
80
+ return (
81
+ <PageProvider
82
+ context={{
83
+ globalSettings,
84
+ accountPageData,
85
+ navigationLabels,
86
+ }}
87
+ >
88
+ <RenderSections globalSections={globalSections} components={COMPONENTS}>
89
+ <NextSeo noindex nofollow />
90
+
91
+ <Layout
92
+ isRepresentative={isRepresentative}
93
+ accountName={accountName}
94
+ navigationLabels={navigationLabels}
95
+ >
96
+ <RenderSectionsBase
97
+ sections={pageSections}
98
+ components={ACCOUNT_COMPONENTS}
99
+ />
100
+ </Layout>
101
+ </RenderSections>
102
+ </PageProvider>
103
+ )
104
+ }
105
+
106
+ const getServerSidePropsBase: GetServerSideProps<
107
+ ListCardsPageProps,
108
+ Record<string, string>,
109
+ Locator
110
+ > = async (context) => {
111
+ const isRepresentative = getIsRepresentative({
112
+ headers: context.req.headers as Record<string, string>,
113
+ account: storeConfig.api.storeId,
114
+ })
115
+
116
+ const { hasOrgAssociation, hasCustomerId } = getB2BSessionClaims({
117
+ headers: context.req.headers as Record<string, string>,
118
+ account: storeConfig.api.storeId,
119
+ })
120
+
121
+ const contentContext = {
122
+ previewData: context.previewData,
123
+ locale: context.locale,
124
+ }
125
+
126
+ const { isFaststoreMyAccountEnabled, redirect } = getMyAccountRedirect({
127
+ query: context.query,
128
+ })
129
+
130
+ if (!isFaststoreMyAccountEnabled) {
131
+ return { redirect }
132
+ }
133
+
134
+ const [
135
+ globalSectionsPromise,
136
+ globalSectionsHeaderPromise,
137
+ globalSectionsFooterPromise,
138
+ ] = getGlobalSectionsData(contentContext)
139
+
140
+ const [
141
+ pageContent,
142
+ listCards,
143
+ globalSections,
144
+ globalSectionsHeader,
145
+ globalSectionsFooter,
146
+ ] = await Promise.all([
147
+ fetchMyAccountPageContent(
148
+ 'myAccountCards',
149
+ contentContext,
150
+ '/pvt/account/cards'
151
+ ),
152
+ execute<ServerListCardsQueryQueryVariables, ServerListCardsQueryQuery>(
153
+ {
154
+ variables: {},
155
+ operation: query,
156
+ },
157
+ { headers: { ...context.req.headers } }
158
+ ),
159
+ globalSectionsPromise,
160
+ globalSectionsHeaderPromise,
161
+ globalSectionsFooterPromise,
162
+ ])
163
+
164
+ const hasError = !!listCards.errors
165
+
166
+ if (hasError) {
167
+ console.error(...listCards.errors)
168
+
169
+ const statusCode = extractStatusFromError(listCards.errors[0])
170
+
171
+ // Auth failures still redirect (consistent with Orders); anything else
172
+ // (e.g. the Saved-cards service being down) renders the page's own error
173
+ // state instead of a hard redirect (spec US-5) — the buyer stays in My
174
+ // Account and can retry.
175
+ if (statusCode === 403 || statusCode === 401) {
176
+ return {
177
+ redirect: {
178
+ destination: `/pvt/account/403?from=${encodeURIComponent('/pvt/account/cards')}`,
179
+ permanent: false,
180
+ },
181
+ }
182
+ }
183
+ }
184
+
185
+ // `useAdHocCard` does not gate access to the Cards route (spec
186
+ // my-account-cards-gating-plan, the rectified model) — it, together with
187
+ // `customerId`, gates visibility of the Personal tab only. The route always
188
+ // stays reachable and the sidebar entry always stays visible; there is no
189
+ // redirect and no menu hiding. Default to allowed so a query failure renders
190
+ // the page's own error state instead of hiding the Personal tab.
191
+ const hasAdHocCardAccess = listCards.data?.hasAdHocCardAccess ?? true
192
+ const canViewPersonalCards = hasCustomerId && hasAdHocCardAccess
193
+
194
+ const { pageSections, navigationData } = extractAccountNavigationData(
195
+ pageContent.sections
196
+ )
197
+
198
+ const globalSectionsResult = injectGlobalSections({
199
+ globalSections,
200
+ globalSectionsHeader,
201
+ globalSectionsFooter,
202
+ })
203
+
204
+ // The Saved-cards service returns personal and shared cards merged into one
205
+ // list, each tagged `origin` (spec my-account-cards-shared-listing-unblock).
206
+ // Partition here rather than de-duplicating: a card present under both
207
+ // origins is intentionally rendered in both tabs.
208
+ const allCards = hasError ? [] : (listCards.data?.listCreditCards?.list ?? [])
209
+ // Mirrors MyAccountListCards.tsx's `sharedOnly` exactly — that's the only
210
+ // combination where the UI actually hides the Personal tab (spec
211
+ // my-account-cards-gating-plan, O1). Stripping personalCards any time
212
+ // `canViewPersonalCards` is false would also empty it for the two
213
+ // "kept as-is" combinations (no `unitId`), which still render the tab and,
214
+ // per FR-7, must never be subject to this check at all. Drop the data from
215
+ // the SSR payload only when the chrome that would show it is gone too — an
216
+ // always-false strip ships nothing extra, but a too-broad one silently
217
+ // hides real cards from buyers this gate was never meant to touch.
218
+ const sharedOnly = !canViewPersonalCards && hasOrgAssociation
219
+ const personalCards = sharedOnly
220
+ ? []
221
+ : allCards.filter((card) => card.origin === 'personal')
222
+ const sharedCards = allCards.filter((card) => card.origin === 'shared')
223
+
224
+ return {
225
+ props: {
226
+ globalSections: globalSectionsResult,
227
+ accountName: listCards.data?.accountProfile.name ?? '',
228
+ navigationLabels: navigationData as AccountNavigationLabels,
229
+ accountPageData: {
230
+ personalCards,
231
+ sharedCards,
232
+ hasOrgAssociation,
233
+ canViewPersonalCards,
234
+ hasError,
235
+ },
236
+ pageSections,
237
+ isRepresentative,
238
+ },
239
+ }
240
+ }
241
+
242
+ export const getServerSideProps = withLocaleValidationSSR(
243
+ getServerSidePropsBase
244
+ )
@@ -1,4 +1,5 @@
1
1
  import type {
2
+ ServerListCardsQueryQuery,
2
3
  ServerListOrdersQueryQuery,
3
4
  ServerOrderDetailsQueryQuery,
4
5
  ServerProfileQueryQuery,
@@ -27,6 +28,20 @@ export type AccountOrdersListPageData = {
27
28
  }
28
29
  }
29
30
 
31
+ export type AccountCardsPageData = {
32
+ // Both partitioned from the same listCreditCards response by `origin`
33
+ // (spec my-account-cards-shared-listing-unblock) — one query, no dedup.
34
+ personalCards: NonNullable<
35
+ ServerListCardsQueryQuery['listCreditCards']
36
+ >['list']
37
+ sharedCards: NonNullable<ServerListCardsQueryQuery['listCreditCards']>['list']
38
+ hasOrgAssociation: boolean
39
+ // Gates the Personal tab (spec my-account-cards-gating-plan) — independent
40
+ // of route access, which is never gated.
41
+ canViewPersonalCards: boolean
42
+ hasError: boolean
43
+ }
44
+
30
45
  export type AccountOrderDetailsPageData = {
31
46
  order: ServerOrderDetailsQueryQuery['userOrder']
32
47
  orderStatusLabels?: OrderStatusCmsLabels
@@ -53,6 +68,7 @@ export type AccountPageData =
53
68
  | AccountOrderDetailsPageData
54
69
  | AccountSecurityPageData
55
70
  | AccountUserDetailsPageData
71
+ | AccountCardsPageData
56
72
  | Record<string, never>
57
73
 
58
74
  export interface AccountPageContext extends PageGlobalContext {
@@ -0,0 +1,42 @@
1
+ import { parse } from 'cookie'
2
+ import { parseJwt } from 'src/utils/getCookie'
3
+
4
+ type GetB2BSessionClaimsParams = {
5
+ headers?: Record<string, string>
6
+ account: string
7
+ }
8
+
9
+ export type B2BSessionClaims = {
10
+ /**
11
+ * Whether the buyer is associated with a Unit/Contract. Per the Personal
12
+ * Cards PRD (REQ-2), this is detected by the presence of a `unitId` claim in
13
+ * the VTEX ID JWT — the same cookie `getIsRepresentative` already decodes.
14
+ * Gates the Shared tab (FR-5).
15
+ *
16
+ * Note this is the only part of the gate that lives in the token. The
17
+ * `useAdHocCard` permission is a License Manager resource key resolved from
18
+ * the user's roles, so it is fetched server-side via the
19
+ * `hasAdHocCardAccess` GraphQL query — not read from here.
20
+ */
21
+ hasOrgAssociation: boolean
22
+ /**
23
+ * Whether the buyer has an individual `customerId` claim, distinct from the
24
+ * `unitId`/contract association. Combined with `hasAdHocCardAccess`, gates
25
+ * the Personal tab (spec my-account-cards-gating-plan, the rectified model —
26
+ * `useAdHocCard` alone doesn't gate the whole Cards route, only Personal-tab
27
+ * visibility).
28
+ */
29
+ hasCustomerId: boolean
30
+ }
31
+
32
+ export function getB2BSessionClaims({
33
+ headers,
34
+ account,
35
+ }: GetB2BSessionClaimsParams): B2BSessionClaims {
36
+ const authCookie = parse(headers?.cookie ?? '')?.[
37
+ 'VtexIdclientAutCookie_' + account
38
+ ]
39
+ const jwt = parseJwt(authCookie)
40
+
41
+ return { hasOrgAssociation: !!jwt?.unitId, hasCustomerId: !!jwt?.customerId }
42
+ }
@@ -21,6 +21,7 @@ export type AccountNavigationLabels = Partial<{
21
21
  switchLabel: string
22
22
  companyLabel: string
23
23
  contractLabel: string
24
+ cardsLabel: string
24
25
  }>
25
26
 
26
27
  interface GetMyAccountRouteParams {
@@ -33,6 +34,7 @@ export const PROFILE_ROUTE = '/pvt/account/profile'
33
34
  export const ORDERS_ROUTE = '/pvt/account/orders'
34
35
  export const USER_DETAILS_ROUTE = '/pvt/account/user-details'
35
36
  export const SECURITY_ROUTE = '/pvt/account/security'
37
+ export const CARDS_ROUTE = '/pvt/account/cards'
36
38
  export const QUOTES_ROUTE = '/pvt/account/quotes'
37
39
 
38
40
  export const ROUTES_ONLY_FOR_B2B_MEMBERS = [QUOTES_ROUTE]
@@ -42,6 +44,7 @@ const ROUTE_LABEL_KEYS: Record<string, keyof AccountNavigationLabels> = {
42
44
  [ORDERS_ROUTE]: 'ordersLabel',
43
45
  [USER_DETAILS_ROUTE]: 'userDetailsLabel',
44
46
  [SECURITY_ROUTE]: 'securityLabel',
47
+ [CARDS_ROUTE]: 'cardsLabel',
45
48
  }
46
49
 
47
50
  // This is the default route list for My Account, we should add then as the feature is implemented
@@ -54,6 +57,10 @@ const DEFAULT_ROUTES: Route[] = [
54
57
  title: 'Orders',
55
58
  route: ORDERS_ROUTE,
56
59
  },
60
+ {
61
+ title: 'Cards',
62
+ route: CARDS_ROUTE,
63
+ },
57
64
  {
58
65
  title: 'Quotes',
59
66
  route: QUOTES_ROUTE,
@@ -5,6 +5,7 @@ export type NativeMyAccountContentType =
5
5
  | 'myAccountUserDetails'
6
6
  | 'myAccountSecurity'
7
7
  | 'myAccountUnauthorized'
8
+ | 'myAccountCards'
8
9
 
9
10
  /** Store content-types are opaque strings; native literals keep autocompletion. */
10
11
  export type MyAccountContentType = NativeMyAccountContentType | (string & {})
@@ -18,6 +19,7 @@ export type DefaultMyAccountSection = {
18
19
  const DEFAULT_SECTION_KEYS: Record<NativeMyAccountContentType, string[]> = {
19
20
  myAccountProfile: ['AccountNavigation', 'AccountProfile'],
20
21
  myAccountOrders: ['AccountNavigation', 'AccountOrdersList'],
22
+ myAccountCards: ['AccountNavigation', 'AccountListCards'],
21
23
  myAccountOrderDetails: [
22
24
  'AccountNavigation',
23
25
  'AccountOrderDetails',
@@ -27,7 +27,14 @@ export function parseJwt(token: string) {
27
27
  if (!token) {
28
28
  return null
29
29
  }
30
- return JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString())
30
+
31
+ try {
32
+ return JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString())
33
+ } catch {
34
+ // Malformed structure/base64/JSON — treat the same as no token, instead
35
+ // of throwing and 500ing the caller's request.
36
+ return null
37
+ }
31
38
  }
32
39
 
33
40
  export function getJWTAutCookie({ headers, account }: Params) {