@hanzo/commerce 1.0.8

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.
@@ -0,0 +1,84 @@
1
+ 'use client'
2
+ import React, { useEffect } from 'react'
3
+ import { computed } from 'mobx'
4
+ import { observer } from 'mobx-react-lite'
5
+
6
+ import { ApplyTypography, ListBox } from '@hanzo/ui/primitives'
7
+ import { cn } from '@hanzo/ui/util'
8
+
9
+ import type { FacetValueDesc, FacetsValue, LineItem } from '../types'
10
+ import { useCommerce } from '../service'
11
+ import { formatPrice } from '../util'
12
+
13
+ import FacetTogglesWidget from './facet-toggles-widget'
14
+
15
+ const formatItem = (item: LineItem, withQuantity: boolean = false): string => (
16
+ `${item.titleAsOption}, ${formatPrice(item.price)}${(withQuantity && item.quantity > 0) ? ` (${item.quantity})` : ''}`
17
+ )
18
+
19
+ const SelectCategoryAndItemWidget: React.FC<{
20
+ categoryLevel: number
21
+ parentLevelToken: string
22
+ categoryLevelValues: FacetValueDesc[]
23
+ className?: string
24
+ }> = observer(({
25
+ categoryLevel,
26
+ parentLevelToken,
27
+ categoryLevelValues,
28
+ className=''
29
+ }) => {
30
+ const comm = useCommerce()
31
+
32
+ useEffect(() => {
33
+ const facets: FacetsValue = {}
34
+ facets[categoryLevel - 1] = [parentLevelToken]
35
+ facets[categoryLevel] = [categoryLevelValues[0].value]
36
+ comm.setFacets(facets)
37
+ comm.setCurrentItem(comm.specifiedCategories[0].products[0].sku)
38
+ }, [])
39
+
40
+ const onFacetTokenChanged = (token: string): void => {
41
+ const facets: FacetsValue = {}
42
+ facets[categoryLevel - 1] = [parentLevelToken]
43
+ facets[categoryLevel] = [token]
44
+ comm.setFacets(facets)
45
+ }
46
+
47
+ const currentFacetToken = computed((): string | null => {
48
+ if (comm.specifiedCategories.length === 0) return null
49
+ const skuPath = comm.specifiedCategories[0].id
50
+ const skuPathTokens = skuPath.split('-')
51
+ return skuPathTokens.length > 0 ? skuPathTokens[skuPathTokens.length - 1] : null
52
+ })
53
+
54
+ return (
55
+ <div className={cn('flex flex-col justify-start gap-4 items-start pt-3', className)}>
56
+ <FacetTogglesWidget
57
+ facetValues={categoryLevelValues}
58
+ mutator={{
59
+ val: currentFacetToken.get(),
60
+ set: onFacetTokenChanged
61
+ }}
62
+ />
63
+ {comm.specifiedItems.length === 0 ? (
64
+ <ApplyTypography>
65
+ <h3>No Items</h3>
66
+ </ApplyTypography>
67
+ ) : comm.specifiedItems.length === 1 ? (
68
+ <ApplyTypography>
69
+ <h4>{formatItem(comm.specifiedItems[0])}</h4>
70
+ </ApplyTypography>
71
+ ) : (
72
+ <ListBox<string>
73
+ values={comm.specifiedItems.map((it) => (it.sku))}
74
+ labels={comm.specifiedItems.map((it) => (formatItem(it)))}
75
+ isEqual={(v1: string, v2: string) => (v1 === v2)}
76
+ value={comm.currentItem?.sku}
77
+ onValueChange={comm.setCurrentItem.bind(comm)}
78
+ />
79
+ )}
80
+ </div>
81
+ )
82
+ })
83
+
84
+ export default SelectCategoryAndItemWidget
@@ -0,0 +1,200 @@
1
+ 'use client'
2
+ import React, { useState } from 'react'
3
+ import Image from 'next/image'
4
+ import { observer } from 'mobx-react-lite'
5
+
6
+ import { cn } from '@hanzo/ui/util'
7
+ import { Skeleton } from '@hanzo/ui/primitives'
8
+
9
+ import { useCommerce } from '../service'
10
+ import type { Category, LineItem, ObsLineItemRef } from '../types'
11
+ import { formatPrice } from '../util'
12
+ import { Icons } from './Icons'
13
+
14
+ import AddToCartWidget from './add-to-cart-widget'
15
+ import ProductSelectionRadioGroup from './product-selection-radio-group'
16
+ import ProductSelectionMobilePicker from './product-selection-mobile-picker'
17
+
18
+ const SelectItemInCategoryView: React.FC<React.HTMLAttributes<HTMLDivElement> & {
19
+ category: Category
20
+ lineItemRef: ObsLineItemRef
21
+ handleItemSelected: (sku: string) => void
22
+ isLoading?: boolean
23
+ mobile?: boolean
24
+ }> = ({
25
+ category,
26
+ lineItemRef,
27
+ handleItemSelected,
28
+ className,
29
+ isLoading = false,
30
+ mobile = false,
31
+ ...props
32
+ }) => {
33
+
34
+ const waiting = (): boolean => (isLoading || !category)
35
+
36
+ const CategoryImage: React.FC<{ className?: string }> = ({ className = '' }) => {
37
+
38
+ if (waiting()) {
39
+ // deliberately not Skeleton to have a better overall pulse effect.
40
+ return <div className={cn(
41
+ 'bg-level-1 rounded-xl aspect-square ' +
42
+ ' min-h-[100px] sm:min-h-[200px] lg:aspect-auto lg:h-[300px] lg:w-[200px] 2xl:w-auto 2xl:aspect-square',
43
+ className)} />
44
+ }
45
+
46
+ if (!category.img) {
47
+ return (
48
+ <div
49
+ aria-label='Placeholder'
50
+ role='img'
51
+ aria-roledescription='placeholder'
52
+ className={cn('h-60 flex items-center justify-center', className)}
53
+ >
54
+ <Icons.barcode className='h-9 w-9 text-muted' aria-hidden='true' />
55
+ </div>
56
+ )
57
+ }
58
+
59
+ return (
60
+ <div className={cn('flex flex-col justify-start', className)}>
61
+ <div className={cn('w-full border rounded-xl p-6 ')}>
62
+ <div className={cn('w-full aspect-square relative')}>
63
+ <Image
64
+ src={category.img!}
65
+ fill
66
+ sizes="(max-width: 480px) 100vw, (max-width: 768px) 50vw, (max-width: 1200px) 50vw, 20vw"
67
+ alt={category.title}
68
+ className=''
69
+ loading='lazy'
70
+ style={{ objectFit: 'contain' }} />
71
+ </div>
72
+ </div>
73
+ </div>
74
+ )
75
+ }
76
+
77
+ const AvailableAmounts: React.FC<{ className?: string }> = observer(({ className = '' }) => {
78
+
79
+ const soleOption = !waiting() && category.products.length === 1
80
+ const mobilePicker = !waiting() && (mobile && category.products.length > 8)
81
+
82
+ return waiting() ? (
83
+ <Skeleton className={'min-h-[120px] w-pr-60 mx-auto ' + className} />
84
+ ) : (
85
+ <div /* id='CV_AVAIL_AMOUNTS' */ className={cn(
86
+ 'w-full md:w-auto flex flex-col justify-start items-center',
87
+ (soleOption ? 'gap-2' : mobilePicker ? 'gap-4' : 'gap-8'),
88
+ className
89
+ )}>
90
+ <div className='w-full flex flex-col justify-start items-center'>
91
+ <h6 className='text-center font-semibold'>{`Available size${soleOption ? '' : 's'}`}</h6>
92
+ <div className={'h-[1px] bg-muted-3 ' + (mobilePicker ? 'w-pr-55' : 'w-pr-70') } />
93
+ </div>
94
+ {soleOption ? (
95
+ <p>{category.products[0].titleAsOption}</p>
96
+ ) : ( mobilePicker ? (
97
+ <ProductSelectionMobilePicker
98
+ products={category.products}
99
+ selectedSku={lineItemRef.item?.sku ?? undefined}
100
+ onValueChange={handleItemSelected}
101
+ height={180}
102
+ itemHeight={30}
103
+ outerClx='mb-4'
104
+ />
105
+ ) : (
106
+ <ProductSelectionRadioGroup
107
+ products={category.products}
108
+ selectedSku={lineItemRef.item?.sku ?? undefined}
109
+ onValueChange={handleItemSelected}
110
+ groupClx='block xs:columns-2 xs:px-3 columns-3 gap-2 lg:columns-2 lg:gap-6 w-full lg:auto'
111
+ itemClx='flex flex-row gap-2 items-center mb-3 xs:mb-5'
112
+ />
113
+ )
114
+ )}
115
+ </div>
116
+ )
117
+ })
118
+
119
+ const AddToCartArea: React.FC<{ className?: string }> = observer(({ className = '' }) => (
120
+ (lineItemRef.item && !isLoading) ? (
121
+ <AddToCartWidget size='default' item={lineItemRef.item} className={className}/>
122
+ ) : (
123
+ <div className={cn('h-6 w-12 invisible', className)} />
124
+ )
125
+ ))
126
+
127
+ const TitleArea: React.FC<{ className?: string }> = observer(({ className = '' }) => (
128
+ waiting() ? (<Skeleton className={'h-12 w-pr-80 mx-auto ' + className} />) : (
129
+
130
+ <div className={cn('flex flex-col justify-start items-center mb-6', className)}>
131
+ <h3 className='text-lg lg:text-xl font-heading text-center'>
132
+ <span className='xs:inline sm:hidden md:inline lg:hidden'>
133
+ {category.title.split(', ').map((s, i) => (<p key={i}>{s}</p>))}
134
+ </span>
135
+ <span className='xs:hidden sm:inline md:hidden lg:inline'>
136
+ {category.title}
137
+ </span>
138
+ </h3>
139
+ {lineItemRef.item?.sku ? (
140
+ <h6 className='text-center font-semibold'>
141
+ {lineItemRef.item.titleAsOption + ': ' + formatPrice(lineItemRef.item.price)}
142
+ </h6>
143
+ ) : ''}
144
+ </div>
145
+ )))
146
+
147
+ const Desc: React.FC<{ className?: string }> = ({ className = '' }) => (
148
+ waiting() ? (
149
+ <Skeleton className={'min-h-20 w-full grow mx-auto ' + className} />
150
+ ) : (
151
+ <p className={cn('text-base lg:text-lg mb-6 xs:mb-0', className)}>{category.desc}</p>
152
+ )
153
+ )
154
+
155
+ return mobile ? (
156
+ <div /* id='CV_OUTER' */
157
+ className={cn(
158
+ 'w-full h-[calc(100svh-96px)] max-h-[700px] flex flex-col justify-between ' +
159
+ 'items-stretch gap-[4vh] mt-[2vh] pb-[6vh]',
160
+ className
161
+ )}
162
+ {...props}
163
+ >
164
+ <div /* id='CV_TITLE_AND_IMAGE_ROW' */ className='flex flex-row justify-between items-start w-full'>
165
+ {waiting() ? ( <Skeleton className={'min-h-30 w-full '} /> ) : (<>
166
+ <CategoryImage className='w-pr-33' />
167
+ <TitleArea className='grow pt-3 mb-0' />
168
+ </>)}
169
+ </div>
170
+ <Desc className='' />
171
+ <AvailableAmounts className='mb-[3vh]' />
172
+ <AddToCartArea className='w-pr-70 mx-auto' />
173
+ </div>
174
+ ) : (
175
+ <div /* id='CV_OUTERMOST' */>
176
+ <div /* id='CV_OUTER' */ className={cn('w-full flex flex-row justify-between items-stretch gap-6 sm:gap-4', className)} {...props}>
177
+ <div /* id='CV_IMAGE_COL' */ className={'relative ' + (!isLoading ? 'w-pr-33' : '')}>
178
+ <CategoryImage className='' />
179
+ </div>
180
+ <div /* id='CV_CONTENT_COL */ className='w-pr-66'>
181
+ <div /* id='CV_CONTENT' */ className={'flex flex-col gap-2.5 ' + (isLoading ? 'justify-between h-full' : '')}>
182
+ <TitleArea className='' />
183
+ <Desc className='' />
184
+ </div>
185
+ <div /* id='CV_CTA_AREA_BIG' */ className='hidden lg:flex p-4 flex-col justify-start items-center gap-6'>
186
+ <AvailableAmounts />
187
+ <AddToCartArea className='' />
188
+ </div>
189
+ </div>
190
+ </div>
191
+ <div /* id='CV_CTA_AREA_COMPACT' */ className='lg:hidden flex p-4 flex-col justify-start items-center gap-6'>
192
+ <AvailableAmounts />
193
+ <AddToCartArea className='' />
194
+ </div>
195
+ </div>
196
+ )
197
+ }
198
+
199
+
200
+ export default SelectItemInCategoryView
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@hanzo/commerce",
3
+ "version": "1.0.8",
4
+ "description": "Library with shopping cart components.",
5
+ "publishConfig": {
6
+ "registry": "https://registry.npmjs.org/",
7
+ "access": "public",
8
+ "scope": "@hanzo"
9
+ },
10
+ "author": "Hanzo AI, Inc.",
11
+ "license": "BSD-3-Clause",
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "git+https://github.com/hanzoai/react-sdk.git",
15
+ "directory": "packages/commerce"
16
+ },
17
+ "keywords": [
18
+ "cart",
19
+ "hanzoai",
20
+ "hanzo"
21
+ ],
22
+ "scripts": {
23
+ "lat": "npm show @hanzo/commerce version",
24
+ "pub": "npm publish",
25
+ "build": "tsc",
26
+ "tc": "tsc",
27
+ "clean": "rm -rf dist && rm -rf node_modules"
28
+ },
29
+ "dependencies": {
30
+ "@hookform/resolvers": "^3.3.2",
31
+ "lucide-react": "^0.307.0",
32
+ "react-mobile-picker": "^1.0.0"
33
+ },
34
+ "peerDependencies": {
35
+ "@hanzo/ui": "^1.0.8",
36
+ "@hanzo/auth": "^1.0.9",
37
+ "next": "^14.1.0",
38
+ "react": "^18.2.0",
39
+ "react-dom": "^18.2.0",
40
+ "mobx": "^6.12.0",
41
+ "mobx-react-lite": "^4.0.5",
42
+ "firebase": "^10.8.0"
43
+ },
44
+ "devDependencies": {
45
+ "@types/react": "^18.2.48",
46
+ "@types/react-dom": "^18.2.18",
47
+ "cross-fetch": "^4.0.0",
48
+ "typescript": "^5.3.3"
49
+ }
50
+ }
@@ -0,0 +1,49 @@
1
+ import type {
2
+ Category,
3
+ FacetsValue,
4
+ LineItem ,
5
+ ObsLineItemRef
6
+ } from '../types'
7
+
8
+ interface CommerceService extends ObsLineItemRef {
9
+
10
+ get cartItems(): LineItem[]
11
+ get cartTotal(): number
12
+ getCartCategorySubtotal(categoryId: string): number
13
+
14
+ /**
15
+ * Sets the tokens at each level supplied.
16
+ * If a level is specifed as [], nothing will be specified.
17
+ * If a level is missing (undefined), everything at that level is included
18
+ *
19
+ * This specifies one or more Category's, and all the LineItem's in them
20
+ *
21
+ * An empty value object specifies all Category's and all LineItem's,
22
+ * */
23
+ setFacets(value: FacetsValue): Category[]
24
+ get facetsValue(): FacetsValue // returns a copy
25
+ get specifiedItems(): LineItem[]
26
+ get specifiedCategories(): Category[]
27
+
28
+ /**
29
+ * For convenience, so widgets can share state.
30
+ * "current" is unrelated to what is "specified",
31
+ * ie, facets' values
32
+ * */
33
+ setCurrentItem(sku: string | undefined): void
34
+ /**
35
+ * For convenience, so widgets can share state.
36
+ * "current" is unrelated to what is "specified",
37
+ * ie, facets' values
38
+ *
39
+ * note: for ObsLineItemRef, there is also
40
+ * get item(): LineItem | undefined
41
+ * which simply delegates to this function
42
+ * */
43
+ get currentItem(): LineItem | undefined
44
+
45
+ }
46
+
47
+ export {
48
+ type CommerceService as default
49
+ }
@@ -0,0 +1,39 @@
1
+ 'use client'
2
+ import { createContext, useContext, useRef, type PropsWithChildren } from 'react'
3
+
4
+ // https://dev.to/ivandotv/mobx-server-side-rendering-with-next-js-4m18
5
+ import { enableStaticRendering } from 'mobx-react-lite'
6
+ enableStaticRendering(typeof window === "undefined")
7
+
8
+
9
+ import type CommerceService from './commerce-service'
10
+
11
+ import getServiceSingleton from './impl'
12
+ import type { Category, FacetsDesc } from '../types'
13
+
14
+ const CommerceServiceContext = createContext<CommerceService | undefined>(undefined)
15
+
16
+ export const useCommerce = (): CommerceService => {
17
+ return useContext(CommerceServiceContext) as CommerceService
18
+ }
19
+
20
+ export const CommerceServiceProvider: React.FC<PropsWithChildren & {
21
+ productsByCategory: Category[]
22
+ facets: FacetsDesc
23
+ options?: any
24
+ }> = ({
25
+ children,
26
+ productsByCategory,
27
+ facets,
28
+ options
29
+ }) => {
30
+
31
+ const serviceRef = useRef<CommerceService>(getServiceSingleton(productsByCategory, facets, options))
32
+ return (
33
+ <CommerceServiceContext.Provider
34
+ value={serviceRef.current}
35
+ >
36
+ {children}
37
+ </CommerceServiceContext.Provider>
38
+ )
39
+ }
@@ -0,0 +1,17 @@
1
+ // Import the functions you need from the SDKs you need
2
+ import { initializeApp, getApps } from "firebase/app"
3
+
4
+ const firebaseConfig = {
5
+ apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY,
6
+ authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN,
7
+ projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID,
8
+ storageBucket: process.env.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET,
9
+ messagingSenderId: process.env.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID,
10
+ appId: process.env.NEXT_PUBLIC_FIREBASE_APP_ID,
11
+ measurementId: process.env.NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID,
12
+ }
13
+
14
+ // Initialize Firebase
15
+ let firebase_app = getApps().length === 0 ? initializeApp(firebaseConfig) : getApps()[0]
16
+
17
+ export default firebase_app
@@ -0,0 +1,3 @@
1
+ import { default as getInstance } from './standalone/get-singleton'
2
+
3
+ export default getInstance
@@ -0,0 +1,28 @@
1
+ import type CommerceService from '../../commerce-service'
2
+
3
+ import StandaloneCommerceService, {type StandaloneServiceOptions} from './service'
4
+
5
+ import type { Category, FacetsDesc } from '../../../types'
6
+
7
+ // https://dev.to/ivandotv/mobx-server-side-rendering-with-next-js-4m18
8
+
9
+ let instance: CommerceService | undefined = undefined
10
+
11
+ export default (categories: Category[], facets: FacetsDesc, options?: any): CommerceService => {
12
+
13
+ const _instance = instance ??
14
+ new StandaloneCommerceService(
15
+ categories,
16
+ facets,
17
+ options ? (options as StandaloneServiceOptions) : {}
18
+ )
19
+ // For server side rendering always create a new store
20
+ if (typeof window === "undefined") return _instance
21
+
22
+ // Create the store once in the client
23
+ if (!instance) {
24
+ instance = _instance
25
+ }
26
+
27
+ return _instance
28
+ }
@@ -0,0 +1,62 @@
1
+ import {
2
+ action,
3
+ computed,
4
+ makeObservable,
5
+ observable,
6
+ } from 'mobx'
7
+
8
+ import type { Product, LineItem } from '../../../types'
9
+
10
+ class ObsLineItem implements LineItem {
11
+
12
+ qu: number = 0
13
+
14
+ id: string
15
+ sku: string
16
+ title: string
17
+ titleAsOption: string
18
+ categoryId: string
19
+ desc?: string
20
+ price: number
21
+ img?: string
22
+
23
+ constructor(prod: Product) {
24
+ this.id = prod.id
25
+ this.sku = prod.sku
26
+ this.title = prod.title
27
+ this.titleAsOption = prod.titleAsOption
28
+ this.categoryId = prod.categoryId
29
+ this.desc = prod.desc
30
+ this.price = prod.price
31
+ this.img = prod.img
32
+
33
+ makeObservable(this, {
34
+ qu: observable,
35
+ canDecrement: computed,
36
+ isInCart: computed,
37
+
38
+ increment: action,
39
+ decrement: action,
40
+ })
41
+ }
42
+
43
+ get canDecrement(): boolean { return this.qu > 0 }
44
+ get quantity(): number {return this.qu}
45
+ get isInCart(): boolean {return this.qu > 0}
46
+
47
+ increment(): void { this.qu++ }
48
+
49
+ decrement(): void {
50
+ if (this.canDecrement) {
51
+ this.qu--
52
+ }
53
+ }
54
+
55
+ inCategory(id: string): boolean {
56
+ // TODO: will break for level one (which is ok for lux, but not generally)
57
+ return this.sku.includes(`-${id}-`)
58
+ }
59
+
60
+ }
61
+
62
+ export default ObsLineItem