Initial commit: backend, storefront, vendor-panel added
This commit is contained in:
139
storefront/src/modules/account/components/account-info/index.tsx
Normal file
139
storefront/src/modules/account/components/account-info/index.tsx
Normal file
@@ -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
|
||||
199
storefront/src/modules/account/components/account-nav/index.tsx
Normal file
199
storefront/src/modules/account/components/account-nav/index.tsx
Normal file
@@ -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
|
||||
64
storefront/src/modules/account/components/login/index.tsx
Normal file
64
storefront/src/modules/account/components/login/index.tsx
Normal file
@@ -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
|
||||
168
storefront/src/modules/account/components/overview/index.tsx
Normal file
168
storefront/src/modules/account/components/overview/index.tsx
Normal file
@@ -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
|
||||
106
storefront/src/modules/account/components/register/index.tsx
Normal file
106
storefront/src/modules/account/components/register/index.tsx
Normal file
@@ -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>
|
||||
)
|
||||
}
|
||||
43
storefront/src/modules/account/templates/account-layout.tsx
Normal file
43
storefront/src/modules/account/templates/account-layout.tsx
Normal file
@@ -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
|
||||
27
storefront/src/modules/account/templates/login-template.tsx
Normal file
27
storefront/src/modules/account/templates/login-template.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
|
||||
import Register from "@modules/account/components/register"
|
||||
import Login from "@modules/account/components/login"
|
||||
|
||||
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
|
||||
144
storefront/src/modules/cart/components/item/index.tsx
Normal file
144
storefront/src/modules/cart/components/item/index.tsx
Normal file
@@ -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
|
||||
51
storefront/src/modules/cart/templates/index.tsx
Normal file
51
storefront/src/modules/cart/templates/index.tsx
Normal file
@@ -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
|
||||
57
storefront/src/modules/cart/templates/items.tsx
Normal file
57
storefront/src/modules/cart/templates/items.tsx
Normal file
@@ -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
|
||||
51
storefront/src/modules/cart/templates/preview.tsx
Normal file
51
storefront/src/modules/cart/templates/preview.tsx
Normal file
@@ -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
|
||||
48
storefront/src/modules/cart/templates/summary.tsx
Normal file
48
storefront/src/modules/cart/templates/summary.tsx
Normal file
@@ -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
|
||||
97
storefront/src/modules/categories/templates/index.tsx
Normal file
97
storefront/src/modules/categories/templates/index.tsx
Normal file
@@ -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
|
||||
184
storefront/src/modules/checkout/components/addresses/index.tsx
Normal file
184
storefront/src/modules/checkout/components/addresses/index.tsx
Normal file
@@ -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
|
||||
261
storefront/src/modules/checkout/components/payment/index.tsx
Normal file
261
storefront/src/modules/checkout/components/payment/index.tsx
Normal file
@@ -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
|
||||
55
storefront/src/modules/checkout/components/review/index.tsx
Normal file
55
storefront/src/modules/checkout/components/review/index.tsx
Normal file
@@ -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
|
||||
400
storefront/src/modules/checkout/components/shipping/index.tsx
Normal file
400
storefront/src/modules/checkout/components/shipping/index.tsx
Normal file
@@ -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
|
||||
47
storefront/src/modules/collections/templates/index.tsx
Normal file
47
storefront/src/modules/collections/templates/index.tsx
Normal file
@@ -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
|
||||
43
storefront/src/modules/common/components/checkbox/index.tsx
Normal file
43
storefront/src/modules/common/components/checkbox/index.tsx
Normal file
@@ -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
|
||||
76
storefront/src/modules/common/components/input/index.tsx
Normal file
76
storefront/src/modules/common/components/input/index.tsx
Normal file
@@ -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
|
||||
118
storefront/src/modules/common/components/modal/index.tsx
Normal file
118
storefront/src/modules/common/components/modal/index.tsx
Normal file
@@ -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
|
||||
27
storefront/src/modules/common/components/radio/index.tsx
Normal file
27
storefront/src/modules/common/components/radio/index.tsx
Normal file
@@ -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
|
||||
37
storefront/src/modules/common/icons/back.tsx
Normal file
37
storefront/src/modules/common/icons/back.tsx
Normal file
@@ -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
|
||||
26
storefront/src/modules/common/icons/bancontact.tsx
Normal file
26
storefront/src/modules/common/icons/bancontact.tsx
Normal file
@@ -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
|
||||
30
storefront/src/modules/common/icons/chevron-down.tsx
Normal file
30
storefront/src/modules/common/icons/chevron-down.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
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
|
||||
37
storefront/src/modules/common/icons/eye-off.tsx
Normal file
37
storefront/src/modules/common/icons/eye-off.tsx
Normal file
@@ -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
|
||||
37
storefront/src/modules/common/icons/eye.tsx
Normal file
37
storefront/src/modules/common/icons/eye.tsx
Normal file
@@ -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
|
||||
65
storefront/src/modules/common/icons/fast-delivery.tsx
Normal file
65
storefront/src/modules/common/icons/fast-delivery.tsx
Normal file
@@ -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
|
||||
26
storefront/src/modules/common/icons/ideal.tsx
Normal file
26
storefront/src/modules/common/icons/ideal.tsx
Normal file
@@ -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
|
||||
37
storefront/src/modules/common/icons/map-pin.tsx
Normal file
37
storefront/src/modules/common/icons/map-pin.tsx
Normal file
@@ -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
|
||||
27
storefront/src/modules/common/icons/medusa.tsx
Normal file
27
storefront/src/modules/common/icons/medusa.tsx
Normal file
@@ -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
|
||||
27
storefront/src/modules/common/icons/nextjs.tsx
Normal file
27
storefront/src/modules/common/icons/nextjs.tsx
Normal file
@@ -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
|
||||
44
storefront/src/modules/common/icons/package.tsx
Normal file
44
storefront/src/modules/common/icons/package.tsx
Normal file
@@ -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
|
||||
30
storefront/src/modules/common/icons/paypal.tsx
Normal file
30
storefront/src/modules/common/icons/paypal.tsx
Normal file
@@ -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
|
||||
44
storefront/src/modules/common/icons/placeholder-image.tsx
Normal file
44
storefront/src/modules/common/icons/placeholder-image.tsx
Normal file
@@ -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
|
||||
51
storefront/src/modules/common/icons/refresh.tsx
Normal file
51
storefront/src/modules/common/icons/refresh.tsx
Normal file
@@ -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
|
||||
37
storefront/src/modules/common/icons/spinner.tsx
Normal file
37
storefront/src/modules/common/icons/spinner.tsx
Normal file
@@ -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
|
||||
51
storefront/src/modules/common/icons/trash.tsx
Normal file
51
storefront/src/modules/common/icons/trash.tsx
Normal file
@@ -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
|
||||
37
storefront/src/modules/common/icons/user.tsx
Normal file
37
storefront/src/modules/common/icons/user.tsx
Normal file
@@ -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
|
||||
37
storefront/src/modules/common/icons/x.tsx
Normal file
37
storefront/src/modules/common/icons/x.tsx
Normal file
@@ -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 "@modules/home/components/featured-products/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>
|
||||
)
|
||||
}
|
||||
36
storefront/src/modules/home/components/hero/index.tsx
Normal file
36
storefront/src/modules/home/components/hero/index.tsx
Normal file
@@ -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} />
|
||||
}
|
||||
230
storefront/src/modules/layout/components/cart-dropdown/index.tsx
Normal file
230
storefront/src/modules/layout/components/cart-dropdown/index.tsx
Normal file
@@ -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,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
|
||||
108
storefront/src/modules/layout/components/side-menu/index.tsx
Normal file
108
storefront/src/modules/layout/components/side-menu/index.tsx
Normal file
@@ -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
|
||||
157
storefront/src/modules/layout/templates/footer/index.tsx
Normal file
157
storefront/src/modules/layout/templates/footer/index.tsx
Normal file
@@ -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>
|
||||
)
|
||||
}
|
||||
18
storefront/src/modules/layout/templates/index.tsx
Normal file
18
storefront/src/modules/layout/templates/index.tsx
Normal file
@@ -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
|
||||
60
storefront/src/modules/layout/templates/nav/index.tsx
Normal file
60
storefront/src/modules/layout/templates/nav/index.tsx
Normal file
@@ -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>
|
||||
)
|
||||
}
|
||||
25
storefront/src/modules/order/components/help/index.tsx
Normal file
25
storefront/src/modules/order/components/help/index.tsx
Normal file
@@ -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
|
||||
57
storefront/src/modules/order/components/item/index.tsx
Normal file
57
storefront/src/modules/order/components/item/index.tsx
Normal file
@@ -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
|
||||
44
storefront/src/modules/order/components/items/index.tsx
Normal file
44
storefront/src/modules/order/components/items/index.tsx
Normal file
@@ -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
|
||||
@@ -0,0 +1,60 @@
|
||||
import { convertToLocale } from "@lib/util/money"
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
|
||||
type OrderSummaryProps = {
|
||||
order: HttpTypes.StoreOrder
|
||||
}
|
||||
|
||||
const OrderSummary = ({ order }: OrderSummaryProps) => {
|
||||
const getAmount = (amount?: number | null) => {
|
||||
if (!amount) {
|
||||
return
|
||||
}
|
||||
|
||||
return convertToLocale({
|
||||
amount,
|
||||
currency_code: order.currency_code,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-base-semi">Order Summary</h2>
|
||||
<div className="text-small-regular text-ui-fg-base my-2">
|
||||
<div className="flex items-center justify-between text-base-regular text-ui-fg-base mb-2">
|
||||
<span>Subtotal</span>
|
||||
<span>{getAmount(order.subtotal)}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-y-1">
|
||||
{order.discount_total > 0 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span>Discount</span>
|
||||
<span>- {getAmount(order.discount_total)}</span>
|
||||
</div>
|
||||
)}
|
||||
{order.gift_card_total > 0 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span>Discount</span>
|
||||
<span>- {getAmount(order.gift_card_total)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
<span>Shipping</span>
|
||||
<span>{getAmount(order.shipping_total)}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span>Taxes</span>
|
||||
<span>{getAmount(order.tax_total)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-px w-full border-b border-gray-200 border-dashed my-4" />
|
||||
<div className="flex items-center justify-between text-base-regular text-ui-fg-base mb-2">
|
||||
<span>Total</span>
|
||||
<span>{getAmount(order.total)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default OrderSummary
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Container, Heading, Text } from "@medusajs/ui"
|
||||
|
||||
import { isStripe, paymentInfoMap } from "@lib/constants"
|
||||
import Divider from "@modules/common/components/divider"
|
||||
import { convertToLocale } from "@lib/util/money"
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
|
||||
type PaymentDetailsProps = {
|
||||
order: HttpTypes.StoreOrder
|
||||
}
|
||||
|
||||
const PaymentDetails = ({ order }: PaymentDetailsProps) => {
|
||||
const payment = order.payment_collections?.[0].payments?.[0]
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Heading level="h2" className="flex flex-row text-3xl-regular my-6">
|
||||
Payment
|
||||
</Heading>
|
||||
<div>
|
||||
{payment && (
|
||||
<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"
|
||||
>
|
||||
{paymentInfoMap[payment.provider_id].title}
|
||||
</Text>
|
||||
</div>
|
||||
<div className="flex flex-col w-2/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">
|
||||
<Container className="flex items-center h-7 w-fit p-2 bg-ui-button-neutral-hover">
|
||||
{paymentInfoMap[payment.provider_id].icon}
|
||||
</Container>
|
||||
<Text data-testid="payment-amount">
|
||||
{isStripe(payment.provider_id) && payment.data?.card_last4
|
||||
? `**** **** **** ${payment.data.card_last4}`
|
||||
: `${convertToLocale({
|
||||
amount: payment.amount,
|
||||
currency_code: order.currency_code,
|
||||
})} paid at ${new Date(
|
||||
payment.created_at ?? ""
|
||||
).toLocaleString()}`}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Divider className="mt-8" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PaymentDetails
|
||||
@@ -0,0 +1,75 @@
|
||||
import { convertToLocale } from "@lib/util/money"
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
import { Heading, Text } from "@medusajs/ui"
|
||||
|
||||
import Divider from "@modules/common/components/divider"
|
||||
|
||||
type ShippingDetailsProps = {
|
||||
order: HttpTypes.StoreOrder
|
||||
}
|
||||
|
||||
const ShippingDetails = ({ order }: ShippingDetailsProps) => {
|
||||
return (
|
||||
<div>
|
||||
<Heading level="h2" className="flex flex-row text-3xl-regular my-6">
|
||||
Delivery
|
||||
</Heading>
|
||||
<div className="flex items-start gap-x-8">
|
||||
<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">
|
||||
{order.shipping_address?.first_name}{" "}
|
||||
{order.shipping_address?.last_name}
|
||||
</Text>
|
||||
<Text className="txt-medium text-ui-fg-subtle">
|
||||
{order.shipping_address?.address_1}{" "}
|
||||
{order.shipping_address?.address_2}
|
||||
</Text>
|
||||
<Text className="txt-medium text-ui-fg-subtle">
|
||||
{order.shipping_address?.postal_code},{" "}
|
||||
{order.shipping_address?.city}
|
||||
</Text>
|
||||
<Text className="txt-medium text-ui-fg-subtle">
|
||||
{order.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">
|
||||
{order.shipping_address?.phone}
|
||||
</Text>
|
||||
<Text className="txt-medium text-ui-fg-subtle">{order.email}</Text>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex flex-col w-1/3"
|
||||
data-testid="shipping-method-summary"
|
||||
>
|
||||
<Text className="txt-medium-plus text-ui-fg-base mb-1">Method</Text>
|
||||
<Text className="txt-medium text-ui-fg-subtle">
|
||||
{(order as any).shipping_methods[0]?.name} (
|
||||
{convertToLocale({
|
||||
amount: order.shipping_methods?.[0].total ?? 0,
|
||||
currency_code: order.currency_code,
|
||||
})
|
||||
.replace(/,/g, "")
|
||||
.replace(/\./g, ",")}
|
||||
)
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
<Divider className="mt-8" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ShippingDetails
|
||||
@@ -0,0 +1,81 @@
|
||||
"use client"
|
||||
|
||||
import { acceptTransferRequest, declineTransferRequest } from "@lib/data/orders"
|
||||
import { Button, Text } from "@medusajs/ui"
|
||||
import { useState } from "react"
|
||||
|
||||
type TransferStatus = "pending" | "success" | "error"
|
||||
|
||||
const TransferActions = ({ id, token }: { id: string; token: string }) => {
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||
const [status, setStatus] = useState<{
|
||||
accept: TransferStatus | null
|
||||
decline: TransferStatus | null
|
||||
} | null>({
|
||||
accept: null,
|
||||
decline: null,
|
||||
})
|
||||
|
||||
const acceptTransfer = async () => {
|
||||
setStatus({ accept: "pending", decline: null })
|
||||
setErrorMessage(null)
|
||||
|
||||
const { success, error } = await acceptTransferRequest(id, token)
|
||||
|
||||
if (error) setErrorMessage(error)
|
||||
setStatus({ accept: success ? "success" : "error", decline: null })
|
||||
}
|
||||
|
||||
const declineTransfer = async () => {
|
||||
setStatus({ accept: null, decline: "pending" })
|
||||
setErrorMessage(null)
|
||||
|
||||
const { success, error } = await declineTransferRequest(id, token)
|
||||
|
||||
if (error) setErrorMessage(error)
|
||||
setStatus({ accept: null, decline: success ? "success" : "error" })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-y-4">
|
||||
{status?.accept === "success" && (
|
||||
<Text className="text-emerald-500">
|
||||
Order transferred successfully!
|
||||
</Text>
|
||||
)}
|
||||
{status?.decline === "success" && (
|
||||
<Text className="text-emerald-500">
|
||||
Order transfer declined successfully!
|
||||
</Text>
|
||||
)}
|
||||
{status?.accept !== "success" && status?.decline !== "success" && (
|
||||
<div className="flex gap-x-4">
|
||||
<Button
|
||||
size="large"
|
||||
onClick={acceptTransfer}
|
||||
isLoading={status?.accept === "pending"}
|
||||
disabled={
|
||||
status?.accept === "pending" || status?.decline === "pending"
|
||||
}
|
||||
>
|
||||
Accept transfer
|
||||
</Button>
|
||||
<Button
|
||||
size="large"
|
||||
variant="secondary"
|
||||
onClick={declineTransfer}
|
||||
isLoading={status?.decline === "pending"}
|
||||
disabled={
|
||||
status?.accept === "pending" || status?.decline === "pending"
|
||||
}
|
||||
>
|
||||
Decline transfer
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{errorMessage && <Text className="text-red-500">{errorMessage}</Text>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TransferActions
|
||||
275
storefront/src/modules/order/components/transfer-image/index.tsx
Normal file
275
storefront/src/modules/order/components/transfer-image/index.tsx
Normal file
@@ -0,0 +1,275 @@
|
||||
import { SVGProps } from "react"
|
||||
|
||||
const TransferImage = (props: SVGProps<SVGSVGElement>) => (
|
||||
<svg
|
||||
width="280"
|
||||
height="181"
|
||||
viewBox="0 0 280 181"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<rect
|
||||
x="0.00428286"
|
||||
y="-0.742904"
|
||||
width="33.5"
|
||||
height="65.5"
|
||||
rx="6.75"
|
||||
transform="matrix(0.865865 0.500278 -0.871576 0.490261 189.756 88.938)"
|
||||
fill="#D4D4D8"
|
||||
stroke="#52525B"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<rect
|
||||
x="0.00428286"
|
||||
y="-0.742904"
|
||||
width="33.5"
|
||||
height="65.5"
|
||||
rx="6.75"
|
||||
transform="matrix(0.865865 0.500278 -0.871576 0.490261 189.756 85.938)"
|
||||
fill="white"
|
||||
stroke="#52525B"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M180.579 107.642L179.126 108.459"
|
||||
stroke="#52525B"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
opacity="0.88"
|
||||
d="M182.305 110.046L180.257 110.034"
|
||||
stroke="#52525B"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
opacity="0.75"
|
||||
d="M180.551 112.429L179.108 111.596"
|
||||
stroke="#52525B"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
opacity="0.63"
|
||||
d="M176.347 113.397L176.354 112.23"
|
||||
stroke="#52525B"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
opacity="0.5"
|
||||
d="M172.154 112.381L173.606 111.564"
|
||||
stroke="#52525B"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
opacity="0.38"
|
||||
d="M170.428 109.977L172.476 109.989"
|
||||
stroke="#52525B"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
opacity="0.25"
|
||||
d="M172.181 107.594L173.624 108.428"
|
||||
stroke="#52525B"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
opacity="0.13"
|
||||
d="M176.386 106.626L176.379 107.793"
|
||||
stroke="#52525B"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<rect
|
||||
width="12"
|
||||
height="3"
|
||||
rx="1.5"
|
||||
transform="matrix(0.865865 0.500278 -0.871576 0.490261 196.447 92.7925)"
|
||||
fill="#D4D4D8"
|
||||
/>
|
||||
<rect
|
||||
x="0.00428286"
|
||||
y="-0.742904"
|
||||
width="33.5"
|
||||
height="65.5"
|
||||
rx="6.75"
|
||||
transform="matrix(0.865865 0.500278 -0.871576 0.490261 117.023 46.9146)"
|
||||
fill="#D4D4D8"
|
||||
stroke="#52525B"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<rect
|
||||
x="0.00428286"
|
||||
y="-0.742904"
|
||||
width="33.5"
|
||||
height="65.5"
|
||||
rx="6.75"
|
||||
transform="matrix(0.865865 0.500278 -0.871576 0.490261 117.023 43.9146)"
|
||||
fill="white"
|
||||
stroke="#52525B"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<rect
|
||||
width="12"
|
||||
height="3"
|
||||
rx="1.5"
|
||||
transform="matrix(0.865865 0.500278 -0.871576 0.490261 123.714 50.769)"
|
||||
fill="#D4D4D8"
|
||||
/>
|
||||
<rect
|
||||
width="17"
|
||||
height="3"
|
||||
rx="1.5"
|
||||
transform="matrix(0.865865 0.500278 -0.871576 0.490261 97.5555 67.458)"
|
||||
fill="#D4D4D8"
|
||||
/>
|
||||
<rect
|
||||
width="12"
|
||||
height="3"
|
||||
rx="1.5"
|
||||
transform="matrix(0.865865 0.500278 -0.871576 0.490261 93.1976 69.9092)"
|
||||
fill="#D4D4D8"
|
||||
/>
|
||||
<path
|
||||
d="M92.3603 64.4564C90.9277 63.6287 88.59 63.6152 87.148 64.4264C85.7059 65.2375 85.6983 66.5703 87.1308 67.398C88.5634 68.2257 90.9011 68.2392 92.3432 67.428C93.7852 66.6169 93.7929 65.2841 92.3603 64.4564ZM88.4382 66.6626C87.7221 66.2488 87.726 65.5822 88.4468 65.1768C89.1676 64.7713 90.3369 64.7781 91.0529 65.1918C91.769 65.6055 91.7652 66.2722 91.0444 66.6776C90.3236 67.083 89.1543 67.0763 88.4382 66.6626Z"
|
||||
fill="#A1A1AA"
|
||||
/>
|
||||
<rect
|
||||
width="17"
|
||||
height="3"
|
||||
rx="1.5"
|
||||
transform="matrix(0.865865 0.500278 -0.871576 0.490261 109.758 60.5942)"
|
||||
fill="#D4D4D8"
|
||||
/>
|
||||
<rect
|
||||
width="12"
|
||||
height="3"
|
||||
rx="1.5"
|
||||
transform="matrix(0.865865 0.500278 -0.871576 0.490261 105.4 63.0454)"
|
||||
fill="#D4D4D8"
|
||||
/>
|
||||
<path
|
||||
d="M104.562 57.5926C103.13 56.7649 100.792 56.7514 99.35 57.5626C97.908 58.3737 97.9003 59.7065 99.3329 60.5342C100.765 61.3619 103.103 61.3754 104.545 60.5642C105.987 59.7531 105.995 58.4203 104.562 57.5926ZM103.858 59.3971L100.815 59.6265C100.683 59.6366 100.55 59.6133 100.449 59.5629C100.44 59.5584 100.432 59.5544 100.424 59.5499C100.339 59.5004 100.29 59.4335 100.29 59.3637L100.293 58.62C100.294 58.4751 100.501 58.3585 100.756 58.3599C101.01 58.3614 101.216 58.48 101.216 58.6256L101.214 59.0669L103.732 58.8768C103.983 58.8577 104.217 58.9584 104.251 59.1029C104.286 59.2468 104.11 59.3787 103.858 59.3976L103.858 59.3971Z"
|
||||
fill="#A1A1AA"
|
||||
/>
|
||||
<g clip-path="url(#clip0_20786_38285)">
|
||||
<path
|
||||
d="M133.106 82.3025L140.49 82.345L140.514 78.1353"
|
||||
stroke="#A1A1AA"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</g>
|
||||
<g clip-path="url(#clip1_20786_38285)">
|
||||
<path
|
||||
d="M143.496 88.3059L150.88 88.3485L150.905 84.1387"
|
||||
stroke="#A1A1AA"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</g>
|
||||
<g clip-path="url(#clip2_20786_38285)">
|
||||
<path
|
||||
d="M153.887 94.3093L161.271 94.3519L161.295 90.1421"
|
||||
stroke="#A1A1AA"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</g>
|
||||
<g clip-path="url(#clip3_20786_38285)">
|
||||
<path
|
||||
d="M126.113 89.6911L118.729 89.6486L118.705 93.8583"
|
||||
stroke="#A1A1AA"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</g>
|
||||
<g clip-path="url(#clip4_20786_38285)">
|
||||
<path
|
||||
d="M136.504 95.6945L129.12 95.652L129.095 99.8618"
|
||||
stroke="#A1A1AA"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</g>
|
||||
<g clip-path="url(#clip5_20786_38285)">
|
||||
<path
|
||||
d="M146.894 101.698L139.51 101.655L139.486 105.865"
|
||||
stroke="#A1A1AA"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_20786_38285">
|
||||
<rect
|
||||
width="12"
|
||||
height="12"
|
||||
fill="white"
|
||||
transform="matrix(0.865865 0.500278 -0.871576 0.490261 138.36 75.1509)"
|
||||
/>
|
||||
</clipPath>
|
||||
<clipPath id="clip1_20786_38285">
|
||||
<rect
|
||||
width="12"
|
||||
height="12"
|
||||
fill="white"
|
||||
transform="matrix(0.865865 0.500278 -0.871576 0.490261 148.75 81.1543)"
|
||||
/>
|
||||
</clipPath>
|
||||
<clipPath id="clip2_20786_38285">
|
||||
<rect
|
||||
width="12"
|
||||
height="12"
|
||||
fill="white"
|
||||
transform="matrix(0.865865 0.500278 -0.871576 0.490261 159.14 87.1577)"
|
||||
/>
|
||||
</clipPath>
|
||||
<clipPath id="clip3_20786_38285">
|
||||
<rect
|
||||
width="12"
|
||||
height="12"
|
||||
fill="white"
|
||||
transform="matrix(0.865865 0.500278 -0.871576 0.490261 120.928 84.9561)"
|
||||
/>
|
||||
</clipPath>
|
||||
<clipPath id="clip4_20786_38285">
|
||||
<rect
|
||||
width="12"
|
||||
height="12"
|
||||
fill="white"
|
||||
transform="matrix(0.865865 0.500278 -0.871576 0.490261 131.318 90.9595)"
|
||||
/>
|
||||
</clipPath>
|
||||
<clipPath id="clip5_20786_38285">
|
||||
<rect
|
||||
width="12"
|
||||
height="12"
|
||||
fill="white"
|
||||
transform="matrix(0.865865 0.500278 -0.871576 0.490261 141.709 96.9629)"
|
||||
/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
)
|
||||
|
||||
export default TransferImage
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user