diff --git a/composable-ui/next.config.js b/composable-ui/next.config.js index 3477a86..6234abd 100644 --- a/composable-ui/next.config.js +++ b/composable-ui/next.config.js @@ -34,6 +34,7 @@ module.exports = () => { transpilePackages: [ '@composable/cms-generic', '@composable/commerce-generic', + '@composable/voucherify', '@composable/stripe', '@composable/types', '@composable/ui', diff --git a/composable-ui/package.json b/composable-ui/package.json index 0f48014..a4bfe26 100644 --- a/composable-ui/package.json +++ b/composable-ui/package.json @@ -21,6 +21,7 @@ "@chakra-ui/theme-tools": "^2.0.16", "@composable/cms-generic": "workspace:*", "@composable/commerce-generic": "workspace:*", + "@composable/voucherify": "workspace:*", "@composable/stripe": "workspace:*", "@composable/types": "workspace:*", "@composable/ui": "workspace:*", diff --git a/composable-ui/src/components/cart/__data__/cart-data.ts b/composable-ui/src/components/cart/__data__/cart-data.ts index a8e7a36..4acd763 100644 --- a/composable-ui/src/components/cart/__data__/cart-data.ts +++ b/composable-ui/src/components/cart/__data__/cart-data.ts @@ -1,10 +1,14 @@ +import { CartItemWithDiscounts } from '@composable/types' import { CartData } from '../../../hooks' export const cartData: CartData = { id: '7a6dd462-24dc-11ed-861d-0242ac120002', + cartType: 'CartWithDiscounts', + redeemables: [], items: [ { id: '1', + cartItemType: 'CartItemWithDiscounts', category: 'Accessories', type: 'Bag', name: 'Venture Daypack', @@ -17,12 +21,18 @@ export const cartData: CartData = { sku: 'SKU-A1-2345', slug: 'venture-daypack', quantity: 1, + discounts: { + subtotalAmount: '', + }, }, - ], + ] as CartItemWithDiscounts[], summary: { taxes: '2.45', totalPrice: '35.00', shipping: 'Free', + discountAmount: '0', + totalDiscountAmount: '0', + grandPrice: '0', }, isLoading: false, isEmpty: false, 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..a465755 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,13 +27,13 @@ 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.grandPrice' })} 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..a90dcbb 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 @@ -47,19 +47,43 @@ export const CartDrawerSummary = () => { {cart.summary?.totalPrice && ( + + + + + + )} + + {cart.summary?.totalDiscountAmount && ( + + + + + + )} + + {cart.summary?.grandPrice && ( <> - - {intl.formatMessage({ id: 'cart.summary.estimatedTotal' })} - + {intl.formatMessage({ id: 'cart.summary.grandPrice' })} - + diff --git a/composable-ui/src/components/cart/cart-drawer/cart-drawer.tsx b/composable-ui/src/components/cart/cart-drawer/cart-drawer.tsx index 4d9984d..9318c14 100644 --- a/composable-ui/src/components/cart/cart-drawer/cart-drawer.tsx +++ b/composable-ui/src/components/cart/cart-drawer/cart-drawer.tsx @@ -22,6 +22,8 @@ import { CartDrawerFooter } from './cart-drawer-footer' import { CartDrawerSummary } from './cart-drawer-summary' import { CartDrawerEmptyState } from './cart-drawer-empty-state' import { HorizontalProductCard } from '@composable/ui' +import { CouponForm } from '../../forms/coupon-form' +import { CartPromotions } from '../cart-promotions' export const CartDrawer = () => { const intl = useIntl() @@ -47,6 +49,11 @@ export const CartDrawer = () => { style: 'currency', } + const promotions = + cart.redeemables?.filter( + (redeemable) => redeemable.object === 'promotion_tier' + ) || [] + useEffect(() => { router.events.on('routeChangeStart', cartDrawer.onClose) return () => { @@ -148,6 +155,14 @@ export const CartDrawer = () => { ) })} + {promotions.length > 0 && ( + + + + )} + + + )} 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..d97b17a --- /dev/null +++ b/composable-ui/src/components/cart/cart-promotions.tsx @@ -0,0 +1,48 @@ +import { useIntl } from 'react-intl' +import { Redeemable } 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: Redeemable[] +} + +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..20d381e 100644 --- a/composable-ui/src/components/cart/cart-summary.tsx +++ b/composable-ui/src/components/cart/cart-summary.tsx @@ -2,6 +2,7 @@ import { useIntl } from 'react-intl' import { useRouter } from 'next/router' import { CartData, useCart } from 'hooks' import { Price } from 'components/price' +import { CouponForm } from 'components/forms/coupon-form' import { Box, Button, @@ -12,6 +13,7 @@ import { Text, } from '@chakra-ui/react' import { CartSummaryItem } from '.' +import { CartPromotions } from './cart-promotions' interface CartSummaryProps { rootProps?: StackProps @@ -29,12 +31,17 @@ export const CartSummary = ({ const intl = useIntl() const _cartData = cartData ?? cart + const promotions = + _cartData.redeemables?.filter( + (redeemable) => redeemable.object === 'promotion_tier' + ) || [] + return ( @@ -90,6 +97,37 @@ export const CartSummary = ({ )} + + + {_cartData.summary?.totalDiscountAmount && ( + + + + )} + + {_cartData.summary?.grandPrice && ( + <> + + + + {intl.formatMessage({ id: 'cart.summary.grandPrice' })} + + + + + + + )} diff --git a/composable-ui/src/components/cart/cart-total.tsx b/composable-ui/src/components/cart/cart-total.tsx index d95d9ab..093cea8 100644 --- a/composable-ui/src/components/cart/cart-total.tsx +++ b/composable-ui/src/components/cart/cart-total.tsx @@ -1,19 +1,15 @@ import { useIntl } from 'react-intl' -import { useRouter } from 'next/router' -import { CartData, useCart } from 'hooks' +import { 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 - cartData?: CartData } -export const CartTotal = ({ cartData, rootProps }: CartTotalProps) => { - const router = useRouter() +export const CartTotal = ({ rootProps }: CartTotalProps) => { const { cart } = useCart() const intl = useIntl() - const _cartData = cartData ?? cart return ( <> @@ -23,20 +19,9 @@ export const CartTotal = ({ cartData, rootProps }: CartTotalProps) => { mb={'1rem'} {...rootProps} > - {intl.formatMessage({ id: 'cart.summary.estimatedTotal' })} - + {intl.formatMessage({ id: 'cart.summary.orderTotal' })} + - ) } diff --git a/composable-ui/src/components/checkout/bag-summary-mobile.tsx b/composable-ui/src/components/checkout/bag-summary-mobile.tsx index 8630b6c..03ef481 100644 --- a/composable-ui/src/components/checkout/bag-summary-mobile.tsx +++ b/composable-ui/src/components/checkout/bag-summary-mobile.tsx @@ -11,7 +11,7 @@ import { import { FormatNumberOptions, useIntl } from 'react-intl' import { Section } from '@composable/ui' import { CartEmptyState, CartLoadingState } from '../cart' -import { useCart, useCheckout } from '../../hooks' +import { useCart } from '../../hooks' import { APP_CONFIG } from '../../utils/constants' import { OrderTotals } from './order-totals' import { ProductsList } from './products-list' @@ -23,8 +23,6 @@ interface BagSummaryMobileProps { export const BagSummaryMobile = ({ accordionProps }: BagSummaryMobileProps) => { const intl = useIntl() const { cart } = useCart() - const { cartSnapshot } = useCheckout() - const _cart = cart.isEmpty ? cartSnapshot : cart const currencyFormatConfig: FormatNumberOptions = { currency: APP_CONFIG.CURRENCY_CODE, @@ -38,25 +36,25 @@ export const BagSummaryMobile = ({ accordionProps }: BagSummaryMobileProps) => { {intl.formatMessage( { - id: _cart?.quantity + id: cart?.quantity ? 'cart.drawer.titleCount' : 'cart.drawer.title', }, - { count: _cart?.quantity } + { count: cart?.quantity } )}{' '} {intl.formatNumber( - parseFloat(_cart?.summary?.totalPrice || '0'), + parseFloat(cart?.summary?.grandPrice || '0'), currencyFormatConfig )} - {_cart?.isLoading ? ( + {cart?.isLoading ? ( - ) : _cart?.isEmpty ? ( + ) : cart?.isEmpty ? ( ) : (
{ }} > - + { id: 'cart.summary.shipping.free', })} tax={intl.formatNumber( - parseFloat(_cart?.summary?.taxes ?? '0'), + parseFloat(cart?.summary?.taxes ?? '0'), currencyFormatConfig )} totalTitle={intl.formatMessage({ id: 'checkout.orderSummary.orderTotal', })} total={intl.formatNumber( - parseFloat(_cart?.summary?.totalPrice ?? '0'), + parseFloat(cart?.summary?.totalPrice ?? '0'), + currencyFormatConfig + )} + totalDiscountAmountTitle={intl.formatMessage({ + id: 'cart.summary.totalDiscountAmount', + })} + totalDiscountAmount={intl.formatNumber( + parseFloat(cart?.summary?.discountAmount ?? '0'), + currencyFormatConfig + )} + grandPriceTitle={intl.formatMessage({ + id: 'cart.summary.grandPrice', + })} + grandPrice={intl.formatNumber( + parseFloat(cart?.summary?.grandPrice ?? '0'), currencyFormatConfig )} /> diff --git a/composable-ui/src/components/checkout/checkout-success-page.tsx b/composable-ui/src/components/checkout/checkout-success-page.tsx index ba7d5ad..ac7fae5 100644 --- a/composable-ui/src/components/checkout/checkout-success-page.tsx +++ b/composable-ui/src/components/checkout/checkout-success-page.tsx @@ -156,6 +156,20 @@ export const CheckoutSuccessPage = ({ style: 'currency', } )} + totalDiscountAmount={intl.formatNumber( + parseFloat(order?.summary.totalDiscountAmount || '0'), + { + currency: APP_CONFIG.CURRENCY_CODE, + style: 'currency', + } + )} + grandPrice={intl.formatNumber( + parseFloat(order?.summary.grandPrice || '0'), + { + currency: APP_CONFIG.CURRENCY_CODE, + style: 'currency', + } + )} /> diff --git a/composable-ui/src/components/checkout/order-summary.tsx b/composable-ui/src/components/checkout/order-summary.tsx index 77545b1..70b1690 100644 --- a/composable-ui/src/components/checkout/order-summary.tsx +++ b/composable-ui/src/components/checkout/order-summary.tsx @@ -15,6 +15,8 @@ import { FormatNumberOptions, useIntl } from 'react-intl' import { APP_CONFIG } from '../../utils/constants' import { OrderTotals } from './order-totals' import { ProductsList } from './products-list' +import { CartPromotions } from '../cart/cart-promotions' +import { CouponForm } from '../forms/coupon-form' export interface CheckoutSidebarProps { itemsBoxProps?: AccordionProps @@ -35,6 +37,11 @@ export const OrderSummary = ({ style: 'currency', } + const promotions = + cart.redeemables?.filter( + (redeemable) => redeemable.object === 'promotion_tier' + ) || [] + const numItems = _cart.items?.reduce((acc, cur) => acc + cur.quantity, 0) return ( @@ -87,10 +94,18 @@ export const OrderSummary = ({ - + {promotions.length > 0 && ( + + + + )} + + + + diff --git a/composable-ui/src/components/checkout/order-totals.tsx b/composable-ui/src/components/checkout/order-totals.tsx index 18bae1f..08b213b 100644 --- a/composable-ui/src/components/checkout/order-totals.tsx +++ b/composable-ui/src/components/checkout/order-totals.tsx @@ -9,6 +9,10 @@ interface OrderTotalsProps { discount?: string totalTitle?: string total: string + totalDiscountAmountTitle?: string + totalDiscountAmount?: string + grandPriceTitle?: string + grandPrice?: string } export const OrderTotals = ({ @@ -16,19 +20,17 @@ export const OrderTotals = ({ deliveryTitle, delivery, tax, - discount, totalTitle, total, + totalDiscountAmountTitle, + totalDiscountAmount, + grandPriceTitle, + grandPrice, }: OrderTotalsProps) => { const intl = useIntl() return ( - } - px={{ base: 4, md: 'none' }} - > + } px={{ base: 4, md: 'none' }}> - {discount && ( + + + {totalDiscountAmount && ( + )} + + {grandPrice && ( + )} - ) } @@ -79,7 +87,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..c33a37c 100644 --- a/composable-ui/src/components/checkout/success/success-order-summary.tsx +++ b/composable-ui/src/components/checkout/success/success-order-summary.tsx @@ -13,6 +13,8 @@ interface OrderSummaryProps { tax: string discount?: string total: string + totalDiscountAmount?: string + grandPrice?: string } export const SuccessOrderSummary = ({ @@ -24,6 +26,8 @@ export const SuccessOrderSummary = ({ tax, discount, total, + totalDiscountAmount, + grandPrice, }: OrderSummaryProps) => { const intl = useIntl() @@ -57,9 +61,17 @@ export const SuccessOrderSummary = ({ tax={tax} discount={discount} totalTitle={intl.formatMessage({ - id: 'checkout.success.orderSummary.totalPaid', + id: 'cart.summary.orderTotal', })} total={total} + totalDiscountAmountTitle={intl.formatMessage({ + id: 'cart.summary.totalDiscountAmount', + })} + totalDiscountAmount={totalDiscountAmount} + grandPriceTitle={intl.formatMessage({ + id: 'checkout.success.orderSummary.totalPaid', + })} + grandPrice={grandPrice} /> ) diff --git a/composable-ui/src/components/forms/coupon-form.tsx b/composable-ui/src/components/forms/coupon-form.tsx new file mode 100644 index 0000000..82db7b7 --- /dev/null +++ b/composable-ui/src/components/forms/coupon-form.tsx @@ -0,0 +1,151 @@ +import * as yup from 'yup' +import { useIntl } from 'react-intl' +import { useForm } from 'react-hook-form' +import { Alert, AlertIcon, Box, Flex, TagLeftIcon } from '@chakra-ui/react' +import { yupResolver } from '@hookform/resolvers/yup' +import { useState } from 'react' +import { IconButton } from '@chakra-ui/react' +import { ArrowForwardIcon } from '@chakra-ui/icons' +import { InputField } from '@composable/ui' +import { Tag, TagLabel, TagCloseButton } from '@chakra-ui/react' +import { useCart } from 'hooks' +import { Price } from 'components/price' +import { CartSummaryItem } from 'components/cart' +import { MdDiscount } from 'react-icons/md' + +export const CouponForm = () => { + const intl = useIntl() + const [errorMessage, setErrorMessage] = useState(false) + const { + register, + handleSubmit, + setValue, + formState: { errors }, + } = useForm<{ coupon: string }>({ + resolver: yupResolver(couponFormSchema()), + mode: 'all', + }) + const { cart, addCartCoupon, deleteCartCoupon } = useCart({ + onCartCouponAddError: (msg) => { + setErrorMessage(msg || 'Could not add coupon') + }, + }) + + const content = { + input: { + coupon: { + label: intl.formatMessage({ id: 'cart.summary.label.coupon' }), + placeholder: intl.formatMessage({ id: 'cart.summary.label.coupon' }), + }, + }, + button: { + login: intl.formatMessage({ id: 'action.addCoupon' }), + }, + } + + const vouchers = + cart.redeemables?.filter((redeemable) => redeemable.object === 'voucher') || + [] + + return ( + <> + +
{ + setErrorMessage(false) + + // setError('coupon', {message: 'Could not add coupon' }) + await addCartCoupon.mutate({ + cartId: cart.id || '', + coupon: data.coupon, + }) + setValue('coupon', '') + })} + > + + + } + type="submit" + size="sm" + variant={'outline'} + /> + + {errorMessage && ( + + + {errorMessage} + + )} +
+ {vouchers.map((redeemable) => ( + + + + {redeemable.label} + + deleteCartCoupon.mutate({ + cartId: cart.id || '', + coupon: redeemable.id, + }) + } + /> + + + + + + ))} + + ) +} + +const couponFormSchema = () => { + return yup.object().shape({ + coupon: yup.string(), + }) +} diff --git a/composable-ui/src/hooks/use-cart.ts b/composable-ui/src/hooks/use-cart.ts index 79a2885..9850873 100644 --- a/composable-ui/src/hooks/use-cart.ts +++ b/composable-ui/src/hooks/use-cart.ts @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useRef } from 'react' +import { useCallback, useMemo, useRef, useState } from 'react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { deleteFromStorage, @@ -10,12 +10,12 @@ import { LOCAL_STORAGE_CART_ID, LOCAL_STORAGE_CART_UPDATED_AT, } from 'utils/constants' -import { Cart } from '@composable/types' +import { CartWithDiscounts, Cart } from '@composable/types' import { useSession } from 'next-auth/react' const USE_CART_KEY = 'useCartKey' -export type CartData = Partial & { +export type CartData = Partial & { isLoading: boolean isEmpty: boolean quantity: number @@ -35,9 +35,23 @@ const setCartId = (id: string) => { interface UseCartOptions { onCartItemAddError?: () => void + onCartCouponAddError?: (errorMessage: string) => void + onCartCouponDeleteError?: () => void onCartItemUpdateError?: () => void onCartItemDeleteError?: () => void - onCartItemAddSuccess?: (cart: Cart) => void + onCartCouponAddSuccess?: ( + data: { + cart: CartWithDiscounts | Cart + result: boolean + }, + variables: { + cartId: string + coupon: string + }, + context: unknown + ) => void + onCartCouponDeleteSuccess?: (cart: CartWithDiscounts) => void + onCartItemAddSuccess?: (cart: CartWithDiscounts) => void } export const useCart = (options?: UseCartOptions) => { @@ -227,6 +241,105 @@ export const useCart = (options?: UseCartOptions) => { [cartId, cartItemDelete] ) + /** + * Cart Coupon Add + */ + const cartCouponAdd = useMutation( + ['cartCouponAdd'], + async (variables: { cartId: string; coupon: string }) => { + const params = { + cartId: variables.cartId, + coupon: variables.coupon, + } + + const response = await client.commerce.addCoupon.mutate(params) + const updatedAt = Date.now() + queryClient.setQueryData( + [USE_CART_KEY, variables.cartId, updatedAt], + response.cart + ) + + setCartUpdatedAt(updatedAt) + + if (!response.result && optionsRef.current?.onCartCouponAddError) { + optionsRef.current?.onCartCouponAddError( + response.errorMsg || `Could not add ${variables.coupon} coupon` + ) + } + + return response + }, + { + onError: optionsRef.current?.onCartCouponAddError, + } + ) + + /** + * Cart Coupon Add Mutation + */ + const cartCouponAddMutation = useCallback( + async (params: { cartId: string; coupon: string }) => { + const id = cartId ? cartId : await cartCreate.mutateAsync() + cartCouponAdd.mutate( + { + cartId: id, + coupon: params.coupon, + }, + { + onSuccess: optionsRef.current?.onCartCouponAddSuccess, + } + ) + }, + [cartId, cartCreate, cartCouponAdd] + ) + + /** + * Cart Coupon Delete + */ + const cartCouponDelete = useMutation( + ['cartCouponAdd'], + async (variables: { cartId: string; coupon: string }) => { + const params = { + cartId: variables.cartId, + coupon: variables.coupon, + } + + const response = await client.commerce.deleteCoupon.mutate(params) + const updatedAt = Date.now() + + queryClient.setQueryData( + [USE_CART_KEY, variables.cartId, updatedAt], + response + ) + + setCartUpdatedAt(updatedAt) + + return response + }, + { + onError: optionsRef.current?.onCartCouponDeleteError, + } + ) + + /** + * Cart Coupon Delete Mutation + */ + const cartCouponDeleteMutation = useCallback( + async (params: { cartId: string; coupon: string }) => { + const id = cartId ? cartId : await cartCreate.mutateAsync() + await cartCouponDelete.mutate( + { + cartId: id, + coupon: params.coupon, + }, + { + onSuccess: optionsRef.current?.onCartCouponDeleteSuccess, + } + ) + }, + [cartId, cartCreate, cartCouponDelete] + ) + /** * Cart Item Add Facade */ @@ -235,6 +348,22 @@ export const useCart = (options?: UseCartOptions) => { isLoading: cartItemAdd.isLoading || cartCreate.isLoading, } + /** + * Cart Coupon Add Facade + */ + const addCartCoupon = { + mutate: cartCouponAddMutation, + isLoading: cartCouponAdd.isLoading || cartCreate.isLoading, + } + + /** + * Cart Coupon Delete Facade + */ + const deleteCartCoupon = { + mutate: cartCouponDeleteMutation, + isLoading: cartCouponDelete.isLoading || cartCreate.isLoading, + } + /** * Cart Item Update Facade */ @@ -278,6 +407,8 @@ export const useCart = (options?: UseCartOptions) => { */ return { addCartItem, + addCartCoupon, + deleteCartCoupon, updateCartItem, deleteCartItem, cart: cartData, diff --git a/composable-ui/src/hooks/use-checkout.tsx b/composable-ui/src/hooks/use-checkout.tsx index a597f82..4295b4d 100644 --- a/composable-ui/src/hooks/use-checkout.tsx +++ b/composable-ui/src/hooks/use-checkout.tsx @@ -23,6 +23,25 @@ export const useCheckout = () => { ...publicContext } = context + /** + * Redeem Coupon Mutation + */ + const redeemCouponsMutation = useMutation( + async (coupons: { id: string; type: string }[]) => { + if (!cart.id) { + return + } + const redemptionSuccessful = await client.commerce.redeemCoupons.mutate({ + cartId: cart.id, + coupons, + }) + if (!redemptionSuccessful) { + throw new Error('Failed to redeem coupons.') + } + return redemptionSuccessful + } + ) + /** * Place Order Mutation */ @@ -34,8 +53,18 @@ export const useCheckout = () => { let __checkoutResponse = context.response.checkout let redirectUrl + + const coupons = context.cartSnapshot.redeemables + ?.filter((redeemable) => redeemable.status === 'APPLICABLE') + .map((redeemable) => { + return { id: redeemable.id, type: redeemable.object } + }) + if (!__checkoutResponse) { try { + if (coupons) { + await redeemCouponsMutation.mutateAsync(coupons) + } const params = { ...state, } diff --git a/composable-ui/src/server/api/routers/commerce/procedures/cart/add-coupon.ts b/composable-ui/src/server/api/routers/commerce/procedures/cart/add-coupon.ts new file mode 100644 index 0000000..eb0bf2f --- /dev/null +++ b/composable-ui/src/server/api/routers/commerce/procedures/cart/add-coupon.ts @@ -0,0 +1,14 @@ +import { z } from 'zod' +import { protectedProcedure } from 'server/api/trpc' +import { commerce } from 'server/data-source' + +export const addCoupon = protectedProcedure + .input( + z.object({ + cartId: z.string(), + coupon: z.string(), + }) + ) + .mutation(async ({ input }) => { + return await commerce.addCoupon({ ...input }) + }) diff --git a/composable-ui/src/server/api/routers/commerce/procedures/cart/delete-coupon.ts b/composable-ui/src/server/api/routers/commerce/procedures/cart/delete-coupon.ts new file mode 100644 index 0000000..48afc09 --- /dev/null +++ b/composable-ui/src/server/api/routers/commerce/procedures/cart/delete-coupon.ts @@ -0,0 +1,14 @@ +import { z } from 'zod' +import { protectedProcedure } from 'server/api/trpc' +import { commerce } from 'server/data-source' + +export const deleteCoupon = protectedProcedure + .input( + z.object({ + cartId: z.string(), + coupon: z.string(), + }) + ) + .mutation(async ({ input }) => { + return await commerce.deleteCoupon({ ...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..eb4329b 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-coupon' +export * from './delete-coupon' diff --git a/composable-ui/src/server/api/routers/commerce/procedures/checkout/index.ts b/composable-ui/src/server/api/routers/commerce/procedures/checkout/index.ts index d3d8265..2ab23a5 100644 --- a/composable-ui/src/server/api/routers/commerce/procedures/checkout/index.ts +++ b/composable-ui/src/server/api/routers/commerce/procedures/checkout/index.ts @@ -1,3 +1,4 @@ export * from './create-order' export * from './get-order' export * from './get-shipping-methods' +export * from './redeem-coupons' diff --git a/composable-ui/src/server/api/routers/commerce/procedures/checkout/redeem-coupons.ts b/composable-ui/src/server/api/routers/commerce/procedures/checkout/redeem-coupons.ts new file mode 100644 index 0000000..594ca22 --- /dev/null +++ b/composable-ui/src/server/api/routers/commerce/procedures/checkout/redeem-coupons.ts @@ -0,0 +1,19 @@ +import { z } from 'zod' +import { protectedProcedure } from 'server/api/trpc' +import { commerce } from 'server/data-source' + +export const redeemCoupons = protectedProcedure + .input( + z.object({ + cartId: z.string(), + coupons: z.array( + z.object({ + id: z.string(), + type: z.string(), + }) + ), + }) + ) + .mutation(async ({ input }) => { + return await commerce.redeemCoupons({ ...input }) + }) diff --git a/composable-ui/src/server/api/routers/stripe.ts b/composable-ui/src/server/api/routers/stripe.ts index e2187e9..639f3c1 100644 --- a/composable-ui/src/server/api/routers/stripe.ts +++ b/composable-ui/src/server/api/routers/stripe.ts @@ -59,7 +59,11 @@ export const stripeRouter = createTRPCRouter({ const cart = await commerce.getCart({ cartId: input.cartId }) return await stripeProvider.createPaymentIntent({ amount: parseInt( - (parseFloat(cart?.summary.totalPrice ?? '0') * 100).toString() + ( + parseFloat( + cart?.summary.grandPrice ?? cart?.summary.totalPrice ?? '0' + ) * 100 + ).toString() ), currency: APP_CONFIG.CURRENCY_CODE, customer: input.customerId, diff --git a/composable-ui/src/server/data-source/commerce.ts b/composable-ui/src/server/data-source/commerce.ts index d4dcfbf..96eb9e8 100644 --- a/composable-ui/src/server/data-source/commerce.ts +++ b/composable-ui/src/server/data-source/commerce.ts @@ -1,2 +1,4 @@ import { commerceGenericDataSource } from '@composable/commerce-generic' -export default commerceGenericDataSource +import { commerceWithDiscount } from '@composable/voucherify' + +export default commerceWithDiscount(commerceGenericDataSource) diff --git a/composable-ui/src/server/intl/en-US.json b/composable-ui/src/server/intl/en-US.json index 651a240..c73fa87 100644 --- a/composable-ui/src/server/intl/en-US.json +++ b/composable-ui/src/server/intl/en-US.json @@ -85,6 +85,7 @@ "action.selectCountry": "Select Country", "action.send": "Send", "action.signIn": "Sign In", + "action.addCoupon": "Add Coupon", "action.signOut": "Log Out", "action.signup": "Sign Up", "action.startShopping": "Start Shopping", @@ -117,14 +118,19 @@ "cart.summary.estimatedTotal": "Estimated Total", "cart.summary.orderTotal": "Order Total", + "cart.summary.totalDiscountAmount": "All discounts", + "cart.summary.promotions": "Promotions", + "cart.summary.grandPrice": "Grand Total", "cart.summary.shipping.complimentaryDelivery": "Complimentary Delivery", "cart.summary.shipping.free": "Free", "cart.summary.shipping": "Complimentary Delivery", "cart.summary.subtotal": "Subtotal", "cart.summary.tax": "Tax", "cart.summary.taxes": "Taxes", + "cart.summary.couponCodes": "Coupon Codes", "cart.summary.title": "Order Summary", "cart.summary.total": "Total", + "cart.summary.label.coupon": "Coupon code", "checkout.title": "Checkout", @@ -185,6 +191,8 @@ "checkout.success.orderDetails.shippingAddress": "Shipping Address", "checkout.success.orderSummary.title": "Order Summary", "checkout.success.orderSummary.totalPaid": "Total Paid", + "checkout.success.orderSummary.orderTotal": "Order Total", + "checkout.success.orderSummary.totalDiscountAmount": "All discounts", "product.returnPolicy.title": "Return Policy", "product.returnsAndShippingPolicy.title": "Return & Shipping", diff --git a/docs/docs/essentials/monorepo.md b/docs/docs/essentials/monorepo.md index 0438690..9515a23 100644 --- a/docs/docs/essentials/monorepo.md +++ b/docs/docs/essentials/monorepo.md @@ -38,6 +38,7 @@ The following table lists the packages exported by the mono-repository: | - | - | | `@composable/cms-generic` | `packages/cms-generic` | | `@composable/commerce-generic`| `packages/commerce-generic` | +| `@composable/voucherify`| `packages/voucherify` | | `@composable/eslint-config-custom` | `packages/eslint-config-custom` | | `@composable/stripe` | `packages/stripe` | | `@composable/tsconfig` | `packages/tsconfig` | diff --git a/package.json b/package.json index 13e25cc..3621203 100644 --- a/package.json +++ b/package.json @@ -30,5 +30,8 @@ }, "lint-staged": { "**/*": "prettier --write --ignore-unknown" + }, + "dependencies": { + "@voucherify/sdk": "^2.5.0" } } diff --git a/packages/commerce-generic/src/services/checkout/create-order.ts b/packages/commerce-generic/src/services/checkout/create-order.ts index dbe333e..887f288 100644 --- a/packages/commerce-generic/src/services/checkout/create-order.ts +++ b/packages/commerce-generic/src/services/checkout/create-order.ts @@ -4,7 +4,7 @@ import { saveOrder } from '../../data/persit' import shippingMethods from '../../data/shipping-methods.json' import { randomUUID } from 'crypto' -const generateOrderFromCart = ( +export const generateOrderFromCart = ( cart: Cart, checkoutInput: CheckoutInput ): Order => { diff --git a/packages/commerce-generic/src/services/checkout/get-order.ts b/packages/commerce-generic/src/services/checkout/get-order.ts index 2a52ac6..7f79329 100644 --- a/packages/commerce-generic/src/services/checkout/get-order.ts +++ b/packages/commerce-generic/src/services/checkout/get-order.ts @@ -1,10 +1,9 @@ import { CommerceService } from '@composable/types' -import { getOrder as getOrerFromStorage } from '../../data/persit' -import order from '../../data/order.json' +import { getOrder as getOrderFromStorage } from '../../data/persit' import shippingMethods from '../../data/shipping-methods.json' export const getOrder: CommerceService['getOrder'] = async ({ orderId }) => { - const order = await getOrerFromStorage(orderId) + const order = await getOrderFromStorage(orderId) if (!order) { throw new Error(`[getOrder] Could not found order: ${orderId}`) diff --git a/packages/types/index.ts b/packages/types/index.ts index 801e302..d07f0bd 100644 --- a/packages/types/index.ts +++ b/packages/types/index.ts @@ -1,2 +1,3 @@ export * from './src/commerce' export * from './src/cms' +export * from './src/voucherify' diff --git a/packages/types/src/voucherify/cart-with-discounts.ts b/packages/types/src/voucherify/cart-with-discounts.ts new file mode 100644 index 0000000..20fd9fa --- /dev/null +++ b/packages/types/src/voucherify/cart-with-discounts.ts @@ -0,0 +1,39 @@ +import { Cart, CartItem } from '../commerce' + +export type CartItemWithDiscounts = CartItem & { + cartItemType: 'CartItemWithDiscounts' + discounts: { + /** + * Final order item amount after the applied item-level discount. If there are no item-level discounts applied + */ + subtotalAmount: string + } +} + +export type CartWithDiscounts = Cart & { + cartType: 'CartWithDiscounts' + redeemables: Redeemable[] + items: CartItemWithDiscounts[] + summary: { + /** + * Sum of all order-level discounts applied to the order. + */ + discountAmount: string + /** + * Sum of all order-level AND all product-specific discounts applied to the order. + */ + totalDiscountAmount: string + /** + * Order amount after applying all the discounts. + */ + grandPrice: string + } +} + +export type Redeemable = { + id: string + status: string + object: 'voucher' | 'promotion_tier' | 'promotion_stack' + label?: string + discount: string +} diff --git a/packages/types/src/voucherify/commerce-service-with-discounts.ts b/packages/types/src/voucherify/commerce-service-with-discounts.ts new file mode 100644 index 0000000..0a85229 --- /dev/null +++ b/packages/types/src/voucherify/commerce-service-with-discounts.ts @@ -0,0 +1,46 @@ +import { CommerceService, CheckoutInput, Cart } from '../commerce' +import { CartWithDiscounts } from './cart-with-discounts' +import { OrderWithDiscounts } from './order-with-discounts' + +export interface CommerceServiceWithDiscounts extends CommerceService { + // Extend existing commerce service methods to return cart with applied discount details + + addCartItem( + ...params: Parameters + ): Promise + createCart(): Promise + deleteCartItem( + ...params: Parameters + ): Promise + getCart( + ...params: Parameters + ): Promise + updateCartItem( + ...params: Parameters + ): Promise + createOrder(params: { + checkout: CheckoutInput + }): Promise + getOrder( + ...params: Parameters + ): Promise + + // Additional commerce endpoints to manage applied coupons + + addCoupon(props: { coupon: string; cartId: string }): Promise<{ + cart: CartWithDiscounts | Cart + result: boolean + errorMsg?: string + }> + deleteCoupon(props: { + coupon: string + cartId: string + }): Promise + redeemCoupons(props: { + cartId: string + coupons: { + id: string + type: string + }[] + }): Promise<{ result: boolean }> +} diff --git a/packages/types/src/voucherify/index.ts b/packages/types/src/voucherify/index.ts new file mode 100644 index 0000000..e0aca89 --- /dev/null +++ b/packages/types/src/voucherify/index.ts @@ -0,0 +1,2 @@ +export * from './cart-with-discounts' +export * from './commerce-service-with-discounts' diff --git a/packages/types/src/voucherify/order-with-discounts.ts b/packages/types/src/voucherify/order-with-discounts.ts new file mode 100644 index 0000000..2f0bc34 --- /dev/null +++ b/packages/types/src/voucherify/order-with-discounts.ts @@ -0,0 +1,19 @@ +import { Order } from '../commerce' + +export type OrderWithDiscounts = Order & { + orderType?: 'OrderWithDiscounts' + summary: { + /** + * Sum of all order-level discounts applied to the order. + */ + discountAmount?: string + /** + * Sum of all order-level AND all product-specific discounts applied to the order. + */ + totalDiscountAmount?: string + /** + * Order amount after applying all the discounts. + */ + grandPrice?: string + } +} diff --git a/packages/voucherify/.eslintrc.js b/packages/voucherify/.eslintrc.js new file mode 100644 index 0000000..b56159e --- /dev/null +++ b/packages/voucherify/.eslintrc.js @@ -0,0 +1,4 @@ +module.exports = { + root: true, + extends: ['custom'], +} diff --git a/packages/voucherify/data/cart-with-discount.ts b/packages/voucherify/data/cart-with-discount.ts new file mode 100644 index 0000000..02a9df5 --- /dev/null +++ b/packages/voucherify/data/cart-with-discount.ts @@ -0,0 +1,73 @@ +import { + Cart, + CartItemWithDiscounts, + CartWithDiscounts, + Redeemable, +} from '@composable/types' +import { + PromotionsValidateResponse, + ValidationValidateStackableResponse, +} from '@voucherify/sdk' +import { centToString, toCent } from '../src/to-cent' + +export const cartWithDiscount = ( + cart: Cart, + validationResponse: ValidationValidateStackableResponse | false, + promotionsResult: PromotionsValidateResponse | false +): CartWithDiscounts => { + const redeemables: Redeemable[] = validationResponse + ? validationResponse.redeemables?.map((redeemable) => ({ + id: redeemable.id, + status: redeemable.status, + object: redeemable.object, + discount: 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 items: CartItemWithDiscounts[] = cart.items.map((item) => ({ + ...item, + cartItemType: 'CartItemWithDiscounts', + discounts: { + subtotalAmount: '', // todo item level discounts + }, + })) + + const discountAmount = centToString( + validationResponse ? validationResponse.order?.discount_amount : 0 + ) + const grandPrice = centToString( + validationResponse + ? validationResponse.order?.total_amount + : toCent(cart.summary.totalPrice) + ) + const totalDiscountAmount = centToString( + validationResponse + ? validationResponse.order?.total_applied_discount_amount + : 0 + ) + + return { + ...cart, + cartType: 'CartWithDiscounts', + summary: { + ...cart.summary, + discountAmount, + totalDiscountAmount, + grandPrice, + }, + redeemables, + items, + } +} diff --git a/packages/voucherify/data/get-redeemables-for-validation.ts b/packages/voucherify/data/get-redeemables-for-validation.ts new file mode 100644 index 0000000..772b554 --- /dev/null +++ b/packages/voucherify/data/get-redeemables-for-validation.ts @@ -0,0 +1,26 @@ +import { + PromotionsValidateResponse, + StackableRedeemableObject, +} from '@voucherify/sdk' + +export const getRedeemablesForValidation = (couponCodes: string[]) => + couponCodes.map((couponCode) => ({ + id: couponCode, + object: 'voucher' as const, + })) + +export const getRedeemablesForRedemption = ( + coupons: { id: string; type: StackableRedeemableObject }[] +) => + coupons.map((coupon) => ({ + id: coupon.id, + object: coupon.type, + })) + +export const getRedeemablesForValidationFromPromotions = ( + promotionResult: PromotionsValidateResponse +) => + promotionResult.promotions?.map((promotion) => ({ + id: promotion.id, + object: 'promotion_tier' as const, + })) || [] diff --git a/packages/voucherify/data/has-at-least-one-redeemable.ts b/packages/voucherify/data/has-at-least-one-redeemable.ts new file mode 100644 index 0000000..9e48603 --- /dev/null +++ b/packages/voucherify/data/has-at-least-one-redeemable.ts @@ -0,0 +1,6 @@ +import { getCartDiscounts } from './persit' + +export const hasAtLeastOneRedeemable = async (cartId: string) => { + const cartDiscountsStorage = await getCartDiscounts(cartId) + return cartDiscountsStorage && cartDiscountsStorage.length > 0 +} diff --git a/packages/voucherify/data/order-with-discount.ts b/packages/voucherify/data/order-with-discount.ts new file mode 100644 index 0000000..65ba512 --- /dev/null +++ b/packages/voucherify/data/order-with-discount.ts @@ -0,0 +1,37 @@ +import { OrderWithDiscounts } from '@composable/types/src/voucherify/order-with-discounts' +import { centToString, toCent } from '../src/to-cent' +import { Cart, Order } from '@composable/types' +import { ValidationValidateStackableResponse } from '@voucherify/sdk' + +export const orderWithDiscount = ( + order: Order | null, + cart: Cart, + validationResponse: ValidationValidateStackableResponse | false +): OrderWithDiscounts | null => { + const discountAmount = centToString( + validationResponse ? validationResponse.order?.discount_amount : 0 + ) + const grandPrice = centToString( + validationResponse + ? validationResponse.order?.total_amount + : toCent(cart.summary.totalPrice) + ) + const totalDiscountAmount = centToString( + validationResponse + ? validationResponse.order?.total_applied_discount_amount + : 0 + ) + if (!order) { + throw new Error('[voucherify][orderWithDiscount] Order not found.') + } + return { + ...order, + orderType: 'OrderWithDiscounts', + summary: { + ...cart.summary, + discountAmount, + totalDiscountAmount, + grandPrice, + }, + } +} diff --git a/packages/voucherify/data/persit.ts b/packages/voucherify/data/persit.ts new file mode 100644 index 0000000..f786e1c --- /dev/null +++ b/packages/voucherify/data/persit.ts @@ -0,0 +1,35 @@ +import storage from 'node-persist' +import path from 'path' +import os from 'os' + +const storageFolderPath = path.join( + os.tmpdir(), + 'composable-ui-storage-voucherify' +) + +const localStorage = storage.create() + +localStorage.init({ + dir: storageFolderPath, +}) + +console.log( + `[voucherify][persist] Local storage in folder ${storageFolderPath}` +) + +export const getCartDiscounts = async (cartId: string): Promise => { + return (await localStorage.getItem(`cart-discounts-${cartId}`)) || [] +} + +export const saveCartDiscounts = async ( + cartId: string, + discounts: string[] +) => { + await localStorage.setItem(`cart-discounts-${cartId}`, discounts) + return discounts +} + +export const deleteCartDiscounts = async (cartId: string) => { + const result = await localStorage.del(`cart-discounts-${cartId}`) + return result.removed +} 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..8d16769 --- /dev/null +++ b/packages/voucherify/package.json @@ -0,0 +1,22 @@ +{ + "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:*", + "@types/node-persist": "^3.1.5", + "node-persist": "^3.1.3" + }, + "devDependencies": { + "eslint-config-custom": "workspace:*", + "tsconfig": "workspace:*", + "typescript": "^4.5.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..1fc3111 --- /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.totalPrice), + items: cart.items.map((item) => ({ + quantity: item.quantity, + product_id: item.id, + sku_id: item.sku, + price: item.price * 100, + })), + } +} diff --git a/packages/voucherify/src/commerce-wrapper/add-cart-item.ts b/packages/voucherify/src/commerce-wrapper/add-cart-item.ts new file mode 100644 index 0000000..48cdc0c --- /dev/null +++ b/packages/voucherify/src/commerce-wrapper/add-cart-item.ts @@ -0,0 +1,27 @@ +import { CommerceService, CartWithDiscounts } from '@composable/types' +import { cartWithDiscount } from '../../data/cart-with-discount' +import { VoucherifyServerSide } from '@voucherify/sdk' +import { getCartDiscounts } from '../../data/persit' +import { validateCouponsAndPromotions } from '../validate-discounts' + +export const addCartItemFunction = + ( + commerceService: CommerceService, + voucherify: ReturnType + ) => + async ( + ...props: Parameters + ): Promise => { + const cart = await commerceService.addCartItem(...props) + + const codes = await getCartDiscounts(props[0].cartId) + + const { validationResult, promotionsResult } = + await validateCouponsAndPromotions({ + voucherify, + cart, + codes, + }) + + return cartWithDiscount(cart, validationResult, promotionsResult) + } diff --git a/packages/voucherify/src/commerce-wrapper/add-coupon.ts b/packages/voucherify/src/commerce-wrapper/add-coupon.ts new file mode 100644 index 0000000..e25c3d3 --- /dev/null +++ b/packages/voucherify/src/commerce-wrapper/add-coupon.ts @@ -0,0 +1,50 @@ +import { commerce } from '../../../../composable-ui/src/server/data-source' +import { cartWithDiscount } from '../../data/cart-with-discount' +import { VoucherifyServerSide } from '@voucherify/sdk' +import { getCartDiscounts, saveCartDiscounts } from '../../data/persit' +import { validateCouponsAndPromotions } from '../validate-discounts' +import { isRedeemableApplicable } from './is-redeemable-applicable' +import { CommerceService } from '@composable/types' + +export const addCouponFunction = + ( + commerceService: CommerceService, + voucherify: ReturnType + ) => + async ({ cartId, coupon }: { cartId: string; coupon: string }) => { + const cart = + (await commerce.getCart({ cartId })) || + (await commerceService.getCart({ cartId })) + + if (!cart) { + throw new Error(`[voucherify][addCoupon] cart not found by id: ${cartId}`) + } + + const cartDiscounts = await getCartDiscounts(cartId) + + const { validationResult, promotionsResult } = + await validateCouponsAndPromotions({ + cart, + voucherify, + codes: [...cartDiscounts, coupon], + }) + + const { isApplicable, error } = isRedeemableApplicable( + coupon, + validationResult + ) + + if (isApplicable) { + await saveCartDiscounts(cartId, [...cartDiscounts, coupon]) + return { + cart: cartWithDiscount(cart, validationResult, promotionsResult), + result: isApplicable, + errorMsg: error, + } + } + return { + cart: cart, + result: isApplicable, + errorMsg: error, + } + } diff --git a/packages/voucherify/src/commerce-wrapper/create-cart.ts b/packages/voucherify/src/commerce-wrapper/create-cart.ts new file mode 100644 index 0000000..264f60c --- /dev/null +++ b/packages/voucherify/src/commerce-wrapper/create-cart.ts @@ -0,0 +1,16 @@ +import { CommerceService, CartWithDiscounts } from '@composable/types' +import { cartWithDiscount } from '../../data/cart-with-discount' +import { VoucherifyServerSide } from '@voucherify/sdk' + +export const createCartFunction = + ( + commerceService: CommerceService, + voucherify: ReturnType + ) => + async ( + ...props: Parameters + ): Promise => { + const cart = await commerceService.createCart(...props) + + return cartWithDiscount(cart, false, false) + } diff --git a/packages/voucherify/src/commerce-wrapper/create-order.ts b/packages/voucherify/src/commerce-wrapper/create-order.ts new file mode 100644 index 0000000..4c41e33 --- /dev/null +++ b/packages/voucherify/src/commerce-wrapper/create-order.ts @@ -0,0 +1,46 @@ +import { CommerceService } from '@composable/types' +import { VoucherifyServerSide } from '@voucherify/sdk' +import { orderWithDiscount } from '../../data/order-with-discount' +import { validateCouponsAndPromotions } from '../validate-discounts' +import { getCartDiscounts } from '../../data/persit' +import { saveOrder } from '../../../commerce-generic/src/data/persit' +import { generateOrderFromCart } from '../../../commerce-generic/src/services' + +export const createOrderFunction = + ( + commerceService: CommerceService, + voucherify: ReturnType + ) => + async (...props: Parameters) => { + const cartId = props[0].checkout.cartId + const cart = await commerceService.getCart({ + cartId: cartId, + }) + + const codes = await getCartDiscounts(cartId) + + if (!cart) { + throw new Error('[voucherify][createOrderFunction] No cart found.') + } + + const { validationResult, promotionsResult } = + await validateCouponsAndPromotions({ + voucherify, + cart, + codes, + }) + + const orderWithDiscounts = orderWithDiscount( + generateOrderFromCart(cart, props[0].checkout), + cart, + validationResult + ) + + if (!orderWithDiscounts) { + throw new Error( + '[voucherify][createOrderFunction] No order with discounts found.' + ) + } + + return saveOrder(orderWithDiscounts) + } diff --git a/packages/voucherify/src/commerce-wrapper/delete-cart-item.ts b/packages/voucherify/src/commerce-wrapper/delete-cart-item.ts new file mode 100644 index 0000000..52d30e6 --- /dev/null +++ b/packages/voucherify/src/commerce-wrapper/delete-cart-item.ts @@ -0,0 +1,27 @@ +import { CommerceService, CartWithDiscounts } from '@composable/types' +import { cartWithDiscount } from '../../data/cart-with-discount' +import { VoucherifyServerSide } from '@voucherify/sdk' +import { getCartDiscounts } from '../../data/persit' +import { validateCouponsAndPromotions } from '../validate-discounts' + +export const deleteCartItemFunction = + ( + commerceService: CommerceService, + voucherify: ReturnType + ) => + async ( + ...props: Parameters + ): Promise => { + const cart = await commerceService.deleteCartItem(...props) + + const codes = await getCartDiscounts(props[0].cartId) + + const { validationResult, promotionsResult } = + await validateCouponsAndPromotions({ + voucherify, + cart, + codes, + }) + + return cartWithDiscount(cart, validationResult, promotionsResult) + } diff --git a/packages/voucherify/src/commerce-wrapper/delete-coupon.ts b/packages/voucherify/src/commerce-wrapper/delete-coupon.ts new file mode 100644 index 0000000..678529b --- /dev/null +++ b/packages/voucherify/src/commerce-wrapper/delete-coupon.ts @@ -0,0 +1,33 @@ +import { CommerceService } from '@composable/types' +import { cartWithDiscount } from '../../data/cart-with-discount' +import { VoucherifyServerSide } from '@voucherify/sdk' +import { getCartDiscounts, saveCartDiscounts } from '../../data/persit' +import { validateCouponsAndPromotions } from '../validate-discounts' + +export const deleteCouponFunction = + ( + commerceService: CommerceService, + voucherify: ReturnType + ) => + async ({ cartId, coupon }: { cartId: string; coupon: string }) => { + const cart = await commerceService.getCart({ cartId }) + + if (!cart) { + throw new Error('[voucherify][deleteCoupon] cart not found') + } + + const codes = (await getCartDiscounts(cartId)).filter( + (redeemable) => redeemable !== coupon + ) + + await saveCartDiscounts(cartId, codes) + + const { validationResult, promotionsResult } = + await validateCouponsAndPromotions({ + voucherify, + cart, + codes, + }) + + return cartWithDiscount(cart, validationResult, promotionsResult) + } diff --git a/packages/voucherify/src/commerce-wrapper/get-cart.ts b/packages/voucherify/src/commerce-wrapper/get-cart.ts new file mode 100644 index 0000000..d5f93aa --- /dev/null +++ b/packages/voucherify/src/commerce-wrapper/get-cart.ts @@ -0,0 +1,33 @@ +import { CommerceService, CartWithDiscounts } from '@composable/types' +import { cartWithDiscount } from '../../data/cart-with-discount' +import { VoucherifyServerSide } from '@voucherify/sdk' +import { getCartDiscounts } from '../../data/persit' +import { validateCouponsAndPromotions } from '../validate-discounts' + +export const getCartFunction = + ( + commerceService: CommerceService, + voucherify: ReturnType + ) => + async ( + ...props: Parameters + ): Promise => { + const cart = await commerceService.getCart(...props) + + if (!cart) { + return null + } + + const codes = await getCartDiscounts(props[0].cartId) + + const { validationResult, promotionsResult } = + await validateCouponsAndPromotions({ + voucherify, + cart, + codes, + }) + console.log( + JSON.stringify({ cart, validationResult, promotionsResult }, null, 2) + ) + return cartWithDiscount(cart, validationResult, promotionsResult) + } diff --git a/packages/voucherify/src/commerce-wrapper/get-order.ts b/packages/voucherify/src/commerce-wrapper/get-order.ts new file mode 100644 index 0000000..ad591d6 --- /dev/null +++ b/packages/voucherify/src/commerce-wrapper/get-order.ts @@ -0,0 +1,14 @@ +import { CommerceService } from '@composable/types' +import { VoucherifyServerSide } from '@voucherify/sdk' +import { OrderWithDiscounts } from '@composable/types/src/voucherify/order-with-discounts' + +export const getOrderFunction = + ( + commerceService: CommerceService, + voucherify: ReturnType + ) => + async ( + ...props: Parameters + ): Promise => { + return await commerceService.getOrder(...props) + } diff --git a/packages/voucherify/src/commerce-wrapper/index.ts b/packages/voucherify/src/commerce-wrapper/index.ts new file mode 100644 index 0000000..71ebd56 --- /dev/null +++ b/packages/voucherify/src/commerce-wrapper/index.ts @@ -0,0 +1,50 @@ +import { + CommerceService, + CommerceServiceWithDiscounts, +} from '@composable/types' +import { getCartFunction } from './get-cart' +import { VoucherifyServerSide } from '@voucherify/sdk' +import { addCartItemFunction } from './add-cart-item' +import { createCartFunction } from './create-cart' +import { deleteCartItemFunction } from './delete-cart-item' +import { updateCartItemFunction } from './update-cart-item' +import { addCouponFunction } from './add-coupon' +import { deleteCouponFunction } from './delete-coupon' +import { createOrderFunction } from './create-order' +import { redeemCouponsFunction } from './redeem-coupons' +import { getOrderFunction } from './get-order' + +if ( + !process.env.VOUCHERIFY_APPLICATION_ID || + !process.env.VOUCHERIFY_SECRET_KEY || + !process.env.VOUCHERIFY_API_URL +) { + throw new Error('[voucherify] Missing configuration') +} + +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', +}) + +export const commerceWithDiscount = ( + commerceService: CommerceService +): CommerceServiceWithDiscounts => { + console.log('[voucherify][commerceWithDiscount] wrapping commerce service') + return { + ...commerceService, + getCart: getCartFunction(commerceService, voucherify), + addCartItem: addCartItemFunction(commerceService, voucherify), + createCart: createCartFunction(commerceService, voucherify), + deleteCartItem: deleteCartItemFunction(commerceService, voucherify), + updateCartItem: updateCartItemFunction(commerceService, voucherify), + addCoupon: addCouponFunction(commerceService, voucherify), + deleteCoupon: deleteCouponFunction(commerceService, voucherify), + redeemCoupons: redeemCouponsFunction(commerceService, voucherify), + createOrder: createOrderFunction(commerceService, voucherify), + getOrder: getOrderFunction(commerceService, voucherify), + } +} diff --git a/packages/voucherify/src/commerce-wrapper/is-redeemable-applicable.ts b/packages/voucherify/src/commerce-wrapper/is-redeemable-applicable.ts new file mode 100644 index 0000000..6e25451 --- /dev/null +++ b/packages/voucherify/src/commerce-wrapper/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 addedRedeembale = + validationResult && validationResult.redeemables + ? [ + ...validationResult.redeemables, + ...(validationResult?.inapplicable_redeemables || []), + ]?.find((redeemable) => redeemable.id === coupon) + : false + + const isApplicable = addedRedeembale + ? addedRedeembale.status === 'APPLICABLE' + : false + + if (!isApplicable) { + error = addedRedeembale + ? addedRedeembale.result?.error?.message + : 'Redeemable not found in response from Voucherify' + } + + return { isApplicable, error } +} diff --git a/packages/voucherify/src/commerce-wrapper/is-redemption-successful.ts b/packages/voucherify/src/commerce-wrapper/is-redemption-successful.ts new file mode 100644 index 0000000..339c778 --- /dev/null +++ b/packages/voucherify/src/commerce-wrapper/is-redemption-successful.ts @@ -0,0 +1,17 @@ +import { RedeemCouponsResponse } from '../redeem-coupons' + +export const isRedemptionSuccessful = ( + redemptionResult: RedeemCouponsResponse +): boolean => { + const isRedemptionOfAllCouponsSuccessful = + redemptionResult && + redemptionResult.redemptions.every( + (redemption) => redemption.result === 'SUCCESS' + ) + + if (!isRedemptionOfAllCouponsSuccessful) { + throw new Error('Redemption failed.') + } + + return isRedemptionOfAllCouponsSuccessful +} diff --git a/packages/voucherify/src/commerce-wrapper/redeem-coupons.ts b/packages/voucherify/src/commerce-wrapper/redeem-coupons.ts new file mode 100644 index 0000000..98078e7 --- /dev/null +++ b/packages/voucherify/src/commerce-wrapper/redeem-coupons.ts @@ -0,0 +1,42 @@ +import { CommerceService } from '@composable/types' +import { + StackableRedeemableObject, + VoucherifyServerSide, +} from '@voucherify/sdk' +import { redeemCoupons } from '../redeem-coupons' +import { isRedemptionSuccessful } from './is-redemption-successful' + +export const redeemCouponsFunction = + ( + commerceService: CommerceService, + voucherify: ReturnType + ) => + async ({ + cartId, + coupons, + }: { + cartId: string + coupons: { + id: string + type: StackableRedeemableObject + }[] + }) => { + const cart = await commerceService.getCart({ cartId }) + + if (!cart) { + throw new Error( + `[voucherify][redeemCoupons] cart not found by id: ${cartId}` + ) + } + + const redemptionsResponse = await redeemCoupons({ + cart, + coupons, + voucherify, + }) + + const redemptionsResult = isRedemptionSuccessful(redemptionsResponse) + return { + result: redemptionsResult, + } + } diff --git a/packages/voucherify/src/commerce-wrapper/update-cart-item.ts b/packages/voucherify/src/commerce-wrapper/update-cart-item.ts new file mode 100644 index 0000000..20406c0 --- /dev/null +++ b/packages/voucherify/src/commerce-wrapper/update-cart-item.ts @@ -0,0 +1,27 @@ +import { CommerceService, CartWithDiscounts } from '@composable/types' +import { cartWithDiscount } from '../../data/cart-with-discount' +import { VoucherifyServerSide } from '@voucherify/sdk' +import { getCartDiscounts } from '../../data/persit' +import { validateCouponsAndPromotions } from '../validate-discounts' + +export const updateCartItemFunction = + ( + commerceService: CommerceService, + voucherify: ReturnType + ) => + async ( + ...props: Parameters + ): Promise => { + const cart = await commerceService.updateCartItem(...props) + + const codes = await getCartDiscounts(props[0].cartId) + + const { validationResult, promotionsResult } = + await validateCouponsAndPromotions({ + voucherify, + cart, + codes, + }) + + return cartWithDiscount(cart, validationResult, promotionsResult) + } diff --git a/packages/voucherify/src/index.ts b/packages/voucherify/src/index.ts new file mode 100644 index 0000000..0736442 --- /dev/null +++ b/packages/voucherify/src/index.ts @@ -0,0 +1 @@ +export * from './commerce-wrapper' diff --git a/packages/voucherify/src/redeem-coupons.ts b/packages/voucherify/src/redeem-coupons.ts new file mode 100644 index 0000000..c217aa0 --- /dev/null +++ b/packages/voucherify/src/redeem-coupons.ts @@ -0,0 +1,41 @@ +import { cartToVoucherifyOrder } from './cart-to-voucherify-order' +import { + getRedeemablesForRedemption, + getRedeemablesForValidation, +} from '../data/get-redeemables-for-validation' +import { Cart } from '@composable/types' +import { + RedemptionsRedeemStackableResponse, + StackableRedeemableObject, + StackableRedeemableResponse, + VoucherifyServerSide, +} from '@voucherify/sdk' + +export type RedeemCouponsParam = { + cart: Cart + coupons: { + id: string + type: StackableRedeemableObject + }[] + voucherify: ReturnType +} + +export type RedeemCouponsResponse = + | false + | (RedemptionsRedeemStackableResponse & { + inapplicable_redeemables?: StackableRedeemableResponse[] + }) + +export const redeemCoupons = async ( + params: RedeemCouponsParam +): Promise => { + const { cart, coupons, voucherify } = params + + const order = cartToVoucherifyOrder(cart) + + return await voucherify.redemptions.redeemStackable({ + redeemables: [...getRedeemablesForRedemption(coupons)], + order, + options: { expand: ['order'] }, + }) +} 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..e3e78c2 --- /dev/null +++ b/packages/voucherify/src/validate-discounts.ts @@ -0,0 +1,54 @@ +import { Cart } from '@composable/types' +import { + PromotionsValidateResponse, + StackableRedeemableResponse, + ValidationValidateStackableResponse, + VoucherifyServerSide, +} from '@voucherify/sdk' +import { + getRedeemablesForValidation, + getRedeemablesForValidationFromPromotions, +} from '../data/get-redeemables-for-validation' +import { cartToVoucherifyOrder } from './cart-to-voucherify-order' + +type ValidateDiscountsParam = { + cart: Cart + codes: 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, codes, voucherify } = params + + const order = cartToVoucherifyOrder(cart) + + 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/tsconfig.json b/packages/voucherify/tsconfig.json new file mode 100644 index 0000000..6b51fef --- /dev/null +++ b/packages/voucherify/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "tsconfig/react-library.json", + "include": ["."], + "exclude": [".turbo", "dist", "tmp", "node_modules", "tsconfig.tsbuildinfo"], + "compilerOptions": { + "resolveJsonModule": true + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2bb9d75..e8639f4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,6 +7,10 @@ settings: importers: .: + dependencies: + '@voucherify/sdk': + specifier: ^2.5.0 + version: 2.5.0 devDependencies: '@babel/core': specifier: ^7.0.0 @@ -50,6 +54,9 @@ importers: '@composable/ui': specifier: workspace:* version: link:../packages/ui + '@composable/voucherify': + specifier: workspace:* + version: link:../packages/voucherify '@emotion/react': specifier: ^11.9.3 version: 11.10.6(@types/react@18.0.31)(react@18.2.0) @@ -343,6 +350,28 @@ importers: specifier: ^4.5.5 version: 4.9.5 + packages/voucherify: + dependencies: + '@composable/types': + specifier: workspace:* + version: link:../types + '@types/node-persist': + specifier: ^3.1.5 + version: 3.1.5 + node-persist: + specifier: ^3.1.3 + version: 3.1.3 + devDependencies: + eslint-config-custom: + specifier: workspace:* + version: link:../eslint-config-custom + tsconfig: + specifier: workspace:* + version: link:../tsconfig + typescript: + specifier: ^4.5.5 + version: 4.9.5 + scripts: dependencies: algoliasearch: @@ -6622,6 +6651,16 @@ packages: resolution: {integrity: sha512-haGBC8noyA5BfjCRXRH+VIkHCDVW5iD5UX24P2nOdilwUxI4qWsattS/co8QBGq64XsNLRAMdM5pQUE3zxkF9Q==} dev: true + /@voucherify/sdk@2.5.0: + resolution: {integrity: sha512-qRi9lfP/kshsmi+An1cRIWrA9NDfj9tqDFSjvhpnm3BhuxlZnp2SgNgiUejTDkoMNxDgSTH7vImC1w9f51WOgw==} + dependencies: + axios: 0.27.2 + form-data: 4.0.0 + qs: 6.9.7 + transitivePeerDependencies: + - debug + dev: false + /@webassemblyjs/ast@1.11.1: resolution: {integrity: sha512-ukBh14qFLjxTQNTXocdyksN5QdM28S1CxHt2rdskFyL+xFV7VremuBLVbmCePj+URalXBENx/9Lm7lnhihtCSw==} dependencies: @@ -7396,6 +7435,15 @@ 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 + dev: false + /axobject-query@3.1.1: resolution: {integrity: sha512-goKlv8DZrK9hUh975fnHzhNIO4jUnFCfv/dszV5VwUGDFjI6vQ2VwoyjYjYNEbBE8AH87TduWP5uyDR1D+Iteg==} 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==} 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..777ad23 100644 --- a/scripts/package.json +++ b/scripts/package.json @@ -3,7 +3,8 @@ "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-setup": "ts-node src/voucherify-setup/index.ts" }, "devDependencies": { "@types/node": "^18.6.3", 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/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/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', +})