@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.
- package/blocks/components/commerce-cat-and-item-block.tsx +13 -0
- package/blocks/def/commerce-cat-and-item-block.ts +9 -0
- package/components/Icons.tsx +35 -0
- package/components/add-to-cart-widget.tsx +83 -0
- package/components/cart-icon.tsx +10 -0
- package/components/cart-line-item.tsx +47 -0
- package/components/cart.tsx +64 -0
- package/components/facet-toggles-widget/facet-image.tsx +58 -0
- package/components/facet-toggles-widget/index.tsx +82 -0
- package/components/facets-widget.tsx +46 -0
- package/components/index.ts +14 -0
- package/components/product-card.tsx +79 -0
- package/components/product-selection-mobile-picker.tsx +78 -0
- package/components/product-selection-radio-group.tsx +34 -0
- package/components/select-category-and-item-widget.tsx +84 -0
- package/components/select-item-in-category-view.tsx +200 -0
- package/package.json +50 -0
- package/service/commerce-service.ts +49 -0
- package/service/context.tsx +39 -0
- package/service/firebase-config.ts +17 -0
- package/service/impl/index.ts +3 -0
- package/service/impl/standalone/get-singleton.ts +28 -0
- package/service/impl/standalone/obs-line-item.ts +62 -0
- package/service/impl/standalone/service.ts +198 -0
- package/service/index.ts +4 -0
- package/service/utils.ts +37 -0
- package/tsconfig.json +11 -0
- package/types/README.md +2 -0
- package/types/index.ts +103 -0
- package/util/index.ts +25 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import {
|
|
2
|
+
computed,
|
|
3
|
+
makeObservable,
|
|
4
|
+
observable,
|
|
5
|
+
runInAction,
|
|
6
|
+
action,
|
|
7
|
+
toJS
|
|
8
|
+
} from 'mobx'
|
|
9
|
+
|
|
10
|
+
import type {
|
|
11
|
+
Category,
|
|
12
|
+
LineItem,
|
|
13
|
+
FacetsValue,
|
|
14
|
+
FacetsDesc
|
|
15
|
+
} from '../../../types'
|
|
16
|
+
|
|
17
|
+
import type CommerceService from '../../commerce-service'
|
|
18
|
+
|
|
19
|
+
import ObservableLineItem from './obs-line-item'
|
|
20
|
+
|
|
21
|
+
export type StandaloneServiceOptions = {
|
|
22
|
+
levelZeroPrefix?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
class StandaloneCommerceService
|
|
26
|
+
implements CommerceService
|
|
27
|
+
{
|
|
28
|
+
private _categoryMap = new Map<string, Category>()
|
|
29
|
+
private _facetsDesc: FacetsDesc
|
|
30
|
+
private _selectedFacets: FacetsValue = {}
|
|
31
|
+
|
|
32
|
+
private _options : StandaloneServiceOptions
|
|
33
|
+
|
|
34
|
+
private _currentItemSku: string | undefined = undefined
|
|
35
|
+
|
|
36
|
+
constructor(
|
|
37
|
+
categories: Category[],
|
|
38
|
+
facets: FacetsDesc,
|
|
39
|
+
options: StandaloneServiceOptions={}
|
|
40
|
+
) {
|
|
41
|
+
|
|
42
|
+
this._facetsDesc = facets
|
|
43
|
+
this._options = options
|
|
44
|
+
|
|
45
|
+
categories.forEach((c) => {
|
|
46
|
+
c.products = c.products.map((p) => (new ObservableLineItem(p)))
|
|
47
|
+
this._categoryMap.set(c.id, c)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
makeObservable<
|
|
51
|
+
StandaloneCommerceService,
|
|
52
|
+
'_selectedFacets' |
|
|
53
|
+
'_currentItemSku'
|
|
54
|
+
>(this, {
|
|
55
|
+
_selectedFacets : observable.deep,
|
|
56
|
+
_currentItemSku: observable,
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
makeObservable(this, {
|
|
60
|
+
cartItems: computed,
|
|
61
|
+
cartTotal: computed,
|
|
62
|
+
specifiedItems: computed,
|
|
63
|
+
setCurrentItem: action,
|
|
64
|
+
currentItem: computed,
|
|
65
|
+
item: computed,
|
|
66
|
+
specifiedCategories: computed,
|
|
67
|
+
facets: computed
|
|
68
|
+
/* NOT setFacets. It implements it's action mechanism */
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
get cartItems(): LineItem[] {
|
|
74
|
+
let result: LineItem[] = []
|
|
75
|
+
this._categoryMap.forEach((cat) => {
|
|
76
|
+
result = [...result, ...(cat.products as LineItem[]).filter((item) => (item.isInCart))]
|
|
77
|
+
})
|
|
78
|
+
return result
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
get cartTotal(): number {
|
|
82
|
+
return this.cartItems.reduce(
|
|
83
|
+
(total, item) => (total + item.price * item.quantity),
|
|
84
|
+
0
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
setCurrentItem(sku: string | undefined): void {
|
|
89
|
+
this._currentItemSku = sku
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/* ObsLineItemRef */
|
|
93
|
+
get item(): LineItem | undefined {
|
|
94
|
+
return this.currentItem
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
get currentItem(): LineItem | undefined {
|
|
98
|
+
if (!this._currentItemSku) return undefined
|
|
99
|
+
|
|
100
|
+
const categoriesTried: string[] = []
|
|
101
|
+
if (this.specifiedCategories && this.specifiedCategories.length > 0) {
|
|
102
|
+
|
|
103
|
+
for (let category of this.specifiedCategories) {
|
|
104
|
+
categoriesTried.push(category.id)
|
|
105
|
+
const foundItem =
|
|
106
|
+
(category.products as LineItem[]).find((item) => (item.sku === this._currentItemSku))
|
|
107
|
+
if (foundItem) {
|
|
108
|
+
return foundItem
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
let foundItem: LineItem | undefined = undefined
|
|
114
|
+
this._categoryMap.forEach((category, categoryId) => {
|
|
115
|
+
if (foundItem) return
|
|
116
|
+
if (categoriesTried.includes(categoryId)) return
|
|
117
|
+
foundItem = (category.products as LineItem[]).find((item) => (item.sku === this._currentItemSku))
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
return foundItem
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
setFacets(sel: FacetsValue): Category[] {
|
|
124
|
+
runInAction (() => {
|
|
125
|
+
const res = this._processAndValidate(sel)
|
|
126
|
+
if (res) {
|
|
127
|
+
this._selectedFacets = res
|
|
128
|
+
}
|
|
129
|
+
})
|
|
130
|
+
return this.specifiedCategories
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
get facets(): FacetsValue {
|
|
134
|
+
return this._selectedFacets
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
get specifiedCategories(): Category[] {
|
|
138
|
+
if (Object.keys(toJS(this._selectedFacets)).length === 0) {
|
|
139
|
+
// FacetsDesc have never been set or unset, so cannot evaluate them
|
|
140
|
+
return []
|
|
141
|
+
}
|
|
142
|
+
const keysStr = Object.keys(this._facetsDesc)
|
|
143
|
+
// 1-base, visiting two per iteration
|
|
144
|
+
let current: string[] = this._selectedFacets[1]
|
|
145
|
+
for (let i = 2; i <= keysStr.length; i++) {
|
|
146
|
+
current = StandaloneCommerceService._visit(current, this._selectedFacets[i])
|
|
147
|
+
}
|
|
148
|
+
const prefix = this._options.levelZeroPrefix ?? ''
|
|
149
|
+
return current.map((almostTheCatId) => (this._categoryMap.get(prefix + almostTheCatId)!))
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private static _visit(current: string[], next: string[]): string[] {
|
|
153
|
+
const result: string[] = []
|
|
154
|
+
current.forEach((c) => {
|
|
155
|
+
next.forEach((n) => {
|
|
156
|
+
result.push(`${c}-${n}`)
|
|
157
|
+
})
|
|
158
|
+
})
|
|
159
|
+
return result
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
private _processAndValidate(partial: FacetsValue): FacetsValue | undefined {
|
|
163
|
+
const result: FacetsValue = {}
|
|
164
|
+
const keysStr = Object.keys(this._facetsDesc)
|
|
165
|
+
const keysNum = keysStr.map((key) => (parseInt(key)))
|
|
166
|
+
keysNum.forEach((key) => {
|
|
167
|
+
// if not present, assume the facet is "off" and allow all (include all in the set).
|
|
168
|
+
if (!partial[key]) {
|
|
169
|
+
result[key] = this._facetsDesc[key].map((fv) => (fv.value))
|
|
170
|
+
}
|
|
171
|
+
// If present, filter out the bad values if any
|
|
172
|
+
const filtered = partial[key].filter((fv) => this._facetsDesc[key].find((fvDesc) => (fvDesc.value === fv)))
|
|
173
|
+
result[key] = filtered
|
|
174
|
+
})
|
|
175
|
+
return result
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
get specifiedItems(): LineItem[] {
|
|
179
|
+
if (Object.keys(toJS(this._selectedFacets)).length === 0) {
|
|
180
|
+
// FacetsDesc have never been set or unset, so cannot evaluate them
|
|
181
|
+
return []
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return this.specifiedCategories.reduce(
|
|
185
|
+
(allProducts, cat) => ([...allProducts, ...(cat.products as LineItem[])]), [] as LineItem[])
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
getCartCategorySubtotal(categoryId: string): number {
|
|
189
|
+
const c = this._categoryMap.get(categoryId)!
|
|
190
|
+
return (c.products as LineItem[]).reduce(
|
|
191
|
+
// avoid floating point bs around zero
|
|
192
|
+
(total, item) => (item.quantity > 0 ? total + item.price * item.quantity : total),
|
|
193
|
+
0
|
|
194
|
+
)
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export default StandaloneCommerceService
|
package/service/index.ts
ADDED
package/service/utils.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { LineItem } from "../types"
|
|
2
|
+
import firebase_app from "./firebase-config"
|
|
3
|
+
import { getFirestore, collection, addDoc, setDoc, doc } from "firebase/firestore"
|
|
4
|
+
|
|
5
|
+
const db = getFirestore(firebase_app, 'lux-commerce')
|
|
6
|
+
|
|
7
|
+
export default async function persistCart(cart: LineItem[], userEmail: string) {
|
|
8
|
+
let result = null
|
|
9
|
+
let error = null
|
|
10
|
+
const ordersRef = collection(db, "orders");
|
|
11
|
+
|
|
12
|
+
const cartAsObject = cart.reduce((acc, item) => {
|
|
13
|
+
acc[item.sku] = {
|
|
14
|
+
sku: item.sku,
|
|
15
|
+
title: item.title,
|
|
16
|
+
quantity: item.quantity,
|
|
17
|
+
description: item.desc,
|
|
18
|
+
img: item.img,
|
|
19
|
+
price: item.price,
|
|
20
|
+
}
|
|
21
|
+
return acc
|
|
22
|
+
}, {} as Record<string, {}>)
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
try {
|
|
26
|
+
result = await setDoc(doc(ordersRef, `${userEmail}-${Date.now()}`), { order: cartAsObject })
|
|
27
|
+
} catch (e) {
|
|
28
|
+
console.error('Error writing item document: ', e)
|
|
29
|
+
error = e
|
|
30
|
+
}
|
|
31
|
+
} catch (e) {
|
|
32
|
+
console.error('Error writing order document: ', e)
|
|
33
|
+
error = e
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return { result, error }
|
|
37
|
+
}
|
package/tsconfig.json
ADDED
package/types/README.md
ADDED
package/types/index.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
|
|
3
|
+
interface Product {
|
|
4
|
+
id: string // DB index // not a logical aspect of our domain. may not be necessary at all
|
|
5
|
+
sku: string // human visible on orders etc.
|
|
6
|
+
title: string
|
|
7
|
+
shortTitle?: string
|
|
8
|
+
titleAsOption: string
|
|
9
|
+
categoryId: string // skuPath, eg LXB-AU-B
|
|
10
|
+
desc?: string
|
|
11
|
+
price: number
|
|
12
|
+
img?: string // if undefined: (category's img exists) ? (use it) : (use generic placeholder)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface Category {
|
|
16
|
+
id: string // LXB-AU-B
|
|
17
|
+
title: string // Lux Gold, Minted Bar
|
|
18
|
+
desc?: string
|
|
19
|
+
img?: string
|
|
20
|
+
// inbound they're Products and then interally they become LineItems
|
|
21
|
+
products: Product[] | LineItem[]
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
interface FacetValueDesc {
|
|
26
|
+
value: string // a token in the sku
|
|
27
|
+
label: string
|
|
28
|
+
img : string | ReactNode // icon is required
|
|
29
|
+
imgAR? : number // helps with svgs
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/* *** FOR EXAMPLE **
|
|
33
|
+
{
|
|
34
|
+
1: [ {
|
|
35
|
+
token: 'AG',
|
|
36
|
+
label: 'Silver',
|
|
37
|
+
img: '/assets/img/cart/ui/facets/silver-swatch-200x200.png'
|
|
38
|
+
},
|
|
39
|
+
... more FaceValues describing the "type" level (Silver, Gold)
|
|
40
|
+
],
|
|
41
|
+
2 [
|
|
42
|
+
{
|
|
43
|
+
token: 'B'
|
|
44
|
+
label: 'Minted Bar
|
|
45
|
+
},
|
|
46
|
+
... more FaceValues describing the "form" level (Bar, Coin, )
|
|
47
|
+
]
|
|
48
|
+
}
|
|
49
|
+
*/
|
|
50
|
+
type FacetsDesc = Record<number, FacetValueDesc[]>
|
|
51
|
+
|
|
52
|
+
// Which facets tokens are on at each level
|
|
53
|
+
type FacetsValue = Record<number, string[]>
|
|
54
|
+
|
|
55
|
+
// Client code always
|
|
56
|
+
// has a LineItem, whether the Product
|
|
57
|
+
// is in the cart or not. Something is in the cart
|
|
58
|
+
// when its quantity > 0. That's the only difference.
|
|
59
|
+
// The ui, and as well as some Cart state, reacts to
|
|
60
|
+
// changes in this quantity.
|
|
61
|
+
|
|
62
|
+
// It could have more accurately been named
|
|
63
|
+
// 'QuantifiedProduct' but that sucked.
|
|
64
|
+
interface LineItem extends Product {
|
|
65
|
+
|
|
66
|
+
/** all observable */
|
|
67
|
+
get quantity(): number
|
|
68
|
+
get canDecrement(): boolean
|
|
69
|
+
get isInCart(): boolean
|
|
70
|
+
|
|
71
|
+
increment(): void
|
|
72
|
+
decrement(): void
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
interface ObsLineItemRef {
|
|
76
|
+
get item(): LineItem | undefined
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
interface StringMutator {
|
|
81
|
+
get(): string | null
|
|
82
|
+
set(v: string | null): void
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
interface StringArrayMutator {
|
|
86
|
+
get(): string[] | null
|
|
87
|
+
set(v: string[] | null): void
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export {
|
|
91
|
+
type Product,
|
|
92
|
+
type Category,
|
|
93
|
+
type LineItem,
|
|
94
|
+
type ObsLineItemRef,
|
|
95
|
+
type FacetsDesc,
|
|
96
|
+
type FacetsValue,
|
|
97
|
+
type FacetValueDesc,
|
|
98
|
+
type StringMutator,
|
|
99
|
+
type StringArrayMutator
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
|
package/util/index.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export function toTitleCase(str: string) {
|
|
2
|
+
return str.replace(
|
|
3
|
+
/\w\S*/g,
|
|
4
|
+
(txt) => txt.charAt(0).toUpperCase() + txt.slice(1).toLowerCase()
|
|
5
|
+
)
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function slugify(str: string) {
|
|
9
|
+
return str
|
|
10
|
+
.toLowerCase()
|
|
11
|
+
.replace(/ /g, '-')
|
|
12
|
+
.replace(/[^\w-]+/g, '')
|
|
13
|
+
.replace(/--+/g, '-')
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function unslugify(str: string) {
|
|
17
|
+
return str.replace(/-/g, ' ')
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function formatPrice(price: number): string {
|
|
21
|
+
return price.toLocaleString('en-US', {
|
|
22
|
+
style: 'currency',
|
|
23
|
+
currency: 'USD',
|
|
24
|
+
});
|
|
25
|
+
}
|