@fayz-ai/storefront 0.7.0 → 0.8.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/README.md +3 -1
- package/dist/auth.d.ts.map +1 -1
- package/dist/components/CartDrawer.d.ts.map +1 -1
- package/dist/components/DeliveryEstimator.d.ts +17 -0
- package/dist/components/DeliveryEstimator.d.ts.map +1 -0
- package/dist/components/ProductGallery.d.ts +22 -0
- package/dist/components/ProductGallery.d.ts.map +1 -0
- package/dist/components/StorefrontHeader.d.ts.map +1 -1
- package/dist/config.d.ts +8 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/createStorefrontApp.d.ts.map +1 -1
- package/dist/index.cjs +886 -370
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +790 -275
- package/dist/index.js.map +1 -1
- package/dist/pages/CheckoutPage.d.ts.map +1 -1
- package/dist/pages/ProductDetailPage.d.ts.map +1 -1
- package/dist/presets.d.ts +5 -0
- package/dist/presets.d.ts.map +1 -1
- package/dist/stores/cart.store.d.ts +21 -0
- package/dist/stores/cart.store.d.ts.map +1 -1
- package/dist/stores/delivery.store.d.ts +64 -0
- package/dist/stores/delivery.store.d.ts.map +1 -0
- package/dist/testids.d.ts +12 -0
- package/dist/testids.d.ts.map +1 -1
- package/dist/theme.d.ts +18 -2
- package/dist/theme.d.ts.map +1 -1
- package/dist/workflows/checkout.d.ts +5 -2
- package/dist/workflows/checkout.d.ts.map +1 -1
- package/package.json +13 -7
- package/src/auth.ts +57 -1
- package/src/components/CartDrawer.tsx +23 -1
- package/src/components/DeliveryEstimator.tsx +157 -0
- package/src/components/ProductGallery.tsx +174 -0
- package/src/components/StorefrontHeader.tsx +35 -8
- package/src/config.ts +15 -3
- package/src/createStorefrontApp.tsx +18 -1
- package/src/index.ts +1 -0
- package/src/pages/CheckoutPage.tsx +240 -134
- package/src/pages/ProductDetailPage.tsx +17 -13
- package/src/presets.ts +66 -0
- package/src/stores/cart.store.ts +37 -1
- package/src/stores/delivery.store.ts +152 -0
- package/src/testids.ts +13 -0
- package/src/theme.ts +33 -3
- package/src/workflows/checkout.ts +34 -6
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import React, { useEffect, useRef, useState } from 'react'
|
|
2
|
+
import type { ProductImage } from '@fayz-ai/shop/types'
|
|
3
|
+
import type { ProductGalleryProps } from '../component-contracts'
|
|
4
|
+
import { SmoothImage } from './SmoothImage'
|
|
5
|
+
import { TID } from '../testids'
|
|
6
|
+
import { storefrontComponentContracts } from '../component-selectors'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Product gallery: one framed image, hover-to-zoom, and a thumbnail strip when
|
|
10
|
+
* the product has more than one photo.
|
|
11
|
+
*
|
|
12
|
+
* It replaces a bare <img> that rendered only the primary image. Three things
|
|
13
|
+
* were wrong with that:
|
|
14
|
+
*
|
|
15
|
+
* · the frame was a grid child with the default `stretch`, so it grew to the
|
|
16
|
+
* height of the (much taller) details column and left a slab of empty dark
|
|
17
|
+
* box under a square photo;
|
|
18
|
+
* · `plg_shop_product_images` has always been a collection with sort_order and
|
|
19
|
+
* is_primary, and every extra photo a merchant uploaded was invisible;
|
|
20
|
+
* · zooming is table stakes for anything sold by its looks — food especially.
|
|
21
|
+
*
|
|
22
|
+
* Zoom is pointer-driven: the transform origin follows the cursor, so the part
|
|
23
|
+
* under the pointer is the part magnified. Disabled on coarse pointers, where
|
|
24
|
+
* there is no hover and a stuck 2× would just be a broken image.
|
|
25
|
+
*/
|
|
26
|
+
export function ProductGallery({ product, images, primaryImage }: ProductGalleryProps) {
|
|
27
|
+
const ordered = orderImages(images, primaryImage)
|
|
28
|
+
const [index, setIndex] = useState(0)
|
|
29
|
+
const [zoom, setZoom] = useState<{ x: number; y: number } | null>(null)
|
|
30
|
+
const frameRef = useRef<HTMLDivElement>(null)
|
|
31
|
+
const canHover = useCanHover()
|
|
32
|
+
|
|
33
|
+
// A different product (client-side navigation) must not keep the previous
|
|
34
|
+
// product's selected slide.
|
|
35
|
+
useEffect(() => { setIndex(0); setZoom(null) }, [product.id])
|
|
36
|
+
|
|
37
|
+
const current = ordered[Math.min(index, ordered.length - 1)]
|
|
38
|
+
|
|
39
|
+
function trackPointer(event: React.MouseEvent<HTMLDivElement>) {
|
|
40
|
+
if (!canHover) return
|
|
41
|
+
const frame = frameRef.current
|
|
42
|
+
if (!frame) return
|
|
43
|
+
const rect = frame.getBoundingClientRect()
|
|
44
|
+
setZoom({
|
|
45
|
+
x: ((event.clientX - rect.left) / rect.width) * 100,
|
|
46
|
+
y: ((event.clientY - rect.top) / rect.height) * 100,
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function step(delta: number) {
|
|
51
|
+
if (ordered.length < 2) return
|
|
52
|
+
setIndex((current) => (current + delta + ordered.length) % ordered.length)
|
|
53
|
+
setZoom(null)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<div className="flex flex-col gap-3 self-start">
|
|
58
|
+
<div
|
|
59
|
+
{...storefrontComponentContracts.productDetail.gallery}
|
|
60
|
+
ref={frameRef}
|
|
61
|
+
// `self-start` above and a fixed aspect here: the frame is sized by the
|
|
62
|
+
// image, never by whatever the column beside it happens to contain.
|
|
63
|
+
className="group relative aspect-square w-full overflow-hidden border bg-muted"
|
|
64
|
+
style={{ borderRadius: 'var(--sf-radius-card)' }}
|
|
65
|
+
onMouseMove={trackPointer}
|
|
66
|
+
onMouseLeave={() => setZoom(null)}
|
|
67
|
+
onKeyDown={(event) => {
|
|
68
|
+
if (event.key === 'ArrowRight') step(1)
|
|
69
|
+
if (event.key === 'ArrowLeft') step(-1)
|
|
70
|
+
}}
|
|
71
|
+
tabIndex={ordered.length > 1 ? 0 : -1}
|
|
72
|
+
role={ordered.length > 1 ? 'group' : undefined}
|
|
73
|
+
aria-label={ordered.length > 1 ? `Imagens de ${product.name}` : undefined}
|
|
74
|
+
>
|
|
75
|
+
{/*
|
|
76
|
+
The transform lives on a wrapper, not on the image: SmoothImage writes
|
|
77
|
+
its own inline `transition` for the load fade (SmoothImage.tsx:52,
|
|
78
|
+
spread AFTER any style passed in), which would have stretched the zoom
|
|
79
|
+
to 420ms and left it visibly lagging the cursor.
|
|
80
|
+
*/}
|
|
81
|
+
{current && (
|
|
82
|
+
<div
|
|
83
|
+
className="h-full w-full transition-transform duration-150 ease-out"
|
|
84
|
+
style={zoom
|
|
85
|
+
? { transform: 'scale(2)', transformOrigin: `${zoom.x}% ${zoom.y}%` }
|
|
86
|
+
: { transform: 'scale(1)' }}
|
|
87
|
+
>
|
|
88
|
+
<SmoothImage
|
|
89
|
+
key={current.id}
|
|
90
|
+
src={current.url}
|
|
91
|
+
alt={current.altText ?? product.name}
|
|
92
|
+
data-testid={TID.pdpGalleryImage}
|
|
93
|
+
className="h-full w-full object-cover"
|
|
94
|
+
/>
|
|
95
|
+
</div>
|
|
96
|
+
)}
|
|
97
|
+
|
|
98
|
+
{ordered.length > 1 && (
|
|
99
|
+
<>
|
|
100
|
+
<GalleryArrow side="left" onClick={() => step(-1)} />
|
|
101
|
+
<GalleryArrow side="right" onClick={() => step(1)} />
|
|
102
|
+
<span className="pointer-events-none absolute bottom-3 right-3 rounded-full bg-black/60 px-2 py-0.5 text-[11px] font-medium text-white">
|
|
103
|
+
{index + 1}/{ordered.length}
|
|
104
|
+
</span>
|
|
105
|
+
</>
|
|
106
|
+
)}
|
|
107
|
+
</div>
|
|
108
|
+
|
|
109
|
+
{ordered.length > 1 && (
|
|
110
|
+
<ul data-testid={TID.pdpGalleryThumbs} className="flex gap-2 overflow-x-auto pb-1">
|
|
111
|
+
{ordered.map((image, position) => (
|
|
112
|
+
<li key={image.id}>
|
|
113
|
+
<button
|
|
114
|
+
type="button"
|
|
115
|
+
data-testid={TID.pdpGalleryThumb}
|
|
116
|
+
data-index={position}
|
|
117
|
+
aria-label={`Imagem ${position + 1} de ${ordered.length}`}
|
|
118
|
+
aria-current={position === index}
|
|
119
|
+
onClick={() => { setIndex(position); setZoom(null) }}
|
|
120
|
+
className={`h-16 w-16 shrink-0 overflow-hidden rounded-lg border transition ${
|
|
121
|
+
position === index ? 'border-primary ring-2 ring-primary/20' : 'border-border opacity-70 hover:opacity-100'
|
|
122
|
+
}`}
|
|
123
|
+
>
|
|
124
|
+
<img src={image.url} alt="" className="h-full w-full object-cover" loading="lazy" />
|
|
125
|
+
</button>
|
|
126
|
+
</li>
|
|
127
|
+
))}
|
|
128
|
+
</ul>
|
|
129
|
+
)}
|
|
130
|
+
</div>
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Primary first, then sort_order — the order the merchant set in the admin. */
|
|
135
|
+
function orderImages(images: ProductImage[], primary?: ProductImage): ProductImage[] {
|
|
136
|
+
const all = images.length > 0 ? images : primary ? [primary] : []
|
|
137
|
+
return [...all].sort((a, b) => {
|
|
138
|
+
if (a.isPrimary !== b.isPrimary) return a.isPrimary ? -1 : 1
|
|
139
|
+
return (a.sortOrder ?? 0) - (b.sortOrder ?? 0)
|
|
140
|
+
})
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Whether the device actually has a hover-capable pointer. On a phone every
|
|
145
|
+
* element is permanently "hovered" by some emulations, which would leave the
|
|
146
|
+
* photo stuck at 2× with no way back.
|
|
147
|
+
*/
|
|
148
|
+
function useCanHover(): boolean {
|
|
149
|
+
const [canHover, setCanHover] = useState(false)
|
|
150
|
+
useEffect(() => {
|
|
151
|
+
const query = window.matchMedia?.('(hover: hover) and (pointer: fine)')
|
|
152
|
+
if (!query) return
|
|
153
|
+
setCanHover(query.matches)
|
|
154
|
+
const onChange = (event: MediaQueryListEvent) => setCanHover(event.matches)
|
|
155
|
+
query.addEventListener?.('change', onChange)
|
|
156
|
+
return () => query.removeEventListener?.('change', onChange)
|
|
157
|
+
}, [])
|
|
158
|
+
return canHover
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function GalleryArrow({ side, onClick }: { side: 'left' | 'right'; onClick: () => void }) {
|
|
162
|
+
return (
|
|
163
|
+
<button
|
|
164
|
+
type="button"
|
|
165
|
+
onClick={onClick}
|
|
166
|
+
aria-label={side === 'left' ? 'Imagem anterior' : 'Próxima imagem'}
|
|
167
|
+
className={`absolute top-1/2 z-10 flex h-9 w-9 -translate-y-1/2 items-center justify-center rounded-full bg-background/80 opacity-0 shadow-md backdrop-blur transition hover:bg-background focus:opacity-100 group-hover:opacity-100 ${
|
|
168
|
+
side === 'left' ? 'left-3' : 'right-3'
|
|
169
|
+
}`}
|
|
170
|
+
>
|
|
171
|
+
<span aria-hidden className="text-lg leading-none">{side === 'left' ? '‹' : '›'}</span>
|
|
172
|
+
</button>
|
|
173
|
+
)
|
|
174
|
+
}
|
|
@@ -99,24 +99,48 @@ function UtilityBar() {
|
|
|
99
99
|
)
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
-
function SearchInput({ className }: { className?: string }) {
|
|
102
|
+
function SearchInput({ className, iconOnly = false }: { className?: string; iconOnly?: boolean }) {
|
|
103
103
|
const config = useStorefrontConfig()
|
|
104
104
|
const search = useCatalogStore((s) => s.search)
|
|
105
105
|
const setSearch = useCatalogStore((s) => s.setSearch)
|
|
106
106
|
const path = useHashPath()
|
|
107
|
+
const [open, setOpen] = useState(false)
|
|
108
|
+
|
|
109
|
+
// Icon-only: a compact search button that expands into the input on click and
|
|
110
|
+
// collapses again on Escape / blur-when-empty.
|
|
111
|
+
if (iconOnly && !open) {
|
|
112
|
+
return (
|
|
113
|
+
<button
|
|
114
|
+
type="button"
|
|
115
|
+
aria-label="Buscar produtos"
|
|
116
|
+
data-testid={TID.headerSearchToggle}
|
|
117
|
+
onClick={() => setOpen(true)}
|
|
118
|
+
className="rounded-full p-2.5 transition-opacity hover:opacity-70"
|
|
119
|
+
>
|
|
120
|
+
<Search className="h-5 w-5" />
|
|
121
|
+
</button>
|
|
122
|
+
)
|
|
123
|
+
}
|
|
107
124
|
|
|
108
125
|
return (
|
|
109
|
-
<div className={`relative ${className ?? ''}`}>
|
|
126
|
+
<div className={`relative ${iconOnly ? 'w-full max-w-xs' : (className ?? '')}`}>
|
|
110
127
|
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 opacity-60" />
|
|
111
128
|
<input
|
|
112
129
|
data-testid={TID.headerSearch}
|
|
113
130
|
type="search"
|
|
114
131
|
placeholder="Buscar produtos..."
|
|
115
132
|
value={search}
|
|
133
|
+
autoFocus={iconOnly}
|
|
116
134
|
onChange={(e) => {
|
|
117
135
|
setSearch(e.target.value)
|
|
118
136
|
if (path !== config.catalogPath) navigateTo(config.catalogPath)
|
|
119
137
|
}}
|
|
138
|
+
onKeyDown={(e) => {
|
|
139
|
+
if (e.key === 'Escape' && iconOnly) setOpen(false)
|
|
140
|
+
}}
|
|
141
|
+
onBlur={() => {
|
|
142
|
+
if (iconOnly && !search) setOpen(false)
|
|
143
|
+
}}
|
|
120
144
|
className="w-full border bg-white/10 py-2 pl-9 pr-4 text-sm outline-none transition-colors placeholder:opacity-60 focus:border-primary focus:bg-white focus:text-gray-900"
|
|
121
145
|
style={{
|
|
122
146
|
borderRadius: 'var(--sf-radius-input)',
|
|
@@ -277,6 +301,7 @@ export function StorefrontHeader() {
|
|
|
277
301
|
const variant = config.theme?.header?.variant ?? 'classic'
|
|
278
302
|
const scrolled = useScrolled()
|
|
279
303
|
const showSearch = config.theme?.header?.showSearch !== false
|
|
304
|
+
const iconSearch = config.theme?.header?.searchStyle === 'icon'
|
|
280
305
|
const logo = (
|
|
281
306
|
<Link to="/" className="sf-heading shrink-0 text-xl font-bold tracking-tight">
|
|
282
307
|
{config.logo ?? config.name}
|
|
@@ -295,7 +320,7 @@ export function StorefrontHeader() {
|
|
|
295
320
|
// Rio/Flex pattern: centered logo row, nav row below
|
|
296
321
|
<>
|
|
297
322
|
<div className="mx-auto grid h-16 max-w-7xl grid-cols-[1fr_auto_1fr] items-center px-4 sm:px-6">
|
|
298
|
-
{showSearch ? <SearchInput className="hidden w-full max-w-xs sm:block" /> : <div />}
|
|
323
|
+
{showSearch ? <SearchInput iconOnly={iconSearch} className="hidden w-full max-w-xs sm:block" /> : <div />}
|
|
299
324
|
<div className="text-center">{logo}</div>
|
|
300
325
|
<div className="justify-self-end">
|
|
301
326
|
<HeaderActions />
|
|
@@ -309,7 +334,7 @@ export function StorefrontHeader() {
|
|
|
309
334
|
// Brasília pattern: prominent search left, logo center, actions right; nav row below
|
|
310
335
|
<>
|
|
311
336
|
<div className="mx-auto grid h-16 max-w-7xl grid-cols-[1fr_auto_1fr] items-center gap-6 px-4 sm:px-6">
|
|
312
|
-
{showSearch ? <SearchInput className="hidden w-full max-w-sm sm:block" /> : <div />}
|
|
337
|
+
{showSearch ? <SearchInput iconOnly={iconSearch} className="hidden w-full max-w-sm sm:block" /> : <div />}
|
|
313
338
|
<div className="text-center">{logo}</div>
|
|
314
339
|
<div className="justify-self-end">
|
|
315
340
|
<HeaderActions />
|
|
@@ -329,17 +354,19 @@ export function StorefrontHeader() {
|
|
|
329
354
|
</div>
|
|
330
355
|
<div className="text-center">{logo}</div>
|
|
331
356
|
<div className="flex items-center justify-end gap-3">
|
|
332
|
-
{showSearch && <SearchInput className="hidden w-44 lg:block" />}
|
|
357
|
+
{showSearch && <SearchInput iconOnly={iconSearch} className="hidden w-44 lg:block" />}
|
|
333
358
|
<HeaderActions />
|
|
334
359
|
</div>
|
|
335
360
|
</div>
|
|
336
361
|
) : (
|
|
337
|
-
// classic: logo left,
|
|
362
|
+
// classic: logo left, nav, then a right-aligned search + actions cluster
|
|
338
363
|
<div className="mx-auto flex h-16 max-w-7xl items-center gap-6 px-4 sm:px-6">
|
|
339
364
|
{logo}
|
|
340
365
|
<NavLinks className="hidden md:flex" />
|
|
341
|
-
|
|
342
|
-
|
|
366
|
+
<div className="ml-auto flex items-center gap-2 sm:gap-3">
|
|
367
|
+
{showSearch && <SearchInput iconOnly={iconSearch} className={iconSearch ? '' : 'hidden w-full max-w-sm sm:block'} />}
|
|
368
|
+
<HeaderActions />
|
|
369
|
+
</div>
|
|
343
370
|
</div>
|
|
344
371
|
)}
|
|
345
372
|
</header>
|
package/src/config.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import React, { createContext, useContext } from 'react'
|
|
2
2
|
import type { ShopProvider } from '@fayz-ai/shop/provider'
|
|
3
3
|
import type { MockShopSeed } from '@fayz-ai/shop/mock'
|
|
4
|
+
import type { PaymentMethodKind } from '@fayz-ai/shop/types'
|
|
4
5
|
import type { StorefrontTheme } from './theme'
|
|
5
6
|
import type { HomeConfig, NavLink, FooterConfig } from './sections'
|
|
6
7
|
import type { StorefrontAuthConfig } from './auth'
|
|
@@ -122,7 +123,15 @@ export interface StorefrontConfig {
|
|
|
122
123
|
* real charge (demo). M4 sets 'pix-mercadopago' so orders stay pending until
|
|
123
124
|
* a real payment webhook confirms settlement.
|
|
124
125
|
*/
|
|
125
|
-
payments?: {
|
|
126
|
+
payments?: {
|
|
127
|
+
mode?: 'mock' | 'pix-mercadopago'
|
|
128
|
+
/**
|
|
129
|
+
* Which methods the checkout offers. Declaring them is what lets the order
|
|
130
|
+
* record how the buyer intends to pay instead of assuming 'credit_card',
|
|
131
|
+
* which is what it did while the card form was a simulation.
|
|
132
|
+
*/
|
|
133
|
+
methods?: readonly PaymentMethodKind[]
|
|
134
|
+
}
|
|
126
135
|
/** Product enquiry behavior for catalog/enquiry stores. */
|
|
127
136
|
enquiry?: StorefrontEnquiryConfig
|
|
128
137
|
/** Feature toggles — defaults depend on commerceMode. */
|
|
@@ -139,7 +148,7 @@ export interface ResolvedStorefrontConfig extends StorefrontConfig {
|
|
|
139
148
|
currency: string
|
|
140
149
|
locale: string
|
|
141
150
|
shipping: { flatRate: number; freeAbove?: number }
|
|
142
|
-
payments: { mode: 'mock' | 'pix-mercadopago' }
|
|
151
|
+
payments: { mode: 'mock' | 'pix-mercadopago'; methods: readonly PaymentMethodKind[] }
|
|
143
152
|
commerceMode: StorefrontCommerceMode
|
|
144
153
|
enquiry: Required<Pick<StorefrontEnquiryConfig, 'label' | 'successMessage' | 'subjectPrefix'>> &
|
|
145
154
|
Omit<StorefrontEnquiryConfig, 'label' | 'successMessage' | 'subjectPrefix'>
|
|
@@ -159,7 +168,10 @@ export function resolveConfig(config: StorefrontConfig): ResolvedStorefrontConfi
|
|
|
159
168
|
currency: config.currency ?? 'BRL',
|
|
160
169
|
locale: config.locale ?? 'pt-BR',
|
|
161
170
|
shipping: { flatRate: config.shipping?.flatRate ?? 0, freeAbove: config.shipping?.freeAbove },
|
|
162
|
-
payments: {
|
|
171
|
+
payments: {
|
|
172
|
+
mode: config.payments?.mode ?? 'mock',
|
|
173
|
+
methods: config.payments?.methods?.length ? config.payments.methods : ['pix', 'credit_card', 'cash'],
|
|
174
|
+
},
|
|
163
175
|
commerceMode,
|
|
164
176
|
enquiry: {
|
|
165
177
|
label: config.enquiry?.label ?? 'Contact me',
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import React from 'react'
|
|
2
|
-
import { setShopProvider } from '@fayz-ai/shop/runtime'
|
|
2
|
+
import { setShopProvider, getShopProvider } from '@fayz-ai/shop/runtime'
|
|
3
3
|
import { createMockShopProvider } from '@fayz-ai/shop/mock'
|
|
4
4
|
import type { MockShopSeed } from '@fayz-ai/shop/mock'
|
|
5
5
|
import type { Discount } from '@fayz-ai/shop/types'
|
|
@@ -10,6 +10,7 @@ import type { StorefrontConfig, StorefrontDiscountConfig } from './config'
|
|
|
10
10
|
import type { StorefrontComponents } from './component-contracts'
|
|
11
11
|
import type { StorefrontSection } from './sections'
|
|
12
12
|
import { useHashPath, matchPath } from './router'
|
|
13
|
+
import { useCartStore } from './stores/cart.store'
|
|
13
14
|
import { StorefrontThemeStyle } from './theme'
|
|
14
15
|
import { StorefrontHeader } from './components/StorefrontHeader'
|
|
15
16
|
import { StorefrontFooter } from './components/StorefrontFooter'
|
|
@@ -128,6 +129,22 @@ export function initStorefrontRuntime(config: StorefrontConfig): void {
|
|
|
128
129
|
setShopProvider(createMockShopProvider(buildMockSeed(config)))
|
|
129
130
|
}
|
|
130
131
|
|
|
132
|
+
// Drop any persisted cart line the active backend can't resolve (e.g. a cart
|
|
133
|
+
// built in an earlier mock-mode session, whose ids don't exist live) so stale
|
|
134
|
+
// ids never reach checkout and 400 the order. Keeps a line on any error so a
|
|
135
|
+
// transient failure never discards a valid item. Runs AFTER the persisted cart
|
|
136
|
+
// has hydrated — zustand/persist rehydrates asynchronously, so reconciling at
|
|
137
|
+
// init would see an empty cart and no-op.
|
|
138
|
+
const reconcileCart = () => {
|
|
139
|
+
const provider = getShopProvider()
|
|
140
|
+
void useCartStore.getState().reconcile(async (line) => {
|
|
141
|
+
const product = await provider.getProduct(line.productId)
|
|
142
|
+
return product != null
|
|
143
|
+
})
|
|
144
|
+
}
|
|
145
|
+
if (useCartStore.persist.hasHydrated()) reconcileCart()
|
|
146
|
+
else useCartStore.persist.onFinishHydration(reconcileCart)
|
|
147
|
+
|
|
131
148
|
if (config.supabaseUrl || config.supabaseAnonKey) {
|
|
132
149
|
console.warn(
|
|
133
150
|
'@fayz-ai/shop: supabaseUrl/supabaseAnonKey are legacy fields. Pass an explicit provider/adapter or use the Fayz SDK broker path.',
|