move selfservice tables to medreport schema
add base medusa store frontend
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
import { Disclosure } from "@headlessui/react"
|
||||
import { Badge, Button, clx } from "@medusajs/ui"
|
||||
import { useEffect } from "react"
|
||||
|
||||
import useToggleState from "@lib/hooks/use-toggle-state"
|
||||
import { useFormStatus } from "react-dom"
|
||||
|
||||
type AccountInfoProps = {
|
||||
label: string
|
||||
currentInfo: string | React.ReactNode
|
||||
isSuccess?: boolean
|
||||
isError?: boolean
|
||||
errorMessage?: string
|
||||
clearState: () => void
|
||||
children?: React.ReactNode
|
||||
'data-testid'?: string
|
||||
}
|
||||
|
||||
const AccountInfo = ({
|
||||
label,
|
||||
currentInfo,
|
||||
isSuccess,
|
||||
isError,
|
||||
clearState,
|
||||
errorMessage = "An error occurred, please try again",
|
||||
children,
|
||||
'data-testid': dataTestid
|
||||
}: AccountInfoProps) => {
|
||||
const { state, close, toggle } = useToggleState()
|
||||
|
||||
const { pending } = useFormStatus()
|
||||
|
||||
const handleToggle = () => {
|
||||
clearState()
|
||||
setTimeout(() => toggle(), 100)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (isSuccess) {
|
||||
close()
|
||||
}
|
||||
}, [isSuccess, close])
|
||||
|
||||
return (
|
||||
<div className="text-small-regular" data-testid={dataTestid}>
|
||||
<div className="flex items-end justify-between">
|
||||
<div className="flex flex-col">
|
||||
<span className="uppercase text-ui-fg-base">{label}</span>
|
||||
<div className="flex items-center flex-1 basis-0 justify-end gap-x-4">
|
||||
{typeof currentInfo === "string" ? (
|
||||
<span className="font-semibold" data-testid="current-info">{currentInfo}</span>
|
||||
) : (
|
||||
currentInfo
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="w-[100px] min-h-[25px] py-1"
|
||||
onClick={handleToggle}
|
||||
type={state ? "reset" : "button"}
|
||||
data-testid="edit-button"
|
||||
data-active={state}
|
||||
>
|
||||
{state ? "Cancel" : "Edit"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Success state */}
|
||||
<Disclosure>
|
||||
<Disclosure.Panel
|
||||
static
|
||||
className={clx(
|
||||
"transition-[max-height,opacity] duration-300 ease-in-out overflow-hidden",
|
||||
{
|
||||
"max-h-[1000px] opacity-100": isSuccess,
|
||||
"max-h-0 opacity-0": !isSuccess,
|
||||
}
|
||||
)}
|
||||
data-testid="success-message"
|
||||
>
|
||||
<Badge className="p-2 my-4" color="green">
|
||||
<span>{label} updated succesfully</span>
|
||||
</Badge>
|
||||
</Disclosure.Panel>
|
||||
</Disclosure>
|
||||
|
||||
{/* Error state */}
|
||||
<Disclosure>
|
||||
<Disclosure.Panel
|
||||
static
|
||||
className={clx(
|
||||
"transition-[max-height,opacity] duration-300 ease-in-out overflow-hidden",
|
||||
{
|
||||
"max-h-[1000px] opacity-100": isError,
|
||||
"max-h-0 opacity-0": !isError,
|
||||
}
|
||||
)}
|
||||
data-testid="error-message"
|
||||
>
|
||||
<Badge className="p-2 my-4" color="red">
|
||||
<span>{errorMessage}</span>
|
||||
</Badge>
|
||||
</Disclosure.Panel>
|
||||
</Disclosure>
|
||||
|
||||
<Disclosure>
|
||||
<Disclosure.Panel
|
||||
static
|
||||
className={clx(
|
||||
"transition-[max-height,opacity] duration-300 ease-in-out overflow-visible",
|
||||
{
|
||||
"max-h-[1000px] opacity-100": state,
|
||||
"max-h-0 opacity-0": !state,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-y-2 py-4">
|
||||
<div>{children}</div>
|
||||
<div className="flex items-center justify-end mt-2">
|
||||
<Button
|
||||
isLoading={pending}
|
||||
className="w-full small:max-w-[140px]"
|
||||
type="submit"
|
||||
data-testid="save-button"
|
||||
>
|
||||
Save changes
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Disclosure.Panel>
|
||||
</Disclosure>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AccountInfo
|
||||
@@ -0,0 +1,199 @@
|
||||
"use client"
|
||||
|
||||
import { clx } from "@medusajs/ui"
|
||||
import { ArrowRightOnRectangle } from "@medusajs/icons"
|
||||
import { useParams, usePathname } from "next/navigation"
|
||||
|
||||
import ChevronDown from "@modules/common/icons/chevron-down"
|
||||
import User from "@modules/common/icons/user"
|
||||
import MapPin from "@modules/common/icons/map-pin"
|
||||
import Package from "@modules/common/icons/package"
|
||||
import LocalizedClientLink from "@modules/common/components/localized-client-link"
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
import { signout } from "@lib/data/customer"
|
||||
|
||||
const AccountNav = ({
|
||||
customer,
|
||||
}: {
|
||||
customer: HttpTypes.StoreCustomer | null
|
||||
}) => {
|
||||
const route = usePathname()
|
||||
const { countryCode } = useParams() as { countryCode: string }
|
||||
|
||||
const handleLogout = async () => {
|
||||
await signout(countryCode)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="small:hidden" data-testid="mobile-account-nav">
|
||||
{route !== `/${countryCode}/account` ? (
|
||||
<LocalizedClientLink
|
||||
href="/account"
|
||||
className="flex items-center gap-x-2 text-small-regular py-2"
|
||||
data-testid="account-main-link"
|
||||
>
|
||||
<>
|
||||
<ChevronDown className="transform rotate-90" />
|
||||
<span>Account</span>
|
||||
</>
|
||||
</LocalizedClientLink>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-xl-semi mb-4 px-8">
|
||||
Hello {customer?.first_name}
|
||||
</div>
|
||||
<div className="text-base-regular">
|
||||
<ul>
|
||||
<li>
|
||||
<LocalizedClientLink
|
||||
href="/account/profile"
|
||||
className="flex items-center justify-between py-4 border-b border-gray-200 px-8"
|
||||
data-testid="profile-link"
|
||||
>
|
||||
<>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<User size={20} />
|
||||
<span>Profile</span>
|
||||
</div>
|
||||
<ChevronDown className="transform -rotate-90" />
|
||||
</>
|
||||
</LocalizedClientLink>
|
||||
</li>
|
||||
<li>
|
||||
<LocalizedClientLink
|
||||
href="/account/addresses"
|
||||
className="flex items-center justify-between py-4 border-b border-gray-200 px-8"
|
||||
data-testid="addresses-link"
|
||||
>
|
||||
<>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<MapPin size={20} />
|
||||
<span>Addresses</span>
|
||||
</div>
|
||||
<ChevronDown className="transform -rotate-90" />
|
||||
</>
|
||||
</LocalizedClientLink>
|
||||
</li>
|
||||
<li>
|
||||
<LocalizedClientLink
|
||||
href="/account/orders"
|
||||
className="flex items-center justify-between py-4 border-b border-gray-200 px-8"
|
||||
data-testid="orders-link"
|
||||
>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Package size={20} />
|
||||
<span>Orders</span>
|
||||
</div>
|
||||
<ChevronDown className="transform -rotate-90" />
|
||||
</LocalizedClientLink>
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center justify-between py-4 border-b border-gray-200 px-8 w-full"
|
||||
onClick={handleLogout}
|
||||
data-testid="logout-button"
|
||||
>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<ArrowRightOnRectangle />
|
||||
<span>Log out</span>
|
||||
</div>
|
||||
<ChevronDown className="transform -rotate-90" />
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="hidden small:block" data-testid="account-nav">
|
||||
<div>
|
||||
<div className="pb-4">
|
||||
<h3 className="text-base-semi">Account</h3>
|
||||
</div>
|
||||
<div className="text-base-regular">
|
||||
<ul className="flex mb-0 justify-start items-start flex-col gap-y-4">
|
||||
<li>
|
||||
<AccountNavLink
|
||||
href="/account"
|
||||
route={route!}
|
||||
data-testid="overview-link"
|
||||
>
|
||||
Overview
|
||||
</AccountNavLink>
|
||||
</li>
|
||||
<li>
|
||||
<AccountNavLink
|
||||
href="/account/profile"
|
||||
route={route!}
|
||||
data-testid="profile-link"
|
||||
>
|
||||
Profile
|
||||
</AccountNavLink>
|
||||
</li>
|
||||
<li>
|
||||
<AccountNavLink
|
||||
href="/account/addresses"
|
||||
route={route!}
|
||||
data-testid="addresses-link"
|
||||
>
|
||||
Addresses
|
||||
</AccountNavLink>
|
||||
</li>
|
||||
<li>
|
||||
<AccountNavLink
|
||||
href="/account/orders"
|
||||
route={route!}
|
||||
data-testid="orders-link"
|
||||
>
|
||||
Orders
|
||||
</AccountNavLink>
|
||||
</li>
|
||||
<li className="text-grey-700">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLogout}
|
||||
data-testid="logout-button"
|
||||
>
|
||||
Log out
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type AccountNavLinkProps = {
|
||||
href: string
|
||||
route: string
|
||||
children: React.ReactNode
|
||||
"data-testid"?: string
|
||||
}
|
||||
|
||||
const AccountNavLink = ({
|
||||
href,
|
||||
route,
|
||||
children,
|
||||
"data-testid": dataTestId,
|
||||
}: AccountNavLinkProps) => {
|
||||
const { countryCode }: { countryCode: string } = useParams()
|
||||
|
||||
const active = route.split(countryCode)[1] === href
|
||||
return (
|
||||
<LocalizedClientLink
|
||||
href={href}
|
||||
className={clx("text-ui-fg-subtle hover:text-ui-fg-base", {
|
||||
"text-ui-fg-base font-semibold": active,
|
||||
})}
|
||||
data-testid={dataTestId}
|
||||
>
|
||||
{children}
|
||||
</LocalizedClientLink>
|
||||
)
|
||||
}
|
||||
|
||||
export default AccountNav
|
||||
@@ -0,0 +1,28 @@
|
||||
import React from "react"
|
||||
|
||||
import AddAddress from "../address-card/add-address"
|
||||
import EditAddress from "../address-card/edit-address-modal"
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
|
||||
type AddressBookProps = {
|
||||
customer: HttpTypes.StoreCustomer
|
||||
region: HttpTypes.StoreRegion
|
||||
}
|
||||
|
||||
const AddressBook: React.FC<AddressBookProps> = ({ customer, region }) => {
|
||||
const { addresses } = customer
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 flex-1 mt-4">
|
||||
<AddAddress region={region} addresses={addresses} />
|
||||
{addresses.map((address) => {
|
||||
return (
|
||||
<EditAddress region={region} address={address} key={address.id} />
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AddressBook
|
||||
@@ -0,0 +1,167 @@
|
||||
"use client"
|
||||
|
||||
import { Plus } from "@medusajs/icons"
|
||||
import { Button, Heading } from "@medusajs/ui"
|
||||
import { useEffect, useState, useActionState } from "react"
|
||||
|
||||
import useToggleState from "@lib/hooks/use-toggle-state"
|
||||
import CountrySelect from "@modules/checkout/components/country-select"
|
||||
import Input from "@modules/common/components/input"
|
||||
import Modal from "@modules/common/components/modal"
|
||||
import { SubmitButton } from "@modules/checkout/components/submit-button"
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
import { addCustomerAddress } from "@lib/data/customer"
|
||||
|
||||
const AddAddress = ({
|
||||
region,
|
||||
addresses,
|
||||
}: {
|
||||
region: HttpTypes.StoreRegion
|
||||
addresses: HttpTypes.StoreCustomerAddress[]
|
||||
}) => {
|
||||
const [successState, setSuccessState] = useState(false)
|
||||
const { state, open, close: closeModal } = useToggleState(false)
|
||||
|
||||
const [formState, formAction] = useActionState(addCustomerAddress, {
|
||||
isDefaultShipping: addresses.length === 0,
|
||||
success: false,
|
||||
error: null,
|
||||
})
|
||||
|
||||
const close = () => {
|
||||
setSuccessState(false)
|
||||
closeModal()
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (successState) {
|
||||
close()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [successState])
|
||||
|
||||
useEffect(() => {
|
||||
if (formState.success) {
|
||||
setSuccessState(true)
|
||||
}
|
||||
}, [formState])
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className="border border-ui-border-base rounded-rounded p-5 min-h-[220px] h-full w-full flex flex-col justify-between"
|
||||
onClick={open}
|
||||
data-testid="add-address-button"
|
||||
>
|
||||
<span className="text-base-semi">New address</span>
|
||||
<Plus />
|
||||
</button>
|
||||
|
||||
<Modal isOpen={state} close={close} data-testid="add-address-modal">
|
||||
<Modal.Title>
|
||||
<Heading className="mb-2">Add address</Heading>
|
||||
</Modal.Title>
|
||||
<form action={formAction}>
|
||||
<Modal.Body>
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<div className="grid grid-cols-2 gap-x-2">
|
||||
<Input
|
||||
label="First name"
|
||||
name="first_name"
|
||||
required
|
||||
autoComplete="given-name"
|
||||
data-testid="first-name-input"
|
||||
/>
|
||||
<Input
|
||||
label="Last name"
|
||||
name="last_name"
|
||||
required
|
||||
autoComplete="family-name"
|
||||
data-testid="last-name-input"
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
label="Company"
|
||||
name="company"
|
||||
autoComplete="organization"
|
||||
data-testid="company-input"
|
||||
/>
|
||||
<Input
|
||||
label="Address"
|
||||
name="address_1"
|
||||
required
|
||||
autoComplete="address-line1"
|
||||
data-testid="address-1-input"
|
||||
/>
|
||||
<Input
|
||||
label="Apartment, suite, etc."
|
||||
name="address_2"
|
||||
autoComplete="address-line2"
|
||||
data-testid="address-2-input"
|
||||
/>
|
||||
<div className="grid grid-cols-[144px_1fr] gap-x-2">
|
||||
<Input
|
||||
label="Postal code"
|
||||
name="postal_code"
|
||||
required
|
||||
autoComplete="postal-code"
|
||||
data-testid="postal-code-input"
|
||||
/>
|
||||
<Input
|
||||
label="City"
|
||||
name="city"
|
||||
required
|
||||
autoComplete="locality"
|
||||
data-testid="city-input"
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
label="Province / State"
|
||||
name="province"
|
||||
autoComplete="address-level1"
|
||||
data-testid="state-input"
|
||||
/>
|
||||
<CountrySelect
|
||||
region={region}
|
||||
name="country_code"
|
||||
required
|
||||
autoComplete="country"
|
||||
data-testid="country-select"
|
||||
/>
|
||||
<Input
|
||||
label="Phone"
|
||||
name="phone"
|
||||
autoComplete="phone"
|
||||
data-testid="phone-input"
|
||||
/>
|
||||
</div>
|
||||
{formState.error && (
|
||||
<div
|
||||
className="text-rose-500 text-small-regular py-2"
|
||||
data-testid="address-error"
|
||||
>
|
||||
{formState.error}
|
||||
</div>
|
||||
)}
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<div className="flex gap-3 mt-6">
|
||||
<Button
|
||||
type="reset"
|
||||
variant="secondary"
|
||||
onClick={close}
|
||||
className="h-10"
|
||||
data-testid="cancel-button"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<SubmitButton data-testid="save-button">Save</SubmitButton>
|
||||
</div>
|
||||
</Modal.Footer>
|
||||
</form>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default AddAddress
|
||||
@@ -0,0 +1,239 @@
|
||||
"use client"
|
||||
|
||||
import React, { useEffect, useState, useActionState } from "react"
|
||||
import { PencilSquare as Edit, Trash } from "@medusajs/icons"
|
||||
import { Button, Heading, Text, clx } from "@medusajs/ui"
|
||||
|
||||
import useToggleState from "@lib/hooks/use-toggle-state"
|
||||
import CountrySelect from "@modules/checkout/components/country-select"
|
||||
import Input from "@modules/common/components/input"
|
||||
import Modal from "@modules/common/components/modal"
|
||||
import Spinner from "@modules/common/icons/spinner"
|
||||
import { SubmitButton } from "@modules/checkout/components/submit-button"
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
import {
|
||||
deleteCustomerAddress,
|
||||
updateCustomerAddress,
|
||||
} from "@lib/data/customer"
|
||||
|
||||
type EditAddressProps = {
|
||||
region: HttpTypes.StoreRegion
|
||||
address: HttpTypes.StoreCustomerAddress
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
const EditAddress: React.FC<EditAddressProps> = ({
|
||||
region,
|
||||
address,
|
||||
isActive = false,
|
||||
}) => {
|
||||
const [removing, setRemoving] = useState(false)
|
||||
const [successState, setSuccessState] = useState(false)
|
||||
const { state, open, close: closeModal } = useToggleState(false)
|
||||
|
||||
const [formState, formAction] = useActionState(updateCustomerAddress, {
|
||||
success: false,
|
||||
error: null,
|
||||
addressId: address.id,
|
||||
})
|
||||
|
||||
const close = () => {
|
||||
setSuccessState(false)
|
||||
closeModal()
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (successState) {
|
||||
close()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [successState])
|
||||
|
||||
useEffect(() => {
|
||||
if (formState.success) {
|
||||
setSuccessState(true)
|
||||
}
|
||||
}, [formState])
|
||||
|
||||
const removeAddress = async () => {
|
||||
setRemoving(true)
|
||||
await deleteCustomerAddress(address.id)
|
||||
setRemoving(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={clx(
|
||||
"border rounded-rounded p-5 min-h-[220px] h-full w-full flex flex-col justify-between transition-colors",
|
||||
{
|
||||
"border-gray-900": isActive,
|
||||
}
|
||||
)}
|
||||
data-testid="address-container"
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<Heading
|
||||
className="text-left text-base-semi"
|
||||
data-testid="address-name"
|
||||
>
|
||||
{address.first_name} {address.last_name}
|
||||
</Heading>
|
||||
{address.company && (
|
||||
<Text
|
||||
className="txt-compact-small text-ui-fg-base"
|
||||
data-testid="address-company"
|
||||
>
|
||||
{address.company}
|
||||
</Text>
|
||||
)}
|
||||
<Text className="flex flex-col text-left text-base-regular mt-2">
|
||||
<span data-testid="address-address">
|
||||
{address.address_1}
|
||||
{address.address_2 && <span>, {address.address_2}</span>}
|
||||
</span>
|
||||
<span data-testid="address-postal-city">
|
||||
{address.postal_code}, {address.city}
|
||||
</span>
|
||||
<span data-testid="address-province-country">
|
||||
{address.province && `${address.province}, `}
|
||||
{address.country_code?.toUpperCase()}
|
||||
</span>
|
||||
</Text>
|
||||
</div>
|
||||
<div className="flex items-center gap-x-4">
|
||||
<button
|
||||
className="text-small-regular text-ui-fg-base flex items-center gap-x-2"
|
||||
onClick={open}
|
||||
data-testid="address-edit-button"
|
||||
>
|
||||
<Edit />
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
className="text-small-regular text-ui-fg-base flex items-center gap-x-2"
|
||||
onClick={removeAddress}
|
||||
data-testid="address-delete-button"
|
||||
>
|
||||
{removing ? <Spinner /> : <Trash />}
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal isOpen={state} close={close} data-testid="edit-address-modal">
|
||||
<Modal.Title>
|
||||
<Heading className="mb-2">Edit address</Heading>
|
||||
</Modal.Title>
|
||||
<form action={formAction}>
|
||||
<input type="hidden" name="addressId" value={address.id} />
|
||||
<Modal.Body>
|
||||
<div className="grid grid-cols-1 gap-y-2">
|
||||
<div className="grid grid-cols-2 gap-x-2">
|
||||
<Input
|
||||
label="First name"
|
||||
name="first_name"
|
||||
required
|
||||
autoComplete="given-name"
|
||||
defaultValue={address.first_name || undefined}
|
||||
data-testid="first-name-input"
|
||||
/>
|
||||
<Input
|
||||
label="Last name"
|
||||
name="last_name"
|
||||
required
|
||||
autoComplete="family-name"
|
||||
defaultValue={address.last_name || undefined}
|
||||
data-testid="last-name-input"
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
label="Company"
|
||||
name="company"
|
||||
autoComplete="organization"
|
||||
defaultValue={address.company || undefined}
|
||||
data-testid="company-input"
|
||||
/>
|
||||
<Input
|
||||
label="Address"
|
||||
name="address_1"
|
||||
required
|
||||
autoComplete="address-line1"
|
||||
defaultValue={address.address_1 || undefined}
|
||||
data-testid="address-1-input"
|
||||
/>
|
||||
<Input
|
||||
label="Apartment, suite, etc."
|
||||
name="address_2"
|
||||
autoComplete="address-line2"
|
||||
defaultValue={address.address_2 || undefined}
|
||||
data-testid="address-2-input"
|
||||
/>
|
||||
<div className="grid grid-cols-[144px_1fr] gap-x-2">
|
||||
<Input
|
||||
label="Postal code"
|
||||
name="postal_code"
|
||||
required
|
||||
autoComplete="postal-code"
|
||||
defaultValue={address.postal_code || undefined}
|
||||
data-testid="postal-code-input"
|
||||
/>
|
||||
<Input
|
||||
label="City"
|
||||
name="city"
|
||||
required
|
||||
autoComplete="locality"
|
||||
defaultValue={address.city || undefined}
|
||||
data-testid="city-input"
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
label="Province / State"
|
||||
name="province"
|
||||
autoComplete="address-level1"
|
||||
defaultValue={address.province || undefined}
|
||||
data-testid="state-input"
|
||||
/>
|
||||
<CountrySelect
|
||||
name="country_code"
|
||||
region={region}
|
||||
required
|
||||
autoComplete="country"
|
||||
defaultValue={address.country_code || undefined}
|
||||
data-testid="country-select"
|
||||
/>
|
||||
<Input
|
||||
label="Phone"
|
||||
name="phone"
|
||||
autoComplete="phone"
|
||||
defaultValue={address.phone || undefined}
|
||||
data-testid="phone-input"
|
||||
/>
|
||||
</div>
|
||||
{formState.error && (
|
||||
<div className="text-rose-500 text-small-regular py-2">
|
||||
{formState.error}
|
||||
</div>
|
||||
)}
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<div className="flex gap-3 mt-6">
|
||||
<Button
|
||||
type="reset"
|
||||
variant="secondary"
|
||||
onClick={close}
|
||||
className="h-10"
|
||||
data-testid="cancel-button"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<SubmitButton data-testid="save-button">Save</SubmitButton>
|
||||
</div>
|
||||
</Modal.Footer>
|
||||
</form>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default EditAddress
|
||||
@@ -0,0 +1,55 @@
|
||||
// account-info
|
||||
export { default as AccountInfo } from "./account-info";
|
||||
export * from "./account-info";
|
||||
|
||||
// account-nav
|
||||
export { default as AccountNav } from "./account-nav";
|
||||
export * from "./account-nav";
|
||||
|
||||
// address-book
|
||||
export { default as AddressBook } from "./address-book";
|
||||
export * from "./address-book";
|
||||
|
||||
// login
|
||||
export { default as Login } from "./login";
|
||||
export * from "./login";
|
||||
|
||||
// order-card
|
||||
export { default as OrderCard } from "./order-card";
|
||||
export * from "./order-card";
|
||||
|
||||
// order-overview
|
||||
export { default as OrderOverview } from "./order-overview";
|
||||
export * from "./order-overview";
|
||||
|
||||
// overview
|
||||
export { default as Overview } from "./overview";
|
||||
export * from "./overview";
|
||||
|
||||
// profile-billing-address
|
||||
export { default as ProfileBillingAddress } from "./profile-billing-address";
|
||||
export * from "./profile-billing-address";
|
||||
|
||||
// profile-email
|
||||
export { default as ProfileEmail } from "./profile-email";
|
||||
export * from "./profile-email";
|
||||
|
||||
// profile-name
|
||||
export { default as ProfileName } from "./profile-name";
|
||||
export * from "./profile-name";
|
||||
|
||||
// profile-password
|
||||
export { default as ProfilePassword } from "./profile-password";
|
||||
export * from "./profile-password";
|
||||
|
||||
// profile-phone
|
||||
export { default as ProfilePhone } from "./profile-phone";
|
||||
export * from "./profile-phone";
|
||||
|
||||
// register
|
||||
export { default as Register } from "./register";
|
||||
export * from "./register";
|
||||
|
||||
// transfer-request-form
|
||||
export { default as TransferRequestForm } from "./transfer-request-form";
|
||||
export * from "./transfer-request-form";
|
||||
@@ -0,0 +1,64 @@
|
||||
import { login } from "@lib/data/customer"
|
||||
import { LOGIN_VIEW } from "@modules/account/templates/login-template"
|
||||
import ErrorMessage from "@modules/checkout/components/error-message"
|
||||
import { SubmitButton } from "@modules/checkout/components/submit-button"
|
||||
import Input from "@modules/common/components/input"
|
||||
import { useActionState } from "react"
|
||||
|
||||
type Props = {
|
||||
setCurrentView: (view: LOGIN_VIEW) => void
|
||||
}
|
||||
|
||||
const Login = ({ setCurrentView }: Props) => {
|
||||
const [message, formAction] = useActionState(login, null)
|
||||
|
||||
return (
|
||||
<div
|
||||
className="max-w-sm w-full flex flex-col items-center"
|
||||
data-testid="login-page"
|
||||
>
|
||||
<h1 className="text-large-semi uppercase mb-6">Welcome back</h1>
|
||||
<p className="text-center text-base-regular text-ui-fg-base mb-8">
|
||||
Sign in to access an enhanced shopping experience.
|
||||
</p>
|
||||
<form className="w-full" action={formAction}>
|
||||
<div className="flex flex-col w-full gap-y-2">
|
||||
<Input
|
||||
label="Email"
|
||||
name="email"
|
||||
type="email"
|
||||
title="Enter a valid email address."
|
||||
autoComplete="email"
|
||||
required
|
||||
data-testid="email-input"
|
||||
/>
|
||||
<Input
|
||||
label="Password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
data-testid="password-input"
|
||||
/>
|
||||
</div>
|
||||
<ErrorMessage error={message} data-testid="login-error-message" />
|
||||
<SubmitButton data-testid="sign-in-button" className="w-full mt-6">
|
||||
Sign in
|
||||
</SubmitButton>
|
||||
</form>
|
||||
<span className="text-center text-ui-fg-base text-small-regular mt-6">
|
||||
Not a member?{" "}
|
||||
<button
|
||||
onClick={() => setCurrentView(LOGIN_VIEW.REGISTER)}
|
||||
className="underline"
|
||||
data-testid="register-button"
|
||||
>
|
||||
Join us
|
||||
</button>
|
||||
.
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Login
|
||||
@@ -0,0 +1,87 @@
|
||||
import { Button } from "@medusajs/ui"
|
||||
import { useMemo } from "react"
|
||||
|
||||
import Thumbnail from "@modules/products/components/thumbnail"
|
||||
import LocalizedClientLink from "@modules/common/components/localized-client-link"
|
||||
import { convertToLocale } from "@lib/util/money"
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
|
||||
type OrderCardProps = {
|
||||
order: HttpTypes.StoreOrder
|
||||
}
|
||||
|
||||
const OrderCard = ({ order }: OrderCardProps) => {
|
||||
const numberOfLines = useMemo(() => {
|
||||
return (
|
||||
order.items?.reduce((acc, item) => {
|
||||
return acc + item.quantity
|
||||
}, 0) ?? 0
|
||||
)
|
||||
}, [order])
|
||||
|
||||
const numberOfProducts = useMemo(() => {
|
||||
return order.items?.length ?? 0
|
||||
}, [order])
|
||||
|
||||
return (
|
||||
<div className="bg-white flex flex-col" data-testid="order-card">
|
||||
<div className="uppercase text-large-semi mb-1">
|
||||
#<span data-testid="order-display-id">{order.display_id}</span>
|
||||
</div>
|
||||
<div className="flex items-center divide-x divide-gray-200 text-small-regular text-ui-fg-base">
|
||||
<span className="pr-2" data-testid="order-created-at">
|
||||
{new Date(order.created_at).toDateString()}
|
||||
</span>
|
||||
<span className="px-2" data-testid="order-amount">
|
||||
{convertToLocale({
|
||||
amount: order.total,
|
||||
currency_code: order.currency_code,
|
||||
})}
|
||||
</span>
|
||||
<span className="pl-2">{`${numberOfLines} ${
|
||||
numberOfLines > 1 ? "items" : "item"
|
||||
}`}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 small:grid-cols-4 gap-4 my-4">
|
||||
{order.items?.slice(0, 3).map((i) => {
|
||||
return (
|
||||
<div
|
||||
key={i.id}
|
||||
className="flex flex-col gap-y-2"
|
||||
data-testid="order-item"
|
||||
>
|
||||
<Thumbnail thumbnail={i.thumbnail} images={[]} size="full" />
|
||||
<div className="flex items-center text-small-regular text-ui-fg-base">
|
||||
<span
|
||||
className="text-ui-fg-base font-semibold"
|
||||
data-testid="item-title"
|
||||
>
|
||||
{i.title}
|
||||
</span>
|
||||
<span className="ml-2">x</span>
|
||||
<span data-testid="item-quantity">{i.quantity}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{numberOfProducts > 4 && (
|
||||
<div className="w-full h-full flex flex-col items-center justify-center">
|
||||
<span className="text-small-regular text-ui-fg-base">
|
||||
+ {numberOfLines - 4}
|
||||
</span>
|
||||
<span className="text-small-regular text-ui-fg-base">more</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<LocalizedClientLink href={`/account/orders/details/${order.id}`}>
|
||||
<Button data-testid="order-details-link" variant="secondary">
|
||||
See details
|
||||
</Button>
|
||||
</LocalizedClientLink>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default OrderCard
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client"
|
||||
|
||||
import { Button } from "@medusajs/ui"
|
||||
|
||||
import OrderCard from "../order-card"
|
||||
import LocalizedClientLink from "@modules/common/components/localized-client-link"
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
|
||||
const OrderOverview = ({ orders }: { orders: HttpTypes.StoreOrder[] }) => {
|
||||
if (orders?.length) {
|
||||
return (
|
||||
<div className="flex flex-col gap-y-8 w-full">
|
||||
{orders.map((o) => (
|
||||
<div
|
||||
key={o.id}
|
||||
className="border-b border-gray-200 pb-6 last:pb-0 last:border-none"
|
||||
>
|
||||
<OrderCard order={o} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-full flex flex-col items-center gap-y-4"
|
||||
data-testid="no-orders-container"
|
||||
>
|
||||
<h2 className="text-large-semi">Nothing to see here</h2>
|
||||
<p className="text-base-regular">
|
||||
You don't have any orders yet, let us change that {":)"}
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<LocalizedClientLink href="/" passHref>
|
||||
<Button data-testid="continue-shopping-button">
|
||||
Continue shopping
|
||||
</Button>
|
||||
</LocalizedClientLink>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default OrderOverview
|
||||
@@ -0,0 +1,168 @@
|
||||
import { Container } from "@medusajs/ui"
|
||||
|
||||
import ChevronDown from "@modules/common/icons/chevron-down"
|
||||
import LocalizedClientLink from "@modules/common/components/localized-client-link"
|
||||
import { convertToLocale } from "@lib/util/money"
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
|
||||
type OverviewProps = {
|
||||
customer: HttpTypes.StoreCustomer | null
|
||||
orders: HttpTypes.StoreOrder[] | null
|
||||
}
|
||||
|
||||
const Overview = ({ customer, orders }: OverviewProps) => {
|
||||
return (
|
||||
<div data-testid="overview-page-wrapper">
|
||||
<div className="hidden small:block">
|
||||
<div className="text-xl-semi flex justify-between items-center mb-4">
|
||||
<span data-testid="welcome-message" data-value={customer?.first_name}>
|
||||
Hello {customer?.first_name}
|
||||
</span>
|
||||
<span className="text-small-regular text-ui-fg-base">
|
||||
Signed in as:{" "}
|
||||
<span
|
||||
className="font-semibold"
|
||||
data-testid="customer-email"
|
||||
data-value={customer?.email}
|
||||
>
|
||||
{customer?.email}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col py-8 border-t border-gray-200">
|
||||
<div className="flex flex-col gap-y-4 h-full col-span-1 row-span-2 flex-1">
|
||||
<div className="flex items-start gap-x-16 mb-6">
|
||||
<div className="flex flex-col gap-y-4">
|
||||
<h3 className="text-large-semi">Profile</h3>
|
||||
<div className="flex items-end gap-x-2">
|
||||
<span
|
||||
className="text-3xl-semi leading-none"
|
||||
data-testid="customer-profile-completion"
|
||||
data-value={getProfileCompletion(customer)}
|
||||
>
|
||||
{getProfileCompletion(customer)}%
|
||||
</span>
|
||||
<span className="uppercase text-base-regular text-ui-fg-subtle">
|
||||
Completed
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-y-4">
|
||||
<h3 className="text-large-semi">Addresses</h3>
|
||||
<div className="flex items-end gap-x-2">
|
||||
<span
|
||||
className="text-3xl-semi leading-none"
|
||||
data-testid="addresses-count"
|
||||
data-value={customer?.addresses?.length || 0}
|
||||
>
|
||||
{customer?.addresses?.length || 0}
|
||||
</span>
|
||||
<span className="uppercase text-base-regular text-ui-fg-subtle">
|
||||
Saved
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-y-4">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<h3 className="text-large-semi">Recent orders</h3>
|
||||
</div>
|
||||
<ul
|
||||
className="flex flex-col gap-y-4"
|
||||
data-testid="orders-wrapper"
|
||||
>
|
||||
{orders && orders.length > 0 ? (
|
||||
orders.slice(0, 5).map((order) => {
|
||||
return (
|
||||
<li
|
||||
key={order.id}
|
||||
data-testid="order-wrapper"
|
||||
data-value={order.id}
|
||||
>
|
||||
<LocalizedClientLink
|
||||
href={`/account/orders/details/${order.id}`}
|
||||
>
|
||||
<Container className="bg-gray-50 flex justify-between items-center p-4">
|
||||
<div className="grid grid-cols-3 grid-rows-2 text-small-regular gap-x-4 flex-1">
|
||||
<span className="font-semibold">Date placed</span>
|
||||
<span className="font-semibold">
|
||||
Order number
|
||||
</span>
|
||||
<span className="font-semibold">
|
||||
Total amount
|
||||
</span>
|
||||
<span data-testid="order-created-date">
|
||||
{new Date(order.created_at).toDateString()}
|
||||
</span>
|
||||
<span
|
||||
data-testid="order-id"
|
||||
data-value={order.display_id}
|
||||
>
|
||||
#{order.display_id}
|
||||
</span>
|
||||
<span data-testid="order-amount">
|
||||
{convertToLocale({
|
||||
amount: order.total,
|
||||
currency_code: order.currency_code,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
className="flex items-center justify-between"
|
||||
data-testid="open-order-button"
|
||||
>
|
||||
<span className="sr-only">
|
||||
Go to order #{order.display_id}
|
||||
</span>
|
||||
<ChevronDown className="-rotate-90" />
|
||||
</button>
|
||||
</Container>
|
||||
</LocalizedClientLink>
|
||||
</li>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<span data-testid="no-orders-message">No recent orders</span>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const getProfileCompletion = (customer: HttpTypes.StoreCustomer | null) => {
|
||||
let count = 0
|
||||
|
||||
if (!customer) {
|
||||
return 0
|
||||
}
|
||||
|
||||
if (customer.email) {
|
||||
count++
|
||||
}
|
||||
|
||||
if (customer.first_name && customer.last_name) {
|
||||
count++
|
||||
}
|
||||
|
||||
if (customer.phone) {
|
||||
count++
|
||||
}
|
||||
|
||||
const billingAddress = customer.addresses?.find(
|
||||
(addr) => addr.is_default_billing
|
||||
)
|
||||
|
||||
if (billingAddress) {
|
||||
count++
|
||||
}
|
||||
|
||||
return (count / 4) * 100
|
||||
}
|
||||
|
||||
export default Overview
|
||||
@@ -0,0 +1,182 @@
|
||||
"use client"
|
||||
|
||||
import React, { useEffect, useMemo, useActionState } from "react"
|
||||
|
||||
import Input from "@modules/common/components/input"
|
||||
import NativeSelect from "@modules/common/components/native-select"
|
||||
|
||||
import AccountInfo from "../account-info"
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
import { addCustomerAddress, updateCustomerAddress } from "@lib/data/customer"
|
||||
|
||||
type MyInformationProps = {
|
||||
customer: HttpTypes.StoreCustomer
|
||||
regions: HttpTypes.StoreRegion[]
|
||||
}
|
||||
|
||||
const ProfileBillingAddress: React.FC<MyInformationProps> = ({
|
||||
customer,
|
||||
regions,
|
||||
}) => {
|
||||
const regionOptions = useMemo(() => {
|
||||
return (
|
||||
regions
|
||||
?.map((region) => {
|
||||
return region.countries?.map((country) => ({
|
||||
value: country.iso_2,
|
||||
label: country.display_name,
|
||||
}))
|
||||
})
|
||||
.flat() || []
|
||||
)
|
||||
}, [regions])
|
||||
|
||||
const [successState, setSuccessState] = React.useState(false)
|
||||
|
||||
const billingAddress = customer.addresses?.find(
|
||||
(addr) => addr.is_default_billing
|
||||
)
|
||||
|
||||
const initialState: Record<string, any> = {
|
||||
isDefaultBilling: true,
|
||||
isDefaultShipping: false,
|
||||
error: false,
|
||||
success: false,
|
||||
}
|
||||
|
||||
if (billingAddress) {
|
||||
initialState.addressId = billingAddress.id
|
||||
}
|
||||
|
||||
const [state, formAction] = useActionState(
|
||||
billingAddress ? updateCustomerAddress : addCustomerAddress,
|
||||
initialState
|
||||
)
|
||||
|
||||
const clearState = () => {
|
||||
setSuccessState(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setSuccessState(state.success)
|
||||
}, [state])
|
||||
|
||||
const currentInfo = useMemo(() => {
|
||||
if (!billingAddress) {
|
||||
return "No billing address"
|
||||
}
|
||||
|
||||
const country =
|
||||
regionOptions?.find(
|
||||
(country) => country?.value === billingAddress.country_code
|
||||
)?.label || billingAddress.country_code?.toUpperCase()
|
||||
|
||||
return (
|
||||
<div className="flex flex-col font-semibold" data-testid="current-info">
|
||||
<span>
|
||||
{billingAddress.first_name} {billingAddress.last_name}
|
||||
</span>
|
||||
<span>{billingAddress.company}</span>
|
||||
<span>
|
||||
{billingAddress.address_1}
|
||||
{billingAddress.address_2 ? `, ${billingAddress.address_2}` : ""}
|
||||
</span>
|
||||
<span>
|
||||
{billingAddress.postal_code}, {billingAddress.city}
|
||||
</span>
|
||||
<span>{country}</span>
|
||||
</div>
|
||||
)
|
||||
}, [billingAddress, regionOptions])
|
||||
|
||||
return (
|
||||
<form action={formAction} onReset={() => clearState()} className="w-full">
|
||||
<input type="hidden" name="addressId" value={billingAddress?.id} />
|
||||
<AccountInfo
|
||||
label="Billing address"
|
||||
currentInfo={currentInfo}
|
||||
isSuccess={successState}
|
||||
isError={!!state.error}
|
||||
clearState={clearState}
|
||||
data-testid="account-billing-address-editor"
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-y-2">
|
||||
<div className="grid grid-cols-2 gap-x-2">
|
||||
<Input
|
||||
label="First name"
|
||||
name="first_name"
|
||||
defaultValue={billingAddress?.first_name || undefined}
|
||||
required
|
||||
data-testid="billing-first-name-input"
|
||||
/>
|
||||
<Input
|
||||
label="Last name"
|
||||
name="last_name"
|
||||
defaultValue={billingAddress?.last_name || undefined}
|
||||
required
|
||||
data-testid="billing-last-name-input"
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
label="Company"
|
||||
name="company"
|
||||
defaultValue={billingAddress?.company || undefined}
|
||||
data-testid="billing-company-input"
|
||||
/>
|
||||
<Input
|
||||
label="Address"
|
||||
name="address_1"
|
||||
defaultValue={billingAddress?.address_1 || undefined}
|
||||
required
|
||||
data-testid="billing-address-1-input"
|
||||
/>
|
||||
<Input
|
||||
label="Apartment, suite, etc."
|
||||
name="address_2"
|
||||
defaultValue={billingAddress?.address_2 || undefined}
|
||||
data-testid="billing-address-2-input"
|
||||
/>
|
||||
<div className="grid grid-cols-[144px_1fr] gap-x-2">
|
||||
<Input
|
||||
label="Postal code"
|
||||
name="postal_code"
|
||||
defaultValue={billingAddress?.postal_code || undefined}
|
||||
required
|
||||
data-testid="billing-postcal-code-input"
|
||||
/>
|
||||
<Input
|
||||
label="City"
|
||||
name="city"
|
||||
defaultValue={billingAddress?.city || undefined}
|
||||
required
|
||||
data-testid="billing-city-input"
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
label="Province"
|
||||
name="province"
|
||||
defaultValue={billingAddress?.province || undefined}
|
||||
data-testid="billing-province-input"
|
||||
/>
|
||||
<NativeSelect
|
||||
name="country_code"
|
||||
defaultValue={billingAddress?.country_code || undefined}
|
||||
required
|
||||
data-testid="billing-country-code-select"
|
||||
>
|
||||
<option value="">-</option>
|
||||
{regionOptions.map((option, i) => {
|
||||
return (
|
||||
<option key={i} value={option?.value}>
|
||||
{option?.label}
|
||||
</option>
|
||||
)
|
||||
})}
|
||||
</NativeSelect>
|
||||
</div>
|
||||
</AccountInfo>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
export default ProfileBillingAddress
|
||||
@@ -0,0 +1,75 @@
|
||||
"use client"
|
||||
|
||||
import React, { useEffect, useActionState } from "react";
|
||||
|
||||
import Input from "@modules/common/components/input"
|
||||
|
||||
import AccountInfo from "../account-info"
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
// import { updateCustomer } from "@lib/data/customer"
|
||||
|
||||
type MyInformationProps = {
|
||||
customer: HttpTypes.StoreCustomer
|
||||
}
|
||||
|
||||
const ProfileEmail: React.FC<MyInformationProps> = ({ customer }) => {
|
||||
const [successState, setSuccessState] = React.useState(false)
|
||||
|
||||
// TODO: It seems we don't support updating emails now?
|
||||
const updateCustomerEmail = (
|
||||
_currentState: Record<string, unknown>,
|
||||
formData: FormData
|
||||
) => {
|
||||
const customer = {
|
||||
email: formData.get("email") as string,
|
||||
}
|
||||
|
||||
try {
|
||||
// await updateCustomer(customer)
|
||||
return { success: true, error: null }
|
||||
} catch (error: any) {
|
||||
return { success: false, error: error.toString() }
|
||||
}
|
||||
}
|
||||
|
||||
const [state, formAction] = useActionState(updateCustomerEmail, {
|
||||
error: false,
|
||||
success: false,
|
||||
})
|
||||
|
||||
const clearState = () => {
|
||||
setSuccessState(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setSuccessState(state.success)
|
||||
}, [state])
|
||||
|
||||
return (
|
||||
<form action={formAction} className="w-full">
|
||||
<AccountInfo
|
||||
label="Email"
|
||||
currentInfo={`${customer.email}`}
|
||||
isSuccess={successState}
|
||||
isError={!!state.error}
|
||||
errorMessage={state.error}
|
||||
clearState={clearState}
|
||||
data-testid="account-email-editor"
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-y-2">
|
||||
<Input
|
||||
label="Email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
defaultValue={customer.email}
|
||||
data-testid="email-input"
|
||||
/>
|
||||
</div>
|
||||
</AccountInfo>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
export default ProfileEmail
|
||||
@@ -0,0 +1,79 @@
|
||||
"use client"
|
||||
|
||||
import React, { useEffect, useActionState } from "react";
|
||||
|
||||
import Input from "@modules/common/components/input"
|
||||
|
||||
import AccountInfo from "../account-info"
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
import { updateCustomer } from "@lib/data/customer"
|
||||
|
||||
type MyInformationProps = {
|
||||
customer: HttpTypes.StoreCustomer
|
||||
}
|
||||
|
||||
const ProfileName: React.FC<MyInformationProps> = ({ customer }) => {
|
||||
const [successState, setSuccessState] = React.useState(false)
|
||||
|
||||
const updateCustomerName = async (
|
||||
_currentState: Record<string, unknown>,
|
||||
formData: FormData
|
||||
) => {
|
||||
const customer = {
|
||||
first_name: formData.get("first_name") as string,
|
||||
last_name: formData.get("last_name") as string,
|
||||
}
|
||||
|
||||
try {
|
||||
await updateCustomer(customer)
|
||||
return { success: true, error: null }
|
||||
} catch (error: any) {
|
||||
return { success: false, error: error.toString() }
|
||||
}
|
||||
}
|
||||
|
||||
const [state, formAction] = useActionState(updateCustomerName, {
|
||||
error: false,
|
||||
success: false,
|
||||
})
|
||||
|
||||
const clearState = () => {
|
||||
setSuccessState(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setSuccessState(state.success)
|
||||
}, [state])
|
||||
|
||||
return (
|
||||
<form action={formAction} className="w-full overflow-visible">
|
||||
<AccountInfo
|
||||
label="Name"
|
||||
currentInfo={`${customer.first_name} ${customer.last_name}`}
|
||||
isSuccess={successState}
|
||||
isError={!!state?.error}
|
||||
clearState={clearState}
|
||||
data-testid="account-name-editor"
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-x-4">
|
||||
<Input
|
||||
label="First name"
|
||||
name="first_name"
|
||||
required
|
||||
defaultValue={customer.first_name ?? ""}
|
||||
data-testid="first-name-input"
|
||||
/>
|
||||
<Input
|
||||
label="Last name"
|
||||
name="last_name"
|
||||
required
|
||||
defaultValue={customer.last_name ?? ""}
|
||||
data-testid="last-name-input"
|
||||
/>
|
||||
</div>
|
||||
</AccountInfo>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
export default ProfileName
|
||||
@@ -0,0 +1,70 @@
|
||||
"use client"
|
||||
|
||||
import React, { useEffect, useActionState } from "react"
|
||||
import Input from "@modules/common/components/input"
|
||||
import AccountInfo from "../account-info"
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
import { toast } from "@medusajs/ui"
|
||||
|
||||
type MyInformationProps = {
|
||||
customer: HttpTypes.StoreCustomer
|
||||
}
|
||||
|
||||
const ProfilePassword: React.FC<MyInformationProps> = ({ customer }) => {
|
||||
const [successState, setSuccessState] = React.useState(false)
|
||||
|
||||
// TODO: Add support for password updates
|
||||
const updatePassword = async () => {
|
||||
toast.info("Password update is not implemented")
|
||||
}
|
||||
|
||||
const clearState = () => {
|
||||
setSuccessState(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
action={updatePassword}
|
||||
onReset={() => clearState()}
|
||||
className="w-full"
|
||||
>
|
||||
<AccountInfo
|
||||
label="Password"
|
||||
currentInfo={
|
||||
<span>The password is not shown for security reasons</span>
|
||||
}
|
||||
isSuccess={successState}
|
||||
isError={false}
|
||||
errorMessage={undefined}
|
||||
clearState={clearState}
|
||||
data-testid="account-password-editor"
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Old password"
|
||||
name="old_password"
|
||||
required
|
||||
type="password"
|
||||
data-testid="old-password-input"
|
||||
/>
|
||||
<Input
|
||||
label="New password"
|
||||
type="password"
|
||||
name="new_password"
|
||||
required
|
||||
data-testid="new-password-input"
|
||||
/>
|
||||
<Input
|
||||
label="Confirm password"
|
||||
type="password"
|
||||
name="confirm_password"
|
||||
required
|
||||
data-testid="confirm-password-input"
|
||||
/>
|
||||
</div>
|
||||
</AccountInfo>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
export default ProfilePassword
|
||||
@@ -0,0 +1,74 @@
|
||||
"use client"
|
||||
|
||||
import React, { useEffect, useActionState } from "react";
|
||||
|
||||
import Input from "@modules/common/components/input"
|
||||
|
||||
import AccountInfo from "../account-info"
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
import { updateCustomer } from "@lib/data/customer"
|
||||
|
||||
type MyInformationProps = {
|
||||
customer: HttpTypes.StoreCustomer
|
||||
}
|
||||
|
||||
const ProfileEmail: React.FC<MyInformationProps> = ({ customer }) => {
|
||||
const [successState, setSuccessState] = React.useState(false)
|
||||
|
||||
const updateCustomerPhone = async (
|
||||
_currentState: Record<string, unknown>,
|
||||
formData: FormData
|
||||
) => {
|
||||
const customer = {
|
||||
phone: formData.get("phone") as string,
|
||||
}
|
||||
|
||||
try {
|
||||
await updateCustomer(customer)
|
||||
return { success: true, error: null }
|
||||
} catch (error: any) {
|
||||
return { success: false, error: error.toString() }
|
||||
}
|
||||
}
|
||||
|
||||
const [state, formAction] = useActionState(updateCustomerPhone, {
|
||||
error: false,
|
||||
success: false,
|
||||
})
|
||||
|
||||
const clearState = () => {
|
||||
setSuccessState(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setSuccessState(state.success)
|
||||
}, [state])
|
||||
|
||||
return (
|
||||
<form action={formAction} className="w-full">
|
||||
<AccountInfo
|
||||
label="Phone"
|
||||
currentInfo={`${customer.phone}`}
|
||||
isSuccess={successState}
|
||||
isError={!!state.error}
|
||||
errorMessage={state.error}
|
||||
clearState={clearState}
|
||||
data-testid="account-phone-editor"
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-y-2">
|
||||
<Input
|
||||
label="Phone"
|
||||
name="phone"
|
||||
type="phone"
|
||||
autoComplete="phone"
|
||||
required
|
||||
defaultValue={customer.phone ?? ""}
|
||||
data-testid="phone-input"
|
||||
/>
|
||||
</div>
|
||||
</AccountInfo>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
export default ProfileEmail
|
||||
@@ -0,0 +1,106 @@
|
||||
"use client"
|
||||
|
||||
import { useActionState } from "react"
|
||||
import Input from "@modules/common/components/input"
|
||||
import { LOGIN_VIEW } from "@modules/account/templates/login-template"
|
||||
import ErrorMessage from "@modules/checkout/components/error-message"
|
||||
import { SubmitButton } from "@modules/checkout/components/submit-button"
|
||||
import LocalizedClientLink from "@modules/common/components/localized-client-link"
|
||||
import { signup } from "@lib/data/customer"
|
||||
|
||||
type Props = {
|
||||
setCurrentView: (view: LOGIN_VIEW) => void
|
||||
}
|
||||
|
||||
const Register = ({ setCurrentView }: Props) => {
|
||||
const [message, formAction] = useActionState(signup, null)
|
||||
|
||||
return (
|
||||
<div
|
||||
className="max-w-sm flex flex-col items-center"
|
||||
data-testid="register-page"
|
||||
>
|
||||
<h1 className="text-large-semi uppercase mb-6">
|
||||
Become a Medusa Store Member
|
||||
</h1>
|
||||
<p className="text-center text-base-regular text-ui-fg-base mb-4">
|
||||
Create your Medusa Store Member profile, and get access to an enhanced
|
||||
shopping experience.
|
||||
</p>
|
||||
<form className="w-full flex flex-col" action={formAction}>
|
||||
<div className="flex flex-col w-full gap-y-2">
|
||||
<Input
|
||||
label="First name"
|
||||
name="first_name"
|
||||
required
|
||||
autoComplete="given-name"
|
||||
data-testid="first-name-input"
|
||||
/>
|
||||
<Input
|
||||
label="Last name"
|
||||
name="last_name"
|
||||
required
|
||||
autoComplete="family-name"
|
||||
data-testid="last-name-input"
|
||||
/>
|
||||
<Input
|
||||
label="Email"
|
||||
name="email"
|
||||
required
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
data-testid="email-input"
|
||||
/>
|
||||
<Input
|
||||
label="Phone"
|
||||
name="phone"
|
||||
type="tel"
|
||||
autoComplete="tel"
|
||||
data-testid="phone-input"
|
||||
/>
|
||||
<Input
|
||||
label="Password"
|
||||
name="password"
|
||||
required
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
data-testid="password-input"
|
||||
/>
|
||||
</div>
|
||||
<ErrorMessage error={message} data-testid="register-error" />
|
||||
<span className="text-center text-ui-fg-base text-small-regular mt-6">
|
||||
By creating an account, you agree to Medusa Store's{" "}
|
||||
<LocalizedClientLink
|
||||
href="/content/privacy-policy"
|
||||
className="underline"
|
||||
>
|
||||
Privacy Policy
|
||||
</LocalizedClientLink>{" "}
|
||||
and{" "}
|
||||
<LocalizedClientLink
|
||||
href="/content/terms-of-use"
|
||||
className="underline"
|
||||
>
|
||||
Terms of Use
|
||||
</LocalizedClientLink>
|
||||
.
|
||||
</span>
|
||||
<SubmitButton className="w-full mt-6" data-testid="register-button">
|
||||
Join
|
||||
</SubmitButton>
|
||||
</form>
|
||||
<span className="text-center text-ui-fg-base text-small-regular mt-6">
|
||||
Already a member?{" "}
|
||||
<button
|
||||
onClick={() => setCurrentView(LOGIN_VIEW.SIGN_IN)}
|
||||
className="underline"
|
||||
>
|
||||
Sign in
|
||||
</button>
|
||||
.
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Register
|
||||
@@ -0,0 +1,81 @@
|
||||
"use client"
|
||||
|
||||
import { useActionState } from "react"
|
||||
import { createTransferRequest } from "@lib/data/orders"
|
||||
import { Text, Heading, Input, Button, IconButton, Toaster } from "@medusajs/ui"
|
||||
import { SubmitButton } from "@modules/checkout/components/submit-button"
|
||||
import { CheckCircleMiniSolid, XCircleSolid } from "@medusajs/icons"
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
export default function TransferRequestForm() {
|
||||
const [showSuccess, setShowSuccess] = useState(false)
|
||||
|
||||
const [state, formAction] = useActionState(createTransferRequest, {
|
||||
success: false,
|
||||
error: null,
|
||||
order: null,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (state.success && state.order) {
|
||||
setShowSuccess(true)
|
||||
}
|
||||
}, [state.success, state.order])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-y-4 w-full">
|
||||
<div className="grid sm:grid-cols-2 items-center gap-x-8 gap-y-4 w-full">
|
||||
<div className="flex flex-col gap-y-1">
|
||||
<Heading level="h3" className="text-lg text-neutral-950">
|
||||
Order transfers
|
||||
</Heading>
|
||||
<Text className="text-base-regular text-neutral-500">
|
||||
Can't find the order you are looking for?
|
||||
<br /> Connect an order to your account.
|
||||
</Text>
|
||||
</div>
|
||||
<form
|
||||
action={formAction}
|
||||
className="flex flex-col gap-y-1 sm:items-end"
|
||||
>
|
||||
<div className="flex flex-col gap-y-2 w-full">
|
||||
<Input className="w-full" name="order_id" placeholder="Order ID" />
|
||||
<SubmitButton
|
||||
variant="secondary"
|
||||
className="w-fit whitespace-nowrap self-end"
|
||||
>
|
||||
Request transfer
|
||||
</SubmitButton>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{!state.success && state.error && (
|
||||
<Text className="text-base-regular text-rose-500 text-right">
|
||||
{state.error}
|
||||
</Text>
|
||||
)}
|
||||
{showSuccess && (
|
||||
<div className="flex justify-between p-4 bg-neutral-50 shadow-borders-base w-full self-stretch items-center">
|
||||
<div className="flex gap-x-2 items-center">
|
||||
<CheckCircleMiniSolid className="w-4 h-4 text-emerald-500" />
|
||||
<div className="flex flex-col gap-y-1">
|
||||
<Text className="text-medim-pl text-neutral-950">
|
||||
Transfer for order {state.order?.id} requested
|
||||
</Text>
|
||||
<Text className="text-base-regular text-neutral-600">
|
||||
Transfer request email sent to {state.order?.email}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
<IconButton
|
||||
variant="transparent"
|
||||
className="h-fit"
|
||||
onClick={() => setShowSuccess(false)}
|
||||
>
|
||||
<XCircleSolid className="w-4 h-4 text-neutral-500" />
|
||||
</IconButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import React from "react"
|
||||
|
||||
import UnderlineLink from "@modules/common/components/interactive-link"
|
||||
|
||||
import AccountNav from "../components/account-nav"
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
|
||||
interface AccountLayoutProps {
|
||||
customer: HttpTypes.StoreCustomer | null
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
const AccountLayout: React.FC<AccountLayoutProps> = ({
|
||||
customer,
|
||||
children,
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex-1 small:py-12" data-testid="account-page">
|
||||
<div className="flex-1 content-container h-full max-w-5xl mx-auto bg-white flex flex-col">
|
||||
<div className="grid grid-cols-1 small:grid-cols-[240px_1fr] py-12">
|
||||
<div>{customer && <AccountNav customer={customer} />}</div>
|
||||
<div className="flex-1">{children}</div>
|
||||
</div>
|
||||
<div className="flex flex-col small:flex-row items-end justify-between small:border-t border-gray-200 py-12 gap-8">
|
||||
<div>
|
||||
<h3 className="text-xl-semi mb-4">Got questions?</h3>
|
||||
<span className="txt-medium">
|
||||
You can find frequently asked questions and answers on our
|
||||
customer service page.
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<UnderlineLink href="/customer-service">
|
||||
Customer Service
|
||||
</UnderlineLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AccountLayout
|
||||
@@ -0,0 +1,7 @@
|
||||
// account-info
|
||||
export { default as AccountLayout } from "./account-layout";
|
||||
export * from "./account-layout";
|
||||
|
||||
// account-nav
|
||||
export { default as LoginTemplate } from "./login-template";
|
||||
export * from "./login-template";
|
||||
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Login, Register } from "../components";
|
||||
|
||||
export enum LOGIN_VIEW {
|
||||
SIGN_IN = "sign-in",
|
||||
REGISTER = "register",
|
||||
}
|
||||
|
||||
const LoginTemplate = () => {
|
||||
const [currentView, setCurrentView] = useState("sign-in");
|
||||
|
||||
return (
|
||||
<div className="w-full flex justify-start px-8 py-8">
|
||||
{currentView === "sign-in" ? (
|
||||
<Login setCurrentView={setCurrentView} />
|
||||
) : (
|
||||
<Register setCurrentView={setCurrentView} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginTemplate;
|
||||
@@ -0,0 +1,73 @@
|
||||
"use client"
|
||||
|
||||
import { IconBadge, clx } from "@medusajs/ui"
|
||||
import {
|
||||
SelectHTMLAttributes,
|
||||
forwardRef,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react"
|
||||
|
||||
import ChevronDown from "@modules/common/icons/chevron-down"
|
||||
|
||||
type NativeSelectProps = {
|
||||
placeholder?: string
|
||||
errors?: Record<string, unknown>
|
||||
touched?: Record<string, unknown>
|
||||
} & Omit<SelectHTMLAttributes<HTMLSelectElement>, "size">
|
||||
|
||||
const CartItemSelect = forwardRef<HTMLSelectElement, NativeSelectProps>(
|
||||
({ placeholder = "Select...", className, children, ...props }, ref) => {
|
||||
const innerRef = useRef<HTMLSelectElement>(null)
|
||||
const [isPlaceholder, setIsPlaceholder] = useState(false)
|
||||
|
||||
useImperativeHandle<HTMLSelectElement | null, HTMLSelectElement | null>(
|
||||
ref,
|
||||
() => innerRef.current
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (innerRef.current && innerRef.current.value === "") {
|
||||
setIsPlaceholder(true)
|
||||
} else {
|
||||
setIsPlaceholder(false)
|
||||
}
|
||||
}, [innerRef.current?.value])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<IconBadge
|
||||
onFocus={() => innerRef.current?.focus()}
|
||||
onBlur={() => innerRef.current?.blur()}
|
||||
className={clx(
|
||||
"relative flex items-center txt-compact-small border text-ui-fg-base group",
|
||||
className,
|
||||
{
|
||||
"text-ui-fg-subtle": isPlaceholder,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<select
|
||||
ref={innerRef}
|
||||
{...props}
|
||||
className="appearance-none bg-transparent border-none px-4 transition-colors duration-150 focus:border-gray-700 outline-none w-16 h-16 items-center justify-center"
|
||||
>
|
||||
<option disabled value="">
|
||||
{placeholder}
|
||||
</option>
|
||||
{children}
|
||||
</select>
|
||||
<span className="absolute flex pointer-events-none justify-end w-8 group-hover:animate-pulse">
|
||||
<ChevronDown />
|
||||
</span>
|
||||
</IconBadge>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
CartItemSelect.displayName = "CartItemSelect"
|
||||
|
||||
export default CartItemSelect
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Heading, Text } from "@medusajs/ui"
|
||||
|
||||
import InteractiveLink from "@modules/common/components/interactive-link"
|
||||
|
||||
const EmptyCartMessage = () => {
|
||||
return (
|
||||
<div className="py-48 px-2 flex flex-col justify-center items-start" data-testid="empty-cart-message">
|
||||
<Heading
|
||||
level="h1"
|
||||
className="flex flex-row text-3xl-regular gap-x-2 items-baseline"
|
||||
>
|
||||
Cart
|
||||
</Heading>
|
||||
<Text className="text-base-regular mt-4 mb-6 max-w-[32rem]">
|
||||
You don't have anything in your cart. Let's change that, use
|
||||
the link below to start browsing our products.
|
||||
</Text>
|
||||
<div>
|
||||
<InteractiveLink href="/store">Explore products</InteractiveLink>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default EmptyCartMessage
|
||||
@@ -0,0 +1,144 @@
|
||||
"use client"
|
||||
|
||||
import { Table, Text, clx } from "@medusajs/ui"
|
||||
import { updateLineItem } from "@lib/data/cart"
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
import CartItemSelect from "@modules/cart/components/cart-item-select"
|
||||
import ErrorMessage from "@modules/checkout/components/error-message"
|
||||
import DeleteButton from "@modules/common/components/delete-button"
|
||||
import LineItemOptions from "@modules/common/components/line-item-options"
|
||||
import LineItemPrice from "@modules/common/components/line-item-price"
|
||||
import LineItemUnitPrice from "@modules/common/components/line-item-unit-price"
|
||||
import LocalizedClientLink from "@modules/common/components/localized-client-link"
|
||||
import Spinner from "@modules/common/icons/spinner"
|
||||
import Thumbnail from "@modules/products/components/thumbnail"
|
||||
import { useState } from "react"
|
||||
|
||||
type ItemProps = {
|
||||
item: HttpTypes.StoreCartLineItem
|
||||
type?: "full" | "preview"
|
||||
currencyCode: string
|
||||
}
|
||||
|
||||
const Item = ({ item, type = "full", currencyCode }: ItemProps) => {
|
||||
const [updating, setUpdating] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const changeQuantity = async (quantity: number) => {
|
||||
setError(null)
|
||||
setUpdating(true)
|
||||
|
||||
await updateLineItem({
|
||||
lineId: item.id,
|
||||
quantity,
|
||||
})
|
||||
.catch((err) => {
|
||||
setError(err.message)
|
||||
})
|
||||
.finally(() => {
|
||||
setUpdating(false)
|
||||
})
|
||||
}
|
||||
|
||||
// TODO: Update this to grab the actual max inventory
|
||||
const maxQtyFromInventory = 10
|
||||
const maxQuantity = item.variant?.manage_inventory ? 10 : maxQtyFromInventory
|
||||
|
||||
return (
|
||||
<Table.Row className="w-full" data-testid="product-row">
|
||||
<Table.Cell className="!pl-0 p-4 w-24">
|
||||
<LocalizedClientLink
|
||||
href={`/products/${item.product_handle}`}
|
||||
className={clx("flex", {
|
||||
"w-16": type === "preview",
|
||||
"small:w-24 w-12": type === "full",
|
||||
})}
|
||||
>
|
||||
<Thumbnail
|
||||
thumbnail={item.thumbnail}
|
||||
images={item.variant?.product?.images}
|
||||
size="square"
|
||||
/>
|
||||
</LocalizedClientLink>
|
||||
</Table.Cell>
|
||||
|
||||
<Table.Cell className="text-left">
|
||||
<Text
|
||||
className="txt-medium-plus text-ui-fg-base"
|
||||
data-testid="product-title"
|
||||
>
|
||||
{item.product_title}
|
||||
</Text>
|
||||
<LineItemOptions variant={item.variant} data-testid="product-variant" />
|
||||
</Table.Cell>
|
||||
|
||||
{type === "full" && (
|
||||
<Table.Cell>
|
||||
<div className="flex gap-2 items-center w-28">
|
||||
<DeleteButton id={item.id} data-testid="product-delete-button" />
|
||||
<CartItemSelect
|
||||
value={item.quantity}
|
||||
onChange={(value) => changeQuantity(parseInt(value.target.value))}
|
||||
className="w-14 h-10 p-4"
|
||||
data-testid="product-select-button"
|
||||
>
|
||||
{/* TODO: Update this with the v2 way of managing inventory */}
|
||||
{Array.from(
|
||||
{
|
||||
length: Math.min(maxQuantity, 10),
|
||||
},
|
||||
(_, i) => (
|
||||
<option value={i + 1} key={i}>
|
||||
{i + 1}
|
||||
</option>
|
||||
)
|
||||
)}
|
||||
|
||||
<option value={1} key={1}>
|
||||
1
|
||||
</option>
|
||||
</CartItemSelect>
|
||||
{updating && <Spinner />}
|
||||
</div>
|
||||
<ErrorMessage error={error} data-testid="product-error-message" />
|
||||
</Table.Cell>
|
||||
)}
|
||||
|
||||
{type === "full" && (
|
||||
<Table.Cell className="hidden small:table-cell">
|
||||
<LineItemUnitPrice
|
||||
item={item}
|
||||
style="tight"
|
||||
currencyCode={currencyCode}
|
||||
/>
|
||||
</Table.Cell>
|
||||
)}
|
||||
|
||||
<Table.Cell className="!pr-0">
|
||||
<span
|
||||
className={clx("!pr-0", {
|
||||
"flex flex-col items-end h-full justify-center": type === "preview",
|
||||
})}
|
||||
>
|
||||
{type === "preview" && (
|
||||
<span className="flex gap-x-1 ">
|
||||
<Text className="text-ui-fg-muted">{item.quantity}x </Text>
|
||||
<LineItemUnitPrice
|
||||
item={item}
|
||||
style="tight"
|
||||
currencyCode={currencyCode}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
<LineItemPrice
|
||||
item={item}
|
||||
style="tight"
|
||||
currencyCode={currencyCode}
|
||||
/>
|
||||
</span>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
)
|
||||
}
|
||||
|
||||
export default Item
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Button, Heading, Text } from "@medusajs/ui"
|
||||
import LocalizedClientLink from "@modules/common/components/localized-client-link"
|
||||
|
||||
const SignInPrompt = () => {
|
||||
return (
|
||||
<div className="bg-white flex items-center justify-between">
|
||||
<div>
|
||||
<Heading level="h2" className="txt-xlarge">
|
||||
Already have an account?
|
||||
</Heading>
|
||||
<Text className="txt-medium text-ui-fg-subtle mt-2">
|
||||
Sign in for a better experience.
|
||||
</Text>
|
||||
</div>
|
||||
<div>
|
||||
<LocalizedClientLink href="/account">
|
||||
<Button variant="secondary" className="h-10" data-testid="sign-in-button">
|
||||
Sign in
|
||||
</Button>
|
||||
</LocalizedClientLink>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SignInPrompt
|
||||
@@ -0,0 +1,51 @@
|
||||
import ItemsTemplate from "./items"
|
||||
import Summary from "./summary"
|
||||
import EmptyCartMessage from "../components/empty-cart-message"
|
||||
import SignInPrompt from "../components/sign-in-prompt"
|
||||
import Divider from "@modules/common/components/divider"
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
|
||||
const CartTemplate = ({
|
||||
cart,
|
||||
customer,
|
||||
}: {
|
||||
cart: HttpTypes.StoreCart | null
|
||||
customer: HttpTypes.StoreCustomer | null
|
||||
}) => {
|
||||
return (
|
||||
<div className="py-12">
|
||||
<div className="content-container" data-testid="cart-container">
|
||||
{cart?.items?.length ? (
|
||||
<div className="grid grid-cols-1 small:grid-cols-[1fr_360px] gap-x-40">
|
||||
<div className="flex flex-col bg-white py-6 gap-y-6">
|
||||
{!customer && (
|
||||
<>
|
||||
<SignInPrompt />
|
||||
<Divider />
|
||||
</>
|
||||
)}
|
||||
<ItemsTemplate cart={cart} />
|
||||
</div>
|
||||
<div className="relative">
|
||||
<div className="flex flex-col gap-y-8 sticky top-12">
|
||||
{cart && cart.region && (
|
||||
<>
|
||||
<div className="bg-white py-6">
|
||||
<Summary cart={cart as any} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<EmptyCartMessage />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CartTemplate
|
||||
@@ -0,0 +1,57 @@
|
||||
import repeat from "@lib/util/repeat"
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
import { Heading, Table } from "@medusajs/ui"
|
||||
|
||||
import Item from "@modules/cart/components/item"
|
||||
import SkeletonLineItem from "@modules/skeletons/components/skeleton-line-item"
|
||||
|
||||
type ItemsTemplateProps = {
|
||||
cart?: HttpTypes.StoreCart
|
||||
}
|
||||
|
||||
const ItemsTemplate = ({ cart }: ItemsTemplateProps) => {
|
||||
const items = cart?.items
|
||||
return (
|
||||
<div>
|
||||
<div className="pb-3 flex items-center">
|
||||
<Heading className="text-[2rem] leading-[2.75rem]">Cart</Heading>
|
||||
</div>
|
||||
<Table>
|
||||
<Table.Header className="border-t-0">
|
||||
<Table.Row className="text-ui-fg-subtle txt-medium-plus">
|
||||
<Table.HeaderCell className="!pl-0">Item</Table.HeaderCell>
|
||||
<Table.HeaderCell></Table.HeaderCell>
|
||||
<Table.HeaderCell>Quantity</Table.HeaderCell>
|
||||
<Table.HeaderCell className="hidden small:table-cell">
|
||||
Price
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell className="!pr-0 text-right">
|
||||
Total
|
||||
</Table.HeaderCell>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{items
|
||||
? items
|
||||
.sort((a, b) => {
|
||||
return (a.created_at ?? "") > (b.created_at ?? "") ? -1 : 1
|
||||
})
|
||||
.map((item) => {
|
||||
return (
|
||||
<Item
|
||||
key={item.id}
|
||||
item={item}
|
||||
currencyCode={cart?.currency_code}
|
||||
/>
|
||||
)
|
||||
})
|
||||
: repeat(5).map((i) => {
|
||||
return <SkeletonLineItem key={i} />
|
||||
})}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ItemsTemplate
|
||||
@@ -0,0 +1,51 @@
|
||||
"use client"
|
||||
|
||||
import repeat from "@lib/util/repeat"
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
import { Table, clx } from "@medusajs/ui"
|
||||
|
||||
import Item from "@modules/cart/components/item"
|
||||
import SkeletonLineItem from "@modules/skeletons/components/skeleton-line-item"
|
||||
|
||||
type ItemsTemplateProps = {
|
||||
cart: HttpTypes.StoreCart
|
||||
}
|
||||
|
||||
const ItemsPreviewTemplate = ({ cart }: ItemsTemplateProps) => {
|
||||
const items = cart.items
|
||||
const hasOverflow = items && items.length > 4
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clx({
|
||||
"pl-[1px] overflow-y-scroll overflow-x-hidden no-scrollbar max-h-[420px]":
|
||||
hasOverflow,
|
||||
})}
|
||||
>
|
||||
<Table>
|
||||
<Table.Body data-testid="items-table">
|
||||
{items
|
||||
? items
|
||||
.sort((a, b) => {
|
||||
return (a.created_at ?? "") > (b.created_at ?? "") ? -1 : 1
|
||||
})
|
||||
.map((item) => {
|
||||
return (
|
||||
<Item
|
||||
key={item.id}
|
||||
item={item}
|
||||
type="preview"
|
||||
currencyCode={cart.currency_code}
|
||||
/>
|
||||
)
|
||||
})
|
||||
: repeat(5).map((i) => {
|
||||
return <SkeletonLineItem key={i} />
|
||||
})}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ItemsPreviewTemplate
|
||||
@@ -0,0 +1,48 @@
|
||||
"use client"
|
||||
|
||||
import { Button, Heading } from "@medusajs/ui"
|
||||
|
||||
import CartTotals from "@modules/common/components/cart-totals"
|
||||
import Divider from "@modules/common/components/divider"
|
||||
import DiscountCode from "@modules/checkout/components/discount-code"
|
||||
import LocalizedClientLink from "@modules/common/components/localized-client-link"
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
|
||||
type SummaryProps = {
|
||||
cart: HttpTypes.StoreCart & {
|
||||
promotions: HttpTypes.StorePromotion[]
|
||||
}
|
||||
}
|
||||
|
||||
function getCheckoutStep(cart: HttpTypes.StoreCart) {
|
||||
if (!cart?.shipping_address?.address_1 || !cart.email) {
|
||||
return "address"
|
||||
} else if (cart?.shipping_methods?.length === 0) {
|
||||
return "delivery"
|
||||
} else {
|
||||
return "payment"
|
||||
}
|
||||
}
|
||||
|
||||
const Summary = ({ cart }: SummaryProps) => {
|
||||
const step = getCheckoutStep(cart)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-y-4">
|
||||
<Heading level="h2" className="text-[2rem] leading-[2.75rem]">
|
||||
Summary
|
||||
</Heading>
|
||||
<DiscountCode cart={cart} />
|
||||
<Divider />
|
||||
<CartTotals totals={cart} />
|
||||
<LocalizedClientLink
|
||||
href={"/checkout?step=" + step}
|
||||
data-testid="checkout-button"
|
||||
>
|
||||
<Button className="w-full h-10">Go to checkout</Button>
|
||||
</LocalizedClientLink>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Summary
|
||||
@@ -0,0 +1,97 @@
|
||||
import { notFound } from "next/navigation"
|
||||
import { Suspense } from "react"
|
||||
|
||||
import InteractiveLink from "@modules/common/components/interactive-link"
|
||||
import SkeletonProductGrid from "@modules/skeletons/templates/skeleton-product-grid"
|
||||
import RefinementList from "@modules/store/components/refinement-list"
|
||||
import { SortOptions } from "@modules/store/components/refinement-list/sort-products"
|
||||
import PaginatedProducts from "@modules/store/templates/paginated-products"
|
||||
import LocalizedClientLink from "@modules/common/components/localized-client-link"
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
|
||||
export default function CategoryTemplate({
|
||||
category,
|
||||
sortBy,
|
||||
page,
|
||||
countryCode,
|
||||
}: {
|
||||
category: HttpTypes.StoreProductCategory
|
||||
sortBy?: SortOptions
|
||||
page?: string
|
||||
countryCode: string
|
||||
}) {
|
||||
const pageNumber = page ? parseInt(page) : 1
|
||||
const sort = sortBy || "created_at"
|
||||
|
||||
if (!category || !countryCode) notFound()
|
||||
|
||||
const parents = [] as HttpTypes.StoreProductCategory[]
|
||||
|
||||
const getParents = (category: HttpTypes.StoreProductCategory) => {
|
||||
if (category.parent_category) {
|
||||
parents.push(category.parent_category)
|
||||
getParents(category.parent_category)
|
||||
}
|
||||
}
|
||||
|
||||
getParents(category)
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col small:flex-row small:items-start py-6 content-container"
|
||||
data-testid="category-container"
|
||||
>
|
||||
<RefinementList sortBy={sort} data-testid="sort-by-container" />
|
||||
<div className="w-full">
|
||||
<div className="flex flex-row mb-8 text-2xl-semi gap-4">
|
||||
{parents &&
|
||||
parents.map((parent) => (
|
||||
<span key={parent.id} className="text-ui-fg-subtle">
|
||||
<LocalizedClientLink
|
||||
className="mr-4 hover:text-black"
|
||||
href={`/categories/${parent.handle}`}
|
||||
data-testid="sort-by-link"
|
||||
>
|
||||
{parent.name}
|
||||
</LocalizedClientLink>
|
||||
/
|
||||
</span>
|
||||
))}
|
||||
<h1 data-testid="category-page-title">{category.name}</h1>
|
||||
</div>
|
||||
{category.description && (
|
||||
<div className="mb-8 text-base-regular">
|
||||
<p>{category.description}</p>
|
||||
</div>
|
||||
)}
|
||||
{category.category_children && (
|
||||
<div className="mb-8 text-base-large">
|
||||
<ul className="grid grid-cols-1 gap-2">
|
||||
{category.category_children?.map((c) => (
|
||||
<li key={c.id}>
|
||||
<InteractiveLink href={`/categories/${c.handle}`}>
|
||||
{c.name}
|
||||
</InteractiveLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
<Suspense
|
||||
fallback={
|
||||
<SkeletonProductGrid
|
||||
numberOfProducts={category.products?.length ?? 8}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<PaginatedProducts
|
||||
sortBy={sort}
|
||||
page={pageNumber}
|
||||
categoryId={category.id}
|
||||
countryCode={countryCode}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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,175 @@
|
||||
"use client"
|
||||
|
||||
import { Badge, Heading, Input, Label, Text, Tooltip } from "@medusajs/ui"
|
||||
import React, { useActionState } from "react";
|
||||
|
||||
import { applyPromotions, submitPromotionForm } from "@lib/data/cart"
|
||||
import { convertToLocale } from "@lib/util/money"
|
||||
import { InformationCircleSolid } from "@medusajs/icons"
|
||||
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 { items = [], 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) => {
|
||||
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())
|
||||
|
||||
await applyPromotions(codes)
|
||||
|
||||
if (input) {
|
||||
input.value = ""
|
||||
}
|
||||
}
|
||||
|
||||
const [message, formAction] = useActionState(submitPromotionForm, null)
|
||||
|
||||
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={message}
|
||||
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,400 @@
|
||||
"use client"
|
||||
|
||||
import { RadioGroup, Radio } 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, Heading, Text, clx } 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) {
|
||||
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) => 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) => 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
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Suspense } from "react"
|
||||
|
||||
import SkeletonProductGrid from "@modules/skeletons/templates/skeleton-product-grid"
|
||||
import RefinementList from "@modules/store/components/refinement-list"
|
||||
import { SortOptions } from "@modules/store/components/refinement-list/sort-products"
|
||||
import PaginatedProducts from "@modules/store/templates/paginated-products"
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
|
||||
export default function CollectionTemplate({
|
||||
sortBy,
|
||||
collection,
|
||||
page,
|
||||
countryCode,
|
||||
}: {
|
||||
sortBy?: SortOptions
|
||||
collection: HttpTypes.StoreCollection
|
||||
page?: string
|
||||
countryCode: string
|
||||
}) {
|
||||
const pageNumber = page ? parseInt(page) : 1
|
||||
const sort = sortBy || "created_at"
|
||||
|
||||
return (
|
||||
<div className="flex flex-col small:flex-row small:items-start py-6 content-container">
|
||||
<RefinementList sortBy={sort} />
|
||||
<div className="w-full">
|
||||
<div className="mb-8 text-2xl-semi">
|
||||
<h1>{collection.title}</h1>
|
||||
</div>
|
||||
<Suspense
|
||||
fallback={
|
||||
<SkeletonProductGrid
|
||||
numberOfProducts={collection.products?.length}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<PaginatedProducts
|
||||
sortBy={sort}
|
||||
page={pageNumber}
|
||||
collectionId={collection.id}
|
||||
countryCode={countryCode}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
"use client"
|
||||
|
||||
import { convertToLocale } from "@lib/util/money"
|
||||
import React from "react"
|
||||
|
||||
type CartTotalsProps = {
|
||||
totals: {
|
||||
total?: number | null
|
||||
subtotal?: number | null
|
||||
tax_total?: number | null
|
||||
shipping_total?: number | null
|
||||
discount_total?: number | null
|
||||
gift_card_total?: number | null
|
||||
currency_code: string
|
||||
shipping_subtotal?: number | null
|
||||
}
|
||||
}
|
||||
|
||||
const CartTotals: React.FC<CartTotalsProps> = ({ totals }) => {
|
||||
const {
|
||||
currency_code,
|
||||
total,
|
||||
subtotal,
|
||||
tax_total,
|
||||
discount_total,
|
||||
gift_card_total,
|
||||
shipping_subtotal,
|
||||
} = totals
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-col gap-y-2 txt-medium text-ui-fg-subtle ">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="flex gap-x-1 items-center">
|
||||
Subtotal (excl. shipping and taxes)
|
||||
</span>
|
||||
<span data-testid="cart-subtotal" data-value={subtotal || 0}>
|
||||
{convertToLocale({ amount: subtotal ?? 0, currency_code })}
|
||||
</span>
|
||||
</div>
|
||||
{!!discount_total && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span>Discount</span>
|
||||
<span
|
||||
className="text-ui-fg-interactive"
|
||||
data-testid="cart-discount"
|
||||
data-value={discount_total || 0}
|
||||
>
|
||||
-{" "}
|
||||
{convertToLocale({ amount: discount_total ?? 0, currency_code })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
<span>Shipping</span>
|
||||
<span data-testid="cart-shipping" data-value={shipping_subtotal || 0}>
|
||||
{convertToLocale({ amount: shipping_subtotal ?? 0, currency_code })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="flex gap-x-1 items-center ">Taxes</span>
|
||||
<span data-testid="cart-taxes" data-value={tax_total || 0}>
|
||||
{convertToLocale({ amount: tax_total ?? 0, currency_code })}
|
||||
</span>
|
||||
</div>
|
||||
{!!gift_card_total && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span>Gift card</span>
|
||||
<span
|
||||
className="text-ui-fg-interactive"
|
||||
data-testid="cart-gift-card-amount"
|
||||
data-value={gift_card_total || 0}
|
||||
>
|
||||
-{" "}
|
||||
{convertToLocale({ amount: gift_card_total ?? 0, currency_code })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="h-px w-full border-b border-gray-200 my-4" />
|
||||
<div className="flex items-center justify-between text-ui-fg-base mb-2 txt-medium ">
|
||||
<span>Total</span>
|
||||
<span
|
||||
className="txt-xlarge-plus"
|
||||
data-testid="cart-total"
|
||||
data-value={total || 0}
|
||||
>
|
||||
{convertToLocale({ amount: total ?? 0, currency_code })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-px w-full border-b border-gray-200 mt-4" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CartTotals
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Checkbox, Label } from "@medusajs/ui"
|
||||
import React from "react"
|
||||
|
||||
type CheckboxProps = {
|
||||
checked?: boolean
|
||||
onChange?: () => void
|
||||
label: string
|
||||
name?: string
|
||||
'data-testid'?: string
|
||||
}
|
||||
|
||||
const CheckboxWithLabel: React.FC<CheckboxProps> = ({
|
||||
checked = true,
|
||||
onChange,
|
||||
label,
|
||||
name,
|
||||
'data-testid': dataTestId
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex items-center space-x-2 ">
|
||||
<Checkbox
|
||||
className="text-base-regular flex items-center gap-x-2"
|
||||
id="checkbox"
|
||||
role="checkbox"
|
||||
type="button"
|
||||
checked={checked}
|
||||
aria-checked={checked}
|
||||
onClick={onChange}
|
||||
name={name}
|
||||
data-testid={dataTestId}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="checkbox"
|
||||
className="!transform-none !txt-medium"
|
||||
size="large"
|
||||
>
|
||||
{label}
|
||||
</Label>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CheckboxWithLabel
|
||||
@@ -0,0 +1,42 @@
|
||||
import { deleteLineItem } from "@lib/data/cart"
|
||||
import { Spinner, Trash } from "@medusajs/icons"
|
||||
import { clx } from "@medusajs/ui"
|
||||
import { useState } from "react"
|
||||
|
||||
const DeleteButton = ({
|
||||
id,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
id: string
|
||||
children?: React.ReactNode
|
||||
className?: string
|
||||
}) => {
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
setIsDeleting(true)
|
||||
await deleteLineItem(id).catch((err) => {
|
||||
setIsDeleting(false)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clx(
|
||||
"flex items-center justify-between text-small-regular",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<button
|
||||
className="flex gap-x-1 text-ui-fg-subtle hover:text-ui-fg-base cursor-pointer"
|
||||
onClick={() => handleDelete(id)}
|
||||
>
|
||||
{isDeleting ? <Spinner className="animate-spin" /> : <Trash />}
|
||||
<span>{children}</span>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DeleteButton
|
||||
@@ -0,0 +1,9 @@
|
||||
import { clx } from "@medusajs/ui"
|
||||
|
||||
const Divider = ({ className }: { className?: string }) => (
|
||||
<div
|
||||
className={clx("h-px w-full border-b border-gray-200 mt-1", className)}
|
||||
/>
|
||||
)
|
||||
|
||||
export default Divider
|
||||
@@ -0,0 +1,60 @@
|
||||
import { EllipseMiniSolid } from "@medusajs/icons"
|
||||
import { Label, RadioGroup, Text, clx } from "@medusajs/ui"
|
||||
|
||||
type FilterRadioGroupProps = {
|
||||
title: string
|
||||
items: {
|
||||
value: string
|
||||
label: string
|
||||
}[]
|
||||
value: any
|
||||
handleChange: (...args: any[]) => void
|
||||
"data-testid"?: string
|
||||
}
|
||||
|
||||
const FilterRadioGroup = ({
|
||||
title,
|
||||
items,
|
||||
value,
|
||||
handleChange,
|
||||
"data-testid": dataTestId,
|
||||
}: FilterRadioGroupProps) => {
|
||||
return (
|
||||
<div className="flex gap-x-3 flex-col gap-y-3">
|
||||
<Text className="txt-compact-small-plus text-ui-fg-muted">{title}</Text>
|
||||
<RadioGroup data-testid={dataTestId} onValueChange={handleChange}>
|
||||
{items?.map((i) => (
|
||||
<div
|
||||
key={i.value}
|
||||
className={clx("flex gap-x-2 items-center", {
|
||||
"ml-[-23px]": i.value === value,
|
||||
})}
|
||||
>
|
||||
{i.value === value && <EllipseMiniSolid />}
|
||||
<RadioGroup.Item
|
||||
checked={i.value === value}
|
||||
className="hidden peer"
|
||||
id={i.value}
|
||||
value={i.value}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={i.value}
|
||||
className={clx(
|
||||
"!txt-compact-small !transform-none text-ui-fg-subtle hover:cursor-pointer",
|
||||
{
|
||||
"text-ui-fg-base": i.value === value,
|
||||
}
|
||||
)}
|
||||
data-testid="radio-label"
|
||||
data-active={i.value === value}
|
||||
>
|
||||
{i.label}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default FilterRadioGroup
|
||||
@@ -0,0 +1,55 @@
|
||||
// cart-totals
|
||||
export { default as CartTotals } from "./cart-totals";
|
||||
export * from "./cart-totals";
|
||||
|
||||
// checkbox
|
||||
export { default as Checkbox } from "./checkbox";
|
||||
export * from "./checkbox";
|
||||
|
||||
// delete-button
|
||||
export { default as DeleteButton } from "./delete-button";
|
||||
export * from "./delete-button";
|
||||
|
||||
// divider
|
||||
export { default as Divider } from "./divider";
|
||||
export * from "./divider";
|
||||
|
||||
// filter-radio-group
|
||||
export { default as FilterRadioGroup } from "./filter-radio-group";
|
||||
export * from "./filter-radio-group";
|
||||
|
||||
// input
|
||||
export { default as Input } from "./input";
|
||||
export * from "./input";
|
||||
|
||||
// interactive-link
|
||||
export { default as InteractiveLink } from "./interactive-link";
|
||||
export * from "./interactive-link";
|
||||
|
||||
// line-item-options
|
||||
export { default as LineItemOptions } from "./line-item-options";
|
||||
export * from "./line-item-options";
|
||||
|
||||
// line-item-price
|
||||
export { default as LineItemPrice } from "./line-item-price";
|
||||
export * from "./line-item-price";
|
||||
|
||||
// line-item-unit-price
|
||||
export { default as LineItemUnitPrice } from "./line-item-unit-price";
|
||||
export * from "./line-item-unit-price";
|
||||
|
||||
// localized-client-link
|
||||
export { default as LocalizedClientLink } from "./localized-client-link";
|
||||
export * from "./localized-client-link";
|
||||
|
||||
// modal
|
||||
export { default as Modal } from "./modal";
|
||||
export * from "./modal";
|
||||
|
||||
// native-select
|
||||
export { default as NativeSelect } from "./native-select";
|
||||
export * from "./native-select";
|
||||
|
||||
// radio
|
||||
export { default as Radio } from "./radio";
|
||||
export * from "./radio";
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Label } from "@medusajs/ui"
|
||||
import React, { useEffect, useImperativeHandle, useState } from "react"
|
||||
|
||||
import Eye from "@modules/common/icons/eye"
|
||||
import EyeOff from "@modules/common/icons/eye-off"
|
||||
|
||||
type InputProps = Omit<
|
||||
Omit<React.InputHTMLAttributes<HTMLInputElement>, "size">,
|
||||
"placeholder"
|
||||
> & {
|
||||
label: string
|
||||
errors?: Record<string, unknown>
|
||||
touched?: Record<string, unknown>
|
||||
name: string
|
||||
topLabel?: string
|
||||
}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ type, name, label, touched, required, topLabel, ...props }, ref) => {
|
||||
const inputRef = React.useRef<HTMLInputElement>(null)
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [inputType, setInputType] = useState(type)
|
||||
|
||||
useEffect(() => {
|
||||
if (type === "password" && showPassword) {
|
||||
setInputType("text")
|
||||
}
|
||||
|
||||
if (type === "password" && !showPassword) {
|
||||
setInputType("password")
|
||||
}
|
||||
}, [type, showPassword])
|
||||
|
||||
useImperativeHandle(ref, () => inputRef.current!)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full">
|
||||
{topLabel && (
|
||||
<Label className="mb-2 txt-compact-medium-plus">{topLabel}</Label>
|
||||
)}
|
||||
<div className="flex relative z-0 w-full txt-compact-medium">
|
||||
<input
|
||||
type={inputType}
|
||||
name={name}
|
||||
placeholder=" "
|
||||
required={required}
|
||||
className="pt-4 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"
|
||||
{...props}
|
||||
ref={inputRef}
|
||||
/>
|
||||
<label
|
||||
htmlFor={name}
|
||||
onClick={() => inputRef.current?.focus()}
|
||||
className="flex items-center justify-center mx-3 px-1 transition-all absolute duration-300 top-3 -z-1 origin-0 text-ui-fg-subtle"
|
||||
>
|
||||
{label}
|
||||
{required && <span className="text-rose-500">*</span>}
|
||||
</label>
|
||||
{type === "password" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="text-ui-fg-subtle px-4 focus:outline-none transition-all duration-150 outline-none focus:text-ui-fg-base absolute right-0 top-3"
|
||||
>
|
||||
{showPassword ? <Eye /> : <EyeOff />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
Input.displayName = "Input"
|
||||
|
||||
export default Input
|
||||
@@ -0,0 +1,33 @@
|
||||
import { ArrowUpRightMini } from "@medusajs/icons"
|
||||
import { Text } from "@medusajs/ui"
|
||||
import LocalizedClientLink from "../localized-client-link"
|
||||
|
||||
type InteractiveLinkProps = {
|
||||
href: string
|
||||
children?: React.ReactNode
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
const InteractiveLink = ({
|
||||
href,
|
||||
children,
|
||||
onClick,
|
||||
...props
|
||||
}: InteractiveLinkProps) => {
|
||||
return (
|
||||
<LocalizedClientLink
|
||||
className="flex gap-x-1 items-center group"
|
||||
href={href}
|
||||
onClick={onClick}
|
||||
{...props}
|
||||
>
|
||||
<Text className="text-ui-fg-interactive">{children}</Text>
|
||||
<ArrowUpRightMini
|
||||
className="group-hover:rotate-45 ease-in-out duration-150"
|
||||
color="var(--fg-interactive)"
|
||||
/>
|
||||
</LocalizedClientLink>
|
||||
)
|
||||
}
|
||||
|
||||
export default InteractiveLink
|
||||
@@ -0,0 +1,26 @@
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
import { Text } from "@medusajs/ui"
|
||||
|
||||
type LineItemOptionsProps = {
|
||||
variant: HttpTypes.StoreProductVariant | undefined
|
||||
"data-testid"?: string
|
||||
"data-value"?: HttpTypes.StoreProductVariant
|
||||
}
|
||||
|
||||
const LineItemOptions = ({
|
||||
variant,
|
||||
"data-testid": dataTestid,
|
||||
"data-value": dataValue,
|
||||
}: LineItemOptionsProps) => {
|
||||
return (
|
||||
<Text
|
||||
data-testid={dataTestid}
|
||||
data-value={dataValue}
|
||||
className="inline-block txt-medium text-ui-fg-subtle w-full overflow-hidden text-ellipsis"
|
||||
>
|
||||
Variant: {variant?.title}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
export default LineItemOptions
|
||||
@@ -0,0 +1,64 @@
|
||||
import { getPercentageDiff } from "@lib/util/get-precentage-diff"
|
||||
import { convertToLocale } from "@lib/util/money"
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
import { clx } from "@medusajs/ui"
|
||||
|
||||
type LineItemPriceProps = {
|
||||
item: HttpTypes.StoreCartLineItem | HttpTypes.StoreOrderLineItem
|
||||
style?: "default" | "tight"
|
||||
currencyCode: string
|
||||
}
|
||||
|
||||
const LineItemPrice = ({
|
||||
item,
|
||||
style = "default",
|
||||
currencyCode,
|
||||
}: LineItemPriceProps) => {
|
||||
const { total, original_total } = item
|
||||
const originalPrice = original_total
|
||||
const currentPrice = total
|
||||
const hasReducedPrice = currentPrice < originalPrice
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-x-2 text-ui-fg-subtle items-end">
|
||||
<div className="text-left">
|
||||
{hasReducedPrice && (
|
||||
<>
|
||||
<p>
|
||||
{style === "default" && (
|
||||
<span className="text-ui-fg-subtle">Original: </span>
|
||||
)}
|
||||
<span
|
||||
className="line-through text-ui-fg-muted"
|
||||
data-testid="product-original-price"
|
||||
>
|
||||
{convertToLocale({
|
||||
amount: originalPrice,
|
||||
currency_code: currencyCode,
|
||||
})}
|
||||
</span>
|
||||
</p>
|
||||
{style === "default" && (
|
||||
<span className="text-ui-fg-interactive">
|
||||
-{getPercentageDiff(originalPrice, currentPrice || 0)}%
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<span
|
||||
className={clx("text-base-regular", {
|
||||
"text-ui-fg-interactive": hasReducedPrice,
|
||||
})}
|
||||
data-testid="product-price"
|
||||
>
|
||||
{convertToLocale({
|
||||
amount: currentPrice,
|
||||
currency_code: currencyCode,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LineItemPrice
|
||||
@@ -0,0 +1,61 @@
|
||||
import { convertToLocale } from "@lib/util/money"
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
import { clx } from "@medusajs/ui"
|
||||
|
||||
type LineItemUnitPriceProps = {
|
||||
item: HttpTypes.StoreCartLineItem | HttpTypes.StoreOrderLineItem
|
||||
style?: "default" | "tight"
|
||||
currencyCode: string
|
||||
}
|
||||
|
||||
const LineItemUnitPrice = ({
|
||||
item,
|
||||
style = "default",
|
||||
currencyCode,
|
||||
}: LineItemUnitPriceProps) => {
|
||||
const { total, original_total } = item
|
||||
const hasReducedPrice = total < original_total
|
||||
|
||||
const percentage_diff = Math.round(
|
||||
((original_total - total) / original_total) * 100
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col text-ui-fg-muted justify-center h-full">
|
||||
{hasReducedPrice && (
|
||||
<>
|
||||
<p>
|
||||
{style === "default" && (
|
||||
<span className="text-ui-fg-muted">Original: </span>
|
||||
)}
|
||||
<span
|
||||
className="line-through"
|
||||
data-testid="product-unit-original-price"
|
||||
>
|
||||
{convertToLocale({
|
||||
amount: original_total / item.quantity,
|
||||
currency_code: currencyCode,
|
||||
})}
|
||||
</span>
|
||||
</p>
|
||||
{style === "default" && (
|
||||
<span className="text-ui-fg-interactive">-{percentage_diff}%</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<span
|
||||
className={clx("text-base-regular", {
|
||||
"text-ui-fg-interactive": hasReducedPrice,
|
||||
})}
|
||||
data-testid="product-unit-price"
|
||||
>
|
||||
{convertToLocale({
|
||||
amount: total / item.quantity,
|
||||
currency_code: currencyCode,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LineItemUnitPrice
|
||||
@@ -0,0 +1,32 @@
|
||||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import { useParams } from "next/navigation"
|
||||
import React from "react"
|
||||
|
||||
/**
|
||||
* Use this component to create a Next.js `<Link />` that persists the current country code in the url,
|
||||
* without having to explicitly pass it as a prop.
|
||||
*/
|
||||
const LocalizedClientLink = ({
|
||||
children,
|
||||
href,
|
||||
...props
|
||||
}: {
|
||||
children?: React.ReactNode
|
||||
href: string
|
||||
className?: string
|
||||
onClick?: () => void
|
||||
passHref?: true
|
||||
[x: string]: any
|
||||
}) => {
|
||||
const { countryCode } = useParams()
|
||||
|
||||
return (
|
||||
<Link href={`/${countryCode}${href}`} {...props}>
|
||||
{children}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export default LocalizedClientLink
|
||||
@@ -0,0 +1,118 @@
|
||||
import { Dialog, Transition } from "@headlessui/react"
|
||||
import { clx } from "@medusajs/ui"
|
||||
import React, { Fragment } from "react"
|
||||
|
||||
import { ModalProvider, useModal } from "@lib/context/modal-context"
|
||||
import X from "@modules/common/icons/x"
|
||||
|
||||
type ModalProps = {
|
||||
isOpen: boolean
|
||||
close: () => void
|
||||
size?: "small" | "medium" | "large"
|
||||
search?: boolean
|
||||
children: React.ReactNode
|
||||
'data-testid'?: string
|
||||
}
|
||||
|
||||
const Modal = ({
|
||||
isOpen,
|
||||
close,
|
||||
size = "medium",
|
||||
search = false,
|
||||
children,
|
||||
'data-testid': dataTestId
|
||||
}: ModalProps) => {
|
||||
return (
|
||||
<Transition appear show={isOpen} as={Fragment}>
|
||||
<Dialog as="div" className="relative z-[75]" onClose={close}>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-opacity-75 backdrop-blur-md h-screen" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 overflow-y-hidden">
|
||||
<div
|
||||
className={clx(
|
||||
"flex min-h-full h-full justify-center p-4 text-center",
|
||||
{
|
||||
"items-center": !search,
|
||||
"items-start": search,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<Dialog.Panel
|
||||
data-testid={dataTestId}
|
||||
className={clx(
|
||||
"flex flex-col justify-start w-full transform p-5 text-left align-middle transition-all max-h-[75vh] h-fit",
|
||||
{
|
||||
"max-w-md": size === "small",
|
||||
"max-w-xl": size === "medium",
|
||||
"max-w-3xl": size === "large",
|
||||
"bg-transparent shadow-none": search,
|
||||
"bg-white shadow-xl border rounded-rounded": !search,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<ModalProvider close={close}>{children}</ModalProvider>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
)
|
||||
}
|
||||
|
||||
const Title: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const { close } = useModal()
|
||||
|
||||
return (
|
||||
<Dialog.Title className="flex items-center justify-between">
|
||||
<div className="text-large-semi">{children}</div>
|
||||
<div>
|
||||
<button onClick={close} data-testid="close-modal-button">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</Dialog.Title>
|
||||
)
|
||||
}
|
||||
|
||||
const Description: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
return (
|
||||
<Dialog.Description className="flex text-small-regular text-ui-fg-base items-center justify-center pt-2 pb-4 h-full">
|
||||
{children}
|
||||
</Dialog.Description>
|
||||
)
|
||||
}
|
||||
|
||||
const Body: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
return <div className="flex justify-center">{children}</div>
|
||||
}
|
||||
|
||||
const Footer: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
return <div className="flex items-center justify-end gap-x-4">{children}</div>
|
||||
}
|
||||
|
||||
Modal.Title = Title
|
||||
Modal.Description = Description
|
||||
Modal.Body = Body
|
||||
Modal.Footer = Footer
|
||||
|
||||
export default Modal
|
||||
@@ -0,0 +1,74 @@
|
||||
import { ChevronUpDown } from "@medusajs/icons"
|
||||
import { clx } from "@medusajs/ui"
|
||||
import {
|
||||
SelectHTMLAttributes,
|
||||
forwardRef,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react"
|
||||
|
||||
export type NativeSelectProps = {
|
||||
placeholder?: string
|
||||
errors?: Record<string, unknown>
|
||||
touched?: Record<string, unknown>
|
||||
} & SelectHTMLAttributes<HTMLSelectElement>
|
||||
|
||||
const NativeSelect = forwardRef<HTMLSelectElement, NativeSelectProps>(
|
||||
(
|
||||
{ placeholder = "Select...", defaultValue, className, children, ...props },
|
||||
ref
|
||||
) => {
|
||||
const innerRef = useRef<HTMLSelectElement>(null)
|
||||
const [isPlaceholder, setIsPlaceholder] = useState(false)
|
||||
|
||||
useImperativeHandle<HTMLSelectElement | null, HTMLSelectElement | null>(
|
||||
ref,
|
||||
() => innerRef.current
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (innerRef.current && innerRef.current.value === "") {
|
||||
setIsPlaceholder(true)
|
||||
} else {
|
||||
setIsPlaceholder(false)
|
||||
}
|
||||
}, [innerRef.current?.value])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
onFocus={() => innerRef.current?.focus()}
|
||||
onBlur={() => innerRef.current?.blur()}
|
||||
className={clx(
|
||||
"relative flex items-center text-base-regular border border-ui-border-base bg-ui-bg-subtle rounded-md hover:bg-ui-bg-field-hover",
|
||||
className,
|
||||
{
|
||||
"text-ui-fg-muted": isPlaceholder,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<select
|
||||
ref={innerRef}
|
||||
defaultValue={defaultValue}
|
||||
{...props}
|
||||
className="appearance-none flex-1 bg-transparent border-none px-4 py-2.5 transition-colors duration-150 outline-none "
|
||||
>
|
||||
<option disabled value="">
|
||||
{placeholder}
|
||||
</option>
|
||||
{children}
|
||||
</select>
|
||||
<span className="absolute right-4 inset-y-0 flex items-center pointer-events-none ">
|
||||
<ChevronUpDown />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
NativeSelect.displayName = "NativeSelect"
|
||||
|
||||
export default NativeSelect
|
||||
@@ -0,0 +1,27 @@
|
||||
const Radio = ({ checked, 'data-testid': dataTestId }: { checked: boolean, 'data-testid'?: string }) => {
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked="true"
|
||||
data-state={checked ? "checked" : "unchecked"}
|
||||
className="group relative flex h-5 w-5 items-center justify-center outline-none"
|
||||
data-testid={dataTestId || 'radio-button'}
|
||||
>
|
||||
<div className="shadow-borders-base group-hover:shadow-borders-strong-with-shadow bg-ui-bg-base group-data-[state=checked]:bg-ui-bg-interactive group-data-[state=checked]:shadow-borders-interactive group-focus:!shadow-borders-interactive-with-focus group-disabled:!bg-ui-bg-disabled group-disabled:!shadow-borders-base flex h-[14px] w-[14px] items-center justify-center rounded-full transition-all">
|
||||
{checked && (
|
||||
<span
|
||||
data-state={checked ? "checked" : "unchecked"}
|
||||
className="group flex items-center justify-center"
|
||||
>
|
||||
<div className="bg-ui-bg-base shadow-details-contrast-on-bg-interactive group-disabled:bg-ui-fg-disabled rounded-full group-disabled:shadow-none h-1.5 w-1.5"></div>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Radio
|
||||
@@ -0,0 +1,37 @@
|
||||
import React from "react"
|
||||
|
||||
import { IconProps } from "types/icon"
|
||||
|
||||
const Back: React.FC<IconProps> = ({
|
||||
size = "16",
|
||||
color = "currentColor",
|
||||
...attributes
|
||||
}) => {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...attributes}
|
||||
>
|
||||
<path
|
||||
d="M4 3.5V9.5H10"
|
||||
stroke={color}
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M4.09714 14.014C4.28641 15.7971 4.97372 16.7931 6.22746 18.0783C7.4812 19.3635 9.13155 20.1915 10.9137 20.4293C12.6958 20.6671 14.5064 20.301 16.0549 19.3898C17.6033 18.4785 18.8 17.0749 19.4527 15.4042C20.1054 13.7335 20.1764 11.8926 19.6543 10.1769C19.1322 8.46112 18.0472 6.97003 16.5735 5.94286C15.0997 4.91569 13.3227 4.412 11.5275 4.51261C9.73236 4.61323 8.02312 5.31232 6.6741 6.4977L4 8.89769"
|
||||
stroke={color}
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default Back
|
||||
@@ -0,0 +1,26 @@
|
||||
import React from "react"
|
||||
|
||||
import { IconProps } from "types/icon"
|
||||
|
||||
const Ideal: React.FC<IconProps> = ({
|
||||
size = "20",
|
||||
color = "currentColor",
|
||||
...attributes
|
||||
}) => {
|
||||
return (
|
||||
<svg
|
||||
width="24px"
|
||||
height="24px"
|
||||
viewBox="0 0 24 24"
|
||||
role="img"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill={color}
|
||||
{...attributes}
|
||||
>
|
||||
<title>Bancontact icon</title>
|
||||
<path d="M21.385 9.768h-7.074l-4.293 5.022H1.557L3.84 12.1H1.122C.505 12.1 0 12.616 0 13.25v2.428c0 .633.505 1.15 1.122 1.15h12.933c.617 0 1.46-.384 1.874-.854l1.956-2.225 3.469-3.946.031-.035zm-1.123 1.279l-.751.855.75-.855zm2.616-3.875H9.982c-.617 0-1.462.384-1.876.853l-5.49 6.208h7.047l4.368-5.02h8.424l-2.263 2.689h2.686c.617 0 1.122-.518 1.122-1.151V8.323c0-.633-.505-1.15-1.122-1.15zm-1.87 3.024l-.374.427-.1.114.474-.54z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default Ideal
|
||||
@@ -0,0 +1,29 @@
|
||||
import React from "react";
|
||||
import { IconProps } from "../../../types/icon";
|
||||
|
||||
const ChevronDown: React.FC<IconProps> = ({
|
||||
size = "16",
|
||||
color = "currentColor",
|
||||
...attributes
|
||||
}) => {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...attributes}
|
||||
>
|
||||
<path
|
||||
d="M4 6L8 10L12 6"
|
||||
stroke={color}
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChevronDown;
|
||||
@@ -0,0 +1,37 @@
|
||||
import React from "react"
|
||||
|
||||
import { IconProps } from "types/icon"
|
||||
|
||||
const EyeOff: React.FC<IconProps> = ({
|
||||
size = "20",
|
||||
color = "currentColor",
|
||||
...attributes
|
||||
}) => {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...attributes}
|
||||
>
|
||||
<path
|
||||
d="M8.56818 4.70906C9.0375 4.59921 9.518 4.54429 10 4.54543C14.7727 4.54543 17.5 9.99997 17.5 9.99997C17.0861 10.7742 16.5925 11.5032 16.0273 12.175M11.4455 11.4454C11.2582 11.6464 11.0324 11.8076 10.7815 11.9194C10.5306 12.0312 10.2597 12.0913 9.98506 12.0961C9.71042 12.101 9.43761 12.0505 9.18292 11.9476C8.92822 11.8447 8.69686 11.6916 8.50262 11.4973C8.30839 11.3031 8.15527 11.0718 8.05239 10.8171C7.94952 10.5624 7.899 10.2896 7.90384 10.0149C7.90869 9.74027 7.9688 9.46941 8.0806 9.2185C8.19239 8.9676 8.35358 8.74178 8.55455 8.55452M14.05 14.05C12.8845 14.9384 11.4653 15.4306 10 15.4545C5.22727 15.4545 2.5 9.99997 2.5 9.99997C3.34811 8.41945 4.52441 7.03857 5.95 5.94997L14.05 14.05Z"
|
||||
stroke={color}
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M2.5 2.5L17.5 17.5"
|
||||
stroke={color}
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default EyeOff
|
||||
@@ -0,0 +1,37 @@
|
||||
import React from "react"
|
||||
|
||||
import { IconProps } from "types/icon"
|
||||
|
||||
const Eye: React.FC<IconProps> = ({
|
||||
size = "20",
|
||||
color = "currentColor",
|
||||
...attributes
|
||||
}) => {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...attributes}
|
||||
>
|
||||
<path
|
||||
d="M2.5 9.99992C2.5 9.99992 5.22727 4.58325 10 4.58325C14.7727 4.58325 17.5 9.99992 17.5 9.99992C17.5 9.99992 14.7727 15.4166 10 15.4166C5.22727 15.4166 2.5 9.99992 2.5 9.99992Z"
|
||||
stroke={color}
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M9.99965 12.074C11.145 12.074 12.0735 11.1455 12.0735 10.0001C12.0735 8.85477 11.145 7.92627 9.99965 7.92627C8.85428 7.92627 7.92578 8.85477 7.92578 10.0001C7.92578 11.1455 8.85428 12.074 9.99965 12.074Z"
|
||||
stroke={color}
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default Eye
|
||||
@@ -0,0 +1,65 @@
|
||||
import React from "react"
|
||||
|
||||
import { IconProps } from "types/icon"
|
||||
|
||||
const FastDelivery: React.FC<IconProps> = ({
|
||||
size = "16",
|
||||
color = "currentColor",
|
||||
...attributes
|
||||
}) => {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...attributes}
|
||||
>
|
||||
<path
|
||||
d="M3.63462 7.35205H2.70508"
|
||||
stroke={color}
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M4.56416 4.56348H2.70508"
|
||||
stroke={color}
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M16.6483 19.4365H3.63477"
|
||||
stroke={color}
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M16.9034 4.56348L15.9868 7.61888C15.8688 8.01207 15.5063 8.28164 15.0963 8.28164H12.2036C11.5808 8.28164 11.1346 7.68115 11.3131 7.08532L12.0697 4.56348"
|
||||
stroke={color}
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M8.28125 12.9297H10.2612"
|
||||
stroke={color}
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M17.055 15.718H7.21305C5.71835 15.718 4.64659 14.2772 5.07603 12.8457L7.08384 6.15299C7.36735 5.20951 8.23554 4.56348 9.22086 4.56348H19.0638C20.5585 4.56348 21.6302 6.00426 21.2008 7.43576L19.193 14.1284C18.9095 15.0719 18.0403 15.718 17.055 15.718Z"
|
||||
stroke={color}
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default FastDelivery
|
||||
@@ -0,0 +1,26 @@
|
||||
import React from "react"
|
||||
|
||||
import { IconProps } from "types/icon"
|
||||
|
||||
const Ideal: React.FC<IconProps> = ({
|
||||
size = "20",
|
||||
color = "currentColor",
|
||||
...attributes
|
||||
}) => {
|
||||
return (
|
||||
<svg
|
||||
width="20px"
|
||||
height="20px"
|
||||
viewBox="0 0 24 24"
|
||||
role="img"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill={color}
|
||||
{...attributes}
|
||||
>
|
||||
<title>iDEAL icon</title>
|
||||
<path d="M.975 2.61v18.782h11.411c6.89 0 10.64-3.21 10.64-9.415 0-6.377-4.064-9.367-10.64-9.367H.975zm11.411-.975C22.491 1.635 24 8.115 24 11.977c0 6.7-4.124 10.39-11.614 10.39H0V1.635h12.386z M2.506 13.357h3.653v6.503H2.506z M6.602 10.082a2.27 2.27 0 1 1-4.54 0 2.27 2.27 0 0 1 4.54 0m1.396-1.057v2.12h.65c.45 0 .867-.13.867-1.077 0-.924-.463-1.043-.867-1.043h-.65zm10.85-1.054h1.053v3.174h1.56c-.428-5.758-4.958-7.002-9.074-7.002H7.999v3.83h.65c1.183 0 1.92.803 1.92 2.095 0 1.333-.719 2.129-1.92 2.129h-.65v7.665h4.388c6.692 0 9.021-3.107 9.103-7.665h-2.64V7.97zm-2.93 2.358h.76l-.348-1.195h-.063l-.35 1.195zm-1.643 1.87l1.274-4.228h1.497l1.274 4.227h-1.095l-.239-.818H15.61l-.24.818h-1.095zm-.505-1.054v1.052h-2.603V7.973h2.519v1.052h-1.467v.49h1.387v1.05H12.22v.58h1.55z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default Ideal
|
||||
@@ -0,0 +1,71 @@
|
||||
// back
|
||||
export { default as BackIcon } from "./back";
|
||||
export * from "./back";
|
||||
|
||||
// bancontact
|
||||
export { default as BancontactIcon } from "./bancontact";
|
||||
export * from "./bancontact";
|
||||
|
||||
// chevron-down
|
||||
export { default as ChevronDownIcon } from "./chevron-down";
|
||||
export * from "./chevron-down";
|
||||
|
||||
// eye-off
|
||||
export { default as EyeOffIcon } from "./eye-off";
|
||||
export * from "./eye-off";
|
||||
|
||||
// eye
|
||||
export { default as EyeIcon } from "./eye";
|
||||
export * from "./eye";
|
||||
|
||||
// fast-delivery
|
||||
export { default as FastDeliveryIcon } from "./fast-delivery";
|
||||
export * from "./fast-delivery";
|
||||
|
||||
// ideal
|
||||
export { default as IdealIcon } from "./ideal";
|
||||
export * from "./ideal";
|
||||
|
||||
// map-pin
|
||||
export { default as MapPinIcon } from "./map-pin";
|
||||
export * from "./map-pin";
|
||||
|
||||
// medusa
|
||||
export { default as MedusaIcon } from "./medusa";
|
||||
export * from "./medusa";
|
||||
|
||||
// nextjs
|
||||
export { default as NextjsIcon } from "./nextjs";
|
||||
export * from "./nextjs";
|
||||
|
||||
// package
|
||||
export { default as PackageIcon } from "./package";
|
||||
export * from "./package";
|
||||
|
||||
// paypal
|
||||
export { default as PaypalIcon } from "./paypal";
|
||||
export * from "./paypal";
|
||||
|
||||
// placeholder-image
|
||||
export { default as PlaceholderImageIcon } from "./placeholder-image";
|
||||
export * from "./placeholder-image";
|
||||
|
||||
// refresh
|
||||
export { default as RefreshIcon } from "./refresh";
|
||||
export * from "./refresh";
|
||||
|
||||
// spinner
|
||||
export { default as SpinnerIcon } from "./spinner";
|
||||
export * from "./spinner";
|
||||
|
||||
// trash
|
||||
export { default as TrashIcon } from "./trash";
|
||||
export * from "./trash";
|
||||
|
||||
// user
|
||||
export { default as UserIcon } from "./user";
|
||||
export * from "./user";
|
||||
|
||||
// x
|
||||
export { default as XIcon } from "./x";
|
||||
export * from "./x";
|
||||
@@ -0,0 +1,37 @@
|
||||
import React from "react"
|
||||
|
||||
import { IconProps } from "types/icon"
|
||||
|
||||
const MapPin: React.FC<IconProps> = ({
|
||||
size = "20",
|
||||
color = "currentColor",
|
||||
...attributes
|
||||
}) => {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...attributes}
|
||||
>
|
||||
<path
|
||||
d="M15.8337 8.63636C15.8337 13.4091 10.0003 17.5 10.0003 17.5C10.0003 17.5 4.16699 13.4091 4.16699 8.63636C4.16699 7.0089 4.78157 5.44809 5.87554 4.2973C6.9695 3.14651 8.45323 2.5 10.0003 2.5C11.5474 2.5 13.0312 3.14651 14.1251 4.2973C15.2191 5.44809 15.8337 7.0089 15.8337 8.63636Z"
|
||||
stroke={color}
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M9.99967 9.99996C10.9201 9.99996 11.6663 9.25377 11.6663 8.33329C11.6663 7.41282 10.9201 6.66663 9.99967 6.66663C9.0792 6.66663 8.33301 7.41282 8.33301 8.33329C8.33301 9.25377 9.0792 9.99996 9.99967 9.99996Z"
|
||||
stroke={color}
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default MapPin
|
||||
@@ -0,0 +1,27 @@
|
||||
import React from "react"
|
||||
|
||||
import { IconProps } from "types/icon"
|
||||
|
||||
const Medusa: React.FC<IconProps> = ({
|
||||
size = "20",
|
||||
color = "#9CA3AF",
|
||||
...attributes
|
||||
}) => {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 18 18"
|
||||
fill="none"
|
||||
{...attributes}
|
||||
>
|
||||
<path
|
||||
d="M15.2447 2.92183L11.1688 0.576863C9.83524 -0.192288 8.20112 -0.192288 6.86753 0.576863L2.77285 2.92183C1.45804 3.69098 0.631592 5.11673 0.631592 6.63627V11.345C0.631592 12.8833 1.45804 14.2903 2.77285 15.0594L6.84875 17.4231C8.18234 18.1923 9.81646 18.1923 11.15 17.4231L15.2259 15.0594C16.5595 14.2903 17.3672 12.8833 17.3672 11.345V6.63627C17.4048 5.11673 16.5783 3.69098 15.2447 2.92183ZM9.00879 13.1834C6.69849 13.1834 4.82019 11.3075 4.82019 9C4.82019 6.69255 6.69849 4.81657 9.00879 4.81657C11.3191 4.81657 13.2162 6.69255 13.2162 9C13.2162 11.3075 11.3379 13.1834 9.00879 13.1834Z"
|
||||
fill={color}
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default Medusa
|
||||
@@ -0,0 +1,27 @@
|
||||
import React from "react"
|
||||
|
||||
import { IconProps } from "types/icon"
|
||||
|
||||
const NextJs: React.FC<IconProps> = ({
|
||||
size = "20",
|
||||
color = "#9CA3AF",
|
||||
...attributes
|
||||
}) => {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 18 18"
|
||||
fill="none"
|
||||
{...attributes}
|
||||
>
|
||||
<path
|
||||
d="M8.41117 0.0131402C8.3725 0.0166554 8.24946 0.0289589 8.13873 0.0377471C5.58488 0.267998 3.19273 1.64599 1.67764 3.76395C0.833977 4.94157 0.294381 6.27737 0.090495 7.69227C0.0184318 8.18617 0.00964355 8.33206 0.00964355 9.00172C0.00964355 9.67138 0.0184318 9.81726 0.090495 10.3112C0.579119 13.6876 2.98181 16.5244 6.24048 17.5755C6.82402 17.7636 7.43919 17.8919 8.13873 17.9692C8.41117 17.9991 9.58879 17.9991 9.86122 17.9692C11.0687 17.8356 12.0917 17.5368 13.1006 17.0218C13.2552 16.9427 13.2851 16.9216 13.264 16.9041C13.25 16.8935 12.5908 16.0094 11.7999 14.9408L10.3621 12.9986L8.56057 10.3323C7.56926 8.86638 6.75371 7.66767 6.74668 7.66767C6.73965 7.66591 6.73262 8.85056 6.7291 10.2971C6.72383 12.8299 6.72207 12.9318 6.69044 12.9916C6.64474 13.0777 6.60958 13.1128 6.53576 13.1515C6.47952 13.1796 6.43031 13.1849 6.1649 13.1849H5.86083L5.77998 13.1339C5.72725 13.1005 5.68858 13.0566 5.66222 13.0056L5.62531 12.9265L5.62882 9.40246L5.63409 5.87663L5.68858 5.80808C5.7167 5.77117 5.77646 5.72372 5.81865 5.70087C5.89071 5.66571 5.91883 5.6622 6.2229 5.6622C6.58146 5.6622 6.64122 5.67626 6.73438 5.7782C6.76074 5.80632 7.73623 7.27571 8.90331 9.04566C10.0704 10.8156 11.6663 13.2324 12.4502 14.4188L13.8739 16.5754L13.946 16.5279C14.584 16.1131 15.2589 15.5226 15.7933 14.9074C16.9305 13.6015 17.6634 12.009 17.9095 10.3112C17.9815 9.81726 17.9903 9.67138 17.9903 9.00172C17.9903 8.33206 17.9815 8.18617 17.9095 7.69227C17.4208 4.31585 15.0181 1.47901 11.7595 0.427943C11.1847 0.241633 10.5731 0.113326 9.88758 0.0359895C9.71885 0.0184131 8.55705 -0.000920974 8.41117 0.0131402ZM12.0917 5.45128C12.176 5.49346 12.2446 5.57432 12.2692 5.65868C12.2832 5.70438 12.2868 6.68163 12.2832 8.88395L12.278 12.0442L11.7208 11.19L11.1619 10.3358V8.03853C11.1619 6.55332 11.1689 5.71844 11.1795 5.67802C11.2076 5.57959 11.2691 5.50225 11.3535 5.45655C11.4255 5.41964 11.4519 5.41613 11.7278 5.41613C11.988 5.41613 12.0337 5.41964 12.0917 5.45128Z"
|
||||
fill={color}
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default NextJs
|
||||
@@ -0,0 +1,44 @@
|
||||
import React from "react"
|
||||
|
||||
import { IconProps } from "types/icon"
|
||||
|
||||
const Package: React.FC<IconProps> = ({
|
||||
size = "20",
|
||||
color = "currentColor",
|
||||
...attributes
|
||||
}) => {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...attributes}
|
||||
>
|
||||
<path
|
||||
d="M13.3634 8.02695L6.73047 4.21271"
|
||||
stroke={color}
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M16.724 12.9577V6.98101C16.7237 6.71899 16.6546 6.46164 16.5234 6.23479C16.3923 6.00794 16.2038 5.81956 15.9769 5.68855L10.7473 2.70018C10.5201 2.56904 10.2625 2.5 10.0002 2.5C9.7379 2.5 9.48024 2.56904 9.25309 2.70018L4.02346 5.68855C3.79654 5.81956 3.60806 6.00794 3.47693 6.23479C3.3458 6.46164 3.27664 6.71899 3.27637 6.98101V12.9577C3.27664 13.2198 3.3458 13.4771 3.47693 13.704C3.60806 13.9308 3.79654 14.1192 4.02346 14.2502L9.25309 17.2386C9.48024 17.3697 9.7379 17.4388 10.0002 17.4388C10.2625 17.4388 10.5201 17.3697 10.7473 17.2386L15.9769 14.2502C16.2038 14.1192 16.3923 13.9308 16.5234 13.704C16.6546 13.4771 16.7237 13.2198 16.724 12.9577Z"
|
||||
stroke={color}
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M3.47852 6.20404L10.0006 9.97685L16.5227 6.20404"
|
||||
stroke={color}
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default Package
|
||||
@@ -0,0 +1,30 @@
|
||||
const PayPal = () => {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
height="20"
|
||||
width="20"
|
||||
viewBox="0 0 26 25"
|
||||
id="paypalIcon"
|
||||
>
|
||||
<path
|
||||
fill="none"
|
||||
stroke="#303c42"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M6.9 20.5H2c-.6 0-.5-.1-.5-.5s2.9-18 3-18.5.5-1 1-1h10c2.8 0 5 2.2 5 5h0c0 4.4-3.6 8-8 8H7.9"
|
||||
></path>
|
||||
<path
|
||||
fill="none"
|
||||
stroke="#303c42"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M7 23.5c-.3 0-.5-.2-.5-.5 0 0 0 0 0 0 0-.3 2.4-16 2.5-16.5s.3-1 1-1h7.5c2.8 0 5 2.2 5 5h0c0 3.9-3.1 7-7 7h-2l-1 6H7z"
|
||||
></path>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default PayPal
|
||||
@@ -0,0 +1,44 @@
|
||||
import React from "react"
|
||||
|
||||
import { IconProps } from "types/icon"
|
||||
|
||||
const PlaceholderImage: React.FC<IconProps> = ({
|
||||
size = "20",
|
||||
color = "currentColor",
|
||||
...attributes
|
||||
}) => {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...attributes}
|
||||
>
|
||||
<path
|
||||
d="M15.3141 3.16699H4.68453C3.84588 3.16699 3.16602 3.84685 3.16602 4.6855V15.3151C3.16602 16.1537 3.84588 16.8336 4.68453 16.8336H15.3141C16.1527 16.8336 16.8326 16.1537 16.8326 15.3151V4.6855C16.8326 3.84685 16.1527 3.16699 15.3141 3.16699Z"
|
||||
stroke={color}
|
||||
strokeWidth="1.53749"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M7.91699 9.16699C8.60735 9.16699 9.16699 8.60735 9.16699 7.91699C9.16699 7.22664 8.60735 6.66699 7.91699 6.66699C7.22664 6.66699 6.66699 7.22664 6.66699 7.91699C6.66699 8.60735 7.22664 9.16699 7.91699 9.16699Z"
|
||||
stroke={color}
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M16.6667 12.5756L13.0208 9.1665L5 16.6665"
|
||||
stroke={color}
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default PlaceholderImage
|
||||
@@ -0,0 +1,51 @@
|
||||
import React from "react"
|
||||
|
||||
import { IconProps } from "types/icon"
|
||||
|
||||
const Refresh: React.FC<IconProps> = ({
|
||||
size = "16",
|
||||
color = "currentColor",
|
||||
...attributes
|
||||
}) => {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...attributes}
|
||||
>
|
||||
<path
|
||||
d="M19.8007 3.33301V8.53308H14.6006"
|
||||
stroke={color}
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M4.2002 12C4.20157 10.4949 4.63839 9.02228 5.45797 7.75984C6.27755 6.4974 7.44488 5.49905 8.81917 4.8852C10.1935 4.27135 11.716 4.06823 13.2031 4.30034C14.6903 4.53244 16.0785 5.18986 17.2004 6.19329L19.8004 8.53332"
|
||||
stroke={color}
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M4.2002 20.6669V15.4668H9.40027"
|
||||
stroke={color}
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M19.8004 12C19.799 13.5051 19.3622 14.9778 18.5426 16.2402C17.7231 17.5026 16.5557 18.501 15.1814 19.1148C13.8072 19.7287 12.2846 19.9318 10.7975 19.6997C9.31033 19.4676 7.9221 18.8102 6.80023 17.8067L4.2002 15.4667"
|
||||
stroke={color}
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default Refresh
|
||||
@@ -0,0 +1,37 @@
|
||||
import React from "react"
|
||||
|
||||
import { IconProps } from "types/icon"
|
||||
|
||||
const Spinner: React.FC<IconProps> = ({
|
||||
size = "16",
|
||||
color = "currentColor",
|
||||
...attributes
|
||||
}) => {
|
||||
return (
|
||||
<svg
|
||||
className="animate-spin"
|
||||
width={size}
|
||||
height={size}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
{...attributes}
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke={color}
|
||||
strokeWidth="4"
|
||||
></circle>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill={color}
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default Spinner
|
||||
@@ -0,0 +1,51 @@
|
||||
import React from "react"
|
||||
|
||||
import { IconProps } from "types/icon"
|
||||
|
||||
const Trash: React.FC<IconProps> = ({
|
||||
size = "16",
|
||||
color = "currentColor",
|
||||
...attributes
|
||||
}) => {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...attributes}
|
||||
>
|
||||
<path
|
||||
d="M3.33301 5.49054H4.81449H16.6663"
|
||||
stroke={color}
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M7.14286 5.5V4C7.14286 3.60218 7.29337 3.22064 7.56128 2.93934C7.82919 2.65804 8.19255 2.5 8.57143 2.5H11.4286C11.8075 2.5 12.1708 2.65804 12.4387 2.93934C12.7066 3.22064 12.8571 3.60218 12.8571 4V5.5M15 5.5V16C15 16.3978 14.8495 16.7794 14.5816 17.0607C14.3137 17.342 13.9503 17.5 13.5714 17.5H6.42857C6.04969 17.5 5.68633 17.342 5.41842 17.0607C5.15051 16.7794 5 16.3978 5 16V5.5H15Z"
|
||||
stroke={color}
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M8.33203 9.23724V13.4039"
|
||||
stroke={color}
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M11.666 9.23724V13.4039"
|
||||
stroke={color}
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default Trash
|
||||
@@ -0,0 +1,37 @@
|
||||
import React from "react"
|
||||
|
||||
import { IconProps } from "types/icon"
|
||||
|
||||
const User: React.FC<IconProps> = ({
|
||||
size = "16",
|
||||
color = "currentColor",
|
||||
...attributes
|
||||
}) => {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...attributes}
|
||||
>
|
||||
<path
|
||||
d="M16.6663 18V16.3333C16.6663 15.4493 16.3152 14.6014 15.69 13.9763C15.0649 13.3512 14.2171 13 13.333 13H6.66634C5.78229 13 4.93444 13.3512 4.30932 13.9763C3.6842 14.6014 3.33301 15.4493 3.33301 16.3333V18"
|
||||
stroke={color}
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M10.0003 9.66667C11.8413 9.66667 13.3337 8.17428 13.3337 6.33333C13.3337 4.49238 11.8413 3 10.0003 3C8.15938 3 6.66699 4.49238 6.66699 6.33333C6.66699 8.17428 8.15938 9.66667 10.0003 9.66667Z"
|
||||
stroke={color}
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default User
|
||||
@@ -0,0 +1,37 @@
|
||||
import React from "react"
|
||||
|
||||
import { IconProps } from "types/icon"
|
||||
|
||||
const X: React.FC<IconProps> = ({
|
||||
size = "20",
|
||||
color = "currentColor",
|
||||
...attributes
|
||||
}) => {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...attributes}
|
||||
>
|
||||
<path
|
||||
d="M15 5L5 15"
|
||||
stroke={color}
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M5 5L15 15"
|
||||
stroke={color}
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default X
|
||||
@@ -0,0 +1,16 @@
|
||||
import { HttpTypes } from "@medusajs/types";
|
||||
import ProductRail from "./product-rail";
|
||||
|
||||
export default async function FeaturedProducts({
|
||||
collections,
|
||||
region,
|
||||
}: {
|
||||
collections: HttpTypes.StoreCollection[];
|
||||
region: HttpTypes.StoreRegion;
|
||||
}) {
|
||||
return collections.map((collection) => (
|
||||
<li key={collection.id}>
|
||||
<ProductRail collection={collection} region={region} />
|
||||
</li>
|
||||
));
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { listProducts } from "@lib/data/products";
|
||||
import { HttpTypes } from "@medusajs/types";
|
||||
import { Text } from "@medusajs/ui";
|
||||
|
||||
import InteractiveLink from "@modules/common/components/interactive-link";
|
||||
import ProductPreview from "@modules/products/components/product-preview";
|
||||
|
||||
export default async function ProductRail({
|
||||
collection,
|
||||
region,
|
||||
}: {
|
||||
collection: HttpTypes.StoreCollection;
|
||||
region: HttpTypes.StoreRegion;
|
||||
}) {
|
||||
const {
|
||||
response: { products: pricedProducts },
|
||||
} = await listProducts({
|
||||
regionId: region.id,
|
||||
queryParams: {
|
||||
collection_id: collection.id,
|
||||
fields: "*variants.calculated_price",
|
||||
},
|
||||
});
|
||||
|
||||
if (!pricedProducts) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="content-container py-12 small:py-24">
|
||||
<div className="flex justify-between mb-8">
|
||||
<Text className="txt-xlarge">{collection.title}</Text>
|
||||
<InteractiveLink href={`/collections/${collection.handle}`}>
|
||||
View all
|
||||
</InteractiveLink>
|
||||
</div>
|
||||
<ul className="grid grid-cols-2 small:grid-cols-3 gap-x-6 gap-y-24 small:gap-y-36">
|
||||
{pricedProducts &&
|
||||
pricedProducts.map((product) => (
|
||||
<li key={product.id}>
|
||||
<ProductPreview product={product} region={region} isFeatured />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Github } from "@medusajs/icons"
|
||||
import { Button, Heading } from "@medusajs/ui"
|
||||
|
||||
const Hero = () => {
|
||||
return (
|
||||
<div className="h-[75vh] w-full border-b border-ui-border-base relative bg-ui-bg-subtle">
|
||||
<div className="absolute inset-0 z-10 flex flex-col justify-center items-center text-center small:p-32 gap-6">
|
||||
<span>
|
||||
<Heading
|
||||
level="h1"
|
||||
className="text-3xl leading-10 text-ui-fg-base font-normal"
|
||||
>
|
||||
Ecommerce Starter Template
|
||||
</Heading>
|
||||
<Heading
|
||||
level="h2"
|
||||
className="text-3xl leading-10 text-ui-fg-subtle font-normal"
|
||||
>
|
||||
Powered by Medusa and Next.js
|
||||
</Heading>
|
||||
</span>
|
||||
<a
|
||||
href="https://github.com/medusajs/nextjs-starter-medusa"
|
||||
target="_blank"
|
||||
>
|
||||
<Button variant="secondary">
|
||||
View on GitHub
|
||||
<Github />
|
||||
</Button>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Hero
|
||||
@@ -0,0 +1,8 @@
|
||||
import { retrieveCart } from "@lib/data/cart"
|
||||
import CartDropdown from "../cart-dropdown"
|
||||
|
||||
export default async function CartButton() {
|
||||
const cart = await retrieveCart().catch(() => null)
|
||||
|
||||
return <CartDropdown cart={cart} />
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
Popover,
|
||||
PopoverButton,
|
||||
PopoverPanel,
|
||||
Transition,
|
||||
} from "@headlessui/react"
|
||||
import { convertToLocale } from "@lib/util/money"
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
import { Button } from "@medusajs/ui"
|
||||
import DeleteButton from "@modules/common/components/delete-button"
|
||||
import LineItemOptions from "@modules/common/components/line-item-options"
|
||||
import LineItemPrice from "@modules/common/components/line-item-price"
|
||||
import LocalizedClientLink from "@modules/common/components/localized-client-link"
|
||||
import Thumbnail from "@modules/products/components/thumbnail"
|
||||
import { usePathname } from "next/navigation"
|
||||
import { Fragment, useEffect, useRef, useState } from "react"
|
||||
|
||||
const CartDropdown = ({
|
||||
cart: cartState,
|
||||
}: {
|
||||
cart?: HttpTypes.StoreCart | null
|
||||
}) => {
|
||||
const [activeTimer, setActiveTimer] = useState<NodeJS.Timer | undefined>(
|
||||
undefined
|
||||
)
|
||||
const [cartDropdownOpen, setCartDropdownOpen] = useState(false)
|
||||
|
||||
const open = () => setCartDropdownOpen(true)
|
||||
const close = () => setCartDropdownOpen(false)
|
||||
|
||||
const totalItems =
|
||||
cartState?.items?.reduce((acc, item) => {
|
||||
return acc + item.quantity
|
||||
}, 0) || 0
|
||||
|
||||
const subtotal = cartState?.subtotal ?? 0
|
||||
const itemRef = useRef<number>(totalItems || 0)
|
||||
|
||||
const timedOpen = () => {
|
||||
open()
|
||||
|
||||
const timer = setTimeout(close, 5000)
|
||||
|
||||
setActiveTimer(timer)
|
||||
}
|
||||
|
||||
const openAndCancel = () => {
|
||||
if (activeTimer) {
|
||||
clearTimeout(activeTimer)
|
||||
}
|
||||
|
||||
open()
|
||||
}
|
||||
|
||||
// Clean up the timer when the component unmounts
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (activeTimer) {
|
||||
clearTimeout(activeTimer)
|
||||
}
|
||||
}
|
||||
}, [activeTimer])
|
||||
|
||||
const pathname = usePathname()
|
||||
|
||||
// open cart dropdown when modifying the cart items, but only if we're not on the cart page
|
||||
useEffect(() => {
|
||||
if (itemRef.current !== totalItems && !pathname.includes("/cart")) {
|
||||
timedOpen()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [totalItems, itemRef.current])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="h-full z-50"
|
||||
onMouseEnter={openAndCancel}
|
||||
onMouseLeave={close}
|
||||
>
|
||||
<Popover className="relative h-full">
|
||||
<PopoverButton className="h-full">
|
||||
<LocalizedClientLink
|
||||
className="hover:text-ui-fg-base"
|
||||
href="/cart"
|
||||
data-testid="nav-cart-link"
|
||||
>{`Cart (${totalItems})`}</LocalizedClientLink>
|
||||
</PopoverButton>
|
||||
<Transition
|
||||
show={cartDropdownOpen}
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-200"
|
||||
enterFrom="opacity-0 translate-y-1"
|
||||
enterTo="opacity-100 translate-y-0"
|
||||
leave="transition ease-in duration-150"
|
||||
leaveFrom="opacity-100 translate-y-0"
|
||||
leaveTo="opacity-0 translate-y-1"
|
||||
>
|
||||
<PopoverPanel
|
||||
static
|
||||
className="hidden small:block absolute top-[calc(100%+1px)] right-0 bg-white border-x border-b border-gray-200 w-[420px] text-ui-fg-base"
|
||||
data-testid="nav-cart-dropdown"
|
||||
>
|
||||
<div className="p-4 flex items-center justify-center">
|
||||
<h3 className="text-large-semi">Cart</h3>
|
||||
</div>
|
||||
{cartState && cartState.items?.length ? (
|
||||
<>
|
||||
<div className="overflow-y-scroll max-h-[402px] px-4 grid grid-cols-1 gap-y-8 no-scrollbar p-px">
|
||||
{cartState.items
|
||||
.sort((a, b) => {
|
||||
return (a.created_at ?? "") > (b.created_at ?? "")
|
||||
? -1
|
||||
: 1
|
||||
})
|
||||
.map((item) => (
|
||||
<div
|
||||
className="grid grid-cols-[122px_1fr] gap-x-4"
|
||||
key={item.id}
|
||||
data-testid="cart-item"
|
||||
>
|
||||
<LocalizedClientLink
|
||||
href={`/products/${item.product_handle}`}
|
||||
className="w-24"
|
||||
>
|
||||
<Thumbnail
|
||||
thumbnail={item.thumbnail}
|
||||
images={item.variant?.product?.images}
|
||||
size="square"
|
||||
/>
|
||||
</LocalizedClientLink>
|
||||
<div className="flex flex-col justify-between flex-1">
|
||||
<div className="flex flex-col flex-1">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex flex-col overflow-ellipsis whitespace-nowrap mr-4 w-[180px]">
|
||||
<h3 className="text-base-regular overflow-hidden text-ellipsis">
|
||||
<LocalizedClientLink
|
||||
href={`/products/${item.product_handle}`}
|
||||
data-testid="product-link"
|
||||
>
|
||||
{item.title}
|
||||
</LocalizedClientLink>
|
||||
</h3>
|
||||
<LineItemOptions
|
||||
variant={item.variant}
|
||||
data-testid="cart-item-variant"
|
||||
data-value={item.variant}
|
||||
/>
|
||||
<span
|
||||
data-testid="cart-item-quantity"
|
||||
data-value={item.quantity}
|
||||
>
|
||||
Quantity: {item.quantity}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<LineItemPrice
|
||||
item={item}
|
||||
style="tight"
|
||||
currencyCode={cartState.currency_code}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DeleteButton
|
||||
id={item.id}
|
||||
className="mt-1"
|
||||
data-testid="cart-item-remove-button"
|
||||
>
|
||||
Remove
|
||||
</DeleteButton>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="p-4 flex flex-col gap-y-4 text-small-regular">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-ui-fg-base font-semibold">
|
||||
Subtotal{" "}
|
||||
<span className="font-normal">(excl. taxes)</span>
|
||||
</span>
|
||||
<span
|
||||
className="text-large-semi"
|
||||
data-testid="cart-subtotal"
|
||||
data-value={subtotal}
|
||||
>
|
||||
{convertToLocale({
|
||||
amount: subtotal,
|
||||
currency_code: cartState.currency_code,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<LocalizedClientLink href="/cart" passHref>
|
||||
<Button
|
||||
className="w-full"
|
||||
size="large"
|
||||
data-testid="go-to-cart-button"
|
||||
>
|
||||
Go to cart
|
||||
</Button>
|
||||
</LocalizedClientLink>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div>
|
||||
<div className="flex py-16 flex-col gap-y-4 items-center justify-center">
|
||||
<div className="bg-gray-900 text-small-regular flex items-center justify-center w-6 h-6 rounded-full text-white">
|
||||
<span>0</span>
|
||||
</div>
|
||||
<span>Your shopping bag is empty.</span>
|
||||
<div>
|
||||
<LocalizedClientLink href="/store">
|
||||
<>
|
||||
<span className="sr-only">Go to all products page</span>
|
||||
<Button onClick={close}>Explore products</Button>
|
||||
</>
|
||||
</LocalizedClientLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</PopoverPanel>
|
||||
</Transition>
|
||||
</Popover>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CartDropdown
|
||||
@@ -0,0 +1,57 @@
|
||||
"use client"
|
||||
|
||||
import { transferCart } from "@lib/data/customer"
|
||||
import { ExclamationCircleSolid } from "@medusajs/icons"
|
||||
import { StoreCart, StoreCustomer } from "@medusajs/types"
|
||||
import { Button } from "@medusajs/ui"
|
||||
import { useState } from "react"
|
||||
|
||||
function CartMismatchBanner(props: {
|
||||
customer: StoreCustomer
|
||||
cart: StoreCart
|
||||
}) {
|
||||
const { customer, cart } = props
|
||||
const [isPending, setIsPending] = useState(false)
|
||||
const [actionText, setActionText] = useState("Run transfer again")
|
||||
|
||||
if (!customer || !!cart.customer_id) {
|
||||
return
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
setIsPending(true)
|
||||
setActionText("Transferring..")
|
||||
|
||||
await transferCart()
|
||||
} catch {
|
||||
setActionText("Run transfer again")
|
||||
setIsPending(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center small:p-4 p-2 text-center bg-orange-300 small:gap-2 gap-1 text-sm mt-2 text-orange-800">
|
||||
<div className="flex flex-col small:flex-row small:gap-2 gap-1 items-center">
|
||||
<span className="flex items-center gap-1">
|
||||
<ExclamationCircleSolid className="inline" />
|
||||
Something went wrong when we tried to transfer your cart
|
||||
</span>
|
||||
|
||||
<span>·</span>
|
||||
|
||||
<Button
|
||||
variant="transparent"
|
||||
className="hover:bg-transparent active:bg-transparent focus:bg-transparent disabled:text-orange-500 text-orange-950 p-0 bg-transparent"
|
||||
size="base"
|
||||
disabled={isPending}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{actionText}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CartMismatchBanner
|
||||
@@ -0,0 +1,135 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
Listbox,
|
||||
ListboxButton,
|
||||
ListboxOption,
|
||||
ListboxOptions,
|
||||
Transition,
|
||||
} from "@headlessui/react"
|
||||
import { Fragment, useEffect, useMemo, useState } from "react"
|
||||
import ReactCountryFlag from "react-country-flag"
|
||||
|
||||
import { StateType } from "@lib/hooks/use-toggle-state"
|
||||
import { useParams, usePathname } from "next/navigation"
|
||||
import { updateRegion } from "@lib/data/cart"
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
|
||||
type CountryOption = {
|
||||
country: string
|
||||
region: string
|
||||
label: string
|
||||
}
|
||||
|
||||
type CountrySelectProps = {
|
||||
toggleState: StateType
|
||||
regions: HttpTypes.StoreRegion[]
|
||||
}
|
||||
|
||||
const CountrySelect = ({ toggleState, regions }: CountrySelectProps) => {
|
||||
const [current, setCurrent] = useState<
|
||||
| { country: string | undefined; region: string; label: string | undefined }
|
||||
| undefined
|
||||
>(undefined)
|
||||
|
||||
const { countryCode } = useParams()
|
||||
const currentPath = usePathname().split(`/${countryCode}`)[1]
|
||||
|
||||
const { state, close } = toggleState
|
||||
|
||||
const options = useMemo(() => {
|
||||
return regions
|
||||
?.map((r) => {
|
||||
return r.countries?.map((c) => ({
|
||||
country: c.iso_2,
|
||||
region: r.id,
|
||||
label: c.display_name,
|
||||
}))
|
||||
})
|
||||
.flat()
|
||||
.sort((a, b) => (a?.label ?? "").localeCompare(b?.label ?? ""))
|
||||
}, [regions])
|
||||
|
||||
useEffect(() => {
|
||||
if (countryCode) {
|
||||
const option = options?.find((o) => o?.country === countryCode)
|
||||
setCurrent(option)
|
||||
}
|
||||
}, [options, countryCode])
|
||||
|
||||
const handleChange = (option: CountryOption) => {
|
||||
updateRegion(option.country, currentPath)
|
||||
close()
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Listbox
|
||||
as="span"
|
||||
onChange={handleChange}
|
||||
defaultValue={
|
||||
countryCode
|
||||
? options?.find((o) => o?.country === countryCode)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<ListboxButton className="py-1 w-full">
|
||||
<div className="txt-compact-small flex items-start gap-x-2">
|
||||
<span>Shipping to:</span>
|
||||
{current && (
|
||||
<span className="txt-compact-small flex items-center gap-x-2">
|
||||
{/* @ts-ignore */}
|
||||
<ReactCountryFlag
|
||||
svg
|
||||
style={{
|
||||
width: "16px",
|
||||
height: "16px",
|
||||
}}
|
||||
countryCode={current.country ?? ""}
|
||||
/>
|
||||
{current.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</ListboxButton>
|
||||
<div className="flex relative w-full min-w-[320px]">
|
||||
<Transition
|
||||
show={state}
|
||||
as={Fragment}
|
||||
leave="transition ease-in duration-150"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<ListboxOptions
|
||||
className="absolute -bottom-[calc(100%-36px)] left-0 xsmall:left-auto xsmall:right-0 max-h-[442px] overflow-y-scroll z-[900] bg-white drop-shadow-md text-small-regular uppercase text-black no-scrollbar rounded-rounded w-full"
|
||||
static
|
||||
>
|
||||
{options?.map((o, index) => {
|
||||
return (
|
||||
<ListboxOption
|
||||
key={index}
|
||||
value={o}
|
||||
className="py-2 hover:bg-gray-200 px-3 cursor-pointer flex items-center gap-x-2"
|
||||
>
|
||||
{/* @ts-ignore */}
|
||||
<ReactCountryFlag
|
||||
svg
|
||||
style={{
|
||||
width: "16px",
|
||||
height: "16px",
|
||||
}}
|
||||
countryCode={o?.country ?? ""}
|
||||
/>{" "}
|
||||
{o?.label}
|
||||
</ListboxOption>
|
||||
)
|
||||
})}
|
||||
</ListboxOptions>
|
||||
</Transition>
|
||||
</div>
|
||||
</Listbox>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CountrySelect
|
||||
@@ -0,0 +1,23 @@
|
||||
// cart-button
|
||||
export { default as CartButton } from "./cart-button";
|
||||
export * from "./cart-button";
|
||||
|
||||
// cart-dropdown
|
||||
export { default as CartDropdown } from "./cart-dropdown";
|
||||
export * from "./cart-dropdown";
|
||||
|
||||
// cart-mismatch-banner
|
||||
export { default as CartMismatchBanner } from "./cart-mismatch-banner";
|
||||
export * from "./cart-mismatch-banner";
|
||||
|
||||
// country-select
|
||||
export { default as CountrySelect } from "./country-select";
|
||||
export * from "./country-select";
|
||||
|
||||
// medusa-cta
|
||||
export { default as MedusaCTA } from "./medusa-cta";
|
||||
export * from "./medusa-cta";
|
||||
|
||||
// side-menu
|
||||
export { default as SideMenu } from "./side-menu";
|
||||
export * from "./side-menu";
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Text } from "@medusajs/ui"
|
||||
|
||||
import Medusa from "../../../common/icons/medusa"
|
||||
import NextJs from "../../../common/icons/nextjs"
|
||||
|
||||
const MedusaCTA = () => {
|
||||
return (
|
||||
<Text className="flex gap-x-2 txt-compact-small-plus items-center">
|
||||
Powered by
|
||||
<a href="https://www.medusajs.com" target="_blank" rel="noreferrer">
|
||||
<Medusa fill="#9ca3af" className="fill-[#9ca3af]" />
|
||||
</a>
|
||||
&
|
||||
<a href="https://nextjs.org" target="_blank" rel="noreferrer">
|
||||
<NextJs fill="#9ca3af" />
|
||||
</a>
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
export default MedusaCTA
|
||||
@@ -0,0 +1,108 @@
|
||||
"use client"
|
||||
|
||||
import { Popover, PopoverPanel, Transition } from "@headlessui/react"
|
||||
import { ArrowRightMini, XMark } from "@medusajs/icons"
|
||||
import { Text, clx, useToggleState } from "@medusajs/ui"
|
||||
import { Fragment } from "react"
|
||||
|
||||
import LocalizedClientLink from "@modules/common/components/localized-client-link"
|
||||
import CountrySelect from "../country-select"
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
|
||||
const SideMenuItems = {
|
||||
Home: "/",
|
||||
Store: "/store",
|
||||
Account: "/account",
|
||||
Cart: "/cart",
|
||||
}
|
||||
|
||||
const SideMenu = ({ regions }: { regions: HttpTypes.StoreRegion[] | null }) => {
|
||||
const toggleState = useToggleState()
|
||||
|
||||
return (
|
||||
<div className="h-full">
|
||||
<div className="flex items-center h-full">
|
||||
<Popover className="h-full flex">
|
||||
{({ open, close }) => (
|
||||
<>
|
||||
<div className="relative flex h-full">
|
||||
<Popover.Button
|
||||
data-testid="nav-menu-button"
|
||||
className="relative h-full flex items-center transition-all ease-out duration-200 focus:outline-none hover:text-ui-fg-base"
|
||||
>
|
||||
Menu
|
||||
</Popover.Button>
|
||||
</div>
|
||||
|
||||
<Transition
|
||||
show={open}
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-150"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100 backdrop-blur-2xl"
|
||||
leave="transition ease-in duration-150"
|
||||
leaveFrom="opacity-100 backdrop-blur-2xl"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<PopoverPanel className="flex flex-col absolute w-full pr-4 sm:pr-0 sm:w-1/3 2xl:w-1/4 sm:min-w-min h-[calc(100vh-1rem)] z-30 inset-x-0 text-sm text-ui-fg-on-color m-2 backdrop-blur-2xl">
|
||||
<div
|
||||
data-testid="nav-menu-popup"
|
||||
className="flex flex-col h-full bg-[rgba(3,7,18,0.5)] rounded-rounded justify-between p-6"
|
||||
>
|
||||
<div className="flex justify-end" id="xmark">
|
||||
<button data-testid="close-menu-button" onClick={close}>
|
||||
<XMark />
|
||||
</button>
|
||||
</div>
|
||||
<ul className="flex flex-col gap-6 items-start justify-start">
|
||||
{Object.entries(SideMenuItems).map(([name, href]) => {
|
||||
return (
|
||||
<li key={name}>
|
||||
<LocalizedClientLink
|
||||
href={href}
|
||||
className="text-3xl leading-10 hover:text-ui-fg-disabled"
|
||||
onClick={close}
|
||||
data-testid={`${name.toLowerCase()}-link`}
|
||||
>
|
||||
{name}
|
||||
</LocalizedClientLink>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
<div className="flex flex-col gap-y-6">
|
||||
<div
|
||||
className="flex justify-between"
|
||||
onMouseEnter={toggleState.open}
|
||||
onMouseLeave={toggleState.close}
|
||||
>
|
||||
{regions && (
|
||||
<CountrySelect
|
||||
toggleState={toggleState}
|
||||
regions={regions}
|
||||
/>
|
||||
)}
|
||||
<ArrowRightMini
|
||||
className={clx(
|
||||
"transition-transform duration-150",
|
||||
toggleState.state ? "-rotate-90" : ""
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Text className="flex justify-between txt-compact-small">
|
||||
© {new Date().getFullYear()} Medusa Store. All rights
|
||||
reserved.
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverPanel>
|
||||
</Transition>
|
||||
</>
|
||||
)}
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SideMenu
|
||||
@@ -0,0 +1,157 @@
|
||||
import { listCategories } from "@lib/data/categories"
|
||||
import { listCollections } from "@lib/data/collections"
|
||||
import { Text, clx } from "@medusajs/ui"
|
||||
|
||||
import LocalizedClientLink from "@modules/common/components/localized-client-link"
|
||||
import MedusaCTA from "@modules/layout/components/medusa-cta"
|
||||
|
||||
export default async function Footer() {
|
||||
const { collections } = await listCollections({
|
||||
fields: "*products",
|
||||
})
|
||||
const productCategories = await listCategories()
|
||||
|
||||
return (
|
||||
<footer className="border-t border-ui-border-base w-full">
|
||||
<div className="content-container flex flex-col w-full">
|
||||
<div className="flex flex-col gap-y-6 xsmall:flex-row items-start justify-between py-40">
|
||||
<div>
|
||||
<LocalizedClientLink
|
||||
href="/"
|
||||
className="txt-compact-xlarge-plus text-ui-fg-subtle hover:text-ui-fg-base uppercase"
|
||||
>
|
||||
Medusa Store
|
||||
</LocalizedClientLink>
|
||||
</div>
|
||||
<div className="text-small-regular gap-10 md:gap-x-16 grid grid-cols-2 sm:grid-cols-3">
|
||||
{productCategories && productCategories?.length > 0 && (
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<span className="txt-small-plus txt-ui-fg-base">
|
||||
Categories
|
||||
</span>
|
||||
<ul
|
||||
className="grid grid-cols-1 gap-2"
|
||||
data-testid="footer-categories"
|
||||
>
|
||||
{productCategories?.slice(0, 6).map((c) => {
|
||||
if (c.parent_category) {
|
||||
return
|
||||
}
|
||||
|
||||
const children =
|
||||
c.category_children?.map((child) => ({
|
||||
name: child.name,
|
||||
handle: child.handle,
|
||||
id: child.id,
|
||||
})) || null
|
||||
|
||||
return (
|
||||
<li
|
||||
className="flex flex-col gap-2 text-ui-fg-subtle txt-small"
|
||||
key={c.id}
|
||||
>
|
||||
<LocalizedClientLink
|
||||
className={clx(
|
||||
"hover:text-ui-fg-base",
|
||||
children && "txt-small-plus"
|
||||
)}
|
||||
href={`/categories/${c.handle}`}
|
||||
data-testid="category-link"
|
||||
>
|
||||
{c.name}
|
||||
</LocalizedClientLink>
|
||||
{children && (
|
||||
<ul className="grid grid-cols-1 ml-3 gap-2">
|
||||
{children &&
|
||||
children.map((child) => (
|
||||
<li key={child.id}>
|
||||
<LocalizedClientLink
|
||||
className="hover:text-ui-fg-base"
|
||||
href={`/categories/${child.handle}`}
|
||||
data-testid="category-link"
|
||||
>
|
||||
{child.name}
|
||||
</LocalizedClientLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{collections && collections.length > 0 && (
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<span className="txt-small-plus txt-ui-fg-base">
|
||||
Collections
|
||||
</span>
|
||||
<ul
|
||||
className={clx(
|
||||
"grid grid-cols-1 gap-2 text-ui-fg-subtle txt-small",
|
||||
{
|
||||
"grid-cols-2": (collections?.length || 0) > 3,
|
||||
}
|
||||
)}
|
||||
>
|
||||
{collections?.slice(0, 6).map((c) => (
|
||||
<li key={c.id}>
|
||||
<LocalizedClientLink
|
||||
className="hover:text-ui-fg-base"
|
||||
href={`/collections/${c.handle}`}
|
||||
>
|
||||
{c.title}
|
||||
</LocalizedClientLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<span className="txt-small-plus txt-ui-fg-base">Medusa</span>
|
||||
<ul className="grid grid-cols-1 gap-y-2 text-ui-fg-subtle txt-small">
|
||||
<li>
|
||||
<a
|
||||
href="https://github.com/medusajs"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="hover:text-ui-fg-base"
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://docs.medusajs.com"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="hover:text-ui-fg-base"
|
||||
>
|
||||
Documentation
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://github.com/medusajs/nextjs-starter-medusa"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="hover:text-ui-fg-base"
|
||||
>
|
||||
Source code
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full mb-16 justify-between text-ui-fg-muted">
|
||||
<Text className="txt-compact-small">
|
||||
© {new Date().getFullYear()} Medusa Store. All rights reserved.
|
||||
</Text>
|
||||
<MedusaCTA />
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import React from "react"
|
||||
|
||||
import Footer from "@modules/layout/templates/footer"
|
||||
import Nav from "@modules/layout/templates/nav"
|
||||
|
||||
const Layout: React.FC<{
|
||||
children: React.ReactNode
|
||||
}> = ({ children }) => {
|
||||
return (
|
||||
<div>
|
||||
<Nav />
|
||||
<main className="relative">{children}</main>
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Layout
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Suspense } from "react"
|
||||
|
||||
import { listRegions } from "@lib/data/regions"
|
||||
import { StoreRegion } from "@medusajs/types"
|
||||
import LocalizedClientLink from "@modules/common/components/localized-client-link"
|
||||
import CartButton from "@modules/layout/components/cart-button"
|
||||
import SideMenu from "@modules/layout/components/side-menu"
|
||||
|
||||
export default async function Nav() {
|
||||
const regions = await listRegions().then((regions: StoreRegion[]) => regions)
|
||||
|
||||
return (
|
||||
<div className="sticky top-0 inset-x-0 z-50 group">
|
||||
<header className="relative h-16 mx-auto border-b duration-200 bg-white border-ui-border-base">
|
||||
<nav className="content-container txt-xsmall-plus text-ui-fg-subtle flex items-center justify-between w-full h-full text-small-regular">
|
||||
<div className="flex-1 basis-0 h-full flex items-center">
|
||||
<div className="h-full">
|
||||
<SideMenu regions={regions} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center h-full">
|
||||
<LocalizedClientLink
|
||||
href="/"
|
||||
className="txt-compact-xlarge-plus hover:text-ui-fg-base uppercase"
|
||||
data-testid="nav-store-link"
|
||||
>
|
||||
Medusa Store
|
||||
</LocalizedClientLink>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-x-6 h-full flex-1 basis-0 justify-end">
|
||||
<div className="hidden small:flex items-center gap-x-6 h-full">
|
||||
<LocalizedClientLink
|
||||
className="hover:text-ui-fg-base"
|
||||
href="/account"
|
||||
data-testid="nav-account-link"
|
||||
>
|
||||
Account
|
||||
</LocalizedClientLink>
|
||||
</div>
|
||||
<Suspense
|
||||
fallback={
|
||||
<LocalizedClientLink
|
||||
className="hover:text-ui-fg-base flex gap-2"
|
||||
href="/cart"
|
||||
data-testid="nav-cart-link"
|
||||
>
|
||||
Cart (0)
|
||||
</LocalizedClientLink>
|
||||
}
|
||||
>
|
||||
<CartButton />
|
||||
</Suspense>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Heading } from "@medusajs/ui"
|
||||
import LocalizedClientLink from "@modules/common/components/localized-client-link"
|
||||
import React from "react"
|
||||
|
||||
const Help = () => {
|
||||
return (
|
||||
<div className="mt-6">
|
||||
<Heading className="text-base-semi">Need help?</Heading>
|
||||
<div className="text-base-regular my-2">
|
||||
<ul className="gap-y-2 flex flex-col">
|
||||
<li>
|
||||
<LocalizedClientLink href="/contact">Contact</LocalizedClientLink>
|
||||
</li>
|
||||
<li>
|
||||
<LocalizedClientLink href="/contact">
|
||||
Returns & Exchanges
|
||||
</LocalizedClientLink>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Help
|
||||
@@ -0,0 +1,57 @@
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
import { Table, Text } from "@medusajs/ui"
|
||||
|
||||
import LineItemOptions from "@modules/common/components/line-item-options"
|
||||
import LineItemPrice from "@modules/common/components/line-item-price"
|
||||
import LineItemUnitPrice from "@modules/common/components/line-item-unit-price"
|
||||
import Thumbnail from "@modules/products/components/thumbnail"
|
||||
|
||||
type ItemProps = {
|
||||
item: HttpTypes.StoreCartLineItem | HttpTypes.StoreOrderLineItem
|
||||
currencyCode: string
|
||||
}
|
||||
|
||||
const Item = ({ item, currencyCode }: ItemProps) => {
|
||||
return (
|
||||
<Table.Row className="w-full" data-testid="product-row">
|
||||
<Table.Cell className="!pl-0 p-4 w-24">
|
||||
<div className="flex w-16">
|
||||
<Thumbnail thumbnail={item.thumbnail} size="square" />
|
||||
</div>
|
||||
</Table.Cell>
|
||||
|
||||
<Table.Cell className="text-left">
|
||||
<Text
|
||||
className="txt-medium-plus text-ui-fg-base"
|
||||
data-testid="product-name"
|
||||
>
|
||||
{item.product_title}
|
||||
</Text>
|
||||
<LineItemOptions variant={item.variant} data-testid="product-variant" />
|
||||
</Table.Cell>
|
||||
|
||||
<Table.Cell className="!pr-0">
|
||||
<span className="!pr-0 flex flex-col items-end h-full justify-center">
|
||||
<span className="flex gap-x-1 ">
|
||||
<Text className="text-ui-fg-muted">
|
||||
<span data-testid="product-quantity">{item.quantity}</span>x{" "}
|
||||
</Text>
|
||||
<LineItemUnitPrice
|
||||
item={item}
|
||||
style="tight"
|
||||
currencyCode={currencyCode}
|
||||
/>
|
||||
</span>
|
||||
|
||||
<LineItemPrice
|
||||
item={item}
|
||||
style="tight"
|
||||
currencyCode={currencyCode}
|
||||
/>
|
||||
</span>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
)
|
||||
}
|
||||
|
||||
export default Item
|
||||
@@ -0,0 +1,44 @@
|
||||
import repeat from "@lib/util/repeat"
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
import { Table } from "@medusajs/ui"
|
||||
|
||||
import Divider from "@modules/common/components/divider"
|
||||
import Item from "@modules/order/components/item"
|
||||
import SkeletonLineItem from "@modules/skeletons/components/skeleton-line-item"
|
||||
|
||||
type ItemsProps = {
|
||||
order: HttpTypes.StoreOrder
|
||||
}
|
||||
|
||||
const Items = ({ order }: ItemsProps) => {
|
||||
const items = order.items
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<Divider className="!mb-0" />
|
||||
<Table>
|
||||
<Table.Body data-testid="products-table">
|
||||
{items?.length
|
||||
? items
|
||||
.sort((a, b) => {
|
||||
return (a.created_at ?? "") > (b.created_at ?? "") ? -1 : 1
|
||||
})
|
||||
.map((item) => {
|
||||
return (
|
||||
<Item
|
||||
key={item.id}
|
||||
item={item}
|
||||
currencyCode={order.currency_code}
|
||||
/>
|
||||
)
|
||||
})
|
||||
: repeat(5).map((i) => {
|
||||
return <SkeletonLineItem key={i} />
|
||||
})}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Items
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client"
|
||||
|
||||
import { resetOnboardingState } from "@lib/data/onboarding"
|
||||
import { Button, Container, Text } from "@medusajs/ui"
|
||||
|
||||
const OnboardingCta = ({ orderId }: { orderId: string }) => {
|
||||
return (
|
||||
<Container className="max-w-4xl h-full bg-ui-bg-subtle w-full">
|
||||
<div className="flex flex-col gap-y-4 center p-4 md:items-center">
|
||||
<Text className="text-ui-fg-base text-xl">
|
||||
Your test order was successfully created! 🎉
|
||||
</Text>
|
||||
<Text className="text-ui-fg-subtle text-small-regular">
|
||||
You can now complete setting up your store in the admin.
|
||||
</Text>
|
||||
<Button
|
||||
className="w-fit"
|
||||
size="xlarge"
|
||||
onClick={() => resetOnboardingState(orderId)}
|
||||
>
|
||||
Complete setup in admin
|
||||
</Button>
|
||||
</div>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
export default OnboardingCta
|
||||
@@ -0,0 +1,63 @@
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
import { Text } from "@medusajs/ui"
|
||||
|
||||
type OrderDetailsProps = {
|
||||
order: HttpTypes.StoreOrder
|
||||
showStatus?: boolean
|
||||
}
|
||||
|
||||
const OrderDetails = ({ order, showStatus }: OrderDetailsProps) => {
|
||||
const formatStatus = (str: string) => {
|
||||
const formatted = str.split("_").join(" ")
|
||||
|
||||
return formatted.slice(0, 1).toUpperCase() + formatted.slice(1)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Text>
|
||||
We have sent the order confirmation details to{" "}
|
||||
<span
|
||||
className="text-ui-fg-medium-plus font-semibold"
|
||||
data-testid="order-email"
|
||||
>
|
||||
{order.email}
|
||||
</span>
|
||||
.
|
||||
</Text>
|
||||
<Text className="mt-2">
|
||||
Order date:{" "}
|
||||
<span data-testid="order-date">
|
||||
{new Date(order.created_at).toDateString()}
|
||||
</span>
|
||||
</Text>
|
||||
<Text className="mt-2 text-ui-fg-interactive">
|
||||
Order number: <span data-testid="order-id">{order.display_id}</span>
|
||||
</Text>
|
||||
|
||||
<div className="flex items-center text-compact-small gap-x-4 mt-4">
|
||||
{showStatus && (
|
||||
<>
|
||||
<Text>
|
||||
Order status:{" "}
|
||||
<span className="text-ui-fg-subtle " data-testid="order-status">
|
||||
{formatStatus(order.fulfillment_status)}
|
||||
</span>
|
||||
</Text>
|
||||
<Text>
|
||||
Payment status:{" "}
|
||||
<span
|
||||
className="text-ui-fg-subtle "
|
||||
sata-testid="order-payment-status"
|
||||
>
|
||||
{formatStatus(order.payment_status)}
|
||||
</span>
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default OrderDetails
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user