From effcbf9d97c4312b2ed2294bb777521c16fa6348 Mon Sep 17 00:00:00 2001 From: Artem Ash <633467+artemis-prime@users.noreply.github.com> Date: Sat, 22 Jun 2024 19:32:33 -0700 Subject: [PATCH] commerce cleanup and changes (#106) cmmc: cleanup; +ActualLineItem.timeModified cmmc: no slider if only one item --- packages/commerce/components/buy/buy-card.tsx | 2 +- .../components/buy/carousel-buy-card.tsx | 2 +- .../cart/cart-panel/cart-line-item.tsx | 2 +- .../components/cart/cart-panel/index.tsx | 2 +- .../components/cart/cart-panel/promo-code.tsx | 14 +- .../components/cart/cart-panel/total-area.tsx | 2 +- .../checkout/payment-step-form/index.tsx | 2 +- .../payment-step-form/methods/card.tsx | 2 +- .../payment-step-form/methods/crypto.tsx | 2 +- .../checkout/shipping-step-form.tsx | 2 +- .../item-selector/carousel/index.tsx | 2 +- packages/commerce/index.ts | 4 +- packages/commerce/package.json | 2 +- .../index.tsx => service/context.tsx} | 2 +- packages/commerce/service/get-instance.ts | 3 - .../impls/standalone/actual-line-item.ts | 22 +- .../service/impls/standalone/get-instance.ts | 64 ++ .../service/impls/standalone/index.ts | 601 ++++++++++++++++-- .../service/impls/standalone/localStorage.ts | 34 - .../firebase-app.ts => order/firebase.ts} | 0 .../standalone/{orders => order}/index.ts | 4 +- .../service/impls/standalone/persistence.ts | 33 + .../impls/standalone/standalone-service.ts | 550 ---------------- packages/commerce/types/commerce-service.ts | 6 + .../util/use-sync-sku-param-w-current-item.ts | 2 +- 25 files changed, 708 insertions(+), 653 deletions(-) rename packages/commerce/{context/index.tsx => service/context.tsx} (95%) delete mode 100644 packages/commerce/service/get-instance.ts create mode 100644 packages/commerce/service/impls/standalone/get-instance.ts delete mode 100644 packages/commerce/service/impls/standalone/localStorage.ts rename packages/commerce/service/impls/standalone/{orders/firebase-app.ts => order/firebase.ts} (100%) rename packages/commerce/service/impls/standalone/{orders => order}/index.ts (98%) create mode 100644 packages/commerce/service/impls/standalone/persistence.ts delete mode 100644 packages/commerce/service/impls/standalone/standalone-service.ts diff --git a/packages/commerce/components/buy/buy-card.tsx b/packages/commerce/components/buy/buy-card.tsx index 854d07dd..650cc335 100644 --- a/packages/commerce/components/buy/buy-card.tsx +++ b/packages/commerce/components/buy/buy-card.tsx @@ -14,7 +14,7 @@ import type { CategoryNode, } from '../../types' -import { useCommerce } from '../../context' +import { useCommerce } from '../../service/context' import * as pathUtils from '../../service/path-utils' import { getFacetValuesMutator, ObsStringMutator } from '../../util' diff --git a/packages/commerce/components/buy/carousel-buy-card.tsx b/packages/commerce/components/buy/carousel-buy-card.tsx index eeaadbc5..f82f75f3 100644 --- a/packages/commerce/components/buy/carousel-buy-card.tsx +++ b/packages/commerce/components/buy/carousel-buy-card.tsx @@ -21,7 +21,7 @@ import type { MultiFamilySelectorOptions, } from '../../types' -import { useCommerce } from '../../context' +import { useCommerce } from '../../service/context' import { getSelectionUISpecifier } from '../../util' import { CarouselItemSelector, ButtonItemSelector } from '../item-selector' diff --git a/packages/commerce/components/cart/cart-panel/cart-line-item.tsx b/packages/commerce/components/cart/cart-panel/cart-line-item.tsx index 72039ef9..3f056870 100644 --- a/packages/commerce/components/cart/cart-panel/cart-line-item.tsx +++ b/packages/commerce/components/cart/cart-panel/cart-line-item.tsx @@ -9,7 +9,7 @@ import { Image } from '@hanzo/ui/primitives' import type { LineItem } from '../../../types' import { formatCurrencyValue } from '../../../util' import AddToCartWidget from '../../add-to-cart-widget' -import { useCommerce } from '../../../context' +import { useCommerce } from '../../../service/context' const DEF_IMG_SIZE=40 diff --git a/packages/commerce/components/cart/cart-panel/index.tsx b/packages/commerce/components/cart/cart-panel/index.tsx index ad3837c8..0657e099 100644 --- a/packages/commerce/components/cart/cart-panel/index.tsx +++ b/packages/commerce/components/cart/cart-panel/index.tsx @@ -7,7 +7,7 @@ import { Button, ScrollArea } from '@hanzo/ui/primitives' import { cn } from '@hanzo/ui/util' import type { LineItem } from '../../../types' -import { useCommerce } from '../../../context' +import { useCommerce } from '../../../service/context' import { sendFBEvent, sendGAEvent } from '../../../util/analytics' import CartLineItem from './cart-line-item' diff --git a/packages/commerce/components/cart/cart-panel/promo-code.tsx b/packages/commerce/components/cart/cart-panel/promo-code.tsx index 67ef6f6f..e6a35bbf 100644 --- a/packages/commerce/components/cart/cart-panel/promo-code.tsx +++ b/packages/commerce/components/cart/cart-panel/promo-code.tsx @@ -2,15 +2,23 @@ import { useEffect, useState } from 'react' import { useSearchParams } from 'next/navigation' +import { observer } from 'mobx-react-lite' + import { useForm } from 'react-hook-form' import * as z from 'zod' import { zodResolver } from '@hookform/resolvers/zod' import { Check } from 'lucide-react' -import { observer } from 'mobx-react-lite' -import { Button, Form, FormControl, FormField, FormItem, Input } from '@hanzo/ui/primitives' +import { + Button, + Form, + FormControl, + FormField, + FormItem, + Input +} from '@hanzo/ui/primitives' -import { useCommerce } from '../../../context' +import { useCommerce } from '../../../service/context' import type { Promo } from '../../../types' import getPromoFromApi from '../../../util/promo-codes' diff --git a/packages/commerce/components/cart/cart-panel/total-area.tsx b/packages/commerce/components/cart/cart-panel/total-area.tsx index 2c84ebca..6b4d8f4c 100644 --- a/packages/commerce/components/cart/cart-panel/total-area.tsx +++ b/packages/commerce/components/cart/cart-panel/total-area.tsx @@ -6,7 +6,7 @@ import { cn } from '@hanzo/ui/util' import { formatCurrencyValue } from '../../../util' import PromoCode from './promo-code' -import { useCommerce } from '../../../context' +import { useCommerce } from '../../../service/context' const TotalArea: React.FC<{ showPromoCode?: boolean diff --git a/packages/commerce/components/checkout/payment-step-form/index.tsx b/packages/commerce/components/checkout/payment-step-form/index.tsx index 462a806d..f2d7bf66 100644 --- a/packages/commerce/components/checkout/payment-step-form/index.tsx +++ b/packages/commerce/components/checkout/payment-step-form/index.tsx @@ -9,7 +9,7 @@ import { useForm } from 'react-hook-form' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@hanzo/ui/primitives' import { useAuth } from '@hanzo/auth/service' -import { useCommerce } from '../../../context' +import { useCommerce } from '../../../service/context' import { sendFBEvent, sendGAEvent } from '../../../util/analytics' import type { CheckoutStepComponentProps, TransactionStatus } from '../../../types' diff --git a/packages/commerce/components/checkout/payment-step-form/methods/card.tsx b/packages/commerce/components/checkout/payment-step-form/methods/card.tsx index 1951078b..9d041fc8 100644 --- a/packages/commerce/components/checkout/payment-step-form/methods/card.tsx +++ b/packages/commerce/components/checkout/payment-step-form/methods/card.tsx @@ -15,7 +15,7 @@ import { import { cn } from '@hanzo/ui/util' -import { useCommerce } from '../../../../context' +import { useCommerce } from '../../../../service/context' import { processSquareCardPayment } from '../../../../util' import type { PaymentMethodComponentProps } from '../../../../types' import { sendFBEvent, sendGAEvent } from '../../../../util/analytics' diff --git a/packages/commerce/components/checkout/payment-step-form/methods/crypto.tsx b/packages/commerce/components/checkout/payment-step-form/methods/crypto.tsx index e7d4428a..d817f653 100644 --- a/packages/commerce/components/checkout/payment-step-form/methods/crypto.tsx +++ b/packages/commerce/components/checkout/payment-step-form/methods/crypto.tsx @@ -19,7 +19,7 @@ import { } from '@hanzo/ui/primitives' import Eth from '../crypto-icons/eth' -import { useCommerce } from '../../../../context' +import { useCommerce } from '../../../../service/context' import type { PaymentMethodComponentProps } from '../../../../types' import { sendFBEvent, sendGAEvent } from '../../../../util/analytics' diff --git a/packages/commerce/components/checkout/shipping-step-form.tsx b/packages/commerce/components/checkout/shipping-step-form.tsx index b45ba928..df5080e3 100644 --- a/packages/commerce/components/checkout/shipping-step-form.tsx +++ b/packages/commerce/components/checkout/shipping-step-form.tsx @@ -19,7 +19,7 @@ import { SelectValue } from '@hanzo/ui/primitives' -import { useCommerce } from '../../context' +import { useCommerce } from '../../service/context' import countries from '../../util/countries' import { sendGAEvent } from '../../util/analytics' diff --git a/packages/commerce/components/item-selector/carousel/index.tsx b/packages/commerce/components/item-selector/carousel/index.tsx index c7a9997f..9d769fe2 100644 --- a/packages/commerce/components/item-selector/carousel/index.tsx +++ b/packages/commerce/components/item-selector/carousel/index.tsx @@ -178,7 +178,7 @@ const CarouselItemSelector: React.FC = observer(({ {showByline && itemRef.item.byline && (

{itemRef.item.byline}

)} - {showSlider && ( + {showSlider && items.length > 1 && ( (undefined) diff --git a/packages/commerce/service/get-instance.ts b/packages/commerce/service/get-instance.ts deleted file mode 100644 index ae8ed6b7..00000000 --- a/packages/commerce/service/get-instance.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { getInstance } from './impls/standalone' - -export default getInstance \ No newline at end of file diff --git a/packages/commerce/service/impls/standalone/actual-line-item.ts b/packages/commerce/service/impls/standalone/actual-line-item.ts index 48f5b2d5..828dcedc 100644 --- a/packages/commerce/service/impls/standalone/actual-line-item.ts +++ b/packages/commerce/service/impls/standalone/actual-line-item.ts @@ -15,13 +15,13 @@ interface ActualLineItemSnapshot { title: string price: number quantity: number - timeAdded: number // helps to sort view of order and cart + timeAdded: number + timeModified: number } class ActualLineItem implements LineItem { - qu: number = 0 id: string @@ -39,7 +39,9 @@ class ActualLineItem animation?: string mediaTransform?: MediaTransform optionImg?: ImageDef - timeAdded: number = 0 // timeAdded of being added to cart + + timeAdded: number = 0 // Timestamp when added + timeModified: number = 0 // Timestamp quantity last modified (0 if not in cart) constructor(prod: Product, snap?: ActualLineItemSnapshot) { this.id = prod.id @@ -61,11 +63,13 @@ class ActualLineItem if (snap) { this.qu = snap.quantity this.timeAdded = snap.timeAdded + this.timeModified = snap.timeModified } makeObservable(this, { qu: observable, timeAdded: observable, + timeModified: observable, canDecrement: computed, isInCart: computed, title: computed, @@ -74,6 +78,7 @@ class ActualLineItem }) } + // TODO: create a way to pass template strings to the ui conf per sku path! get title(): string { return this.fullTitle ? this.fullTitle : (this.familyTitle + ', ' + this.optionLabel) } @@ -91,7 +96,8 @@ class ActualLineItem title, price: this.price, quantity: this.qu, - timeAdded: this.timeAdded + timeAdded: this.timeAdded, + timeModified: this.timeModified } satisfies ActualLineItemSnapshot) } @@ -102,6 +108,10 @@ class ActualLineItem increment(): void { if (this.qu === 0) { this.timeAdded = new Date().getTime() + this.timeModified = this.timeAdded + } + else { + this.timeModified = new Date().getTime() } this.qu++ } @@ -111,6 +121,10 @@ class ActualLineItem this.qu-- if (this.qu === 0) { this.timeAdded = 0 + this.timeModified = 0 + } + else { + this.timeModified = new Date().getTime() } } } diff --git a/packages/commerce/service/impls/standalone/get-instance.ts b/packages/commerce/service/impls/standalone/get-instance.ts new file mode 100644 index 00000000..f141593d --- /dev/null +++ b/packages/commerce/service/impls/standalone/get-instance.ts @@ -0,0 +1,64 @@ +import { enableStaticRendering } from 'mobx-react-lite' + +import type { CommerceService, CommerceConfig } from '../../../types' +import { StandaloneService } from './index' +import { initSelectionUI } from '../../../util' + +import { readSnapshot, writeSnapshotsOnChange } from './persistence' + +enableStaticRendering(typeof window === "undefined") + +const _LOG = false +const _log = (s: string) => { + if (!_LOG) return; + const d = new Date() + console.log(`TIMESTAMPED: ${d.getUTCMinutes()}:${d.getUTCSeconds()}:${d.getUTCMilliseconds()}`) + console.log(s) +} + +// https://dev.to/ivandotv/mobx-server-side-rendering-with-next-js-4m18 + +let instance: StandaloneService | undefined = undefined + +export const getInstance = ({ + families, + rootNode, + options, + uiSpecifiers +}: CommerceConfig) : CommerceService => { + + if (!options) { + throw new Error('cmmc getInstance(): Standalone Commerce Service requires config options!') + } + + if (typeof window === "undefined") { + if (uiSpecifiers) { + initSelectionUI(uiSpecifiers) + } + _log("NEW INSTANCE: SERVER") ////////// + return new StandaloneService( + families, + rootNode, + options + ) + } + + // Client side, create the store only once in the client + if (!instance) { + if (uiSpecifiers) { + initSelectionUI(uiSpecifiers) + } + _log("NEW INSTANCE: CLIENT") /////////// + const snapShot = readSnapshot(options.localStorageKey) + instance = new StandaloneService( + families, + rootNode, + options, + snapShot + ) + writeSnapshotsOnChange(instance, options.localStorageKey) + } + + return instance +} + diff --git a/packages/commerce/service/impls/standalone/index.ts b/packages/commerce/service/impls/standalone/index.ts index 3e5cb25d..e7cbd0dc 100644 --- a/packages/commerce/service/impls/standalone/index.ts +++ b/packages/commerce/service/impls/standalone/index.ts @@ -1,62 +1,579 @@ -import { enableStaticRendering } from 'mobx-react-lite' +import { + computed, + makeObservable, + observable, + runInAction, + action, + toJS +} from 'mobx' -import type { CommerceService, Family, CategoryNode, SelectionUISpecifier, CommerceConfig } from '../../../types' -import StandaloneService, {type StandaloneServiceOptions} from './standalone-service' -import { initSelectionUI } from '../../../util' +import { computedFn } from 'mobx-utils' -import { readSnapshot, listenAndWriteSnapshots } from './localStorage' +import type { + CommerceService, + Family, + LineItem, + SelectedPaths, + CategoryNode, + CategoryNodeRole, + Promo +} from '../../../types' -enableStaticRendering(typeof window === "undefined") +import { + createOrder as createOrderHelper, + updateOrderShippingInfo as updateOrderShippingInfoHelper, + updateOrderPaymentInfo as updateOrderPaymentInfoHelper +} from './order' -const _log = (s: string) => { - const d = new Date() - console.log(`TIMESTAMPED: ${d.getUTCMinutes()}:${d.getUTCSeconds()}:${d.getUTCMilliseconds()}`) - console.log(s) +import ActualLineItem, { type ActualLineItemSnapshot } from './actual-line-item' +import { getParentPath } from '../../path-utils' +import { getErrorMessage } from '../../../util' +import sep from '../../sep' + +import { getInstance } from './get-instance' + +type StandaloneServiceOptions = { + dbName: string + ordersTable: string + localStorageKey: string +} + +interface StandaloneServiceSnapshot { + items: ActualLineItemSnapshot[] } -// https://dev.to/ivandotv/mobx-server-side-rendering-with-next-js-4m18 +class StandaloneService + implements CommerceService +{ + private _familyMap = new Map() + private _rootNode: CategoryNode + private _selectedPaths: SelectedPaths = {} + private _promo: Promo | null = null + + private _options : StandaloneServiceOptions + private _currentFamily: Family | undefined = undefined + private _currentItem: ActualLineItem | undefined = undefined + + constructor( + families: Family[], + rootNode: CategoryNode, + options: StandaloneServiceOptions, + serviceSnapshot?: StandaloneServiceSnapshot, + ) { + + this._rootNode = rootNode + this._options = options + + families.forEach((fam) => { + fam.products = fam.products.map((p) => { + if (serviceSnapshot) { + const itemSnapshot = serviceSnapshot.items.find((is) => (is.sku === p.sku)) + if (itemSnapshot) { + return new ActualLineItem(p, itemSnapshot) + } + } + return new ActualLineItem(p) + }) + this._familyMap.set(fam.id, fam) + }) + + makeObservable< + StandaloneService, + '_selectedPaths' | + '_currentItem' | + '_currentFamily' | + '_promo' | + '_cartItems' + >(this, { + _selectedPaths : observable.deep, + _currentItem: observable.shallow, + _currentFamily: observable.shallow, + _promo: observable, + _cartItems: computed + }) + + makeObservable(this, { + + setCurrentItem: action, + setCurrentFamily: action, + setAppliedPromo: action, + /* NOT selectPaths. It implements it's action mechanism */ + + cartItems: computed, + recentItem: computed, + cartQuantity: computed, + cartTotal: computed, + promoAppliedCartTotal: computed, + cartEmpty: computed, + selectedItems: computed, + selectedFamilies: computed, + hasSelection: computed, + currentItem: computed, + currentFamily: computed, + item: computed, + family: computed, + selectedPaths: computed, + appliedPromo: computed, + }) + } + + getFamilyById(id: string): Family | undefined { + return this._familyMap.get(id) + } + + getNodeAtPath(skuPath: string): CategoryNode | undefined { + const toks = skuPath.split(sep.tok) + let level = 1 + let node: CategoryNode | undefined = this._rootNode + do { + node = node!.subNodes?.find((sn) => (sn.skuToken === toks[level])) + level++ + } + while (node && (level < toks.length)) + return level === toks.length ? node : undefined + } + + peek(skuPath: string): { + role: CategoryNodeRole + family: Family | undefined + families: Family[] | undefined + node: CategoryNode | undefined + item: LineItem | undefined + } | string /* OR error string */ { + + const toks = skuPath.split(sep.tok) + let level: number + let node: CategoryNode | undefined = this._rootNode + let parent: CategoryNode | undefined = undefined + + for (level = 1; level < toks.length && node && node.subNodes; level++) { + // https://stackoverflow.com/questions/62367492/inference-problem-referenced-directly-or-indirectly-in-its-own-initializer + const _node: CategoryNode | undefined = + node!.subNodes.find((sn) => (sn.skuToken === toks[level])) + if (!_node) { + return `service.peekAtNode: traversing '${skuPath}'... no CategoryNode at '${toks[level]}'!` + } + parent = node + node = _node + } + + const atEnd = level === toks.length + const possibleSKU = level === toks.length - 1 + + let role: CategoryNodeRole = 'non-outermost' + let families: Family[] | undefined = undefined + let family: Family | undefined = undefined + let item: LineItem | undefined = undefined + let error: string | undefined = undefined + + try { + if (node.subNodes && atEnd && node.outermost) { + role = 'multi-family' + families = node.subNodes.map((sub) => { + const familyId = skuPath + sep.tok + sub.skuToken + const fam = this._familyMap.get(familyId) + if (!fam) { + throw new Error(`service.peekAtNode: No Family under for CategoryNode '${skuPath}' with id ${familyId}!`) + } + return fam + }) + } + else if (!node.subNodes && (atEnd || possibleSKU)) { + const _skuPath = (possibleSKU) ? getParentPath(skuPath) : skuPath + if (parent?.outermost) { + role = 'family-in-multi-family' + const fam = this._familyMap.get(_skuPath) + if (!fam) { + throw new Error(`service.peekAtNode: '${_skuPath}' graphs as a Family under a multi-family node, but no such family exists!`) + } + family = fam + const parentPath = getParentPath(_skuPath) + // get all siblings (subnodes of parent) + families = parent.subNodes!.map((sn) => { + const familyId = parentPath + sep.tok + sn.skuToken + const fam = this._familyMap.get(familyId) + if (!fam) { + throw new Error(`service.peekAtNode: No sibling Family for '${_skuPath}' with id '${familyId}'!`) + } + return fam + }) + node = parent + } + else { + role = 'single-family' + const fam = this._familyMap.get(_skuPath) + if (!fam) { + throw new Error(`service.peekAtNode: '${_skuPath}' graphs as a single Family, but no such family exists!`) + } + family = fam + } + if (possibleSKU) { + const skuToTry = family.id + sep.tok + toks[toks.length - 1] + const _item = family.products.find((p) => (p.sku === skuToTry)) + if (_item) { + item = _item as LineItem + } + else { + throw new Error(`service.peekAtNode: '${skuPath}' graphs as LineItem in Family '${family.id}', but no such sku exists there!`) + } + } + } + } + catch (e) { + error = getErrorMessage(e) + } + + return error ?? { + role, + family, + families, + node, + item + } + } + + getSelectedNodesAtLevel = computedFn((level: number): CategoryNode[] | undefined => { + + let lvl = 1 + let nodesAtLevel: CategoryNode[] | undefined = this._rootNode.subNodes + + do { + let selectedAtLevel: CategoryNode[] | undefined = undefined + // If not specified, assume all + if (lvl in this._selectedPaths) { + selectedAtLevel = nodesAtLevel!.filter((n) => (this._selectedPaths[lvl].includes(n.skuToken))) + } + else { + selectedAtLevel = nodesAtLevel + } + let allSubsOfSelected: CategoryNode[] = [] + selectedAtLevel?.forEach((n: CategoryNode) => { + if (n.subNodes) { + allSubsOfSelected = [...allSubsOfSelected, ...n.subNodes] + } + }) + + nodesAtLevel = allSubsOfSelected + lvl++ + } while (nodesAtLevel.length > 0 && lvl <= level) + + return (nodesAtLevel.length > 0 && ((lvl - 1) === level)) ? nodesAtLevel : undefined + }) + + get options() { return this._options} + + //async createOrder(email: string, paymentMethod: string): Promise { + async createOrder(email: string, name?: string): Promise { + const snapshot = this.takeSnapshot() + const order = await createOrderHelper(email, snapshot.items, this._options, name) // didn't want to have two levels of 'items' + return order.id + } + + // TODO: add shippingInfo type + async updateOrderShippingInfo(orderId: string, shippingInfo: any): Promise { + updateOrderShippingInfoHelper(orderId, shippingInfo, this._options) + } + + // TODO: add paymentInfo type + async updateOrderPaymentInfo(orderId: string, paymentInfo: any): Promise { + updateOrderPaymentInfoHelper(orderId, paymentInfo, this._options) + } + // Might as well use the ordered set. + takeSnapshot = (): StandaloneServiceSnapshot => ({ + items : (this.cartItems as ActualLineItem[]).map((it) => (it.takeSnapshot(this))) + }) + + // last cartItem whose quantity was modified (undefined if cartEmpty) + get recentItem(): { item: LineItem, modified: number} | undefined { + + if (this.cartEmpty) return undefined; + + const mostRecent = this._cartItems.reduce( + (newest, item) => ((!newest) ? item : ( + (item as ActualLineItem).timeModified > (newest as ActualLineItem).timeModified) ? item : newest + ), + undefined as LineItem | undefined + ) + return { item: mostRecent!, modified: (mostRecent! as ActualLineItem).timeModified } + } + + private get _cartItems(): LineItem[] { + let result: LineItem[] = [] + this._familyMap.forEach((fam) => { + result = [...result, ...(fam.products as LineItem[]).filter((item) => (item.isInCart))] + }) + return result + } + + get cartItems(): LineItem[] { + return this._cartItems.sort((it1, it2) => ((it1 as ActualLineItem).timeAdded - (it2 as ActualLineItem).timeAdded)) + } + + + + get cartEmpty(): boolean { + return this._cartItems.length === 0 + } + + get cartTotal(): number { + return this._cartItems.reduce( + (total, item) => (total + item.price * item.quantity), + 0 + ) + } + + _promoValue_unsafe(value: number): number { + if (this._promo!.type === 'percent') { + return value * (1 - this._promo!.value / 100) + } + return value - this._promo!.value + } + + get promoAppliedCartTotal(): number { + if (!this._promo) { + return this.cartTotal + } + if (!this._promo.skus) { + return this._promoValue_unsafe(this.cartTotal) + } + let total = this._cartItems.reduce( + (total, item) => { + const itemPrice = this._promo!.skus!.includes(item.sku) ? + this._promoValue_unsafe(item.price) + : + item.price + return total + itemPrice * item.quantity + }, + 0 + ) + return total + } + + itemPromoPrice(item: LineItem): number | undefined { + if (this._promo && (!this._promo.skus || this._promo.skus.includes(item.sku) )) { + return this._promoValue_unsafe(item.price) + } + return undefined + } + + get cartQuantity(): number { + return this.cartItems.reduce( + (total, item) => (total + item.quantity), + 0 + ) + } + + get appliedPromo(): Promo | null { + return this._promo + } + + setAppliedPromo(promo: Promo | null): void { + this._promo = promo + } + + getItemBySku = (skuToFind: string | undefined): LineItem | undefined => { + + if (skuToFind === undefined || skuToFind.length === 0) { + return undefined + } + // Self-calling + const found = ((): ActualLineItem | undefined => { + + const familiesTried: string[] = [] + if (this.selectedFamilies && this.selectedFamilies.length > 0) { + for (let family of this.selectedFamilies) { + familiesTried.push(family.id) + const foundItem = family.products.find((p) => (p.sku === skuToFind)) + if (foundItem) { + return foundItem as ActualLineItem + } + } + } + for( const [familyId, family] of this._familyMap.entries()) { + if (familiesTried.includes(familyId)) continue + const foundItem = family.products.find((p) => (p.sku === skuToFind)) as ActualLineItem | undefined + if (foundItem) { + return foundItem as ActualLineItem + } + } + return undefined + })(); // Self-calling, necessary semi + + return found + } + + setCurrentItem = (skuToFind: string | undefined): boolean => { + + if (skuToFind === undefined || skuToFind.length === 0) { + this._currentItem = undefined + return true + } + // self calling function + this._currentItem = this.getItemBySku(skuToFind) as ActualLineItem | undefined + this.setCurrentFamily(this._currentItem ? this._currentItem.familyId : undefined) + return !!this._currentItem + } + + /* for ObsLineItemRef */ + get item(): LineItem | undefined { + return this._currentItem + } + + get currentItem(): LineItem | undefined { + return this._currentItem + } + + setCurrentFamily(id: string | undefined): boolean { -let instance: StandaloneService | undefined = undefined + if (id === undefined || id.length === 0) { + this._currentFamily = undefined + return true + } + + const fam = this._familyMap.get(id) + this._currentFamily = fam // undef ok + + if ( + this._currentFamily && + this._currentItem && + this._currentItem.familyId !== this._currentFamily.id + ) { + this._currentItem = undefined + } -export const getInstance = ({ - families, - rootNode, - options, - uiSpecifiers -}: CommerceConfig) : CommerceService => { + return !!this._currentFamily + } + + get currentFamily(): Family | undefined { + return this._currentFamily + } - if (!options) { - throw new Error('cmmc getInstance(): Standalone Commerce Service requires config options!') + /* for ObsFamilyRef */ + get family(): Family | undefined { + return this._currentFamily } - if (typeof window === "undefined") { - if (uiSpecifiers) { - initSelectionUI(uiSpecifiers) + selectPaths(sel: SelectedPaths): Family[] { + runInAction (() => { + this._selectedPaths = this._processAndValidate(sel) + }) + return this.selectedFamilies + } + + selectPath(skuPath: string): Family[] { + const toks = skuPath.split(sep.tok) + const highestLevel = toks.length - 1 + const fsv: SelectedPaths = {} + for (let level = 1; level <= highestLevel; level++ ) { + fsv[level] = [toks[level]] + } + return this.selectPaths(fsv) + } + + get selectedPaths(): SelectedPaths { + const result: SelectedPaths = {} + for( let level in this._selectedPaths ) { + result[level] = [...this._selectedPaths[level]] } - //_log("NEW INSTANCE FOR SERVER") - return new StandaloneService( - families, - rootNode, - options + return result + } + + get selectedFamilies(): Family[] { + if (Object.keys(toJS(this._selectedPaths)).length === 0) { + // FacetsDesc have never been set or unset, so cannot evaluate them + return [] + } + + return this._rootNode.subNodes!.reduce( + (acc: Family[], subFacet: CategoryNode) => ( + // Pass the root token as a one member array + this._reduceNode([this._rootNode.skuToken], acc, subFacet) + ), + [] ) } - // Client side, create the store only once in the client - if (!instance) { - if (uiSpecifiers) { - initSelectionUI(uiSpecifiers) + private _reduceNode(parentPath: string[], acc: Family[], node: CategoryNode): Family[] { + const path = [...parentPath, node.skuToken] // Don't mutate original please :) + const level = path.length - 1 + // If there is no token array supplied for this level, + // assume all are specified. Otherwise, see if the + // current node is in the array + const specified = ( + !this._selectedPaths[level] + || + this._selectedPaths[level].includes(node.skuToken) + ) + if (specified) { + // Process subnodes + if (node.subNodes && node.subNodes.length > 0) { + return node.subNodes.reduce((acc, n) => ( + this._reduceNode(path, acc, n) + ) + , acc) + } + // Process leaf + const fam = this._familyMap.get(path.join(sep.tok)) + if (!fam) { + throw new Error("selectedFamilies WTF?!" + path.join(sep.tok)) + } + acc.push(fam) } - //_log("NEW INSTANCE FOR CLIENT") - const snapShot = readSnapshot() - instance = new StandaloneService( - families, - rootNode, - options, - snapShot + return acc + } + + private _processAndValidate(partial: SelectedPaths): SelectedPaths { + + const result: SelectedPaths = {} + + let level = 1 + let currentSet = this._rootNode.subNodes! + + while (true) { + let possibleCurrent = currentSet.map((el) => (el.skuToken)) + const validTokens = !partial[level] ? undefined : partial[level].filter((tok) => possibleCurrent.includes(tok)) + if (!validTokens) { + break + } + result[level] = validTokens + currentSet = validTokens.map((tok) => { + const fd = currentSet.find((node) => ( node.skuToken === tok )) + return (fd && fd.subNodes && fd.subNodes.length > 0) ? fd.subNodes : [] + }).flat() + level++ + } + + return result + } + + get selectedItems(): LineItem[] { + if (Object.keys(toJS(this._selectedPaths)).length === 0) { + // FacetsDesc have never been set or unset, so cannot evaluate them + return [] + } + + return this.selectedFamilies.reduce( + (allProducts, fam) => ([...allProducts, ...(fam.products as LineItem[])]), [] as LineItem[]) + } + + get hasSelection(): boolean { + return this.selectedFamilies.length > 0 + } + + getFamilySubtotal(familyId: string): number { + const c = this._familyMap.get(familyId)! + return (c.products as LineItem[]).reduce( + // avoid floating point bs around zero + (total, item) => (item.quantity > 0 ? total + item.price * item.quantity : total), + 0 ) - listenAndWriteSnapshots(instance) - } + } - return instance } +export { + type StandaloneServiceOptions, + type StandaloneServiceSnapshot, + StandaloneService, + getInstance +} diff --git a/packages/commerce/service/impls/standalone/localStorage.ts b/packages/commerce/service/impls/standalone/localStorage.ts deleted file mode 100644 index 0264e928..00000000 --- a/packages/commerce/service/impls/standalone/localStorage.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { reaction } from 'mobx' -import StandaloneService, { type StandaloneServiceSnapshot } from './standalone-service' - -const LS_KEY = 'lux-cart' - -const readSnapshot = (): StandaloneServiceSnapshot | undefined => { - const snapshotAsStr = localStorage.getItem(LS_KEY) - return snapshotAsStr ? JSON.parse(snapshotAsStr) as StandaloneServiceSnapshot : undefined -} - -const listenAndWriteSnapshots = (cmmc: StandaloneService): void => { - - if (typeof window !== 'undefined') { - reaction( - () => (cmmc.cartTotal), - (total) => { - if (total > 0) { - const snapshot = cmmc.takeSnapshot() -// console.log(`CMMC LOCAL STORAGE UPDATE. (CART TOTAL: ${total}`) - localStorage.setItem(LS_KEY, JSON.stringify(snapshot) ) - } - else { - localStorage.removeItem(LS_KEY) - } - } - ) - } -} - -export { - readSnapshot, - listenAndWriteSnapshots -} - diff --git a/packages/commerce/service/impls/standalone/orders/firebase-app.ts b/packages/commerce/service/impls/standalone/order/firebase.ts similarity index 100% rename from packages/commerce/service/impls/standalone/orders/firebase-app.ts rename to packages/commerce/service/impls/standalone/order/firebase.ts diff --git a/packages/commerce/service/impls/standalone/orders/index.ts b/packages/commerce/service/impls/standalone/order/index.ts similarity index 98% rename from packages/commerce/service/impls/standalone/orders/index.ts rename to packages/commerce/service/impls/standalone/order/index.ts index 078ba887..77d78ae4 100644 --- a/packages/commerce/service/impls/standalone/orders/index.ts +++ b/packages/commerce/service/impls/standalone/order/index.ts @@ -1,5 +1,3 @@ -import firebaseApp from './firebase-app' - import { getFirestore, collection, @@ -12,6 +10,8 @@ import { import type { ActualLineItemSnapshot } from '../actual-line-item' +import firebaseApp from './firebase' + let dbInstance: Firestore | undefined = undefined const getDBInstance = (name: string): Firestore => { diff --git a/packages/commerce/service/impls/standalone/persistence.ts b/packages/commerce/service/impls/standalone/persistence.ts new file mode 100644 index 00000000..bc67183a --- /dev/null +++ b/packages/commerce/service/impls/standalone/persistence.ts @@ -0,0 +1,33 @@ +import { reaction } from 'mobx' +import { StandaloneService, type StandaloneServiceSnapshot } from './index' + +const LS_KEY = 'lux-cart' + +const readSnapshot = (key: string): StandaloneServiceSnapshot | undefined => { + const snapshotAsStr = localStorage.getItem(key) + return snapshotAsStr ? JSON.parse(snapshotAsStr) as StandaloneServiceSnapshot : undefined +} + +const writeSnapshotsOnChange = (cmmc: StandaloneService, key: string): void => { + + if (typeof window !== 'undefined') { + reaction( + () => (cmmc.cartTotal), + (total) => { + if (total > 0) { + const snapshot = cmmc.takeSnapshot() + localStorage.setItem(key, JSON.stringify(snapshot) ) + } + else { + localStorage.removeItem(key) + } + } + ) + } +} + +export { + readSnapshot, + writeSnapshotsOnChange +} + diff --git a/packages/commerce/service/impls/standalone/standalone-service.ts b/packages/commerce/service/impls/standalone/standalone-service.ts deleted file mode 100644 index cee1a392..00000000 --- a/packages/commerce/service/impls/standalone/standalone-service.ts +++ /dev/null @@ -1,550 +0,0 @@ -import { - computed, - makeObservable, - observable, - runInAction, - action, - toJS -} from 'mobx' - -import { computedFn } from 'mobx-utils' - -import type { - CommerceService, - Family, - LineItem, - SelectedPaths, - CategoryNode, - CategoryNodeRole, - Promo -} from '../../../types' - -import { - createOrder as createOrderHelper, - updateOrderShippingInfo as updateOrderShippingInfoHelper, - updateOrderPaymentInfo as updateOrderPaymentInfoHelper -} from './orders' - -import ActualLineItem, { type ActualLineItemSnapshot } from './actual-line-item' -import { getParentPath } from '../../../service/path-utils' -import { getErrorMessage } from '../../../util' -import sep from '../../sep' - -type StandaloneServiceOptions = { - dbName: string - ordersTable: string -} - -interface StandaloneServiceSnapshot { - items: ActualLineItemSnapshot[] -} - - -class StandaloneService - implements CommerceService -{ - private _familyMap = new Map() - private _rootNode: CategoryNode - private _selectedPaths: SelectedPaths = {} - private _promo: Promo | null = null - - private _options : StandaloneServiceOptions - private _currentFamily: Family | undefined = undefined - private _currentItem: ActualLineItem | undefined = undefined - - constructor( - families: Family[], - rootNode: CategoryNode, - options: StandaloneServiceOptions, - serviceSnapshot?: StandaloneServiceSnapshot, - ) { - - this._rootNode = rootNode - this._options = options - - families.forEach((fam) => { - fam.products = fam.products.map((p) => { - if (serviceSnapshot) { - const itemSnapshot = serviceSnapshot.items.find((is) => (is.sku === p.sku)) - if (itemSnapshot) { - return new ActualLineItem(p, itemSnapshot) - } - } - return new ActualLineItem(p) - }) - this._familyMap.set(fam.id, fam) - }) - - makeObservable< - StandaloneService, - '_selectedPaths' | - '_currentItem' | - '_currentFamily' | - '_promo' - >(this, { - _selectedPaths : observable.deep, - _currentItem: observable.shallow, - _currentFamily: observable.shallow, - _promo: observable, - }) - - makeObservable(this, { - cartItems: computed, - cartQuantity: computed, - cartTotal: computed, - promoAppliedCartTotal: computed, - cartEmpty: computed, - selectedItems: computed, - selectedFamilies: computed, - hasSelection: computed, - setCurrentItem: action, - setCurrentFamily: action, - currentItem: computed, - currentFamily: computed, - item: computed, - family: computed, - selectedPaths: computed, - appliedPromo: computed, - setAppliedPromo: action, - /* NOT selectPaths. It implements it's action mechanism */ - }) - } - - getFamilyById(id: string): Family | undefined { - return this._familyMap.get(id) - } - - getNodeAtPath(skuPath: string): CategoryNode | undefined { - const toks = skuPath.split(sep.tok) - let level = 1 - let node: CategoryNode | undefined = this._rootNode - do { - node = node!.subNodes?.find((sn) => (sn.skuToken === toks[level])) - level++ - } - while (node && (level < toks.length)) - return level === toks.length ? node : undefined - } - - peek(skuPath: string): { - role: CategoryNodeRole - family: Family | undefined - families: Family[] | undefined - node: CategoryNode | undefined - item: LineItem | undefined - } | string /* OR error string */ { - - const toks = skuPath.split(sep.tok) - let level: number - let node: CategoryNode | undefined = this._rootNode - let parent: CategoryNode | undefined = undefined - - for (level = 1; level < toks.length && node && node.subNodes; level++) { - // https://stackoverflow.com/questions/62367492/inference-problem-referenced-directly-or-indirectly-in-its-own-initializer - const _node: CategoryNode | undefined = - node!.subNodes.find((sn) => (sn.skuToken === toks[level])) - if (!_node) { - return `service.peekAtNode: traversing '${skuPath}'... no CategoryNode at '${toks[level]}'!` - } - parent = node - node = _node - } - - const atEnd = level === toks.length - const possibleSKU = level === toks.length - 1 - - let role: CategoryNodeRole = 'non-outermost' - let families: Family[] | undefined = undefined - let family: Family | undefined = undefined - let item: LineItem | undefined = undefined - let error: string | undefined = undefined - - try { - if (node.subNodes && atEnd && node.outermost) { - role = 'multi-family' - families = node.subNodes.map((sub) => { - const familyId = skuPath + sep.tok + sub.skuToken - const fam = this._familyMap.get(familyId) - if (!fam) { - throw new Error(`service.peekAtNode: No Family under for CategoryNode '${skuPath}' with id ${familyId}!`) - } - return fam - }) - } - else if (!node.subNodes && (atEnd || possibleSKU)) { - const _skuPath = (possibleSKU) ? getParentPath(skuPath) : skuPath - if (parent?.outermost) { - role = 'family-in-multi-family' - const fam = this._familyMap.get(_skuPath) - if (!fam) { - throw new Error(`service.peekAtNode: '${_skuPath}' graphs as a Family under a multi-family node, but no such family exists!`) - } - family = fam - const parentPath = getParentPath(_skuPath) - // get all siblings (subnodes of parent) - families = parent.subNodes!.map((sn) => { - const familyId = parentPath + sep.tok + sn.skuToken - const fam = this._familyMap.get(familyId) - if (!fam) { - throw new Error(`service.peekAtNode: No sibling Family for '${_skuPath}' with id '${familyId}'!`) - } - return fam - }) - node = parent - } - else { - role = 'single-family' - const fam = this._familyMap.get(_skuPath) - if (!fam) { - throw new Error(`service.peekAtNode: '${_skuPath}' graphs as a single Family, but no such family exists!`) - } - family = fam - } - if (possibleSKU) { - const skuToTry = family.id + sep.tok + toks[toks.length - 1] - const _item = family.products.find((p) => (p.sku === skuToTry)) - if (_item) { - item = _item as LineItem - } - else { - throw new Error(`service.peekAtNode: '${skuPath}' graphs as LineItem in Family '${family.id}', but no such sku exists there!`) - } - } - } - } - catch (e) { - error = getErrorMessage(e) - } - - return error ?? { - role, - family, - families, - node, - item - } - } - - getSelectedNodesAtLevel = computedFn((level: number): CategoryNode[] | undefined => { - - let lvl = 1 - let nodesAtLevel: CategoryNode[] | undefined = this._rootNode.subNodes - - do { - let selectedAtLevel: CategoryNode[] | undefined = undefined - // If not specified, assume all - if (lvl in this._selectedPaths) { - selectedAtLevel = nodesAtLevel!.filter((n) => (this._selectedPaths[lvl].includes(n.skuToken))) - } - else { - selectedAtLevel = nodesAtLevel - } - let allSubsOfSelected: CategoryNode[] = [] - selectedAtLevel?.forEach((n: CategoryNode) => { - if (n.subNodes) { - allSubsOfSelected = [...allSubsOfSelected, ...n.subNodes] - } - }) - - nodesAtLevel = allSubsOfSelected - lvl++ - } while (nodesAtLevel.length > 0 && lvl <= level) - - return (nodesAtLevel.length > 0 && ((lvl - 1) === level)) ? nodesAtLevel : undefined - }) - - - //async createOrder(email: string, paymentMethod: string): Promise { - async createOrder(email: string, name?: string): Promise { - const snapshot = this.takeSnapshot() - const order = await createOrderHelper(email, snapshot.items, this._options, name) // didn't want to have two levels of 'items' - return order.id - } - - // TODO: add shippingInfo type - async updateOrderShippingInfo(orderId: string, shippingInfo: any): Promise { - updateOrderShippingInfoHelper(orderId, shippingInfo, this._options) - } - - // TODO: add paymentInfo type - async updateOrderPaymentInfo(orderId: string, paymentInfo: any): Promise { - updateOrderPaymentInfoHelper(orderId, paymentInfo, this._options) - } - - takeSnapshot = (): StandaloneServiceSnapshot => ({ - items : (this.cartItems as ActualLineItem[]).map((it) => (it.takeSnapshot(this))) - }) - - get cartItems(): LineItem[] { - let result: LineItem[] = [] - this._familyMap.forEach((fam) => { - result = [...result, ...(fam.products as LineItem[]).filter((item) => (item.isInCart))] - }) - return result.sort((it1, it2) => ((it1 as ActualLineItem).timeAdded - (it2 as ActualLineItem).timeAdded)) - } - - get cartEmpty(): boolean { - return this.cartItems.length === 0 - } - - get cartTotal(): number { - return this.cartItems.reduce( - (total, item) => (total + item.price * item.quantity), - 0 - ) - } - - _promoValue_unsafe(value: number): number { - if (this._promo!.type === 'percent') { - return value * (1 - this._promo!.value / 100) - } - return value - this._promo!.value - } - - get promoAppliedCartTotal(): number { - if (!this._promo) { - return this.cartTotal - } - if (!this._promo.skus) { - return this._promoValue_unsafe(this.cartTotal) - } - let total = this.cartItems.reduce( - (total, item) => { - const itemPrice = this._promo!.skus!.includes(item.sku) ? - this._promoValue_unsafe(item.price) - : - item.price - return total + itemPrice * item.quantity - }, - 0 - ) - return total - } - - itemPromoPrice(item: LineItem): number | undefined { - if (this._promo && (!this._promo.skus || this._promo.skus.includes(item.sku) )) { - return this._promoValue_unsafe(item.price) - } - return undefined - } - - get cartQuantity(): number { - return this.cartItems.reduce( - (total, item) => (total + item.quantity), - 0 - ) - } - - get appliedPromo(): Promo | null { - return this._promo - } - - setAppliedPromo(promo: Promo | null): void { - this._promo = promo - } - - getItemBySku = (skuToFind: string | undefined): LineItem | undefined => { - - if (skuToFind === undefined || skuToFind.length === 0) { - return undefined - } - // Self-calling - const found = ((): ActualLineItem | undefined => { - - const familiesTried: string[] = [] - if (this.selectedFamilies && this.selectedFamilies.length > 0) { - for (let family of this.selectedFamilies) { - familiesTried.push(family.id) - const foundItem = family.products.find((p) => (p.sku === skuToFind)) - if (foundItem) { - return foundItem as ActualLineItem - } - } - } - for( const [familyId, family] of this._familyMap.entries()) { - if (familiesTried.includes(familyId)) continue - const foundItem = family.products.find((p) => (p.sku === skuToFind)) as ActualLineItem | undefined - if (foundItem) { - return foundItem as ActualLineItem - } - } - return undefined - })(); // Self-calling, necessary semi - - return found - } - - setCurrentItem = (skuToFind: string | undefined): boolean => { - - if (skuToFind === undefined || skuToFind.length === 0) { - this._currentItem = undefined - return true - } - // self calling function - this._currentItem = this.getItemBySku(skuToFind) as ActualLineItem | undefined - this.setCurrentFamily(this._currentItem ? this._currentItem.familyId : undefined) - return !!this._currentItem - } - - /* for ObsLineItemRef */ - get item(): LineItem | undefined { - return this._currentItem - } - - get currentItem(): LineItem | undefined { - return this._currentItem - } - - setCurrentFamily(id: string | undefined): boolean { - - if (id === undefined || id.length === 0) { - this._currentFamily = undefined - return true - } - - const fam = this._familyMap.get(id) - this._currentFamily = fam // undef ok - - if ( - this._currentFamily && - this._currentItem && - this._currentItem.familyId !== this._currentFamily.id - ) { - this._currentItem = undefined - } - - return !!this._currentFamily - } - - get currentFamily(): Family | undefined { - return this._currentFamily - } - - /* for ObsFamilyRef */ - get family(): Family | undefined { - return this._currentFamily - } - - selectPaths(sel: SelectedPaths): Family[] { - runInAction (() => { - this._selectedPaths = this._processAndValidate(sel) - }) - return this.selectedFamilies - } - - selectPath(skuPath: string): Family[] { - const toks = skuPath.split(sep.tok) - const highestLevel = toks.length - 1 - const fsv: SelectedPaths = {} - for (let level = 1; level <= highestLevel; level++ ) { - fsv[level] = [toks[level]] - } - return this.selectPaths(fsv) - } - - get selectedPaths(): SelectedPaths { - const result: SelectedPaths = {} - for( let level in this._selectedPaths ) { - result[level] = [...this._selectedPaths[level]] - } - return result - } - - get selectedFamilies(): Family[] { - if (Object.keys(toJS(this._selectedPaths)).length === 0) { - // FacetsDesc have never been set or unset, so cannot evaluate them - return [] - } - - return this._rootNode.subNodes!.reduce( - (acc: Family[], subFacet: CategoryNode) => ( - // Pass the root token as a one member array - this._reduceNode([this._rootNode.skuToken], acc, subFacet) - ), - [] - ) - } - - private _reduceNode(parentPath: string[], acc: Family[], node: CategoryNode): Family[] { - const path = [...parentPath, node.skuToken] // Don't mutate original please :) - const level = path.length - 1 - // If there is no token array supplied for this level, - // assume all are specified. Otherwise, see if the - // current node is in the array - const specified = ( - !this._selectedPaths[level] - || - this._selectedPaths[level].includes(node.skuToken) - ) - if (specified) { - // Process subnodes - if (node.subNodes && node.subNodes.length > 0) { - return node.subNodes.reduce((acc, n) => ( - this._reduceNode(path, acc, n) - ) - , acc) - } - // Process leaf - const fam = this._familyMap.get(path.join(sep.tok)) - if (!fam) { - throw new Error("selectedFamilies WTF?!" + path.join(sep.tok)) - } - acc.push(fam) - } - return acc - } - - private _processAndValidate(partial: SelectedPaths): SelectedPaths { - - const result: SelectedPaths = {} - - let level = 1 - let currentSet = this._rootNode.subNodes! - - while (true) { - let possibleCurrent = currentSet.map((el) => (el.skuToken)) - const validTokens = !partial[level] ? undefined : partial[level].filter((tok) => possibleCurrent.includes(tok)) - if (!validTokens) { - break - } - result[level] = validTokens - currentSet = validTokens.map((tok) => { - const fd = currentSet.find((node) => ( node.skuToken === tok )) - return (fd && fd.subNodes && fd.subNodes.length > 0) ? fd.subNodes : [] - }).flat() - level++ - } - - return result - } - - get selectedItems(): LineItem[] { - if (Object.keys(toJS(this._selectedPaths)).length === 0) { - // FacetsDesc have never been set or unset, so cannot evaluate them - return [] - } - - return this.selectedFamilies.reduce( - (allProducts, fam) => ([...allProducts, ...(fam.products as LineItem[])]), [] as LineItem[]) - } - - get hasSelection(): boolean { - return this.selectedFamilies.length > 0 - } - - getFamilySubtotal(familyId: string): number { - const c = this._familyMap.get(familyId)! - return (c.products as LineItem[]).reduce( - // avoid floating point bs around zero - (total, item) => (item.quantity > 0 ? total + item.price * item.quantity : total), - 0 - ) - } - -} - -export { - type StandaloneServiceOptions, - type StandaloneServiceSnapshot, - StandaloneService as default -} diff --git a/packages/commerce/types/commerce-service.ts b/packages/commerce/types/commerce-service.ts index d2964b1a..67756827 100644 --- a/packages/commerce/types/commerce-service.ts +++ b/packages/commerce/types/commerce-service.ts @@ -13,6 +13,12 @@ interface CommerceService extends ObsLineItemRef, ObsFamilyRef { /** Total of all prices x quantities of items in cart */ get cartTotal(): number + /** cartItem whose quantity was modified most recently + * item: LineItem + * modified: number (timestamp) + * (undefined if cartEmpty) + */ + get recentItem(): { item: LineItem, modified: number } | undefined get promoAppliedCartTotal(): number get cartEmpty(): boolean diff --git a/packages/commerce/util/use-sync-sku-param-w-current-item.ts b/packages/commerce/util/use-sync-sku-param-w-current-item.ts index e78bf650..e9090912 100644 --- a/packages/commerce/util/use-sync-sku-param-w-current-item.ts +++ b/packages/commerce/util/use-sync-sku-param-w-current-item.ts @@ -8,7 +8,7 @@ import { parseAsBoolean, } from 'next-usequerystate' -import { useCommerce } from '../context' +import { useCommerce } from '../service/context' import type { SelectedPaths } from '../types' import sep from '../service/sep'