Initial commit: backend, storefront, vendor-panel added

This commit is contained in:
2025-08-01 11:05:32 +08:00
commit 08174125d2
2958 changed files with 310810 additions and 0 deletions

View 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

View 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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View 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

View File

@@ -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

View File

@@ -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&apos;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

View 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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View 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&apos;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

View File

@@ -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&apos;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>
)
}

View 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

View 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

View File

@@ -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

View File

@@ -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&apos;t have anything in your cart. Let&apos;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

View 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

View File

@@ -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

View 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

View 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

View 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

View 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

View 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>
)
}

View File

@@ -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

View 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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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>
)
}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View 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

View 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&apos;s Privacy Policy.
</Text>
</div>
</div>
<PaymentButton cart={cart} data-testid="submit-order-button" />
</>
)}
</div>
)
}
export default Review

View File

@@ -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

View 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

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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

View 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>
)
}

View File

@@ -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

View 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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View 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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View 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

View File

@@ -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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View File

@@ -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>
))
}

View File

@@ -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>
)
}

View 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

View File

@@ -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} />
}

View 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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View 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

View 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>
)
}

View 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

View 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>
)
}

View 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

View 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

View 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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View 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