Created
May 6, 2022 10:26
-
-
Save ikraamg/761bd285746c42aae006182140c0a03c to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
diff --git a/graphql-cms/queries/catalog.js b/graphql-cms/queries/catalog.js | |
index 75d1f9d..db89a42 100644 | |
--- a/graphql-cms/queries/catalog.js | |
+++ b/graphql-cms/queries/catalog.js | |
@@ -1,6 +1,7 @@ | |
import { fetchGraphQL } from '../api' | |
import { COLLECTION_FRAGMENT } from 'graphql-cms/fragments' | |
import placeholder from 'graphql-cms/utils/placeholder' | |
+import { bootstrapReduxStore } from './redux' | |
const PAGE_SIZE = 1 | |
@@ -82,3 +83,8 @@ export const getPaginatedCollections = async page => { | |
return collectionItems | |
} | |
+ | |
+export const getCatalogWithReduxState = async ({ slug }, store) => | |
+ Promise.all([getCatalog({ slug }), bootstrapReduxStore(store)]).then( | |
+ ([{ collections }]) => collections | |
+ ) | |
diff --git a/graphql-cms/queries/index.js b/graphql-cms/queries/index.js | |
index 33e7ff6..dbf53f7 100644 | |
--- a/graphql-cms/queries/index.js | |
+++ b/graphql-cms/queries/index.js | |
@@ -3,7 +3,7 @@ export { getProduct, getProducts, getProductWithReduxState } from './product' | |
export { getKit, getKits, getKitWithReduxState } from './kit' | |
export { getArticle, getArticles, getArticleWithReduxState } from './article' | |
export { getFaq, getFaqs } from './faq' | |
-export { getCatalog } from './catalog' | |
+export { getCatalog, getCatalogWithReduxState } from './catalog' | |
export { getSiteMetadata } from './metadata' | |
export { getNavigation } from './navigation' | |
export { getNotificationBar } from './notification' | |
diff --git a/locales/en.json b/locales/en.json | |
index f6f1fe9..c24d9f6 100644 | |
--- a/locales/en.json | |
+++ b/locales/en.json | |
@@ -54,7 +54,10 @@ | |
"text": "An email is on its way to ", | |
"shipping": "shipping to", | |
"payment": "payment method" | |
- } | |
+ }, | |
+ "add_to_order": "Add to order", | |
+ "limited_offer": "Limited time offer", | |
+ "limited_offer_text": "Grab these additional items at {off_text} off. Offer expires in " | |
}, | |
"product": { | |
"related_products": "More Great Products to Love", | |
@@ -64,6 +67,7 @@ | |
"plural": "products", | |
"ingredients_list": "View Full Ingredients List", | |
"add_to_cart": "Add to cart", | |
+ "added_to_cart": "Added to cart", | |
"coming_soon": "Coming Soon", | |
"sold_out": "Out of Stock", | |
"notify_when_available": "Enter your email below to be notified when this product becomes available again.", | |
diff --git a/package.json b/package.json | |
index 4b6438c..55622ef 100644 | |
--- a/package.json | |
+++ b/package.json | |
@@ -22,6 +22,7 @@ | |
"@stripe/terminal-js": "^0.9.0", | |
"babel-plugin-styled-components": "^1.10.7", | |
"copy-to-clipboard": "^3.3.1", | |
+ "date-fns": "^2.28.0", | |
"dotenv": "^10.0.0", | |
"humps": "^2.0.1", | |
"magic-sdk": "^8.0.0", | |
diff --git a/pages/order/confirmation.js b/pages/order/confirmation.js | |
index 6c8f53e..7ddf859 100644 | |
--- a/pages/order/confirmation.js | |
+++ b/pages/order/confirmation.js | |
@@ -2,6 +2,17 @@ import React from 'react' | |
import Metadata from '~/components/Metadata' | |
import OrderConfirmationPage from '~/components/Order/Confirmation/Page' | |
import { useTranslate } from '~/hooks/actions' | |
+import { getCatalogWithReduxState } from 'graphql-cms/queries/catalog' | |
+import { wrapper as reduxWrapper } from '~/redux/store' | |
+ | |
+export const getStaticProps = reduxWrapper.getStaticProps(store => async () => { | |
+ const catalog = await getCatalogWithReduxState({ slug: 'catalog' }, store) | |
+ return { | |
+ props: { | |
+ catalog | |
+ } | |
+ } | |
+}) | |
/** | |
* This is left for backwards compatibility only. | |
@@ -15,13 +26,13 @@ import { useTranslate } from '~/hooks/actions' | |
* | |
* STRIPE_SUCCESS_URL_FORMAT=path | |
*/ | |
-const OrderConfirmation = () => { | |
+const OrderConfirmation = ({ catalog }) => { | |
const translate = useTranslate() | |
return ( | |
<> | |
<Metadata title={translate('confirmation.page_title')} /> | |
- <OrderConfirmationPage /> | |
+ <OrderConfirmationPage collections={catalog} /> | |
</> | |
) | |
} | |
diff --git a/pages/order/confirmation/[number]/[id].js b/pages/order/confirmation/[number]/[id].js | |
index 8a4ae04..cb2f25e 100644 | |
--- a/pages/order/confirmation/[number]/[id].js | |
+++ b/pages/order/confirmation/[number]/[id].js | |
@@ -3,8 +3,21 @@ import { useRouter } from 'next/router' | |
import { useTranslate } from '~/hooks/actions' | |
import Metadata from '~/components/Metadata' | |
import OrderConfirmationPage from '~/components/Order/Confirmation/Page' | |
+import { getCatalogWithReduxState } from 'graphql-cms/queries/catalog' | |
+import { wrapper as reduxWrapper } from '~/redux/store' | |
-const OrderConfirmation = () => { | |
+export const getServerSideProps = reduxWrapper.getServerSideProps( | |
+ store => async () => { | |
+ const catalog = await getCatalogWithReduxState({ slug: 'catalog' }, store) | |
+ return { | |
+ props: { | |
+ catalog | |
+ } | |
+ } | |
+ } | |
+) | |
+ | |
+const OrderConfirmation = ({ catalog }) => { | |
const translate = useTranslate() | |
const [data, setData] = useState({}) | |
@@ -26,6 +39,7 @@ const OrderConfirmation = () => { | |
<OrderConfirmationPage | |
orderNumber={data.number} | |
checkoutSessionId={data.id} | |
+ collections={catalog} | |
/> | |
</> | |
) | |
diff --git a/src/components/Generic/Ticker/index.jsx b/src/components/Generic/Ticker/index.jsx | |
new file mode 100644 | |
index 0000000..3bbc45a | |
--- /dev/null | |
+++ b/src/components/Generic/Ticker/index.jsx | |
@@ -0,0 +1,16 @@ | |
+import { useTicker } from '~/hooks/components/use-ticker' | |
+import { Text } from 'theme-ui' | |
+ | |
+export const Ticker = ({ futureDate, styles }) => { | |
+ const { days, hours, minutes, seconds } = useTicker(futureDate) | |
+ const tickerContents = ( | |
+ <> | |
+ {days > 0 && `${days} days, `} | |
+ {hours > 0 && `${hours} hours, `} | |
+ {minutes > 0 && `${minutes} minutes, `} | |
+ {`${seconds} seconds`} | |
+ </> | |
+ ) | |
+ | |
+ return <Text sx={styles}>{tickerContents}</Text> | |
+} | |
diff --git a/src/components/Order/Confirmation/Page/index.jsx b/src/components/Order/Confirmation/Page/index.jsx | |
index d4ad8f6..dcbe096 100644 | |
--- a/src/components/Order/Confirmation/Page/index.jsx | |
+++ b/src/components/Order/Confirmation/Page/index.jsx | |
@@ -20,14 +20,22 @@ const OrderConfirmationDetails = dynamic(() => | |
const OrderConfirmationContact = dynamic(() => | |
import('~/components/Order/Confirmation/Contact') | |
) | |
+const CrossSellContainer = dynamic(() => | |
+ import('~/components/Order/CrossSell/Container') | |
+) | |
-const OrderConfirmationPage = ({ orderNumber, checkoutSessionId }) => { | |
+const OrderConfirmationPage = ({ | |
+ orderNumber, | |
+ checkoutSessionId, | |
+ collections | |
+}) => { | |
const { loadUser } = useUser() | |
const { finalizeCheckout } = useCart() | |
const [cart, setCart] = useState(null) | |
const [referralPurl, setReferralPurl] = useState(null) | |
const [error, setError] = useState(null) | |
const [isLoading, setIsLoading] = useState(true) | |
+ const [showCrossSell, setShowCrossSell] = useState(true) | |
useEffect(() => { | |
const finalizeCheckoutAsync = async () => { | |
@@ -69,6 +77,15 @@ const OrderConfirmationPage = ({ orderNumber, checkoutSessionId }) => { | |
> | |
<OrderConfirmationDetails cart={cart} /> | |
+ {showCrossSell && ( | |
+ <CrossSellContainer | |
+ cart={cart} | |
+ setCart={setCart} | |
+ setShowCrossSell={setShowCrossSell} | |
+ collections={collections} | |
+ /> | |
+ )} | |
+ | |
{referralPurl && ( | |
<OrderConfirmationReferralPrompt purl={referralPurl} /> | |
)} | |
diff --git a/src/components/Order/CrossSell/AddToCartButton/index.jsx b/src/components/Order/CrossSell/AddToCartButton/index.jsx | |
new file mode 100644 | |
index 0000000..4bdc71d | |
--- /dev/null | |
+++ b/src/components/Order/CrossSell/AddToCartButton/index.jsx | |
@@ -0,0 +1,157 @@ | |
+/** @jsxImportSource theme-ui */ | |
+import { Fragment, useState, useCallback } from 'react' | |
+import { Button, Flex, Text, Spinner } from 'theme-ui' | |
+import { useTranslate } from '~/hooks/actions' | |
+import { toUsdCurrency } from '~/utils' | |
+import useVariantAvailability from '~/hooks/components/use-variant-availability' | |
+ | |
+const AddToCartButton = ({ | |
+ lineItemsAttributes, | |
+ setLineItemsAttributes, | |
+ disabled = false, | |
+ price, | |
+ quantity, | |
+ regularPrice, | |
+ sku, | |
+ interval = null, | |
+ ...props | |
+}) => { | |
+ const translate = useTranslate() | |
+ | |
+ const isAddedToCart = sku => | |
+ lineItemsAttributes.some(item => item.sku === sku) | |
+ const [addedToCart, setAddedToCart] = useState(() => isAddedToCart(sku)) | |
+ | |
+ const subscriptionAttributes = (sku, quantity, interval) => ({ | |
+ sku: sku, | |
+ quantity: quantity, | |
+ subscription_line_items_attributes: [ | |
+ { | |
+ interval_length: interval.length, | |
+ interval_units: interval.unit | |
+ } | |
+ ] | |
+ }) | |
+ | |
+ const handleClick = useCallback(() => { | |
+ if (disabled || isFetchingAvailability || !isAvailable) return | |
+ | |
+ setLineItemsAttributes(currentState => { | |
+ interval | |
+ ? currentState.push(subscriptionAttributes(sku, quantity, interval)) | |
+ : currentState.push({ sku: sku, quantity: quantity }) | |
+ | |
+ return currentState | |
+ }) | |
+ | |
+ setAddedToCart(isAddedToCart(sku)) | |
+ }, [ | |
+ disabled, | |
+ isFetchingAvailability, | |
+ isAvailable, | |
+ sku, | |
+ quantity, | |
+ interval, | |
+ isAddedToCart, | |
+ setAddedToCart, | |
+ setLineItemsAttributes | |
+ ]) | |
+ | |
+ const { | |
+ isAvailable, | |
+ isFetchingAvailability, | |
+ error: availabilityError | |
+ } = useVariantAvailability({ | |
+ sku | |
+ }) | |
+ | |
+ return ( | |
+ <> | |
+ <Button | |
+ onClick={handleClick} | |
+ disabled={disabled || isFetchingAvailability || !isAvailable} | |
+ {...props} | |
+ p={0} | |
+ type="submit" | |
+ sx={{ | |
+ width: '100%', | |
+ display: 'block', | |
+ fontSize: ['13px', null, '16px'] | |
+ }} | |
+ > | |
+ <Flex sx={{ height: '100%' }}> | |
+ <Flex | |
+ sx={{ | |
+ alignItems: 'center', | |
+ flexGrow: 1, | |
+ height: '100%', | |
+ justifyContent: 'center', | |
+ padding: ['0 16px', null, '0 32px'] | |
+ }} | |
+ > | |
+ {isFetchingAvailability && ( | |
+ <Spinner data-testid="spinner" size="15" color="inherit" /> | |
+ )} | |
+ {!isFetchingAvailability && ( | |
+ <Fragment> | |
+ {!isAvailable | |
+ ? translate('product.sold_out') | |
+ : addedToCart | |
+ ? translate('product.added_to_cart') | |
+ : interval | |
+ ? translate('subscriptions.subscribe') | |
+ : translate('product.add_to_cart')} | |
+ </Fragment> | |
+ )} | |
+ </Flex> | |
+ {!isFetchingAvailability && ( | |
+ <Flex | |
+ sx={{ | |
+ height: '100%', | |
+ alignItems: 'center', | |
+ backgroundColor: 'accent', | |
+ padding: ['0 16px', null, '0 32px'] | |
+ }} | |
+ > | |
+ {regularPrice !== price ? ( | |
+ <Flex | |
+ sx={{ | |
+ height: '100%', | |
+ flexDirection: 'column', | |
+ justifyContent: 'center', | |
+ padding: '10px 0' | |
+ }} | |
+ > | |
+ <Text | |
+ sx={{ | |
+ textDecoration: 'line-through', | |
+ color: 'white', | |
+ opacity: '.7', | |
+ variant: 'text.formLabel', | |
+ lineHeight: ['10px', null, '12px'], | |
+ marginTop: ['4px', null, '0'] | |
+ }} | |
+ > | |
+ {toUsdCurrency(regularPrice)} | |
+ </Text> | |
+ <Text sx={{ lineHeight: '1.5rem' }}> | |
+ {toUsdCurrency(price)} | |
+ </Text> | |
+ </Flex> | |
+ ) : ( | |
+ <Text>{toUsdCurrency(price)}</Text> | |
+ )} | |
+ </Flex> | |
+ )} | |
+ </Flex> | |
+ </Button> | |
+ {availabilityError && ( | |
+ <Text color="errorDark" variant="textLink" mt="1.5rem"> | |
+ {availabilityError.toString()} | |
+ </Text> | |
+ )} | |
+ </> | |
+ ) | |
+} | |
+ | |
+export default AddToCartButton | |
diff --git a/src/components/Order/CrossSell/CheckoutButton/index.jsx b/src/components/Order/CrossSell/CheckoutButton/index.jsx | |
new file mode 100644 | |
index 0000000..43e3ba6 | |
--- /dev/null | |
+++ b/src/components/Order/CrossSell/CheckoutButton/index.jsx | |
@@ -0,0 +1,75 @@ | |
+/** @jsxImportSource theme-ui */ | |
+import { useState, useEffect, useCallback } from 'react' | |
+import { useCart, useTranslate } from '~/hooks/actions' | |
+import { Button, Text, Spinner } from 'theme-ui' | |
+ | |
+const CheckoutButton = ({ | |
+ cart, | |
+ lineItemsAttributes, | |
+ setCart, | |
+ setShowCrossSell | |
+}) => { | |
+ const { crossSell } = useCart() | |
+ const translate = useTranslate() | |
+ const [buttonIsLoading, setButtonIsLoading] = useState(false) | |
+ const [apiError, setApiError] = useState(null) | |
+ | |
+ // cleanup on unmount | |
+ useEffect(() => { | |
+ return () => { | |
+ setApiError(null) | |
+ setButtonIsLoading(false) | |
+ } | |
+ }, []) | |
+ | |
+ const handleClick = useCallback(async () => { | |
+ setButtonIsLoading(true) | |
+ setApiError(null) | |
+ | |
+ try { | |
+ const response = await crossSell(cart, lineItemsAttributes) | |
+ | |
+ setCart(response.data) | |
+ setShowCrossSell(false) | |
+ } catch (error) { | |
+ setApiError(translate('error.api.default')) | |
+ } | |
+ | |
+ setButtonIsLoading(false) | |
+ }, [ | |
+ crossSell, | |
+ cart, | |
+ lineItemsAttributes, | |
+ translate, | |
+ setCart, | |
+ setShowCrossSell | |
+ ]) | |
+ | |
+ return ( | |
+ <> | |
+ <Button | |
+ onClick={handleClick} | |
+ sx={{ | |
+ marginTop: '1rem', | |
+ width: '100%' | |
+ }} | |
+ > | |
+ {buttonIsLoading && ( | |
+ <Spinner data-testid="spinner" size="15" color="inherit" /> | |
+ )} | |
+ | |
+ {!buttonIsLoading && ( | |
+ <Text variant="link"> {translate('confirmation.add_to_order')}</Text> | |
+ )} | |
+ </Button> | |
+ | |
+ {apiError && ( | |
+ <Text color="errorDark" variant="textLink" mt="1.5rem"> | |
+ {apiError} | |
+ </Text> | |
+ )} | |
+ </> | |
+ ) | |
+} | |
+ | |
+export default CheckoutButton | |
diff --git a/src/components/Order/CrossSell/Container/index.jsx b/src/components/Order/CrossSell/Container/index.jsx | |
new file mode 100644 | |
index 0000000..89ffaeb | |
--- /dev/null | |
+++ b/src/components/Order/CrossSell/Container/index.jsx | |
@@ -0,0 +1,80 @@ | |
+/** @jsxImportSource theme-ui */ | |
+import { Card, Text, Grid } from 'theme-ui' | |
+import { useTranslate } from '~/hooks/actions' | |
+import { Ticker } from '~/components/Generic/Ticker' | |
+import CheckoutButton from '../CheckoutButton' | |
+import CrossSellProduct from '../CrossSellProduct' | |
+import { useState } from 'react' | |
+ | |
+const CrossSellContainer = ({ | |
+ cart = {}, | |
+ setCart, | |
+ setShowCrossSell, | |
+ collections | |
+}) => { | |
+ const translate = useTranslate() | |
+ const { crossSellDetails } = cart | |
+ | |
+ const [lineItemsAttributes, setLineItemsAttributes] = useState([]) | |
+ | |
+ const futureDate = | |
+ crossSellDetails.crossSellable && new Date(crossSellDetails.crossSellEndsAt) | |
+ | |
+ // Selected first collection for demo purposes | |
+ const selectedCollection = collections[0] | |
+ const { items: products } = selectedCollection.productsCollection | |
+ const selectedProducts = [products.at(-1)] | |
+ const selectedSubscriptions = [products[0]] | |
+ const subscriptionsInterval = { length: 1, unit: 'month' } | |
+ | |
+ return ( | |
+ crossSellDetails.crossSellable && ( | |
+ <Card | |
+ sx={{ | |
+ width: '100%', | |
+ marginBottom: '1.5rem', | |
+ padding: ['32px 18px', '2.5rem'] | |
+ }} | |
+ > | |
+ <Text as="p" sx={{ marginBottom: '0.5rem' }}> | |
+ <strong>{translate('confirmation.limited_offer')}</strong> -{' '} | |
+ {translate('confirmation.limited_offer_text', { off_text: '15%' })} | |
+ <Ticker styles={{ color: '#9F1908' }} futureDate={futureDate} /> | |
+ </Text> | |
+ {selectedProducts && selectedProducts.length > 0 && ( | |
+ <Grid | |
+ columns={[1, 2]} | |
+ gap={['0.75rem', '2.25rem']} | |
+ p={['0 0.5rem 2rem 0.5rem', '0 0 2.25rem 0']} | |
+ > | |
+ {selectedProducts.map(product => ( | |
+ <CrossSellProduct | |
+ key={product.slug} | |
+ product={product} | |
+ setLineItemsAttributes={setLineItemsAttributes} | |
+ lineItemsAttributes={lineItemsAttributes} | |
+ /> | |
+ ))} | |
+ {selectedSubscriptions.map(product => ( | |
+ <CrossSellProduct | |
+ key={product.slug} | |
+ product={product} | |
+ setLineItemsAttributes={setLineItemsAttributes} | |
+ interval={subscriptionsInterval} | |
+ lineItemsAttributes={lineItemsAttributes} | |
+ /> | |
+ ))} | |
+ </Grid> | |
+ )} | |
+ <CheckoutButton | |
+ cart={cart} | |
+ lineItemsAttributes={lineItemsAttributes} | |
+ setCart={setCart} | |
+ setShowCrossSell={setShowCrossSell} | |
+ ></CheckoutButton> | |
+ </Card> | |
+ ) | |
+ ) | |
+} | |
+ | |
+export default CrossSellContainer | |
diff --git a/src/components/Order/CrossSell/CrossSellProduct/index.jsx b/src/components/Order/CrossSell/CrossSellProduct/index.jsx | |
new file mode 100644 | |
index 0000000..81a40a3 | |
--- /dev/null | |
+++ b/src/components/Order/CrossSell/CrossSellProduct/index.jsx | |
@@ -0,0 +1,85 @@ | |
+/** @jsxImportSource theme-ui */ | |
+import ResponsiveImage from '~/components/Generic/Image' | |
+import AddToCartButton from '../AddToCartButton' | |
+import { Box, Card, Flex, Heading, Text } from 'theme-ui' | |
+ | |
+const CrossSellProduct = ({ | |
+ product, | |
+ setLineItemsAttributes, | |
+ lineItemsAttributes, | |
+ interval = null | |
+}) => { | |
+ const { mainImage, name, shortDescription } = product | |
+ const { items: variants } = product.variantsCollection | |
+ | |
+ // Select the first variant for demo purposes and artifically drop price by 15% which should match the set promotion. | |
+ const { regularPrice, size, sku } = variants[0] | |
+ const price = regularPrice - regularPrice * 0.15 | |
+ | |
+ return ( | |
+ <Card | |
+ sx={{ | |
+ display: 'flex', | |
+ justifyContent: 'space-between', | |
+ alignItems: 'left', | |
+ flexDirection: 'column' | |
+ }} | |
+ > | |
+ <Flex | |
+ sx={{ | |
+ justifyContent: 'flex-start', | |
+ flexDirection: 'column' | |
+ }} | |
+ > | |
+ {mainImage && <ResponsiveImage image={mainImage} />} | |
+ | |
+ <Flex | |
+ pt={['0.5rem', '0.5rem', '1.25rem']} | |
+ sx={{ | |
+ justifyContent: 'flex-start', | |
+ alignItems: 'left', | |
+ flexDirection: 'column' | |
+ }} | |
+ > | |
+ <Heading | |
+ as="h3" | |
+ sx={{ | |
+ display: 'block', | |
+ textTransform: 'none', | |
+ variant: ['text.h3', 'text.h3', 'text.h3'], | |
+ textAlign: 'left', | |
+ marginBottom: ['4px', null, '6px'] | |
+ }} | |
+ > | |
+ {name} | |
+ </Heading> | |
+ {shortDescription} | |
+ </Flex> | |
+ </Flex> | |
+ <Box> | |
+ <Flex | |
+ color="primary" | |
+ sx={{ | |
+ justifyContent: 'space-between', | |
+ paddingTop: ['6px', null, '12px'], | |
+ paddingBottom: ['8px', '8px', '16px'] | |
+ }} | |
+ > | |
+ {size && <Text>{size}</Text>} | |
+ </Flex> | |
+ <AddToCartButton | |
+ price={price} | |
+ quantity={1} | |
+ regularPrice={regularPrice} | |
+ sku={sku} | |
+ sx={{ width: '100%' }} | |
+ setLineItemsAttributes={setLineItemsAttributes} | |
+ lineItemsAttributes={lineItemsAttributes} | |
+ interval={interval} | |
+ /> | |
+ </Box> | |
+ </Card> | |
+ ) | |
+} | |
+ | |
+export default CrossSellProduct | |
diff --git a/src/hooks/actions/use-cart.js b/src/hooks/actions/use-cart.js | |
index 1341e0e..1cc34c2 100644 | |
--- a/src/hooks/actions/use-cart.js | |
+++ b/src/hooks/actions/use-cart.js | |
@@ -67,6 +67,11 @@ export function useCart() { | |
[dispatch] | |
) | |
+ const crossSell = useMemo( | |
+ () => bindActionCreators(actions.crossSell, dispatch), | |
+ [dispatch] | |
+ ) | |
+ | |
return { | |
cart, | |
addPromoCode, | |
@@ -80,6 +85,7 @@ export function useCart() { | |
modifyGiftCards, | |
removeFromCart, | |
removePromoCode, | |
- subscribeProduct | |
+ subscribeProduct, | |
+ crossSell | |
} | |
} | |
diff --git a/src/hooks/components/use-ticker.js b/src/hooks/components/use-ticker.js | |
new file mode 100644 | |
index 0000000..ffef612 | |
--- /dev/null | |
+++ b/src/hooks/components/use-ticker.js | |
@@ -0,0 +1,30 @@ | |
+import { useEffect, useState } from 'react' | |
+import isBefore from 'date-fns/isBefore' | |
+import intervalToDuration from 'date-fns/intervalToDuration' | |
+ | |
+export const useTicker = futureDate => { | |
+ const [now, setNow] = useState(new Date()) | |
+ | |
+ useEffect(() => { | |
+ const interval = setInterval(() => { | |
+ setNow(new Date()) | |
+ }, 1000) | |
+ | |
+ return () => { | |
+ clearInterval(interval) | |
+ } | |
+ }, [futureDate]) | |
+ | |
+ const isTimeUp = isBefore(futureDate, now) | |
+ | |
+ if (isTimeUp) { | |
+ return { days: 0, hours: 0, minutes: 0, seconds: 0, isTimeUp } | |
+ } | |
+ | |
+ let { days, hours, minutes, seconds } = intervalToDuration({ | |
+ start: now, | |
+ end: futureDate | |
+ }) | |
+ | |
+ return { days, hours, minutes, seconds, isTimeUp } | |
+} | |
diff --git a/src/redux/actions/cart/cross-sell.js b/src/redux/actions/cart/cross-sell.js | |
new file mode 100644 | |
index 0000000..6910f75 | |
--- /dev/null | |
+++ b/src/redux/actions/cart/cross-sell.js | |
@@ -0,0 +1,35 @@ | |
+import { | |
+ CROSS_SELL_ERROR, | |
+ CROSS_SELL_SUCCESS, | |
+ CROSS_SELL_REQUEST | |
+} from '~/redux/actions/types' | |
+ | |
+export const crossSellRequest = lineItemsAttributes => ({ | |
+ type: CROSS_SELL_REQUEST, | |
+ data: lineItemsAttributes | |
+}) | |
+ | |
+export const crossSellSuccess = response => ({ | |
+ type: CROSS_SELL_SUCCESS, | |
+ data: response | |
+}) | |
+ | |
+export const crossSellError = (error, meta = {}) => ({ | |
+ type: CROSS_SELL_ERROR, | |
+ error: true, | |
+ meta, | |
+ payload: error | |
+}) | |
+ | |
+export const crossSell = | |
+ (cart, lineItemsAttributes) => | |
+ async (dispatch, _, { api }) => { | |
+ try { | |
+ dispatch(crossSellRequest(lineItemsAttributes)) | |
+ const response = await api.crossSell(cart, lineItemsAttributes) | |
+ dispatch(crossSellSuccess(response)) | |
+ return response | |
+ } catch (error) { | |
+ dispatch(crossSellError(error)) | |
+ } | |
+ } | |
diff --git a/src/redux/actions/cart/index.js b/src/redux/actions/cart/index.js | |
index a4ac1dc..30b602e 100644 | |
--- a/src/redux/actions/cart/index.js | |
+++ b/src/redux/actions/cart/index.js | |
@@ -16,3 +16,4 @@ export * from './get-user-wallet' | |
export * from './update-order-addresses' | |
export * from './update-order-delivery' | |
export * from './update-order-payment' | |
+export * from './cross-sell' | |
diff --git a/src/redux/actions/types.js b/src/redux/actions/types.js | |
index 2fad0de..d5490f0 100644 | |
--- a/src/redux/actions/types.js | |
+++ b/src/redux/actions/types.js | |
@@ -108,6 +108,9 @@ export const UPDATE_ORDER_PAYMENT_ERROR = 'UPDATE_ORDER_PAYMENT_ERROR' | |
export const SEED_NOTIFICATIONS = 'SEED_NOTIFICATIONS' | |
export const ADD_NOTIFICATION = 'ADD_NOTIFICATION' | |
export const REMOVE_NOTIFICATION = 'REMOVE_NOTIFICATION' | |
+export const CROSS_SELL_ERROR = 'CROSS_SELL_ERROR' | |
+export const CROSS_SELL_SUCCESS = 'CROSS_SELL_SUCCESS' | |
+export const CROSS_SELL_REQUEST = 'CROSS_SELL_REQUEST' | |
const status = { | |
Idle: 'idle', | |
diff --git a/src/redux/reducers/cart.js b/src/redux/reducers/cart.js | |
index 6ed44b4..aefb40c 100644 | |
--- a/src/redux/reducers/cart.js | |
+++ b/src/redux/reducers/cart.js | |
@@ -40,7 +40,10 @@ import { | |
UPDATE_ORDER_DELIVERY_ERROR, | |
UPDATE_ORDER_PAYMENT_REQUEST, | |
UPDATE_ORDER_PAYMENT_SUCCESS, | |
- UPDATE_ORDER_PAYMENT_ERROR | |
+ UPDATE_ORDER_PAYMENT_ERROR, | |
+ CROSS_SELL_ERROR, | |
+ CROSS_SELL_SUCCESS, | |
+ CROSS_SELL_REQUEST | |
} from '~/redux/actions/types' | |
import initialState from '~/redux/store/initial-state' | |
@@ -54,6 +57,7 @@ const cart = (state = initialState.cart, action) => { | |
case UPDATE_ORDER_ADDRESSES_REQUEST: | |
case UPDATE_ORDER_DELIVERY_REQUEST: | |
case UPDATE_ORDER_PAYMENT_REQUEST: | |
+ case CROSS_SELL_REQUEST: | |
return { | |
...state, | |
isFetching: true, | |
@@ -80,6 +84,7 @@ const cart = (state = initialState.cart, action) => { | |
case UPDATE_ORDER_ADDRESSES_SUCCESS: | |
case UPDATE_ORDER_DELIVERY_SUCCESS: | |
case UPDATE_ORDER_PAYMENT_SUCCESS: | |
+ case CROSS_SELL_SUCCESS: | |
return { | |
...state, | |
isFetching: false, | |
@@ -103,6 +108,7 @@ const cart = (state = initialState.cart, action) => { | |
case UPDATE_ORDER_ADDRESSES_ERROR: | |
case UPDATE_ORDER_DELIVERY_ERROR: | |
case UPDATE_ORDER_PAYMENT_ERROR: | |
+ case CROSS_SELL_ERROR: | |
return { | |
...state, | |
error: action.payload, | |
diff --git a/src/redux/reducers/cart.spec.js b/src/redux/reducers/cart.spec.js | |
index 190f8a2..53c1c34 100644 | |
--- a/src/redux/reducers/cart.spec.js | |
+++ b/src/redux/reducers/cart.spec.js | |
@@ -40,7 +40,10 @@ import { | |
UPDATE_ORDER_DELIVERY_ERROR, | |
UPDATE_ORDER_PAYMENT_REQUEST, | |
UPDATE_ORDER_PAYMENT_SUCCESS, | |
- UPDATE_ORDER_PAYMENT_ERROR | |
+ UPDATE_ORDER_PAYMENT_ERROR, | |
+ CROSS_SELL_ERROR, | |
+ CROSS_SELL_SUCCESS, | |
+ CROSS_SELL_REQUEST | |
} from '~/redux/actions/types' | |
import initialState from '~/redux/store/initial-state' | |
import { mockedFullCart } from '~/__mocks__/acs/cart_full' | |
@@ -726,4 +729,50 @@ describe('cart reducer', () => { | |
error | |
}) | |
}) | |
+ | |
+ it('should handle CROSS_SELL_REQUEST', () => { | |
+ expect( | |
+ cart( | |
+ {}, | |
+ { | |
+ type: CROSS_SELL_REQUEST | |
+ } | |
+ ) | |
+ ).toEqual({ | |
+ isFetching: true, | |
+ error: null | |
+ }) | |
+ }) | |
+ | |
+ it('should handle CROSS_SELL_SUCCESS', () => { | |
+ expect( | |
+ cart( | |
+ {}, | |
+ { | |
+ type: CROSS_SELL_SUCCESS, | |
+ data: {} | |
+ } | |
+ ) | |
+ ).toEqual({ | |
+ data: {}, | |
+ isFetching: false, | |
+ error: null | |
+ }) | |
+ }) | |
+ | |
+ it('should handle CROSS_SELL_ERROR', () => { | |
+ expect( | |
+ cart( | |
+ {}, | |
+ { | |
+ type: CROSS_SELL_ERROR, | |
+ error: true, | |
+ payload: error | |
+ } | |
+ ) | |
+ ).toEqual({ | |
+ isFetching: false, | |
+ error | |
+ }) | |
+ }) | |
}) | |
diff --git a/src/services/api/index.js b/src/services/api/index.js | |
index b24f5fe..1c6d4c0 100644 | |
--- a/src/services/api/index.js | |
+++ b/src/services/api/index.js | |
@@ -386,6 +386,25 @@ class Api { | |
const response = await axiosClient.post(url, props, config) | |
return response.data | |
} | |
+ | |
+ async crossSell(cart, lineItemsAttributes) { | |
+ const url = `/api/orders/${cart.number}/cross_sell` | |
+ const config = { | |
+ transformRequest: [ | |
+ (data, headers) => { | |
+ headers['X-Spree-Order-Token'] = cart.token | |
+ return JSON.stringify(data) | |
+ } | |
+ ] | |
+ } | |
+ const payload = { | |
+ order: { | |
+ line_items_attributes: lineItemsAttributes | |
+ } | |
+ } | |
+ const response = await axiosClient.put(url, payload, config) | |
+ return response.data | |
+ } | |
} | |
const api = new Api() | |
diff --git a/src/services/api/index.test.js b/src/services/api/index.test.js | |
index b4f722a..5a029db 100644 | |
--- a/src/services/api/index.test.js | |
+++ b/src/services/api/index.test.js | |
@@ -103,6 +103,28 @@ describe('api', () => { | |
}) | |
}) | |
+ describe('crossSell', () => { | |
+ const cart = { number: 'CART-ID', token: 'test' } | |
+ const lineItemsAttributes = [{ sku: 'product-4', quantity: 8 }] | |
+ const payload = { order: { line_items_attributes: lineItemsAttributes } } | |
+ | |
+ it('puts the request to the cart endpoint', () => { | |
+ axios.put = jest.fn().mockImplementation(() => { | |
+ return {} | |
+ }) | |
+ | |
+ api.crossSell(cart, lineItemsAttributes) | |
+ | |
+ expect(axios.put).toHaveBeenCalledWith( | |
+ `/api/orders/${cart.number}/cross_sell`, | |
+ payload, | |
+ { | |
+ transformRequest: expect.any(Array) | |
+ } | |
+ ) | |
+ }) | |
+ }) | |
+ | |
describe('getStates', () => { | |
it('calls the states endpoint', async () => { | |
const iso = 'US' | |
diff --git a/yarn.lock b/yarn.lock | |
index dda9b99..389878a 100644 | |
--- a/yarn.lock | |
+++ b/yarn.lock | |
@@ -3519,6 +3519,11 @@ data-urls@^2.0.0: | |
whatwg-mimetype "^2.3.0" | |
whatwg-url "^8.0.0" | |
+date-fns@^2.28.0: | |
+ version "2.28.0" | |
+ resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.28.0.tgz#9570d656f5fc13143e50c975a3b6bbeb46cd08b2" | |
+ integrity sha512-8d35hViGYx/QH0icHYCeLmsLmMUheMmTyV9Fcm6gvNwdw31yXXH+O85sOBJ+OLnLQMKZowvpKb6FgMIQjcpvQw== | |
+ | |
debug@4, debug@^4.1.0: | |
version "4.3.1" | |
resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.1.tgz#f0d229c505e0c6d8c49ac553d1b13dc183f6b2ee" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment