init starter package

This commit is contained in:
2025-10-16 17:29:57 +02:00
commit b7c67b5834
216 changed files with 37028 additions and 0 deletions
@@ -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&apos;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