Skip to content

Commit

Permalink
Cart and order data persistence on the server side (#21)
Browse files Browse the repository at this point in the history
* Cart and order data persistence on the server side

* commerce-generic:
- better files naming
- comment about the persistence limitation in serverless environment

---------

Co-authored-by: Marcin Slezak <[email protected]>
  • Loading branch information
marcin-slezak and Marcin Slezak committed Nov 14, 2023
1 parent 0404be1 commit df12c21
Show file tree
Hide file tree
Showing 17 changed files with 277 additions and 85 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ export const CheckoutProvider = ({ children }: CheckoutProviderProps) => {
*/

const [checkoutState, setCheckoutState] = useState<CheckoutState>({
cartId: cart.id || '',
config: {
billingSameAsShipping: true,
gdpr: false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { InputField } from '@composable/ui'
import { CHECKOUT_FORM_KEY } from '../constants'
import { CheckoutInput } from '@composable/types'

type FormData = Omit<CheckoutInput['customer'], 'id' | 'name'>
type FormData = Omit<CheckoutInput['customer'], 'id' | 'name' | 'cartId'>

type GuestFormProps = Pick<
UseCheckoutFormProps<FormData>,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export const createOrder = protectedProcedure
.input(
z.object({
checkout: z.object({
cartId: z.string(),
customer: z.object({
id: z.string().optional(),
email: z.string(),
Expand Down
6 changes: 6 additions & 0 deletions composable-ui/src/utils/__mocks__/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,21 +28,27 @@ export const api = {
items: [
{
__typename: 'BannerSplit',
id: '23385feb-cd12-4122-8105-bf5da178d70c',
},
{
__typename: 'BannerFull',
id: '2d095fdd-e3ea-4f7a-907a-359ef1d0593d',
},
{
__typename: 'BannerTextOnly',
id: '40433348-1eb0-43fd-99dc-090c79972512',
},
{
__typename: 'BannerTextOnly',
id: 'a609a45e-f3f2-4cfc-8709-29a08153c9ac',
},
{
__typename: 'Grid',
id: '9fad8aa3-6e8d-43e2-b79a-492409b49003',
},
{
__typename: 'CommerceConnector',
id: '662f1110-8a51-4a11-9a8e-730a43d6867d',
},
],
},
Expand Down
4 changes: 3 additions & 1 deletion packages/commerce-generic/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@
"ts": "tsc --noEmit --incremental"
},
"dependencies": {
"@composable/types": "workspace:*"
"@composable/types": "workspace:*",
"@types/node-persist": "^3.1.5",
"node-persist": "^3.1.3"
},
"devDependencies": {
"eslint-config-custom": "workspace:*",
Expand Down
44 changes: 44 additions & 0 deletions packages/commerce-generic/src/data/generate-cart-data.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { Cart, CartItem } from '@composable/types'
import products from './products.json'
import { randomUUID } from 'crypto'

const findProductById = (id: string) => {
return products.find((product) => product.id === id) ?? products[0]
}

export const generateEmptyCart = (cartId?: string): Cart => ({
id: cartId || randomUUID(),
items: [],
summary: {},
})

export const generateCartItem = (productId: string, quantity: number) => {
const _product = findProductById(productId)
return {
brand: _product.brand,
category: _product.category,
id: _product.id,
image: _product.images[0],
name: _product.name,
price: _product.price,
quantity: quantity ?? 1,
sku: _product.sku,
slug: _product.slug,
type: _product.type,
}
}

export const calculateCartSummary = (cartItems: CartItem[]) => {
const subtotal = cartItems.reduce((_subtotal, item) => {
return _subtotal + item.price * (item.quantity ?? 1)
}, 0)
const taxes = subtotal * 0.07
const total = subtotal + taxes

return {
subtotalPrice: subtotal.toFixed(2),
taxes: taxes.toFixed(2),
totalPrice: total.toFixed(2),
shipping: 'Free',
}
}
38 changes: 0 additions & 38 deletions packages/commerce-generic/src/data/generateCartData.ts

This file was deleted.

41 changes: 41 additions & 0 deletions packages/commerce-generic/src/data/mock-storage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/**
* IMPORTANT: The following implementation is purely for demonstration purposes and should NEVER be used in a Production environment.
*
* The current persistence mechanism leverages storing data as JSON documents in the serverless functions' file system.
* Keep in mind that these files may be destroyed and recreated by the Cloud Provider (e.g., Vercel or Netlify) at any time.
* As a result, users with a working cart containing items may experience data loss and need to start anew if serverless
* functions are reinitialized. This is not suitable for a Production environment where data persistence and reliability are critical.
*/
import storage from 'node-persist'
import path from 'path'
import os from 'os'
import { Order, Cart } from '@composable/types'

const storageFolderPath = path.join(os.tmpdir(), 'composable-ui-storage')

storage.init({
dir: storageFolderPath,
})

export const getOrder = async (orderId: string): Promise<Order | undefined> => {
return storage.getItem(`order-${orderId}`)
}

export const saveOrder = async (order: Order) => {
await storage.setItem(`order-${order.id}`, order)
return order
}

export const getCart = async (cartId: string): Promise<Cart | undefined> => {
return storage.getItem(`cart-${cartId}`)
}

export const saveCart = async (cart: Cart) => {
await storage.setItem(`cart-${cart.id}`, cart)
return cart
}

export const deleteCart = async (cartId: string) => {
const result = await storage.del(`cart-${cartId}`)
return result.removed
}
26 changes: 19 additions & 7 deletions packages/commerce-generic/src/services/cart/add-cart-item.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,30 @@
import { CommerceService } from '@composable/types'
import { generateCartData } from '../../data/generateCartData'
import cart from '../../data/cart.json'
import { getCart, saveCart } from '../../data/mock-storage'
import {
generateCartItem,
calculateCartSummary,
generateEmptyCart,
} from '../../data/generate-cart-data'

export const addCartItem: CommerceService['addCartItem'] = async ({
cartId,
productId,
quantity,
variantId,
}) => {
const { items, summary } = generateCartData({ productId, quantity })
const cart = (await getCart(cartId)) || generateEmptyCart(cartId)

return {
...cart,
items,
summary,
const isProductInCartAlready = cart.items.some(
(item) => item.id === productId
)

if (isProductInCartAlready) {
cart.items.find((item) => item.id === productId)!.quantity++
} else {
const newItem = generateCartItem(productId, quantity)
cart.items.push(newItem)
}
cart.summary = calculateCartSummary(cart.items)

return saveCart(cart)
}
5 changes: 3 additions & 2 deletions packages/commerce-generic/src/services/cart/create-cart.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { CommerceService } from '@composable/types'
import cart from '../../data/cart.json'
import { saveCart } from '../../data/mock-storage'
import { generateEmptyCart } from '../../data/generate-cart-data'

export const createCart: CommerceService['createCart'] = async () => {
return cart
return saveCart(generateEmptyCart())
}
17 changes: 15 additions & 2 deletions packages/commerce-generic/src/services/cart/delete-cart-item.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,22 @@
import { CommerceService } from '@composable/types'
import cart from '../../data/cart.json'
import { getCart, saveCart } from '../../data/mock-storage'

import { calculateCartSummary } from '../../data/generate-cart-data'

export const deleteCartItem: CommerceService['deleteCartItem'] = async ({
cartId,
productId,
}) => {
return cart
const cart = await getCart(cartId)

if (!cart) {
throw new Error(
`[deleteCartItem] Could not found cart with requested cart id: ${cartId}`
)
}

cart.items = cart.items.filter((item) => item.id !== productId)
cart.summary = calculateCartSummary(cart.items)

return saveCart(cart)
}
11 changes: 2 additions & 9 deletions packages/commerce-generic/src/services/cart/get-cart.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,10 @@
import { CommerceService } from '@composable/types'
import { generateCartData } from '../../data/generateCartData'
import cart from '../../data/cart.json'
import { getCart as getCartFromStorage } from '../../data/mock-storage'

export const getCart: CommerceService['getCart'] = async ({ cartId }) => {
if (!cartId) {
return null
}

const { items, summary } = generateCartData()

return {
...cart,
items,
summary,
}
return (await getCartFromStorage(cartId)) || null
}
29 changes: 22 additions & 7 deletions packages/commerce-generic/src/services/cart/update-cart-item.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,32 @@
import { CommerceService } from '@composable/types'
import { generateCartData } from '../../data/generateCartData'
import cart from '../../data/cart.json'
import { getCart, saveCart } from '../../data/mock-storage'

import { calculateCartSummary } from '../../data/generate-cart-data'

export const updateCartItem: CommerceService['updateCartItem'] = async ({
cartId,
productId,
quantity,
}) => {
const { items, summary } = generateCartData({ productId, quantity })
const cart = await getCart(cartId)

if (!cart) {
throw new Error(
`[updateCartItem] Could not found cart with requested cart id: ${cartId}`
)
}

const cartItem = cart.items.find((item) => item.id === productId)

return {
...cart,
items,
summary,
if (!cartItem) {
throw new Error(
`[updateCartItem] Could not found cart item with requested product id: ${productId}`
)
}

cartItem.quantity = quantity

cart.summary = calculateCartSummary(cart.items)

return saveCart(cart)
}
50 changes: 41 additions & 9 deletions packages/commerce-generic/src/services/checkout/create-order.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,48 @@
import { CommerceService } from '@composable/types'
import { generateCartData } from '../../data/generateCartData'
import order from '../../data/order.json'
import { Cart, CheckoutInput, CommerceService, Order } from '@composable/types'
import { getCart } from '../../data/mock-storage'
import { saveOrder } from '../../data/mock-storage'
import shippingMethods from '../../data/shipping-methods.json'
import { randomUUID } from 'crypto'

export const createOrder: CommerceService['createOrder'] = async ({
checkout,
}) => {
const cartItems = generateCartData()
const generateOrderFromCart = (
cart: Cart,
checkoutInput: CheckoutInput
): Order => {
return {
...order,
...cartItems,
id: randomUUID(),
status: 'complete',
payment: 'unpaid',
shipping: 'unfulfilled',
customer: {
email: checkoutInput.customer.email,
},
shipping_address: {
phone_number: '',
city: '',
...checkoutInput.shipping_address,
},
billing_address: {
phone_number: '',
city: '',
...checkoutInput.billing_address,
},
shipping_method: shippingMethods[0],
created_at: Date.now(),
items: cart.items,
summary: cart.summary,
}
}

export const createOrder: CommerceService['createOrder'] = async ({
checkout,
}) => {
const cart = await getCart(checkout.cartId)

if (!cart) {
throw new Error(
`[createOrder] Could not find cart by id: ${checkout.cartId}`
)
}

return saveOrder(generateOrderFromCart(cart, checkout))
}
Loading

2 comments on commit df12c21

@vercel
Copy link

@vercel vercel bot commented on df12c21 Nov 14, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@github-actions
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lines Statements Branches Functions
Coverage: 44%
43.54% (297/682) 23.11% (46/199) 23.23% (33/142)

Please sign in to comment.