@faststore/core 4.6.0-dev.0 → 4.6.0-dev.2
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 +3 -3
- package/.turbo/turbo-test.log +102 -91
- package/@generated/gql.ts +3 -3
- package/@generated/graphql.ts +8 -2
- package/@generated/persisted-documents.json +1 -1
- package/@generated/schema.graphql +6 -0
- package/CHANGELOG.md +45 -0
- package/package.json +6 -6
- package/src/components/product/ProductGrid/ProductGrid.tsx +1 -0
- package/src/components/skeletons/ProductGridSkeleton/ProductGridSkeleton.tsx +4 -1
- package/src/components/templates/SearchPage/SearchWrapper.tsx +15 -5
- package/src/components/ui/ProductGallery/GalleryPageHeightLock.tsx +238 -0
- package/src/components/ui/ProductGallery/ProductGallery.tsx +69 -24
- package/src/sdk/cart/index.ts +15 -1
- package/src/sdk/cart/waitForSessionValidated.ts +43 -0
- package/src/sdk/session/index.ts +14 -0
- package/src/sdk/session/syncSalesChannelFromOrderForm.ts +68 -0
- package/src/sdk/ui/useScrollRestoration.tsx +616 -32
- package/src/styles/global/index.scss +12 -0
- package/test/components/skeletons/ProductGridSkeleton.browser.test.tsx +32 -0
- package/test/components/templates/SearchPage/SearchWrapper.browser.test.tsx +137 -0
- package/test/components/ui/ProductGallery/GalleryPageHeightLock.browser.test.tsx +230 -0
- package/test/sdk/cart/waitForSessionValidated.test.ts +96 -0
- package/test/sdk/session/syncSalesChannelFromOrderForm.test.ts +125 -0
- package/test/sdk/ui/useScrollRestoration.browser.test.ts +798 -0
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { useEffect, useRef } from 'react'
|
|
1
2
|
import { useSearch } from '@faststore/sdk'
|
|
2
3
|
import { useRouter } from 'next/router'
|
|
3
4
|
|
|
@@ -40,6 +41,19 @@ export default function SearchWrapper({
|
|
|
40
41
|
selectedFacets,
|
|
41
42
|
})
|
|
42
43
|
|
|
44
|
+
const redirectUrl = pageProductGalleryData?.redirect?.url
|
|
45
|
+
const lastRedirectedUrl = useRef<string | null>(null)
|
|
46
|
+
|
|
47
|
+
// Intelligent Search redirects must run in an effect — calling router.replace
|
|
48
|
+
// during render re-fires on every state update (e.g. scrollRestoration's
|
|
49
|
+
// resetInfiniteScroll) and loops until the tab hangs.
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
if (!redirectUrl || lastRedirectedUrl.current === redirectUrl) return
|
|
52
|
+
|
|
53
|
+
lastRedirectedUrl.current = redirectUrl
|
|
54
|
+
void router.replace(redirectUrl)
|
|
55
|
+
}, [redirectUrl, router])
|
|
56
|
+
|
|
43
57
|
const emptySearchProps = storeConfig.experimental.enableSearchSSR
|
|
44
58
|
? {
|
|
45
59
|
title: storeConfig.seo.search.bodyH1 ?? 'Showing results for:',
|
|
@@ -58,11 +72,7 @@ export default function SearchWrapper({
|
|
|
58
72
|
}
|
|
59
73
|
|
|
60
74
|
// Redirect when there are registered Intelligent Search redirects on VTEX Admin
|
|
61
|
-
if (
|
|
62
|
-
router.replace(pageProductGalleryData?.redirect?.url, null, {
|
|
63
|
-
shallow: true,
|
|
64
|
-
})
|
|
65
|
-
|
|
75
|
+
if (redirectUrl) {
|
|
66
76
|
return (
|
|
67
77
|
<PageProvider context={{ globalSettings }}>
|
|
68
78
|
<RenderSections globalSections={globalSections}>
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useEffect,
|
|
3
|
+
useMemo,
|
|
4
|
+
useRef,
|
|
5
|
+
useState,
|
|
6
|
+
type CSSProperties,
|
|
7
|
+
type PropsWithChildren,
|
|
8
|
+
} from 'react'
|
|
9
|
+
import { useSearch } from '@faststore/sdk'
|
|
10
|
+
|
|
11
|
+
/** Coarse layout buckets — page height differs a lot across these. */
|
|
12
|
+
export function getGalleryViewportBucket() {
|
|
13
|
+
if (globalThis.window === undefined) return 'ssr'
|
|
14
|
+
const width = globalThis.innerWidth
|
|
15
|
+
if (width <= 420) return 'mobile'
|
|
16
|
+
if (width <= 768) return 'tablet'
|
|
17
|
+
if (width < 1280) return 'notebook'
|
|
18
|
+
return 'desktop'
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** 32-bit string hash — avoids truncating long facet/path keys into collisions. */
|
|
22
|
+
export function hashGalleryPageHeightKey(value: string) {
|
|
23
|
+
let hash = 0
|
|
24
|
+
for (const char of value) {
|
|
25
|
+
hash = Math.trunc(Math.imul(31, hash) + (char.codePointAt(0) ?? 0))
|
|
26
|
+
}
|
|
27
|
+
return (hash >>> 0).toString(36)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function buildGalleryPageHeightKey(
|
|
31
|
+
page: number,
|
|
32
|
+
search: {
|
|
33
|
+
path: string
|
|
34
|
+
term: string | null
|
|
35
|
+
sort: string | null
|
|
36
|
+
selectedFacets: unknown
|
|
37
|
+
viewport?: string
|
|
38
|
+
}
|
|
39
|
+
) {
|
|
40
|
+
const viewport = search.viewport ?? getGalleryViewportBucket()
|
|
41
|
+
const raw = JSON.stringify({
|
|
42
|
+
path: search.path,
|
|
43
|
+
term: search.term,
|
|
44
|
+
sort: search.sort,
|
|
45
|
+
selectedFacets: search.selectedFacets ?? [],
|
|
46
|
+
page,
|
|
47
|
+
viewport,
|
|
48
|
+
})
|
|
49
|
+
// Hash the full payload so long paths/facets never truncate page/viewport away.
|
|
50
|
+
return `__fs_gallery_page_h_p${page}_${viewport}_${hashGalleryPageHeightKey(raw)}`
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function readGalleryPageHeight(key: string): number | null {
|
|
54
|
+
try {
|
|
55
|
+
const raw = sessionStorage.getItem(key)
|
|
56
|
+
if (!raw) return null
|
|
57
|
+
const height = Number(raw)
|
|
58
|
+
return Number.isFinite(height) && height > 0 ? height : null
|
|
59
|
+
} catch {
|
|
60
|
+
return null
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function writeGalleryPageHeight(key: string, height: number) {
|
|
65
|
+
try {
|
|
66
|
+
sessionStorage.setItem(key, String(Math.round(height)))
|
|
67
|
+
} catch {
|
|
68
|
+
// ignore quota / private mode
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Sum of last-measured heights for the active infinite-scroll pages.
|
|
74
|
+
* Used to reserve space for the whole gallery before individual pages mount.
|
|
75
|
+
*/
|
|
76
|
+
export function getReservedGalleryHeight(
|
|
77
|
+
pages: number[],
|
|
78
|
+
search: {
|
|
79
|
+
path: string
|
|
80
|
+
term: string | null
|
|
81
|
+
sort: string | null
|
|
82
|
+
selectedFacets: unknown
|
|
83
|
+
viewport?: string
|
|
84
|
+
}
|
|
85
|
+
) {
|
|
86
|
+
return pages.reduce((sum, page) => {
|
|
87
|
+
const height = readGalleryPageHeight(
|
|
88
|
+
buildGalleryPageHeightKey(page, search)
|
|
89
|
+
)
|
|
90
|
+
return sum + (height ?? 0)
|
|
91
|
+
}, 0)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Client-only reserved height for the results column while products load. */
|
|
95
|
+
export function resolveReservedGalleryHeight(
|
|
96
|
+
hasProductsLoaded: boolean,
|
|
97
|
+
pages: number[],
|
|
98
|
+
search: {
|
|
99
|
+
path: string
|
|
100
|
+
term: string | null
|
|
101
|
+
sort: string | null
|
|
102
|
+
selectedFacets: unknown
|
|
103
|
+
viewport?: string
|
|
104
|
+
}
|
|
105
|
+
) {
|
|
106
|
+
if (hasProductsLoaded) return 0
|
|
107
|
+
return getReservedGalleryHeight(pages, search)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
type Props = PropsWithChildren<{
|
|
111
|
+
page: number
|
|
112
|
+
}>
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Locks the gallery page slot to the last measured height **only while the
|
|
116
|
+
* skeleton is showing**. Once real product cards are mounted, the lock is
|
|
117
|
+
* released so a viewport resize cannot leave huge empty gaps between pages.
|
|
118
|
+
*/
|
|
119
|
+
function GalleryPageHeightLock({ page, children }: Props) {
|
|
120
|
+
const {
|
|
121
|
+
state: { term, sort, selectedFacets },
|
|
122
|
+
} = useSearch()
|
|
123
|
+
|
|
124
|
+
const [viewport, setViewport] = useState(getGalleryViewportBucket)
|
|
125
|
+
|
|
126
|
+
useEffect(() => {
|
|
127
|
+
const onResize = () => setViewport(getGalleryViewportBucket())
|
|
128
|
+
globalThis.addEventListener('resize', onResize)
|
|
129
|
+
return () => globalThis.removeEventListener('resize', onResize)
|
|
130
|
+
}, [])
|
|
131
|
+
|
|
132
|
+
const storageKey = useMemo(() => {
|
|
133
|
+
const path =
|
|
134
|
+
globalThis.window === undefined ? '' : globalThis.location.pathname
|
|
135
|
+
return buildGalleryPageHeightKey(page, {
|
|
136
|
+
path,
|
|
137
|
+
term: term ?? null,
|
|
138
|
+
sort: sort ?? null,
|
|
139
|
+
selectedFacets,
|
|
140
|
+
viewport,
|
|
141
|
+
})
|
|
142
|
+
}, [term, sort, selectedFacets, page, viewport])
|
|
143
|
+
|
|
144
|
+
const ref = useRef<HTMLDivElement>(null)
|
|
145
|
+
/** Height applied only while this page has no product cards yet. */
|
|
146
|
+
// Start null on SSR and first client paint; storageKey effect applies the
|
|
147
|
+
// sessionStorage value after mount to avoid hydration mismatches.
|
|
148
|
+
const [pendingMinHeight, setPendingMinHeight] = useState<number | null>(null)
|
|
149
|
+
const [isLocked, setIsLocked] = useState(true)
|
|
150
|
+
|
|
151
|
+
useEffect(() => {
|
|
152
|
+
setPendingMinHeight(readGalleryPageHeight(storageKey))
|
|
153
|
+
// Re-lock briefly when the viewport bucket / search key changes so back-nav
|
|
154
|
+
// skeletons on a new breakpoint still get the right reserved height.
|
|
155
|
+
setIsLocked(true)
|
|
156
|
+
}, [storageKey])
|
|
157
|
+
|
|
158
|
+
useEffect(() => {
|
|
159
|
+
const el = ref.current
|
|
160
|
+
if (!el) return
|
|
161
|
+
|
|
162
|
+
const syncFromDom = () => {
|
|
163
|
+
const hasProducts = Boolean(
|
|
164
|
+
el.querySelector(
|
|
165
|
+
'[data-fs-product-card], a[data-testid="product-link"]'
|
|
166
|
+
)
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
if (!hasProducts) {
|
|
170
|
+
// Skeleton / empty — keep (or restore) the reserved minHeight.
|
|
171
|
+
setIsLocked(true)
|
|
172
|
+
return
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Products are visible: drop the lock so natural height can shrink on
|
|
176
|
+
// resize, then persist the unconstrained content height for next back-nav.
|
|
177
|
+
setIsLocked(false)
|
|
178
|
+
|
|
179
|
+
// Measure after minHeight is cleared (next frame).
|
|
180
|
+
requestAnimationFrame(() => {
|
|
181
|
+
const height = el.getBoundingClientRect().height
|
|
182
|
+
if (height < 80) return
|
|
183
|
+
const rounded = Math.round(height)
|
|
184
|
+
writeGalleryPageHeight(storageKey, rounded)
|
|
185
|
+
setPendingMinHeight(rounded)
|
|
186
|
+
})
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
syncFromDom()
|
|
190
|
+
|
|
191
|
+
const mutationObserver = new MutationObserver(syncFromDom)
|
|
192
|
+
mutationObserver.observe(el, { childList: true, subtree: true })
|
|
193
|
+
|
|
194
|
+
let resizeObserver: ResizeObserver | null = null
|
|
195
|
+
if (typeof ResizeObserver !== 'undefined') {
|
|
196
|
+
resizeObserver = new ResizeObserver(() => {
|
|
197
|
+
// Only persist when unlocked (real layout). Observing while locked
|
|
198
|
+
// would re-read the artificial minHeight and never shrink.
|
|
199
|
+
if (
|
|
200
|
+
!el.querySelector(
|
|
201
|
+
'[data-fs-product-card], a[data-testid="product-link"]'
|
|
202
|
+
)
|
|
203
|
+
) {
|
|
204
|
+
return
|
|
205
|
+
}
|
|
206
|
+
const height = el.getBoundingClientRect().height
|
|
207
|
+
if (height < 80) return
|
|
208
|
+
const rounded = Math.round(height)
|
|
209
|
+
writeGalleryPageHeight(storageKey, rounded)
|
|
210
|
+
setPendingMinHeight(rounded)
|
|
211
|
+
})
|
|
212
|
+
resizeObserver.observe(el)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return () => {
|
|
216
|
+
mutationObserver.disconnect()
|
|
217
|
+
resizeObserver?.disconnect()
|
|
218
|
+
}
|
|
219
|
+
// Re-sync when children swap (e.g. skeleton → product grid). MutationObserver
|
|
220
|
+
// alone is not reliable across React rerenders in jsdom/CI.
|
|
221
|
+
}, [storageKey, children])
|
|
222
|
+
|
|
223
|
+
const style: CSSProperties | undefined =
|
|
224
|
+
isLocked && pendingMinHeight ? { minHeight: pendingMinHeight } : undefined
|
|
225
|
+
|
|
226
|
+
return (
|
|
227
|
+
<div
|
|
228
|
+
ref={ref}
|
|
229
|
+
data-fs-gallery-page-height-lock
|
|
230
|
+
data-fs-gallery-page={page}
|
|
231
|
+
style={style}
|
|
232
|
+
>
|
|
233
|
+
{children}
|
|
234
|
+
</div>
|
|
235
|
+
)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export default GalleryPageHeightLock
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { NextSeo } from 'next-seo'
|
|
2
2
|
import dynamic from 'next/dynamic'
|
|
3
|
-
import { Suspense, useState, type MouseEvent } from 'react'
|
|
3
|
+
import { Suspense, useEffect, useState, type MouseEvent } from 'react'
|
|
4
4
|
|
|
5
5
|
import { useSearch } from '@faststore/sdk'
|
|
6
6
|
import { useUI } from '@faststore/ui'
|
|
@@ -24,6 +24,10 @@ import useScreenResize from 'src/sdk/ui/useScreenResize'
|
|
|
24
24
|
|
|
25
25
|
import { useFormattedPrice } from 'src/sdk/product/useFormattedPrice'
|
|
26
26
|
import styles from '../../sections/ProductGallery/section.module.scss'
|
|
27
|
+
import GalleryPageHeightLock, {
|
|
28
|
+
getGalleryViewportBucket,
|
|
29
|
+
resolveReservedGalleryHeight,
|
|
30
|
+
} from './GalleryPageHeightLock'
|
|
27
31
|
import ProductGalleryPage from './ProductGalleryPage'
|
|
28
32
|
const FilterSkeleton = dynamic(
|
|
29
33
|
() =>
|
|
@@ -33,8 +37,6 @@ const FilterSkeleton = dynamic(
|
|
|
33
37
|
)
|
|
34
38
|
)
|
|
35
39
|
|
|
36
|
-
const GalleryPageSkeleton = <ProductGridSkeleton loading />
|
|
37
|
-
|
|
38
40
|
export interface ProductGalleryProps {
|
|
39
41
|
title?: string
|
|
40
42
|
searchTerm?: string
|
|
@@ -131,7 +133,13 @@ function ProductGallery({
|
|
|
131
133
|
} = useOverrideComponents<'ProductGallery'>()
|
|
132
134
|
|
|
133
135
|
const { openFilter, filter: displayFilter } = useUI()
|
|
134
|
-
const {
|
|
136
|
+
const {
|
|
137
|
+
pages,
|
|
138
|
+
addNextPage,
|
|
139
|
+
addPrevPage,
|
|
140
|
+
itemsPerPage,
|
|
141
|
+
state: { term, sort, selectedFacets },
|
|
142
|
+
} = useSearch()
|
|
135
143
|
const context = usePage<SearchPageContext | PLPContext>()
|
|
136
144
|
const data = context?.data
|
|
137
145
|
const facets = useDelayedFacets(data) ?? []
|
|
@@ -151,6 +159,25 @@ function ProductGallery({
|
|
|
151
159
|
(data as PLPContext['data'])?.collection?.meta?.selectedFacets ?? []
|
|
152
160
|
const filter = useFilter(facets, initialSelectedFacets)
|
|
153
161
|
|
|
162
|
+
// Keep the results column tall on PDP→PLP back nav before pages remount.
|
|
163
|
+
// Only while products are loading — once grids paint, natural height wins
|
|
164
|
+
// (avoids huge gaps after a viewport resize with a stale reserved minHeight).
|
|
165
|
+
// Populate after mount so SSR/hydration always start at 0 (sessionStorage is
|
|
166
|
+
// client-only and would otherwise mismatch).
|
|
167
|
+
const [reservedGalleryHeight, setReservedGalleryHeight] = useState(0)
|
|
168
|
+
|
|
169
|
+
useEffect(() => {
|
|
170
|
+
setReservedGalleryHeight(
|
|
171
|
+
resolveReservedGalleryHeight(hasProductsLoaded, pages, {
|
|
172
|
+
path: globalThis.location.pathname,
|
|
173
|
+
term: term ?? null,
|
|
174
|
+
sort: sort ?? null,
|
|
175
|
+
selectedFacets,
|
|
176
|
+
viewport: getGalleryViewportBucket(),
|
|
177
|
+
})
|
|
178
|
+
)
|
|
179
|
+
}, [pages, term, sort, selectedFacets, hasProductsLoaded])
|
|
180
|
+
|
|
154
181
|
return (
|
|
155
182
|
<section data-testid="product-gallery" data-fs-product-listing>
|
|
156
183
|
{searchTerm && (
|
|
@@ -272,7 +299,14 @@ function ProductGallery({
|
|
|
272
299
|
)}
|
|
273
300
|
</FilterButtonSkeleton.Component>
|
|
274
301
|
</div>
|
|
275
|
-
<div
|
|
302
|
+
<div
|
|
303
|
+
data-fs-product-listing-results
|
|
304
|
+
style={
|
|
305
|
+
reservedGalleryHeight > 0
|
|
306
|
+
? { minHeight: reservedGalleryHeight }
|
|
307
|
+
: undefined
|
|
308
|
+
}
|
|
309
|
+
>
|
|
276
310
|
{/* Add link to previous page. This helps on SEO */}
|
|
277
311
|
{!!prev && (
|
|
278
312
|
<div data-fs-product-listing-pagination="top">
|
|
@@ -313,25 +347,36 @@ function ProductGallery({
|
|
|
313
347
|
</LinkButtonPrev.Component>
|
|
314
348
|
</div>
|
|
315
349
|
)}
|
|
316
|
-
{/* Render ALL products
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
350
|
+
{/* Render ALL products.
|
|
351
|
+
Each page has its own Suspense boundary so a refetch on page N
|
|
352
|
+
does not unmount pages that already have data (avoids CLS when
|
|
353
|
+
returning from a PDP with infinite-scroll pages restored).
|
|
354
|
+
GalleryPageHeightLock keeps the last measured page height while
|
|
355
|
+
skeletons are shown so the list does not collapse — including
|
|
356
|
+
the initial !hasProductsLoaded frame. */}
|
|
357
|
+
{pages.map((page) => (
|
|
358
|
+
<GalleryPageHeightLock key={`gallery-page-${page}`} page={page}>
|
|
359
|
+
{hasProductsLoaded ? (
|
|
360
|
+
<Suspense
|
|
361
|
+
fallback={
|
|
362
|
+
<ProductGridSkeleton loading count={itemsPerPage} />
|
|
363
|
+
}
|
|
364
|
+
>
|
|
365
|
+
<ProductGalleryPage
|
|
366
|
+
page={page}
|
|
367
|
+
title={title}
|
|
368
|
+
productCard={productCard}
|
|
369
|
+
itemsPerPage={itemsPerPage}
|
|
370
|
+
firstPage={pages[0]}
|
|
371
|
+
shouldShowComparison={showComparisonProducts}
|
|
372
|
+
compareLabel={productComparison?.labels?.compareButton}
|
|
373
|
+
/>
|
|
374
|
+
</Suspense>
|
|
375
|
+
) : (
|
|
376
|
+
<ProductGridSkeleton loading count={itemsPerPage} />
|
|
377
|
+
)}
|
|
378
|
+
</GalleryPageHeightLock>
|
|
379
|
+
))}
|
|
335
380
|
{/* Add link to next page. This helps on SEO */}
|
|
336
381
|
{next !== false && (
|
|
337
382
|
<div data-fs-product-listing-pagination="bottom">
|
package/src/sdk/cart/index.ts
CHANGED
|
@@ -13,8 +13,10 @@ import type {
|
|
|
13
13
|
|
|
14
14
|
import storeConfig from '../../../discovery.config'
|
|
15
15
|
import { request } from '../graphql/request'
|
|
16
|
-
import { sessionStore } from '../session'
|
|
16
|
+
import { hasValidatedSessionStore, sessionStore } from '../session'
|
|
17
|
+
import { syncSalesChannelFromOrderForm } from '../session/syncSalesChannelFromOrderForm'
|
|
17
18
|
import { createValidationStore, useStore } from '../useStore'
|
|
19
|
+
import { waitForSessionValidated } from './waitForSessionValidated'
|
|
18
20
|
|
|
19
21
|
export interface CartItem
|
|
20
22
|
extends SDKCartItem,
|
|
@@ -33,6 +35,7 @@ export const ValidateCartMutation = gql(`
|
|
|
33
35
|
validateCart(cart: $cart, session: $session) {
|
|
34
36
|
order {
|
|
35
37
|
orderNumber
|
|
38
|
+
salesChannel
|
|
36
39
|
acceptedOffer {
|
|
37
40
|
...CartItem
|
|
38
41
|
}
|
|
@@ -114,6 +117,8 @@ const getItemId = (item: Pick<CartItem, 'itemOffered' | 'seller' | 'price'>) =>
|
|
|
114
117
|
.join('::')
|
|
115
118
|
|
|
116
119
|
const validateCart = async (cart: Cart): Promise<Cart | null> => {
|
|
120
|
+
await waitForSessionValidated(hasValidatedSessionStore)
|
|
121
|
+
|
|
117
122
|
const { validateCart: validated = null } = await request<
|
|
118
123
|
ValidateCartMutationMutation,
|
|
119
124
|
ValidateCartMutationMutationVariables
|
|
@@ -151,6 +156,15 @@ const validateCart = async (cart: Cart): Promise<Cart | null> => {
|
|
|
151
156
|
},
|
|
152
157
|
})
|
|
153
158
|
|
|
159
|
+
const adoptedSalesChannel = validated?.order?.salesChannel
|
|
160
|
+
if (adoptedSalesChannel) {
|
|
161
|
+
syncSalesChannelFromOrderForm(
|
|
162
|
+
adoptedSalesChannel,
|
|
163
|
+
() => sessionStore.read(),
|
|
164
|
+
(session) => sessionStore.setSilent(session)
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
|
|
154
168
|
return (
|
|
155
169
|
validated && {
|
|
156
170
|
id: validated.order.orderNumber,
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { Store } from '@faststore/sdk'
|
|
2
|
+
|
|
3
|
+
/** Max time to wait for the first validateSession before validating the cart. */
|
|
4
|
+
export const SESSION_VALIDATION_WAIT_MS = 3_000
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Defers cart validation until session has been validated at least once.
|
|
8
|
+
* This reduces first-paint races between Session and Cart; sales-channel
|
|
9
|
+
* correctness after external flows (e.g. Quick Order) is enforced in the API
|
|
10
|
+
* by preserving/adopting the orderForm SC.
|
|
11
|
+
*/
|
|
12
|
+
export const waitForSessionValidated = async (
|
|
13
|
+
hasValidatedSessionStore: Store<boolean>,
|
|
14
|
+
waitMs = SESSION_VALIDATION_WAIT_MS
|
|
15
|
+
): Promise<void> => {
|
|
16
|
+
if (hasValidatedSessionStore.read()) {
|
|
17
|
+
return
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
await new Promise<void>((resolve) => {
|
|
21
|
+
const timeoutId = setTimeout(() => {
|
|
22
|
+
unsubscribe()
|
|
23
|
+
resolve()
|
|
24
|
+
}, waitMs)
|
|
25
|
+
|
|
26
|
+
const unsubscribe = hasValidatedSessionStore.subscribe((validated) => {
|
|
27
|
+
if (!validated) {
|
|
28
|
+
return
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
clearTimeout(timeoutId)
|
|
32
|
+
unsubscribe()
|
|
33
|
+
resolve()
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
// Race: validation may have finished between read() and subscribe()
|
|
37
|
+
if (hasValidatedSessionStore.read()) {
|
|
38
|
+
clearTimeout(timeoutId)
|
|
39
|
+
unsubscribe()
|
|
40
|
+
resolve()
|
|
41
|
+
}
|
|
42
|
+
})
|
|
43
|
+
}
|
package/src/sdk/session/index.ts
CHANGED
|
@@ -194,6 +194,9 @@ export const validateSession = async (session: Session) => {
|
|
|
194
194
|
const [validationStore, onValidate, hasValidatedStore] =
|
|
195
195
|
createValidationStore(validateSession)
|
|
196
196
|
|
|
197
|
+
/** True after at least one validateSession cycle finished (success or failure). */
|
|
198
|
+
export const hasValidatedSessionStore = hasValidatedStore
|
|
199
|
+
|
|
197
200
|
const urlAwareInitialSession = getInitialSession()
|
|
198
201
|
const defaultStore = createSessionStore(
|
|
199
202
|
urlAwareInitialSession,
|
|
@@ -214,6 +217,17 @@ export const sessionStore = {
|
|
|
214
217
|
// Trigger cart revalidation when session changes
|
|
215
218
|
cartStore.set(cartStore.read())
|
|
216
219
|
},
|
|
220
|
+
/**
|
|
221
|
+
* Updates session without revalidating the cart. Used when aligning
|
|
222
|
+
* `channel.salesChannel` to an orderForm SC adopted by `validateCart`.
|
|
223
|
+
* Still runs `validateSession` via the store's optimistic validator; that
|
|
224
|
+
* path keeps an explicit (non-default) client SC.
|
|
225
|
+
*/
|
|
226
|
+
setSilent: (val: Session) => {
|
|
227
|
+
if (deepEqual(val, defaultStore.read()) === true) return
|
|
228
|
+
|
|
229
|
+
defaultStore.set(val)
|
|
230
|
+
},
|
|
217
231
|
}
|
|
218
232
|
|
|
219
233
|
export async function logoutAndClearSession(session: Session) {
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { Session } from '@faststore/sdk'
|
|
2
|
+
|
|
3
|
+
function parseChannelRecord(
|
|
4
|
+
raw: string | null | undefined
|
|
5
|
+
): Record<string, unknown> {
|
|
6
|
+
try {
|
|
7
|
+
const parsed: unknown = JSON.parse(raw || '{}')
|
|
8
|
+
|
|
9
|
+
if (
|
|
10
|
+
parsed !== null &&
|
|
11
|
+
typeof parsed === 'object' &&
|
|
12
|
+
!Array.isArray(parsed)
|
|
13
|
+
) {
|
|
14
|
+
return parsed as Record<string, unknown>
|
|
15
|
+
}
|
|
16
|
+
} catch {
|
|
17
|
+
// Malformed channel — reset below.
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return {}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function readSalesChannel(channel: Record<string, unknown>): string {
|
|
24
|
+
const value = channel.salesChannel
|
|
25
|
+
|
|
26
|
+
if (typeof value === 'string' || typeof value === 'number') {
|
|
27
|
+
return String(value)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return ''
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Aligns `fs::session.channel` with a sales channel adopted from the orderForm
|
|
35
|
+
* (e.g. after Quick Order) without revalidating the cart.
|
|
36
|
+
*
|
|
37
|
+
* Uses `sessionStore.setSilent` so we do not trigger `cartStore.set` mid-flight
|
|
38
|
+
* (that race previously wiped SC-exclusive items). Marks the channel as
|
|
39
|
+
* explicit (`hasOnlyDefaultSalesChannel: false`) so `validateSession` keeps it.
|
|
40
|
+
*/
|
|
41
|
+
export function syncSalesChannelFromOrderForm(
|
|
42
|
+
salesChannel: string | null | undefined,
|
|
43
|
+
readSession: () => Session,
|
|
44
|
+
setSilent: (session: Session) => void
|
|
45
|
+
): boolean {
|
|
46
|
+
if (salesChannel == null || salesChannel === '') {
|
|
47
|
+
return false
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const session = readSession()
|
|
51
|
+
const channel = parseChannelRecord(session.channel)
|
|
52
|
+
const nextSalesChannel = String(salesChannel)
|
|
53
|
+
|
|
54
|
+
if (readSalesChannel(channel) === nextSalesChannel) {
|
|
55
|
+
return false
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
setSilent({
|
|
59
|
+
...session,
|
|
60
|
+
channel: JSON.stringify({
|
|
61
|
+
...channel,
|
|
62
|
+
salesChannel: nextSalesChannel,
|
|
63
|
+
hasOnlyDefaultSalesChannel: false,
|
|
64
|
+
}),
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
return true
|
|
68
|
+
}
|