From 94a145b6044beec7c8f236c7e5d8decbe8dce184 Mon Sep 17 00:00:00 2001 From: Chloe Date: Sun, 8 May 2022 13:04:25 +0700 Subject: [PATCH] Add custome checkout function Signed-off-by: Chloe --- packages/opencommerce/.env.template | 4 +- packages/opencommerce/global.d.ts | 1 + packages/opencommerce/package.json | 3 +- .../src/api/endpoints/cart/update-item.ts | 1 - .../address.ts => checkout/get-checkout.ts} | 0 .../src/api/endpoints/checkout/index.ts | 24 +- .../api/endpoints/checkout/submit-checkout.ts | 96 ++++++++ .../endpoints/customer/address/add-item.ts | 73 ++++++ .../customer/address/get-addresses.ts | 9 + .../api/endpoints/customer/address/index.ts | 30 +++ .../endpoints/customer/address/remove-item.ts | 9 + .../endpoints/customer/address/update-item.ts | 9 + .../src/api/endpoints/customer/card.ts | 1 - .../api/endpoints/customer/card/add-item.ts | 43 ++++ .../api/endpoints/customer/card/get-cards.ts | 9 + .../src/api/endpoints/customer/card/index.ts | 26 +++ .../src/api/mutations/add-shipping-address.ts | 13 ++ .../src/api/mutations/place-order.ts | 211 ++++++++++++++++++ .../mutations/select-fulfillment-options.ts | 14 ++ .../mutations/set-email-on-anonymous-cart.ts | 15 ++ .../mutations/update-fulfillment-options.ts | 15 ++ packages/opencommerce/src/checkout/index.ts | 2 + .../src/checkout/use-checkout.tsx | 47 +++- .../src/checkout/use-submit-checkout.ts | 32 +++ .../opencommerce/src/commerce.config.json | 4 +- .../src/customer/address/index.ts | 1 + .../src/customer/address/use-add-item.tsx | 35 ++- .../opencommerce/src/customer/card/index.ts | 2 + .../src/customer/card/use-add-item.tsx | 26 ++- .../src/customer/card/use-cards.ts | 32 +++ packages/opencommerce/src/provider.ts | 14 +- packages/opencommerce/src/types/cart.ts | 4 +- packages/opencommerce/src/types/checkout.ts | 1 + .../src/types/customer/address.ts | 1 + .../opencommerce/src/types/customer/card.ts | 1 + packages/opencommerce/src/utils/normalize.ts | 1 + packages/opencommerce/tsconfig.json | 18 +- site/.env.template | 1 + .../CheckoutSidebarView.tsx | 15 +- .../PaymentMethodView/PaymentMethodView.tsx | 1 - yarn.lock | 75 +++++-- 41 files changed, 860 insertions(+), 59 deletions(-) create mode 100644 packages/opencommerce/global.d.ts rename packages/opencommerce/src/api/endpoints/{customer/address.ts => checkout/get-checkout.ts} (100%) create mode 100644 packages/opencommerce/src/api/endpoints/checkout/submit-checkout.ts create mode 100644 packages/opencommerce/src/api/endpoints/customer/address/add-item.ts create mode 100644 packages/opencommerce/src/api/endpoints/customer/address/get-addresses.ts create mode 100644 packages/opencommerce/src/api/endpoints/customer/address/index.ts create mode 100644 packages/opencommerce/src/api/endpoints/customer/address/remove-item.ts create mode 100644 packages/opencommerce/src/api/endpoints/customer/address/update-item.ts delete mode 100644 packages/opencommerce/src/api/endpoints/customer/card.ts create mode 100644 packages/opencommerce/src/api/endpoints/customer/card/add-item.ts create mode 100644 packages/opencommerce/src/api/endpoints/customer/card/get-cards.ts create mode 100644 packages/opencommerce/src/api/endpoints/customer/card/index.ts create mode 100644 packages/opencommerce/src/api/mutations/add-shipping-address.ts create mode 100644 packages/opencommerce/src/api/mutations/place-order.ts create mode 100644 packages/opencommerce/src/api/mutations/select-fulfillment-options.ts create mode 100644 packages/opencommerce/src/api/mutations/set-email-on-anonymous-cart.ts create mode 100644 packages/opencommerce/src/api/mutations/update-fulfillment-options.ts create mode 100644 packages/opencommerce/src/checkout/index.ts create mode 100644 packages/opencommerce/src/checkout/use-submit-checkout.ts create mode 100644 packages/opencommerce/src/customer/address/index.ts create mode 100644 packages/opencommerce/src/customer/card/index.ts create mode 100644 packages/opencommerce/src/customer/card/use-cards.ts create mode 100644 packages/opencommerce/src/types/checkout.ts create mode 100644 packages/opencommerce/src/types/customer/address.ts create mode 100644 packages/opencommerce/src/types/customer/card.ts diff --git a/packages/opencommerce/.env.template b/packages/opencommerce/.env.template index c64c3bd66..cf92d6262 100644 --- a/packages/opencommerce/.env.template +++ b/packages/opencommerce/.env.template @@ -1,4 +1,6 @@ COMMERCE_PROVIDER=@vercel/commerce-opencommerce OPENCOMMERCE_STOREFRONT_API_URL= -OPENCOMMERCE_PRIMARY_SHOP_ID= \ No newline at end of file +OPENCOMMERCE_PRIMARY_SHOP_ID= +OPENCOMMERCE_IMAGE_DOMAIN= +OPENCOMMERCE_STRIPE_API_KEY= \ No newline at end of file diff --git a/packages/opencommerce/global.d.ts b/packages/opencommerce/global.d.ts new file mode 100644 index 000000000..3fc378752 --- /dev/null +++ b/packages/opencommerce/global.d.ts @@ -0,0 +1 @@ +declare module '@components/checkout/context' diff --git a/packages/opencommerce/package.json b/packages/opencommerce/package.json index 7ace2bcd5..fce7c4979 100644 --- a/packages/opencommerce/package.json +++ b/packages/opencommerce/package.json @@ -50,7 +50,8 @@ "dependencies": { "@vercel/commerce": "^0.0.1", "@vercel/fetch": "^6.1.1", - "graphql": "^16.3.0" + "graphql": "^16.3.0", + "stripe": "^8.220.0" }, "peerDependencies": { "next": "^12", diff --git a/packages/opencommerce/src/api/endpoints/cart/update-item.ts b/packages/opencommerce/src/api/endpoints/cart/update-item.ts index ebe44d1c8..6645544f7 100644 --- a/packages/opencommerce/src/api/endpoints/cart/update-item.ts +++ b/packages/opencommerce/src/api/endpoints/cart/update-item.ts @@ -2,7 +2,6 @@ import { normalizeCart } from '../../../utils/normalize' import getCartCookie from '../../utils/get-cart-cookie' import updateCartItemsQuantityMutation from '../../mutations/update-cart-item-quantity' import type { CartEndpoint } from '.' -import { UpdateCartItemsQuantityPayload } from '../../../../schema' const updateItem: CartEndpoint['handlers']['updateItem'] = async ({ res, diff --git a/packages/opencommerce/src/api/endpoints/customer/address.ts b/packages/opencommerce/src/api/endpoints/checkout/get-checkout.ts similarity index 100% rename from packages/opencommerce/src/api/endpoints/customer/address.ts rename to packages/opencommerce/src/api/endpoints/checkout/get-checkout.ts diff --git a/packages/opencommerce/src/api/endpoints/checkout/index.ts b/packages/opencommerce/src/api/endpoints/checkout/index.ts index 491bf0ac9..9807b93d3 100644 --- a/packages/opencommerce/src/api/endpoints/checkout/index.ts +++ b/packages/opencommerce/src/api/endpoints/checkout/index.ts @@ -1 +1,23 @@ -export default function noopApi(...args: any[]): void {} +import { GetAPISchema, createEndpoint } from '@vercel/commerce/api' +import checkoutEndpoint from '@vercel/commerce/api/endpoints/checkout' +import type { CheckoutSchema } from '../../../types/checkout' +import type { OpenCommerceAPI } from '../..' + +import submitCheckout from './submit-checkout' +import getCheckout from './get-checkout' + +export type CheckoutAPI = GetAPISchema + +export type CheckoutEndpoint = CheckoutAPI['endpoint'] + +export const handlers: CheckoutEndpoint['handlers'] = { + submitCheckout, + getCheckout, +} + +const checkoutApi = createEndpoint({ + handler: checkoutEndpoint, + handlers, +}) + +export default checkoutApi diff --git a/packages/opencommerce/src/api/endpoints/checkout/submit-checkout.ts b/packages/opencommerce/src/api/endpoints/checkout/submit-checkout.ts new file mode 100644 index 000000000..84e06e85f --- /dev/null +++ b/packages/opencommerce/src/api/endpoints/checkout/submit-checkout.ts @@ -0,0 +1,96 @@ +import Stripe from 'stripe' + +import type { CardFields } from '../../../types/customer/card' +import { LineItem } from '../../../types/cart' +import placeOrder from '../../mutations/place-order' +import setEmailOnAnonymousCart from '../../mutations/set-email-on-anonymous-cart' +import getCartCookie from '../../utils/get-cart-cookie' +import type { CheckoutEndpoint } from '.' + +const stripe = new Stripe(process.env.OPENCOMMERCE_STRIPE_API_KEY as string, { + apiVersion: '2020-08-27', +}) + +const submitCheckout: CheckoutEndpoint['handlers']['submitCheckout'] = async ({ + res, + body: { item, cartId }, + config: { fetch, shopId, anonymousCartTokenCookie, cartCookie }, + req: { cookies }, +}) => { + await fetch(setEmailOnAnonymousCart, { + variables: { + input: { + cartId, + cartToken: cookies[anonymousCartTokenCookie], + email: 'opencommerce@test.com', + }, + }, + }) + + const card = item.card as CardFields + + const pm = await stripe.paymentMethods.create({ + type: 'card', + card: { + number: card.cardNumber, + exp_month: Number(card.cardExpireDate.split('/')[0]), + exp_year: Number(card.cardExpireDate.split('/')[1]), + cvc: card.cardCvc, + }, + } as Stripe.PaymentMethodCreateParams) + + const result = await stripe.paymentIntents.create({ + confirm: true, + amount: Math.round(item.checkout.cart.checkout.summary.total.amount * 100), + currency: item.checkout.cart.currency.code, + capture_method: 'manual', + metadata: { + integration_check: 'accept_a_payment', + }, + payment_method: pm.id, + }) + + if (result.status === 'succeeded' || result.status === 'requires_capture') { + const { data } = await fetch(placeOrder, { + variables: { + input: { + payments: { + data: { stripePaymentIntentId: result.id }, + amount: item.checkout.cart.checkout.summary.total.amount, + method: 'stripe_payment_intent', + }, + order: { + cartId, + currencyCode: item.checkout.cart.currency.code, + email: 'opencommerce@test.com', + shopId, + fulfillmentGroups: { + shopId, + data: item.checkout.cart.checkout.fulfillmentGroups[0].data, + items: item.checkout.cart.lineItems.map((item: LineItem) => ({ + price: item.variant.price, + quantity: item.quantity, + productConfiguration: { + productId: item.productId, + productVariantId: item.variantId, + }, + })), + type: item.checkout.cart.checkout.fulfillmentGroups[0].type, + selectedFulfillmentMethodId: + item.checkout.cart.checkout.fulfillmentGroups[0] + .selectedFulfillmentOption.fulfillmentMethod._id, + }, + }, + }, + }, + }) + + res.setHeader('Set-Cookie', [ + getCartCookie(cartCookie), + getCartCookie(anonymousCartTokenCookie), + ]) + } + res.status(200).json({ data: null, errors: [] }) +} + +export default submitCheckout diff --git a/packages/opencommerce/src/api/endpoints/customer/address/add-item.ts b/packages/opencommerce/src/api/endpoints/customer/address/add-item.ts new file mode 100644 index 000000000..229eed701 --- /dev/null +++ b/packages/opencommerce/src/api/endpoints/customer/address/add-item.ts @@ -0,0 +1,73 @@ +import setShippingAddressOnCartMutation from '../../../mutations/add-shipping-address' +import type { CustomerAddressEndpoint } from '.' +import updateFulfillmentOptions from '../../../mutations/update-fulfillment-options' +import selectFulfillmentOptions from '../../../mutations/select-fulfillment-options' + +const addItem: CustomerAddressEndpoint['handlers']['addItem'] = async ({ + res, + body: { item, cartId }, + config: { fetch, anonymousCartTokenCookie }, + req: { cookies }, +}) => { + // Return an error if no cart is present + if (!cartId) { + return res.status(400).json({ + data: null, + errors: [{ message: 'Cookie not found' }], + }) + } + + // Register address + const { + data: { setShippingAddressOnCart }, + } = await fetch(setShippingAddressOnCartMutation, { + variables: { + input: { + address: { + address1: item.streetNumber || 'NextJS storefront', + country: item.country, + fullName: `${item.firstName || 'Test'} ${ + item.lastName || 'Account' + }}`, + city: item.city || 'LA', + phone: '0123456789', + postal: item.zipCode || '1234567', + region: item.city || 'LA', + }, + cartId, + cartToken: cookies[anonymousCartTokenCookie], + }, + }, + }) + + const { + data: { updateFulfillmentOptionsForGroup }, + } = await fetch(updateFulfillmentOptions, { + variables: { + input: { + cartId, + fulfillmentGroupId: + setShippingAddressOnCart.cart.checkout.fulfillmentGroups[0]._id, + }, + }, + }) + + await fetch(selectFulfillmentOptions, { + variables: { + input: { + cartId, + cartToken: cookies[anonymousCartTokenCookie], + fulfillmentGroupId: + updateFulfillmentOptionsForGroup.cart.checkout.fulfillmentGroups[0] + ._id, + fulfillmentMethodId: + updateFulfillmentOptionsForGroup.cart.checkout.fulfillmentGroups[0] + .availableFulfillmentOptions[0].fulfillmentMethod._id, + }, + }, + }) + + return res.status(200).json({ data: null, errors: [] }) +} + +export default addItem diff --git a/packages/opencommerce/src/api/endpoints/customer/address/get-addresses.ts b/packages/opencommerce/src/api/endpoints/customer/address/get-addresses.ts new file mode 100644 index 000000000..2e27591c0 --- /dev/null +++ b/packages/opencommerce/src/api/endpoints/customer/address/get-addresses.ts @@ -0,0 +1,9 @@ +import type { CustomerAddressEndpoint } from '.' + +const getCards: CustomerAddressEndpoint['handlers']['getAddresses'] = async ({ + res, +}) => { + return res.status(200).json({ data: null, errors: [] }) +} + +export default getCards diff --git a/packages/opencommerce/src/api/endpoints/customer/address/index.ts b/packages/opencommerce/src/api/endpoints/customer/address/index.ts new file mode 100644 index 000000000..d1a7f6fca --- /dev/null +++ b/packages/opencommerce/src/api/endpoints/customer/address/index.ts @@ -0,0 +1,30 @@ +import type { CustomerAddressSchema } from '../../../../types/customer/address' +import type { OpenCommerceAPI } from '../../..' + +import { GetAPISchema, createEndpoint } from '@vercel/commerce/api' +import customerAddressEndpoint from '@vercel/commerce/api/endpoints/customer/address' + +import getAddresses from './get-addresses' +import addItem from './add-item' +import updateItem from './update-item' +import removeItem from './remove-item' + +export type CustomerAddressAPI = GetAPISchema< + OpenCommerceAPI, + CustomerAddressSchema +> +export type CustomerAddressEndpoint = CustomerAddressAPI['endpoint'] + +export const handlers: CustomerAddressEndpoint['handlers'] = { + getAddresses, + addItem, + updateItem, + removeItem, +} + +const customerAddressApi = createEndpoint({ + handler: customerAddressEndpoint, + handlers, +}) + +export default customerAddressApi diff --git a/packages/opencommerce/src/api/endpoints/customer/address/remove-item.ts b/packages/opencommerce/src/api/endpoints/customer/address/remove-item.ts new file mode 100644 index 000000000..fba4e1154 --- /dev/null +++ b/packages/opencommerce/src/api/endpoints/customer/address/remove-item.ts @@ -0,0 +1,9 @@ +import type { CustomerAddressEndpoint } from '.' + +const removeItem: CustomerAddressEndpoint['handlers']['removeItem'] = async ({ + res, +}) => { + return res.status(200).json({ data: null, errors: [] }) +} + +export default removeItem diff --git a/packages/opencommerce/src/api/endpoints/customer/address/update-item.ts b/packages/opencommerce/src/api/endpoints/customer/address/update-item.ts new file mode 100644 index 000000000..4c4b4b9ae --- /dev/null +++ b/packages/opencommerce/src/api/endpoints/customer/address/update-item.ts @@ -0,0 +1,9 @@ +import type { CustomerAddressEndpoint } from '.' + +const updateItem: CustomerAddressEndpoint['handlers']['updateItem'] = async ({ + res, +}) => { + return res.status(200).json({ data: null, errors: [] }) +} + +export default updateItem diff --git a/packages/opencommerce/src/api/endpoints/customer/card.ts b/packages/opencommerce/src/api/endpoints/customer/card.ts deleted file mode 100644 index 491bf0ac9..000000000 --- a/packages/opencommerce/src/api/endpoints/customer/card.ts +++ /dev/null @@ -1 +0,0 @@ -export default function noopApi(...args: any[]): void {} diff --git a/packages/opencommerce/src/api/endpoints/customer/card/add-item.ts b/packages/opencommerce/src/api/endpoints/customer/card/add-item.ts new file mode 100644 index 000000000..bba058d65 --- /dev/null +++ b/packages/opencommerce/src/api/endpoints/customer/card/add-item.ts @@ -0,0 +1,43 @@ +import type { CustomerCardEndpoint } from '.' +import createPaymentIntent from '../../../mutations/create-payment-intent' + +const addItem: CustomerCardEndpoint['handlers']['addItem'] = async ({ + res, + body: { item, cartId }, + config, + req: { cookies }, +}) => { + // Return an error if no item is present + if (!item) { + return res.status(400).json({ + data: null, + errors: [{ message: 'Missing item' }], + }) + } + + // Return an error if no cart is present + if (!cartId) { + return res.status(400).json({ + data: null, + errors: [{ message: 'Cookie not found' }], + }) + } + + const { + data: { createStripePaymentIntent }, + } = await config.fetch(createPaymentIntent, { + variables: { + input: { + cartId, + shopId: config.shopId, + cartToken: cookies[config.anonymousCartTokenCookie], + }, + }, + }) + + return res.status(200).json({ + data: createStripePaymentIntent.paymentIntentClientSecret, + }) +} + +export default addItem diff --git a/packages/opencommerce/src/api/endpoints/customer/card/get-cards.ts b/packages/opencommerce/src/api/endpoints/customer/card/get-cards.ts new file mode 100644 index 000000000..e77520803 --- /dev/null +++ b/packages/opencommerce/src/api/endpoints/customer/card/get-cards.ts @@ -0,0 +1,9 @@ +import type { CustomerCardEndpoint } from '.' + +const getCards: CustomerCardEndpoint['handlers']['getCards'] = async ({ + res, +}) => { + return res.status(200).json({ data: null, errors: [] }) +} + +export default getCards diff --git a/packages/opencommerce/src/api/endpoints/customer/card/index.ts b/packages/opencommerce/src/api/endpoints/customer/card/index.ts new file mode 100644 index 000000000..cbabf452b --- /dev/null +++ b/packages/opencommerce/src/api/endpoints/customer/card/index.ts @@ -0,0 +1,26 @@ +import { GetAPISchema, createEndpoint } from '@vercel/commerce/api' +import customerCardEndpoint from '@vercel/commerce/api/endpoints/customer/card' +import { CustomerCardSchema } from '../../../../types/customer/card' +import { OpenCommerceAPI } from '../../..' + +import getCards from './get-cards' +import addItem from './add-item' +// import updateItem from './update-item' +// import removeItem from './remove-item' + +export type CustomerCardAPI = GetAPISchema +export type CustomerCardEndpoint = CustomerCardAPI['endpoint'] + +export const handlers: CustomerCardEndpoint['handlers'] = { + getCards, + addItem, + updateItem: () => {}, + removeItem: () => {}, +} + +const customerCardApi = createEndpoint({ + handler: customerCardEndpoint, + handlers, +}) + +export default customerCardApi diff --git a/packages/opencommerce/src/api/mutations/add-shipping-address.ts b/packages/opencommerce/src/api/mutations/add-shipping-address.ts new file mode 100644 index 000000000..165a26676 --- /dev/null +++ b/packages/opencommerce/src/api/mutations/add-shipping-address.ts @@ -0,0 +1,13 @@ +import { cartPayloadFragment } from '../queries/get-cart-query' + +const setShippingAddressOnCartMutation = ` + mutation setShippingAddressOnCartMutation($input: SetShippingAddressOnCartInput!) { + setShippingAddressOnCart(input: $input) { + cart { + ${cartPayloadFragment} + } + } + } +` + +export default setShippingAddressOnCartMutation diff --git a/packages/opencommerce/src/api/mutations/place-order.ts b/packages/opencommerce/src/api/mutations/place-order.ts new file mode 100644 index 000000000..326045f35 --- /dev/null +++ b/packages/opencommerce/src/api/mutations/place-order.ts @@ -0,0 +1,211 @@ +const orderCommon = ` +_id + account { + _id + } + cartId + createdAt + displayStatus(language: $language) + email + fulfillmentGroups { + _id + data { + ... on ShippingOrderFulfillmentGroupData { + shippingAddress { + _id + address1 + address2 + city + company + country + fullName + isCommercial + isShippingDefault + phone + postal + region + } + } + } + items { + nodes { + _id + addedAt + createdAt + imageURLs { + large + medium + original + small + thumbnail + } + isTaxable + optionTitle + parcel { + containers + distanceUnit + height + length + massUnit + weight + width + } + price { + amount + currency { + code + } + displayAmount + } + productConfiguration { + productId + productVariantId + } + productSlug + productType + productVendor + productTags { + nodes { + name + } + } + quantity + shop { + _id + } + subtotal { + amount + currency { + code + } + displayAmount + } + taxCode + title + updatedAt + variantTitle + } + } + selectedFulfillmentOption { + fulfillmentMethod { + _id + carrier + displayName + fulfillmentTypes + group + name + } + handlingPrice { + amount + currency { + code + } + displayAmount + } + price { + amount + currency { + code + } + displayAmount + } + } + shop { + _id + } + summary { + fulfillmentTotal { + amount + displayAmount + } + itemTotal { + amount + displayAmount + } + surchargeTotal { + amount + displayAmount + } + taxTotal { + amount + displayAmount + } + total { + amount + displayAmount + } + } + tracking + type + } + payments { + _id + amount { + displayAmount + } + billingAddress { + address1 + address2 + city + company + country + fullName + isCommercial + phone + postal + region + } + displayName + method { + name + } + } + referenceId + shop { + _id + currency { + code + } + } + status + summary { + fulfillmentTotal { + amount + displayAmount + } + itemTotal { + amount + displayAmount + } + surchargeTotal { + amount + displayAmount + } + taxTotal { + amount + displayAmount + } + total { + amount + displayAmount + } + } + totalItemQuantity + updatedAt +` + +const placeOrder = /* GraphQL */ ` + mutation placeOrderMutation( + $input: PlaceOrderInput! + $language: String! = "en" + ) { + placeOrder(input: $input) { + orders { + ${orderCommon} + } + token + } + } +` + +export default placeOrder diff --git a/packages/opencommerce/src/api/mutations/select-fulfillment-options.ts b/packages/opencommerce/src/api/mutations/select-fulfillment-options.ts new file mode 100644 index 000000000..d1911bf2c --- /dev/null +++ b/packages/opencommerce/src/api/mutations/select-fulfillment-options.ts @@ -0,0 +1,14 @@ +import { cartPayloadFragment } from '../queries/get-cart-query' + +const selectFulfillmentOptions = /* GraphQL */ ` + mutation setFulfillmentOptionCartMutation( + $input: SelectFulfillmentOptionForGroupInput! + ) { + selectFulfillmentOptionForGroup(input: $input) { + cart { + ${cartPayloadFragment} + } + } + } +` +export default selectFulfillmentOptions diff --git a/packages/opencommerce/src/api/mutations/set-email-on-anonymous-cart.ts b/packages/opencommerce/src/api/mutations/set-email-on-anonymous-cart.ts new file mode 100644 index 000000000..68ac57db3 --- /dev/null +++ b/packages/opencommerce/src/api/mutations/set-email-on-anonymous-cart.ts @@ -0,0 +1,15 @@ +import { cartPayloadFragment } from '../queries/get-cart-query' + +const setEmailOnAnonymousCart = /* GraphQL */ ` + mutation setEmailOnAnonymousCartMutation( + $input: SetEmailOnAnonymousCartInput! + ) { + setEmailOnAnonymousCart(input: $input) { + cart { + ${cartPayloadFragment} + } + } + } +` + +export default setEmailOnAnonymousCart diff --git a/packages/opencommerce/src/api/mutations/update-fulfillment-options.ts b/packages/opencommerce/src/api/mutations/update-fulfillment-options.ts new file mode 100644 index 000000000..f72ae8a78 --- /dev/null +++ b/packages/opencommerce/src/api/mutations/update-fulfillment-options.ts @@ -0,0 +1,15 @@ +import { cartPayloadFragment } from '../queries/get-cart-query' + +const updateFulfillmentOptions = /* GraphQL */ ` + mutation updateFulfillmentOptionsForGroup( + $input: UpdateFulfillmentOptionsForGroupInput! + ) { + updateFulfillmentOptionsForGroup(input: $input) { + cart { + ${cartPayloadFragment} + } + } + } +` + +export default updateFulfillmentOptions diff --git a/packages/opencommerce/src/checkout/index.ts b/packages/opencommerce/src/checkout/index.ts new file mode 100644 index 000000000..d256f9048 --- /dev/null +++ b/packages/opencommerce/src/checkout/index.ts @@ -0,0 +1,2 @@ +export { default as useCheckout } from './use-checkout' +export { default as useSubmitCheckout } from './use-submit-checkout' diff --git a/packages/opencommerce/src/checkout/use-checkout.tsx b/packages/opencommerce/src/checkout/use-checkout.tsx index 76997be73..d4e08576f 100644 --- a/packages/opencommerce/src/checkout/use-checkout.tsx +++ b/packages/opencommerce/src/checkout/use-checkout.tsx @@ -1,16 +1,53 @@ +import type { GetCheckoutHook } from '@vercel/commerce/types/checkout' + +import { useMemo } from 'react' import { SWRHook } from '@vercel/commerce/utils/types' import useCheckout, { UseCheckout, } from '@vercel/commerce/checkout/use-checkout' +import useSubmitCheckout from './use-submit-checkout' +import { useCheckoutContext } from '@components/checkout/context' export default useCheckout as UseCheckout -export const handler: SWRHook = { +export const handler: SWRHook = { fetchOptions: { query: '', + method: '', }, - async fetcher({ input, options, fetch }) {}, - useHook: - ({ useData }) => - async (input) => ({}), + useHook: () => + function useHook() { + const { cardFields, addressFields } = useCheckoutContext() + + // Basic validation - check that at least one field has a value. + const hasEnteredCard = Object.values(cardFields).some( + (fieldValue) => !!fieldValue + ) + const hasEnteredAddress = Object.values(addressFields).some( + (fieldValue) => !!fieldValue + ) + + const response = useMemo( + () => ({ + data: { + hasPayment: hasEnteredCard, + hasShipping: hasEnteredAddress, + }, + }), + [hasEnteredCard, hasEnteredAddress] + ) + + return useMemo( + () => + Object.create(response, { + submit: { + get() { + return useSubmitCheckout + }, + enumerable: true, + }, + }), + [response, useSubmitCheckout] + ) + }, } diff --git a/packages/opencommerce/src/checkout/use-submit-checkout.ts b/packages/opencommerce/src/checkout/use-submit-checkout.ts new file mode 100644 index 000000000..ea07b252f --- /dev/null +++ b/packages/opencommerce/src/checkout/use-submit-checkout.ts @@ -0,0 +1,32 @@ +import { useCallback } from 'react' + +import type { SubmitCheckoutHook } from '@vercel/commerce/types/checkout' +import type { MutationHook } from '@vercel/commerce/utils/types' +import useSubmitCheckout, { + UseSubmitCheckout, +} from '@vercel/commerce/checkout/use-submit-checkout' + +export default useSubmitCheckout as UseSubmitCheckout + +export const handler: MutationHook = { + fetchOptions: { + url: '/api/checkout', + method: 'POST', + }, + async fetcher({ input: item, options, fetch }) { + const data = await fetch({ + ...options, + body: { item }, + }) + return data + }, + useHook: ({ fetch }) => + function useHook() { + return useCallback(async function onSubmitCheckout(input) { + const data = await fetch({ + input, + }) + return data + }, []) + }, +} diff --git a/packages/opencommerce/src/commerce.config.json b/packages/opencommerce/src/commerce.config.json index c2275f54f..876b7d7e1 100644 --- a/packages/opencommerce/src/commerce.config.json +++ b/packages/opencommerce/src/commerce.config.json @@ -1,4 +1,6 @@ { "provider": "opencommerce", - "features": {} + "features": { + "customCheckout": true + } } diff --git a/packages/opencommerce/src/customer/address/index.ts b/packages/opencommerce/src/customer/address/index.ts new file mode 100644 index 000000000..20e12df09 --- /dev/null +++ b/packages/opencommerce/src/customer/address/index.ts @@ -0,0 +1 @@ +export { default as useAddItem } from './use-add-item' diff --git a/packages/opencommerce/src/customer/address/use-add-item.tsx b/packages/opencommerce/src/customer/address/use-add-item.tsx index 4f85c8472..c6bfa414d 100644 --- a/packages/opencommerce/src/customer/address/use-add-item.tsx +++ b/packages/opencommerce/src/customer/address/use-add-item.tsx @@ -1,17 +1,36 @@ +import type { AddItemHook } from '@vercel/commerce/types/customer/address' +import type { MutationHook } from '@vercel/commerce/utils/types' +import { useCallback } from 'react' import useAddItem, { UseAddItem, } from '@vercel/commerce/customer/address/use-add-item' -import { MutationHook } from '@vercel/commerce/utils/types' +import { useCheckoutContext } from '@components/checkout/context' export default useAddItem as UseAddItem -export const handler: MutationHook = { +export const handler: MutationHook = { fetchOptions: { - query: '', + url: '/api/customer/address', + method: 'POST', }, - async fetcher({ input, options, fetch }) {}, - useHook: - ({ fetch }) => - () => - async () => ({}), + async fetcher({ input: item, options, fetch }) { + const data = await fetch({ + ...options, + body: { item }, + }) + + return data + }, + useHook: ({ fetch }) => + function useHook() { + const { setAddressFields } = useCheckoutContext() + return useCallback( + async function addItem(input) { + await fetch({ input }) + setAddressFields(input) + return undefined + }, + [setAddressFields] + ) + }, } diff --git a/packages/opencommerce/src/customer/card/index.ts b/packages/opencommerce/src/customer/card/index.ts new file mode 100644 index 000000000..c582993b4 --- /dev/null +++ b/packages/opencommerce/src/customer/card/index.ts @@ -0,0 +1,2 @@ +export { default as useAddItem } from './use-add-item' +export { default as useCards } from './use-cards' diff --git a/packages/opencommerce/src/customer/card/use-add-item.tsx b/packages/opencommerce/src/customer/card/use-add-item.tsx index 77d149eff..a201a289a 100644 --- a/packages/opencommerce/src/customer/card/use-add-item.tsx +++ b/packages/opencommerce/src/customer/card/use-add-item.tsx @@ -1,17 +1,27 @@ +import type { AddItemHook } from '@vercel/commerce/types/customer/card' +import type { MutationHook } from '@vercel/commerce/utils/types' +import { useCallback } from 'react' import useAddItem, { UseAddItem, } from '@vercel/commerce/customer/card/use-add-item' -import { MutationHook } from '@vercel/commerce/utils/types' +import { useCheckoutContext } from '@components/checkout/context' export default useAddItem as UseAddItem -export const handler: MutationHook = { +export const handler: MutationHook = { fetchOptions: { - query: '', + url: '', + method: '', }, - async fetcher({ input, options, fetch }) {}, - useHook: - ({ fetch }) => - () => - async () => ({}), + useHook: () => + function useHook() { + const { setCardFields } = useCheckoutContext() + return useCallback( + async function addItem(input) { + setCardFields(input) + return undefined + }, + [setCardFields] + ) + }, } diff --git a/packages/opencommerce/src/customer/card/use-cards.ts b/packages/opencommerce/src/customer/card/use-cards.ts new file mode 100644 index 000000000..3c691a1e3 --- /dev/null +++ b/packages/opencommerce/src/customer/card/use-cards.ts @@ -0,0 +1,32 @@ +import { useMemo } from 'react' +import type { GetCardsHook } from '@vercel/commerce/types/customer/card' +import { SWRHook } from '@vercel/commerce/utils/types' +import useCard, { UseCards } from '@vercel/commerce/customer/card/use-cards' + +export default useCard as UseCards + +export const handler: SWRHook = { + fetchOptions: { + url: '/api/customer/card', + method: 'GET', + }, + useHook: ({ useData }) => + function useHook(input) { + const response = useData({ + swrOptions: { revalidateOnFocus: false, ...input?.swrOptions }, + }) + + return useMemo( + () => + Object.create(response, { + isEmpty: { + get() { + return (response.data?.length ?? 0) <= 0 + }, + enumerable: true, + }, + }), + [response] + ) + }, +} diff --git a/packages/opencommerce/src/provider.ts b/packages/opencommerce/src/provider.ts index f9a8e344a..607b4feaa 100644 --- a/packages/opencommerce/src/provider.ts +++ b/packages/opencommerce/src/provider.ts @@ -8,15 +8,27 @@ import { handler as useSearch } from './product/use-search' import { handler as useLogin } from './auth/use-login' import { handler as useLogout } from './auth/use-logout' import { handler as useSignup } from './auth/use-signup' +import { handler as useCheckout } from './checkout/use-checkout' +import { handler as useSubmitCheckout } from './checkout/use-submit-checkout' +import { handler as useAddCardItem } from './customer/card/use-add-item' +import { handler as useCards } from './customer/card/use-cards' +import { handler as useAddAddressItem } from './customer/address/use-add-item' export const openCommerceProvider = { locale: 'en-us', cartCookie: 'opencommerce_cartId', fetcher, cart: { useCart, useAddItem, useUpdateItem, useRemoveItem }, - customer: { useCustomer }, + customer: { + useCustomer, + card: { useCards, useAddItem: useAddCardItem }, + address: { + useAddItem: useAddAddressItem, + }, + }, products: { useSearch }, auth: { useLogin, useLogout, useSignup }, + checkout: { useCheckout, useSubmitCheckout }, } export type OpenCommerceProvider = typeof openCommerceProvider diff --git a/packages/opencommerce/src/types/cart.ts b/packages/opencommerce/src/types/cart.ts index 604f842f6..c382c4eab 100644 --- a/packages/opencommerce/src/types/cart.ts +++ b/packages/opencommerce/src/types/cart.ts @@ -1,11 +1,11 @@ import * as Core from '@vercel/commerce/types/cart' +import { Checkout } from '../../schema' import { ProductVariant } from './product' export * from '@vercel/commerce/types/cart' export type Cart = Core.Cart & { - lineItems: Core.LineItem[] - id: string + checkout?: Checkout } export type CartItemBody = Core.CartItemBody & { diff --git a/packages/opencommerce/src/types/checkout.ts b/packages/opencommerce/src/types/checkout.ts new file mode 100644 index 000000000..d139db685 --- /dev/null +++ b/packages/opencommerce/src/types/checkout.ts @@ -0,0 +1 @@ +export * from '@vercel/commerce/types/checkout' diff --git a/packages/opencommerce/src/types/customer/address.ts b/packages/opencommerce/src/types/customer/address.ts new file mode 100644 index 000000000..a818183f2 --- /dev/null +++ b/packages/opencommerce/src/types/customer/address.ts @@ -0,0 +1 @@ +export * from '@vercel/commerce/types/customer/address' diff --git a/packages/opencommerce/src/types/customer/card.ts b/packages/opencommerce/src/types/customer/card.ts new file mode 100644 index 000000000..370a0d743 --- /dev/null +++ b/packages/opencommerce/src/types/customer/card.ts @@ -0,0 +1 @@ +export * from '@vercel/commerce/types/customer/card' diff --git a/packages/opencommerce/src/utils/normalize.ts b/packages/opencommerce/src/utils/normalize.ts index 2b59c5485..a7cb76b46 100644 --- a/packages/opencommerce/src/utils/normalize.ts +++ b/packages/opencommerce/src/utils/normalize.ts @@ -249,6 +249,7 @@ export function normalizeCart(cart: OCCart): Cart { totalPrice: cart.checkout?.summary?.total?.amount ?? 0, discounts: [], taxesIncluded: !!cart.checkout?.summary?.taxTotal?.amount, + checkout: cart.checkout ? cart.checkout : undefined, } } diff --git a/packages/opencommerce/tsconfig.json b/packages/opencommerce/tsconfig.json index cd04ab2ff..2f84a843f 100644 --- a/packages/opencommerce/tsconfig.json +++ b/packages/opencommerce/tsconfig.json @@ -4,7 +4,11 @@ "module": "esnext", "outDir": "dist", "baseUrl": "src", - "lib": ["dom", "dom.iterable", "esnext"], + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], "declaration": true, "allowJs": true, "skipLibCheck": true, @@ -16,6 +20,12 @@ "incremental": true, "jsx": "react-jsx" }, - "include": ["src"], - "exclude": ["node_modules", "dist"] -} + "include": [ + "src", + "global.d.ts" + ], + "exclude": [ + "node_modules", + "dist" + ] +} \ No newline at end of file diff --git a/site/.env.template b/site/.env.template index 5eab31d0a..a7c522c31 100644 --- a/site/.env.template +++ b/site/.env.template @@ -53,6 +53,7 @@ NEXT_PUBLIC_COMMERCEJS_DEPLOYMENT_URL= OPENCOMMERCE_STOREFRONT_API_URL= OPENCOMMERCE_PRIMARY_SHOP_ID= OPENCOMMERCE_IMAGE_DOMAIN= +OPENCOMMERCE_STRIPE_API_KEY= SFCC_CLIENT_ID= SFCC_CLIENT_SECRET= diff --git a/site/components/checkout/CheckoutSidebarView/CheckoutSidebarView.tsx b/site/components/checkout/CheckoutSidebarView/CheckoutSidebarView.tsx index 21a20c429..8f5f5dcbb 100644 --- a/site/components/checkout/CheckoutSidebarView/CheckoutSidebarView.tsx +++ b/site/components/checkout/CheckoutSidebarView/CheckoutSidebarView.tsx @@ -7,6 +7,7 @@ import SidebarLayout from '@components/common/SidebarLayout' import useCart from '@framework/cart/use-cart' import usePrice from '@framework/product/use-price' import useCheckout from '@framework/checkout/use-checkout' +import useSubmitCheckout from '@framework/checkout/use-submit-checkout' import ShippingWidget from '../ShippingWidget' import PaymentWidget from '../PaymentWidget' import s from './CheckoutSidebarView.module.css' @@ -16,15 +17,21 @@ const CheckoutSidebarView: FC = () => { const [loadingSubmit, setLoadingSubmit] = useState(false) const { setSidebarView, closeSidebar } = useUI() const { data: cartData, mutate: refreshCart } = useCart() - const { data: checkoutData, submit: onCheckout } = useCheckout() - const { clearCheckoutFields } = useCheckoutContext() + const { data: checkoutData } = useCheckout() + const onCheckout = useSubmitCheckout() + + const { clearCheckoutFields, cardFields, addressFields } = + useCheckoutContext() async function handleSubmit(event: React.ChangeEvent) { try { setLoadingSubmit(true) event.preventDefault() - - await onCheckout() + await onCheckout({ + card: cardFields, + address: addressFields, + checkout: { cart: cartData }, + }) clearCheckoutFields() setLoadingSubmit(false) refreshCart() diff --git a/site/components/checkout/PaymentMethodView/PaymentMethodView.tsx b/site/components/checkout/PaymentMethodView/PaymentMethodView.tsx index 115619c75..c09c8bf3b 100644 --- a/site/components/checkout/PaymentMethodView/PaymentMethodView.tsx +++ b/site/components/checkout/PaymentMethodView/PaymentMethodView.tsx @@ -42,7 +42,6 @@ const PaymentMethodView: FC = () => { city: event.target.city.value, country: event.target.country.value, }) - setSidebarView('CHECKOUT_VIEW') } diff --git a/yarn.lock b/yarn.lock index 464ec979a..3d5330175 100644 --- a/yarn.lock +++ b/yarn.lock @@ -552,6 +552,52 @@ resolved "https://registry.yarnpkg.com/@gar/promisify/-/promisify-1.1.3.tgz#555193ab2e3bb3b6adc3d551c9c030d9e860daf6" integrity sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw== +"@graphql-codegen/cli@2.6.2": + version "2.6.2" + resolved "https://registry.yarnpkg.com/@graphql-codegen/cli/-/cli-2.6.2.tgz#a9aa4656141ee0998cae8c7ad7d0bf9ca8e0c9ae" + integrity sha512-UO75msoVgvLEvfjCezM09cQQqp32+mR8Ma1ACsBpr7nroFvHbgcu2ulx1cMovg4sxDBCsvd9Eq/xOOMpARUxtw== + dependencies: + "@graphql-codegen/core" "2.5.1" + "@graphql-codegen/plugin-helpers" "^2.4.1" + "@graphql-tools/apollo-engine-loader" "^7.0.5" + "@graphql-tools/code-file-loader" "^7.0.6" + "@graphql-tools/git-loader" "^7.0.5" + "@graphql-tools/github-loader" "^7.0.5" + "@graphql-tools/graphql-file-loader" "^7.0.5" + "@graphql-tools/json-file-loader" "^7.1.2" + "@graphql-tools/load" "^7.3.0" + "@graphql-tools/prisma-loader" "^7.0.6" + "@graphql-tools/url-loader" "^7.0.11" + "@graphql-tools/utils" "^8.1.1" + ansi-escapes "^4.3.1" + chalk "^4.1.0" + change-case-all "1.0.14" + chokidar "^3.5.2" + common-tags "^1.8.0" + cosmiconfig "^7.0.0" + debounce "^1.2.0" + dependency-graph "^0.11.0" + detect-indent "^6.0.0" + glob "^7.1.6" + globby "^11.0.4" + graphql-config "^4.1.0" + inquirer "^8.0.0" + is-glob "^4.0.1" + json-to-pretty-yaml "^1.2.2" + latest-version "5.1.0" + listr "^0.14.3" + listr-update-renderer "^0.5.0" + log-symbols "^4.0.0" + minimatch "^4.0.0" + mkdirp "^1.0.4" + string-env-interpolation "^1.0.1" + ts-log "^2.2.3" + tslib "~2.3.0" + valid-url "^1.0.9" + wrap-ansi "^7.0.0" + yaml "^1.10.0" + yargs "^17.0.0" + "@graphql-codegen/cli@^2.3.1": version "2.4.0" resolved "https://registry.yarnpkg.com/@graphql-codegen/cli/-/cli-2.4.0.tgz#7df3ee2bdd5b88a5904ee6f52eafeb370ef70e51" @@ -618,14 +664,6 @@ "@graphql-tools/utils" "^8.1.1" tslib "~2.3.0" -"@graphql-codegen/introspection@2.1.1": - version "2.1.1" - resolved "https://registry.yarnpkg.com/@graphql-codegen/introspection/-/introspection-2.1.1.tgz#5f3aac47ef46ed817baf969e78dd2dd6d307b18a" - integrity sha512-O9zsy0IoFYDo37pBVF4pSvRMDx/AKdgOxyko4R/O+0DHEw9Nya/pQ3dbn+LDLj2n6X+xOXUBUfFvqhODTqU28w== - dependencies: - "@graphql-codegen/plugin-helpers" "^2.3.2" - tslib "~2.3.0" - "@graphql-codegen/plugin-helpers@^2.3.2": version "2.3.2" resolved "https://registry.yarnpkg.com/@graphql-codegen/plugin-helpers/-/plugin-helpers-2.3.2.tgz#3f9ba625791901d19be733db1dfc9a3dbd0dac44" @@ -681,17 +719,6 @@ auto-bind "~4.0.0" tslib "~2.3.0" -"@graphql-codegen/typescript-react-apollo@3.2.11": - version "3.2.11" - resolved "https://registry.yarnpkg.com/@graphql-codegen/typescript-react-apollo/-/typescript-react-apollo-3.2.11.tgz#dc13abc1ec24aa78f7f0774c1f52da5d982dd1fc" - integrity sha512-Bfo7/OprnWk/srhA/3I0cKySg/SyVPX3ZoxzTk6ChYVBsy69jKDkdPWwlmE7Fjfv7+5G+xXb99OoqUUgBLma3w== - dependencies: - "@graphql-codegen/plugin-helpers" "^2.4.0" - "@graphql-codegen/visitor-plugin-common" "2.7.4" - auto-bind "~4.0.0" - change-case-all "1.0.14" - tslib "~2.3.0" - "@graphql-codegen/typescript@2.4.8", "@graphql-codegen/typescript@^2.4.8": version "2.4.8" resolved "https://registry.yarnpkg.com/@graphql-codegen/typescript/-/typescript-2.4.8.tgz#e8110baba9713cde72d57a5c95aa27400363ed9a" @@ -6256,7 +6283,7 @@ qs@6.7.0: resolved "https://registry.yarnpkg.com/qs/-/qs-6.7.0.tgz#41dc1a015e3d581f1621776be31afb2876a9b1bc" integrity sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ== -qs@^6.10.2, qs@^6.6.0: +qs@^6.10.2, qs@^6.10.3, qs@^6.6.0: version "6.10.3" resolved "https://registry.yarnpkg.com/qs/-/qs-6.10.3.tgz#d6cde1b2ffca87b5aa57889816c5f81535e22e8e" integrity sha512-wr7M2E0OFRfIfJZjKGieI8lBKb7fRCH4Fv5KNPEs7gJ8jadvotdsS08PzOKR7opXhZ/Xkjtt3WF9g38drmyRqQ== @@ -7131,6 +7158,14 @@ stripe@^8.197.0: "@types/node" ">=8.1.0" qs "^6.6.0" +stripe@^8.220.0: + version "8.220.0" + resolved "https://registry.yarnpkg.com/stripe/-/stripe-8.220.0.tgz#90bdedcb7d83e64f22c47b47f5072d837ef56357" + integrity sha512-hE3NapEqNCiiQD1lMQPccKgJsu2aANR+oDudUHcuvRnNUJ3GrbntwACs7Op45PvHpJ/RY4l46XDwTMgdWJAm3w== + dependencies: + "@types/node" ">=8.1.0" + qs "^6.10.3" + styled-jsx@5.0.0-beta.6: version "5.0.0-beta.6" resolved "https://registry.yarnpkg.com/styled-jsx/-/styled-jsx-5.0.0-beta.6.tgz#666552f8831a06f80c9084a47afc4b32b0c9f461"