@hanzo/commerce 6.2.4 → 6.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/components/buy/_to_deprecate_select-family-item-card.tsx +0 -1
- package/components/buy/_to_deprecate_select-family-item-panel.tsx +1 -1
- package/components/buy/add-to-cart-widget.tsx +9 -7
- package/components/buy/buy-card.tsx +3 -4
- package/components/buy/carousel-buy-card.tsx +120 -85
- package/components/buy/multi-family/all-variants-carousel.tsx +257 -0
- package/components/{select-family → buy/multi-family}/family-carousel/index.tsx +7 -17
- package/components/{select-family → buy/multi-family}/family-carousel/slide.tsx +3 -3
- package/components/{select-family → buy/multi-family}/family-carousel/state.ts +6 -2
- package/components/buy/multi-family/index.ts +2 -0
- package/components/buy/{foo.txt → please-keep.txt} +1 -1
- package/components/buy/single-family-selector.tsx +90 -0
- package/components/buy/title-and-byline.tsx +25 -0
- package/components/cart/cart-panel/cart-line-item.tsx +1 -1
- package/components/index.ts +1 -2
- package/components/item-selector/button.tsx +28 -18
- package/components/item-selector/carousel/index.tsx +197 -0
- package/components/item-selector/carousel/slider.tsx +40 -0
- package/components/{select-family/level-nodes-widget → node-tabs}/index.tsx +3 -3
- package/components/{select-family/level-nodes-widget → node-tabs}/node-image.tsx +1 -1
- package/package.json +3 -3
- package/service/impls/standalone/actual-line-item.ts +3 -1
- package/service/impls/standalone/standalone-service.ts +41 -4
- package/types/category-node.ts +21 -9
- package/types/commerce-service.ts +12 -13
- package/types/family.ts +7 -1
- package/types/index.ts +4 -3
- package/types/item-selector.ts +46 -9
- package/types/multi-family-selector-props.ts +20 -0
- package/types/selection-ui-specifier.ts +37 -21
- package/util/index.ts +3 -0
- package/util/item-selector-options-accessor.ts +32 -0
- package/util/multi-family-selector-options-accessor.ts +15 -0
- package/util/promo-codes.ts +71 -9
- package/util/selection-ui-specifiers.ts +1 -5
- package/components/item-selector/carousel.tsx +0 -162
|
@@ -66,7 +66,6 @@ const SelectFamilyItemCard: React.FC<React.HTMLAttributes<HTMLDivElement> & Item
|
|
|
66
66
|
// TODO disable if nothing selected
|
|
67
67
|
(selItemRef.item && !isLoading) && (
|
|
68
68
|
<AddToCartWidget
|
|
69
|
-
size='default'
|
|
70
69
|
item={selItemRef.item}
|
|
71
70
|
onQuantityChanged={onQuantityChanged}
|
|
72
71
|
className={cn('lg:min-w-[160px] lg:mx-auto', className)}
|
|
@@ -106,7 +106,7 @@ const SelectFamilyItemPanel: React.FC<
|
|
|
106
106
|
|
|
107
107
|
const AddToCartArea: React.FC<{ className?: string }> = observer(({ className = '' }) => (
|
|
108
108
|
(selectedItemRef.item && !isLoading) ? (
|
|
109
|
-
<AddToCartWidget
|
|
109
|
+
<AddToCartWidget item={selectedItemRef.item} className={className}/>
|
|
110
110
|
) : (
|
|
111
111
|
<div className={cn('h-6 w-12 invisible', className)} />
|
|
112
112
|
)
|
|
@@ -14,7 +14,6 @@ const AddToCartWidget: React.FC<{
|
|
|
14
14
|
disabled?: boolean
|
|
15
15
|
className?: string
|
|
16
16
|
buttonClx?: string
|
|
17
|
-
size?: ButtonSizes
|
|
18
17
|
variant?: 'minimal' | 'primary' | 'outline'
|
|
19
18
|
onQuantityChanged?: (sku: string, oldV: number, newV: number) => void
|
|
20
19
|
}> = observer(({
|
|
@@ -23,7 +22,6 @@ const AddToCartWidget: React.FC<{
|
|
|
23
22
|
disabled=false,
|
|
24
23
|
className='',
|
|
25
24
|
buttonClx='',
|
|
26
|
-
size='xs',
|
|
27
25
|
onQuantityChanged
|
|
28
26
|
}) => {
|
|
29
27
|
|
|
@@ -100,6 +98,8 @@ const AddToCartWidget: React.FC<{
|
|
|
100
98
|
return ( item.isInCart ? (
|
|
101
99
|
<div className={cn(
|
|
102
100
|
'flex flex-row items-stretch justify-between ',
|
|
101
|
+
// should match 'xs' and 'default' button heights
|
|
102
|
+
(ghost ? 'h-8' : 'h-10'),
|
|
103
103
|
ROUNDED_CLX,
|
|
104
104
|
(primary ? 'bg-primary' : 'bg-transparent'),
|
|
105
105
|
(outline ? 'border border-muted' : ''),
|
|
@@ -107,14 +107,15 @@ const AddToCartWidget: React.FC<{
|
|
|
107
107
|
)}>
|
|
108
108
|
<Button
|
|
109
109
|
aria-label={'Remove a ' + item.title + ' from the cart'}
|
|
110
|
-
size={
|
|
110
|
+
size={ghost ? 'xs' : 'default'}
|
|
111
111
|
variant={primary ? 'primary' : 'ghost'}
|
|
112
112
|
rounded={ghost ? 'full' : ROUNDED_VAL}
|
|
113
113
|
className={cn(
|
|
114
114
|
'lg:min-w-0 lg:px-2 grow justify-start group',
|
|
115
115
|
(ghost ? 'px-1' : 'px-2'),
|
|
116
116
|
(outline ? 'hover:bg-transparent' : ''),
|
|
117
|
-
buttonClx
|
|
117
|
+
buttonClx,
|
|
118
|
+
'h-auto self-stretch' // must be smaller than normal
|
|
118
119
|
)}
|
|
119
120
|
key='left'
|
|
120
121
|
onClick={dec}
|
|
@@ -128,14 +129,15 @@ const AddToCartWidget: React.FC<{
|
|
|
128
129
|
<div className={'text-sm grow-0 shrink-0 flex items-center cursor-default xs:px-2 ' + digitClx} >{item.quantity}{ghost ? '' : ' in Bag'}</div>
|
|
129
130
|
<Button
|
|
130
131
|
aria-label={'Add another ' + item.title + ' to the cart'}
|
|
131
|
-
size={
|
|
132
|
+
size={ghost ? 'xs' : 'default'}
|
|
132
133
|
variant={primary ? 'primary' : 'ghost'}
|
|
133
134
|
rounded={ghost ? 'full' : ROUNDED_VAL}
|
|
134
135
|
className={cn(
|
|
135
136
|
'lg:min-w-0 lg:px-2 grow justify-end group',
|
|
136
137
|
(ghost ? 'px-1' : 'px-2'),
|
|
137
138
|
(outline ? 'hover:bg-transparent' : ''),
|
|
138
|
-
buttonClx
|
|
139
|
+
buttonClx,
|
|
140
|
+
'h-auto self-stretch' // must be smaller than normal
|
|
139
141
|
)}
|
|
140
142
|
onClick={inc}
|
|
141
143
|
key='right'
|
|
@@ -146,7 +148,7 @@ const AddToCartWidget: React.FC<{
|
|
|
146
148
|
) : (
|
|
147
149
|
<Button
|
|
148
150
|
aria-label={'Add a ' + item.title + ' to cart'}
|
|
149
|
-
size={
|
|
151
|
+
size={ghost ? 'xs' : 'default'}
|
|
150
152
|
variant={variant as ButtonVariants}
|
|
151
153
|
rounded={ROUNDED_VAL}
|
|
152
154
|
className={cn(buttonClx, className)}
|
|
@@ -18,7 +18,7 @@ import { useCommerce } from '../../service/context'
|
|
|
18
18
|
import * as pathUtils from '../../service/path-utils'
|
|
19
19
|
import { getFacetValuesMutator, ObsStringMutator } from '../../util'
|
|
20
20
|
|
|
21
|
-
import
|
|
21
|
+
import NodeTabs from '../node-tabs'
|
|
22
22
|
import AddToCartWidget from './add-to-cart-widget'
|
|
23
23
|
|
|
24
24
|
const BuyCard: React.FC<{
|
|
@@ -170,7 +170,7 @@ const BuyCard: React.FC<{
|
|
|
170
170
|
}
|
|
171
171
|
}
|
|
172
172
|
|
|
173
|
-
const famTitle = inst.current?.requestedFamily ? inst.current.requestedFamily.title : cmmc.selectedFamilies
|
|
173
|
+
const famTitle = inst.current?.requestedFamily ? inst.current.requestedFamily.title : cmmc.selectedFamilies[0].title
|
|
174
174
|
|
|
175
175
|
const itemsToShow = (!cmmc.hasSelection) ? undefined :
|
|
176
176
|
allVariants ? cmmc.selectedItems : cmmc.selectedFamilies[0].products as LineItem[]
|
|
@@ -205,7 +205,7 @@ const BuyCard: React.FC<{
|
|
|
205
205
|
<h6 className='!text-center font-bold text-muted mt-3'>{inst.current!.requestedNode.subNodesLabel}</h6>
|
|
206
206
|
)}
|
|
207
207
|
</ApplyTypography>
|
|
208
|
-
<
|
|
208
|
+
<NodeTabs
|
|
209
209
|
className={cn(
|
|
210
210
|
'grid gap-0 align-stretch justify-normal ' + `grid-cols-${inst.current.requestedNode.subNodes!.length}`,
|
|
211
211
|
'border-b-2 rounded-lg border-level-3 mb-4 -mr-2 -ml-2 max-w-[460px] h-10', // height is needed for iPhone bug
|
|
@@ -247,7 +247,6 @@ const BuyCard: React.FC<{
|
|
|
247
247
|
)}
|
|
248
248
|
{(cmmc.currentItem) && (
|
|
249
249
|
<AddToCartWidget
|
|
250
|
-
size='default'
|
|
251
250
|
item={cmmc.currentItem}
|
|
252
251
|
onQuantityChanged={onQuantityChanged}
|
|
253
252
|
className={cn('min-w-[160px] mx-auto mt-4', (scroll ? 'shrink-0' : ''), addWidgetClx)}
|
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
'use client'
|
|
2
|
-
import React, {
|
|
2
|
+
import React, {
|
|
3
|
+
useRef,
|
|
4
|
+
useEffect,
|
|
5
|
+
type ComponentType,
|
|
6
|
+
useState
|
|
7
|
+
} from 'react'
|
|
3
8
|
import { observer } from 'mobx-react-lite'
|
|
4
9
|
|
|
5
10
|
import { cn } from '@hanzo/ui/util'
|
|
6
|
-
import {
|
|
11
|
+
import { Button } from '@hanzo/ui/primitives'
|
|
7
12
|
|
|
8
13
|
import type {
|
|
9
14
|
ItemSelectorProps,
|
|
@@ -12,14 +17,17 @@ import type {
|
|
|
12
17
|
CategoryNode,
|
|
13
18
|
CategoryNodeRole,
|
|
14
19
|
SelectionUISpecifier,
|
|
15
|
-
ItemSelectorOptions,
|
|
20
|
+
ItemSelectorOptions,
|
|
21
|
+
MultiFamilySelectorProps,
|
|
22
|
+
MultiFamilySelectorOptions,
|
|
16
23
|
} from '../../types'
|
|
17
24
|
|
|
18
25
|
import { useCommerce } from '../../service/context'
|
|
19
26
|
import { getSelectionUISpecifier } from '../../util'
|
|
20
27
|
|
|
21
28
|
import { CarouselItemSelector, ButtonItemSelector } from '../item-selector'
|
|
22
|
-
import
|
|
29
|
+
import SingleFamilySelector from './single-family-selector'
|
|
30
|
+
import { FamilyCarousel, AllVariantsCarousel } from './multi-family'
|
|
23
31
|
|
|
24
32
|
import AddToCartWidget from './add-to-cart-widget'
|
|
25
33
|
|
|
@@ -30,13 +38,25 @@ const SCROLL = {
|
|
|
30
38
|
|
|
31
39
|
const MEDIA_CONSTRAINT = {w: 200, h: 200}
|
|
32
40
|
|
|
41
|
+
const sortItems = (items: LineItem[], sort: 'asc' | 'desc' | 'none'): LineItem[] => (
|
|
42
|
+
((sort === 'asc') ?
|
|
43
|
+
items.sort((a: LineItem, b: LineItem): number => (a.price - b.price))
|
|
44
|
+
:
|
|
45
|
+
((sort === 'desc') ?
|
|
46
|
+
items.sort((a: LineItem, b: LineItem): number => (b.price - a.price ))
|
|
47
|
+
:
|
|
48
|
+
items
|
|
49
|
+
)
|
|
50
|
+
)
|
|
51
|
+
)
|
|
52
|
+
|
|
33
53
|
const CarouselBuyCard: React.FC<{
|
|
34
54
|
skuPath: string
|
|
35
55
|
clx?: string
|
|
36
56
|
mobile?: boolean
|
|
37
57
|
handleCheckout: () => void
|
|
38
58
|
onQuantityChanged?: (sku: string, oldV: number, newV: number) => void
|
|
39
|
-
}> =
|
|
59
|
+
}> = ({
|
|
40
60
|
skuPath,
|
|
41
61
|
clx='',
|
|
42
62
|
mobile=false,
|
|
@@ -59,7 +79,13 @@ const CarouselBuyCard: React.FC<{
|
|
|
59
79
|
selOptions: ItemSelectorOptions | undefined
|
|
60
80
|
scrollable: boolean
|
|
61
81
|
showItemMedia: boolean
|
|
62
|
-
}
|
|
82
|
+
}
|
|
83
|
+
multi?: {
|
|
84
|
+
Selector: ComponentType<MultiFamilySelectorProps>
|
|
85
|
+
selectorOptions: MultiFamilySelectorOptions | undefined
|
|
86
|
+
itemOptions: ItemSelectorOptions | undefined
|
|
87
|
+
initialFamilyId: string
|
|
88
|
+
}
|
|
63
89
|
} | undefined>(undefined)
|
|
64
90
|
|
|
65
91
|
const [changeMeToRerender, setChangeMeToRerender] = useState<boolean>(false)
|
|
@@ -84,124 +110,133 @@ const CarouselBuyCard: React.FC<{
|
|
|
84
110
|
|
|
85
111
|
if (peek.role === 'single-family') {
|
|
86
112
|
|
|
113
|
+
const sort = (): 'none' | 'asc' | 'desc' => {
|
|
114
|
+
if (!uiSpec.singleFamily?.options) {
|
|
115
|
+
return 'none'
|
|
116
|
+
}
|
|
117
|
+
const options = uiSpec.singleFamily.options
|
|
118
|
+
const showSlider = 'showSlider' in options ? options.showSlider! : true
|
|
119
|
+
return ('sort' in options) ?
|
|
120
|
+
options.sort!
|
|
121
|
+
:
|
|
122
|
+
(showSlider ? 'asc' : 'none')
|
|
123
|
+
}
|
|
124
|
+
|
|
87
125
|
const items = peek.family!.products as LineItem[]
|
|
88
126
|
const Selector: ComponentType<ItemSelectorProps> =
|
|
89
127
|
(uiSpec.singleFamily?.type === 'buttons') ? ButtonItemSelector : CarouselItemSelector
|
|
90
128
|
const selOptions = uiSpec.singleFamily?.options
|
|
91
129
|
|
|
92
130
|
r.current.single = {
|
|
93
|
-
items,
|
|
131
|
+
items: sortItems(items, sort()),
|
|
94
132
|
Selector,
|
|
95
133
|
selOptions,
|
|
96
134
|
scrollable: !!(items.length > SCROLL.scrollAfter),
|
|
97
135
|
showItemMedia: uiSpec.singleFamily?.type !== 'carousel'
|
|
98
136
|
}
|
|
99
137
|
|
|
100
|
-
const currItem = peek.item ??
|
|
138
|
+
const currItem = peek.item ?? r.current.single.items[0]
|
|
101
139
|
cmmc.setCurrentItem(currItem.sku)
|
|
102
140
|
}
|
|
103
141
|
else {
|
|
104
142
|
const initialFamily = peek.family ? peek.family : peek.families![0]
|
|
143
|
+
// TODO: Does this ever need to be sorted??
|
|
105
144
|
const currItem = peek.item ?? initialFamily.products[0]
|
|
145
|
+
|
|
146
|
+
// sets currentFamily as well
|
|
106
147
|
cmmc.setCurrentItem(currItem.sku)
|
|
148
|
+
|
|
149
|
+
r.current.multi = {
|
|
150
|
+
Selector: (uiSpec.multiFamily?.type === 'family-carousel') ?
|
|
151
|
+
FamilyCarousel
|
|
152
|
+
:
|
|
153
|
+
AllVariantsCarousel,
|
|
154
|
+
itemOptions: uiSpec.multiFamily?.itemOptions,
|
|
155
|
+
selectorOptions: uiSpec.multiFamily?.selectorOptions,
|
|
156
|
+
initialFamilyId: initialFamily.id
|
|
157
|
+
}
|
|
107
158
|
}
|
|
108
159
|
|
|
109
160
|
// Must do this since Dialog code takes this comp out of the React shadow DOM
|
|
110
161
|
// (only in prod apparently.)
|
|
111
162
|
setChangeMeToRerender(!changeMeToRerender)
|
|
112
|
-
|
|
113
163
|
}, [skuPath])
|
|
114
164
|
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
165
|
+
const MultiFamilyUI: React.FC<{
|
|
166
|
+
Selector: ComponentType<MultiFamilySelectorProps>
|
|
167
|
+
selectorOptions: MultiFamilySelectorOptions | undefined
|
|
168
|
+
itemOptions: ItemSelectorOptions | undefined
|
|
169
|
+
families: Family[]
|
|
170
|
+
parent: CategoryNode
|
|
118
171
|
clx?: string
|
|
119
|
-
bylineClx?: string
|
|
120
172
|
}> = ({
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
173
|
+
Selector,
|
|
174
|
+
itemOptions,
|
|
175
|
+
selectorOptions,
|
|
176
|
+
families,
|
|
177
|
+
parent,
|
|
178
|
+
clx=''
|
|
125
179
|
}) => (
|
|
126
|
-
<
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
180
|
+
<Selector
|
|
181
|
+
families={families}
|
|
182
|
+
parent={parent}
|
|
183
|
+
clx={clx}
|
|
184
|
+
itemOptions={itemOptions}
|
|
185
|
+
selectorOptions={selectorOptions}
|
|
186
|
+
mediaConstraint={MEDIA_CONSTRAINT}
|
|
187
|
+
mobile={mobile}
|
|
188
|
+
/>
|
|
130
189
|
)
|
|
131
190
|
|
|
191
|
+
const Buttons: React.FC<{clx?: string}> = observer(({
|
|
192
|
+
clx=''
|
|
193
|
+
}) => (cmmc.currentItem ? (
|
|
194
|
+
<div className={clx}>
|
|
195
|
+
<AddToCartWidget
|
|
196
|
+
item={cmmc.currentItem}
|
|
197
|
+
onQuantityChanged={onQuantityChanged}
|
|
198
|
+
variant={cmmc.cartEmpty ? 'primary' : 'outline'}
|
|
199
|
+
className='min-w-[160px] w-full sm:max-w-[320px]'
|
|
200
|
+
/>
|
|
201
|
+
{!cmmc.cartEmpty && (
|
|
202
|
+
<Button
|
|
203
|
+
onClick={handleCheckout}
|
|
204
|
+
variant='primary'
|
|
205
|
+
rounded='lg'
|
|
206
|
+
className='min-w-[160px] w-full sm:max-w-[320px]'
|
|
207
|
+
>
|
|
208
|
+
Checkout
|
|
209
|
+
</Button>
|
|
210
|
+
)}
|
|
211
|
+
</div>
|
|
212
|
+
) : null))
|
|
213
|
+
|
|
132
214
|
return (
|
|
133
215
|
<div className={cn(
|
|
134
|
-
'px-4 md:px-6 pt-3 pb-4 flex flex-col gap-
|
|
216
|
+
'px-4 md:px-6 pt-3 pb-4 flex flex-col gap-1 items-center min-h-[40vh]',
|
|
135
217
|
clx,
|
|
136
218
|
r.current?.single?.scrollable ? SCROLL.scrollHeightClx : 'h-auto'
|
|
137
219
|
)}>
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
<MediaStack
|
|
142
|
-
media={cmmc.currentItem}
|
|
143
|
-
constrainTo={MEDIA_CONSTRAINT}
|
|
144
|
-
clx={(r.current.single.scrollable ? 'shrink-0' : '')}
|
|
145
|
-
/>
|
|
146
|
-
)}
|
|
147
|
-
{(r.current.single.showItemMedia && !cmmc.currentItem ) && (
|
|
148
|
-
<Skeleton className={'w-[200px] h-[200px] my-4 ' + (r.current.single.scrollable ? 'shrink-0' : '')}/>
|
|
149
|
-
)}
|
|
150
|
-
{r.current.single.items && (
|
|
151
|
-
<r.current.single.Selector
|
|
152
|
-
items={r.current.single.items}
|
|
153
|
-
selectedItemRef={cmmc}
|
|
154
|
-
selectSku={cmmc.setCurrentItem.bind(cmmc)}
|
|
155
|
-
scrollable={r.current.single.scrollable}
|
|
156
|
-
mobile={mobile}
|
|
157
|
-
options={r.current.single.selOptions}
|
|
158
|
-
/>
|
|
159
|
-
)}
|
|
160
|
-
</>) : (<>
|
|
161
|
-
{r.current?.uiSpec.multiFamily?.showParentTitle && (
|
|
162
|
-
<TitleArea
|
|
163
|
-
title={r.current?.node.label ?? ''}
|
|
164
|
-
byline={r.current?.node.subNodesLabel}
|
|
165
|
-
clx={'mb-2 '}
|
|
166
|
-
bylineClx='!text-center font-bold text-muted mt-3'
|
|
167
|
-
/>
|
|
168
|
-
)}
|
|
169
|
-
{r.current?.families && ( // safegaurd for first render etc.
|
|
170
|
-
<FamilyCarousel
|
|
171
|
-
families={r.current?.families}
|
|
172
|
-
clx='w-full'
|
|
173
|
-
slideOptions={r.current?.uiSpec.multiFamily?.slide.options}
|
|
220
|
+
{r.current?.single ? (
|
|
221
|
+
<SingleFamilySelector
|
|
222
|
+
{...r.current.single}
|
|
174
223
|
mediaConstraint={MEDIA_CONSTRAINT}
|
|
175
|
-
mobile={mobile}
|
|
224
|
+
mobile={mobile}
|
|
225
|
+
/>
|
|
226
|
+
) : (r.current?.multi && r.current.families && /* safegaurd for first render, etc. */ (
|
|
227
|
+
<MultiFamilyUI
|
|
228
|
+
{...r.current.multi}
|
|
229
|
+
families={r.current.families}
|
|
230
|
+
parent={r.current.node}
|
|
231
|
+
clx='max-w-[475px]'
|
|
176
232
|
/>
|
|
177
|
-
)}
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
<div className={cn(
|
|
181
|
-
'self-stretch mt-4 flex flex-col items-center gap-3',
|
|
233
|
+
))}
|
|
234
|
+
<Buttons clx={cn(
|
|
235
|
+
'self-stretch mt-8 flex flex-col items-center gap-3',
|
|
182
236
|
(r.current?.single?.scrollable ? 'shrink-0' : '')
|
|
183
|
-
)}
|
|
184
|
-
<AddToCartWidget
|
|
185
|
-
size='default'
|
|
186
|
-
item={cmmc.currentItem}
|
|
187
|
-
onQuantityChanged={onQuantityChanged}
|
|
188
|
-
variant={cmmc.cartEmpty ? 'primary' : 'outline'}
|
|
189
|
-
className='min-w-[160px] w-full sm:max-w-[320px]'
|
|
190
|
-
/>
|
|
191
|
-
{!cmmc.cartEmpty && (
|
|
192
|
-
<Button
|
|
193
|
-
onClick={handleCheckout}
|
|
194
|
-
variant='primary'
|
|
195
|
-
rounded='lg'
|
|
196
|
-
className='min-w-[160px] w-full sm:max-w-[320px]'
|
|
197
|
-
>
|
|
198
|
-
Checkout
|
|
199
|
-
</Button>
|
|
200
|
-
)}
|
|
201
|
-
</div>
|
|
202
|
-
)}
|
|
237
|
+
)}/>
|
|
203
238
|
</div >
|
|
204
239
|
)
|
|
205
|
-
}
|
|
240
|
+
}
|
|
206
241
|
|
|
207
242
|
export default CarouselBuyCard
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
|
3
|
+
import { reaction } from 'mobx'
|
|
4
|
+
import { observer } from 'mobx-react-lite'
|
|
5
|
+
|
|
6
|
+
import { cn } from '@hanzo/ui/util'
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
ApplyTypography,
|
|
10
|
+
type CarouselApi,
|
|
11
|
+
Carousel,
|
|
12
|
+
CarouselContent,
|
|
13
|
+
CarouselItem,
|
|
14
|
+
CarouselPrevious,
|
|
15
|
+
CarouselNext,
|
|
16
|
+
MediaStack,
|
|
17
|
+
} from '@hanzo/ui/primitives'
|
|
18
|
+
|
|
19
|
+
import type { MultiFamilySelectorProps, LineItem } from '../../../types'
|
|
20
|
+
import {
|
|
21
|
+
formatCurrencyValue,
|
|
22
|
+
accessItemOptions,
|
|
23
|
+
accessMultiSelectorOptions
|
|
24
|
+
} from '../../../util'
|
|
25
|
+
|
|
26
|
+
import QuantityIndicator from '../../quantity-indicator'
|
|
27
|
+
import { ButtonItemSelector, useCommerce } from '../../..'
|
|
28
|
+
|
|
29
|
+
const debugBorder = (c: 'r' | 'g' | 'b', disable: boolean = true): string => {
|
|
30
|
+
|
|
31
|
+
if (disable) {
|
|
32
|
+
return ''
|
|
33
|
+
}
|
|
34
|
+
switch (c) {
|
|
35
|
+
case 'r': return ' border border-[#ffaaaa] '
|
|
36
|
+
case 'g': return ' border border-[#aaffaa] '
|
|
37
|
+
case 'b': return ' border border-[#aaaaff] '
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const DEFAULT_CONSTRAINT = {w: 250, h: 250}
|
|
42
|
+
|
|
43
|
+
const AllVariantsCarousel: React.FC<MultiFamilySelectorProps> = ({
|
|
44
|
+
families,
|
|
45
|
+
parent,
|
|
46
|
+
clx='',
|
|
47
|
+
itemClx='',
|
|
48
|
+
itemOptions,
|
|
49
|
+
selectorOptions,
|
|
50
|
+
mediaConstraint=DEFAULT_CONSTRAINT,
|
|
51
|
+
mobile=false, // not relavant to any children
|
|
52
|
+
}) => {
|
|
53
|
+
|
|
54
|
+
const cmmc = useCommerce()
|
|
55
|
+
const r = useRef<{
|
|
56
|
+
api: CarouselApi | undefined
|
|
57
|
+
items: LineItem[]
|
|
58
|
+
dontRespond: boolean
|
|
59
|
+
initialIndex: number
|
|
60
|
+
} | undefined>(undefined)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
const [changeMeToRerender, setChangeMeToRerender] = useState<boolean>(false)
|
|
64
|
+
// Safe, since Carousel is only render (and this method passed), once the ref is initialized.
|
|
65
|
+
const setApi = (api: CarouselApi) => { r.current!.api = api }
|
|
66
|
+
|
|
67
|
+
const onSelect = useCallback((emblaApi: CarouselApi) => {
|
|
68
|
+
if (r.current?.dontRespond) {
|
|
69
|
+
r.current.dontRespond = false
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
const index = emblaApi.selectedScrollSnap()
|
|
73
|
+
if (index !== -1) {
|
|
74
|
+
const item = r.current?.items[index]
|
|
75
|
+
cmmc.setCurrentItem(item?.sku) // (sets currentFamily as well)
|
|
76
|
+
}
|
|
77
|
+
if (r.current) {
|
|
78
|
+
r.current.dontRespond = false
|
|
79
|
+
}
|
|
80
|
+
}, [])
|
|
81
|
+
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
|
|
84
|
+
const items = families.map((fam) => (fam.products as LineItem[])).flat()
|
|
85
|
+
const foundIndex = cmmc.currentItem ? items.findIndex((item) => (cmmc.currentItem!.sku == item.sku)) : -1
|
|
86
|
+
r.current = {
|
|
87
|
+
items,
|
|
88
|
+
initialIndex: foundIndex === -1 ? 0 : foundIndex,
|
|
89
|
+
dontRespond: false,
|
|
90
|
+
api: undefined
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
setChangeMeToRerender(!changeMeToRerender)
|
|
94
|
+
|
|
95
|
+
// This responds to the swatch clicks
|
|
96
|
+
return reaction(
|
|
97
|
+
() => (cmmc.currentItem),
|
|
98
|
+
(item) => {
|
|
99
|
+
if (r.current && r.current.api) {
|
|
100
|
+
const index = r.current.items.findIndex((_item: LineItem) => (_item.sku === item?.sku))
|
|
101
|
+
if (index && index !== -1) {
|
|
102
|
+
// no need to sync family, since ui only allows selecting within a family
|
|
103
|
+
r.current.dontRespond = true
|
|
104
|
+
r.current.api.scrollTo(index)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
)
|
|
109
|
+
}, [])
|
|
110
|
+
|
|
111
|
+
const Header: React.FC<{clx?: string}> = observer(({
|
|
112
|
+
clx=''
|
|
113
|
+
}) => {
|
|
114
|
+
|
|
115
|
+
const { showParentTitle, parentByline: parentBylineMode } = accessMultiSelectorOptions(selectorOptions)
|
|
116
|
+
const { familyTitle, showFamilyByline } = accessItemOptions(itemOptions)
|
|
117
|
+
|
|
118
|
+
const parentTitleDisplay = showParentTitle ? parent.label : undefined
|
|
119
|
+
const parentBylineDisplay = (parentBylineMode === 'own-line') ? parent.subNodesLabel : ''
|
|
120
|
+
|
|
121
|
+
const title = (familyTitle === 'none' ?
|
|
122
|
+
undefined
|
|
123
|
+
:
|
|
124
|
+
(familyTitle === 'long' ?
|
|
125
|
+
cmmc.currentFamily?.title
|
|
126
|
+
:
|
|
127
|
+
(cmmc.currentFamily?.titleShort ?? cmmc.currentFamily?.title)
|
|
128
|
+
)
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
let titleLinePrefix = (parentBylineMode === 'none' || parentBylineMode === 'own-line') ? '' : (parent.subNodesLabel ?? '')
|
|
132
|
+
if (titleLinePrefix.length > 0 && title && parentBylineMode === 'comma-sep') {
|
|
133
|
+
titleLinePrefix += ', '
|
|
134
|
+
}
|
|
135
|
+
else if (titleLinePrefix.length > 0 && title && parentBylineMode === 'colon-sep') {
|
|
136
|
+
titleLinePrefix += ': '
|
|
137
|
+
}
|
|
138
|
+
const titleDisplay = title ? (titleLinePrefix + title) : undefined
|
|
139
|
+
const bylineDisplay = (familyTitle !== 'none') && showFamilyByline ? cmmc.currentFamily?.byline : undefined
|
|
140
|
+
|
|
141
|
+
return (
|
|
142
|
+
<ApplyTypography className={cn('flex flex-col items-center !gap-0 [&>*]:!m-0 ', clx)} >
|
|
143
|
+
{parentTitleDisplay && <h4>{parentTitleDisplay}</h4>}
|
|
144
|
+
{parentBylineDisplay && <h4>{parentBylineDisplay}</h4>}
|
|
145
|
+
{titleDisplay && <h4>{titleDisplay}</h4>}
|
|
146
|
+
{bylineDisplay && (<h6 >{bylineDisplay}</h6>)}
|
|
147
|
+
</ApplyTypography>
|
|
148
|
+
)
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
const ItemInfo: React.FC<{
|
|
152
|
+
clx?: string
|
|
153
|
+
labelClx?: string
|
|
154
|
+
}> = observer(({
|
|
155
|
+
clx='',
|
|
156
|
+
labelClx=''
|
|
157
|
+
}) => {
|
|
158
|
+
|
|
159
|
+
const {
|
|
160
|
+
showPrice,
|
|
161
|
+
showQuantity,
|
|
162
|
+
showFamilyInOption,
|
|
163
|
+
showByline,
|
|
164
|
+
} = accessItemOptions(itemOptions)
|
|
165
|
+
|
|
166
|
+
const optionLabel = () => (
|
|
167
|
+
showFamilyInOption ?
|
|
168
|
+
(cmmc.currentItem!.familyTitle + ', ' + cmmc.currentItem!.optionLabel)
|
|
169
|
+
:
|
|
170
|
+
cmmc.currentItem!.optionLabel
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
return (cmmc.currentItem && (
|
|
174
|
+
<ApplyTypography className={cn('flex flex-col items-center [&>*]:!m-0 !gap-1 ', clx)}>
|
|
175
|
+
<div className={
|
|
176
|
+
'flex items-center gap-1 [&>*]:!m-0 ' +
|
|
177
|
+
debugBorder('g') +
|
|
178
|
+
(showFamilyInOption ? 'flex-col' : 'flex-row')
|
|
179
|
+
}>
|
|
180
|
+
<h6 className={cn('font-semibold', labelClx)}>
|
|
181
|
+
{optionLabel() + (showPrice && !showFamilyInOption ? ',' : '')}
|
|
182
|
+
</h6>
|
|
183
|
+
<div className={
|
|
184
|
+
'flex items-center gap-1 [&>*]:!m-0 flex-row ' + debugBorder('b') +
|
|
185
|
+
(showFamilyInOption ? 'w-full justify-between' : '')
|
|
186
|
+
}>
|
|
187
|
+
{showPrice && (<p>{formatCurrencyValue(cmmc.currentItem.price)}</p>)}
|
|
188
|
+
{showQuantity && (
|
|
189
|
+
<QuantityIndicator
|
|
190
|
+
item={cmmc.currentItem}
|
|
191
|
+
clx='h-[22px] ml-4'
|
|
192
|
+
iconClx='fill-foreground'
|
|
193
|
+
digitClx='not-typography font-semibold text-primary-fg leading-none font-sans text-xs'
|
|
194
|
+
/>
|
|
195
|
+
)}
|
|
196
|
+
</div>
|
|
197
|
+
</div>
|
|
198
|
+
{showByline && cmmc.currentItem.byline && (<p>{cmmc.currentItem.byline}</p>)}
|
|
199
|
+
</ApplyTypography>
|
|
200
|
+
))
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
const Swatches: React.FC<{
|
|
204
|
+
clx?: string
|
|
205
|
+
}> = observer(({
|
|
206
|
+
clx=''
|
|
207
|
+
}) => {
|
|
208
|
+
const { showItemSwatches } = accessMultiSelectorOptions(selectorOptions)
|
|
209
|
+
if (
|
|
210
|
+
!showItemSwatches ||
|
|
211
|
+
!cmmc.currentFamily ||
|
|
212
|
+
cmmc.currentFamily.products.length === 1
|
|
213
|
+
) {
|
|
214
|
+
return null
|
|
215
|
+
}
|
|
216
|
+
return <ButtonItemSelector
|
|
217
|
+
items={cmmc.currentFamily.products as LineItem[]}
|
|
218
|
+
selectedItemRef={cmmc}
|
|
219
|
+
selectSku={cmmc.setCurrentItem.bind(cmmc)}
|
|
220
|
+
clx={clx}
|
|
221
|
+
options={{
|
|
222
|
+
buttonType: 'image',
|
|
223
|
+
horizButtons: true
|
|
224
|
+
}}
|
|
225
|
+
/>
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
return (
|
|
229
|
+
<div className={cn('w-full flex flex-col items-center', clx)}>
|
|
230
|
+
<Header />
|
|
231
|
+
{r.current && /* Only render once we've set the ref fields, or else bad things! */ (
|
|
232
|
+
<Carousel
|
|
233
|
+
options={{loop: true, startIndex: r.current.initialIndex}}
|
|
234
|
+
className={'w-full px-2' + debugBorder('r')}
|
|
235
|
+
onCarouselSelect={onSelect}
|
|
236
|
+
setApi={setApi}
|
|
237
|
+
>
|
|
238
|
+
<CarouselContent>
|
|
239
|
+
{r.current.items.map((item) => (
|
|
240
|
+
<CarouselItem key={item.sku} className={cn('p-2 flex flex-col justify-center items-center', itemClx)}>
|
|
241
|
+
<MediaStack media={item} constrainTo={mediaConstraint} clx='my-4'/>
|
|
242
|
+
</CarouselItem>
|
|
243
|
+
))}
|
|
244
|
+
</CarouselContent>
|
|
245
|
+
<CarouselPrevious className='left-1'/>
|
|
246
|
+
<CarouselNext className='right-1'/>
|
|
247
|
+
</Carousel>
|
|
248
|
+
)}
|
|
249
|
+
<ItemInfo labelClx='!text-base font-medium'/>
|
|
250
|
+
<Swatches clx='mt-2'/>
|
|
251
|
+
</div>
|
|
252
|
+
)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export {
|
|
256
|
+
AllVariantsCarousel as default
|
|
257
|
+
}
|