@faststore/core 4.8.1-dev.1 → 4.8.1-dev.3

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 (41) hide show
  1. package/.turbo/turbo-generate.log +3 -3
  2. package/.turbo/turbo-test.log +129 -124
  3. package/@generated/gql.ts +3 -3
  4. package/@generated/graphql.ts +4 -2
  5. package/@generated/persisted-documents.json +1 -1
  6. package/@generated/schema.graphql +4 -0
  7. package/CHANGELOG.md +14 -0
  8. package/package.json +6 -6
  9. package/src/components/account/Drawer/OrganizationDrawer/ContractSwitcher/ContractSwitcher.tsx +4 -6
  10. package/src/components/account/Drawer/OrganizationDrawer/OrganizationDrawer.tsx +18 -3
  11. package/src/components/account/Drawer/OrganizationDrawer/OrganizationDrawerHeader.tsx +1 -0
  12. package/src/components/account/Drawer/OrganizationDrawer/section.module.scss +7 -0
  13. package/src/components/account/Drawer/OrganizationSignInButton/OrganizationSignInButton.tsx +7 -9
  14. package/src/components/account/Menu/Menu.tsx +24 -7
  15. package/src/components/navigation/Navbar/Navbar.tsx +17 -19
  16. package/src/components/navigation/Navbar/NavbarAccountArea.tsx +57 -0
  17. package/src/components/navigation/NavbarSlider/NavbarSlider.tsx +16 -18
  18. package/src/components/templates/LandingPage/LandingPage.tsx +1 -1
  19. package/src/pages/[slug]/p.tsx +3 -1
  20. package/src/pages/api/fs/switch-contract.ts +194 -0
  21. package/src/pages/index.tsx +2 -1
  22. package/src/pages/s.tsx +9 -3
  23. package/src/sdk/account/changeContractToken.ts +19 -63
  24. package/src/sdk/account/clearPersistedSessionState.ts +13 -7
  25. package/src/sdk/account/useAvailableContracts.ts +1 -0
  26. package/src/sdk/account/useSwitchContract.ts +11 -3
  27. package/src/sdk/session/index.ts +2 -1
  28. package/src/sdk/session/isSignInAreaResolved.ts +12 -0
  29. package/test/components/account/ContractSwitcher.browser.test.tsx +42 -0
  30. package/test/components/account/Menu.browser.test.tsx +123 -0
  31. package/test/components/account/OrganizationDrawer.browser.test.tsx +148 -0
  32. package/test/components/account/OrganizationDrawerHeader.browser.test.tsx +2 -0
  33. package/test/components/account/OrganizationSignInButton.browser.test.tsx +49 -0
  34. package/test/components/navigation/Navbar.browser.test.tsx +183 -0
  35. package/test/components/navigation/NavbarAccountArea.browser.test.tsx +115 -0
  36. package/test/components/navigation/NavbarSlider.browser.test.tsx +143 -0
  37. package/test/pages/api/switch-contract.test.ts +385 -0
  38. package/test/sdk/account/changeContractToken.browser.test.ts +23 -71
  39. package/test/sdk/account/clearPersistedSessionState.browser.test.ts +24 -0
  40. package/test/sdk/account/useSwitchContract.browser.test.ts +21 -0
  41. package/test/sdk/session/isSignInAreaResolved.test.ts +22 -0
@@ -2,6 +2,7 @@ import { SlideOver, useFadeEffect } from '@faststore/ui'
2
2
  import { useEffect, useState } from 'react'
3
3
 
4
4
  import { getStoreURL } from 'src/sdk/localization/useLocalizationConfig'
5
+ import { useAvailableContracts } from 'src/sdk/account/useAvailableContracts'
5
6
  import { useSession } from 'src/sdk/session'
6
7
  import {
7
8
  expireCookieClient,
@@ -23,6 +24,8 @@ type OrganizationDrawerProps = {
23
24
  isOpen: boolean
24
25
  closeDrawer: () => void
25
26
  isRepresentative: boolean
27
+ /** Open straight into the contract switcher (My Account "Switch" button). */
28
+ initialView?: OrganizationDrawerView
26
29
  }
27
30
 
28
31
  const clearBrowserStorageForCurrentDomain = async () => {
@@ -140,16 +143,17 @@ export const OrganizationDrawer = ({
140
143
  isOpen,
141
144
  closeDrawer,
142
145
  isRepresentative,
146
+ initialView = 'menu',
143
147
  }: OrganizationDrawerProps) => {
144
148
  const { fade, fadeOut } = useFadeEffect()
145
149
  const { b2b, person } = useSession()
146
- const [view, setView] = useState<OrganizationDrawerView>('menu')
150
+ const [view, setView] = useState<OrganizationDrawerView>(initialView)
147
151
 
148
152
  useEffect(() => {
149
153
  if (!isOpen) {
150
- setView('menu')
154
+ setView(initialView)
151
155
  }
152
- }, [isOpen])
156
+ }, [isOpen, initialView])
153
157
 
154
158
  const contractName =
155
159
  b2b?.contractName?.trim() ||
@@ -168,6 +172,16 @@ export const OrganizationDrawer = ({
168
172
  const canSwitchContract = Boolean(b2b?.unitId)
169
173
  const isOrgMember = Boolean(b2b?.unitId)
170
174
 
175
+ // Only fetch the contract list while the drawer is open for an org member,
176
+ // so nothing is requested on initial page render (TTFB budget).
177
+ const { contracts } = useAvailableContracts(isOpen && canSwitchContract)
178
+ const activeContractId = b2b?.customerId?.trim() ?? ''
179
+ const activeContract =
180
+ (activeContractId
181
+ ? contracts.find((contract) => contract.id === activeContractId)
182
+ : undefined) ?? contracts.find((contract) => contract.isActive)
183
+ const isActiveContractDefault = Boolean(activeContract?.isDefault)
184
+
171
185
  return (
172
186
  <SlideOver
173
187
  data-fs-organization-drawer
@@ -190,6 +204,7 @@ export const OrganizationDrawer = ({
190
204
  onCloseDrawer={closeDrawer}
191
205
  contractName={contractName}
192
206
  contractUrl={contractUrl}
207
+ isDefault={isActiveContractDefault}
193
208
  onChangeContract={
194
209
  canSwitchContract ? () => setView('switch') : undefined
195
210
  }
@@ -59,6 +59,7 @@ export const OrganizationDrawerHeader = ({
59
59
  name="Star"
60
60
  width={20}
61
61
  height={20}
62
+ aria-label="Default contract"
62
63
  />
63
64
  )}
64
65
  {onChangeContract && (
@@ -128,6 +128,10 @@
128
128
  [data-fs-organization-drawer-header-default] {
129
129
  flex-shrink: 0;
130
130
  color: var(--fs-color-link);
131
+
132
+ // The Star symbol only declares stroke="currentColor", so without this
133
+ // the fill falls back to the SVG default (black).
134
+ fill: currentColor;
131
135
  }
132
136
 
133
137
  [data-fs-organization-drawer-header-change] {
@@ -331,6 +335,9 @@
331
335
  [data-fs-contract-switcher-default] {
332
336
  flex-shrink: 0;
333
337
  color: var(--fs-color-link);
338
+
339
+ // See the drawer header star: the symbol carries no fill of its own.
340
+ fill: currentColor;
334
341
  }
335
342
 
336
343
  // Footer: Cancel / Confirm
@@ -13,21 +13,19 @@ export const OrganizationSignInButton = ({
13
13
  }) => {
14
14
  const [isOpen, setIsOpen] = useState(false)
15
15
 
16
- const { person } = useSession()
16
+ const { person, b2b } = useSession()
17
17
  const navigationLabels = useAccountNavigationLabels()
18
18
  const companyLabel = navigationLabels?.companyLabel ?? 'Company'
19
19
  const contractLabel = navigationLabels?.contractLabel ?? 'Contract'
20
+ const activeContractName = b2b?.contractName?.trim()
21
+ const label =
22
+ activeContractName || (person?.id ? companyLabel : contractLabel)
20
23
 
21
24
  const openDrawer = (
22
25
  event: MouseEvent<HTMLAnchorElement> | MouseEvent<HTMLButtonElement>
23
26
  ) => {
24
- // if (session.person) {
25
- // biome-ignore lint/correctness/noConstantCondition: <explanation>
26
- if (true) {
27
- event.preventDefault()
28
-
29
- setIsOpen(true)
30
- }
27
+ event.preventDefault()
28
+ setIsOpen(true)
31
29
  }
32
30
 
33
31
  const closeDrawer = () => {
@@ -45,7 +43,7 @@ export const OrganizationSignInButton = ({
45
43
  icon={<Icon name={icon} width={18} height={18} weight="bold" />}
46
44
  iconPosition="left"
47
45
  >
48
- {person?.id ? companyLabel : contractLabel}
46
+ {label}
49
47
  </Button>
50
48
 
51
49
  {isOpen && (
@@ -1,10 +1,13 @@
1
1
  import { Button } from '@faststore/ui'
2
2
  import { useRouter } from 'next/router'
3
+ import { useState } from 'react'
3
4
 
4
5
  import Link from 'src/components/ui/Link'
5
6
  import styles from '../section.module.scss'
6
7
 
8
+ import { OrganizationDrawer } from '../Drawer/OrganizationDrawer/OrganizationDrawer'
7
9
  import { useAccountNavigationLabels } from 'src/sdk/account/accountPageContext'
10
+ import { useSession } from 'src/sdk/session'
8
11
  import useScreenResize from 'src/sdk/ui/useScreenResize'
9
12
 
10
13
  interface NavItem {
@@ -43,6 +46,9 @@ const Menu = ({ avatarImageUrl, accountName, items }: MenuProps) => {
43
46
  const { isDesktop } = useScreenResize()
44
47
  const navigationLabels = useAccountNavigationLabels()
45
48
  const switchLabel = navigationLabels?.switchLabel ?? 'Switch'
49
+ const { b2b } = useSession()
50
+ const [isSwitcherOpen, setIsSwitcherOpen] = useState(false)
51
+ const canSwitchContract = Boolean(b2b?.unitId)
46
52
 
47
53
  return (
48
54
  <div className={styles.menu}>
@@ -56,16 +62,27 @@ const Menu = ({ avatarImageUrl, accountName, items }: MenuProps) => {
56
62
  )}
57
63
  <h2>{accountName}</h2>
58
64
  </div>
59
- <Button
60
- className={styles.switchButton}
61
- variant="secondary"
62
- size="small"
63
- >
64
- {switchLabel}
65
- </Button>
65
+ {canSwitchContract && (
66
+ <Button
67
+ className={styles.switchButton}
68
+ variant="secondary"
69
+ size="small"
70
+ onClick={() => setIsSwitcherOpen(true)}
71
+ >
72
+ {switchLabel}
73
+ </Button>
74
+ )}
66
75
  </div>
67
76
  ) : null}
68
77
  <Nav items={items} />
78
+ {isSwitcherOpen && (
79
+ <OrganizationDrawer
80
+ isOpen
81
+ closeDrawer={() => setIsSwitcherOpen(false)}
82
+ isRepresentative={Boolean(b2b?.isRepresentative)}
83
+ initialView="switch"
84
+ />
85
+ )}
69
86
  </div>
70
87
  )
71
88
  }
@@ -3,8 +3,6 @@ import { useCallback, useRef, useState } from 'react'
3
3
 
4
4
  import { Icon as UIIcon, useScrollDirection, useUI } from '@faststore/ui'
5
5
 
6
- import { Skeleton as UISkeleton } from '@faststore/ui'
7
- import { OrganizationSignInButton } from 'src/components/account/Drawer/OrganizationSignInButton'
8
6
  import CartToggle from 'src/components/cart/CartToggle'
9
7
  import type { SearchInputRef } from 'src/components/search/SearchInput'
10
8
  import SearchInput from 'src/components/search/SearchInput'
@@ -13,10 +11,12 @@ import LocalizationButton from 'src/components/ui/LocalizationButton'
13
11
  import Logo from 'src/components/ui/Logo'
14
12
  import { useOverrideComponents } from 'src/sdk/overrides/OverrideContext'
15
13
  import { useSession } from 'src/sdk/session'
14
+ import { isSignInAreaResolved } from 'src/sdk/session/isSignInAreaResolved'
16
15
  import useScreenResize from 'src/sdk/ui/useScreenResize'
17
16
 
18
17
  import storeConfig from 'discovery.config'
19
18
  import type { NavbarProps as SectionNavbarProps } from '../../sections/Navbar'
19
+ import { NavbarAccountArea } from './NavbarAccountArea'
20
20
 
21
21
  const NavbarLinks = dynamic(
22
22
  () =>
@@ -121,7 +121,11 @@ function Navbar({
121
121
  const scrollDirection = useScrollDirection()
122
122
  const { openNavbar, navbar: displayNavbar } = useUI()
123
123
  const { isDesktop } = useScreenResize()
124
- const { b2b, isSessionReady } = useSession()
124
+ const { b2b, isSessionReady, hasValidated } = useSession()
125
+ const isSignInResolved = isSignInAreaResolved({
126
+ isSessionReady,
127
+ hasValidated,
128
+ })
125
129
 
126
130
  const searchMobileRef = useRef<SearchInputRef>(null)
127
131
  const [searchExpanded, setSearchExpanded] = useState(false)
@@ -232,22 +236,16 @@ function Navbar({
232
236
  />
233
237
  )}
234
238
 
235
- {isDesktop &&
236
- (isSessionReady ? (
237
- isOrganizationEnabled ? (
238
- <OrganizationSignInButton
239
- icon={signInButton.icon}
240
- isRepresentative={isRepresentative}
241
- />
242
- ) : (
243
- <ButtonSignIn.Component {...signInButton} />
244
- )
245
- ) : (
246
- <UISkeleton
247
- data-fs-navbar-signin-skeleton
248
- size={{ width: '140px', height: '2.5rem' }}
249
- />
250
- ))}
239
+ {isDesktop && (
240
+ <NavbarAccountArea
241
+ isSignInResolved={isSignInResolved}
242
+ isOrganizationEnabled={isOrganizationEnabled}
243
+ isRepresentative={isRepresentative}
244
+ signInButton={signInButton}
245
+ ButtonSignIn={ButtonSignIn}
246
+ skeletonAttr="data-fs-navbar-signin-skeleton"
247
+ />
248
+ )}
251
249
 
252
250
  <CartToggle {...cart} />
253
251
  </NavbarButtons.Component>
@@ -0,0 +1,57 @@
1
+ import { Skeleton as UISkeleton } from '@faststore/ui'
2
+
3
+ import { OrganizationSignInButton } from 'src/components/account/Drawer/OrganizationSignInButton'
4
+ import type { useOverrideComponents } from 'src/sdk/overrides/OverrideContext'
5
+
6
+ import type { NavbarProps as SectionNavbarProps } from '../../sections/Navbar'
7
+
8
+ type NavbarOverrides = ReturnType<typeof useOverrideComponents<'Navbar'>>
9
+
10
+ export type NavbarAccountAreaProps = {
11
+ /** Whether the B2B session has resolved enough to know what to render. */
12
+ isSignInResolved: boolean
13
+ /** Whether the representative-only Organization sign-in area is enabled. */
14
+ isOrganizationEnabled: boolean | undefined
15
+ isRepresentative?: boolean
16
+ signInButton: SectionNavbarProps['signInButton']
17
+ ButtonSignIn: NavbarOverrides['_experimentalButtonSignIn']
18
+ /** Data attribute toggled on the loading skeleton (Navbar vs NavbarSlider). */
19
+ skeletonAttr:
20
+ | 'data-fs-navbar-signin-skeleton'
21
+ | 'data-fs-navbar-slider-signin-skeleton'
22
+ }
23
+
24
+ /**
25
+ * Renders the account/sign-in area shared by the desktop Navbar and the
26
+ * mobile NavbarSlider: a loading skeleton until the B2B session resolves,
27
+ * then either the Organization sign-in button (representatives) or the
28
+ * storefront's own sign-in button.
29
+ */
30
+ export const NavbarAccountArea = ({
31
+ isSignInResolved,
32
+ isOrganizationEnabled,
33
+ isRepresentative,
34
+ signInButton,
35
+ ButtonSignIn,
36
+ skeletonAttr,
37
+ }: NavbarAccountAreaProps) => {
38
+ if (!isSignInResolved) {
39
+ return (
40
+ <UISkeleton
41
+ {...{ [skeletonAttr]: true }}
42
+ size={{ width: '140px', height: '2.5rem' }}
43
+ />
44
+ )
45
+ }
46
+
47
+ if (isOrganizationEnabled) {
48
+ return (
49
+ <OrganizationSignInButton
50
+ icon={signInButton.icon}
51
+ isRepresentative={isRepresentative}
52
+ />
53
+ )
54
+ }
55
+
56
+ return <ButtonSignIn.Component {...signInButton} />
57
+ }
@@ -1,4 +1,4 @@
1
- import { Skeleton as UISkeleton, useFadeEffect, useUI } from '@faststore/ui'
1
+ import { useFadeEffect, useUI } from '@faststore/ui'
2
2
  import { Suspense } from 'react'
3
3
 
4
4
  import storeConfig from 'discovery.config'
@@ -9,10 +9,11 @@ import LocalizationButton from 'src/components/ui/LocalizationButton'
9
9
  import Logo from 'src/components/ui/Logo'
10
10
 
11
11
  import type { NavbarProps } from '../Navbar'
12
+ import { NavbarAccountArea } from '../Navbar/NavbarAccountArea'
12
13
 
13
- import { OrganizationSignInButton } from 'src/components/account/Drawer/OrganizationSignInButton'
14
14
  import { useOverrideComponents } from 'src/sdk/overrides/OverrideContext'
15
15
  import { useSession } from 'src/sdk/session'
16
+ import { isSignInAreaResolved } from 'src/sdk/session/isSignInAreaResolved'
16
17
  import styles from './section.module.scss'
17
18
 
18
19
  interface NavbarSliderProps {
@@ -44,7 +45,11 @@ function NavbarSlider({
44
45
 
45
46
  const { closeNavbar } = useUI()
46
47
  const { fade, fadeOut } = useFadeEffect()
47
- const { b2b, isSessionReady } = useSession()
48
+ const { b2b, isSessionReady, hasValidated } = useSession()
49
+ const isSignInResolved = isSignInAreaResolved({
50
+ isSessionReady,
51
+ hasValidated,
52
+ })
48
53
 
49
54
  const isFaststoreMyAccountEnabled =
50
55
  storeConfig.experimental?.enableFaststoreMyAccount
@@ -84,21 +89,14 @@ function NavbarSlider({
84
89
  </NavbarSliderContent.Component>
85
90
  <NavbarSliderFooter.Component {...NavbarSliderFooter.props}>
86
91
  <Suspense fallback={<ButtonSignInFallback />}>
87
- {isSessionReady ? (
88
- isOrganizationEnabled ? (
89
- <OrganizationSignInButton
90
- icon={signInButton.icon}
91
- isRepresentative={isRepresentative}
92
- />
93
- ) : (
94
- <ButtonSignIn.Component {...signInButton} />
95
- )
96
- ) : (
97
- <UISkeleton
98
- data-fs-navbar-slider-signin-skeleton
99
- size={{ width: '140px', height: '2.5rem' }}
100
- />
101
- )}
92
+ <NavbarAccountArea
93
+ isSignInResolved={isSignInResolved}
94
+ isOrganizationEnabled={isOrganizationEnabled}
95
+ isRepresentative={isRepresentative}
96
+ signInButton={signInButton}
97
+ ButtonSignIn={ButtonSignIn}
98
+ skeletonAttr="data-fs-navbar-slider-signin-skeleton"
99
+ />
102
100
  </Suspense>
103
101
  {isLocalizationEnabled && (
104
102
  <LocalizationButton
@@ -65,7 +65,7 @@ export default function LandingPage({
65
65
  storeConfig.seo.titleTemplate ??
66
66
  storeConfig.seo.title
67
67
 
68
- const storeURL = getStoreURL(router.locale)
68
+ const storeURL = getStoreURL(router.locale).replace(/\/$/, '')
69
69
 
70
70
  return (
71
71
  <>
@@ -167,9 +167,11 @@ function Page({
167
167
 
168
168
  let itemListElements = product.breadcrumbList.itemListElement ?? []
169
169
  if (itemListElements.length !== 0) {
170
+ const storeURL = getStoreURL(router.locale).replace(/\/$/, '')
171
+
170
172
  itemListElements = itemListElements.map(
171
173
  ({ item: pathname, name, position }) => {
172
- const pageUrl = getStoreURL(router.locale) + pathname
174
+ const pageUrl = storeURL + pathname
173
175
 
174
176
  return { name, position, item: pageUrl }
175
177
  }
@@ -0,0 +1,194 @@
1
+ import { parse } from 'cookie'
2
+ import type { NextApiHandler, NextApiRequest, NextApiResponse } from 'next'
3
+
4
+ import storeConfig from 'discovery.config'
5
+ import { expireCookieServer, getCookieDomains } from 'src/utils/clearCookies'
6
+ import { getRequestHostname } from 'src/utils/getRequestHostname'
7
+
8
+ /**
9
+ * Cookies holding the checkout orderForm of the previous commercial context.
10
+ * Both are `HttpOnly`, so they are unreachable from `document.cookie` and can
11
+ * only be expired from the server (B2BTEAM-3827).
12
+ */
13
+ const ORDER_FORM_COOKIES = [
14
+ 'checkout.vtex.com',
15
+ 'CheckoutOrderFormOwnership',
16
+ ] as const
17
+
18
+ const SWITCH_PROPERTIES_TIMEOUT_MS = 30_000
19
+ const DEFAULT_AUTH_COOKIE_MAX_AGE = 86_400
20
+
21
+ type VtexAuthCookie = {
22
+ Name: string
23
+ Value: string
24
+ }
25
+
26
+ /** Untrusted upstream payload: every field is validated before use. */
27
+ type SwitchPropertiesResponse = {
28
+ authStatus?: string
29
+ expiresIn?: unknown
30
+ authCookie?: VtexAuthCookie | null
31
+ accountAuthCookie?: VtexAuthCookie | null
32
+ }
33
+
34
+ export type SwitchContractResponse = {
35
+ success: boolean
36
+ error?: string
37
+ }
38
+
39
+ const isAuthCookie = (cookie: unknown): cookie is VtexAuthCookie => {
40
+ const candidate = cookie as VtexAuthCookie | null
41
+
42
+ return Boolean(candidate?.Name?.trim() && candidate?.Value)
43
+ }
44
+
45
+ const isSecureRequest = (request: NextApiRequest): boolean => {
46
+ const forwarded = request.headers['x-forwarded-proto']
47
+ const proto = Array.isArray(forwarded) ? forwarded[0] : forwarded
48
+
49
+ if (proto) {
50
+ return proto.split(',')[0].trim() === 'https'
51
+ }
52
+
53
+ const hostname = getRequestHostname(request.headers.host)
54
+
55
+ return hostname !== 'localhost' && hostname !== '127.0.0.1'
56
+ }
57
+
58
+ /**
59
+ * A non-positive `expiresIn` would turn into `Max-Age=0`, which expires the
60
+ * credential the response is supposed to install — logging the buyer out while
61
+ * the route reports success. Anything that is not a positive integer falls back
62
+ * to the default.
63
+ */
64
+ const resolveMaxAge = (expiresIn: unknown): number =>
65
+ typeof expiresIn === 'number' && Number.isInteger(expiresIn) && expiresIn > 0
66
+ ? expiresIn
67
+ : DEFAULT_AUTH_COOKIE_MAX_AGE
68
+
69
+ /**
70
+ * Mirrors the attributes the storefront used to write via `document.cookie`.
71
+ * `HttpOnly` is deliberately left out: the cookie lives on a domain shared
72
+ * with VTEX's own checkout, so hardening it is a separate, explicitly tested
73
+ * change.
74
+ */
75
+ const buildAuthCookies = (
76
+ payload: SwitchPropertiesResponse,
77
+ secure: boolean
78
+ ): string[] => {
79
+ const maxAge = resolveMaxAge(payload.expiresIn)
80
+ const secureAttr = secure ? '; Secure' : ''
81
+
82
+ return [payload.authCookie, payload.accountAuthCookie]
83
+ .filter(isAuthCookie)
84
+ .map(
85
+ (cookie) =>
86
+ `${cookie.Name}=${cookie.Value}; Path=/; Max-Age=${maxAge}; SameSite=Lax${secureAttr}`
87
+ )
88
+ }
89
+
90
+ const buildOrderFormExpiryCookies = (request: NextApiRequest): string[] => {
91
+ const hostname = getRequestHostname(request.headers.host)
92
+ const domains = hostname ? getCookieDomains(hostname) : [undefined]
93
+ const cookies = parse(request.headers.cookie ?? '')
94
+
95
+ return ORDER_FORM_COOKIES.filter((name) => cookies[name]).flatMap((name) =>
96
+ domains.map((domain) => expireCookieServer({ name, path: '/', domain }))
97
+ )
98
+ }
99
+
100
+ /**
101
+ * Switches the buyer's active contract and drops the previous contract's
102
+ * checkout orderForm in a single response.
103
+ *
104
+ * Relays to VTEX Identity server-side, forwarding the buyer cookie as the only
105
+ * credential — no app keys involved, same as `commerce.storeFront`. On success
106
+ * the new auth cookie and the orderForm expiry travel in the same `Set-Cookie`
107
+ * batch, so the browser can never end up authenticated as the new contract
108
+ * while still carrying the old contract's orderForm. On any failure nothing is
109
+ * emitted at all and the previous contract stays active.
110
+ */
111
+ const handler: NextApiHandler<SwitchContractResponse> = async (
112
+ request: NextApiRequest,
113
+ response: NextApiResponse<SwitchContractResponse>
114
+ ) => {
115
+ if (request.method !== 'POST') {
116
+ response.status(405).end()
117
+ return
118
+ }
119
+
120
+ const { contractId } = request.body ?? {}
121
+
122
+ if (!contractId || typeof contractId !== 'string') {
123
+ response
124
+ .status(400)
125
+ .json({ success: false, error: 'contractId is required' })
126
+ return
127
+ }
128
+
129
+ const account = storeConfig.api.storeId
130
+ const an = encodeURIComponent(account)
131
+ const url = `https://${account}.myvtex.com/api/authenticator/storefront/credential/switch-properties?an=${an}`
132
+
133
+ try {
134
+ const switchResponse = await fetch(url, {
135
+ method: 'POST',
136
+ headers: {
137
+ 'Content-Type': 'application/json',
138
+ cookie: request.headers.cookie ?? '',
139
+ },
140
+ body: JSON.stringify({ properties: { customerId: contractId } }),
141
+ signal: AbortSignal.timeout(SWITCH_PROPERTIES_TIMEOUT_MS),
142
+ // A redirect is never a successful switch, and following one would carry
143
+ // the buyer's cookie to whatever target the upstream names (CVE-2023-45143
144
+ // on Node < 20.8.1). Fail closed instead.
145
+ redirect: 'error',
146
+ })
147
+
148
+ if (!switchResponse.ok) {
149
+ const unauthorized =
150
+ switchResponse.status === 401 || switchResponse.status === 403
151
+
152
+ response
153
+ .status(unauthorized ? 401 : 502)
154
+ .json({ success: false, error: 'Failed to switch contract' })
155
+ return
156
+ }
157
+
158
+ const payload = (await switchResponse.json()) as SwitchPropertiesResponse
159
+
160
+ if (
161
+ payload.authStatus?.toLowerCase() !== 'success' ||
162
+ !isAuthCookie(payload.authCookie)
163
+ ) {
164
+ response
165
+ .status(502)
166
+ .json({ success: false, error: 'Contract switch was not successful' })
167
+ return
168
+ }
169
+
170
+ response.setHeader('Set-Cookie', [
171
+ ...buildAuthCookies(payload, isSecureRequest(request)),
172
+ ...buildOrderFormExpiryCookies(request),
173
+ ])
174
+
175
+ response.status(200).json({ success: true })
176
+ } catch (error) {
177
+ const timedOut =
178
+ error instanceof Error &&
179
+ (error.name === 'TimeoutError' || error.name === 'AbortError')
180
+
181
+ // Only the error name is logged: the request and response carry the buyer's
182
+ // credentials.
183
+ console.error(
184
+ 'switch-contract: upstream failure',
185
+ error instanceof Error ? error.name : 'unknown'
186
+ )
187
+
188
+ response
189
+ .status(timedOut ? 504 : 502)
190
+ .json({ success: false, error: 'Failed to switch contract' })
191
+ }
192
+ }
193
+
194
+ export default handler
@@ -56,6 +56,7 @@ function Page({
56
56
  )
57
57
 
58
58
  const storeUrl = getStoreURL(router.locale)
59
+ const searchStoreUrl = storeUrl.replace(/\/$/, '')
59
60
 
60
61
  return (
61
62
  <>
@@ -79,7 +80,7 @@ function Page({
79
80
  url={storeUrl}
80
81
  potentialActions={[
81
82
  {
82
- target: `${storeUrl}/s/?q`,
83
+ target: `${searchStoreUrl}/s/?q`,
83
84
  queryInput: 'search_term_string',
84
85
  },
85
86
  ]}
package/src/pages/s.tsx CHANGED
@@ -54,7 +54,8 @@ type StoreConfig = typeof storeConfig
54
54
  function generateSEOData(
55
55
  storeConfig: StoreConfig,
56
56
  searchTerm?: string,
57
- pageSeoSettings?: SearchSettings['settings']['seo']
57
+ pageSeoSettings?: SearchSettings['settings']['seo'],
58
+ locale?: string
58
59
  ) {
59
60
  const { search: searchSeo, ...seo } = storeConfig.seo
60
61
 
@@ -88,7 +89,10 @@ function generateSEOData(
88
89
  }
89
90
 
90
91
  const canonical = searchTerm
91
- ? `${getStoreURL()}/s?q=${searchTerm.replaceAll(' ', '+')}`
92
+ ? `${getStoreURL(locale).replace(/\/$/, '')}/s?q=${searchTerm.replaceAll(
93
+ ' ',
94
+ '+'
95
+ )}`
92
96
  : undefined
93
97
 
94
98
  return {
@@ -114,6 +118,7 @@ function Page({
114
118
  const { sections: globalSections, settings: globalSettings } =
115
119
  globalSectionsProp ?? {}
116
120
  const { settings } = searchContentType
121
+ const { locale } = useRouter()
117
122
  const applySearchState = useApplySearchState()
118
123
  const searchParams = useSearchParams({
119
124
  sort: settings?.productGallery?.sortBySelection as SearchState['sort'],
@@ -138,7 +143,8 @@ function Page({
138
143
  const { noindex, nofollow, ...seoData } = generateSEOData(
139
144
  storeConfig,
140
145
  effectiveSearchTerm,
141
- settings?.seo
146
+ settings?.seo,
147
+ locale
142
148
  )
143
149
 
144
150
  return (