@hanzo/commerce 6.1.13 → 6.2.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 +3 -4
- package/components/buy/_to_deprecate_select-family-item-panel.tsx +4 -5
- package/components/buy/add-to-cart-widget.tsx +43 -32
- package/components/buy/buy-button.tsx +1 -1
- package/components/buy/buy-card.tsx +17 -26
- package/components/buy/buy-drawer.tsx +6 -25
- package/components/buy/carousel-buy-card.tsx +145 -194
- package/components/buy/foo.txt +27 -0
- package/components/cart/cart-panel/cart-line-item.tsx +1 -1
- package/components/checkout/payment-step-form/contact-form.tsx +2 -2
- package/components/checkout/payment-step-form/methods/crypto.tsx +1 -2
- package/components/checkout/shipping-step-form.tsx +5 -5
- package/components/index.ts +1 -3
- package/components/item-selector/button.tsx +175 -0
- package/components/item-selector/carousel.tsx +162 -0
- package/components/item-selector/index.ts +5 -0
- package/components/quantity-indicator.tsx +48 -0
- package/components/select-family/family-carousel/index.tsx +47 -38
- package/components/select-family/family-carousel/slide.tsx +83 -0
- package/components/select-family/family-carousel/state.ts +83 -0
- package/components/select-family/level-nodes-widget/index.tsx +2 -2
- package/components/select-family/level-nodes-widget/node-image.tsx +2 -2
- package/index.ts +7 -7
- package/package.json +7 -4
- package/service/context.tsx +8 -7
- package/service/debug.ts +41 -0
- package/service/get-instance.ts +3 -0
- package/service/impls/standalone/actual-line-item.ts +5 -7
- package/service/impls/standalone/index.ts +11 -3
- package/service/impls/standalone/standalone-service.ts +133 -38
- package/service/path-utils.ts +26 -0
- package/service/sep.ts +7 -0
- package/types/category-node.ts +38 -0
- package/types/commerce-service.ts +44 -12
- package/types/family.ts +4 -1
- package/types/index.ts +3 -2
- package/types/item-selector.ts +29 -14
- package/types/product.ts +5 -1
- package/types/selection-ui-specifier.ts +36 -0
- package/types/token-separators.ts +7 -0
- package/util/error.ts +34 -0
- package/util/index.ts +4 -1
- package/util/product-media-accessor.ts +58 -0
- package/util/promo-codes.ts +1 -2
- package/util/selection-ui-specifiers.ts +34 -0
- package/util/use-sync-sku-param-w-current-item.ts +2 -2
- package/components/select-family/family-carousel/family-slide.tsx +0 -36
- package/components/select-product/carousel-selector.tsx +0 -112
- package/components/select-product/image-selector.tsx +0 -162
- package/components/select-product/radio-selector.tsx +0 -125
- package/service/impls/index.ts +0 -3
- package/types/buy-ui-spec.ts +0 -8
- package/types/tree-node.ts +0 -24
- package/util/buy-ui-conf.ts +0 -35
- package/util/sep.ts +0 -5
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
import React from 'react'
|
|
3
|
+
import { observer } from 'mobx-react-lite'
|
|
4
|
+
|
|
5
|
+
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
Image,
|
|
9
|
+
Label,
|
|
10
|
+
RadioGroup,
|
|
11
|
+
ScrollArea
|
|
12
|
+
} from '@hanzo/ui/primitives'
|
|
13
|
+
import { cn } from '@hanzo/ui/util'
|
|
14
|
+
import type { Dimensions } from '@hanzo/ui/types'
|
|
15
|
+
|
|
16
|
+
import type { ItemSelectorProps, LineItem } from '../../types'
|
|
17
|
+
import { formatCurrencyValue } from '../../util'
|
|
18
|
+
|
|
19
|
+
import QuantityIndicator from '../quantity-indicator'
|
|
20
|
+
|
|
21
|
+
const DEFAULT_CONSTRAINT = {h: 36, w: 72} // // Apple suggest 42px for clickability
|
|
22
|
+
|
|
23
|
+
const ImageRadioGroupItem = React.forwardRef<
|
|
24
|
+
React.ElementRef<typeof RadioGroupPrimitive.Item>,
|
|
25
|
+
Omit<React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>, 'value' | 'id'> & {
|
|
26
|
+
item: LineItem,
|
|
27
|
+
constrainTo: Dimensions
|
|
28
|
+
}
|
|
29
|
+
>(({
|
|
30
|
+
item,
|
|
31
|
+
constrainTo,
|
|
32
|
+
className,
|
|
33
|
+
...props
|
|
34
|
+
}, ref) => {
|
|
35
|
+
|
|
36
|
+
const img = item.optionImg ? item.optionImg : item.img
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<RadioGroupPrimitive.Item
|
|
40
|
+
ref={ref}
|
|
41
|
+
className={cn(
|
|
42
|
+
'ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
|
43
|
+
'focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
|
44
|
+
className,
|
|
45
|
+
'overflow-hidden',
|
|
46
|
+
img?.rounded ? `rounded-${img.rounded}` : 'rounded-sm'
|
|
47
|
+
)}
|
|
48
|
+
{...props}
|
|
49
|
+
id={item.sku}
|
|
50
|
+
value={item.sku}
|
|
51
|
+
>
|
|
52
|
+
{img ? (
|
|
53
|
+
<Image def={img} constrainTo={constrainTo} preload className=''/>
|
|
54
|
+
) : ( // placeholder so things align
|
|
55
|
+
<div style={{height: constrainTo.h, width: constrainTo.w}}/>
|
|
56
|
+
)}
|
|
57
|
+
</RadioGroupPrimitive.Item>
|
|
58
|
+
)
|
|
59
|
+
})
|
|
60
|
+
ImageRadioGroupItem.displayName = 'ImageRadioGroupItem'
|
|
61
|
+
|
|
62
|
+
const ButtonItemSelector: React.FC<ItemSelectorProps> = observer(({
|
|
63
|
+
items,
|
|
64
|
+
selectedItemRef: itemRef,
|
|
65
|
+
selectSku,
|
|
66
|
+
clx='',
|
|
67
|
+
itemClx='',
|
|
68
|
+
soleItemClx='',
|
|
69
|
+
scrollable=false,
|
|
70
|
+
mobile=false,
|
|
71
|
+
options={}
|
|
72
|
+
}) => {
|
|
73
|
+
|
|
74
|
+
const imageButtons = 'imageButtons' in options ? options.imageButtons : false
|
|
75
|
+
const showFamily = 'showFamily' in options ? options.showFamily : false
|
|
76
|
+
const showPrice = 'showPrice' in options ? options.showPrice : true
|
|
77
|
+
const showQuantity = 'showQuantity' in options ? options.showQuantity : false
|
|
78
|
+
const horizButtons = 'horizButtons' in options ? options.horizButtons : false
|
|
79
|
+
|
|
80
|
+
const labelAndPrice = (item : LineItem) => (
|
|
81
|
+
(showFamily ? (item.familyTitle + ', ' + item.optionLabel) : item.optionLabel) +
|
|
82
|
+
(showPrice ? ((showFamily ? ': ' : ', ') + formatCurrencyValue(item.price)) : '')
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
const Item: React.FC<{
|
|
86
|
+
item: LineItem
|
|
87
|
+
selected: boolean
|
|
88
|
+
}> = observer(({
|
|
89
|
+
item,
|
|
90
|
+
selected
|
|
91
|
+
}) => {
|
|
92
|
+
|
|
93
|
+
const textClx = (selected) ? 'text-accent ' : 'text-muted'
|
|
94
|
+
const cursorClx = (selected) ? 'hover:cursor-default' : 'hover:cursor-pointer'
|
|
95
|
+
const justifyClx = (!imageButtons) ? 'justify-center ' : ''
|
|
96
|
+
const paddingClx = (imageButtons) ? (scrollable ? 'px-4' : 'px-2' ) : ''
|
|
97
|
+
let bgClx = ''
|
|
98
|
+
let borderClx = ''
|
|
99
|
+
if (scrollable) {
|
|
100
|
+
borderClx += (selected) ? 'border-foreground border ' : 'border-b border-muted-2 '
|
|
101
|
+
}
|
|
102
|
+
else if (!imageButtons) {
|
|
103
|
+
borderClx += 'border rounded-lg '
|
|
104
|
+
borderClx += (selected) ? 'border-foreground ' : 'border-muted-2 '
|
|
105
|
+
bgClx += 'hover:bg-level-2'
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const outerClx = ['h-10 py-2 flex items-center', justifyClx, paddingClx, bgClx, borderClx]
|
|
109
|
+
|
|
110
|
+
return (
|
|
111
|
+
<div className={cn(...outerClx, itemClx )}>
|
|
112
|
+
<ImageRadioGroupItem
|
|
113
|
+
item={item}
|
|
114
|
+
constrainTo={DEFAULT_CONSTRAINT}
|
|
115
|
+
className={cn(
|
|
116
|
+
cursorClx,
|
|
117
|
+
(scrollable ? '' : 'border-transparent border-2 data-[state=checked]:border-foreground'),
|
|
118
|
+
imageButtons ? 'mr-2' : 'hidden'
|
|
119
|
+
)}
|
|
120
|
+
/>
|
|
121
|
+
<Label htmlFor={item.sku} className={cn(
|
|
122
|
+
showQuantity ? 'flex items-center' : 'block',
|
|
123
|
+
textClx,
|
|
124
|
+
cursorClx,
|
|
125
|
+
)}>
|
|
126
|
+
<div className={showQuantity ? 'grow' : ''}>{labelAndPrice(item)}</div>
|
|
127
|
+
{showQuantity && (
|
|
128
|
+
<QuantityIndicator
|
|
129
|
+
item={item}
|
|
130
|
+
clx='grow-0 shrink-0 h-[20px] ml-2'
|
|
131
|
+
iconClx={selected ? 'fill-foreground' : 'fill-muted'}
|
|
132
|
+
digitClx='font-semibold text-primary-fg leading-none font-sans text-xxs'
|
|
133
|
+
/>
|
|
134
|
+
)}
|
|
135
|
+
</Label>
|
|
136
|
+
</div>
|
|
137
|
+
)
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
return items.length > 1 ? (
|
|
141
|
+
<RadioGroup
|
|
142
|
+
className={cn('flex',
|
|
143
|
+
(scrollable ? 'shrink min-h-0 gap-0' : (mobile ? 'gap-3' : 'gap-1')),
|
|
144
|
+
(horizButtons ? 'flex-row gap-0' : 'flex-col'),
|
|
145
|
+
(mobile && !imageButtons) ? 'min-w-pr-50' : '',
|
|
146
|
+
clx,
|
|
147
|
+
)}
|
|
148
|
+
onValueChange={selectSku}
|
|
149
|
+
value={itemRef.item ? itemRef.item.sku : ''}
|
|
150
|
+
>
|
|
151
|
+
{scrollable ? (
|
|
152
|
+
<ScrollArea className='mt-2 w-full h-full py-0 border border-muted-2' data-vaul-no-drag>
|
|
153
|
+
{items.map((item) => (
|
|
154
|
+
<Item item={item} selected={itemRef.item?.sku === item.sku} key={item.sku}/>
|
|
155
|
+
))}
|
|
156
|
+
</ScrollArea>
|
|
157
|
+
) : (<>
|
|
158
|
+
{items.map((item) => (
|
|
159
|
+
<Item item={item} selected={itemRef.item?.sku === item.sku} key={item.sku}/>
|
|
160
|
+
))}
|
|
161
|
+
</>)}
|
|
162
|
+
</RadioGroup>
|
|
163
|
+
) : (
|
|
164
|
+
<div className={cn(showQuantity ? 'flex items-center' : 'block', soleItemClx )}>
|
|
165
|
+
<div className={showQuantity ? 'grow' : ''}>
|
|
166
|
+
{labelAndPrice(items[0])}
|
|
167
|
+
</div>
|
|
168
|
+
{showQuantity && (
|
|
169
|
+
<QuantityIndicator item={items[0]} clx='grow-0 shrink-0 font-semibold text-sm leading-none px-2' />
|
|
170
|
+
)}
|
|
171
|
+
</div>
|
|
172
|
+
)
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
export default ButtonItemSelector
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
import React, { useCallback, useEffect, useRef } from 'react'
|
|
3
|
+
import { reaction } from 'mobx'
|
|
4
|
+
import { observer } from 'mobx-react-lite'
|
|
5
|
+
|
|
6
|
+
import { cn } from '@hanzo/ui/util'
|
|
7
|
+
import type { Dimensions } from '@hanzo/ui/types'
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
type CarouselOptions,
|
|
11
|
+
type CarouselApi,
|
|
12
|
+
Carousel,
|
|
13
|
+
CarouselContent,
|
|
14
|
+
CarouselItem,
|
|
15
|
+
CarouselPrevious,
|
|
16
|
+
CarouselNext,
|
|
17
|
+
ApplyTypography,
|
|
18
|
+
MediaStack
|
|
19
|
+
} from '@hanzo/ui/primitives'
|
|
20
|
+
|
|
21
|
+
import type { ItemSelectorProps, LineItem } from '../../types'
|
|
22
|
+
import { formatCurrencyValue } from '../../util'
|
|
23
|
+
|
|
24
|
+
import QuantityIndicator from '../quantity-indicator'
|
|
25
|
+
|
|
26
|
+
const DEFAULT_CONSTRAINT = {w: 250, h: 250}
|
|
27
|
+
|
|
28
|
+
interface CarouselItemSelectorPropsExt {
|
|
29
|
+
constrainTo: Dimensions
|
|
30
|
+
options?: CarouselOptions
|
|
31
|
+
/** Do not show Family and / or Item title and Price */
|
|
32
|
+
imageOnly?: boolean
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const ItemSlide: React.FC<{
|
|
36
|
+
item: LineItem,
|
|
37
|
+
constrainTo: Dimensions
|
|
38
|
+
imageOnly: boolean
|
|
39
|
+
showFamily: boolean
|
|
40
|
+
showPrice: boolean
|
|
41
|
+
showQuantity: boolean
|
|
42
|
+
clx?: string
|
|
43
|
+
}> = ({
|
|
44
|
+
item,
|
|
45
|
+
constrainTo,
|
|
46
|
+
showFamily,
|
|
47
|
+
imageOnly,
|
|
48
|
+
showPrice,
|
|
49
|
+
showQuantity,
|
|
50
|
+
clx=''
|
|
51
|
+
}) => {
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<CarouselItem className={cn('p-2 flex flex-col justify-center items-center', clx)}>
|
|
55
|
+
{!imageOnly && (
|
|
56
|
+
<ApplyTypography className='flex flex-col items-center !gap-2 [&>*]:!m-0'>
|
|
57
|
+
{showFamily && (<h4>{item.familyTitle}</h4>)}
|
|
58
|
+
<h6>{item.optionLabel}</h6>
|
|
59
|
+
{item.byline && (<p>{item.byline}</p>)}
|
|
60
|
+
</ApplyTypography>
|
|
61
|
+
)}
|
|
62
|
+
<MediaStack media={item} constrainTo={constrainTo} clx='my-4' />
|
|
63
|
+
{!imageOnly && (showPrice || showQuantity) && (
|
|
64
|
+
<ApplyTypography className='flex flex-row items-center !gap-2 [&>*]:!m-0'>
|
|
65
|
+
{showPrice && (<p>{formatCurrencyValue(item.price)}</p>)}
|
|
66
|
+
{showQuantity && (
|
|
67
|
+
<QuantityIndicator
|
|
68
|
+
item={item}
|
|
69
|
+
clx='h-[26px] ml-1'
|
|
70
|
+
iconClx='fill-foreground'
|
|
71
|
+
digitClx='not-typography font-semibold text-primary-fg leading-none font-sans text-xs'
|
|
72
|
+
/>
|
|
73
|
+
)}
|
|
74
|
+
</ApplyTypography>
|
|
75
|
+
)}
|
|
76
|
+
</CarouselItem>
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const CarouselItemSelector: React.FC<ItemSelectorProps> = observer(({
|
|
81
|
+
items,
|
|
82
|
+
selectSku,
|
|
83
|
+
selectedItemRef: itemRef,
|
|
84
|
+
scrollable, // ignored
|
|
85
|
+
clx='',
|
|
86
|
+
itemClx='',
|
|
87
|
+
options={},
|
|
88
|
+
ext={
|
|
89
|
+
options: {loop: true},
|
|
90
|
+
constrainTo: DEFAULT_CONSTRAINT,
|
|
91
|
+
imageOnly: false
|
|
92
|
+
} satisfies CarouselItemSelectorPropsExt
|
|
93
|
+
}) => {
|
|
94
|
+
|
|
95
|
+
const showFamily = 'showFamily' in options ? options.showFamily! : false
|
|
96
|
+
const showPrice = 'showPrice' in options ? options.showPrice! : true
|
|
97
|
+
const showQuantity = 'showQuantity' in options ? options.showQuantity! : false
|
|
98
|
+
|
|
99
|
+
const carouselOptions = 'options' in ext ? ext.options : undefined
|
|
100
|
+
const constrainTo = 'constrainTo' in ext ? ext.constrainTo : DEFAULT_CONSTRAINT
|
|
101
|
+
const imageOnly = 'imageOnly' in ext ? ext.imageOnly : false
|
|
102
|
+
|
|
103
|
+
const elbaApiRef = useRef<CarouselApi | undefined>(undefined)
|
|
104
|
+
const dontRespondRef = useRef<boolean>(false)
|
|
105
|
+
|
|
106
|
+
const setApi = (api: CarouselApi) => {elbaApiRef.current = api}
|
|
107
|
+
|
|
108
|
+
const onSelect = useCallback((emblaApi: CarouselApi) => {
|
|
109
|
+
if (dontRespondRef.current) {
|
|
110
|
+
dontRespondRef.current = false
|
|
111
|
+
return
|
|
112
|
+
}
|
|
113
|
+
const index = emblaApi.selectedScrollSnap()
|
|
114
|
+
if (index !== -1) {
|
|
115
|
+
selectSku(items[index].sku)
|
|
116
|
+
}
|
|
117
|
+
dontRespondRef.current = false
|
|
118
|
+
}, [])
|
|
119
|
+
|
|
120
|
+
useEffect(() => {
|
|
121
|
+
return reaction(
|
|
122
|
+
() => ({item: itemRef.item}),
|
|
123
|
+
({item}) => {
|
|
124
|
+
if (elbaApiRef.current) {
|
|
125
|
+
const index = items.findIndex((el) => (el.sku === item?.sku))
|
|
126
|
+
if (index !== -1) {
|
|
127
|
+
dontRespondRef.current = true
|
|
128
|
+
elbaApiRef.current.scrollTo(index)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
)
|
|
133
|
+
}, [])
|
|
134
|
+
|
|
135
|
+
return (
|
|
136
|
+
<Carousel options={carouselOptions} className={cn('w-full px-2', clx)} onCarouselSelect={onSelect} setApi={setApi}>
|
|
137
|
+
<CarouselContent>
|
|
138
|
+
{items.map((item) => (
|
|
139
|
+
<ItemSlide
|
|
140
|
+
key={item.sku}
|
|
141
|
+
item={item}
|
|
142
|
+
{...{
|
|
143
|
+
showFamily,
|
|
144
|
+
showPrice,
|
|
145
|
+
showQuantity,
|
|
146
|
+
constrainTo,
|
|
147
|
+
imageOnly
|
|
148
|
+
}}
|
|
149
|
+
clx={itemClx}
|
|
150
|
+
/>
|
|
151
|
+
))}
|
|
152
|
+
</CarouselContent>
|
|
153
|
+
<CarouselPrevious className='left-1'/>
|
|
154
|
+
<CarouselNext className='right-1'/>
|
|
155
|
+
</Carousel>
|
|
156
|
+
)
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
export {
|
|
160
|
+
type CarouselItemSelectorPropsExt,
|
|
161
|
+
CarouselItemSelector as default
|
|
162
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
import React from 'react'
|
|
3
|
+
import { observer } from 'mobx-react-lite'
|
|
4
|
+
|
|
5
|
+
import { type LucideProps } from 'lucide-react'
|
|
6
|
+
|
|
7
|
+
import { cn } from '@hanzo/ui/util'
|
|
8
|
+
import type { LineItem } from '../types'
|
|
9
|
+
|
|
10
|
+
// Generalize this.
|
|
11
|
+
const BagIcon: React.FC<LucideProps> = (props: LucideProps) => (
|
|
12
|
+
<svg fill="currentColor" viewBox='0 0 20 23' {...props}>
|
|
13
|
+
<path fillRule="evenodd" d="M5 5a5 5 0 0 1 10 0v1h-2V5a3 3 0 1 0-6 0v1H5V5Zm0 1v4h2V6h6v4h2V6h3.5a1 1 0 0 1 1 1v15a1 1 0 0 1-1 1h-17a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1H5Z" clipRule="evenodd"/>
|
|
14
|
+
</svg>
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
const QuantityIndicator: React.FC<{
|
|
18
|
+
item: LineItem
|
|
19
|
+
clx?: string
|
|
20
|
+
iconClx?: string
|
|
21
|
+
digitClx?: string
|
|
22
|
+
}> = observer(({
|
|
23
|
+
item,
|
|
24
|
+
clx='',
|
|
25
|
+
iconClx='',
|
|
26
|
+
digitClx=''
|
|
27
|
+
}) => {
|
|
28
|
+
|
|
29
|
+
if (!item.isInCart) {
|
|
30
|
+
return <div />
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<div className={cn('relative aspect-square flex items-center justify-center', clx)} >
|
|
35
|
+
<div className={cn(
|
|
36
|
+
'z-above-content flex flex-col justify-center items-center',
|
|
37
|
+
'absolute left-0 right-0 top-0 bottom-0',
|
|
38
|
+
digitClx
|
|
39
|
+
)}>
|
|
40
|
+
<div className='h-[1px] w-full' />
|
|
41
|
+
<div style={{color: 'black' /* tailwind bug */}}>{item.quantity}</div>
|
|
42
|
+
</div>
|
|
43
|
+
<BagIcon className={cn('relative -top-[12%] h-full ', iconClx, )} aria-hidden="true" />
|
|
44
|
+
</div>
|
|
45
|
+
)
|
|
46
|
+
}) // -top-[3px]
|
|
47
|
+
|
|
48
|
+
export default QuantityIndicator
|
|
@@ -1,78 +1,87 @@
|
|
|
1
1
|
'use client'
|
|
2
|
-
import React, { useCallback,
|
|
3
|
-
import { reaction } from 'mobx'
|
|
4
|
-
import { observer } from 'mobx-react-lite'
|
|
2
|
+
import React, { useCallback, useRef } from 'react'
|
|
5
3
|
|
|
6
4
|
import { cn } from '@hanzo/ui/util'
|
|
7
5
|
|
|
8
6
|
import {
|
|
9
|
-
type CarouselOptions,
|
|
10
7
|
type CarouselApi,
|
|
11
8
|
Carousel,
|
|
12
9
|
CarouselContent,
|
|
13
10
|
CarouselItem,
|
|
14
11
|
CarouselPrevious,
|
|
15
12
|
CarouselNext,
|
|
16
|
-
ApplyTypography,
|
|
17
|
-
MediaStack
|
|
18
13
|
} from '@hanzo/ui/primitives'
|
|
14
|
+
import type { Dimensions } from '@hanzo/ui/types'
|
|
19
15
|
|
|
20
|
-
import type { Family,
|
|
21
|
-
import { formatCurrencyValue } from '../../../util'
|
|
16
|
+
import type { Family, FamilyCarouselSlideOptions } from '../../../types'
|
|
22
17
|
|
|
18
|
+
import FamilySlide from './slide'
|
|
19
|
+
import {
|
|
20
|
+
SlideState,
|
|
21
|
+
FamilyCarouselState
|
|
22
|
+
} from './state'
|
|
23
|
+
|
|
24
|
+
import { useCommerce } from '../../..'
|
|
23
25
|
|
|
24
26
|
const FamilyCarousel: React.FC<{
|
|
25
|
-
|
|
27
|
+
families: Family[]
|
|
26
28
|
clx?: string
|
|
27
29
|
itemClx?: string
|
|
28
|
-
|
|
29
|
-
mediaConstraint?:
|
|
30
|
-
getBylineText?: (c: Family) => string
|
|
31
|
-
variantOrientation?: 'vert' | 'horiz'
|
|
32
|
-
showVariantImage?: boolean
|
|
33
|
-
showVariantPrice?: boolean
|
|
34
|
-
onQuantityChanged?: (sku: string, oldV: number, newV: number) => void
|
|
30
|
+
slideOptions?: FamilyCarouselSlideOptions
|
|
31
|
+
mediaConstraint?: Dimensions
|
|
35
32
|
mobile?: boolean
|
|
36
|
-
}> =
|
|
37
|
-
|
|
33
|
+
}> = ({
|
|
34
|
+
families,
|
|
38
35
|
clx='',
|
|
39
36
|
itemClx='',
|
|
40
|
-
|
|
37
|
+
slideOptions,
|
|
41
38
|
mediaConstraint={w: 250, h: 250},
|
|
42
|
-
variantOrientation='vert',
|
|
43
|
-
showVariantImage=true,
|
|
44
|
-
showVariantPrice=true,
|
|
45
|
-
onQuantityChanged,
|
|
46
39
|
mobile=false,
|
|
47
40
|
}) => {
|
|
48
41
|
|
|
49
|
-
const
|
|
50
|
-
const
|
|
42
|
+
const cmmc = useCommerce()
|
|
43
|
+
const stateRef = useRef<FamilyCarouselState>(
|
|
44
|
+
new FamilyCarouselState(families, cmmc.setCurrentItem.bind(cmmc))
|
|
45
|
+
)
|
|
51
46
|
|
|
52
47
|
const onSelect = useCallback((emblaApi: CarouselApi) => {
|
|
53
48
|
const index = emblaApi.selectedScrollSnap()
|
|
54
49
|
if (index !== -1) {
|
|
55
|
-
|
|
56
|
-
// cmmc.selectPath
|
|
50
|
+
stateRef.current.setCurrentFamily(families[index].id)
|
|
57
51
|
}
|
|
58
|
-
}, [])
|
|
59
|
-
|
|
60
|
-
const TEST = ["one", "two", "three"]
|
|
52
|
+
}, [stateRef.current])
|
|
61
53
|
|
|
62
54
|
return (
|
|
63
|
-
<Carousel
|
|
55
|
+
<Carousel
|
|
56
|
+
className={cn('px-2', clx)}
|
|
57
|
+
options={{loop: false}}
|
|
58
|
+
onCarouselSelect={onSelect}
|
|
59
|
+
>
|
|
64
60
|
<CarouselContent>
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
61
|
+
{families.map((family) => {
|
|
62
|
+
const slideState = stateRef.current.getSlideState(family.id)
|
|
63
|
+
if (!slideState) {
|
|
64
|
+
throw new Error(`FamilyCarousel: no SlideState for family '${family.id}'!`)
|
|
65
|
+
}
|
|
66
|
+
return (
|
|
67
|
+
<CarouselItem key={family.id} className='p-2 flex flex-col justify-center items-center'>
|
|
68
|
+
<FamilySlide
|
|
69
|
+
family={family}
|
|
70
|
+
selectedItemRef={slideState}
|
|
71
|
+
selectSku={slideState.selectSku.bind(slideState)}
|
|
72
|
+
mediaConstraint={mediaConstraint}
|
|
73
|
+
options={slideOptions}
|
|
74
|
+
mobile={mobile}
|
|
75
|
+
clx={itemClx}
|
|
76
|
+
/>
|
|
77
|
+
</CarouselItem>
|
|
78
|
+
)}
|
|
79
|
+
)}
|
|
71
80
|
</CarouselContent>
|
|
72
81
|
<CarouselPrevious className='left-1'/>
|
|
73
82
|
<CarouselNext className='right-1'/>
|
|
74
83
|
</Carousel>
|
|
75
84
|
)
|
|
76
|
-
}
|
|
85
|
+
}
|
|
77
86
|
|
|
78
87
|
export default FamilyCarousel
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
import React from 'react'
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
ApplyTypography,
|
|
6
|
+
MediaStack
|
|
7
|
+
} from '@hanzo/ui/primitives'
|
|
8
|
+
import type { Dimensions } from '@hanzo/ui/types'
|
|
9
|
+
|
|
10
|
+
import type { FamilyCarouselSlideOptions, Family, LineItem, ItemSelector } from '../../../types'
|
|
11
|
+
import { ButtonItemSelector } from '../..'
|
|
12
|
+
import { cn } from '@hanzo/ui/util'
|
|
13
|
+
|
|
14
|
+
const FamilySlide: React.FC<Omit<ItemSelector, 'items'> & {
|
|
15
|
+
family: Family
|
|
16
|
+
mediaConstraint: Dimensions
|
|
17
|
+
clx?: string
|
|
18
|
+
mobile?: boolean,
|
|
19
|
+
options?: FamilyCarouselSlideOptions
|
|
20
|
+
}> = ({
|
|
21
|
+
family,
|
|
22
|
+
selectedItemRef,
|
|
23
|
+
selectSku,
|
|
24
|
+
mediaConstraint: cnst = {w: 200, h: 200},
|
|
25
|
+
clx='',
|
|
26
|
+
mobile=false,
|
|
27
|
+
options={}
|
|
28
|
+
}) => {
|
|
29
|
+
|
|
30
|
+
const titleSpec = 'title' in options ? options.title : 'short'
|
|
31
|
+
const showByline = 'showByline' in options ? options.showByline : true
|
|
32
|
+
const title = titleSpec === 'long' ?
|
|
33
|
+
family.title
|
|
34
|
+
:
|
|
35
|
+
(
|
|
36
|
+
titleSpec === 'short' ?
|
|
37
|
+
(family.titleShort ? family.titleShort : family.title )
|
|
38
|
+
:
|
|
39
|
+
undefined
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
const byline = (showByline && family.byline) ? family.byline : undefined
|
|
43
|
+
/*
|
|
44
|
+
let byline: string | undefined = undefined
|
|
45
|
+
if (showByline) {
|
|
46
|
+
if (family.byline) {
|
|
47
|
+
// if byline names another field thats a function, call it
|
|
48
|
+
if (family.byline in family && typeof (family as any)[family.byline] === 'function') {
|
|
49
|
+
byline = (family as any)[family.byline]() as string
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
byline = family.byline
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
*/
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<div className={cn('flex flex-col items-center gap-4', clx)}>
|
|
60
|
+
{title && (
|
|
61
|
+
<ApplyTypography className='flex flex-col items-center !gap-1 [&>*]:!m-0'>
|
|
62
|
+
<h6 className='font-bold text-lg'>{title}</h6>
|
|
63
|
+
{byline && (<p className={''}>{byline}</p>)}
|
|
64
|
+
</ApplyTypography>
|
|
65
|
+
)}
|
|
66
|
+
<MediaStack
|
|
67
|
+
media={selectedItemRef.item!}
|
|
68
|
+
constrainTo={cnst}
|
|
69
|
+
/>
|
|
70
|
+
<ButtonItemSelector
|
|
71
|
+
items={family.products as LineItem[]}
|
|
72
|
+
selectedItemRef={selectedItemRef}
|
|
73
|
+
selectSku={selectSku}
|
|
74
|
+
scrollable={false}
|
|
75
|
+
mobile={mobile}
|
|
76
|
+
options={options}
|
|
77
|
+
itemClx='text-sm'
|
|
78
|
+
/>
|
|
79
|
+
</div>
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export default FamilySlide
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { Family, LineItem, ObsLineItemRef, Product } from '../../../types'
|
|
2
|
+
import { observable, action, computed, makeObservable } from 'mobx'
|
|
3
|
+
|
|
4
|
+
class SlideState implements ObsLineItemRef {
|
|
5
|
+
|
|
6
|
+
_fam: Family
|
|
7
|
+
_item: Product
|
|
8
|
+
_syncSku: (sku: string) => void
|
|
9
|
+
_selected: boolean
|
|
10
|
+
|
|
11
|
+
constructor(fam: Family, initiallySelected: boolean, sync: (sku: string) => void) {
|
|
12
|
+
this._fam = fam
|
|
13
|
+
this._item = fam.products[0]
|
|
14
|
+
this._selected = initiallySelected
|
|
15
|
+
this._syncSku = sync
|
|
16
|
+
|
|
17
|
+
makeObservable(this, {
|
|
18
|
+
_item: observable,
|
|
19
|
+
item: computed,
|
|
20
|
+
selectSku: action
|
|
21
|
+
})
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
selectSku = (sku: string) => {
|
|
25
|
+
if (this._item.sku === sku) return;
|
|
26
|
+
const found = this._fam.products.find((i) => (i.sku === sku))
|
|
27
|
+
if (found) {
|
|
28
|
+
this._item = found
|
|
29
|
+
if (this._selected) {
|
|
30
|
+
this._syncSku(sku)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
throw new Error('SlideState.selectSku(): no item found!')
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
setSelected = (b: boolean) => {this._selected = b}
|
|
39
|
+
|
|
40
|
+
get id(): string {return (this._fam.id)}
|
|
41
|
+
|
|
42
|
+
get item(): LineItem | undefined {
|
|
43
|
+
return this._item as LineItem
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
class FamilyCarouselState {
|
|
48
|
+
|
|
49
|
+
_map = new Map<string, SlideState>()
|
|
50
|
+
_syncSku: (sku: string) => void
|
|
51
|
+
|
|
52
|
+
constructor(fams: Family[], syncSkuToCurrentSlide: (sku: string) => void) {
|
|
53
|
+
|
|
54
|
+
fams.forEach((f, index) => {this._map.set(f.id, new SlideState(
|
|
55
|
+
f,
|
|
56
|
+
(index === 0),
|
|
57
|
+
syncSkuToCurrentSlide
|
|
58
|
+
))})
|
|
59
|
+
this._syncSku = syncSkuToCurrentSlide
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
setCurrentFamily = (id: string) => {
|
|
63
|
+
|
|
64
|
+
this._map.forEach((s) => {
|
|
65
|
+
s.setSelected(s.id === id)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
const slideState = this._map.get(id)
|
|
69
|
+
if (slideState) {
|
|
70
|
+
this._syncSku(slideState.item!.sku)
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
throw new Error(`FamilyCarouselState.setCurrentFamily: no state object for id '${id}'!`)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
getSlideState = (familyId: string): SlideState | undefined => (this._map.get(familyId))
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export {
|
|
81
|
+
SlideState,
|
|
82
|
+
FamilyCarouselState
|
|
83
|
+
}
|
|
@@ -4,11 +4,11 @@ import React, { useState } from 'react'
|
|
|
4
4
|
import { ToggleGroup, ToggleGroupItem} from "@hanzo/ui/primitives"
|
|
5
5
|
import { cn } from '@hanzo/ui/util'
|
|
6
6
|
|
|
7
|
-
import type {
|
|
7
|
+
import type { CategoryNode, StringMutator, StringArrayMutator } from '../../../types'
|
|
8
8
|
import NodeImage from './node-image'
|
|
9
9
|
|
|
10
10
|
const LevelNodesWidget: React.FC<{
|
|
11
|
-
levelNodes:
|
|
11
|
+
levelNodes: CategoryNode[]
|
|
12
12
|
mutator: StringMutator | StringArrayMutator
|
|
13
13
|
multiple?: boolean
|
|
14
14
|
className?: string
|