init starter package
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
import { Listbox, Transition } from "@headlessui/react"
|
||||
import { ChevronUpDown } from "@medusajs/icons"
|
||||
import { clx } from "@medusajs/ui"
|
||||
import { Fragment, useMemo } from "react"
|
||||
|
||||
import Radio from "@modules/common/components/radio"
|
||||
import compareAddresses from "@lib/util/compare-addresses"
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
|
||||
type AddressSelectProps = {
|
||||
addresses: HttpTypes.StoreCustomerAddress[]
|
||||
addressInput: HttpTypes.StoreCartAddress | null
|
||||
onSelect: (
|
||||
address: HttpTypes.StoreCartAddress | undefined,
|
||||
email?: string
|
||||
) => void
|
||||
}
|
||||
|
||||
const AddressSelect = ({
|
||||
addresses,
|
||||
addressInput,
|
||||
onSelect,
|
||||
}: AddressSelectProps) => {
|
||||
const handleSelect = (id: string) => {
|
||||
const savedAddress = addresses.find((a) => a.id === id)
|
||||
if (savedAddress) {
|
||||
onSelect(savedAddress as HttpTypes.StoreCartAddress)
|
||||
}
|
||||
}
|
||||
|
||||
const selectedAddress = useMemo(() => {
|
||||
return addresses.find((a) => compareAddresses(a, addressInput))
|
||||
}, [addresses, addressInput])
|
||||
|
||||
return (
|
||||
<Listbox onChange={handleSelect} value={selectedAddress?.id}>
|
||||
<div className="relative">
|
||||
<Listbox.Button
|
||||
className="relative w-full flex justify-between items-center px-4 py-[10px] text-left bg-white cursor-default focus:outline-none border rounded-rounded focus-visible:ring-2 focus-visible:ring-opacity-75 focus-visible:ring-white focus-visible:ring-offset-gray-300 focus-visible:ring-offset-2 focus-visible:border-gray-300 text-base-regular"
|
||||
data-testid="shipping-address-select"
|
||||
>
|
||||
{({ open }) => (
|
||||
<>
|
||||
<span className="block truncate">
|
||||
{selectedAddress
|
||||
? selectedAddress.address_1
|
||||
: "Choose an address"}
|
||||
</span>
|
||||
<ChevronUpDown
|
||||
className={clx("transition-rotate duration-200", {
|
||||
"transform rotate-180": open,
|
||||
})}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Listbox.Button>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
leave="transition ease-in duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<Listbox.Options
|
||||
className="absolute z-20 w-full overflow-auto text-small-regular bg-white border border-top-0 max-h-60 focus:outline-none sm:text-sm"
|
||||
data-testid="shipping-address-options"
|
||||
>
|
||||
{addresses.map((address) => {
|
||||
return (
|
||||
<Listbox.Option
|
||||
key={address.id}
|
||||
value={address.id}
|
||||
className="cursor-default select-none relative pl-6 pr-10 hover:bg-gray-50 py-4"
|
||||
data-testid="shipping-address-option"
|
||||
>
|
||||
<div className="flex gap-x-4 items-start">
|
||||
<Radio
|
||||
checked={selectedAddress?.id === address.id}
|
||||
data-testid="shipping-address-radio"
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-left text-base-semi">
|
||||
{address.first_name} {address.last_name}
|
||||
</span>
|
||||
{address.company && (
|
||||
<span className="text-small-regular text-ui-fg-base">
|
||||
{address.company}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex flex-col text-left text-base-regular mt-2">
|
||||
<span>
|
||||
{address.address_1}
|
||||
{address.address_2 && (
|
||||
<span>, {address.address_2}</span>
|
||||
)}
|
||||
</span>
|
||||
<span>
|
||||
{address.postal_code}, {address.city}
|
||||
</span>
|
||||
<span>
|
||||
{address.province && `${address.province}, `}
|
||||
{address.country_code?.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Listbox.Option>
|
||||
)
|
||||
})}
|
||||
</Listbox.Options>
|
||||
</Transition>
|
||||
</div>
|
||||
</Listbox>
|
||||
)
|
||||
}
|
||||
|
||||
export default AddressSelect
|
||||
@@ -0,0 +1,184 @@
|
||||
"use client"
|
||||
|
||||
import { setAddresses } from "@lib/data/cart"
|
||||
import compareAddresses from "@lib/util/compare-addresses"
|
||||
import { CheckCircleSolid } from "@medusajs/icons"
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
import { Heading, Text, useToggleState } from "@medusajs/ui"
|
||||
import Divider from "@modules/common/components/divider"
|
||||
import Spinner from "@modules/common/icons/spinner"
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation"
|
||||
import { useActionState } from "react"
|
||||
import BillingAddress from "../billing_address"
|
||||
import ErrorMessage from "../error-message"
|
||||
import ShippingAddress from "../shipping-address"
|
||||
import { SubmitButton } from "../submit-button"
|
||||
|
||||
const Addresses = ({
|
||||
cart,
|
||||
customer,
|
||||
}: {
|
||||
cart: HttpTypes.StoreCart | null
|
||||
customer: HttpTypes.StoreCustomer | null
|
||||
}) => {
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
|
||||
const isOpen = searchParams.get("step") === "address"
|
||||
|
||||
const { state: sameAsBilling, toggle: toggleSameAsBilling } = useToggleState(
|
||||
cart?.shipping_address && cart?.billing_address
|
||||
? compareAddresses(cart?.shipping_address, cart?.billing_address)
|
||||
: true
|
||||
)
|
||||
|
||||
const handleEdit = () => {
|
||||
router.push(pathname + "?step=address")
|
||||
}
|
||||
|
||||
const [message, formAction] = useActionState(setAddresses, null)
|
||||
|
||||
return (
|
||||
<div className="bg-white">
|
||||
<div className="flex flex-row items-center justify-between mb-6">
|
||||
<Heading
|
||||
level="h2"
|
||||
className="flex flex-row text-3xl-regular gap-x-2 items-baseline"
|
||||
>
|
||||
Shipping Address
|
||||
{!isOpen && <CheckCircleSolid />}
|
||||
</Heading>
|
||||
{!isOpen && cart?.shipping_address && (
|
||||
<Text>
|
||||
<button
|
||||
onClick={handleEdit}
|
||||
className="text-ui-fg-interactive hover:text-ui-fg-interactive-hover"
|
||||
data-testid="edit-address-button"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
{isOpen ? (
|
||||
<form action={formAction}>
|
||||
<div className="pb-8">
|
||||
<ShippingAddress
|
||||
customer={customer}
|
||||
checked={sameAsBilling}
|
||||
onChange={toggleSameAsBilling}
|
||||
cart={cart}
|
||||
/>
|
||||
|
||||
{!sameAsBilling && (
|
||||
<div>
|
||||
<Heading
|
||||
level="h2"
|
||||
className="text-3xl-regular gap-x-4 pb-6 pt-8"
|
||||
>
|
||||
Billing address
|
||||
</Heading>
|
||||
|
||||
<BillingAddress cart={cart} />
|
||||
</div>
|
||||
)}
|
||||
<SubmitButton className="mt-6" data-testid="submit-address-button">
|
||||
Continue to delivery
|
||||
</SubmitButton>
|
||||
<ErrorMessage error={message} data-testid="address-error-message" />
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<div>
|
||||
<div className="text-small-regular">
|
||||
{cart && cart.shipping_address ? (
|
||||
<div className="flex items-start gap-x-8">
|
||||
<div className="flex items-start gap-x-1 w-full">
|
||||
<div
|
||||
className="flex flex-col w-1/3"
|
||||
data-testid="shipping-address-summary"
|
||||
>
|
||||
<Text className="txt-medium-plus text-ui-fg-base mb-1">
|
||||
Shipping Address
|
||||
</Text>
|
||||
<Text className="txt-medium text-ui-fg-subtle">
|
||||
{cart.shipping_address.first_name}{" "}
|
||||
{cart.shipping_address.last_name}
|
||||
</Text>
|
||||
<Text className="txt-medium text-ui-fg-subtle">
|
||||
{cart.shipping_address.address_1}{" "}
|
||||
{cart.shipping_address.address_2}
|
||||
</Text>
|
||||
<Text className="txt-medium text-ui-fg-subtle">
|
||||
{cart.shipping_address.postal_code},{" "}
|
||||
{cart.shipping_address.city}
|
||||
</Text>
|
||||
<Text className="txt-medium text-ui-fg-subtle">
|
||||
{cart.shipping_address.country_code?.toUpperCase()}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex flex-col w-1/3 "
|
||||
data-testid="shipping-contact-summary"
|
||||
>
|
||||
<Text className="txt-medium-plus text-ui-fg-base mb-1">
|
||||
Contact
|
||||
</Text>
|
||||
<Text className="txt-medium text-ui-fg-subtle">
|
||||
{cart.shipping_address.phone}
|
||||
</Text>
|
||||
<Text className="txt-medium text-ui-fg-subtle">
|
||||
{cart.email}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex flex-col w-1/3"
|
||||
data-testid="billing-address-summary"
|
||||
>
|
||||
<Text className="txt-medium-plus text-ui-fg-base mb-1">
|
||||
Billing Address
|
||||
</Text>
|
||||
|
||||
{sameAsBilling ? (
|
||||
<Text className="txt-medium text-ui-fg-subtle">
|
||||
Billing and delivery address are the same.
|
||||
</Text>
|
||||
) : (
|
||||
<>
|
||||
<Text className="txt-medium text-ui-fg-subtle">
|
||||
{cart.billing_address?.first_name}{" "}
|
||||
{cart.billing_address?.last_name}
|
||||
</Text>
|
||||
<Text className="txt-medium text-ui-fg-subtle">
|
||||
{cart.billing_address?.address_1}{" "}
|
||||
{cart.billing_address?.address_2}
|
||||
</Text>
|
||||
<Text className="txt-medium text-ui-fg-subtle">
|
||||
{cart.billing_address?.postal_code},{" "}
|
||||
{cart.billing_address?.city}
|
||||
</Text>
|
||||
<Text className="txt-medium text-ui-fg-subtle">
|
||||
{cart.billing_address?.country_code?.toUpperCase()}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<Spinner />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Divider className="mt-8" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Addresses
|
||||
@@ -0,0 +1,114 @@
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
import Input from "@modules/common/components/input"
|
||||
import React, { useState } from "react"
|
||||
import CountrySelect from "../country-select"
|
||||
|
||||
const BillingAddress = ({ cart }: { cart: HttpTypes.StoreCart | null }) => {
|
||||
const [formData, setFormData] = useState<any>({
|
||||
"billing_address.first_name": cart?.billing_address?.first_name || "",
|
||||
"billing_address.last_name": cart?.billing_address?.last_name || "",
|
||||
"billing_address.address_1": cart?.billing_address?.address_1 || "",
|
||||
"billing_address.company": cart?.billing_address?.company || "",
|
||||
"billing_address.postal_code": cart?.billing_address?.postal_code || "",
|
||||
"billing_address.city": cart?.billing_address?.city || "",
|
||||
"billing_address.country_code": cart?.billing_address?.country_code || "",
|
||||
"billing_address.province": cart?.billing_address?.province || "",
|
||||
"billing_address.phone": cart?.billing_address?.phone || "",
|
||||
})
|
||||
|
||||
const handleChange = (
|
||||
e: React.ChangeEvent<
|
||||
HTMLInputElement | HTMLInputElement | HTMLSelectElement
|
||||
>
|
||||
) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
[e.target.name]: e.target.value,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="First name"
|
||||
name="billing_address.first_name"
|
||||
autoComplete="given-name"
|
||||
value={formData["billing_address.first_name"]}
|
||||
onChange={handleChange}
|
||||
required
|
||||
data-testid="billing-first-name-input"
|
||||
/>
|
||||
<Input
|
||||
label="Last name"
|
||||
name="billing_address.last_name"
|
||||
autoComplete="family-name"
|
||||
value={formData["billing_address.last_name"]}
|
||||
onChange={handleChange}
|
||||
required
|
||||
data-testid="billing-last-name-input"
|
||||
/>
|
||||
<Input
|
||||
label="Address"
|
||||
name="billing_address.address_1"
|
||||
autoComplete="address-line1"
|
||||
value={formData["billing_address.address_1"]}
|
||||
onChange={handleChange}
|
||||
required
|
||||
data-testid="billing-address-input"
|
||||
/>
|
||||
<Input
|
||||
label="Company"
|
||||
name="billing_address.company"
|
||||
value={formData["billing_address.company"]}
|
||||
onChange={handleChange}
|
||||
autoComplete="organization"
|
||||
data-testid="billing-company-input"
|
||||
/>
|
||||
<Input
|
||||
label="Postal code"
|
||||
name="billing_address.postal_code"
|
||||
autoComplete="postal-code"
|
||||
value={formData["billing_address.postal_code"]}
|
||||
onChange={handleChange}
|
||||
required
|
||||
data-testid="billing-postal-input"
|
||||
/>
|
||||
<Input
|
||||
label="City"
|
||||
name="billing_address.city"
|
||||
autoComplete="address-level2"
|
||||
value={formData["billing_address.city"]}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<CountrySelect
|
||||
name="billing_address.country_code"
|
||||
autoComplete="country"
|
||||
region={cart?.region}
|
||||
value={formData["billing_address.country_code"]}
|
||||
onChange={handleChange}
|
||||
required
|
||||
data-testid="billing-country-select"
|
||||
/>
|
||||
<Input
|
||||
label="State / Province"
|
||||
name="billing_address.province"
|
||||
autoComplete="address-level1"
|
||||
value={formData["billing_address.province"]}
|
||||
onChange={handleChange}
|
||||
data-testid="billing-province-input"
|
||||
/>
|
||||
<Input
|
||||
label="Phone"
|
||||
name="billing_address.phone"
|
||||
autoComplete="tel"
|
||||
value={formData["billing_address.phone"]}
|
||||
onChange={handleChange}
|
||||
data-testid="billing-phone-input"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default BillingAddress
|
||||
@@ -0,0 +1,50 @@
|
||||
import { forwardRef, useImperativeHandle, useMemo, useRef } from "react"
|
||||
|
||||
import NativeSelect, {
|
||||
NativeSelectProps,
|
||||
} from "@modules/common/components/native-select"
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
|
||||
const CountrySelect = forwardRef<
|
||||
HTMLSelectElement,
|
||||
NativeSelectProps & {
|
||||
region?: HttpTypes.StoreRegion
|
||||
}
|
||||
>(({ placeholder = "Country", region, defaultValue, ...props }, ref) => {
|
||||
const innerRef = useRef<HTMLSelectElement>(null)
|
||||
|
||||
useImperativeHandle<HTMLSelectElement | null, HTMLSelectElement | null>(
|
||||
ref,
|
||||
() => innerRef.current
|
||||
)
|
||||
|
||||
const countryOptions = useMemo(() => {
|
||||
if (!region) {
|
||||
return []
|
||||
}
|
||||
|
||||
return region.countries?.map((country) => ({
|
||||
value: country.iso_2,
|
||||
label: country.display_name,
|
||||
}))
|
||||
}, [region])
|
||||
|
||||
return (
|
||||
<NativeSelect
|
||||
ref={innerRef}
|
||||
placeholder={placeholder}
|
||||
defaultValue={defaultValue}
|
||||
{...props}
|
||||
>
|
||||
{countryOptions?.map(({ value, label }, index) => (
|
||||
<option key={index} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
)
|
||||
})
|
||||
|
||||
CountrySelect.displayName = "CountrySelect"
|
||||
|
||||
export default CountrySelect
|
||||
@@ -0,0 +1,179 @@
|
||||
"use client"
|
||||
|
||||
import { Badge, Heading, Input, Label, Text } from "@medusajs/ui"
|
||||
import React from "react"
|
||||
|
||||
import { applyPromotions } from "@lib/data/cart"
|
||||
import { convertToLocale } from "@lib/util/money"
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
import Trash from "@modules/common/icons/trash"
|
||||
import ErrorMessage from "../error-message"
|
||||
import { SubmitButton } from "../submit-button"
|
||||
|
||||
type DiscountCodeProps = {
|
||||
cart: HttpTypes.StoreCart & {
|
||||
promotions: HttpTypes.StorePromotion[]
|
||||
}
|
||||
}
|
||||
|
||||
const DiscountCode: React.FC<DiscountCodeProps> = ({ cart }) => {
|
||||
const [isOpen, setIsOpen] = React.useState(false)
|
||||
const [errorMessage, setErrorMessage] = React.useState("")
|
||||
|
||||
const { promotions = [] } = cart
|
||||
const removePromotionCode = async (code: string) => {
|
||||
const validPromotions = promotions.filter(
|
||||
(promotion) => promotion.code !== code
|
||||
)
|
||||
|
||||
await applyPromotions(
|
||||
validPromotions.filter((p) => p.code !== undefined).map((p) => p.code!)
|
||||
)
|
||||
}
|
||||
|
||||
const addPromotionCode = async (formData: FormData) => {
|
||||
setErrorMessage("")
|
||||
|
||||
const code = formData.get("code")
|
||||
if (!code) {
|
||||
return
|
||||
}
|
||||
const input = document.getElementById("promotion-input") as HTMLInputElement
|
||||
const codes = promotions
|
||||
.filter((p) => p.code !== undefined)
|
||||
.map((p) => p.code!)
|
||||
codes.push(code.toString())
|
||||
|
||||
try {
|
||||
await applyPromotions(codes)
|
||||
} catch (e: any) {
|
||||
setErrorMessage(e.message)
|
||||
}
|
||||
|
||||
if (input) {
|
||||
input.value = ""
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full bg-white flex flex-col">
|
||||
<div className="txt-medium">
|
||||
<form action={(a) => addPromotionCode(a)} className="w-full mb-5">
|
||||
<Label className="flex gap-x-1 my-2 items-center">
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
type="button"
|
||||
className="txt-medium text-ui-fg-interactive hover:text-ui-fg-interactive-hover"
|
||||
data-testid="add-discount-button"
|
||||
>
|
||||
Add Promotion Code(s)
|
||||
</button>
|
||||
|
||||
{/* <Tooltip content="You can add multiple promotion codes">
|
||||
<InformationCircleSolid color="var(--fg-muted)" />
|
||||
</Tooltip> */}
|
||||
</Label>
|
||||
|
||||
{isOpen && (
|
||||
<>
|
||||
<div className="flex w-full gap-x-2">
|
||||
<Input
|
||||
className="size-full"
|
||||
id="promotion-input"
|
||||
name="code"
|
||||
type="text"
|
||||
autoFocus={false}
|
||||
data-testid="discount-input"
|
||||
/>
|
||||
<SubmitButton
|
||||
variant="secondary"
|
||||
data-testid="discount-apply-button"
|
||||
>
|
||||
Apply
|
||||
</SubmitButton>
|
||||
</div>
|
||||
|
||||
<ErrorMessage
|
||||
error={errorMessage}
|
||||
data-testid="discount-error-message"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
|
||||
{promotions.length > 0 && (
|
||||
<div className="w-full flex items-center">
|
||||
<div className="flex flex-col w-full">
|
||||
<Heading className="txt-medium mb-2">
|
||||
Promotion(s) applied:
|
||||
</Heading>
|
||||
|
||||
{promotions.map((promotion) => {
|
||||
return (
|
||||
<div
|
||||
key={promotion.id}
|
||||
className="flex items-center justify-between w-full max-w-full mb-2"
|
||||
data-testid="discount-row"
|
||||
>
|
||||
<Text className="flex gap-x-1 items-baseline txt-small-plus w-4/5 pr-1">
|
||||
<span className="truncate" data-testid="discount-code">
|
||||
<Badge
|
||||
color={promotion.is_automatic ? "green" : "grey"}
|
||||
size="small"
|
||||
>
|
||||
{promotion.code}
|
||||
</Badge>{" "}
|
||||
(
|
||||
{promotion.application_method?.value !== undefined &&
|
||||
promotion.application_method.currency_code !==
|
||||
undefined && (
|
||||
<>
|
||||
{promotion.application_method.type ===
|
||||
"percentage"
|
||||
? `${promotion.application_method.value}%`
|
||||
: convertToLocale({
|
||||
amount: +promotion.application_method.value,
|
||||
currency_code:
|
||||
promotion.application_method
|
||||
.currency_code,
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
)
|
||||
{/* {promotion.is_automatic && (
|
||||
<Tooltip content="This promotion is automatically applied">
|
||||
<InformationCircleSolid className="inline text-zinc-400" />
|
||||
</Tooltip>
|
||||
)} */}
|
||||
</span>
|
||||
</Text>
|
||||
{!promotion.is_automatic && (
|
||||
<button
|
||||
className="flex items-center"
|
||||
onClick={() => {
|
||||
if (!promotion.code) {
|
||||
return
|
||||
}
|
||||
|
||||
removePromotionCode(promotion.code)
|
||||
}}
|
||||
data-testid="remove-discount-button"
|
||||
>
|
||||
<Trash size={14} />
|
||||
<span className="sr-only">
|
||||
Remove discount code from order
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DiscountCode
|
||||
@@ -0,0 +1,13 @@
|
||||
const ErrorMessage = ({ error, 'data-testid': dataTestid }: { error?: string | null, 'data-testid'?: string }) => {
|
||||
if (!error) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pt-2 text-rose-500 text-small-regular" data-testid={dataTestid}>
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ErrorMessage
|
||||
@@ -0,0 +1,193 @@
|
||||
"use client"
|
||||
|
||||
import { isManual, isStripe } from "@lib/constants"
|
||||
import { placeOrder } from "@lib/data/cart"
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
import { Button } from "@medusajs/ui"
|
||||
import { useElements, useStripe } from "@stripe/react-stripe-js"
|
||||
import React, { useState } from "react"
|
||||
import ErrorMessage from "../error-message"
|
||||
|
||||
type PaymentButtonProps = {
|
||||
cart: HttpTypes.StoreCart
|
||||
"data-testid": string
|
||||
}
|
||||
|
||||
const PaymentButton: React.FC<PaymentButtonProps> = ({
|
||||
cart,
|
||||
"data-testid": dataTestId,
|
||||
}) => {
|
||||
const notReady =
|
||||
!cart ||
|
||||
!cart.shipping_address ||
|
||||
!cart.billing_address ||
|
||||
!cart.email ||
|
||||
(cart.shipping_methods?.length ?? 0) < 1
|
||||
|
||||
const paymentSession = cart.payment_collection?.payment_sessions?.[0]
|
||||
|
||||
switch (true) {
|
||||
case isStripe(paymentSession?.provider_id):
|
||||
return (
|
||||
<StripePaymentButton
|
||||
notReady={notReady}
|
||||
cart={cart}
|
||||
data-testid={dataTestId}
|
||||
/>
|
||||
)
|
||||
case isManual(paymentSession?.provider_id):
|
||||
return (
|
||||
<ManualTestPaymentButton notReady={notReady} data-testid={dataTestId} />
|
||||
)
|
||||
default:
|
||||
return <Button disabled>Select a payment method</Button>
|
||||
}
|
||||
}
|
||||
|
||||
const StripePaymentButton = ({
|
||||
cart,
|
||||
notReady,
|
||||
"data-testid": dataTestId,
|
||||
}: {
|
||||
cart: HttpTypes.StoreCart
|
||||
notReady: boolean
|
||||
"data-testid"?: string
|
||||
}) => {
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||
|
||||
const onPaymentCompleted = async () => {
|
||||
await placeOrder()
|
||||
.catch((err) => {
|
||||
setErrorMessage(err.message)
|
||||
})
|
||||
.finally(() => {
|
||||
setSubmitting(false)
|
||||
})
|
||||
}
|
||||
|
||||
const stripe = useStripe()
|
||||
const elements = useElements()
|
||||
const card = elements?.getElement("card")
|
||||
|
||||
const session = cart.payment_collection?.payment_sessions?.find(
|
||||
(s) => s.status === "pending"
|
||||
)
|
||||
|
||||
const disabled = !stripe || !elements ? true : false
|
||||
|
||||
const handlePayment = async () => {
|
||||
setSubmitting(true)
|
||||
|
||||
if (!stripe || !elements || !card || !cart) {
|
||||
setSubmitting(false)
|
||||
return
|
||||
}
|
||||
|
||||
await stripe
|
||||
.confirmCardPayment(session?.data.client_secret as string, {
|
||||
payment_method: {
|
||||
card: card,
|
||||
billing_details: {
|
||||
name:
|
||||
cart.billing_address?.first_name +
|
||||
" " +
|
||||
cart.billing_address?.last_name,
|
||||
address: {
|
||||
city: cart.billing_address?.city ?? undefined,
|
||||
country: cart.billing_address?.country_code ?? undefined,
|
||||
line1: cart.billing_address?.address_1 ?? undefined,
|
||||
line2: cart.billing_address?.address_2 ?? undefined,
|
||||
postal_code: cart.billing_address?.postal_code ?? undefined,
|
||||
state: cart.billing_address?.province ?? undefined,
|
||||
},
|
||||
email: cart.email,
|
||||
phone: cart.billing_address?.phone ?? undefined,
|
||||
},
|
||||
},
|
||||
})
|
||||
.then(({ error, paymentIntent }) => {
|
||||
if (error) {
|
||||
const pi = error.payment_intent
|
||||
|
||||
if (
|
||||
(pi && pi.status === "requires_capture") ||
|
||||
(pi && pi.status === "succeeded")
|
||||
) {
|
||||
onPaymentCompleted()
|
||||
}
|
||||
|
||||
setErrorMessage(error.message || null)
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
(paymentIntent && paymentIntent.status === "requires_capture") ||
|
||||
paymentIntent.status === "succeeded"
|
||||
) {
|
||||
return onPaymentCompleted()
|
||||
}
|
||||
|
||||
return
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
disabled={disabled || notReady}
|
||||
onClick={handlePayment}
|
||||
size="large"
|
||||
isLoading={submitting}
|
||||
data-testid={dataTestId}
|
||||
>
|
||||
Place order
|
||||
</Button>
|
||||
<ErrorMessage
|
||||
error={errorMessage}
|
||||
data-testid="stripe-payment-error-message"
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const ManualTestPaymentButton = ({ notReady }: { notReady: boolean }) => {
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||
|
||||
const onPaymentCompleted = async () => {
|
||||
await placeOrder()
|
||||
.catch((err) => {
|
||||
setErrorMessage(err.message)
|
||||
})
|
||||
.finally(() => {
|
||||
setSubmitting(false)
|
||||
})
|
||||
}
|
||||
|
||||
const handlePayment = () => {
|
||||
setSubmitting(true)
|
||||
|
||||
onPaymentCompleted()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
disabled={notReady}
|
||||
isLoading={submitting}
|
||||
onClick={handlePayment}
|
||||
size="large"
|
||||
data-testid="submit-order-button"
|
||||
>
|
||||
Place order
|
||||
</Button>
|
||||
<ErrorMessage
|
||||
error={errorMessage}
|
||||
data-testid="manual-payment-error-message"
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default PaymentButton
|
||||
@@ -0,0 +1,129 @@
|
||||
import { Radio as RadioGroupOption } from "@headlessui/react"
|
||||
import { Text, clx } from "@medusajs/ui"
|
||||
import React, { useContext, useMemo, type JSX } from "react"
|
||||
|
||||
import Radio from "@modules/common/components/radio"
|
||||
|
||||
import { isManual } from "@lib/constants"
|
||||
import SkeletonCardDetails from "@modules/skeletons/components/skeleton-card-details"
|
||||
import { CardElement } from "@stripe/react-stripe-js"
|
||||
import { StripeCardElementOptions } from "@stripe/stripe-js"
|
||||
import PaymentTest from "../payment-test"
|
||||
import { StripeContext } from "../payment-wrapper/stripe-wrapper"
|
||||
|
||||
type PaymentContainerProps = {
|
||||
paymentProviderId: string
|
||||
selectedPaymentOptionId: string | null
|
||||
disabled?: boolean
|
||||
paymentInfoMap: Record<string, { title: string; icon: JSX.Element }>
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
const PaymentContainer: React.FC<PaymentContainerProps> = ({
|
||||
paymentProviderId,
|
||||
selectedPaymentOptionId,
|
||||
paymentInfoMap,
|
||||
disabled = false,
|
||||
children,
|
||||
}) => {
|
||||
const isDevelopment = process.env.NODE_ENV === "development"
|
||||
|
||||
return (
|
||||
<RadioGroupOption
|
||||
key={paymentProviderId}
|
||||
value={paymentProviderId}
|
||||
disabled={disabled}
|
||||
className={clx(
|
||||
"flex flex-col gap-y-2 text-small-regular cursor-pointer py-4 border rounded-rounded px-8 mb-2 hover:shadow-borders-interactive-with-active",
|
||||
{
|
||||
"border-ui-border-interactive":
|
||||
selectedPaymentOptionId === paymentProviderId,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between ">
|
||||
<div className="flex items-center gap-x-4">
|
||||
<Radio checked={selectedPaymentOptionId === paymentProviderId} />
|
||||
<Text className="text-base-regular">
|
||||
{paymentInfoMap[paymentProviderId]?.title || paymentProviderId}
|
||||
</Text>
|
||||
{isManual(paymentProviderId) && isDevelopment && (
|
||||
<PaymentTest className="hidden small:block" />
|
||||
)}
|
||||
</div>
|
||||
<span className="justify-self-end text-ui-fg-base">
|
||||
{paymentInfoMap[paymentProviderId]?.icon}
|
||||
</span>
|
||||
</div>
|
||||
{isManual(paymentProviderId) && isDevelopment && (
|
||||
<PaymentTest className="small:hidden text-[10px]" />
|
||||
)}
|
||||
{children}
|
||||
</RadioGroupOption>
|
||||
)
|
||||
}
|
||||
|
||||
export default PaymentContainer
|
||||
|
||||
export const StripeCardContainer = ({
|
||||
paymentProviderId,
|
||||
selectedPaymentOptionId,
|
||||
paymentInfoMap,
|
||||
disabled = false,
|
||||
setCardBrand,
|
||||
setError,
|
||||
setCardComplete,
|
||||
}: Omit<PaymentContainerProps, "children"> & {
|
||||
setCardBrand: (brand: string) => void
|
||||
setError: (error: string | null) => void
|
||||
setCardComplete: (complete: boolean) => void
|
||||
}) => {
|
||||
const stripeReady = useContext(StripeContext)
|
||||
|
||||
const useOptions: StripeCardElementOptions = useMemo(() => {
|
||||
return {
|
||||
style: {
|
||||
base: {
|
||||
fontFamily: "Inter, sans-serif",
|
||||
color: "#424270",
|
||||
"::placeholder": {
|
||||
color: "rgb(107 114 128)",
|
||||
},
|
||||
},
|
||||
},
|
||||
classes: {
|
||||
base: "pt-3 pb-1 block w-full h-11 px-4 mt-0 bg-ui-bg-field border rounded-md appearance-none focus:outline-none focus:ring-0 focus:shadow-borders-interactive-with-active border-ui-border-base hover:bg-ui-bg-field-hover transition-all duration-300 ease-in-out",
|
||||
},
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<PaymentContainer
|
||||
paymentProviderId={paymentProviderId}
|
||||
selectedPaymentOptionId={selectedPaymentOptionId}
|
||||
paymentInfoMap={paymentInfoMap}
|
||||
disabled={disabled}
|
||||
>
|
||||
{selectedPaymentOptionId === paymentProviderId &&
|
||||
(stripeReady ? (
|
||||
<div className="my-4 transition-all duration-150 ease-in-out">
|
||||
<Text className="txt-medium-plus text-ui-fg-base mb-1">
|
||||
Enter your card details:
|
||||
</Text>
|
||||
<CardElement
|
||||
options={useOptions as StripeCardElementOptions}
|
||||
onChange={(e) => {
|
||||
setCardBrand(
|
||||
e.brand && e.brand.charAt(0).toUpperCase() + e.brand.slice(1)
|
||||
)
|
||||
setError(e.error?.message || null)
|
||||
setCardComplete(e.complete)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<SkeletonCardDetails />
|
||||
))}
|
||||
</PaymentContainer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Badge } from "@medusajs/ui"
|
||||
|
||||
const PaymentTest = ({ className }: { className?: string }) => {
|
||||
return (
|
||||
<Badge color="orange" className={className}>
|
||||
<span className="font-semibold">Attention:</span> For testing purposes
|
||||
only.
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
export default PaymentTest
|
||||
@@ -0,0 +1,41 @@
|
||||
"use client"
|
||||
|
||||
import { loadStripe } from "@stripe/stripe-js"
|
||||
import React from "react"
|
||||
import StripeWrapper from "./stripe-wrapper"
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
import { isStripe } from "@lib/constants"
|
||||
|
||||
type PaymentWrapperProps = {
|
||||
cart: HttpTypes.StoreCart
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
const stripeKey = process.env.NEXT_PUBLIC_STRIPE_KEY
|
||||
const stripePromise = stripeKey ? loadStripe(stripeKey) : null
|
||||
|
||||
const PaymentWrapper: React.FC<PaymentWrapperProps> = ({ cart, children }) => {
|
||||
const paymentSession = cart.payment_collection?.payment_sessions?.find(
|
||||
(s) => s.status === "pending"
|
||||
)
|
||||
|
||||
if (
|
||||
isStripe(paymentSession?.provider_id) &&
|
||||
paymentSession &&
|
||||
stripePromise
|
||||
) {
|
||||
return (
|
||||
<StripeWrapper
|
||||
paymentSession={paymentSession}
|
||||
stripeKey={stripeKey}
|
||||
stripePromise={stripePromise}
|
||||
>
|
||||
{children}
|
||||
</StripeWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
return <div>{children}</div>
|
||||
}
|
||||
|
||||
export default PaymentWrapper
|
||||
@@ -0,0 +1,54 @@
|
||||
"use client"
|
||||
|
||||
import { Stripe, StripeElementsOptions } from "@stripe/stripe-js"
|
||||
import { Elements } from "@stripe/react-stripe-js"
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
import { createContext } from "react"
|
||||
|
||||
type StripeWrapperProps = {
|
||||
paymentSession: HttpTypes.StorePaymentSession
|
||||
stripeKey?: string
|
||||
stripePromise: Promise<Stripe | null> | null
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export const StripeContext = createContext(false)
|
||||
|
||||
const StripeWrapper: React.FC<StripeWrapperProps> = ({
|
||||
paymentSession,
|
||||
stripeKey,
|
||||
stripePromise,
|
||||
children,
|
||||
}) => {
|
||||
const options: StripeElementsOptions = {
|
||||
clientSecret: paymentSession!.data?.client_secret as string | undefined,
|
||||
}
|
||||
|
||||
if (!stripeKey) {
|
||||
throw new Error(
|
||||
"Stripe key is missing. Set NEXT_PUBLIC_STRIPE_KEY environment variable."
|
||||
)
|
||||
}
|
||||
|
||||
if (!stripePromise) {
|
||||
throw new Error(
|
||||
"Stripe promise is missing. Make sure you have provided a valid Stripe key."
|
||||
)
|
||||
}
|
||||
|
||||
if (!paymentSession?.data?.client_secret) {
|
||||
throw new Error(
|
||||
"Stripe client secret is missing. Cannot initialize Stripe."
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<StripeContext.Provider value={true}>
|
||||
<Elements options={options} stripe={stripePromise}>
|
||||
{children}
|
||||
</Elements>
|
||||
</StripeContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export default StripeWrapper
|
||||
@@ -0,0 +1,261 @@
|
||||
"use client"
|
||||
|
||||
import { RadioGroup } from "@headlessui/react"
|
||||
import { isStripe as isStripeFunc, paymentInfoMap } from "@lib/constants"
|
||||
import { initiatePaymentSession } from "@lib/data/cart"
|
||||
import { CheckCircleSolid, CreditCard } from "@medusajs/icons"
|
||||
import { Button, Container, Heading, Text, clx } from "@medusajs/ui"
|
||||
import ErrorMessage from "@modules/checkout/components/error-message"
|
||||
import PaymentContainer, {
|
||||
StripeCardContainer,
|
||||
} from "@modules/checkout/components/payment-container"
|
||||
import Divider from "@modules/common/components/divider"
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation"
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
|
||||
const Payment = ({
|
||||
cart,
|
||||
availablePaymentMethods,
|
||||
}: {
|
||||
cart: any
|
||||
availablePaymentMethods: any[]
|
||||
}) => {
|
||||
const activeSession = cart.payment_collection?.payment_sessions?.find(
|
||||
(paymentSession: any) => paymentSession.status === "pending"
|
||||
)
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [cardBrand, setCardBrand] = useState<string | null>(null)
|
||||
const [cardComplete, setCardComplete] = useState(false)
|
||||
const [selectedPaymentMethod, setSelectedPaymentMethod] = useState(
|
||||
activeSession?.provider_id ?? ""
|
||||
)
|
||||
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
|
||||
const isOpen = searchParams.get("step") === "payment"
|
||||
|
||||
const isStripe = isStripeFunc(selectedPaymentMethod)
|
||||
|
||||
const setPaymentMethod = async (method: string) => {
|
||||
setError(null)
|
||||
setSelectedPaymentMethod(method)
|
||||
if (isStripeFunc(method)) {
|
||||
await initiatePaymentSession(cart, {
|
||||
provider_id: method,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const paidByGiftcard =
|
||||
cart?.gift_cards && cart?.gift_cards?.length > 0 && cart?.total === 0
|
||||
|
||||
const paymentReady =
|
||||
(activeSession && cart?.shipping_methods.length !== 0) || paidByGiftcard
|
||||
|
||||
const createQueryString = useCallback(
|
||||
(name: string, value: string) => {
|
||||
const params = new URLSearchParams(searchParams)
|
||||
params.set(name, value)
|
||||
|
||||
return params.toString()
|
||||
},
|
||||
[searchParams]
|
||||
)
|
||||
|
||||
const handleEdit = () => {
|
||||
router.push(pathname + "?" + createQueryString("step", "payment"), {
|
||||
scroll: false,
|
||||
})
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const shouldInputCard =
|
||||
isStripeFunc(selectedPaymentMethod) && !activeSession
|
||||
|
||||
const checkActiveSession =
|
||||
activeSession?.provider_id === selectedPaymentMethod
|
||||
|
||||
if (!checkActiveSession) {
|
||||
await initiatePaymentSession(cart, {
|
||||
provider_id: selectedPaymentMethod,
|
||||
})
|
||||
}
|
||||
|
||||
if (!shouldInputCard) {
|
||||
return router.push(
|
||||
pathname + "?" + createQueryString("step", "review"),
|
||||
{
|
||||
scroll: false,
|
||||
}
|
||||
)
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setError(null)
|
||||
}, [isOpen])
|
||||
|
||||
return (
|
||||
<div className="bg-white">
|
||||
<div className="flex flex-row items-center justify-between mb-6">
|
||||
<Heading
|
||||
level="h2"
|
||||
className={clx(
|
||||
"flex flex-row text-3xl-regular gap-x-2 items-baseline",
|
||||
{
|
||||
"opacity-50 pointer-events-none select-none":
|
||||
!isOpen && !paymentReady,
|
||||
}
|
||||
)}
|
||||
>
|
||||
Payment
|
||||
{!isOpen && paymentReady && <CheckCircleSolid />}
|
||||
</Heading>
|
||||
{!isOpen && paymentReady && (
|
||||
<Text>
|
||||
<button
|
||||
onClick={handleEdit}
|
||||
className="text-ui-fg-interactive hover:text-ui-fg-interactive-hover"
|
||||
data-testid="edit-payment-button"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className={isOpen ? "block" : "hidden"}>
|
||||
{!paidByGiftcard && availablePaymentMethods?.length && (
|
||||
<>
|
||||
<RadioGroup
|
||||
value={selectedPaymentMethod}
|
||||
onChange={(value: string) => setPaymentMethod(value)}
|
||||
>
|
||||
{availablePaymentMethods.map((paymentMethod) => (
|
||||
<div key={paymentMethod.id}>
|
||||
{isStripeFunc(paymentMethod.id) ? (
|
||||
<StripeCardContainer
|
||||
paymentProviderId={paymentMethod.id}
|
||||
selectedPaymentOptionId={selectedPaymentMethod}
|
||||
paymentInfoMap={paymentInfoMap}
|
||||
setCardBrand={setCardBrand}
|
||||
setError={setError}
|
||||
setCardComplete={setCardComplete}
|
||||
/>
|
||||
) : (
|
||||
<PaymentContainer
|
||||
paymentInfoMap={paymentInfoMap}
|
||||
paymentProviderId={paymentMethod.id}
|
||||
selectedPaymentOptionId={selectedPaymentMethod}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</>
|
||||
)}
|
||||
|
||||
{paidByGiftcard && (
|
||||
<div className="flex flex-col w-1/3">
|
||||
<Text className="txt-medium-plus text-ui-fg-base mb-1">
|
||||
Payment method
|
||||
</Text>
|
||||
<Text
|
||||
className="txt-medium text-ui-fg-subtle"
|
||||
data-testid="payment-method-summary"
|
||||
>
|
||||
Gift card
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ErrorMessage
|
||||
error={error}
|
||||
data-testid="payment-method-error-message"
|
||||
/>
|
||||
|
||||
<Button
|
||||
size="large"
|
||||
className="mt-6"
|
||||
onClick={handleSubmit}
|
||||
isLoading={isLoading}
|
||||
disabled={
|
||||
(isStripe && !cardComplete) ||
|
||||
(!selectedPaymentMethod && !paidByGiftcard)
|
||||
}
|
||||
data-testid="submit-payment-button"
|
||||
>
|
||||
{!activeSession && isStripeFunc(selectedPaymentMethod)
|
||||
? " Enter card details"
|
||||
: "Continue to review"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className={isOpen ? "hidden" : "block"}>
|
||||
{cart && paymentReady && activeSession ? (
|
||||
<div className="flex items-start gap-x-1 w-full">
|
||||
<div className="flex flex-col w-1/3">
|
||||
<Text className="txt-medium-plus text-ui-fg-base mb-1">
|
||||
Payment method
|
||||
</Text>
|
||||
<Text
|
||||
className="txt-medium text-ui-fg-subtle"
|
||||
data-testid="payment-method-summary"
|
||||
>
|
||||
{paymentInfoMap[activeSession?.provider_id]?.title ||
|
||||
activeSession?.provider_id}
|
||||
</Text>
|
||||
</div>
|
||||
<div className="flex flex-col w-1/3">
|
||||
<Text className="txt-medium-plus text-ui-fg-base mb-1">
|
||||
Payment details
|
||||
</Text>
|
||||
<div
|
||||
className="flex gap-2 txt-medium text-ui-fg-subtle items-center"
|
||||
data-testid="payment-details-summary"
|
||||
>
|
||||
<Container className="flex items-center h-7 w-fit p-2 bg-ui-button-neutral-hover">
|
||||
{paymentInfoMap[selectedPaymentMethod]?.icon || (
|
||||
<CreditCard />
|
||||
)}
|
||||
</Container>
|
||||
<Text>
|
||||
{isStripeFunc(selectedPaymentMethod) && cardBrand
|
||||
? cardBrand
|
||||
: "Another step will appear"}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : paidByGiftcard ? (
|
||||
<div className="flex flex-col w-1/3">
|
||||
<Text className="txt-medium-plus text-ui-fg-base mb-1">
|
||||
Payment method
|
||||
</Text>
|
||||
<Text
|
||||
className="txt-medium text-ui-fg-subtle"
|
||||
data-testid="payment-method-summary"
|
||||
>
|
||||
Gift card
|
||||
</Text>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<Divider className="mt-8" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Payment
|
||||
@@ -0,0 +1,55 @@
|
||||
"use client"
|
||||
|
||||
import { Heading, Text, clx } from "@medusajs/ui"
|
||||
|
||||
import PaymentButton from "../payment-button"
|
||||
import { useSearchParams } from "next/navigation"
|
||||
|
||||
const Review = ({ cart }: { cart: any }) => {
|
||||
const searchParams = useSearchParams()
|
||||
|
||||
const isOpen = searchParams.get("step") === "review"
|
||||
|
||||
const paidByGiftcard =
|
||||
cart?.gift_cards && cart?.gift_cards?.length > 0 && cart?.total === 0
|
||||
|
||||
const previousStepsCompleted =
|
||||
cart.shipping_address &&
|
||||
cart.shipping_methods.length > 0 &&
|
||||
(cart.payment_collection || paidByGiftcard)
|
||||
|
||||
return (
|
||||
<div className="bg-white">
|
||||
<div className="flex flex-row items-center justify-between mb-6">
|
||||
<Heading
|
||||
level="h2"
|
||||
className={clx(
|
||||
"flex flex-row text-3xl-regular gap-x-2 items-baseline",
|
||||
{
|
||||
"opacity-50 pointer-events-none select-none": !isOpen,
|
||||
}
|
||||
)}
|
||||
>
|
||||
Review
|
||||
</Heading>
|
||||
</div>
|
||||
{isOpen && previousStepsCompleted && (
|
||||
<>
|
||||
<div className="flex items-start gap-x-1 w-full mb-6">
|
||||
<div className="w-full">
|
||||
<Text className="txt-medium-plus text-ui-fg-base mb-1">
|
||||
By clicking the Place Order button, you confirm that you have
|
||||
read, understand and accept our Terms of Use, Terms of Sale and
|
||||
Returns Policy and acknowledge that you have read Medusa
|
||||
Store's Privacy Policy.
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
<PaymentButton cart={cart} data-testid="submit-order-button" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Review
|
||||
@@ -0,0 +1,219 @@
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
import { Container } from "@medusajs/ui"
|
||||
import Checkbox from "@modules/common/components/checkbox"
|
||||
import Input from "@modules/common/components/input"
|
||||
import { mapKeys } from "lodash"
|
||||
import React, { useEffect, useMemo, useState } from "react"
|
||||
import AddressSelect from "../address-select"
|
||||
import CountrySelect from "../country-select"
|
||||
|
||||
const ShippingAddress = ({
|
||||
customer,
|
||||
cart,
|
||||
checked,
|
||||
onChange,
|
||||
}: {
|
||||
customer: HttpTypes.StoreCustomer | null
|
||||
cart: HttpTypes.StoreCart | null
|
||||
checked: boolean
|
||||
onChange: () => void
|
||||
}) => {
|
||||
const [formData, setFormData] = useState<Record<string, any>>({
|
||||
"shipping_address.first_name": cart?.shipping_address?.first_name || "",
|
||||
"shipping_address.last_name": cart?.shipping_address?.last_name || "",
|
||||
"shipping_address.address_1": cart?.shipping_address?.address_1 || "",
|
||||
"shipping_address.company": cart?.shipping_address?.company || "",
|
||||
"shipping_address.postal_code": cart?.shipping_address?.postal_code || "",
|
||||
"shipping_address.city": cart?.shipping_address?.city || "",
|
||||
"shipping_address.country_code": cart?.shipping_address?.country_code || "",
|
||||
"shipping_address.province": cart?.shipping_address?.province || "",
|
||||
"shipping_address.phone": cart?.shipping_address?.phone || "",
|
||||
email: cart?.email || "",
|
||||
})
|
||||
|
||||
const countriesInRegion = useMemo(
|
||||
() => cart?.region?.countries?.map((c) => c.iso_2),
|
||||
[cart?.region]
|
||||
)
|
||||
|
||||
// check if customer has saved addresses that are in the current region
|
||||
const addressesInRegion = useMemo(
|
||||
() =>
|
||||
customer?.addresses.filter(
|
||||
(a) => a.country_code && countriesInRegion?.includes(a.country_code)
|
||||
),
|
||||
[customer?.addresses, countriesInRegion]
|
||||
)
|
||||
|
||||
const setFormAddress = (
|
||||
address?: HttpTypes.StoreCartAddress,
|
||||
email?: string
|
||||
) => {
|
||||
address &&
|
||||
setFormData((prevState: Record<string, any>) => ({
|
||||
...prevState,
|
||||
"shipping_address.first_name": address?.first_name || "",
|
||||
"shipping_address.last_name": address?.last_name || "",
|
||||
"shipping_address.address_1": address?.address_1 || "",
|
||||
"shipping_address.company": address?.company || "",
|
||||
"shipping_address.postal_code": address?.postal_code || "",
|
||||
"shipping_address.city": address?.city || "",
|
||||
"shipping_address.country_code": address?.country_code || "",
|
||||
"shipping_address.province": address?.province || "",
|
||||
"shipping_address.phone": address?.phone || "",
|
||||
}))
|
||||
|
||||
email &&
|
||||
setFormData((prevState: Record<string, any>) => ({
|
||||
...prevState,
|
||||
email: email,
|
||||
}))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// Ensure cart is not null and has a shipping_address before setting form data
|
||||
if (cart && cart.shipping_address) {
|
||||
setFormAddress(cart?.shipping_address, cart?.email)
|
||||
}
|
||||
|
||||
if (cart && !cart.email && customer?.email) {
|
||||
setFormAddress(undefined, customer.email)
|
||||
}
|
||||
}, [cart]) // Add cart as a dependency
|
||||
|
||||
const handleChange = (
|
||||
e: React.ChangeEvent<
|
||||
HTMLInputElement | HTMLInputElement | HTMLSelectElement
|
||||
>
|
||||
) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
[e.target.name]: e.target.value,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{customer && (addressesInRegion?.length || 0) > 0 && (
|
||||
<Container className="mb-6 flex flex-col gap-y-4 p-5">
|
||||
<p className="text-small-regular">
|
||||
{`Hi ${customer.first_name}, do you want to use one of your saved addresses?`}
|
||||
</p>
|
||||
<AddressSelect
|
||||
addresses={customer.addresses}
|
||||
addressInput={
|
||||
mapKeys(formData, (_, key) =>
|
||||
key.replace("shipping_address.", "")
|
||||
) as HttpTypes.StoreCartAddress
|
||||
}
|
||||
onSelect={setFormAddress}
|
||||
/>
|
||||
</Container>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="First name"
|
||||
name="shipping_address.first_name"
|
||||
autoComplete="given-name"
|
||||
value={formData["shipping_address.first_name"]}
|
||||
onChange={handleChange}
|
||||
required
|
||||
data-testid="shipping-first-name-input"
|
||||
/>
|
||||
<Input
|
||||
label="Last name"
|
||||
name="shipping_address.last_name"
|
||||
autoComplete="family-name"
|
||||
value={formData["shipping_address.last_name"]}
|
||||
onChange={handleChange}
|
||||
required
|
||||
data-testid="shipping-last-name-input"
|
||||
/>
|
||||
<Input
|
||||
label="Address"
|
||||
name="shipping_address.address_1"
|
||||
autoComplete="address-line1"
|
||||
value={formData["shipping_address.address_1"]}
|
||||
onChange={handleChange}
|
||||
required
|
||||
data-testid="shipping-address-input"
|
||||
/>
|
||||
<Input
|
||||
label="Company"
|
||||
name="shipping_address.company"
|
||||
value={formData["shipping_address.company"]}
|
||||
onChange={handleChange}
|
||||
autoComplete="organization"
|
||||
data-testid="shipping-company-input"
|
||||
/>
|
||||
<Input
|
||||
label="Postal code"
|
||||
name="shipping_address.postal_code"
|
||||
autoComplete="postal-code"
|
||||
value={formData["shipping_address.postal_code"]}
|
||||
onChange={handleChange}
|
||||
required
|
||||
data-testid="shipping-postal-code-input"
|
||||
/>
|
||||
<Input
|
||||
label="City"
|
||||
name="shipping_address.city"
|
||||
autoComplete="address-level2"
|
||||
value={formData["shipping_address.city"]}
|
||||
onChange={handleChange}
|
||||
required
|
||||
data-testid="shipping-city-input"
|
||||
/>
|
||||
<CountrySelect
|
||||
name="shipping_address.country_code"
|
||||
autoComplete="country"
|
||||
region={cart?.region}
|
||||
value={formData["shipping_address.country_code"]}
|
||||
onChange={handleChange}
|
||||
required
|
||||
data-testid="shipping-country-select"
|
||||
/>
|
||||
<Input
|
||||
label="State / Province"
|
||||
name="shipping_address.province"
|
||||
autoComplete="address-level1"
|
||||
value={formData["shipping_address.province"]}
|
||||
onChange={handleChange}
|
||||
data-testid="shipping-province-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="my-8">
|
||||
<Checkbox
|
||||
label="Billing address same as shipping address"
|
||||
name="same_as_billing"
|
||||
checked={checked}
|
||||
onChange={onChange}
|
||||
data-testid="billing-address-checkbox"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||
<Input
|
||||
label="Email"
|
||||
name="email"
|
||||
type="email"
|
||||
title="Enter a valid email address."
|
||||
autoComplete="email"
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
required
|
||||
data-testid="shipping-email-input"
|
||||
/>
|
||||
<Input
|
||||
label="Phone"
|
||||
name="shipping_address.phone"
|
||||
autoComplete="tel"
|
||||
value={formData["shipping_address.phone"]}
|
||||
onChange={handleChange}
|
||||
data-testid="shipping-phone-input"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default ShippingAddress
|
||||
@@ -0,0 +1,408 @@
|
||||
"use client"
|
||||
|
||||
import { Radio, RadioGroup } from "@headlessui/react"
|
||||
import { setShippingMethod } from "@lib/data/cart"
|
||||
import { calculatePriceForShippingOption } from "@lib/data/fulfillment"
|
||||
import { convertToLocale } from "@lib/util/money"
|
||||
import { CheckCircleSolid, Loader } from "@medusajs/icons"
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
import { Button, clx, Heading, Text } from "@medusajs/ui"
|
||||
import ErrorMessage from "@modules/checkout/components/error-message"
|
||||
import Divider from "@modules/common/components/divider"
|
||||
import MedusaRadio from "@modules/common/components/radio"
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation"
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
const PICKUP_OPTION_ON = "__PICKUP_ON"
|
||||
const PICKUP_OPTION_OFF = "__PICKUP_OFF"
|
||||
|
||||
type ShippingProps = {
|
||||
cart: HttpTypes.StoreCart
|
||||
availableShippingMethods: HttpTypes.StoreCartShippingOption[] | null
|
||||
}
|
||||
|
||||
function formatAddress(address: HttpTypes.StoreCartAddress) {
|
||||
if (!address) {
|
||||
return ""
|
||||
}
|
||||
|
||||
let ret = ""
|
||||
|
||||
if (address.address_1) {
|
||||
ret += ` ${address.address_1}`
|
||||
}
|
||||
|
||||
if (address.address_2) {
|
||||
ret += `, ${address.address_2}`
|
||||
}
|
||||
|
||||
if (address.postal_code) {
|
||||
ret += `, ${address.postal_code} ${address.city}`
|
||||
}
|
||||
|
||||
if (address.country_code) {
|
||||
ret += `, ${address.country_code.toUpperCase()}`
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
const Shipping: React.FC<ShippingProps> = ({
|
||||
cart,
|
||||
availableShippingMethods,
|
||||
}) => {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isLoadingPrices, setIsLoadingPrices] = useState(true)
|
||||
|
||||
const [showPickupOptions, setShowPickupOptions] =
|
||||
useState<string>(PICKUP_OPTION_OFF)
|
||||
const [calculatedPricesMap, setCalculatedPricesMap] = useState<
|
||||
Record<string, number>
|
||||
>({})
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [shippingMethodId, setShippingMethodId] = useState<string | null>(
|
||||
cart.shipping_methods?.at(-1)?.shipping_option_id || null
|
||||
)
|
||||
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
|
||||
const isOpen = searchParams.get("step") === "delivery"
|
||||
|
||||
const _shippingMethods = availableShippingMethods?.filter(
|
||||
(sm) => sm.service_zone?.fulfillment_set?.type !== "pickup"
|
||||
)
|
||||
|
||||
const _pickupMethods = availableShippingMethods?.filter(
|
||||
(sm) => sm.service_zone?.fulfillment_set?.type === "pickup"
|
||||
)
|
||||
|
||||
const hasPickupOptions = !!_pickupMethods?.length
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoadingPrices(true)
|
||||
|
||||
if (_shippingMethods?.length) {
|
||||
const promises = _shippingMethods
|
||||
.filter((sm) => sm.price_type === "calculated")
|
||||
.map((sm) => calculatePriceForShippingOption(sm.id, cart.id))
|
||||
|
||||
if (promises.length) {
|
||||
Promise.allSettled(promises).then((res) => {
|
||||
const pricesMap: Record<string, number> = {}
|
||||
res
|
||||
.filter((r) => r.status === "fulfilled")
|
||||
.forEach((p) => (pricesMap[p.value?.id || ""] = p.value?.amount!))
|
||||
|
||||
setCalculatedPricesMap(pricesMap)
|
||||
setIsLoadingPrices(false)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (_pickupMethods?.find((m) => m.id === shippingMethodId)) {
|
||||
setShowPickupOptions(PICKUP_OPTION_ON)
|
||||
}
|
||||
}, [availableShippingMethods])
|
||||
|
||||
const handleEdit = () => {
|
||||
router.push(pathname + "?step=delivery", { scroll: false })
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
router.push(pathname + "?step=payment", { scroll: false })
|
||||
}
|
||||
|
||||
const handleSetShippingMethod = async (
|
||||
id: string,
|
||||
variant: "shipping" | "pickup"
|
||||
) => {
|
||||
setError(null)
|
||||
|
||||
if (variant === "pickup") {
|
||||
setShowPickupOptions(PICKUP_OPTION_ON)
|
||||
} else {
|
||||
setShowPickupOptions(PICKUP_OPTION_OFF)
|
||||
}
|
||||
|
||||
let currentId: string | null = null
|
||||
setIsLoading(true)
|
||||
setShippingMethodId((prev) => {
|
||||
currentId = prev
|
||||
return id
|
||||
})
|
||||
|
||||
await setShippingMethod({ cartId: cart.id, shippingMethodId: id })
|
||||
.catch((err) => {
|
||||
setShippingMethodId(currentId)
|
||||
|
||||
setError(err.message)
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false)
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setError(null)
|
||||
}, [isOpen])
|
||||
|
||||
return (
|
||||
<div className="bg-white">
|
||||
<div className="flex flex-row items-center justify-between mb-6">
|
||||
<Heading
|
||||
level="h2"
|
||||
className={clx(
|
||||
"flex flex-row text-3xl-regular gap-x-2 items-baseline",
|
||||
{
|
||||
"opacity-50 pointer-events-none select-none":
|
||||
!isOpen && cart.shipping_methods?.length === 0,
|
||||
}
|
||||
)}
|
||||
>
|
||||
Delivery
|
||||
{!isOpen && (cart.shipping_methods?.length ?? 0) > 0 && (
|
||||
<CheckCircleSolid />
|
||||
)}
|
||||
</Heading>
|
||||
{!isOpen &&
|
||||
cart?.shipping_address &&
|
||||
cart?.billing_address &&
|
||||
cart?.email && (
|
||||
<Text>
|
||||
<button
|
||||
onClick={handleEdit}
|
||||
className="text-ui-fg-interactive hover:text-ui-fg-interactive-hover"
|
||||
data-testid="edit-delivery-button"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
{isOpen ? (
|
||||
<>
|
||||
<div className="grid">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium txt-medium text-ui-fg-base">
|
||||
Shipping method
|
||||
</span>
|
||||
<span className="mb-4 text-ui-fg-muted txt-medium">
|
||||
How would you like you order delivered
|
||||
</span>
|
||||
</div>
|
||||
<div data-testid="delivery-options-container">
|
||||
<div className="pb-8 md:pt-0 pt-2">
|
||||
{hasPickupOptions && (
|
||||
<RadioGroup
|
||||
value={showPickupOptions}
|
||||
onChange={(value) => {
|
||||
const id = _pickupMethods.find(
|
||||
(option) => !option.insufficient_inventory
|
||||
)?.id
|
||||
|
||||
if (id) {
|
||||
handleSetShippingMethod(id, "pickup")
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Radio
|
||||
value={PICKUP_OPTION_ON}
|
||||
data-testid="delivery-option-radio"
|
||||
className={clx(
|
||||
"flex items-center justify-between text-small-regular cursor-pointer py-4 border rounded-rounded px-8 mb-2 hover:shadow-borders-interactive-with-active",
|
||||
{
|
||||
"border-ui-border-interactive":
|
||||
showPickupOptions === PICKUP_OPTION_ON,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-x-4">
|
||||
<MedusaRadio
|
||||
checked={showPickupOptions === PICKUP_OPTION_ON}
|
||||
/>
|
||||
<span className="text-base-regular">
|
||||
Pick up your order
|
||||
</span>
|
||||
</div>
|
||||
<span className="justify-self-end text-ui-fg-base">
|
||||
-
|
||||
</span>
|
||||
</Radio>
|
||||
</RadioGroup>
|
||||
)}
|
||||
<RadioGroup
|
||||
value={shippingMethodId}
|
||||
onChange={(v) => {
|
||||
if (v) {
|
||||
return handleSetShippingMethod(v, "shipping")
|
||||
}
|
||||
}}
|
||||
>
|
||||
{_shippingMethods?.map((option) => {
|
||||
const isDisabled =
|
||||
option.price_type === "calculated" &&
|
||||
!isLoadingPrices &&
|
||||
typeof calculatedPricesMap[option.id] !== "number"
|
||||
|
||||
return (
|
||||
<Radio
|
||||
key={option.id}
|
||||
value={option.id}
|
||||
data-testid="delivery-option-radio"
|
||||
disabled={isDisabled}
|
||||
className={clx(
|
||||
"flex items-center justify-between text-small-regular cursor-pointer py-4 border rounded-rounded px-8 mb-2 hover:shadow-borders-interactive-with-active",
|
||||
{
|
||||
"border-ui-border-interactive":
|
||||
option.id === shippingMethodId,
|
||||
"hover:shadow-brders-none cursor-not-allowed":
|
||||
isDisabled,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-x-4">
|
||||
<MedusaRadio
|
||||
checked={option.id === shippingMethodId}
|
||||
/>
|
||||
<span className="text-base-regular">
|
||||
{option.name}
|
||||
</span>
|
||||
</div>
|
||||
<span className="justify-self-end text-ui-fg-base">
|
||||
{option.price_type === "flat" ? (
|
||||
convertToLocale({
|
||||
amount: option.amount!,
|
||||
currency_code: cart?.currency_code,
|
||||
})
|
||||
) : calculatedPricesMap[option.id] ? (
|
||||
convertToLocale({
|
||||
amount: calculatedPricesMap[option.id],
|
||||
currency_code: cart?.currency_code,
|
||||
})
|
||||
) : isLoadingPrices ? (
|
||||
<Loader />
|
||||
) : (
|
||||
"-"
|
||||
)}
|
||||
</span>
|
||||
</Radio>
|
||||
)
|
||||
})}
|
||||
</RadioGroup>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showPickupOptions === PICKUP_OPTION_ON && (
|
||||
<div className="grid">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium txt-medium text-ui-fg-base">
|
||||
Store
|
||||
</span>
|
||||
<span className="mb-4 text-ui-fg-muted txt-medium">
|
||||
Choose a store near you
|
||||
</span>
|
||||
</div>
|
||||
<div data-testid="delivery-options-container">
|
||||
<div className="pb-8 md:pt-0 pt-2">
|
||||
<RadioGroup
|
||||
value={shippingMethodId}
|
||||
onChange={(v) => {
|
||||
if (v) {
|
||||
return handleSetShippingMethod(v, "pickup")
|
||||
}
|
||||
}}
|
||||
>
|
||||
{_pickupMethods?.map((option) => {
|
||||
return (
|
||||
<Radio
|
||||
key={option.id}
|
||||
value={option.id}
|
||||
disabled={option.insufficient_inventory}
|
||||
data-testid="delivery-option-radio"
|
||||
className={clx(
|
||||
"flex items-center justify-between text-small-regular cursor-pointer py-4 border rounded-rounded px-8 mb-2 hover:shadow-borders-interactive-with-active",
|
||||
{
|
||||
"border-ui-border-interactive":
|
||||
option.id === shippingMethodId,
|
||||
"hover:shadow-brders-none cursor-not-allowed":
|
||||
option.insufficient_inventory,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-x-4">
|
||||
<MedusaRadio
|
||||
checked={option.id === shippingMethodId}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-base-regular">
|
||||
{option.name}
|
||||
</span>
|
||||
<span className="text-base-regular text-ui-fg-muted">
|
||||
{formatAddress(
|
||||
option.service_zone?.fulfillment_set?.location
|
||||
?.address
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="justify-self-end text-ui-fg-base">
|
||||
{convertToLocale({
|
||||
amount: option.amount!,
|
||||
currency_code: cart?.currency_code,
|
||||
})}
|
||||
</span>
|
||||
</Radio>
|
||||
)
|
||||
})}
|
||||
</RadioGroup>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<ErrorMessage
|
||||
error={error}
|
||||
data-testid="delivery-option-error-message"
|
||||
/>
|
||||
<Button
|
||||
size="large"
|
||||
className="mt"
|
||||
onClick={handleSubmit}
|
||||
isLoading={isLoading}
|
||||
disabled={!cart.shipping_methods?.[0]}
|
||||
data-testid="submit-delivery-option-button"
|
||||
>
|
||||
Continue to payment
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div>
|
||||
<div className="text-small-regular">
|
||||
{cart && (cart.shipping_methods?.length ?? 0) > 0 && (
|
||||
<div className="flex flex-col w-1/3">
|
||||
<Text className="txt-medium-plus text-ui-fg-base mb-1">
|
||||
Method
|
||||
</Text>
|
||||
<Text className="txt-medium text-ui-fg-subtle">
|
||||
{cart.shipping_methods!.at(-1)!.name}{" "}
|
||||
{convertToLocale({
|
||||
amount: cart.shipping_methods!.at(-1)!.amount!,
|
||||
currency_code: cart?.currency_code,
|
||||
})}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Divider className="mt-8" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Shipping
|
||||
@@ -0,0 +1,32 @@
|
||||
"use client"
|
||||
|
||||
import { Button } from "@medusajs/ui"
|
||||
import React from "react"
|
||||
import { useFormStatus } from "react-dom"
|
||||
|
||||
export function SubmitButton({
|
||||
children,
|
||||
variant = "primary",
|
||||
className,
|
||||
"data-testid": dataTestId,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
variant?: "primary" | "secondary" | "transparent" | "danger" | null
|
||||
className?: string
|
||||
"data-testid"?: string
|
||||
}) {
|
||||
const { pending } = useFormStatus()
|
||||
|
||||
return (
|
||||
<Button
|
||||
size="large"
|
||||
className={className}
|
||||
type="submit"
|
||||
isLoading={pending}
|
||||
variant={variant || "primary"}
|
||||
data-testid={dataTestId}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { listCartShippingMethods } from "@lib/data/fulfillment"
|
||||
import { listCartPaymentMethods } from "@lib/data/payment"
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
import Addresses from "@modules/checkout/components/addresses"
|
||||
import Payment from "@modules/checkout/components/payment"
|
||||
import Review from "@modules/checkout/components/review"
|
||||
import Shipping from "@modules/checkout/components/shipping"
|
||||
|
||||
export default async function CheckoutForm({
|
||||
cart,
|
||||
customer,
|
||||
}: {
|
||||
cart: HttpTypes.StoreCart | null
|
||||
customer: HttpTypes.StoreCustomer | null
|
||||
}) {
|
||||
if (!cart) {
|
||||
return null
|
||||
}
|
||||
|
||||
const shippingMethods = await listCartShippingMethods(cart.id)
|
||||
const paymentMethods = await listCartPaymentMethods(cart.region?.id ?? "")
|
||||
|
||||
if (!shippingMethods || !paymentMethods) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full grid grid-cols-1 gap-y-8">
|
||||
<Addresses cart={cart} customer={customer} />
|
||||
|
||||
<Shipping cart={cart} availableShippingMethods={shippingMethods} />
|
||||
|
||||
<Payment cart={cart} availablePaymentMethods={paymentMethods} />
|
||||
|
||||
<Review cart={cart} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Heading } from "@medusajs/ui"
|
||||
|
||||
import ItemsPreviewTemplate from "@modules/cart/templates/preview"
|
||||
import DiscountCode from "@modules/checkout/components/discount-code"
|
||||
import CartTotals from "@modules/common/components/cart-totals"
|
||||
import Divider from "@modules/common/components/divider"
|
||||
|
||||
const CheckoutSummary = ({ cart }: { cart: any }) => {
|
||||
return (
|
||||
<div className="sticky top-0 flex flex-col-reverse small:flex-col gap-y-8 py-8 small:py-0 ">
|
||||
<div className="w-full bg-white flex flex-col">
|
||||
<Divider className="my-6 small:hidden" />
|
||||
<Heading
|
||||
level="h2"
|
||||
className="flex flex-row text-3xl-regular items-baseline"
|
||||
>
|
||||
In your Cart
|
||||
</Heading>
|
||||
<Divider className="my-6" />
|
||||
<CartTotals totals={cart} />
|
||||
<ItemsPreviewTemplate cart={cart} />
|
||||
<div className="my-6">
|
||||
<DiscountCode cart={cart} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CheckoutSummary
|
||||
Reference in New Issue
Block a user