diff --git a/composable-ui/src/__mocks__/@composable/ui.tsx b/composable-ui/src/__mocks__/@composable/ui.tsx index da47ebf..73302e7 100644 --- a/composable-ui/src/__mocks__/@composable/ui.tsx +++ b/composable-ui/src/__mocks__/@composable/ui.tsx @@ -1,5 +1,14 @@ import { ImageBannerProps } from '@composable/ui' +import { FormControl, Input } from '@chakra-ui/react' export const ImageBanner = (props: ImageBannerProps) => { return
{props.image?.src}
} + +export const InputField = () => { + return ( + + + + ) +} diff --git a/composable-ui/src/components/cart/__data__/cart-data.ts b/composable-ui/src/components/cart/__data__/cart-data.ts index a8e7a36..e17686a 100644 --- a/composable-ui/src/components/cart/__data__/cart-data.ts +++ b/composable-ui/src/components/cart/__data__/cart-data.ts @@ -10,6 +10,7 @@ export const cartData: CartData = { name: 'Venture Daypack', brand: 'Riley', price: 129, + tax: 0.07, image: { url: '/img/products/_0000s_0001_backpack-rugged-black-front.jpg', alt: '', diff --git a/composable-ui/src/components/cart/__tests__/cart-summary.test.tsx b/composable-ui/src/components/cart/__tests__/cart-summary.test.tsx index 1b9c763..d5e835f 100644 --- a/composable-ui/src/components/cart/__tests__/cart-summary.test.tsx +++ b/composable-ui/src/components/cart/__tests__/cart-summary.test.tsx @@ -18,8 +18,18 @@ jest.mock('hooks', () => ({ subtotalPrice: 70, shipping: 10, taxes: 15, - totalPrice: 95, + priceBeforeDiscount: 95, + totalDiscountAmount: 10, + totalPrice: 85, }, + vouchersApplied: [ + { + code: 'CODE', + label: 'VOUCHER', + discount: '10', + }, + ], + promotionsApplied: [], }, } }, @@ -27,11 +37,16 @@ jest.mock('hooks', () => ({ const translations = { 'action.proceedToCheckout': 'Proceed to Checkout', - 'cart.summary.orderTotal': 'Order Total', + 'cart.summary.orderTotal': 'Grand Total', 'cart.summary.shipping': 'Shipping Label', 'cart.summary.subtotal': 'Subtotal', 'cart.summary.taxes': 'Taxes Label', 'cart.summary.title': 'Cart Title', + 'cart.summary.label.voucher': 'Voucher code', + 'cart.summary.priceBeforeDiscount': 'Order Total', + 'cart.summary.totalDiscountAmount': 'All discounts', + 'action.addVoucher': 'Add Voucher', + 'cart.summary.vouchers': 'Vouchers', } describe('CartSummary', () => { @@ -54,8 +69,18 @@ describe('CartSummary', () => { // taxes const taxesPrice = screen.getByText('$15.00') const taxesLabel = screen.getByText(translations['cart.summary.taxes']) + // priceBeforeDiscount + const priceBeforeDiscount = screen.getByText('$95.00') + const priceBeforeDiscountLabel = screen.getByText( + translations['cart.summary.priceBeforeDiscount'] + ) + // totalDiscountAmount + const totalDiscountAmount = screen.getByText('$10.00') + const totalDiscountAmountLabel = screen.getByText( + translations['cart.summary.totalDiscountAmount'] + ) // totalPrice - const totalPrice = screen.getByText('$95.00') + const totalPrice = screen.getByText('$85.00') const totalPriceLabel = screen.getByText( translations['cart.summary.orderTotal'] ) @@ -63,6 +88,8 @@ describe('CartSummary', () => { const proceedToCheckout = screen.getByText( translations['action.proceedToCheckout'] ) + // vouchers + const vouchers = screen.getByText(translations['cart.summary.vouchers']) expect(title).toBeInTheDocument() expect(subtotalPrice).toBeInTheDocument() @@ -71,9 +98,14 @@ describe('CartSummary', () => { expect(shippingLabel).toBeInTheDocument() expect(taxesPrice).toBeInTheDocument() expect(taxesLabel).toBeInTheDocument() + expect(priceBeforeDiscount).toBeInTheDocument() + expect(priceBeforeDiscountLabel).toBeInTheDocument() + expect(totalDiscountAmount).toBeInTheDocument() + expect(totalDiscountAmountLabel).toBeInTheDocument() expect(totalPrice).toBeInTheDocument() expect(totalPriceLabel).toBeInTheDocument() expect(proceedToCheckout).toBeInTheDocument() + expect(vouchers).toBeInTheDocument() fireEvent.click(proceedToCheckout) expect(pushMock).toHaveBeenCalledWith('/checkout') diff --git a/composable-ui/src/components/cart/cart-drawer/cart-drawer-footer.tsx b/composable-ui/src/components/cart/cart-drawer/cart-drawer-footer.tsx index bc401f9..89068d6 100644 --- a/composable-ui/src/components/cart/cart-drawer/cart-drawer-footer.tsx +++ b/composable-ui/src/components/cart/cart-drawer/cart-drawer-footer.tsx @@ -27,7 +27,7 @@ export const CartDrawerFooter = () => { color={'text-muted'} textStyle={{ base: 'Mobile/Eyebrow', md: 'Desktop/Body-XS' }} > - {intl.formatMessage({ id: 'cart.summary.estimatedTotal' })} + {intl.formatMessage({ id: 'cart.summary.orderTotal' })} diff --git a/composable-ui/src/components/cart/cart-drawer/cart-drawer-summary.tsx b/composable-ui/src/components/cart/cart-drawer/cart-drawer-summary.tsx index 074590f..4a5a0dd 100644 --- a/composable-ui/src/components/cart/cart-drawer/cart-drawer-summary.tsx +++ b/composable-ui/src/components/cart/cart-drawer/cart-drawer-summary.tsx @@ -1,17 +1,20 @@ -import { Divider, Box, Flex, Text } from '@chakra-ui/react' +import { Divider, Box, Flex, Text, Stack } from '@chakra-ui/react' import { useIntl } from 'react-intl' import { useCart } from 'hooks' import { CartDrawerSummaryItem } from './cart-drawer-summary-item' import { Price } from '../../price' +import { CartPromotions } from '../cart-promotions' +import { CartVouchers } from '../cart-vouchers' export const CartDrawerSummary = () => { const { cart } = useCart() const intl = useIntl() + const promotions = cart.promotionsApplied || [] + return ( - {cart.summary?.subtotalPrice && ( { )} + + {promotions.length > 0 && ( + + + + )} + + + + + + {cart.summary?.priceBeforeDiscount && ( + + + + + + )} + + {cart.summary?.totalDiscountAmount && ( + <> + + + + + + + )} {cart.summary?.totalPrice && ( <> + - - {intl.formatMessage({ id: 'cart.summary.estimatedTotal' })} - + {intl.formatMessage({ id: 'cart.summary.orderTotal' })} { ) : cart.isEmpty ? ( ) : ( - + } diff --git a/composable-ui/src/components/cart/cart-promotions.tsx b/composable-ui/src/components/cart/cart-promotions.tsx new file mode 100644 index 0000000..e187844 --- /dev/null +++ b/composable-ui/src/components/cart/cart-promotions.tsx @@ -0,0 +1,48 @@ +import { useIntl } from 'react-intl' +import { Promotion } from '@composable/types' +import { Box, Flex, Tag, TagLabel, TagLeftIcon } from '@chakra-ui/react' +import { MdShoppingCart } from 'react-icons/md' +import { Price } from 'components/price' +import { CartSummaryItem } from '.' + +interface CartPromotionsProps { + promotions: Promotion[] +} + +export const CartPromotions = ({ promotions }: CartPromotionsProps) => { + const intl = useIntl() + if (!promotions.length) { + return null + } + + return ( + <> + + {promotions.map((redeemable) => ( + + + + {redeemable.label} + + + + + + ))} + + ) +} diff --git a/composable-ui/src/components/cart/cart-summary.tsx b/composable-ui/src/components/cart/cart-summary.tsx index b51a87a..5a88992 100644 --- a/composable-ui/src/components/cart/cart-summary.tsx +++ b/composable-ui/src/components/cart/cart-summary.tsx @@ -11,7 +11,7 @@ import { StackProps, Text, } from '@chakra-ui/react' -import { CartSummaryItem } from '.' +import { CartSummaryItem, CartPromotions, CartVouchers } from '.' interface CartSummaryProps { rootProps?: StackProps @@ -28,6 +28,7 @@ export const CartSummary = ({ const { cart } = useCart() const intl = useIntl() const _cartData = cartData ?? cart + const promotions = _cartData.promotionsApplied || [] return ( @@ -52,6 +53,18 @@ export const CartSummary = ({ /> )} + + {_cartData.summary?.taxes && ( + + + + )} + {_cartData.summary?.shipping && ( )} - - {_cartData.summary?.taxes && ( + {promotions.length > 0 && ( + <> + + + + )} + + + + {_cartData.summary?.priceBeforeDiscount && ( + + )} + {_cartData.summary?.totalDiscountAmount && ( + + )} diff --git a/composable-ui/src/components/cart/cart-total.tsx b/composable-ui/src/components/cart/cart-total.tsx index d95d9ab..7d30ce5 100644 --- a/composable-ui/src/components/cart/cart-total.tsx +++ b/composable-ui/src/components/cart/cart-total.tsx @@ -1,8 +1,7 @@ import { useIntl } from 'react-intl' -import { useRouter } from 'next/router' import { CartData, useCart } from 'hooks' import { Price } from 'components/price' -import { Button, Flex, Text, FlexProps } from '@chakra-ui/react' +import { Flex, Text, FlexProps } from '@chakra-ui/react' interface CartTotalProps { rootProps?: FlexProps @@ -10,7 +9,6 @@ interface CartTotalProps { } export const CartTotal = ({ cartData, rootProps }: CartTotalProps) => { - const router = useRouter() const { cart } = useCart() const intl = useIntl() const _cartData = cartData ?? cart @@ -26,17 +24,6 @@ export const CartTotal = ({ cartData, rootProps }: CartTotalProps) => { {intl.formatMessage({ id: 'cart.summary.estimatedTotal' })} - ) } diff --git a/composable-ui/src/components/cart/cart-vouchers.tsx b/composable-ui/src/components/cart/cart-vouchers.tsx new file mode 100644 index 0000000..a358968 --- /dev/null +++ b/composable-ui/src/components/cart/cart-vouchers.tsx @@ -0,0 +1,66 @@ +import { CartSummaryItem } from '../cart' +import { useIntl } from 'react-intl' +import { VoucherForm } from '../forms' +import { + Box, + Flex, + Tag, + TagCloseButton, + TagLabel, + TagLeftIcon, +} from '@chakra-ui/react' +import { Price } from '../price' +import { MdDiscount } from 'react-icons/md' +import { useCart } from '../../hooks' + +export const CartVouchers = () => { + const intl = useIntl() + + const { cart, deleteCartVoucher } = useCart() + + const vouchers = cart.vouchersApplied + + return ( + <> + + + {vouchers?.map((voucher) => ( + + + + {voucher.label} + + deleteCartVoucher.mutate({ + cartId: cart.id || '', + code: voucher.code, + }) + } + /> + + + + + + ))} + + ) +} diff --git a/composable-ui/src/components/cart/index.ts b/composable-ui/src/components/cart/index.ts index a41b9d9..857a432 100644 --- a/composable-ui/src/components/cart/index.ts +++ b/composable-ui/src/components/cart/index.ts @@ -8,3 +8,5 @@ export * from './cart-page' export * from './cart-summary-item' export * from './cart-summary' export * from './cart-total' +export * from './cart-vouchers' +export * from './cart-promotions' diff --git a/composable-ui/src/components/checkout/bag-summary-mobile.tsx b/composable-ui/src/components/checkout/bag-summary-mobile.tsx index 8630b6c..ba9d5f9 100644 --- a/composable-ui/src/components/checkout/bag-summary-mobile.tsx +++ b/composable-ui/src/components/checkout/bag-summary-mobile.tsx @@ -83,6 +83,20 @@ export const BagSummaryMobile = ({ accordionProps }: BagSummaryMobileProps) => { parseFloat(_cart?.summary?.taxes ?? '0'), currencyFormatConfig )} + priceBeforeDiscountTitle={intl.formatMessage({ + id: 'cart.summary.priceBeforeDiscount', + })} + priceBeforeDiscount={intl.formatNumber( + parseFloat(_cart?.summary?.priceBeforeDiscount ?? '0'), + currencyFormatConfig + )} + totalDiscountAmountTitle={intl.formatMessage({ + id: 'cart.summary.totalDiscountAmount', + })} + totalDiscountAmount={intl.formatNumber( + parseFloat(_cart?.summary?.totalDiscountAmount ?? '0'), + currencyFormatConfig + )} totalTitle={intl.formatMessage({ id: 'checkout.orderSummary.orderTotal', })} diff --git a/composable-ui/src/components/checkout/checkout-success-page.tsx b/composable-ui/src/components/checkout/checkout-success-page.tsx index ba7d5ad..f750cca 100644 --- a/composable-ui/src/components/checkout/checkout-success-page.tsx +++ b/composable-ui/src/components/checkout/checkout-success-page.tsx @@ -149,6 +149,20 @@ export const CheckoutSuccessPage = ({ currency: APP_CONFIG.CURRENCY_CODE, style: 'currency', })} + priceBeforeDiscount={intl.formatNumber( + parseFloat(order?.summary.priceBeforeDiscount || '0'), + { + currency: APP_CONFIG.CURRENCY_CODE, + style: 'currency', + } + )} + totalDiscountAmount={intl.formatNumber( + parseFloat(order?.summary.totalDiscountAmount || '0'), + { + currency: APP_CONFIG.CURRENCY_CODE, + style: 'currency', + } + )} total={intl.formatNumber( parseFloat(order?.summary.totalPrice ?? '0'), { diff --git a/composable-ui/src/components/checkout/order-summary.tsx b/composable-ui/src/components/checkout/order-summary.tsx index 77545b1..1de5c69 100644 --- a/composable-ui/src/components/checkout/order-summary.tsx +++ b/composable-ui/src/components/checkout/order-summary.tsx @@ -39,7 +39,7 @@ export const OrderSummary = ({ return ( - + {showTitle && ( {intl.formatMessage({ id: 'cart.summary.title' })} @@ -101,8 +101,22 @@ export const OrderSummary = ({ parseFloat(_cart?.summary?.taxes ?? '0'), currencyFormatConfig )} + priceBeforeDiscountTitle={intl.formatMessage({ + id: 'cart.summary.priceBeforeDiscount', + })} + priceBeforeDiscount={intl.formatNumber( + parseFloat(_cart?.summary?.priceBeforeDiscount ?? '0'), + currencyFormatConfig + )} + totalDiscountAmountTitle={intl.formatMessage({ + id: 'cart.summary.totalDiscountAmount', + })} + totalDiscountAmount={intl.formatNumber( + parseFloat(_cart?.summary?.totalDiscountAmount ?? '0'), + currencyFormatConfig + )} totalTitle={intl.formatMessage({ - id: 'checkout.orderSummary.orderTotal', + id: 'cart.summary.orderTotal', })} total={intl.formatNumber( parseFloat(_cart?.summary?.totalPrice ?? '0'), diff --git a/composable-ui/src/components/checkout/order-totals.tsx b/composable-ui/src/components/checkout/order-totals.tsx index 18bae1f..1d110e7 100644 --- a/composable-ui/src/components/checkout/order-totals.tsx +++ b/composable-ui/src/components/checkout/order-totals.tsx @@ -7,8 +7,12 @@ interface OrderTotalsProps { delivery: string tax: string discount?: string + priceBeforeDiscountTitle?: string + priceBeforeDiscount: string + totalDiscountAmountTitle?: string + totalDiscountAmount?: string totalTitle?: string - total: string + total?: string } export const OrderTotals = ({ @@ -16,24 +20,26 @@ export const OrderTotals = ({ deliveryTitle, delivery, tax, - discount, + priceBeforeDiscountTitle, + priceBeforeDiscount, + totalDiscountAmountTitle, + totalDiscountAmount, totalTitle, total, }: OrderTotalsProps) => { const intl = useIntl() return ( - } - px={{ base: 4, md: 'none' }} - > + } px={{ base: 4, md: 'none' }}> + + - {discount && ( + {totalDiscountAmount && ( + )} + + {total && ( + )} - ) } @@ -79,7 +90,10 @@ const CartSummaryItem = (props: CartSummaryItemProps) => { return ( {label} - + {isDiscount && '-'} {value} diff --git a/composable-ui/src/components/checkout/success/success-order-summary.tsx b/composable-ui/src/components/checkout/success/success-order-summary.tsx index fb02712..417be0c 100644 --- a/composable-ui/src/components/checkout/success/success-order-summary.tsx +++ b/composable-ui/src/components/checkout/success/success-order-summary.tsx @@ -11,7 +11,8 @@ interface OrderSummaryProps { deliveryTitle?: string delivery: string tax: string - discount?: string + priceBeforeDiscount: string + totalDiscountAmount?: string total: string } @@ -22,7 +23,8 @@ export const SuccessOrderSummary = ({ deliveryTitle, delivery, tax, - discount, + priceBeforeDiscount, + totalDiscountAmount, total, }: OrderSummaryProps) => { const intl = useIntl() @@ -55,7 +57,14 @@ export const SuccessOrderSummary = ({ deliveryTitle={deliveryTitle} delivery={delivery} tax={tax} - discount={discount} + priceBeforeDiscountTitle={intl.formatMessage({ + id: 'cart.summary.priceBeforeDiscount', + })} + priceBeforeDiscount={priceBeforeDiscount} + totalDiscountAmountTitle={intl.formatMessage({ + id: 'cart.summary.totalDiscountAmount', + })} + totalDiscountAmount={totalDiscountAmount} totalTitle={intl.formatMessage({ id: 'checkout.success.orderSummary.totalPaid', })} diff --git a/composable-ui/src/components/forms/index.tsx b/composable-ui/src/components/forms/index.tsx index 2fdc29a..0140cd8 100644 --- a/composable-ui/src/components/forms/index.tsx +++ b/composable-ui/src/components/forms/index.tsx @@ -1,3 +1,4 @@ export * from './forget-password-form' export * from './login-form' export * from './register-form' +export * from './voucher-form' diff --git a/composable-ui/src/components/forms/voucher-form.tsx b/composable-ui/src/components/forms/voucher-form.tsx new file mode 100644 index 0000000..13b953e --- /dev/null +++ b/composable-ui/src/components/forms/voucher-form.tsx @@ -0,0 +1,112 @@ +import React, { useState } from 'react' +import { Alert, AlertIcon, Box, Button, CloseButton } from '@chakra-ui/react' +import { useIntl } from 'react-intl' +import { InputField } from '@composable/ui' +import { useForm } from 'react-hook-form' +import { yupResolver } from '@hookform/resolvers/yup' +import * as yup from 'yup' +import { useCart } from '../../hooks' +export const VoucherForm = () => { + const intl = useIntl() + const { + register, + handleSubmit, + setValue, + setError, + formState: { errors }, + } = useForm<{ voucher: string }>({ + resolver: yupResolver(voucherFormSchema()), + mode: 'all', + }) + const [errorMessage, setErrorMessage] = useState('') + const [showAlert, setShowAlert] = useState(false) + const { cart, addCartVoucher } = useCart({ + onCartVoucherAddError: (message) => { + setErrorMessage(message || 'Could not add voucher') + setShowAlert(true) + const alertTimer = setTimeout(() => { + setShowAlert(false) + }, 3000) + return () => clearTimeout(alertTimer) + }, + }) + const content = { + title: intl.formatMessage({ id: 'cart.summary.vouchers' }), + input: { + voucher: { + label: intl.formatMessage({ id: 'cart.summary.label.voucher' }), + placeholder: intl.formatMessage({ + id: 'cart.summary.label.voucher', + }), + }, + }, + button: { + login: intl.formatMessage({ id: 'action.addVoucher' }), + }, + } + return ( +
{ + setErrorMessage('') + if (!data.voucher) { + setError('voucher', { message: 'This field cannot be empty.' }) + return + } + await addCartVoucher.mutate({ + cartId: cart.id || '', + code: data.voucher, + }) + setValue('voucher', '') + })} + > + + + + + {showAlert && errorMessage && ( + + + {errorMessage} + setShowAlert(false)} + /> + + )} +
+ ) +} +const voucherFormSchema = () => { + return yup.object().shape({ + voucher: yup.string(), + }) +} diff --git a/composable-ui/src/hooks/use-cart.ts b/composable-ui/src/hooks/use-cart.ts index 79a2885..fcccd96 100644 --- a/composable-ui/src/hooks/use-cart.ts +++ b/composable-ui/src/hooks/use-cart.ts @@ -37,7 +37,21 @@ interface UseCartOptions { onCartItemAddError?: () => void onCartItemUpdateError?: () => void onCartItemDeleteError?: () => void + onCartVoucherAddError?: (errorMessage: string) => void + onCartVoucherDeleteError?: () => void onCartItemAddSuccess?: (cart: Cart) => void + onCartVoucherAddSuccess?: ( + data: { + cart: Cart + success: boolean + }, + variables: { + cartId: string + code: string + }, + context: unknown + ) => void + onCartVoucherDeleteSuccess?: (cart: Cart) => void } export const useCart = (options?: UseCartOptions) => { @@ -227,6 +241,105 @@ export const useCart = (options?: UseCartOptions) => { [cartId, cartItemDelete] ) + /** + * Cart Voucher Add + */ + const cartVoucherAdd = useMutation( + ['cartVoucherAdd'], + async (variables: { cartId: string; code: string }) => { + const params = { + cartId: variables.cartId, + code: variables.code, + } + + const response = await client.commerce.addVoucher.mutate(params) + const updatedAt = Date.now() + queryClient.setQueryData( + [USE_CART_KEY, variables.cartId, updatedAt], + response.cart + ) + + setCartUpdatedAt(updatedAt) + + if (!response.success && optionsRef.current?.onCartVoucherAddError) { + optionsRef.current?.onCartVoucherAddError( + response.errorMessage || `Could not add ${variables.code} voucher` + ) + } + + return response + }, + { + onError: optionsRef.current?.onCartVoucherAddError, + } + ) + + /** + * Cart Voucher Add Mutation + */ + const cartVoucherAddMutation = useCallback( + async (params: { cartId: string; code: string }) => { + const id = cartId ? cartId : await cartCreate.mutateAsync() + cartVoucherAdd.mutate( + { + cartId: id, + code: params.code, + }, + { + onSuccess: optionsRef.current?.onCartVoucherAddSuccess, + } + ) + }, + [cartId, cartCreate, cartVoucherAdd] + ) + + /** + * Cart Voucher Delete + */ + const cartVoucherDelete = useMutation( + ['cartVoucherDelete'], + async (variables: { cartId: string; code: string }) => { + const params = { + cartId: variables.cartId, + code: variables.code, + } + + const response = await client.commerce.deleteVoucher.mutate(params) + const updatedAt = Date.now() + + queryClient.setQueryData( + [USE_CART_KEY, variables.cartId, updatedAt], + response + ) + + setCartUpdatedAt(updatedAt) + + return response + }, + { + onError: optionsRef.current?.onCartVoucherDeleteError, + } + ) + + /** + * Cart Voucher Delete Mutation + */ + const cartVoucherDeleteMutation = useCallback( + async (params: { cartId: string; code: string }) => { + const id = cartId ? cartId : await cartCreate.mutateAsync() + await cartVoucherDelete.mutate( + { + cartId: id, + code: params.code, + }, + { + onSuccess: optionsRef.current?.onCartVoucherDeleteSuccess, + } + ) + }, + [cartId, cartCreate, cartVoucherDelete] + ) + /** * Cart Item Add Facade */ @@ -251,6 +364,22 @@ export const useCart = (options?: UseCartOptions) => { isLoading: cartItemDelete.isLoading, } + /** + * Cart Voucher Add Facade + */ + const addCartVoucher = { + mutate: cartVoucherAddMutation, + isLoading: cartVoucherAdd.isLoading || cartCreate.isLoading, + } + + /** + * Cart Voucher Delete Facade + */ + const deleteCartVoucher = { + mutate: cartVoucherDeleteMutation, + isLoading: cartVoucherDelete.isLoading || cartCreate.isLoading, + } + /** * Cart data */ @@ -280,6 +409,8 @@ export const useCart = (options?: UseCartOptions) => { addCartItem, updateCartItem, deleteCartItem, + addCartVoucher, + deleteCartVoucher, cart: cartData, deleteCart: deleteCartHandler, } diff --git a/composable-ui/src/server/api/routers/commerce/procedures/cart/add-voucher.ts b/composable-ui/src/server/api/routers/commerce/procedures/cart/add-voucher.ts new file mode 100644 index 0000000..de12aad --- /dev/null +++ b/composable-ui/src/server/api/routers/commerce/procedures/cart/add-voucher.ts @@ -0,0 +1,14 @@ +import { protectedProcedure } from '../../../../trpc' +import { z } from 'zod' +import { commerce } from 'server/data-source' + +export const addVoucher = protectedProcedure + .input( + z.object({ + cartId: z.string(), + code: z.string(), + }) + ) + .mutation(async ({ input }) => { + return await commerce.addVoucher({ ...input }) + }) diff --git a/composable-ui/src/server/api/routers/commerce/procedures/cart/delete-voucher.ts b/composable-ui/src/server/api/routers/commerce/procedures/cart/delete-voucher.ts new file mode 100644 index 0000000..4b1b9a3 --- /dev/null +++ b/composable-ui/src/server/api/routers/commerce/procedures/cart/delete-voucher.ts @@ -0,0 +1,14 @@ +import { z } from 'zod' +import { protectedProcedure } from 'server/api/trpc' +import { commerce } from 'server/data-source' + +export const deleteVoucher = protectedProcedure + .input( + z.object({ + cartId: z.string(), + code: z.string(), + }) + ) + .mutation(async ({ input }) => { + return await commerce.deleteVoucher({ ...input }) + }) diff --git a/composable-ui/src/server/api/routers/commerce/procedures/cart/index.ts b/composable-ui/src/server/api/routers/commerce/procedures/cart/index.ts index c00875d..51ef78b 100644 --- a/composable-ui/src/server/api/routers/commerce/procedures/cart/index.ts +++ b/composable-ui/src/server/api/routers/commerce/procedures/cart/index.ts @@ -3,3 +3,5 @@ export * from './create-cart' export * from './delete-cart-item' export * from './get-cart' export * from './update-cart-item' +export * from './add-voucher' +export * from './delete-voucher' diff --git a/composable-ui/src/server/intl/en-US.json b/composable-ui/src/server/intl/en-US.json index 651a240..fb44f0a 100644 --- a/composable-ui/src/server/intl/en-US.json +++ b/composable-ui/src/server/intl/en-US.json @@ -94,6 +94,7 @@ "action.updatePassword": "Update Password", "action.updateSubscription": "Update Subscription", "action.viewCart": "View Cart", + "action.addVoucher": "Add Voucher", "app.failure": "Whoops, something went wrong! If the problem persists, please contact us.", "app.loading": "Loading...", @@ -116,7 +117,11 @@ "cart.item.totalPrice": "Total Price", "cart.summary.estimatedTotal": "Estimated Total", - "cart.summary.orderTotal": "Order Total", + "cart.summary.priceBeforeDiscount": "Order Total", + "cart.summary.promotions": "Promotions", + "cart.summary.vouchers": "Vouchers", + "cart.summary.totalDiscountAmount": "All discounts", + "cart.summary.orderTotal": "Grand Total", "cart.summary.shipping.complimentaryDelivery": "Complimentary Delivery", "cart.summary.shipping.free": "Free", "cart.summary.shipping": "Complimentary Delivery", @@ -125,6 +130,7 @@ "cart.summary.taxes": "Taxes", "cart.summary.title": "Order Summary", "cart.summary.total": "Total", + "cart.summary.label.voucher": "Voucher code", "checkout.title": "Checkout", diff --git a/docs/docs/integrations/promotions/_category_.json b/docs/docs/integrations/promotions/_category_.json new file mode 100644 index 0000000..6d50d06 --- /dev/null +++ b/docs/docs/integrations/promotions/_category_.json @@ -0,0 +1,8 @@ +{ + "label": "Promotions", + "position": 7, + "link": { + "type": "generated-index", + "description": "Promotions" + } +} diff --git a/docs/docs/integrations/promotions/voucherify.md b/docs/docs/integrations/promotions/voucherify.md new file mode 100644 index 0000000..586b1a1 --- /dev/null +++ b/docs/docs/integrations/promotions/voucherify.md @@ -0,0 +1,113 @@ + +# Voucherify Integration + +Voucherify is an API-first Promotions and Loyalty Engine that helps brands run personalised coupons, gift cards, auto-applied promotions, loyalty programs, and referral campaigns. This integration focuses on educating people about using Voucherify in composable commerce to enhance advanced promotions capabilities. + +## Integration architecture + +The application already has basic functionalities related to discounts: +- `packages/commerce-generic/src/services/cart/add-voucher.ts` - this is a service responsible for validating and adding entered vouchers to the array in the cart called: `vouchersApplied`. This service calls the `addVoucherToCart` function located in `packages/commerce-generic/src/services/cart/discount.ts` +- `packages/commerce-generic/src/services/cart/delete-voucher.ts`- this is a service responsible for removing selected vouchers from cart. This service calls the `deleteVoucherFromCart` function located in `packages/commerce-generic/src/services/cart/discount.ts` +- `packages/commerce-generic/src/services/cart/discount.ts` - main file containing the function that handles operations on discounts +- `packages/commerce-generic/src/services/checkout/create-order.ts` - when creating an order, vouchers and promotions from the cart are saved to the properties:`redeemedVouchers` and `redeemedPromotions`. + +As we see above, the cart already has basic services responsible for handling discounts. +However, in order to use Voucherify as a tool for managing vouchers and promotions, it was necessary to create functions that would use Voucherify REST API. + +To extend the storefront by capabilities provided by Voucherify integration a separate package called `voucherify` has been created, which contains the necessary functions needed to manage discounts. + +`@composable/voucherify` dependency implements a standard checkout integration pattern, where for each cart update, the Voucherify functions make Validation (eligibility check, discount calculations) and Qualification (list of applicable promotions) requests to Voucherify REST API. Collected pieces of information extend cart data. + +```mermaid +sequenceDiagram + participant B as Browser + participant CV as @composable/voucherify + participant CGS as Commerce Generic + participant V as Voucherify API + B->>CGS: Discount related methods (addVoucherToCart, updateCartDiscount etc.) + activate CV + CGS->>CV: Use Voucherify specific methods from @composable/voucherify + activate CGS + CGS-->>CV: Cart object + deactivate CGS + Note over CV: Get cart vouchers codes from storage + CV->>V: Get available cart promotions + activate V + V-->>CV: promotions + deactivate V + CV->>V: Validate promotions and voucher codes + activate V + V-->>CV: Eligibility check and discount calculations results + deactivate V + Note over CV: Extend cart by discounts details + CV-->>B: Cart object containing discounts from Voucherify + deactivate CV +``` + +## Reference Files + +### Backend Files + +- `packages/voucherify`: This package contains functions that enhance the default behavior of functions related to discounts. + +### React Components + +- `composable-ui/src/components/cart/cart-vouchers.tsx`: A component that allows you to add, display, validate and delete vouchers. +- `composable-ui/src/components/forms/voucher-form.tsx`: Form used in `cart-vouchers.tsx`. +- `composable-ui/src/components/cart/cart-promotions.tsx`: Displays auto-applied cart promotions. + +## Integrating Voucherify with Composable UI + +### First steps + +1. [Create a Voucherify account](https://app.voucherify.io/#/signup). +2. In Voucherify Dashboard, [set Discount Application Rule to "Partial"](https://support.voucherify.io/article/604-stacking-rules#application-rules) +3. Retrieve your API keys from your Voucherify dashboard and set the following environment variables: + +```code +VOUCHERIFY_API_URL=https://api.voucherify.io +VOUCHERIFY_APPLICATION_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxxx +VOUCHERIFY_SECRET_KEY=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxxx +``` + +**Caution:** Ensure you never expose your Voucherify API keys in the NEXT_PUBLIC_* environment variables or client-side code. Take the necessary steps to ensure that secret keys are never disclosed to the public. + + +### Populating the Products Using the Script + +To configure product base promotions in Voucherify, propagate product definitions to your Voucherify account: + +1. Open the terminal and navigate to the `scripts` directory. +2. In the `scripts` directory, run the following command: + ``` + pnpm install + ``` +3. To set up Voucherify, run the following command: + ``` + pnpm voucherify-setup + ``` + +For more information about the configurations, see the [Application Configuration](essentials/configuration.md) section. + +### Enabling the use of Voucherify in the store + +1. To switch between the classic implementation and the Voucherify integration you can run: +``` +pnpm voucherify-activate +``` +in `scripts` directory. +2. This action will make commerce-generic services start using methods from `@composable/voucherify`. + +**Note:** In order for the changes to take effect, a re-run of `pnpm install` from root directory is required. + +3. To stop using Voucherify integration, run: +``` +pnpm voucherify-deactivate +``` +in `scripts` directory. + + +## Related Resources + +- [Application Configuration](essentials/configuration.md) +- [Mono-repository](essentials/monorepo.md) \ No newline at end of file diff --git a/packages/commerce-generic/src/data/generate-cart-data.ts b/packages/commerce-generic/src/data/generate-cart-data.ts index 1f4ddfc..4a389d0 100644 --- a/packages/commerce-generic/src/data/generate-cart-data.ts +++ b/packages/commerce-generic/src/data/generate-cart-data.ts @@ -9,6 +9,8 @@ const findProductById = (id: string) => { export const generateEmptyCart = (cartId?: string): Cart => ({ id: cartId || randomUUID(), items: [], + promotionsApplied: [], + vouchersApplied: [], summary: {}, }) @@ -21,6 +23,7 @@ export const generateCartItem = (productId: string, quantity: number) => { image: _product.images[0], name: _product.name, price: _product.price, + tax: _product.price * 0.07, quantity: quantity ?? 1, sku: _product.sku, slug: _product.slug, @@ -28,7 +31,9 @@ export const generateCartItem = (productId: string, quantity: number) => { } } -export const calculateCartSummary = (cartItems: CartItem[]) => { +export const calculateCartSummary = ( + cartItems: CartItem[] +): Cart['summary'] => { const subtotal = cartItems.reduce((_subtotal, item) => { return _subtotal + item.price * (item.quantity ?? 1) }, 0) @@ -38,6 +43,8 @@ export const calculateCartSummary = (cartItems: CartItem[]) => { return { subtotalPrice: subtotal.toFixed(2), taxes: taxes.toFixed(2), + priceBeforeDiscount: total.toFixed(2), + totalDiscountAmount: '0', totalPrice: total.toFixed(2), shipping: 'Free', } diff --git a/packages/commerce-generic/src/index.ts b/packages/commerce-generic/src/index.ts index 5c6a9a8..b0bbf61 100644 --- a/packages/commerce-generic/src/index.ts +++ b/packages/commerce-generic/src/index.ts @@ -1,2 +1,3 @@ export * from './commerce-provider' export * from './mock-data' +export * from './services' diff --git a/packages/commerce-generic/src/services/cart/add-cart-item.ts b/packages/commerce-generic/src/services/cart/add-cart-item.ts index de08f0c..65ca4ee 100644 --- a/packages/commerce-generic/src/services/cart/add-cart-item.ts +++ b/packages/commerce-generic/src/services/cart/add-cart-item.ts @@ -5,12 +5,12 @@ import { calculateCartSummary, generateEmptyCart, } from '../../data/generate-cart-data' +import { updateCartDiscount } from './discount' export const addCartItem: CommerceService['addCartItem'] = async ({ cartId, productId, quantity, - variantId, }) => { const cart = (await getCart(cartId)) || generateEmptyCart(cartId) @@ -24,7 +24,9 @@ export const addCartItem: CommerceService['addCartItem'] = async ({ const newItem = generateCartItem(productId, quantity) cart.items.push(newItem) } + cart.summary = calculateCartSummary(cart.items) - return saveCart(cart) + const cartWithDiscount = await updateCartDiscount(cart) + return saveCart(cartWithDiscount) } diff --git a/packages/commerce-generic/src/services/cart/add-voucher.ts b/packages/commerce-generic/src/services/cart/add-voucher.ts new file mode 100644 index 0000000..07711b7 --- /dev/null +++ b/packages/commerce-generic/src/services/cart/add-voucher.ts @@ -0,0 +1,35 @@ +import { CommerceService } from '@composable/types' +import { + getCart as getCartFromStorage, + saveCart, +} from '../../data/mock-storage' +import { addVoucherToCart } from './discount' + +export const addVoucher: CommerceService['addVoucher'] = async ({ + cartId, + code, +}) => { + const cart = await getCartFromStorage(cartId) + + if (!cart) { + throw new Error( + `[updateCartItem] Could not found cart with requested cart id: ${cartId}` + ) + } + + const { + cart: cartWithDiscount, + errorMessage, + success, + } = await addVoucherToCart(cart, code) + + if (success) { + await saveCart(cartWithDiscount) + } + + return { + cart: cartWithDiscount, + success, + errorMessage, + } +} diff --git a/packages/commerce-generic/src/services/cart/delete-cart-item.ts b/packages/commerce-generic/src/services/cart/delete-cart-item.ts index 7189377..41af8cb 100644 --- a/packages/commerce-generic/src/services/cart/delete-cart-item.ts +++ b/packages/commerce-generic/src/services/cart/delete-cart-item.ts @@ -2,6 +2,7 @@ import { CommerceService } from '@composable/types' import { getCart, saveCart } from '../../data/mock-storage' import { calculateCartSummary } from '../../data/generate-cart-data' +import { updateCartDiscount } from './discount' export const deleteCartItem: CommerceService['deleteCartItem'] = async ({ cartId, @@ -18,5 +19,7 @@ export const deleteCartItem: CommerceService['deleteCartItem'] = async ({ cart.items = cart.items.filter((item) => item.id !== productId) cart.summary = calculateCartSummary(cart.items) - return saveCart(cart) + const cartWithDiscount = await updateCartDiscount(cart) + + return saveCart(cartWithDiscount) } diff --git a/packages/commerce-generic/src/services/cart/delete-voucher.ts b/packages/commerce-generic/src/services/cart/delete-voucher.ts new file mode 100644 index 0000000..f85b76a --- /dev/null +++ b/packages/commerce-generic/src/services/cart/delete-voucher.ts @@ -0,0 +1,29 @@ +import { CommerceService } from '@composable/types' +import { + getCart as getCartFromStorage, + saveCart, +} from '../../data/mock-storage' +import { deleteVoucherFromCart } from './discount' +export const deleteVoucher: CommerceService['deleteVoucher'] = async ({ + cartId, + code, +}) => { + const cart = await getCartFromStorage(cartId) + + if (!cart) { + throw new Error( + `[updateCartItem] Could not found cart with requested cart id: ${cartId}` + ) + } + + const { cart: cartWithDiscount, success } = await deleteVoucherFromCart( + cart, + code + ) + + if (success) { + await saveCart(cartWithDiscount) + } + + return cartWithDiscount +} diff --git a/packages/commerce-generic/src/services/cart/discount.ts b/packages/commerce-generic/src/services/cart/discount.ts new file mode 100644 index 0000000..59fc2e5 --- /dev/null +++ b/packages/commerce-generic/src/services/cart/discount.ts @@ -0,0 +1,87 @@ +import { Cart, Voucher, Promotion } from '@composable/types' +import { centToString, toCent } from './to-cent' + +const examplePromotion: Promotion = { + id: 'prom_1', + label: 'Black Friday 2024 - 10$', + discountAmount: '10', +} + +const vouchersAvailable: Voucher[] = [ + { code: '5$OFF', label: '5 bucks off in winter', discountAmount: '5' }, + { code: '15$OFF', label: '15 bucks off in winter', discountAmount: '15' }, +] + +export const deleteVoucherFromCart = async ( + cart: Cart, + code: string +): Promise<{ cart: Cart; success: boolean; errorMessage?: string }> => { + const success = true + const errorMessage = undefined + const updatedCart = await updateCartDiscount({ + ...cart, + vouchersApplied: [ + ...(cart.vouchersApplied?.filter((voucher) => voucher.code !== code) || + []), + ], + }) + return { + cart: updatedCart, + success, + errorMessage, + } +} + +export const addVoucherToCart = async ( + cart: Cart, + code: string +): Promise<{ cart: Cart; success: boolean; errorMessage?: string }> => { + if (cart.vouchersApplied?.some((voucher) => voucher.code === code)) { + return { + cart, + success: false, + errorMessage: 'Voucher is already applied', + } + } + const voucher = vouchersAvailable.find((voucher) => voucher.code === code) + if (!voucher) { + return { + cart, + success: false, + errorMessage: 'Voucher not found', + } + } + const success = true + const errorMessage = undefined + const updatedCart = await updateCartDiscount({ + ...cart, + vouchersApplied: [...(cart.vouchersApplied || []), voucher], + }) + return { + cart: updatedCart, + success, + errorMessage, + } +} + +export const updateCartDiscount = async (cart: Cart): Promise => { + const voucherDiscountsInCents = + cart.vouchersApplied?.reduce((sum, voucher) => { + return sum + toCent(voucher.discountAmount) + }, 0) || 0 + const totalDiscountAmountInCents = + toCent(examplePromotion.discountAmount) + voucherDiscountsInCents + const totalPrice = centToString( + toCent(cart.summary.priceBeforeDiscount) - totalDiscountAmountInCents + ) + return { + ...cart, + promotionsApplied: [{ ...examplePromotion }], + vouchersApplied: cart.vouchersApplied, + summary: { + ...cart.summary, + totalDiscountAmount: centToString(totalDiscountAmountInCents), + totalPrice, + }, + } +} diff --git a/packages/commerce-generic/src/services/cart/get-cart.ts b/packages/commerce-generic/src/services/cart/get-cart.ts index 13240f9..724e57d 100644 --- a/packages/commerce-generic/src/services/cart/get-cart.ts +++ b/packages/commerce-generic/src/services/cart/get-cart.ts @@ -1,10 +1,19 @@ import { CommerceService } from '@composable/types' import { getCart as getCartFromStorage } from '../../data/mock-storage' +import { updateCartDiscount } from './discount' export const getCart: CommerceService['getCart'] = async ({ cartId }) => { if (!cartId) { return null } - return (await getCartFromStorage(cartId)) || null + const cart = await getCartFromStorage(cartId) + + if (!cart) { + return null + } + + const cartWithDiscount = await updateCartDiscount(cart) + + return cartWithDiscount || null } diff --git a/packages/commerce-generic/src/services/cart/index.ts b/packages/commerce-generic/src/services/cart/index.ts index c00875d..1863cdd 100644 --- a/packages/commerce-generic/src/services/cart/index.ts +++ b/packages/commerce-generic/src/services/cart/index.ts @@ -3,3 +3,6 @@ export * from './create-cart' export * from './delete-cart-item' export * from './get-cart' export * from './update-cart-item' +export * from './add-voucher' +export * from './delete-voucher' +export * from './to-cent' diff --git a/packages/commerce-generic/src/services/cart/to-cent.ts b/packages/commerce-generic/src/services/cart/to-cent.ts new file mode 100644 index 0000000..1667a66 --- /dev/null +++ b/packages/commerce-generic/src/services/cart/to-cent.ts @@ -0,0 +1,14 @@ +export const toCent = (amount: string | undefined | null): number => { + if (!amount) { + return 0 + } + + return Math.round(parseFloat(amount) * 100) +} + +export const centToString = (amount: number | null | undefined) => { + if (!amount) { + return '' + } + return Number(amount / 100).toString() +} diff --git a/packages/commerce-generic/src/services/cart/update-cart-item.ts b/packages/commerce-generic/src/services/cart/update-cart-item.ts index a780aa9..2bdd80f 100644 --- a/packages/commerce-generic/src/services/cart/update-cart-item.ts +++ b/packages/commerce-generic/src/services/cart/update-cart-item.ts @@ -2,6 +2,7 @@ import { CommerceService } from '@composable/types' import { getCart, saveCart } from '../../data/mock-storage' import { calculateCartSummary } from '../../data/generate-cart-data' +import { updateCartDiscount } from './discount' export const updateCartItem: CommerceService['updateCartItem'] = async ({ cartId, @@ -28,5 +29,7 @@ export const updateCartItem: CommerceService['updateCartItem'] = async ({ cart.summary = calculateCartSummary(cart.items) - return saveCart(cart) + const cartWithDiscount = await updateCartDiscount(cart) + + return saveCart(cartWithDiscount) } diff --git a/packages/commerce-generic/src/services/checkout/create-order.ts b/packages/commerce-generic/src/services/checkout/create-order.ts index c9857ee..409800b 100644 --- a/packages/commerce-generic/src/services/checkout/create-order.ts +++ b/packages/commerce-generic/src/services/checkout/create-order.ts @@ -30,6 +30,8 @@ const generateOrderFromCart = ( created_at: Date.now(), items: cart.items, summary: cart.summary, + vouchers_applied: cart.vouchersApplied || [], + promotions_applied: cart.promotionsApplied || [], } } @@ -44,5 +46,7 @@ export const createOrder: CommerceService['createOrder'] = async ({ ) } - return saveOrder(generateOrderFromCart(cart, checkout)) + const updatedOrder = generateOrderFromCart(cart, checkout) + + return await saveOrder(updatedOrder) } diff --git a/packages/types/src/commerce/cart.ts b/packages/types/src/commerce/cart.ts index 7e7804e..11f1709 100644 --- a/packages/types/src/commerce/cart.ts +++ b/packages/types/src/commerce/cart.ts @@ -1,19 +1,31 @@ export interface Cart { id: string items: CartItem[] - couponApplied?: Coupon + promotionsApplied?: Promotion[] + vouchersApplied?: Voucher[] summary: { subtotalPrice?: string taxes?: string + totalDiscountAmount?: string + priceBeforeDiscount?: string + /** + * Order amount after applying all the discounts. + */ totalPrice?: string shipping?: string } } -interface Coupon { +export interface Promotion { id: string + label: string + discountAmount: string +} + +export interface Voucher { code: string - discount: string + label: string + discountAmount: string } export interface CartItem { @@ -22,9 +34,9 @@ export interface CartItem { type: string brand: string image: { url: string; alt: string } - name: string price: number + tax: number quantity: number sku: string slug: string diff --git a/packages/types/src/commerce/commerce-service.ts b/packages/types/src/commerce/commerce-service.ts index 872926f..61b7677 100644 --- a/packages/types/src/commerce/commerce-service.ts +++ b/packages/types/src/commerce/commerce-service.ts @@ -33,6 +33,14 @@ export interface CommerceService { quantity: number }): Promise + addVoucher(params: { cartId: string; code: string }): Promise<{ + cart: Cart + success: boolean + errorMessage?: string + }> + + deleteVoucher(params: { cartId: string; code: string }): Promise + /** * Catalog methods */ diff --git a/packages/types/src/commerce/order.ts b/packages/types/src/commerce/order.ts index 5d4f0d7..ce21546 100644 --- a/packages/types/src/commerce/order.ts +++ b/packages/types/src/commerce/order.ts @@ -1,5 +1,5 @@ import { ShippingMethod } from './checkout' -import { Cart } from './cart' +import { Cart, Promotion, Voucher } from './cart' export interface Order { id: string @@ -15,6 +15,8 @@ export interface Order { created_at: number items: Cart['items'] summary: Cart['summary'] + vouchers_applied: Voucher[] + promotions_applied: Promotion[] } export interface Address { diff --git a/packages/voucherify/index.ts b/packages/voucherify/index.ts new file mode 100644 index 0000000..6f39cd4 --- /dev/null +++ b/packages/voucherify/index.ts @@ -0,0 +1 @@ +export * from './src' diff --git a/packages/voucherify/package.json b/packages/voucherify/package.json new file mode 100644 index 0000000..5c9cf53 --- /dev/null +++ b/packages/voucherify/package.json @@ -0,0 +1,23 @@ +{ + "name": "@composable/voucherify", + "version": "0.0.0", + "main": "./index.ts", + "types": "./index.ts", + "sideEffects": "false", + "scripts": { + "build": "echo \"Build script for @composable/voucherify ...\"", + "lint": "eslint \"**/*.{js,ts,tsx}\" --max-warnings 0", + "ts": "tsc --noEmit --incremental" + }, + "dependencies": { + "@composable/types": "workspace:*", + "@voucherify/sdk": "^2.6.0" + }, + "devDependencies": { + "@types/node": "^18.6.3", + "eslint-config-custom": "workspace:*", + "ts-node": "^10.9.1", + "tsconfig": "workspace:*", + "typescript": "^4.9.5" + } +} diff --git a/packages/voucherify/src/cart-to-voucherify-order.ts b/packages/voucherify/src/cart-to-voucherify-order.ts new file mode 100644 index 0000000..1743bd2 --- /dev/null +++ b/packages/voucherify/src/cart-to-voucherify-order.ts @@ -0,0 +1,15 @@ +import { Cart } from '@composable/types' +import { OrdersCreate } from '@voucherify/sdk' +import { toCent } from './to-cent' + +export const cartToVoucherifyOrder = (cart: Cart): OrdersCreate => { + return { + amount: toCent(cart.summary.priceBeforeDiscount), + items: cart.items.map((item) => ({ + quantity: item.quantity, + product_id: item.id, + sku_id: item.sku, + price: (item.price + item.tax) * 100, + })), + } +} diff --git a/packages/voucherify/src/cart-with-discount.ts b/packages/voucherify/src/cart-with-discount.ts new file mode 100644 index 0000000..91fe71f --- /dev/null +++ b/packages/voucherify/src/cart-with-discount.ts @@ -0,0 +1,78 @@ +import { Cart, Promotion, Voucher } from '@composable/types' +import { + PromotionsValidateResponse, + StackableRedeemableResponse, + ValidationValidateStackableResponse, +} from '@voucherify/sdk' +import { centToString, toCent } from './to-cent' + +export const cartWithDiscount = ( + cart: Cart, + validationResponse: ValidationValidateStackableResponse | false, + promotionsResult: PromotionsValidateResponse | false +): Cart => { + if (!validationResponse || !validationResponse.redeemables) { + return { + ...cart, + summary: { ...cart.summary, totalDiscountAmount: undefined }, + } + } + + const promotions: Promotion[] = validationResponse.redeemables + .filter((redeemable) => redeemable.object === 'promotion_tier') + .map((redeemable) => mapRedeemableToPromotion(redeemable, promotionsResult)) + + const vouchers: Voucher[] = validationResponse.redeemables + .filter((redeemable) => redeemable.object === 'voucher') + .map(mapRedeemableToVoucher) + + const totalDiscountAmount = centToString( + validationResponse.order?.total_applied_discount_amount ?? 0 + ) + const totalPrice = centToString( + validationResponse.order?.total_amount ?? toCent(cart.summary.totalPrice) + ) + + return { + ...cart, + summary: { + ...cart.summary, + totalDiscountAmount, + totalPrice, + }, + vouchersApplied: vouchers, + promotionsApplied: promotions, + } +} + +const mapRedeemableToPromotion = ( + redeemable: StackableRedeemableResponse, + promotionsResult: PromotionsValidateResponse | false +) => ({ + id: redeemable.id, + discountAmount: centToString( + redeemable.order?.total_applied_discount_amount || + redeemable.result?.discount?.amount_off || + redeemable.result?.discount?.percent_off || + 0 + ), + label: + redeemable.object === 'promotion_tier' + ? promotionsResult + ? promotionsResult.promotions?.find( + (promotion) => promotion.id === redeemable.id + )?.banner || '' + : redeemable.id + : redeemable.id, +}) + +const mapRedeemableToVoucher = (redeemable: StackableRedeemableResponse) => ({ + code: redeemable.id, + discountAmount: centToString( + redeemable.order?.total_applied_discount_amount || + redeemable.result?.discount?.amount_off || + redeemable.result?.discount?.percent_off || + 0 + ), + label: redeemable.id, +}) diff --git a/packages/voucherify/src/discount.ts b/packages/voucherify/src/discount.ts new file mode 100644 index 0000000..ef0a633 --- /dev/null +++ b/packages/voucherify/src/discount.ts @@ -0,0 +1,90 @@ +import { Cart, Order } from '@composable/types' +import { validateCouponsAndPromotions } from './validate-discounts' +import { isRedeemableApplicable } from './is-redeemable-applicable' +import { cartWithDiscount } from './cart-with-discount' +import { voucherify } from './voucherify-config' +import { orderToVoucherifyOrder } from './order-to-voucherify-order' + +export const deleteVoucherFromCart = async ( + cart: Cart, + code: string +): Promise<{ cart: Cart; success: boolean; errorMessage?: string }> => { + const cartAfterDeletion: Cart = { + ...cart, + vouchersApplied: cart.vouchersApplied?.filter( + (voucher) => voucher.code !== code + ), + } + const updatedCart = await updateCartDiscount(cartAfterDeletion) + return { + cart: updatedCart, + success: true, + } +} + +export const updateCartDiscount = async (cart: Cart): Promise => { + const { validationResult, promotionsResult } = + await validateCouponsAndPromotions({ + cart, + voucherify, + }) + return cartWithDiscount(cart, validationResult, promotionsResult) +} + +export const addVoucherToCart = async ( + cart: Cart, + code: string +): Promise<{ cart: Cart; success: boolean; errorMessage?: string }> => { + if (cart.vouchersApplied?.some((voucher) => voucher.code === code)) { + return { + cart, + success: false, + errorMessage: 'Voucher is already applied', + } + } + const { validationResult, promotionsResult } = + await validateCouponsAndPromotions({ + cart, + code, + voucherify, + }) + + const { isApplicable, error } = isRedeemableApplicable(code, validationResult) + + if (isApplicable) { + const updatedCart = cartWithDiscount( + cart, + validationResult, + promotionsResult + ) + return { + cart: updatedCart, + success: isApplicable, + } + } + + return { + cart, + success: isApplicable, + errorMessage: error || 'This voucher is not applicable', + } +} + +export const orderPaid = async (order: Order) => { + const voucherifyOrder = orderToVoucherifyOrder(order) + + const vouchers = order.vouchers_applied?.map((voucher) => ({ + id: voucher.code, + object: 'voucher' as const, + })) + const promotions = order.promotions_applied?.map((promotion) => ({ + id: promotion.id, + object: 'promotion_tier' as const, + })) + + return await voucherify.redemptions.redeemStackable({ + redeemables: [...(vouchers || []), ...(promotions || [])], + order: voucherifyOrder, + options: { expand: ['order'] }, + }) +} diff --git a/packages/voucherify/src/get-redeemables-for-validation.ts b/packages/voucherify/src/get-redeemables-for-validation.ts new file mode 100644 index 0000000..4edb963 --- /dev/null +++ b/packages/voucherify/src/get-redeemables-for-validation.ts @@ -0,0 +1,15 @@ +import { PromotionsValidateResponse } from '@voucherify/sdk' + +export const getRedeemablesForValidation = (codes: string[]) => + codes.map((code) => ({ + id: code, + object: 'voucher' as const, + })) + +export const getRedeemablesForValidationFromPromotions = ( + promotionResult: PromotionsValidateResponse +) => + promotionResult.promotions?.map((promotion) => ({ + id: promotion.id, + object: 'promotion_tier' as const, + })) || [] diff --git a/packages/voucherify/src/index.ts b/packages/voucherify/src/index.ts new file mode 100644 index 0000000..5e8a3b0 --- /dev/null +++ b/packages/voucherify/src/index.ts @@ -0,0 +1 @@ +export * from './discount' diff --git a/packages/voucherify/src/is-redeemable-applicable.ts b/packages/voucherify/src/is-redeemable-applicable.ts new file mode 100644 index 0000000..a61eb95 --- /dev/null +++ b/packages/voucherify/src/is-redeemable-applicable.ts @@ -0,0 +1,27 @@ +import { ValidateStackableResult } from './validate-discounts' + +export const isRedeemableApplicable = ( + coupon: string, + validationResult: ValidateStackableResult +): { isApplicable: boolean; error: undefined | string } => { + let error + const addedRedeemable = + validationResult && validationResult.redeemables + ? [ + ...validationResult.redeemables, + ...(validationResult?.inapplicable_redeemables || []), + ]?.find((redeemable) => redeemable.id === coupon) + : false + + const isApplicable = addedRedeemable + ? addedRedeemable.status === 'APPLICABLE' + : false + + if (!isApplicable) { + error = addedRedeemable + ? addedRedeemable.result?.error?.message + : 'Redeemable not found in response from Voucherify' + } + + return { isApplicable, error } +} diff --git a/packages/voucherify/src/order-to-voucherify-order.ts b/packages/voucherify/src/order-to-voucherify-order.ts new file mode 100644 index 0000000..6553fd3 --- /dev/null +++ b/packages/voucherify/src/order-to-voucherify-order.ts @@ -0,0 +1,15 @@ +import { Order } from '@composable/types' +import { OrdersCreate } from '@voucherify/sdk' +import { toCent } from './to-cent' + +export const orderToVoucherifyOrder = (order: Order): OrdersCreate => { + return { + amount: toCent(order.summary.priceBeforeDiscount), + items: order.items.map((item) => ({ + quantity: item.quantity, + product_id: item.id, + sku_id: item.sku, + price: (item.price + item.tax) * 100, + })), + } +} diff --git a/packages/voucherify/src/to-cent.ts b/packages/voucherify/src/to-cent.ts new file mode 100644 index 0000000..1667a66 --- /dev/null +++ b/packages/voucherify/src/to-cent.ts @@ -0,0 +1,14 @@ +export const toCent = (amount: string | undefined | null): number => { + if (!amount) { + return 0 + } + + return Math.round(parseFloat(amount) * 100) +} + +export const centToString = (amount: number | null | undefined) => { + if (!amount) { + return '' + } + return Number(amount / 100).toString() +} diff --git a/packages/voucherify/src/validate-discounts.ts b/packages/voucherify/src/validate-discounts.ts new file mode 100644 index 0000000..26bc213 --- /dev/null +++ b/packages/voucherify/src/validate-discounts.ts @@ -0,0 +1,57 @@ +import { Cart } from '@composable/types' +import { + PromotionsValidateResponse, + StackableRedeemableResponse, + ValidationValidateStackableResponse, + VoucherifyServerSide, +} from '@voucherify/sdk' +import { + getRedeemablesForValidation, + getRedeemablesForValidationFromPromotions, +} from './get-redeemables-for-validation' +import { cartToVoucherifyOrder } from './cart-to-voucherify-order' + +type ValidateDiscountsParam = { + cart: Cart + code?: string + voucherify: ReturnType +} + +export type ValidateCouponsAndPromotionsResponse = { + promotionsResult: PromotionsValidateResponse + validationResult: ValidateStackableResult +} + +export type ValidateStackableResult = + | false + | (ValidationValidateStackableResponse & { + inapplicable_redeemables?: StackableRedeemableResponse[] + }) + +export const validateCouponsAndPromotions = async ( + params: ValidateDiscountsParam +): Promise => { + const { cart, code, voucherify } = params + + const appliedCodes = + cart.vouchersApplied?.map((voucher) => voucher.code) || [] + + const order = cartToVoucherifyOrder(cart) + const codes = code ? [...appliedCodes, code] : appliedCodes + + const promotionsResult = await voucherify.promotions.validate({ order }) + if (!codes.length && !promotionsResult.promotions?.length) { + return { promotionsResult, validationResult: false } + } + + const validationResult = await voucherify.validations.validateStackable({ + redeemables: [ + ...getRedeemablesForValidation(codes), + ...getRedeemablesForValidationFromPromotions(promotionsResult), + ], + order, + options: { expand: ['order'] }, + }) + + return { promotionsResult, validationResult } +} diff --git a/packages/voucherify/src/voucherify-config.ts b/packages/voucherify/src/voucherify-config.ts new file mode 100644 index 0000000..f67b512 --- /dev/null +++ b/packages/voucherify/src/voucherify-config.ts @@ -0,0 +1,17 @@ +import { VoucherifyServerSide } from '@voucherify/sdk' + +if ( + !process.env.VOUCHERIFY_APPLICATION_ID || + !process.env.VOUCHERIFY_SECRET_KEY || + !process.env.VOUCHERIFY_API_URL +) { + throw new Error('[voucherify] Missing configuration') +} + +export const voucherify = VoucherifyServerSide({ + applicationId: process.env.VOUCHERIFY_APPLICATION_ID, + secretKey: process.env.VOUCHERIFY_SECRET_KEY, + exposeErrorCause: true, + apiUrl: process.env.VOUCHERIFY_API_URL, + channel: 'ComposableUI', +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2bb9d75..89b369f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -208,6 +208,9 @@ importers: '@composable/types': specifier: workspace:* version: link:../types + '@composable/voucherify': + specifier: workspace:* + version: link:../voucherify '@types/node-persist': specifier: ^3.1.5 version: 3.1.5 @@ -238,7 +241,7 @@ importers: version: 8.8.0(eslint@7.32.0) eslint-config-turbo: specifier: latest - version: 1.10.16(eslint@7.32.0) + version: 1.11.0(eslint@7.32.0) eslint-plugin-react: specifier: 7.28.0 version: 7.28.0(eslint@7.32.0) @@ -343,6 +346,31 @@ importers: specifier: ^4.5.5 version: 4.9.5 + packages/voucherify: + dependencies: + '@composable/types': + specifier: workspace:* + version: link:../types + '@voucherify/sdk': + specifier: ^2.6.0 + version: 2.6.0 + devDependencies: + '@types/node': + specifier: ^18.6.3 + version: 18.15.11 + eslint-config-custom: + specifier: workspace:* + version: link:../eslint-config-custom + ts-node: + specifier: ^10.9.1 + version: 10.9.1(@swc/core@1.3.42)(@types/node@18.15.11)(typescript@4.9.5) + tsconfig: + specifier: workspace:* + version: link:../tsconfig + typescript: + specifier: ^4.9.5 + version: 4.9.5 + scripts: dependencies: algoliasearch: @@ -355,6 +383,9 @@ importers: '@types/node': specifier: ^18.6.3 version: 18.15.11 + '@voucherify/sdk': + specifier: ^2.5.0 + version: 2.6.0 ts-node: specifier: ^10.9.1 version: 10.9.1(@swc/core@1.3.42)(@types/node@18.15.11)(typescript@4.9.5) @@ -6622,6 +6653,15 @@ packages: resolution: {integrity: sha512-haGBC8noyA5BfjCRXRH+VIkHCDVW5iD5UX24P2nOdilwUxI4qWsattS/co8QBGq64XsNLRAMdM5pQUE3zxkF9Q==} dev: true + /@voucherify/sdk@2.6.0: + resolution: {integrity: sha512-im9Z6sVtN2mcJ9ReVXgG7Hp4CcaDxwg+WOlsSg/m4Lb3/QLKqGfrFUjdS30O4jXIyy8OVf4EFcYXf0DOFd3FLg==} + dependencies: + axios: 0.27.2 + form-data: 4.0.0 + qs: 6.9.7 + transitivePeerDependencies: + - debug + /@webassemblyjs/ast@1.11.1: resolution: {integrity: sha512-ukBh14qFLjxTQNTXocdyksN5QdM28S1CxHt2rdskFyL+xFV7VremuBLVbmCePj+URalXBENx/9Lm7lnhihtCSw==} dependencies: @@ -7396,6 +7436,14 @@ packages: - debug dev: false + /axios@0.27.2: + resolution: {integrity: sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ==} + dependencies: + follow-redirects: 1.15.2 + form-data: 4.0.0 + transitivePeerDependencies: + - debug + /axobject-query@3.1.1: resolution: {integrity: sha512-goKlv8DZrK9hUh975fnHzhNIO4jUnFCfv/dszV5VwUGDFjI6vQ2VwoyjYjYNEbBE8AH87TduWP5uyDR1D+Iteg==} dependencies: @@ -9459,13 +9507,13 @@ packages: eslint: 7.32.0 dev: false - /eslint-config-turbo@1.10.16(eslint@7.32.0): - resolution: {integrity: sha512-O3NQI72bQHV7FvSC6lWj66EGx8drJJjuT1kuInn6nbMLOHdMBhSUX/8uhTAlHRQdlxZk2j9HtgFCIzSc93w42g==} + /eslint-config-turbo@1.11.0(eslint@7.32.0): + resolution: {integrity: sha512-PBiDoO1ZRHBXoydfn/qYazJTwmoRNXdsf3CBg6j7BMVvuAPa39e7ao6KccsDplyLgV2jIjeRtra/q1CUaWs2kg==} peerDependencies: eslint: '>6.6.0' dependencies: eslint: 7.32.0 - eslint-plugin-turbo: 1.10.16(eslint@7.32.0) + eslint-plugin-turbo: 1.11.0(eslint@7.32.0) dev: false /eslint-import-resolver-node@0.3.7: @@ -9640,8 +9688,8 @@ packages: string.prototype.matchall: 4.0.8 dev: false - /eslint-plugin-turbo@1.10.16(eslint@7.32.0): - resolution: {integrity: sha512-ZjrR88MTN64PNGufSEcM0tf+V1xFYVbeiMeuIqr0aiABGomxFLo4DBkQ7WI4WzkZtWQSIA2sP+yxqSboEfL9MQ==} + /eslint-plugin-turbo@1.11.0(eslint@7.32.0): + resolution: {integrity: sha512-u3GeDFuKUMcQOPi5euJIAivTvJBPMZL62LVrNc4uGksGMYekl7Dl3yGcevcUpebm7XDValyWcw1iHZjUm3DfCg==} peerDependencies: eslint: '>6.6.0' dependencies: @@ -10287,7 +10335,6 @@ packages: asynckit: 0.4.0 combined-stream: 1.0.8 mime-types: 2.1.35 - dev: true /forwarded@0.2.0: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} @@ -14563,7 +14610,6 @@ packages: /qs@6.9.7: resolution: {integrity: sha512-IhMFgUmuNpyRfxA90umL7ByLlgRXu6tIfKPpF5TmcfRLlLCckfP/g3IQmju6jjpu+Hh8rA+2p6A27ZSPOOHdKw==} engines: {node: '>=0.6'} - dev: false /querystring-es3@0.2.1: resolution: {integrity: sha512-773xhDQnZBMFobEiztv8LIl70ch5MSF/jUQVlhwFyBILqq96anmoctVIYz+ZRp0qbCKATTn6ev02M3r7Ga5vqA==} diff --git a/scripts/.env.example b/scripts/.env.example index 2e67426..8fea423 100644 --- a/scripts/.env.example +++ b/scripts/.env.example @@ -1,3 +1,7 @@ ALGOLIA_APP_ID= ALGOLIA_API_ADMIN_KEY= -ALGOLIA_INDEX_NAME=products \ No newline at end of file +ALGOLIA_INDEX_NAME=products + +VOUCHERIFY_API_URL= +VOUCHERIFY_APPLICATION_ID= +VOUCHERIFY_SECRET_KEY= \ No newline at end of file diff --git a/scripts/package.json b/scripts/package.json index 3c23f51..ac638b9 100644 --- a/scripts/package.json +++ b/scripts/package.json @@ -3,12 +3,16 @@ "version": "0.0.0", "private": true, "scripts": { - "algolia-setup": "ts-node src/algolia-setup/index.ts" + "algolia-setup": "ts-node src/algolia-setup/index.ts", + "voucherify-preconfigure": "ts-node src/voucherify-setup/index.ts", + "voucherify-activate": "ts-node src/voucherify-setup/activate.ts", + "voucherify-deactivate": "ts-node src/voucherify-setup/deactivate.ts" }, "devDependencies": { "@types/node": "^18.6.3", "ts-node": "^10.9.1", - "typescript": "^4.9.5" + "typescript": "^4.9.5", + "@voucherify/sdk": "^2.5.0" }, "dependencies": { "algoliasearch": "^4.14.3", diff --git a/scripts/src/voucherify-setup/activate.ts b/scripts/src/voucherify-setup/activate.ts new file mode 100644 index 0000000..08eab17 --- /dev/null +++ b/scripts/src/voucherify-setup/activate.ts @@ -0,0 +1,44 @@ +import { + CREATE_ORDER_FILE_PATH, + COMMERCE_GENERIC_FILE_PATHS, + IMPORT_CONTENT, + DEPENDENCY_NAME, + DEPENDENCY_VERSION, + DEFAULT_IMPORT, + UPDATED_ORDER_LINE, + VOUCHERIFY_IMPORT, + UPDATE_PAID_ORDER_CONTENT, + PACKAGE_JSON_PATH, +} from './constants' +import { + replaceInFiles, + addDependencyToPackage, + replaceInFile, +} from './source-code-updater' + +const activate = async () => { + // Update commerce generic files - replace classic usage with Voucherify usage + await replaceInFiles( + COMMERCE_GENERIC_FILE_PATHS, + DEFAULT_IMPORT, + VOUCHERIFY_IMPORT + ) + + // Update createOrder file + await replaceInFile(CREATE_ORDER_FILE_PATH, '', IMPORT_CONTENT) + await replaceInFile( + CREATE_ORDER_FILE_PATH, + UPDATED_ORDER_LINE, + UPDATED_ORDER_LINE + UPDATE_PAID_ORDER_CONTENT + ) + + // Add Voucherify dependency to package.json + await addDependencyToPackage( + PACKAGE_JSON_PATH, + DEPENDENCY_NAME, + DEPENDENCY_VERSION + ) +} +;(async () => { + await activate() +})() diff --git a/scripts/src/voucherify-setup/config.ts b/scripts/src/voucherify-setup/config.ts new file mode 100644 index 0000000..592febd --- /dev/null +++ b/scripts/src/voucherify-setup/config.ts @@ -0,0 +1,6 @@ +import { config } from 'dotenv' +config() + +export const VOUCHERIFY_API_URL = process.env.VOUCHERIFY_API_URL +export const VOUCHERIFY_APPLICATION_ID = process.env.VOUCHERIFY_APPLICATION_ID +export const VOUCHERIFY_SECRET_KEY = process.env.VOUCHERIFY_SECRET_KEY diff --git a/scripts/src/voucherify-setup/constants.ts b/scripts/src/voucherify-setup/constants.ts new file mode 100644 index 0000000..ce19be9 --- /dev/null +++ b/scripts/src/voucherify-setup/constants.ts @@ -0,0 +1,42 @@ +const PACKAGE_JSON_PATH = '../../../packages/commerce-generic/package.json' +const DEPENDENCY_NAME = '@composable/voucherify' +const DEPENDENCY_VERSION = 'workspace:*' + +const COMMERCE_GENERIC_FILE_PATHS = [ + '../../../packages/commerce-generic/src/services/cart/add-cart-item.ts', + '../../../packages/commerce-generic/src/services/cart/delete-cart-item.ts', + '../../../packages/commerce-generic/src/services/cart/discount.ts', + '../../../packages/commerce-generic/src/services/cart/update-cart-item.ts', + '../../../packages/commerce-generic/src/services/cart/get-cart.ts', + '../../../packages/commerce-generic/src/services/cart/delete-voucher.ts', + '../../../packages/commerce-generic/src/services/cart/add-voucher.ts', +] + +const DEFAULT_IMPORT = "from './discount'" +const VOUCHERIFY_IMPORT = "from '@composable/voucherify'" + +const CREATE_ORDER_FILE_PATH = + '../../../packages/commerce-generic/src/services/checkout/create-order.ts' +const IMPORT_CONTENT = "import { orderPaid } from '@composable/voucherify'\n" +const NOTE_CONTENT = `/* Redemptions using Voucherify should only be performed when we receive information that the payment was successful. + In this situation, the ‘payment’ property is always set as 'unpaid' (in 'generateOrderFromCart'), + so to simulate the correct behavior, the ‘payment’ value was changed here to 'paid' and the ‘orderPaid’ function was called to trigger the redemptions process.*/` +const UPDATE_PAID_ORDER_CONTENT = ` + ${NOTE_CONTENT} + updatedOrder.payment = 'paid' + await orderPaid(updatedOrder)` +const UPDATED_ORDER_LINE = + 'const updatedOrder = generateOrderFromCart(cart, checkout)' + +export { + PACKAGE_JSON_PATH, + DEPENDENCY_NAME, + DEPENDENCY_VERSION, + COMMERCE_GENERIC_FILE_PATHS, + DEFAULT_IMPORT, + VOUCHERIFY_IMPORT, + CREATE_ORDER_FILE_PATH, + IMPORT_CONTENT, + UPDATE_PAID_ORDER_CONTENT, + UPDATED_ORDER_LINE, +} diff --git a/scripts/src/voucherify-setup/deactivate.ts b/scripts/src/voucherify-setup/deactivate.ts new file mode 100644 index 0000000..84bdd14 --- /dev/null +++ b/scripts/src/voucherify-setup/deactivate.ts @@ -0,0 +1,34 @@ +import { + CREATE_ORDER_FILE_PATH, + COMMERCE_GENERIC_FILE_PATHS, + IMPORT_CONTENT, + DEPENDENCY_NAME, + DEFAULT_IMPORT, + VOUCHERIFY_IMPORT, + UPDATE_PAID_ORDER_CONTENT, + PACKAGE_JSON_PATH, +} from './constants' +import { + replaceInFiles, + removeDependencyFromPackage, + replaceInFile, +} from './source-code-updater' + +const deactivate = async () => { + // Update commerce generic files Replace Voucherify usage with classic usage + await replaceInFiles( + COMMERCE_GENERIC_FILE_PATHS, + VOUCHERIFY_IMPORT, + DEFAULT_IMPORT + ) + + // Update createOrder file - remove Voucherify implementation from create order + await replaceInFile(CREATE_ORDER_FILE_PATH, IMPORT_CONTENT, '') + await replaceInFile(CREATE_ORDER_FILE_PATH, UPDATE_PAID_ORDER_CONTENT, '') + + // Remove Voucherify dependency from package.json + await removeDependencyFromPackage(PACKAGE_JSON_PATH, DEPENDENCY_NAME) +} +;(async () => { + await deactivate() +})() diff --git a/scripts/src/voucherify-setup/index.ts b/scripts/src/voucherify-setup/index.ts new file mode 100644 index 0000000..a48dcbb --- /dev/null +++ b/scripts/src/voucherify-setup/index.ts @@ -0,0 +1,62 @@ +import { voucherifyClient } from './voucherify' +import { + VOUCHERIFY_API_URL, + VOUCHERIFY_APPLICATION_ID, + VOUCHERIFY_SECRET_KEY, +} from './config' + +import products from '../../../packages/commerce-generic/src/data/products.json' + +const VOUCHERIFY_KEYS = [ + VOUCHERIFY_API_URL, + VOUCHERIFY_APPLICATION_ID, + VOUCHERIFY_SECRET_KEY, +] +const voucherifyKeysMissing = VOUCHERIFY_KEYS.some((key) => !key) + +const voucherifySetup = async () => { + console.log('Starting setting up Voucherify!') + try { + if (voucherifyKeysMissing) { + console.error( + 'You are missing some Voucherify keys in your .env file.', + `You must set the following:VOUCHERIFY_API_URL, VOUCHERIFY_APPLICATION_ID, VOUCHERIFY_SECRET_KEY.` + ) + throw new Error('VOUCHERIFY_MISSING_KEYS') + } + + for (const product of products) { + const createdProduct = await voucherifyClient.products.create({ + name: product.name, + source_id: product.id, + price: product.price, + image_url: product.images[0].url, + metadata: { + brand: product.brand, + category: product.category, + description: product.description, + materialAndCare: product.materialAndCare, + slug: product.slug, + type: product.type, + }, + }) + const createdSKU = await voucherifyClient.products.createSku( + createdProduct.id, + { + sku: product.sku, + } + ) + + console.log(`Created product ${product.id} and sku ${createdSKU.id}`) + } + + console.log('Finished setting up Voucherify!') + } catch (err) { + console.error(err.message) + throw err + } +} + +;(async () => { + await voucherifySetup() +})() diff --git a/scripts/src/voucherify-setup/source-code-updater.ts b/scripts/src/voucherify-setup/source-code-updater.ts new file mode 100644 index 0000000..a02ab87 --- /dev/null +++ b/scripts/src/voucherify-setup/source-code-updater.ts @@ -0,0 +1,96 @@ +import * as fs from 'fs/promises' +import * as path from 'path' + +export async function replaceInFile( + filePath: string, + searchPhrase: string, + replacePhrase: string +): Promise { + try { + // Read the content of the file + const fullPath = path.join(__dirname, filePath) // assuming file paths are relative to the script's directory + const data = await fs.readFile(fullPath, 'utf8') + + // Replace the search phrase with the replacement phrase + const updatedContent = data.replace(searchPhrase, replacePhrase) + + // Write the updated content back to the file + await fs.writeFile(fullPath, updatedContent, 'utf8') + + console.log(`Replacement complete in ${fullPath}`) + } catch (error) { + const errorMessage = `Error replacing content in ${filePath}: ${error.message}` + throw new Error(errorMessage) + } +} + +export async function replaceInFiles( + filePaths: string[], + searchPhrase: string, + replacePhrase: string +): Promise { + try { + for (const filePath of filePaths) { + await replaceInFile(filePath, searchPhrase, replacePhrase) + } + } catch (err) { + console.error(`The process of replacement has stopped due to error.`) + } +} + +export async function addDependencyToPackage( + packageJsonPath: string, + newDependency: string, + newDependencyVersion: string +): Promise { + try { + // Load the package.json file + const fullPath = path.join(__dirname, packageJsonPath) + const packageJsonContent = await fs.readFile(fullPath, 'utf8') + const packageJson = JSON.parse(packageJsonContent) + + // Add the new dependency + if (!packageJson.dependencies) { + packageJson.dependencies = {} + } + packageJson.dependencies[newDependency] = newDependencyVersion // Replace 'newDependency' with the actual package name + + // Save the updated package.json file + await fs.writeFile(fullPath, JSON.stringify(packageJson, null, 2), 'utf8') + + console.log(`Dependency added to package.json: ${newDependency}`) + } catch (error) { + const errorMessage = `Error adding dependency to ${packageJsonPath}: ${error.message}` + throw new Error(errorMessage) + } +} + +export async function removeDependencyFromPackage( + packageJsonPath: string, + dependency: string +): Promise { + try { + // Load the package.json file + const fullPath = path.join(__dirname, packageJsonPath) + const packageJsonContent = await fs.readFile(fullPath, 'utf8') + const packageJson = JSON.parse(packageJsonContent) + + // Add the new dependency + if (!packageJson.dependencies) { + packageJson.dependencies = {} + } + packageJson.dependencies = Object.fromEntries( + Object.entries(packageJson.dependencies).filter( + ([key, val]) => key !== dependency + ) + ) + + // Save the updated package.json file + await fs.writeFile(fullPath, JSON.stringify(packageJson, null, 2), 'utf8') + + console.log(`Dependency removed from package.json: ${dependency}`) + } catch (error) { + const errorMessage = `Error removing dependency from ${packageJsonPath}: ${error.message}` + throw new Error(errorMessage) + } +} diff --git a/scripts/src/voucherify-setup/voucherify.ts b/scripts/src/voucherify-setup/voucherify.ts new file mode 100644 index 0000000..c0bb3aa --- /dev/null +++ b/scripts/src/voucherify-setup/voucherify.ts @@ -0,0 +1,14 @@ +import { VoucherifyServerSide } from '@voucherify/sdk' +import { + VOUCHERIFY_API_URL, + VOUCHERIFY_APPLICATION_ID, + VOUCHERIFY_SECRET_KEY, +} from './config' + +export const voucherifyClient = VoucherifyServerSide({ + applicationId: VOUCHERIFY_APPLICATION_ID, + secretKey: VOUCHERIFY_SECRET_KEY, + exposeErrorCause: true, + apiUrl: VOUCHERIFY_API_URL, + channel: 'ComposableUI', +})