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 (
+ <>
+
+
+ {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',
+})