mirror of
https://github.com/vercel/commerce.git
synced 2025-05-17 15:06:59 +00:00
Polish checkout view
Signed-off-by: Chloe <pinkcloudvnn@gmail.com>
This commit is contained in:
parent
9bbd1b29a7
commit
630b4ba486
@ -61,6 +61,7 @@ const addItem: CustomerAddressEndpoint['handlers']['addItem'] = async ({
|
||||
updateFulfillmentOptionsForGroup.cart.checkout.fulfillmentGroups[0]
|
||||
._id,
|
||||
fulfillmentMethodId:
|
||||
item.shippingMethodId ||
|
||||
updateFulfillmentOptionsForGroup.cart.checkout.fulfillmentGroups[0]
|
||||
.availableFulfillmentOptions[0].fulfillmentMethod._id,
|
||||
},
|
||||
|
@ -1,4 +1,7 @@
|
||||
import type { CustomerAddressSchema } from '../../../../types/customer/address'
|
||||
import type {
|
||||
CustomerAddressSchema,
|
||||
CustomerAddressTypes,
|
||||
} from '../../../../types/customer/address'
|
||||
import type { OpenCommerceAPI } from '../../..'
|
||||
|
||||
import { GetAPISchema, createEndpoint } from '@vercel/commerce/api'
|
||||
@ -11,7 +14,7 @@ import removeItem from './remove-item'
|
||||
|
||||
export type CustomerAddressAPI = GetAPISchema<
|
||||
OpenCommerceAPI,
|
||||
CustomerAddressSchema
|
||||
CustomerAddressSchema<CustomerAddressTypes>
|
||||
>
|
||||
export type CustomerAddressEndpoint = CustomerAddressAPI['endpoint']
|
||||
|
||||
|
@ -1,8 +1,33 @@
|
||||
import selectFulfillmentOptions from '../../../mutations/select-fulfillment-options'
|
||||
import type { CustomerAddressEndpoint } from '.'
|
||||
|
||||
const updateItem: CustomerAddressEndpoint['handlers']['updateItem'] = 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' }],
|
||||
})
|
||||
}
|
||||
|
||||
if (item.shippingMethod) {
|
||||
await fetch(selectFulfillmentOptions, {
|
||||
variables: {
|
||||
input: {
|
||||
cartId,
|
||||
cartToken: cookies[anonymousCartTokenCookie],
|
||||
fulfillmentGroupId: item.shippingMethod.fulfillmentGroupId,
|
||||
fulfillmentMethodId: item.shippingMethod.id,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return res.status(200).json({ data: null, errors: [] })
|
||||
}
|
||||
|
||||
|
@ -2,10 +2,11 @@ import { useMemo } from 'react'
|
||||
import { SWRHook } from '@vercel/commerce/utils/types'
|
||||
import useCart, { UseCart } from '@vercel/commerce/cart/use-cart'
|
||||
import type { GetCartHook } from '@vercel/commerce/types/cart'
|
||||
import { CartTypes } from '../types/cart'
|
||||
|
||||
export default useCart as UseCart<typeof handler>
|
||||
|
||||
export const handler: SWRHook<GetCartHook> = {
|
||||
export const handler: SWRHook<GetCartHook<CartTypes>> = {
|
||||
fetchOptions: {
|
||||
url: '/api/cart',
|
||||
method: 'GET',
|
||||
|
@ -7,6 +7,7 @@ import useCheckout, {
|
||||
} from '@vercel/commerce/checkout/use-checkout'
|
||||
import useSubmitCheckout from './use-submit-checkout'
|
||||
import { useCheckoutContext } from '@components/checkout/context'
|
||||
import { useCart } from '../cart'
|
||||
|
||||
export default useCheckout as UseCheckout<typeof handler>
|
||||
|
||||
@ -17,13 +18,23 @@ export const handler: SWRHook<GetCheckoutHook> = {
|
||||
},
|
||||
useHook: () =>
|
||||
function useHook() {
|
||||
const { data: cart } = useCart()
|
||||
const hasShippingMethods = !!(
|
||||
cart?.checkout?.fulfillmentGroups &&
|
||||
cart.checkout.fulfillmentGroups.find(
|
||||
(group) => group?.type === 'shipping'
|
||||
)
|
||||
)
|
||||
|
||||
const { cardFields, addressFields } = useCheckoutContext()
|
||||
|
||||
const { shippingMethod, ...restAddressFields } = addressFields
|
||||
|
||||
// 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(
|
||||
const hasEnteredAddress = Object.values(restAddressFields).some(
|
||||
(fieldValue) => !!fieldValue
|
||||
)
|
||||
|
||||
@ -32,6 +43,8 @@ export const handler: SWRHook<GetCheckoutHook> = {
|
||||
data: {
|
||||
hasPayment: hasEnteredCard,
|
||||
hasShipping: hasEnteredAddress,
|
||||
hasShippingMethods,
|
||||
hasSelectedShippingMethod: !!shippingMethod?.id,
|
||||
},
|
||||
}),
|
||||
[hasEnteredCard, hasEnteredAddress]
|
||||
|
@ -1 +1,2 @@
|
||||
export { default as useAddItem } from './use-add-item'
|
||||
export { default as useUpdateItem } from './use-update-item'
|
||||
|
@ -5,10 +5,11 @@ import useAddItem, {
|
||||
UseAddItem,
|
||||
} from '@vercel/commerce/customer/address/use-add-item'
|
||||
import { useCheckoutContext } from '@components/checkout/context'
|
||||
import { CustomerAddressTypes } from '../../types/customer/address'
|
||||
|
||||
export default useAddItem as UseAddItem<typeof handler>
|
||||
|
||||
export const handler: MutationHook<AddItemHook> = {
|
||||
export const handler: MutationHook<AddItemHook<CustomerAddressTypes>> = {
|
||||
fetchOptions: {
|
||||
url: '/api/customer/address',
|
||||
method: 'POST',
|
||||
@ -23,11 +24,11 @@ export const handler: MutationHook<AddItemHook> = {
|
||||
},
|
||||
useHook: ({ fetch }) =>
|
||||
function useHook() {
|
||||
const { setAddressFields } = useCheckoutContext()
|
||||
const { setAddressFields, addressFields } = useCheckoutContext()
|
||||
return useCallback(
|
||||
async function addItem(input) {
|
||||
await fetch({ input })
|
||||
setAddressFields(input)
|
||||
setAddressFields({ ...addressFields, ...input })
|
||||
return undefined
|
||||
},
|
||||
[setAddressFields]
|
||||
|
@ -0,0 +1,44 @@
|
||||
import type { UpdateItemHook } from '@vercel/commerce/types/customer/address'
|
||||
import type {
|
||||
MutationHook,
|
||||
MutationHookContext,
|
||||
} from '@vercel/commerce/utils/types'
|
||||
import { useCallback } from 'react'
|
||||
import useUpdateItem, {
|
||||
UseUpdateItem,
|
||||
} from '@vercel/commerce/customer/address/use-update-item'
|
||||
import { useCheckoutContext } from '@components/checkout/context'
|
||||
import { CustomerAddressTypes } from '../../types/customer/address'
|
||||
|
||||
export default useUpdateItem as UseUpdateItem<typeof handler>
|
||||
|
||||
export const handler: MutationHook<UpdateItemHook<CustomerAddressTypes>> = {
|
||||
fetchOptions: {
|
||||
url: '/api/customer/address',
|
||||
method: 'PUT',
|
||||
},
|
||||
async fetcher({ input: { item, itemId }, options, fetch }) {
|
||||
const data = await fetch({
|
||||
...options,
|
||||
body: { item, itemId },
|
||||
})
|
||||
|
||||
return data
|
||||
},
|
||||
useHook: ({ fetch }) =>
|
||||
function useHook() {
|
||||
const { setAddressFields, addressFields } = useCheckoutContext()
|
||||
return useCallback(
|
||||
async function updateItem(input) {
|
||||
const { id, ...rest } = input
|
||||
await fetch({ input: { item: rest, itemId: id } })
|
||||
setAddressFields({
|
||||
...addressFields,
|
||||
shippingMethod: rest.shippingMethod,
|
||||
})
|
||||
return undefined
|
||||
},
|
||||
[setAddressFields]
|
||||
)
|
||||
},
|
||||
}
|
@ -13,6 +13,7 @@ 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'
|
||||
import { handler as useUpdateAddressItem } from './customer/address/use-update-item'
|
||||
|
||||
export const openCommerceProvider = {
|
||||
locale: 'en-us',
|
||||
@ -24,6 +25,7 @@ export const openCommerceProvider = {
|
||||
card: { useCards, useAddItem: useAddCardItem },
|
||||
address: {
|
||||
useAddItem: useAddAddressItem,
|
||||
useUpdateItem: useUpdateAddressItem,
|
||||
},
|
||||
},
|
||||
products: { useSearch },
|
||||
|
@ -15,6 +15,7 @@ export type CartItemBody = Core.CartItemBody & {
|
||||
|
||||
export type CartTypes = Core.CartTypes & {
|
||||
itemBody: CartItemBody
|
||||
cart?: Cart
|
||||
}
|
||||
|
||||
export type CartSchema = Core.CartSchema<CartTypes>
|
||||
|
@ -1 +1,14 @@
|
||||
import * as Core from '@vercel/commerce/types/customer/address'
|
||||
|
||||
export * from '@vercel/commerce/types/customer/address'
|
||||
|
||||
export type AddressFields = Core.AddressFields & {
|
||||
shippingMethod?: {
|
||||
id: string
|
||||
fulfillmentGroupId: string
|
||||
}
|
||||
}
|
||||
|
||||
export type CustomerAddressTypes = Core.CustomerAddressTypes & {
|
||||
fields: AddressFields
|
||||
}
|
||||
|
@ -10,9 +10,11 @@ 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'
|
||||
import ShippingMethodWidget from '../ShippingMethodWidget'
|
||||
import { useCheckoutContext } from '../context'
|
||||
|
||||
import s from './CheckoutSidebarView.module.css'
|
||||
|
||||
const CheckoutSidebarView: FC = () => {
|
||||
const [loadingSubmit, setLoadingSubmit] = useState(false)
|
||||
const { setSidebarView, closeSidebar } = useUI()
|
||||
@ -76,6 +78,13 @@ const CheckoutSidebarView: FC = () => {
|
||||
onClick={() => setSidebarView('SHIPPING_VIEW')}
|
||||
/>
|
||||
|
||||
{checkoutData?.hasShippingMethods && (
|
||||
<ShippingMethodWidget
|
||||
isValid={checkoutData?.hasSelectedShippingMethod}
|
||||
onClick={() => setSidebarView('SHIPPING_METHOD_VIEW')}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ul className={s.lineItemsList}>
|
||||
{cartData!.lineItems.map((item: any) => (
|
||||
<CartItem
|
||||
@ -101,10 +110,19 @@ const CheckoutSidebarView: FC = () => {
|
||||
<span>Taxes</span>
|
||||
<span>Calculated at checkout</span>
|
||||
</li>
|
||||
<li className="flex justify-between py-1">
|
||||
<span>Shipping</span>
|
||||
<span className="font-bold tracking-wide">FREE</span>
|
||||
</li>
|
||||
{checkoutData?.hasSelectedShippingMethod ? (
|
||||
<li className="flex justify-between py-1">
|
||||
<span>Shipping</span>
|
||||
<span>
|
||||
{cartData?.checkout?.summary.fulfillmentTotal?.displayAmount}
|
||||
</span>
|
||||
</li>
|
||||
) : (
|
||||
<li className="flex justify-between py-1">
|
||||
<span>Shipping</span>
|
||||
<span className="font-bold tracking-wide">FREE</span>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
<div className="flex justify-between border-t border-accent-2 py-3 font-bold mb-2">
|
||||
<span>Total</span>
|
||||
|
@ -0,0 +1,79 @@
|
||||
import useCart from '@framework/cart/use-cart'
|
||||
import useUpdateUpdateAddress from '@framework/customer/address/use-update-item'
|
||||
import SidebarLayout from '@components/common/SidebarLayout'
|
||||
import { useUI } from '@components/ui/context'
|
||||
import { Button } from '@components/ui'
|
||||
import { useCheckoutContext } from '../context'
|
||||
|
||||
const ShippingMethod = () => {
|
||||
const { setSidebarView } = useUI()
|
||||
|
||||
const { data: cart } = useCart()
|
||||
const { addressFields } = useCheckoutContext()
|
||||
|
||||
const updateShippingMethod = useUpdateUpdateAddress()
|
||||
const shippingGroup = cart?.checkout?.fulfillmentGroups.find(
|
||||
(group) => group?.type === 'shipping'
|
||||
)
|
||||
|
||||
const handleSubmit = async (event: React.ChangeEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
|
||||
await updateShippingMethod({
|
||||
id: cart!.id,
|
||||
...addressFields,
|
||||
shippingMethod: {
|
||||
fulfillmentGroupId:
|
||||
cart!.checkout?.fulfillmentGroups[0]?._id ?? 'groupId',
|
||||
id: event.target.shippingMethod.value,
|
||||
},
|
||||
})
|
||||
|
||||
setSidebarView('CHECKOUT_VIEW')
|
||||
}
|
||||
|
||||
return shippingGroup ? (
|
||||
<form className="h-full" onSubmit={handleSubmit}>
|
||||
<SidebarLayout handleBack={() => setSidebarView('CHECKOUT_VIEW')}>
|
||||
<div className="px-4 sm:px-6 flex-1">
|
||||
<h2 className="pt-1 pb-8 text-2xl font-semibold tracking-wide cursor-pointer inline-block">
|
||||
Shipping Methods
|
||||
</h2>
|
||||
<div>
|
||||
{shippingGroup.availableFulfillmentOptions.map((option) => (
|
||||
<div
|
||||
className="flex flex-row my-3 items-center justify-between"
|
||||
key={option?.fulfillmentMethod?._id}
|
||||
>
|
||||
<fieldset className="flex flex-row items-center">
|
||||
<input
|
||||
name="shippingMethod"
|
||||
className="bg-black"
|
||||
type="radio"
|
||||
value={option?.fulfillmentMethod?._id}
|
||||
defaultChecked={
|
||||
shippingGroup.selectedFulfillmentOption?.fulfillmentMethod
|
||||
?._id === option?.fulfillmentMethod?._id
|
||||
}
|
||||
/>
|
||||
<span className="ml-3 text-sm">
|
||||
{option?.fulfillmentMethod?.displayName ||
|
||||
'Shipping Method'}
|
||||
</span>
|
||||
</fieldset>
|
||||
<span>{option?.price.displayAmount}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="sticky z-20 bottom-0 w-full right-0 left-0 py-12 bg-accent-0 border-t border-accent-2 px-6">
|
||||
<Button type="submit" width="100%" variant="ghost">
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
</SidebarLayout>
|
||||
</form>
|
||||
) : null
|
||||
}
|
||||
|
||||
export default ShippingMethod
|
1
site/components/checkout/ShippingMethodView/index.ts
Normal file
1
site/components/checkout/ShippingMethodView/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './ShippingMethodView'
|
@ -0,0 +1,4 @@
|
||||
.root {
|
||||
@apply border border-accent-2 px-6 py-5 mb-4 text-center
|
||||
flex items-center cursor-pointer hover:border-accent-4;
|
||||
}
|
@ -0,0 +1,28 @@
|
||||
import { FC } from 'react'
|
||||
import s from './ShippingMethodWidget.module.css'
|
||||
import { ChevronRight, CreditCard, Check } from '@components/icons'
|
||||
|
||||
interface ComponentProps {
|
||||
onClick?: () => any
|
||||
isValid?: boolean
|
||||
}
|
||||
|
||||
const ShippingMethodWidget: FC<ComponentProps> = ({ onClick, isValid }) => {
|
||||
/* Shipping Method Widget
|
||||
Only available with checkout set to true and cart has some available shipping methods
|
||||
This means that the provider does offer checkout functionality. */
|
||||
return (
|
||||
<div onClick={onClick} className={s.root}>
|
||||
<div className="flex flex-1 items-center">
|
||||
<CreditCard className="w-5 flex" />
|
||||
<span className="ml-5 text-sm text-center font-medium">
|
||||
Add Shipping Method
|
||||
</span>
|
||||
{/* <span>VISA #### #### #### 2345</span> */}
|
||||
</div>
|
||||
<div>{isValid ? <Check /> : <ChevronRight />}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ShippingMethodWidget
|
1
site/components/checkout/ShippingMethodWidget/index.ts
Normal file
1
site/components/checkout/ShippingMethodWidget/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './ShippingMethodWidget'
|
@ -5,9 +5,9 @@ import Button from '@components/ui/Button'
|
||||
import { useUI } from '@components/ui/context'
|
||||
import SidebarLayout from '@components/common/SidebarLayout'
|
||||
import useAddAddress from '@framework/customer/address/use-add-item'
|
||||
import { useCheckoutContext } from '../context'
|
||||
|
||||
import s from './ShippingView.module.css'
|
||||
import { useCheckoutContext } from '../context'
|
||||
|
||||
interface Form extends HTMLFormElement {
|
||||
cardHolder: HTMLInputElement
|
||||
@ -21,6 +21,7 @@ interface Form extends HTMLFormElement {
|
||||
zipCode: HTMLInputElement
|
||||
city: HTMLInputElement
|
||||
country: HTMLSelectElement
|
||||
shippingMethod?: HTMLInputElement
|
||||
}
|
||||
|
||||
const ShippingView: FC = () => {
|
||||
@ -64,6 +65,7 @@ const ShippingView: FC = () => {
|
||||
Use a different shipping address
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<hr className="border-accent-2 my-6" />
|
||||
<div className="grid gap-3 grid-flow-row grid-cols-12">
|
||||
<div className={cn(s.fieldset, 'col-span-6')}>
|
||||
|
@ -7,7 +7,7 @@ import React, {
|
||||
createContext,
|
||||
} from 'react'
|
||||
import type { CardFields } from '@commerce/types/customer/card'
|
||||
import type { AddressFields } from '@commerce/types/customer/address'
|
||||
import type { AddressFields } from '@framework/types/customer/address'
|
||||
|
||||
export type State = {
|
||||
cardFields: CardFields
|
||||
@ -86,7 +86,10 @@ export const CheckoutProvider: FC = (props) => {
|
||||
|
||||
const cardFields = useMemo(() => state.cardFields, [state.cardFields])
|
||||
|
||||
const addressFields = useMemo(() => state.addressFields, [state.addressFields])
|
||||
const addressFields = useMemo(
|
||||
() => state.addressFields,
|
||||
[state.addressFields]
|
||||
)
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
@ -96,7 +99,13 @@ export const CheckoutProvider: FC = (props) => {
|
||||
setAddressFields,
|
||||
clearCheckoutFields,
|
||||
}),
|
||||
[cardFields, addressFields, setCardFields, setAddressFields, clearCheckoutFields]
|
||||
[
|
||||
cardFields,
|
||||
addressFields,
|
||||
setCardFields,
|
||||
setAddressFields,
|
||||
clearCheckoutFields,
|
||||
]
|
||||
)
|
||||
|
||||
return <CheckoutContext.Provider value={value} {...props} />
|
||||
|
@ -12,6 +12,7 @@ import { useAcceptCookies } from '@lib/hooks/useAcceptCookies'
|
||||
import { Sidebar, Button, LoadingDots } from '@components/ui'
|
||||
import PaymentMethodView from '@components/checkout/PaymentMethodView'
|
||||
import CheckoutSidebarView from '@components/checkout/CheckoutSidebarView'
|
||||
import ShippingMethodView from '@components/checkout/ShippingMethodView'
|
||||
import { CheckoutProvider } from '@components/checkout/context'
|
||||
import { MenuSidebarView } from '@components/common/UserNav'
|
||||
import type { Page } from '@commerce/types/page'
|
||||
@ -87,6 +88,7 @@ const SidebarView: React.FC<{
|
||||
{sidebarView === 'CART_VIEW' && <CartSidebarView />}
|
||||
{sidebarView === 'SHIPPING_VIEW' && <ShippingView />}
|
||||
{sidebarView === 'PAYMENT_VIEW' && <PaymentMethodView />}
|
||||
{sidebarView === 'SHIPPING_METHOD_VIEW' && <ShippingMethodView />}
|
||||
{sidebarView === 'CHECKOUT_VIEW' && <CheckoutSidebarView />}
|
||||
{sidebarView === 'MOBILE_MENU_VIEW' && <MenuSidebarView links={links} />}
|
||||
</Sidebar>
|
||||
|
@ -18,7 +18,7 @@ const SubItem = ({ subItem, level = 0 }: SubItemProps) => {
|
||||
<a
|
||||
className={`block rounded ml-${
|
||||
level * 2
|
||||
} py-[10px] px-4 text-sm text-secondary`}
|
||||
} py-[10px] px-4 text-sm text-black`}
|
||||
>
|
||||
{subItem.label}
|
||||
</a>
|
||||
@ -28,7 +28,7 @@ const SubItem = ({ subItem, level = 0 }: SubItemProps) => {
|
||||
href={subItem.url}
|
||||
className={`block rounded ml-${
|
||||
level * 2
|
||||
} py-[10px] px-4 text-sm text-secondary`}
|
||||
} py-[10px] px-4 text-sm text-black`}
|
||||
target={subItem.shouldOpenInNewWindow ? '_blank' : ''}
|
||||
rel="noreferrer"
|
||||
>
|
||||
|
@ -29,16 +29,19 @@ const Navbar: FC<NavbarProps> = ({ links, customNavigation }) => (
|
||||
</a>
|
||||
</Link>
|
||||
<nav className={s.navMenu}>
|
||||
<Link href="/search">
|
||||
<a className={s.link}>All</a>
|
||||
</Link>
|
||||
{links?.map((l) => (
|
||||
<Link href={l.href} key={l.href}>
|
||||
<a className={s.link}>{l.label}</a>
|
||||
</Link>
|
||||
))}
|
||||
{process.env.COMMERCE_CUSTOMNAVIGATION_ENABLED && (
|
||||
{process.env.COMMERCE_CUSTOMNAVIGATION_ENABLED ? (
|
||||
<CustomNavbar links={customNavigation} />
|
||||
) : (
|
||||
<>
|
||||
<Link href="/search">
|
||||
<a className={s.link}>All</a>
|
||||
</Link>
|
||||
{links?.map((l) => (
|
||||
<Link href={l.href} key={l.href}>
|
||||
<a className={s.link}>{l.label}</a>
|
||||
</Link>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
|
Loading…
x
Reference in New Issue
Block a user